Approval workflow with audit trail#

A multi-step approval flow: DRAFT → SUBMITTED → IN_PROGRESS → APPROVED (or REJECTED), with a field-level audit trail that records who changed what at each step.

Data model#

Three tables power the flow:

-- The entity being approved (e.g., an invoice)
ALTER TABLE invoice ADD COLUMN status VARCHAR(16) NOT NULL DEFAULT 'DRAFT';

-- Each step in the approval chain
CREATE TABLE workflow_step (
  id            BIGINT AUTO_INCREMENT PRIMARY KEY,
  invoice_id    BIGINT NOT NULL,
  step_order    INT    NOT NULL,          -- 1, 2, 3, ...
  group_name    VARCHAR(64),              -- "Finance", "Director", "Secretary"
  status        VARCHAR(16) NOT NULL DEFAULT 'PENDING',
  acted_by      VARCHAR(128),
  acted_at      TIMESTAMP,
  remarks       TEXT,
  CONSTRAINT fk_ws_invoice FOREIGN KEY (invoice_id) REFERENCES invoice(id)
);

-- Field-level change log per step
CREATE TABLE workflow_context (
  id            BIGINT AUTO_INCREMENT PRIMARY KEY,
  step_id       BIGINT NOT NULL,
  field_name    VARCHAR(64),
  old_value     VARCHAR(512),
  new_value     VARCHAR(512),
  CONSTRAINT fk_wc_step FOREIGN KEY (step_id) REFERENCES workflow_step(id)
);

Workflow service#

@Service
@RequiredArgsConstructor
public class InvoiceWorkflowService {

    private final WorkflowStepRepo   stepRepo;
    private final WorkflowContextRepo contextRepo;
    private final InvoiceRepo         invoiceRepo;

    @Transactional
    public void submit(Long invoiceId, String submittedBy) {
        var invoice = invoiceRepo.findById(invoiceId).orElseThrow();
        assertStatus(invoice, "DRAFT", "REJECTED");

        invoice.setStatus("SUBMITTED");
        invoiceRepo.save(invoice);

        stepRepo.save(WorkflowStepEntity.builder()
            .invoiceId(invoiceId)
            .stepOrder(nextStep(invoiceId))
            .groupName("Submitter")
            .status("COMPLETED")
            .actedBy(submittedBy)
            .actedAt(Instant.now())
            .build());
    }

    @Transactional
    public void approve(Long invoiceId, String approvedBy, Map<String, String> amendments) {
        var invoice = invoiceRepo.findById(invoiceId).orElseThrow();
        assertStatus(invoice, "SUBMITTED", "IN_PROGRESS");

        int order = nextStep(invoiceId);
        var step = stepRepo.save(WorkflowStepEntity.builder()
            .invoiceId(invoiceId)
            .stepOrder(order)
            .groupName(resolveGroup(approvedBy))
            .status("APPROVED")
            .actedBy(approvedBy)
            .actedAt(Instant.now())
            .build());

        // Audit every field the approver amended
        amendments.forEach((field, newValue) -> {
            String oldValue = getFieldValue(invoice, field);
            if (!Objects.equals(oldValue, newValue)) {
                contextRepo.save(WorkflowContextEntity.builder()
                    .stepId(step.getId())
                    .fieldName(field)
                    .oldValue(oldValue)
                    .newValue(newValue)
                    .build());
                setFieldValue(invoice, field, newValue);
            }
        });

        invoice.setStatus(isLastApprover(order) ? "APPROVED" : "IN_PROGRESS");
        invoiceRepo.save(invoice);
    }

    @Transactional
    public void reject(Long invoiceId, String rejectedBy, String remarks) {
        var invoice = invoiceRepo.findById(invoiceId).orElseThrow();
        assertStatus(invoice, "SUBMITTED", "IN_PROGRESS");

        invoice.setStatus("REJECTED");
        invoiceRepo.save(invoice);

        stepRepo.save(WorkflowStepEntity.builder()
            .invoiceId(invoiceId)
            .stepOrder(nextStep(invoiceId))
            .groupName(resolveGroup(rejectedBy))
            .status("REJECTED")
            .actedBy(rejectedBy)
            .actedAt(Instant.now())
            .remarks(remarks)
            .build());
    }

    private void assertStatus(InvoiceEntity invoice, String... allowed) {
        if (!Set.of(allowed).contains(invoice.getStatus())) {
            throw new BusinessException("Invoice is in status " + invoice.getStatus()
                + "; expected one of " + Arrays.toString(allowed));
        }
    }

    private int nextStep(Long invoiceId) {
        return stepRepo.countByInvoiceId(invoiceId) + 1;
    }
}

Wiring to a Palmyra handler#

The handler gates the status transitions; the workflow service owns the logic.

@Component
@CrudMapping(mapping = "/invoice", type = InvoiceModel.class,
             secondaryMapping = "/invoice/{id}")
public class InvoiceHandler extends AbstractHandler
        implements QueryHandler, ReadHandler, SaveHandler {

    @Override
    public Tuple preUpdate(Tuple tuple, Tuple dbTuple, HandlerContext ctx) {
        String status = (String) dbTuple.get("status");
        if (!Set.of("DRAFT", "REJECTED").contains(status)) {
            throw new BusinessException("Cannot edit invoice in status: " + status);
        }
        return tuple;
    }
}

Approve / reject actions go through a custom controller that calls the workflow service:

@PostMapping("/invoice/{id}/approve")
@PreAuthorize("hasPermission(#id, 'Invoice', 'INVOICE_APPROVE')")
public ResponseEntity<?> approve(@PathVariable Long id,
                                  @RequestBody Map<String, String> amendments) {
    workflowService.approve(id, auth.getUser(), amendments);
    return ResponseEntity.noContent().build();
}

Querying the audit trail#

Expose the trail through a simple read handler — or a NativeQueryHandler if you want the timeline flattened:

@Component
@CrudMapping(mapping = "/invoice/{invoiceId}/workflow", type = WorkflowStepModel.class)
public class WorkflowStepHandler implements QueryHandler {
    @Override
    public QueryFilter applyQueryFilter(QueryFilter filter, HandlerContext ctx) {
        filter.addOrderAsc("stepOrder");
        return filter;
    }
}

See also: Custom controllers, Cross-entity validation.