diff --git a/pom.xml b/pom.xml
index f8c5c3d7..bcf55d8c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -196,6 +196,10 @@
ical4j
3.2.12
+
+ org.springframework.data
+ spring-data-envers
+
org.mockito
mockito-junit-jupiter
diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/Ingredient.java b/src/main/java/com/brennaswitzer/cookbook/domain/Ingredient.java
index ab2f3d45..ed5c5b6f 100644
--- a/src/main/java/com/brennaswitzer/cookbook/domain/Ingredient.java
+++ b/src/main/java/com/brennaswitzer/cookbook/domain/Ingredient.java
@@ -13,6 +13,7 @@
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.BatchSize;
+import org.hibernate.envers.Audited;
import java.util.Comparator;
import java.util.HashSet;
@@ -42,6 +43,7 @@ public abstract class Ingredient extends BaseEntity implements Named, Labeled {
@Getter
@Setter
+ @Audited
private String name;
@ElementCollection
diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/PantryItem.java b/src/main/java/com/brennaswitzer/cookbook/domain/PantryItem.java
index fe2156fa..54330d1f 100644
--- a/src/main/java/com/brennaswitzer/cookbook/domain/PantryItem.java
+++ b/src/main/java/com/brennaswitzer/cookbook/domain/PantryItem.java
@@ -12,6 +12,8 @@
import lombok.Setter;
import org.hibernate.annotations.BatchSize;
import org.hibernate.collection.spi.PersistentSet;
+import org.hibernate.envers.Audited;
+import org.hibernate.envers.NotAudited;
import org.springframework.util.Assert;
import java.util.Collections;
@@ -24,6 +26,7 @@
@Entity
@DiscriminatorValue("PantryItem")
@JsonTypeName("PantryItem")
+@Audited
public class PantryItem extends Ingredient {
public static final Comparator BY_STORE_ORDER = (a, b) -> {
@@ -37,12 +40,14 @@ public class PantryItem extends Ingredient {
};
// todo: make this user specific
+ @NotAudited
private int storeOrder = 0;
@ElementCollection
@BatchSize(size = 50)
@Column(name = "synonym")
@Setter(AccessLevel.PRIVATE)
+ @NotAudited
private Set synonyms;
/**
diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/PlanBucket.java b/src/main/java/com/brennaswitzer/cookbook/domain/PlanBucket.java
index 7056d741..6188ce54 100644
--- a/src/main/java/com/brennaswitzer/cookbook/domain/PlanBucket.java
+++ b/src/main/java/com/brennaswitzer/cookbook/domain/PlanBucket.java
@@ -11,6 +11,7 @@
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
+import org.hibernate.envers.Audited;
import java.time.LocalDate;
import java.util.Collection;
@@ -19,29 +20,23 @@
@Table(name = "plan_bucket", uniqueConstraints = {
@UniqueConstraint(columnNames = { "plan_id", "name" })
})
+@Getter
+@Setter
public class PlanBucket extends BaseEntity {
@NotNull
- @Getter
- @Setter
@ManyToOne(fetch = FetchType.LAZY)
private Plan plan;
- @Getter
- @Setter
+ @Audited
private String name;
- @Getter
- @Setter
+ @Audited
private LocalDate date;
- @Getter
- @Setter
@OneToMany(mappedBy = "bucket")
private Collection items;
- @Getter
- @Setter
@Column(name = "mod_count")
private int modCount;
diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java b/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java
index 02ea9b94..4355d83b 100644
--- a/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java
+++ b/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java
@@ -17,6 +17,7 @@
import lombok.val;
import org.hibernate.Hibernate;
import org.hibernate.annotations.BatchSize;
+import org.hibernate.envers.Audited;
import java.util.ArrayList;
import java.util.Collection;
@@ -73,6 +74,7 @@ public class PlanItem extends BaseEntity implements Named, MutableItem {
@NotNull
@Getter
@Setter
+ @Audited
private String name;
@Getter
@@ -82,6 +84,7 @@ public class PlanItem extends BaseEntity implements Named, MutableItem {
@Column(name = "status_id")
@Getter
@Setter
+ @Audited
private PlanItemStatus status = PlanItemStatus.NEEDED;
@Embedded
@@ -125,11 +128,13 @@ public class PlanItem extends BaseEntity implements Named, MutableItem {
fetch = FetchType.LAZY)
@Getter
@Setter
+ @Audited
private Ingredient ingredient;
@Getter
@Setter
@ManyToOne(fetch = FetchType.LAZY)
+ @Audited
private PlanBucket bucket;
@Getter
diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/Recipe.java b/src/main/java/com/brennaswitzer/cookbook/domain/Recipe.java
index ac04c9ef..3ab254b7 100644
--- a/src/main/java/com/brennaswitzer/cookbook/domain/Recipe.java
+++ b/src/main/java/com/brennaswitzer/cookbook/domain/Recipe.java
@@ -17,41 +17,45 @@
import jakarta.persistence.PreUpdate;
import lombok.Getter;
import lombok.Setter;
+import org.hibernate.envers.Audited;
+import org.hibernate.envers.NotAudited;
import java.util.Collection;
import java.util.LinkedList;
import java.util.List;
+@Setter
@Entity
@DiscriminatorValue("Recipe")
@JsonTypeName("Recipe")
+@Audited
public class Recipe extends Ingredient implements AggregateIngredient, Owned {
// this will gracefully store the same way as an @Embedded Acl will
@ManyToOne(fetch = FetchType.LAZY)
+ @NotAudited
private User owner;
// these will gracefully emulate AccessControlled's owner property
@JsonIgnore // but hide it from the client :)
public User getOwner() { return owner; }
- public void setOwner(User owner) { this.owner = owner; }
// end access control emulation
@Getter
- @Setter
+ @NotAudited
private String externalUrl;
@Getter
- @Setter
+ @NotAudited
private String directions;
@Getter
- @Setter
+ @NotAudited
private Integer yield;
@Getter
- @Setter
+ @NotAudited
private Integer calories;
@Embedded
@@ -62,6 +66,7 @@ public class Recipe extends Ingredient implements AggregateIngredient, Owned {
@AttributeOverride(name = "focusTop", column = @Column(name = "photo_focus_top")),
@AttributeOverride(name = "focusLeft", column = @Column(name = "photo_focus_left"))
})
+ @NotAudited
private Photo photo;
public Photo getPhoto() {
return getPhoto(false);
@@ -72,9 +77,7 @@ public Photo getPhoto(boolean create) {
}
return this.photo;
}
- public void setPhoto(Photo photo) {
- this.photo = photo;
- }
+
public void setPhoto(S3File file) {
getPhoto(true).setFile(file);
}
@@ -91,17 +94,18 @@ public boolean hasPhoto() {
* Time is stored in milliseconds
*/
@Getter
- @Setter
+ @NotAudited
private Integer totalTime;
- @Setter
@Getter
@ElementCollection
@OrderBy("_idx, raw")
+ @NotAudited
private List ingredients;
@Getter
@OneToMany(mappedBy = "recipe")
+ @NotAudited
private Collection planHistory;
public Recipe() {
diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/envers/BfsRevisionEntity.java b/src/main/java/com/brennaswitzer/cookbook/domain/envers/BfsRevisionEntity.java
new file mode 100644
index 00000000..46045663
--- /dev/null
+++ b/src/main/java/com/brennaswitzer/cookbook/domain/envers/BfsRevisionEntity.java
@@ -0,0 +1,44 @@
+package com.brennaswitzer.cookbook.domain.envers;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.SequenceGenerator;
+import jakarta.persistence.Table;
+import lombok.Data;
+import org.hibernate.envers.RevisionEntity;
+import org.hibernate.envers.RevisionNumber;
+import org.hibernate.envers.RevisionTimestamp;
+
+import java.time.Instant;
+
+@Entity
+@Table(name = "AUD__REVINFO") // two underscores so it sorts first
+@RevisionEntity(BfsRevisionListener.class)
+@SequenceGenerator(
+ name = "aud_seq",
+ sequenceName = "aud_seq"
+)
+@Data
+public class BfsRevisionEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "aud_seq")
+ @Column(name = "REV")
+ @RevisionNumber
+ private long id;
+
+ @Column(name = "REV_TSTMP")
+ @RevisionTimestamp
+ private long timestamp;
+
+ public Instant getInstant() {
+ return Instant.ofEpochMilli(timestamp);
+ }
+
+ @Column(name = "REV_USERNAME")
+ private String username;
+
+}
diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/envers/BfsRevisionListener.java b/src/main/java/com/brennaswitzer/cookbook/domain/envers/BfsRevisionListener.java
new file mode 100644
index 00000000..09c830f7
--- /dev/null
+++ b/src/main/java/com/brennaswitzer/cookbook/domain/envers/BfsRevisionListener.java
@@ -0,0 +1,39 @@
+package com.brennaswitzer.cookbook.domain.envers;
+
+import com.brennaswitzer.cookbook.util.UserPrincipalAccess;
+import org.hibernate.envers.RevisionListener;
+import org.springframework.context.ApplicationContext;
+
+/**
+ * Envers uses this to populate the custom {@link BfsRevisionEntity}. Due to
+ * partial Spring magic, this type is instantiated by the bean factory, but
+ * isn't quite a "real" bean with full wiring support. So just capture the
+ * {@link ApplicationContext} during construction, and then obtain the
+ * {@link UserPrincipalAccess} from it upon first need. This also happens to
+ * avoid a cyclic dependency, as this listener is needed for Hibernate to start,
+ * but Hibernate is needed by our implementation.
+ */
+public class BfsRevisionListener implements RevisionListener {
+
+ private final ApplicationContext appCtx;
+ private UserPrincipalAccess principalAccess;
+
+ public BfsRevisionListener(ApplicationContext appCtx) {
+ this.appCtx = appCtx;
+ }
+
+ private UserPrincipalAccess getPrincipalAccess() {
+ if (principalAccess == null) {
+ principalAccess = appCtx.getBean(UserPrincipalAccess.class);
+ }
+ return principalAccess;
+ }
+
+ @Override
+ public void newRevision(Object revisionEntity) {
+ if (revisionEntity instanceof BfsRevisionEntity rev) {
+ rev.setUsername(getPrincipalAccess().getUsername());
+ }
+ }
+
+}
diff --git a/src/main/java/com/brennaswitzer/cookbook/repositories/IngredientRepository.java b/src/main/java/com/brennaswitzer/cookbook/repositories/IngredientRepository.java
index 77192c88..720179dd 100644
--- a/src/main/java/com/brennaswitzer/cookbook/repositories/IngredientRepository.java
+++ b/src/main/java/com/brennaswitzer/cookbook/repositories/IngredientRepository.java
@@ -1,7 +1,8 @@
package com.brennaswitzer.cookbook.repositories;
import com.brennaswitzer.cookbook.domain.Ingredient;
+import org.springframework.data.repository.history.RevisionRepository;
-public interface IngredientRepository extends BaseEntityRepository {
+public interface IngredientRepository extends BaseEntityRepository, RevisionRepository {
}
diff --git a/src/main/java/com/brennaswitzer/cookbook/repositories/PlanBucketRepository.java b/src/main/java/com/brennaswitzer/cookbook/repositories/PlanBucketRepository.java
index 46495242..9f44f4d4 100644
--- a/src/main/java/com/brennaswitzer/cookbook/repositories/PlanBucketRepository.java
+++ b/src/main/java/com/brennaswitzer/cookbook/repositories/PlanBucketRepository.java
@@ -1,10 +1,11 @@
package com.brennaswitzer.cookbook.repositories;
import com.brennaswitzer.cookbook.domain.PlanBucket;
+import org.springframework.data.repository.history.RevisionRepository;
import java.util.stream.Stream;
-public interface PlanBucketRepository extends BaseEntityRepository {
+public interface PlanBucketRepository extends BaseEntityRepository, RevisionRepository {
Stream streamAllByPlanIdAndDateIsNotNull(Long id);
diff --git a/src/main/java/com/brennaswitzer/cookbook/repositories/PlanItemRepository.java b/src/main/java/com/brennaswitzer/cookbook/repositories/PlanItemRepository.java
index 6d4d1320..6d39e43c 100644
--- a/src/main/java/com/brennaswitzer/cookbook/repositories/PlanItemRepository.java
+++ b/src/main/java/com/brennaswitzer/cookbook/repositories/PlanItemRepository.java
@@ -5,10 +5,11 @@
import com.brennaswitzer.cookbook.domain.PlanItemStatus;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.history.RevisionRepository;
import java.time.Instant;
-public interface PlanItemRepository extends BaseEntityRepository {
+public interface PlanItemRepository extends BaseEntityRepository, RevisionRepository {
int countByStatusNot(PlanItemStatus status);
diff --git a/src/main/java/com/brennaswitzer/cookbook/util/UserPrincipalAccess.java b/src/main/java/com/brennaswitzer/cookbook/util/UserPrincipalAccess.java
index 0e893dcd..de7961b3 100644
--- a/src/main/java/com/brennaswitzer/cookbook/util/UserPrincipalAccess.java
+++ b/src/main/java/com/brennaswitzer/cookbook/util/UserPrincipalAccess.java
@@ -9,6 +9,10 @@ default Long getId() {
return getUserPrincipal().getId();
}
+ default String getUsername() {
+ return getUserPrincipal().getUsername();
+ }
+
UserPrincipal getUserPrincipal();
default User getUser() {
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index ed7b5cd2..7956b51c 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -29,6 +29,15 @@ spring:
jdbc:
lob:
non_contextual_creation: true
+ org:
+ hibernate:
+ envers:
+ audit_table_prefix: "AUD_"
+ audit_table_suffix: ""
+ audit_strategy: org.hibernate.envers.strategy.ValidityAuditStrategy
+ audit_strategy_validity_store_revend_timestamp: true
+ audit_strategy_validity_revend_timestamp_numeric: true
+ cascade_delete_revision: true
open-in-view: true # the default; just suppress the warning
security:
oauth2:
diff --git a/src/main/resources/db/changelog/gobrennas-2024.sql b/src/main/resources/db/changelog/gobrennas-2024.sql
index 03b9ba90..9ba09741 100644
--- a/src/main/resources/db/changelog/gobrennas-2024.sql
+++ b/src/main/resources/db/changelog/gobrennas-2024.sql
@@ -400,3 +400,122 @@ create table planned_recipe_history
create index idx_planned_recipe_history_recipe
on planned_recipe_history (recipe_id);
+
+--changeset barneyb:envers-auditing-setup
+create sequence aud_seq start with 1 increment by 50;
+create table aud__revinfo
+(
+ rev bigint not null default nextval('aud_seq'),
+ rev_tstmp bigint not null default extract(epoch from now()) * 1000,
+ rev_username varchar,
+ primary key (rev)
+);
+
+--changeset barneyb:add-auditing-for-planned-recipes-and-initialize-history
+create table aud_ingredient
+(
+ id bigint not null,
+ rev bigint not null,
+ revtype smallint,
+ revend bigint,
+ revend_tstmp bigint,
+ -- data columns
+ dtype varchar not null,
+ name varchar,
+ constraint pk_aud_ingredient primary key (id, rev),
+ constraint fk_aud_ingredient_rev
+ foreign key (rev)
+ references aud__revinfo
+ on delete cascade,
+ constraint fk_aud_ingredient_revend
+ foreign key (revend)
+ references aud__revinfo
+ on delete cascade
+);
+
+insert into aud__revinfo (rev_username)
+values ('system');
+
+insert into aud_ingredient
+select id
+ , (select max(rev) from aud__revinfo)
+ , 0 -- add
+ , null
+ , null
+ , dtype
+ , name
+from ingredient;
+
+create table aud_plan_bucket
+(
+ id bigint not null,
+ rev bigint not null,
+ revtype smallint,
+ revend bigint,
+ revend_tstmp bigint,
+ -- data columns
+ name varchar,
+ date date,
+ constraint pk_aud_plan_bucket primary key (id, rev),
+ constraint fk_aud_plan_bucket_rev
+ foreign key (rev)
+ references aud__revinfo
+ on delete cascade,
+ constraint fk_aud_plan_bucket_revend
+ foreign key (revend)
+ references aud__revinfo
+ on delete cascade
+);
+
+insert into aud__revinfo (rev_username)
+values ('system');
+
+insert into aud_plan_bucket
+select id
+ , (select max(rev) from aud__revinfo)
+ , 0 -- add
+ , null
+ , null
+ , name
+ , date
+from plan_bucket;
+
+create table aud_plan_item
+(
+ id bigint not null,
+ rev bigint not null,
+ revtype smallint,
+ revend bigint,
+ revend_tstmp bigint,
+ -- data columns
+ dtype varchar not null,
+ name varchar,
+ status_id bigint,
+ ingredient_id bigint,
+ bucket_id bigint,
+ constraint pk_aud_plan_item primary key (id, rev),
+ constraint fk_aud_plan_item_rev
+ foreign key (rev)
+ references aud__revinfo
+ on delete cascade,
+ constraint fk_aud_plan_item_revend
+ foreign key (revend)
+ references aud__revinfo
+ on delete cascade
+);
+
+insert into aud__revinfo (rev_username)
+values ('system');
+
+insert into aud_plan_item
+select id
+ , (select max(rev) from aud__revinfo)
+ , 0 -- add
+ , null
+ , null
+ , dtype
+ , name
+ , status_id
+ , ingredient_id
+ , bucket_id
+from plan_item;