From dde3bc18384d8ecf416d169c4ff2907dabe85dee Mon Sep 17 00:00:00 2001 From: Serhii_Nosko Date: Fri, 26 Jun 2026 14:53:25 +0300 Subject: [PATCH 1/4] MODINVOSTO-216. Add old object for EDIT audit events for Invoice, Invoice Line --- ramls/acq-models | 2 +- .../audit/AuditOutboxEventLogPostgresDAO.java | 7 +- .../org/folio/dao/lines/InvoiceLinesDAO.java | 2 + .../dao/lines/InvoiceLinesPostgresDAO.java | 14 +++ .../service/InvoiceLineStorageService.java | 5 +- .../folio/service/InvoiceStorageService.java | 5 +- .../service/audit/AuditEventProducer.java | 38 +++++--- .../service/audit/AuditOutboxService.java | 43 +++++++-- .../folio/service/util/OutboxEventFields.java | 3 +- .../templates/db_scripts/schema.json | 2 +- .../tables/create_audit_outbox_table.sql | 5 +- .../service/audit/AuditEventProducerTest.java | 95 +++++++++++++++++++ 12 files changed, 190 insertions(+), 31 deletions(-) create mode 100644 src/test/java/org/folio/service/audit/AuditEventProducerTest.java diff --git a/ramls/acq-models b/ramls/acq-models index f01316a3..e4b810ee 160000 --- a/ramls/acq-models +++ b/ramls/acq-models @@ -1 +1 @@ -Subproject commit f01316a33d05d034c5e45c63252b6092fd0a81d6 +Subproject commit e4b810ee35c78f505517495c7121ee304abc8ae5 diff --git a/src/main/java/org/folio/dao/audit/AuditOutboxEventLogPostgresDAO.java b/src/main/java/org/folio/dao/audit/AuditOutboxEventLogPostgresDAO.java index c9cf3e92..acd751e1 100644 --- a/src/main/java/org/folio/dao/audit/AuditOutboxEventLogPostgresDAO.java +++ b/src/main/java/org/folio/dao/audit/AuditOutboxEventLogPostgresDAO.java @@ -21,7 +21,7 @@ public class AuditOutboxEventLogPostgresDAO implements AuditOutboxEventLogDAO { public static final String OUTBOX_TABLE_NAME = "outbox_event_log"; private static final String SELECT_SQL = "SELECT * FROM %s FOR UPDATE SKIP LOCKED LIMIT 1000"; - private static final String INSERT_SQL = "INSERT INTO %s (event_id, entity_type, action, payload) VALUES ($1, $2, $3, $4)"; + private static final String INSERT_SQL = "INSERT INTO %s (event_id, entity_type, action, payload, original_payload) VALUES ($1, $2, $3, $4, $5)"; private static final String BATCH_DELETE_SQL = "DELETE from %s where event_id = ANY ($1)"; /** @@ -50,7 +50,7 @@ public Future> getEventLogs(Conn conn, String tenantId) { public Future saveEventLog(Conn conn, OutboxEventLog eventLog, String tenantId) { log.debug("saveEventLog:: Saving event log to outbox table with eventId: '{}'", eventLog.getEventId()); var tableName = getTenantTableName(tenantId, OUTBOX_TABLE_NAME); - Tuple params = Tuple.of(eventLog.getEventId(), eventLog.getEntityType().value(), eventLog.getAction(), eventLog.getPayload()); + Tuple params = Tuple.of(eventLog.getEventId(), eventLog.getEntityType().value(), eventLog.getAction(), eventLog.getPayload(), eventLog.getOriginalPayload()); return conn.execute(INSERT_SQL.formatted(tableName), params) .onFailure(t -> log.warn("saveEventLog:: Failed to save event log with id: '{}'", eventLog.getEventId(), t)) .mapEmpty(); @@ -78,7 +78,8 @@ private OutboxEventLog convertDbRowToOutboxEventLog(Row row) { .withEventId(row.getUUID(OutboxEventFields.EVENT_ID.getName()).toString()) .withEntityType(OutboxEventLog.EntityType.fromValue(row.getString(OutboxEventFields.ENTITY_TYPE.getName()))) .withAction(row.getString(OutboxEventFields.ACTION.getName())) - .withPayload(row.getString(OutboxEventFields.PAYLOAD.getName())); + .withPayload(row.getString(OutboxEventFields.PAYLOAD.getName())) + .withOriginalPayload(row.getString(OutboxEventFields.ORIGINAL_PAYLOAD.getName())); } } diff --git a/src/main/java/org/folio/dao/lines/InvoiceLinesDAO.java b/src/main/java/org/folio/dao/lines/InvoiceLinesDAO.java index 11ddb022..30762bdf 100644 --- a/src/main/java/org/folio/dao/lines/InvoiceLinesDAO.java +++ b/src/main/java/org/folio/dao/lines/InvoiceLinesDAO.java @@ -11,6 +11,8 @@ public interface InvoiceLinesDAO { Future> getInvoiceLines(Criterion criterion, Conn conn); + Future getInvoiceLineByIdForUpdate(String id, Conn conn); + Future createInvoiceLine(InvoiceLine invoiceLine, Conn conn); Future updateInvoiceLine(String id, InvoiceLine invoiceLine, Conn conn); diff --git a/src/main/java/org/folio/dao/lines/InvoiceLinesPostgresDAO.java b/src/main/java/org/folio/dao/lines/InvoiceLinesPostgresDAO.java index 8fc84aa6..413af0df 100644 --- a/src/main/java/org/folio/dao/lines/InvoiceLinesPostgresDAO.java +++ b/src/main/java/org/folio/dao/lines/InvoiceLinesPostgresDAO.java @@ -1,6 +1,7 @@ package org.folio.dao.lines; import io.vertx.core.Future; +import io.vertx.ext.web.handler.HttpException; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.folio.dao.DbUtils; @@ -11,6 +12,7 @@ import java.util.List; +import static javax.ws.rs.core.Response.Status.NOT_FOUND; import static org.folio.rest.impl.InvoiceStorageImpl.INVOICE_LINE_TABLE; import static org.folio.rest.utils.ResponseUtils.convertPgExceptionIfNeeded; @@ -26,6 +28,18 @@ public Future> getInvoiceLines(Criterion criterion, Conn conn) .onFailure(t -> log.error("Failed to get invoice lines with criterion: {}", criterion, t)); } + @Override + public Future getInvoiceLineByIdForUpdate(String id, Conn conn) { + return conn.getByIdForUpdate(INVOICE_LINE_TABLE, id, InvoiceLine.class) + .map(invoiceLine -> { + if (invoiceLine == null) { + throw new HttpException(NOT_FOUND.getStatusCode(), NOT_FOUND.getReasonPhrase()); + } + return invoiceLine; + }) + .onFailure(t -> log.error("getInvoiceLineByIdForUpdate failed for invoice line with id {}", id, t)); + } + @Override public Future createInvoiceLine(InvoiceLine invoiceLine, Conn conn) { log.trace("createInvoiceLine:: Creating invoice line: {}", invoiceLine); diff --git a/src/main/java/org/folio/service/InvoiceLineStorageService.java b/src/main/java/org/folio/service/InvoiceLineStorageService.java index 255091bc..8a6ccfb9 100644 --- a/src/main/java/org/folio/service/InvoiceLineStorageService.java +++ b/src/main/java/org/folio/service/InvoiceLineStorageService.java @@ -55,8 +55,9 @@ public void updateInvoiceLine(String id, InvoiceLine invoiceLine, Map invoiceLinesDAO.updateInvoiceLine(id, invoiceLine, conn) - .compose(invoiceLineId -> auditOutboxService.saveInvoiceLineOutboxLog(conn, invoiceLine, InvoiceLineAuditEvent.Action.EDIT, headers))) + .withTrans(conn -> invoiceLinesDAO.getInvoiceLineByIdForUpdate(id, conn) + .compose(original -> invoiceLinesDAO.updateInvoiceLine(id, invoiceLine, conn) + .compose(v -> auditOutboxService.saveInvoiceLineOutboxLog(conn, invoiceLine, original, InvoiceLineAuditEvent.Action.EDIT, headers)))) .onSuccess(s -> { log.info("updateInvoiceLine:: Successfully updated invoice line with id: {}", id); auditOutboxService.processOutboxEventLogs(headers, vertxContext); diff --git a/src/main/java/org/folio/service/InvoiceStorageService.java b/src/main/java/org/folio/service/InvoiceStorageService.java index 85117679..ee0d0240 100644 --- a/src/main/java/org/folio/service/InvoiceStorageService.java +++ b/src/main/java/org/folio/service/InvoiceStorageService.java @@ -68,8 +68,9 @@ public void putInvoiceStorageInvoicesById(String id, Invoice invoice, Map invoiceDAO.updateInvoice(id, invoice, conn) - .compose(invoiceId -> auditOutboxService.saveInvoiceOutboxLog(conn, invoice, InvoiceAuditEvent.Action.EDIT, headers))) + .withTrans(conn -> invoiceDAO.getInvoiceByIdForUpdate(id, conn) + .compose(original -> invoiceDAO.updateInvoice(id, invoice, conn) + .compose(v -> auditOutboxService.saveInvoiceOutboxLog(conn, invoice, original, InvoiceAuditEvent.Action.EDIT, headers)))) .onSuccess(s -> { log.info("putInvoiceStorageInvoicesById:: Successfully updated invoice with id: {}", id); auditOutboxService.processOutboxEventLogs(headers, vertxContext); diff --git a/src/main/java/org/folio/service/audit/AuditEventProducer.java b/src/main/java/org/folio/service/audit/AuditEventProducer.java index c28aa80c..dbbcd769 100644 --- a/src/main/java/org/folio/service/audit/AuditEventProducer.java +++ b/src/main/java/org/folio/service/audit/AuditEventProducer.java @@ -32,13 +32,14 @@ public class AuditEventProducer { * Sends event for invoice change(Create, Edit) to kafka. * InvoiceId is used as partition key to send all events for particular invoice to the same partition. * - * @param invoice the event payload - * @param eventAction the event action - * @param okapiHeaders the okapi headers + * @param invoice the event payload (post-edit state) + * @param originalInvoice the pre-edit invoice state; null for Create + * @param eventAction the event action + * @param okapiHeaders the okapi headers * @return future with true if sending was success or failed future in another case */ - public Future sendInvoiceEvent(Invoice invoice, InvoiceAuditEvent.Action eventAction, Map okapiHeaders) { - var event = getAuditEvent(invoice, eventAction); + public Future sendInvoiceEvent(Invoice invoice, Invoice originalInvoice, InvoiceAuditEvent.Action eventAction, Map okapiHeaders) { + var event = getAuditEvent(invoice, originalInvoice, eventAction); log.info("sendInvoiceEvent:: Sending event with id: {} and invoiceId: {} to Kafka", event.getId(), invoice.getId()); return sendToKafka(EventTopic.ACQ_INVOICE_CHANGED, event.getInvoiceId(), event, okapiHeaders) .onFailure(t -> log.warn("sendInvoiceEvent:: Failed to send event with id: {} and invoiceId: {} to Kafka", event.getId(), invoice.getId(), t)); @@ -48,20 +49,21 @@ public Future sendInvoiceEvent(Invoice invoice, InvoiceAuditEvent.Action e * Sends event for invoice line change(Create, Edit) to kafka. * InvoiceLineId is used as partition key to send all events for particular invoice line to the same partition. * - * @param invoiceLine the event payload - * @param eventAction the event action - * @param okapiHeaders the okapi headers + * @param invoiceLine the event payload (post-edit state) + * @param originalInvoiceLine the pre-edit invoice line state; null for Create + * @param eventAction the event action + * @param okapiHeaders the okapi headers * @return future with true if sending was success or failed future in another case */ - public Future sendInvoiceLineEvent(InvoiceLine invoiceLine, InvoiceLineAuditEvent.Action eventAction, Map okapiHeaders) { - var event = getAuditEvent(invoiceLine, eventAction); + public Future sendInvoiceLineEvent(InvoiceLine invoiceLine, InvoiceLine originalInvoiceLine, InvoiceLineAuditEvent.Action eventAction, Map okapiHeaders) { + var event = getAuditEvent(invoiceLine, originalInvoiceLine, eventAction); log.info("sendInvoiceLineEvent:: Sending event with id: {} and invoiceLineId: {} to Kafka", event.getId(), invoiceLine.getId()); return sendToKafka(EventTopic.ACQ_INVOICE_LINE_CHANGED, event.getInvoiceLineId(), event, okapiHeaders) .onFailure(t -> log.error("sendInvoiceLineEvent:: Failed to send event with id: {} and invoiceLineId: {} to Kafka", event.getId(), invoiceLine.getId(), t)); } - private InvoiceAuditEvent getAuditEvent(Invoice invoice, InvoiceAuditEvent.Action eventAction) { - return new InvoiceAuditEvent() + InvoiceAuditEvent getAuditEvent(Invoice invoice, Invoice originalInvoice, InvoiceAuditEvent.Action eventAction) { + var event = new InvoiceAuditEvent() .withId(UUID.randomUUID().toString()) .withAction(eventAction) .withInvoiceId(invoice.getId()) @@ -69,10 +71,14 @@ private InvoiceAuditEvent getAuditEvent(Invoice invoice, InvoiceAuditEvent.Actio .withActionDate(invoice.getMetadata().getUpdatedDate()) .withUserId(invoice.getMetadata().getUpdatedByUserId()) .withInvoiceSnapshot(invoice.withMetadata(null)); + if (originalInvoice != null) { + event.setOriginalInvoiceSnapshot(originalInvoice.withMetadata(null)); + } + return event; } - private InvoiceLineAuditEvent getAuditEvent(InvoiceLine invoiceLine, InvoiceLineAuditEvent.Action eventAction) { - return new InvoiceLineAuditEvent() + InvoiceLineAuditEvent getAuditEvent(InvoiceLine invoiceLine, InvoiceLine originalInvoiceLine, InvoiceLineAuditEvent.Action eventAction) { + var event = new InvoiceLineAuditEvent() .withId(UUID.randomUUID().toString()) .withAction(eventAction) .withInvoiceId(invoiceLine.getInvoiceId()) @@ -81,6 +87,10 @@ private InvoiceLineAuditEvent getAuditEvent(InvoiceLine invoiceLine, InvoiceLine .withActionDate(invoiceLine.getMetadata().getUpdatedDate()) .withUserId(invoiceLine.getMetadata().getUpdatedByUserId()) .withInvoiceLineSnapshot(invoiceLine.withMetadata(null)); + if (originalInvoiceLine != null) { + event.setOriginalInvoiceLineSnapshot(originalInvoiceLine.withMetadata(null)); + } + return event; } private Future sendToKafka(EventTopic eventTopic, String key, Object eventPayload, Map okapiHeaders) { diff --git a/src/main/java/org/folio/service/audit/AuditOutboxService.java b/src/main/java/org/folio/service/audit/AuditOutboxService.java index 9eb12411..aa9f4992 100644 --- a/src/main/java/org/folio/service/audit/AuditOutboxService.java +++ b/src/main/java/org/folio/service/audit/AuditOutboxService.java @@ -61,13 +61,17 @@ private List> sendEventLogsToKafka(List eventLogs, switch (eventLog.getEntityType()) { case INVOICE -> { var invoice = Json.decodeValue(eventLog.getPayload(), Invoice.class); + var original = eventLog.getOriginalPayload() != null + ? Json.decodeValue(eventLog.getOriginalPayload(), Invoice.class) : null; var action = InvoiceAuditEvent.Action.fromValue(eventLog.getAction()); - yield producer.sendInvoiceEvent(invoice, action, okapiHeaders); + yield producer.sendInvoiceEvent(invoice, original, action, okapiHeaders); } case INVOICE_LINE -> { var invoiceLine = Json.decodeValue(eventLog.getPayload(), InvoiceLine.class); + var original = eventLog.getOriginalPayload() != null + ? Json.decodeValue(eventLog.getOriginalPayload(), InvoiceLine.class) : null; var action = InvoiceLineAuditEvent.Action.fromValue(eventLog.getAction()); - yield producer.sendInvoiceLineEvent(invoiceLine, action, okapiHeaders); + yield producer.sendInvoiceLineEvent(invoiceLine, original, action, okapiHeaders); } }).toList(); } @@ -82,7 +86,20 @@ private List> sendEventLogsToKafka(List eventLogs, * @return future with saved outbox log id in the same transaction */ public Future saveInvoiceOutboxLog(Conn conn, Invoice entity, InvoiceAuditEvent.Action action, Map okapiHeaders) { - return saveOutboxLog(conn, okapiHeaders, action.value(), EntityType.INVOICE, entity.getId(), entity); + return saveInvoiceOutboxLog(conn, entity, null, action, okapiHeaders); + } + + /** + * Saves invoice outbox log capturing the pre-edit state. + * + * @param conn connection in transaction + * @param entity the invoice (post-edit state) + * @param original the invoice before the edit; null for Create + * @param action the event action + * @param okapiHeaders okapi headers + */ + public Future saveInvoiceOutboxLog(Conn conn, Invoice entity, Invoice original, InvoiceAuditEvent.Action action, Map okapiHeaders) { + return saveOutboxLog(conn, okapiHeaders, action.value(), EntityType.INVOICE, entity.getId(), entity, original); } /** @@ -95,16 +112,30 @@ public Future saveInvoiceOutboxLog(Conn conn, Invoice entity, InvoiceAudit * @return future with saved outbox log id in the same transaction */ public Future saveInvoiceLineOutboxLog(Conn conn, InvoiceLine entity, InvoiceLineAuditEvent.Action action, Map okapiHeaders) { - return saveOutboxLog(conn, okapiHeaders, action.value(), EntityType.INVOICE_LINE, entity.getId(), entity); + return saveInvoiceLineOutboxLog(conn, entity, null, action, okapiHeaders); + } + + /** + * Saves invoice line outbox log capturing the pre-edit state. + * + * @param conn connection in transaction + * @param entity the invoice line (post-edit state) + * @param original the invoice line before the edit; null for Create + * @param action the event action + * @param okapiHeaders okapi headers + */ + public Future saveInvoiceLineOutboxLog(Conn conn, InvoiceLine entity, InvoiceLine original, InvoiceLineAuditEvent.Action action, Map okapiHeaders) { + return saveOutboxLog(conn, okapiHeaders, action.value(), EntityType.INVOICE_LINE, entity.getId(), entity, original); } - private Future saveOutboxLog(Conn conn, Map okapiHeaders, String action, EntityType entityType, String entityId, Object entity) { + private Future saveOutboxLog(Conn conn, Map okapiHeaders, String action, EntityType entityType, String entityId, Object entity, Object originalEntity) { log.debug("saveOutboxLog:: Saving outbox log for {} with id: {}", entityType, entityId); var eventLog = new OutboxEventLog() .withEventId(UUID.randomUUID().toString()) .withAction(action) .withEntityType(entityType) - .withPayload(Json.encode(entity)); + .withPayload(Json.encode(entity)) + .withOriginalPayload(originalEntity != null ? Json.encode(originalEntity) : null); return outboxEventLogDAO.saveEventLog(conn, eventLog, TenantTool.tenantId(okapiHeaders)) .onSuccess(reply -> log.info("saveOutboxLog:: Outbox log has been saved for {} with id: {}", entityType, entityId)) .onFailure(e -> log.warn("saveOutboxLog:: Could not save outbox audit log for {} with id: {}", entityType, entityId, e)); diff --git a/src/main/java/org/folio/service/util/OutboxEventFields.java b/src/main/java/org/folio/service/util/OutboxEventFields.java index ad7ce7d0..8bd99978 100644 --- a/src/main/java/org/folio/service/util/OutboxEventFields.java +++ b/src/main/java/org/folio/service/util/OutboxEventFields.java @@ -10,7 +10,8 @@ public enum OutboxEventFields { EVENT_ID("event_id"), ENTITY_TYPE("entity_type"), ACTION("action"), - PAYLOAD("payload"); + PAYLOAD("payload"), + ORIGINAL_PAYLOAD("original_payload"); private final String name; diff --git a/src/main/resources/templates/db_scripts/schema.json b/src/main/resources/templates/db_scripts/schema.json index 5fae4c21..d9d3b720 100644 --- a/src/main/resources/templates/db_scripts/schema.json +++ b/src/main/resources/templates/db_scripts/schema.json @@ -88,7 +88,7 @@ { "run": "after", "snippetPath": "tables/create_audit_outbox_table.sql", - "fromModuleVersion": "mod-invoice-storage-6.0.0" + "fromModuleVersion": "mod-invoice-storage-7.0.0" }, { "run": "after", diff --git a/src/main/resources/templates/db_scripts/tables/create_audit_outbox_table.sql b/src/main/resources/templates/db_scripts/tables/create_audit_outbox_table.sql index ff31c93e..44714bdc 100644 --- a/src/main/resources/templates/db_scripts/tables/create_audit_outbox_table.sql +++ b/src/main/resources/templates/db_scripts/tables/create_audit_outbox_table.sql @@ -2,5 +2,8 @@ CREATE TABLE IF NOT EXISTS outbox_event_log ( event_id uuid NOT NULL PRIMARY KEY, entity_type text NOT NULL, action text NOT NULL, - payload jsonb + payload jsonb, + original_payload jsonb ); + +ALTER TABLE outbox_event_log ADD COLUMN IF NOT EXISTS original_payload jsonb; diff --git a/src/test/java/org/folio/service/audit/AuditEventProducerTest.java b/src/test/java/org/folio/service/audit/AuditEventProducerTest.java new file mode 100644 index 00000000..77c8346f --- /dev/null +++ b/src/test/java/org/folio/service/audit/AuditEventProducerTest.java @@ -0,0 +1,95 @@ +package org.folio.service.audit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Date; +import java.util.UUID; + +import org.folio.rest.jaxrs.model.Invoice; +import org.folio.rest.jaxrs.model.InvoiceAuditEvent; +import org.folio.rest.jaxrs.model.InvoiceLine; +import org.folio.rest.jaxrs.model.InvoiceLineAuditEvent; +import org.folio.rest.jaxrs.model.Metadata; +import org.junit.jupiter.api.Test; + +class AuditEventProducerTest { + + private final AuditEventProducer producer = new AuditEventProducer(null); + + @Test + void invoiceEditEventCarriesOriginalSnapshot() { + var original = getInvoice("vendor-old", "Open"); + var updated = getInvoice("vendor-new", "Reviewed"); + + InvoiceAuditEvent event = producer.getAuditEvent(updated, original, InvoiceAuditEvent.Action.EDIT); + + assertEquals(InvoiceAuditEvent.Action.EDIT, event.getAction()); + assertNotNull(event.getInvoiceSnapshot()); + assertEquals("vendor-new", event.getInvoiceSnapshot().getVendorInvoiceNo()); + assertEquals(Invoice.Status.REVIEWED, event.getInvoiceSnapshot().getStatus()); + + assertNotNull(event.getOriginalInvoiceSnapshot()); + assertEquals("vendor-old", event.getOriginalInvoiceSnapshot().getVendorInvoiceNo()); + assertEquals(Invoice.Status.OPEN, event.getOriginalInvoiceSnapshot().getStatus()); + } + + @Test + void invoiceCreateEventOmitsOriginalSnapshot() { + InvoiceAuditEvent event = producer.getAuditEvent(getInvoice("v", "Open"), null, InvoiceAuditEvent.Action.CREATE); + + assertEquals(InvoiceAuditEvent.Action.CREATE, event.getAction()); + assertNotNull(event.getInvoiceSnapshot()); + assertNull(event.getOriginalInvoiceSnapshot()); + } + + @Test + void invoiceLineEditEventCarriesOriginalSnapshot() { + var original = getInvoiceLine("desc-old", 10d); + var updated = getInvoiceLine("desc-new", 25d); + + InvoiceLineAuditEvent event = producer.getAuditEvent(updated, original, InvoiceLineAuditEvent.Action.EDIT); + + assertEquals(InvoiceLineAuditEvent.Action.EDIT, event.getAction()); + assertNotNull(event.getInvoiceLineSnapshot()); + assertEquals("desc-new", event.getInvoiceLineSnapshot().getDescription()); + assertEquals(25d, event.getInvoiceLineSnapshot().getSubTotal()); + + assertNotNull(event.getOriginalInvoiceLineSnapshot()); + assertEquals("desc-old", event.getOriginalInvoiceLineSnapshot().getDescription()); + assertEquals(10d, event.getOriginalInvoiceLineSnapshot().getSubTotal()); + } + + @Test + void invoiceLineCreateEventOmitsOriginalSnapshot() { + InvoiceLineAuditEvent event = producer.getAuditEvent(getInvoiceLine("d", 1d), null, InvoiceLineAuditEvent.Action.CREATE); + + assertEquals(InvoiceLineAuditEvent.Action.CREATE, event.getAction()); + assertNotNull(event.getInvoiceLineSnapshot()); + assertNull(event.getOriginalInvoiceLineSnapshot()); + } + + private Invoice getInvoice(String vendorInvoiceNo, String status) { + return new Invoice() + .withId(UUID.randomUUID().toString()) + .withVendorInvoiceNo(vendorInvoiceNo) + .withStatus(Invoice.Status.fromValue(status)) + .withMetadata(getMetadata()); + } + + private InvoiceLine getInvoiceLine(String description, double subTotal) { + return new InvoiceLine() + .withId(UUID.randomUUID().toString()) + .withInvoiceId(UUID.randomUUID().toString()) + .withDescription(description) + .withSubTotal(subTotal) + .withMetadata(getMetadata()); + } + + private Metadata getMetadata() { + return new Metadata() + .withUpdatedDate(new Date()) + .withUpdatedByUserId(UUID.randomUUID().toString()); + } +} From eed0286c8c227ef974612765fc082ae66086ea85 Mon Sep 17 00:00:00 2001 From: Serhii_Nosko Date: Mon, 29 Jun 2026 11:42:47 +0300 Subject: [PATCH 2/4] MODINVOSTO-216. Add old object for EDIT audit events for Invoice, Invoice Line --- pom.xml | 2 +- ramls/acq-models | 2 +- .../audit/AuditOutboxEventLogPostgresDAO.java | 7 +-- .../service/audit/AuditEntityWrapper.java | 18 ++++++ .../service/audit/AuditOutboxService.java | 42 +++++++++---- .../folio/service/util/OutboxEventFields.java | 3 +- .../templates/db_scripts/schema.json | 2 +- .../tables/create_audit_outbox_table.sql | 5 +- .../service/audit/AuditOutboxServiceTest.java | 60 +++++++++++++++++++ 9 files changed, 115 insertions(+), 26 deletions(-) create mode 100644 src/main/java/org/folio/service/audit/AuditEntityWrapper.java create mode 100644 src/test/java/org/folio/service/audit/AuditOutboxServiceTest.java diff --git a/pom.xml b/pom.xml index 56ef8e16..c84b305e 100644 --- a/pom.xml +++ b/pom.xml @@ -2,7 +2,7 @@ 4.0.0 org.folio mod-invoice-storage - 7.0.0-SNAPSHOT + 7.0.0-SNAPSHOT.2117 jar diff --git a/ramls/acq-models b/ramls/acq-models index e4b810ee..38c4b15c 160000 --- a/ramls/acq-models +++ b/ramls/acq-models @@ -1 +1 @@ -Subproject commit e4b810ee35c78f505517495c7121ee304abc8ae5 +Subproject commit 38c4b15c799fc98d1adf109cb31db22689318cf3 diff --git a/src/main/java/org/folio/dao/audit/AuditOutboxEventLogPostgresDAO.java b/src/main/java/org/folio/dao/audit/AuditOutboxEventLogPostgresDAO.java index acd751e1..c9cf3e92 100644 --- a/src/main/java/org/folio/dao/audit/AuditOutboxEventLogPostgresDAO.java +++ b/src/main/java/org/folio/dao/audit/AuditOutboxEventLogPostgresDAO.java @@ -21,7 +21,7 @@ public class AuditOutboxEventLogPostgresDAO implements AuditOutboxEventLogDAO { public static final String OUTBOX_TABLE_NAME = "outbox_event_log"; private static final String SELECT_SQL = "SELECT * FROM %s FOR UPDATE SKIP LOCKED LIMIT 1000"; - private static final String INSERT_SQL = "INSERT INTO %s (event_id, entity_type, action, payload, original_payload) VALUES ($1, $2, $3, $4, $5)"; + private static final String INSERT_SQL = "INSERT INTO %s (event_id, entity_type, action, payload) VALUES ($1, $2, $3, $4)"; private static final String BATCH_DELETE_SQL = "DELETE from %s where event_id = ANY ($1)"; /** @@ -50,7 +50,7 @@ public Future> getEventLogs(Conn conn, String tenantId) { public Future saveEventLog(Conn conn, OutboxEventLog eventLog, String tenantId) { log.debug("saveEventLog:: Saving event log to outbox table with eventId: '{}'", eventLog.getEventId()); var tableName = getTenantTableName(tenantId, OUTBOX_TABLE_NAME); - Tuple params = Tuple.of(eventLog.getEventId(), eventLog.getEntityType().value(), eventLog.getAction(), eventLog.getPayload(), eventLog.getOriginalPayload()); + Tuple params = Tuple.of(eventLog.getEventId(), eventLog.getEntityType().value(), eventLog.getAction(), eventLog.getPayload()); return conn.execute(INSERT_SQL.formatted(tableName), params) .onFailure(t -> log.warn("saveEventLog:: Failed to save event log with id: '{}'", eventLog.getEventId(), t)) .mapEmpty(); @@ -78,8 +78,7 @@ private OutboxEventLog convertDbRowToOutboxEventLog(Row row) { .withEventId(row.getUUID(OutboxEventFields.EVENT_ID.getName()).toString()) .withEntityType(OutboxEventLog.EntityType.fromValue(row.getString(OutboxEventFields.ENTITY_TYPE.getName()))) .withAction(row.getString(OutboxEventFields.ACTION.getName())) - .withPayload(row.getString(OutboxEventFields.PAYLOAD.getName())) - .withOriginalPayload(row.getString(OutboxEventFields.ORIGINAL_PAYLOAD.getName())); + .withPayload(row.getString(OutboxEventFields.PAYLOAD.getName())); } } diff --git a/src/main/java/org/folio/service/audit/AuditEntityWrapper.java b/src/main/java/org/folio/service/audit/AuditEntityWrapper.java new file mode 100644 index 00000000..9aeb5030 --- /dev/null +++ b/src/main/java/org/folio/service/audit/AuditEntityWrapper.java @@ -0,0 +1,18 @@ +package org.folio.service.audit; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AuditEntityWrapper { + + private T entity; + private T originalEntity; + + public static AuditEntityWrapper of(T entity, T originalEntity) { + return new AuditEntityWrapper<>(entity, originalEntity); + } +} \ No newline at end of file diff --git a/src/main/java/org/folio/service/audit/AuditOutboxService.java b/src/main/java/org/folio/service/audit/AuditOutboxService.java index aa9f4992..7360075c 100644 --- a/src/main/java/org/folio/service/audit/AuditOutboxService.java +++ b/src/main/java/org/folio/service/audit/AuditOutboxService.java @@ -6,6 +6,7 @@ import org.apache.commons.collections4.CollectionUtils; import org.folio.dao.audit.AuditOutboxEventLogDAO; +import org.folio.dbschema.ObjectMapperTool; import org.folio.rest.jaxrs.model.Invoice; import org.folio.rest.jaxrs.model.InvoiceAuditEvent; import org.folio.rest.jaxrs.model.InvoiceLine; @@ -60,22 +61,38 @@ private List> sendEventLogsToKafka(List eventLogs, return eventLogs.stream().map(eventLog -> switch (eventLog.getEntityType()) { case INVOICE -> { - var invoice = Json.decodeValue(eventLog.getPayload(), Invoice.class); - var original = eventLog.getOriginalPayload() != null - ? Json.decodeValue(eventLog.getOriginalPayload(), Invoice.class) : null; + var wrapper = decodePayload(eventLog.getPayload(), Invoice.class); var action = InvoiceAuditEvent.Action.fromValue(eventLog.getAction()); - yield producer.sendInvoiceEvent(invoice, original, action, okapiHeaders); + yield producer.sendInvoiceEvent(wrapper.getEntity(), wrapper.getOriginalEntity(), action, okapiHeaders); } case INVOICE_LINE -> { - var invoiceLine = Json.decodeValue(eventLog.getPayload(), InvoiceLine.class); - var original = eventLog.getOriginalPayload() != null - ? Json.decodeValue(eventLog.getOriginalPayload(), InvoiceLine.class) : null; + var wrapper = decodePayload(eventLog.getPayload(), InvoiceLine.class); var action = InvoiceLineAuditEvent.Action.fromValue(eventLog.getAction()); - yield producer.sendInvoiceLineEvent(invoiceLine, original, action, okapiHeaders); + yield producer.sendInvoiceLineEvent(wrapper.getEntity(), wrapper.getOriginalEntity(), action, okapiHeaders); } }).toList(); } + /** + * Decode an outbox payload into a wrapper. Falls back to decoding the payload as a bare entity + * for backwards compatibility with rows written before the wrapper format was introduced. + */ + AuditEntityWrapper decodePayload(String payload, Class entityClass) { + var mapper = ObjectMapperTool.getMapper(); + try { + var wrapperType = mapper.getTypeFactory().constructParametricType(AuditEntityWrapper.class, entityClass); + AuditEntityWrapper wrapper = mapper.readValue(payload, wrapperType); + if (wrapper.getEntity() != null) { + return wrapper; + } + } catch (Exception ignored) { + // fall through to legacy decoding + } + log.warn("decodePayload:: Falling back to legacy (bare-entity) outbox payload decoding"); + T entity = Json.decodeValue(payload, entityClass); + return AuditEntityWrapper.of(entity, null); + } + /** * Saves invoice outbox log. * @@ -99,7 +116,7 @@ public Future saveInvoiceOutboxLog(Conn conn, Invoice entity, InvoiceAudit * @param okapiHeaders okapi headers */ public Future saveInvoiceOutboxLog(Conn conn, Invoice entity, Invoice original, InvoiceAuditEvent.Action action, Map okapiHeaders) { - return saveOutboxLog(conn, okapiHeaders, action.value(), EntityType.INVOICE, entity.getId(), entity, original); + return saveOutboxLog(conn, okapiHeaders, action.value(), EntityType.INVOICE, entity.getId(), AuditEntityWrapper.of(entity, original)); } /** @@ -125,17 +142,16 @@ public Future saveInvoiceLineOutboxLog(Conn conn, InvoiceLine entity, Invo * @param okapiHeaders okapi headers */ public Future saveInvoiceLineOutboxLog(Conn conn, InvoiceLine entity, InvoiceLine original, InvoiceLineAuditEvent.Action action, Map okapiHeaders) { - return saveOutboxLog(conn, okapiHeaders, action.value(), EntityType.INVOICE_LINE, entity.getId(), entity, original); + return saveOutboxLog(conn, okapiHeaders, action.value(), EntityType.INVOICE_LINE, entity.getId(), AuditEntityWrapper.of(entity, original)); } - private Future saveOutboxLog(Conn conn, Map okapiHeaders, String action, EntityType entityType, String entityId, Object entity, Object originalEntity) { + private Future saveOutboxLog(Conn conn, Map okapiHeaders, String action, EntityType entityType, String entityId, AuditEntityWrapper wrapper) { log.debug("saveOutboxLog:: Saving outbox log for {} with id: {}", entityType, entityId); var eventLog = new OutboxEventLog() .withEventId(UUID.randomUUID().toString()) .withAction(action) .withEntityType(entityType) - .withPayload(Json.encode(entity)) - .withOriginalPayload(originalEntity != null ? Json.encode(originalEntity) : null); + .withPayload(Json.encode(wrapper)); return outboxEventLogDAO.saveEventLog(conn, eventLog, TenantTool.tenantId(okapiHeaders)) .onSuccess(reply -> log.info("saveOutboxLog:: Outbox log has been saved for {} with id: {}", entityType, entityId)) .onFailure(e -> log.warn("saveOutboxLog:: Could not save outbox audit log for {} with id: {}", entityType, entityId, e)); diff --git a/src/main/java/org/folio/service/util/OutboxEventFields.java b/src/main/java/org/folio/service/util/OutboxEventFields.java index 8bd99978..ad7ce7d0 100644 --- a/src/main/java/org/folio/service/util/OutboxEventFields.java +++ b/src/main/java/org/folio/service/util/OutboxEventFields.java @@ -10,8 +10,7 @@ public enum OutboxEventFields { EVENT_ID("event_id"), ENTITY_TYPE("entity_type"), ACTION("action"), - PAYLOAD("payload"), - ORIGINAL_PAYLOAD("original_payload"); + PAYLOAD("payload"); private final String name; diff --git a/src/main/resources/templates/db_scripts/schema.json b/src/main/resources/templates/db_scripts/schema.json index d9d3b720..5fae4c21 100644 --- a/src/main/resources/templates/db_scripts/schema.json +++ b/src/main/resources/templates/db_scripts/schema.json @@ -88,7 +88,7 @@ { "run": "after", "snippetPath": "tables/create_audit_outbox_table.sql", - "fromModuleVersion": "mod-invoice-storage-7.0.0" + "fromModuleVersion": "mod-invoice-storage-6.0.0" }, { "run": "after", diff --git a/src/main/resources/templates/db_scripts/tables/create_audit_outbox_table.sql b/src/main/resources/templates/db_scripts/tables/create_audit_outbox_table.sql index 44714bdc..ff31c93e 100644 --- a/src/main/resources/templates/db_scripts/tables/create_audit_outbox_table.sql +++ b/src/main/resources/templates/db_scripts/tables/create_audit_outbox_table.sql @@ -2,8 +2,5 @@ CREATE TABLE IF NOT EXISTS outbox_event_log ( event_id uuid NOT NULL PRIMARY KEY, entity_type text NOT NULL, action text NOT NULL, - payload jsonb, - original_payload jsonb + payload jsonb ); - -ALTER TABLE outbox_event_log ADD COLUMN IF NOT EXISTS original_payload jsonb; diff --git a/src/test/java/org/folio/service/audit/AuditOutboxServiceTest.java b/src/test/java/org/folio/service/audit/AuditOutboxServiceTest.java new file mode 100644 index 00000000..a519c9f7 --- /dev/null +++ b/src/test/java/org/folio/service/audit/AuditOutboxServiceTest.java @@ -0,0 +1,60 @@ +package org.folio.service.audit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.UUID; + +import org.folio.rest.jaxrs.model.Invoice; +import org.junit.jupiter.api.Test; + +import io.vertx.core.json.Json; + +class AuditOutboxServiceTest { + + private final AuditOutboxService service = new AuditOutboxService(null, null); + + @Test + void decodesWrapperPayloadWithBothEntities() { + var current = invoice("v-new"); + var original = invoice("v-old"); + var payload = Json.encode(AuditEntityWrapper.of(current, original)); + + AuditEntityWrapper result = service.decodePayload(payload, Invoice.class); + + assertNotNull(result.getEntity()); + assertEquals("v-new", result.getEntity().getVendorInvoiceNo()); + assertNotNull(result.getOriginalEntity()); + assertEquals("v-old", result.getOriginalEntity().getVendorInvoiceNo()); + } + + @Test + void decodesWrapperPayloadWithNullOriginal() { + var current = invoice("v"); + var payload = Json.encode(AuditEntityWrapper.of(current, null)); + + AuditEntityWrapper result = service.decodePayload(payload, Invoice.class); + + assertEquals("v", result.getEntity().getVendorInvoiceNo()); + assertNull(result.getOriginalEntity()); + } + + @Test + void fallsBackToBareEntityForLegacyPayload() { + var current = invoice("legacy"); + var legacyPayload = Json.encode(current); + + AuditEntityWrapper result = service.decodePayload(legacyPayload, Invoice.class); + + assertNotNull(result.getEntity()); + assertEquals("legacy", result.getEntity().getVendorInvoiceNo()); + assertNull(result.getOriginalEntity()); + } + + private Invoice invoice(String vendorInvoiceNo) { + return new Invoice() + .withId(UUID.randomUUID().toString()) + .withVendorInvoiceNo(vendorInvoiceNo); + } +} \ No newline at end of file From cc8b439368ab97eedaaccabf5da3340378afd0be Mon Sep 17 00:00:00 2001 From: Serhii_Nosko Date: Mon, 29 Jun 2026 12:28:44 +0300 Subject: [PATCH 3/4] MODINVOSTO-216. Code review --- src/main/java/org/folio/service/audit/AuditOutboxService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/folio/service/audit/AuditOutboxService.java b/src/main/java/org/folio/service/audit/AuditOutboxService.java index 7360075c..561e8179 100644 --- a/src/main/java/org/folio/service/audit/AuditOutboxService.java +++ b/src/main/java/org/folio/service/audit/AuditOutboxService.java @@ -6,7 +6,6 @@ import org.apache.commons.collections4.CollectionUtils; import org.folio.dao.audit.AuditOutboxEventLogDAO; -import org.folio.dbschema.ObjectMapperTool; import org.folio.rest.jaxrs.model.Invoice; import org.folio.rest.jaxrs.model.InvoiceAuditEvent; import org.folio.rest.jaxrs.model.InvoiceLine; @@ -20,6 +19,7 @@ import io.vertx.core.Context; import io.vertx.core.Future; import io.vertx.core.json.Json; +import io.vertx.core.json.jackson.DatabindCodec; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; @@ -78,7 +78,7 @@ private List> sendEventLogsToKafka(List eventLogs, * for backwards compatibility with rows written before the wrapper format was introduced. */ AuditEntityWrapper decodePayload(String payload, Class entityClass) { - var mapper = ObjectMapperTool.getMapper(); + var mapper = DatabindCodec.mapper(); try { var wrapperType = mapper.getTypeFactory().constructParametricType(AuditEntityWrapper.class, entityClass); AuditEntityWrapper wrapper = mapper.readValue(payload, wrapperType); From e87694551b4c1ec07a7b7fb6c10e555293abce52 Mon Sep 17 00:00:00 2001 From: Serhii_Nosko Date: Mon, 29 Jun 2026 13:01:29 +0300 Subject: [PATCH 4/4] MODINVOSTO-216. Remove unnecessary change --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index c84b305e..56ef8e16 100644 --- a/pom.xml +++ b/pom.xml @@ -2,7 +2,7 @@ 4.0.0 org.folio mod-invoice-storage - 7.0.0-SNAPSHOT.2117 + 7.0.0-SNAPSHOT jar