Add updated-from entity snapshot to updated webhooks

Update webhook events now carry a top-level "updatedFrom" field
alongside "data", containing a full snapshot of the entity as it was
before the update. This lets subscribers diff old vs new state (for
example the prior membership role on user:updated) without tracking
prior state themselves. It is a complete snapshot with the same shape as
"data", not a partial diff, so consumers select whatever fields they
need. The field is omitted for non-update events.

The webhook_data table gains a nullable updated_from JSONB column, and
webhook.InsertUpdateData enqueues both snapshots; InsertData delegates to
it with a nil updatedFrom so non-update callers are unaffected. Each
*:updated emission site snapshots the entity right after load, before
mutation: obligation, third-party, user (org and SCIM flows), document,
document-version, and document-version-approval-quorum. The document
emit helpers gained an optional updatedFrom argument threaded through to
the payload.

For document-version-approval-quorum:updated the snapshot requires an
extra query, so it is now gated behind the same subscription-existence
check the emitter uses: when no subscriber is configured the load is
skipped entirely rather than running (and potentially failing the
approval) for an event nobody receives.

Add integration tests (against a real Postgres, skipped when none is
reachable) covering the updated_from round-trip, the SQL NULL behavior
when no snapshot is provided, and the no-op when no subscription matches,
plus a unit test asserting updatedFrom is omitted from the payload when
absent.

Document the new field in the probod and n8n changelogs and the n8n
README.

Signed-off-by: Sacha Al Himdani <sacha@probo.com>
This commit is contained in:
Sacha Al Himdani
2026-07-15 10:34:17 +02:00
parent 68338fb4ae
commit 944bcb7380
15 changed files with 428 additions and 19 deletions

View File

@@ -228,6 +228,7 @@ func (s *DocumentApprovalService) BulkPublishVersions(
dv,
nil,
&requestedQuorum.ID,
nil,
); err != nil {
return fmt.Errorf("cannot emit approval quorum requested webhook: %w", err)
}
@@ -241,6 +242,7 @@ func (s *DocumentApprovalService) BulkPublishVersions(
dv,
nil,
nil,
nil,
); err != nil {
return fmt.Errorf("cannot emit version published webhook: %w", err)
}
@@ -398,6 +400,33 @@ func (s *DocumentApprovalService) Approve(
return fmt.Errorf("cannot create electronic signature: %w", err)
}
// Snapshot the quorum before mutating it, but only when a
// subscriber can actually receive the resulting
// `...:updated` event. Otherwise the emitter short-circuits
// on the same subscription check and this extra load would
// be wasted work that could still fail the approval.
var updatedFromQuorum any
subscriptions := coredata.WebhookSubscriptions{}
hasSubscription, err := subscriptions.ExistsByOrganizationIDAndEventType(
ctx,
tx,
scope,
document.OrganizationID,
coredata.WebhookEventTypeDocumentVersionApprovalQuorumUpdated,
)
if err != nil {
return fmt.Errorf("cannot check webhook subscriptions for approval quorum: %w", err)
}
if hasSubscription {
updatedFromQuorum, err = s.svc.Documents.loadDocumentApprovalQuorumForWebhook(ctx, scope, tx, quorum.ID, documentVersion, document)
if err != nil {
return fmt.Errorf("cannot load approval quorum snapshot for webhook: %w", err)
}
}
decision.State = coredata.DocumentVersionApprovalDecisionStateApproved
decision.Comment = req.Comment
decision.ElectronicSignatureID = &esig.ID
@@ -433,6 +462,7 @@ func (s *DocumentApprovalService) Approve(
documentVersion,
nil,
&quorum.ID,
updatedFromQuorum,
); err != nil {
return fmt.Errorf("cannot emit approval quorum updated webhook: %w", err)
}
@@ -531,6 +561,7 @@ func (s *DocumentApprovalService) Reject(
documentVersion,
nil,
&quorum.ID,
nil,
); err != nil {
return fmt.Errorf("cannot emit approval quorum rejected webhook: %w", err)
}
@@ -544,6 +575,7 @@ func (s *DocumentApprovalService) Reject(
documentVersion,
nil,
nil,
nil,
); err != nil {
return fmt.Errorf("cannot emit document version rejected webhook: %w", err)
}
@@ -635,6 +667,7 @@ func (s *DocumentApprovalService) VoidApproval(
documentVersion,
nil,
&quorum.ID,
nil,
); err != nil {
return fmt.Errorf("cannot emit approval quorum voided webhook: %w", err)
}
@@ -998,6 +1031,7 @@ func (s *DocumentApprovalService) maybeApproveQuorum(
version,
nil,
&quorum.ID,
nil,
); err != nil {
return fmt.Errorf("cannot emit approval quorum approved webhook: %w", err)
}
@@ -1011,6 +1045,7 @@ func (s *DocumentApprovalService) maybeApproveQuorum(
version,
nil,
nil,
nil,
); err != nil {
return fmt.Errorf("cannot emit document version published webhook: %w", err)
}

View File

@@ -628,6 +628,7 @@ func (s *DocumentService) PublishVersion(
version,
nil,
nil,
nil,
); err != nil {
return fmt.Errorf("cannot emit document version published webhook: %w", err)
}
@@ -653,6 +654,7 @@ func (s *DocumentService) PublishVersion(
version,
nil,
nil,
nil,
); err != nil {
return fmt.Errorf("cannot emit document version published webhook: %w", err)
}
@@ -708,6 +710,7 @@ func (s *DocumentService) PublishVersion(
dv,
nil,
&quorum.ID,
nil,
); err != nil {
return fmt.Errorf("cannot emit approval quorum requested webhook: %w", err)
}
@@ -805,7 +808,7 @@ func (s *DocumentService) Create(
}
}
if err := s.emitDocumentEventInTx(ctx, scope, conn, documentID, coredata.WebhookEventTypeDocumentCreated, nil, nil, nil); err != nil {
if err := s.emitDocumentEventInTx(ctx, scope, conn, documentID, coredata.WebhookEventTypeDocumentCreated, nil, nil, nil, nil); err != nil {
return fmt.Errorf("cannot emit document created webhook: %w", err)
}
@@ -818,6 +821,7 @@ func (s *DocumentService) Create(
documentVersion,
nil,
nil,
nil,
); err != nil {
return fmt.Errorf("cannot emit document version created webhook: %w", err)
}
@@ -993,6 +997,7 @@ func (s *DocumentService) SignDocumentVersionByIdentity(
documentVersion,
documentVersionSignature,
nil,
nil,
); err != nil {
return fmt.Errorf("cannot emit document version signature signed webhook: %w", err)
}
@@ -1122,6 +1127,7 @@ func (s *DocumentService) BulkRequestSignatures(
documentVersion,
signature,
nil,
nil,
); err != nil {
return fmt.Errorf("cannot emit signature requested webhook: %w", err)
}
@@ -1258,6 +1264,7 @@ func (s *DocumentService) RequestSignature(
documentVersion,
signature,
nil,
nil,
); err != nil {
return fmt.Errorf("cannot emit document version signature requested webhook: %w", err)
}
@@ -1378,13 +1385,14 @@ func (s *DocumentService) emitDocumentEventInTx(
version *coredata.DocumentVersion,
signature *coredata.DocumentVersionSignature,
quorumID *gid.GID,
updatedFrom any,
) error {
document := &coredata.Document{}
if err := document.LoadByID(ctx, tx, scope, documentID); err != nil {
return fmt.Errorf("cannot load document for %q webhook: %w", eventType, err)
}
return s.emitLoadedDocumentEventInTx(ctx, scope, tx, document, eventType, version, signature, quorumID)
return s.emitLoadedDocumentEventInTx(ctx, scope, tx, document, eventType, version, signature, quorumID, updatedFrom)
}
func (s *DocumentService) emitLoadedDocumentEventInTx(
@@ -1395,6 +1403,7 @@ func (s *DocumentService) emitLoadedDocumentEventInTx(
version *coredata.DocumentVersion,
signature *coredata.DocumentVersionSignature,
quorumID *gid.GID,
updatedFrom any,
) error {
subscriptions := coredata.WebhookSubscriptions{}
@@ -1423,13 +1432,14 @@ func (s *DocumentService) emitLoadedDocumentEventInTx(
payload = webhooktypes.NewDocument(document)
}
if err := webhook.InsertData(
if err := webhook.InsertUpdateData(
ctx,
tx,
scope,
document.OrganizationID,
eventType,
payload,
updatedFrom,
); err != nil {
return fmt.Errorf("cannot insert %q webhook event: %w", eventType, err)
}
@@ -1533,7 +1543,7 @@ func (s *DocumentService) SoftDelete(
return s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := s.emitDocumentEventInTx(ctx, scope, tx, documentID, coredata.WebhookEventTypeDocumentDeleted, nil, nil, nil); err != nil {
if err := s.emitDocumentEventInTx(ctx, scope, tx, documentID, coredata.WebhookEventTypeDocumentDeleted, nil, nil, nil, nil); err != nil {
return fmt.Errorf("cannot emit document deleted webhook: %w", err)
}
@@ -2134,6 +2144,8 @@ func (s *DocumentService) Update(
return &ErrDocumentArchived{}
}
previousDocument := *document
if req.TrustCenterVisibility != nil {
document.TrustCenterVisibility = *req.TrustCenterVisibility
}
@@ -2172,8 +2184,13 @@ func (s *DocumentService) Update(
versionDeleted := false
var versionPrevious any
if hasVersionChanges {
if latestVersion.Status == coredata.DocumentVersionStatusDraft {
previousVersion := *latestVersion
versionPrevious = webhooktypes.NewDocumentVersion(&previousVersion, &previousDocument)
if err := s.updateVersionInTx(ctx, scope, tx, latestVersion, req.Content, req.Classification, req.DocumentType, req.Title); err != nil {
return err
}
@@ -2231,6 +2248,7 @@ func (s *DocumentService) Update(
latestVersion,
nil,
nil,
nil,
); err != nil {
return fmt.Errorf("cannot emit document version deleted webhook: %w", err)
}
@@ -2238,16 +2256,17 @@ func (s *DocumentService) Update(
versionEvent := coredata.WebhookEventTypeDocumentVersionUpdated
if draftCreated {
versionEvent = coredata.WebhookEventTypeDocumentVersionCreated
versionPrevious = nil
}
if err := s.emitDocumentEventInTx(ctx, scope, tx, resultVersion.DocumentID, versionEvent, resultVersion, nil, nil); err != nil {
if err := s.emitDocumentEventInTx(ctx, scope, tx, resultVersion.DocumentID, versionEvent, resultVersion, nil, nil, versionPrevious); err != nil {
return fmt.Errorf("cannot emit document version webhook: %w", err)
}
}
}
if docLevelChanged {
if err := s.emitDocumentEventInTx(ctx, scope, tx, req.DocumentID, coredata.WebhookEventTypeDocumentUpdated, nil, nil, nil); err != nil {
if err := s.emitDocumentEventInTx(ctx, scope, tx, req.DocumentID, coredata.WebhookEventTypeDocumentUpdated, nil, nil, nil, webhooktypes.NewDocument(&previousDocument)); err != nil {
return fmt.Errorf("cannot emit document updated webhook: %w", err)
}
}
@@ -2305,6 +2324,7 @@ func (s *DocumentService) DeleteDraft(
latestVersion,
nil,
nil,
nil,
); err != nil {
return fmt.Errorf("cannot emit document version deleted webhook: %w", err)
}
@@ -2365,7 +2385,7 @@ func (s *DocumentService) Archive(
return fmt.Errorf("cannot archive document: %w", err)
}
if err := s.emitDocumentEventInTx(ctx, scope, tx, documentID, coredata.WebhookEventTypeDocumentArchived, nil, nil, nil); err != nil {
if err := s.emitDocumentEventInTx(ctx, scope, tx, documentID, coredata.WebhookEventTypeDocumentArchived, nil, nil, nil, nil); err != nil {
return fmt.Errorf("cannot emit document archived webhook: %w", err)
}
@@ -2405,7 +2425,7 @@ func (s *DocumentService) Unarchive(
return fmt.Errorf("cannot unarchive document: %w", err)
}
if err := s.emitDocumentEventInTx(ctx, scope, tx, documentID, coredata.WebhookEventTypeDocumentUnarchived, nil, nil, nil); err != nil {
if err := s.emitDocumentEventInTx(ctx, scope, tx, documentID, coredata.WebhookEventTypeDocumentUnarchived, nil, nil, nil, nil); err != nil {
return fmt.Errorf("cannot emit document unarchived webhook: %w", err)
}
@@ -2466,6 +2486,7 @@ func (s *DocumentService) CancelSignatureRequest(
documentVersion,
documentVersionSignature,
nil,
nil,
); err != nil {
return fmt.Errorf("cannot emit document version signature cancelled webhook: %w", err)
}

View File

@@ -3296,7 +3296,7 @@ func (s *GeneratedDocumentService) publishOrRequestApproval(
}
if isFirstVersion {
if err := s.svc.Documents.emitDocumentEventInTx(ctx, scope, tx, document.ID, coredata.WebhookEventTypeDocumentCreated, nil, nil, nil); err != nil {
if err := s.svc.Documents.emitDocumentEventInTx(ctx, scope, tx, document.ID, coredata.WebhookEventTypeDocumentCreated, nil, nil, nil, nil); err != nil {
return fmt.Errorf("cannot emit document created webhook: %w", err)
}
}
@@ -3310,6 +3310,7 @@ func (s *GeneratedDocumentService) publishOrRequestApproval(
version,
nil,
&quorum.ID,
nil,
); err != nil {
return fmt.Errorf("cannot emit approval quorum requested webhook: %w", err)
}
@@ -3332,7 +3333,7 @@ func (s *GeneratedDocumentService) publishOrRequestApproval(
}
if isFirstVersion {
if err := s.svc.Documents.emitDocumentEventInTx(ctx, scope, tx, document.ID, coredata.WebhookEventTypeDocumentCreated, nil, nil, nil); err != nil {
if err := s.svc.Documents.emitDocumentEventInTx(ctx, scope, tx, document.ID, coredata.WebhookEventTypeDocumentCreated, nil, nil, nil, nil); err != nil {
return fmt.Errorf("cannot emit document created webhook: %w", err)
}
}
@@ -3346,6 +3347,7 @@ func (s *GeneratedDocumentService) publishOrRequestApproval(
version,
nil,
nil,
nil,
); err != nil {
return fmt.Errorf("cannot emit document version published webhook: %w", err)
}

View File

@@ -198,6 +198,8 @@ func (s *ObligationService) Update(
return fmt.Errorf("cannot load obligation: %w", err)
}
previousObligation := webhooktypes.NewObligation(obligation)
if req.Area != nil {
obligation.Area = *req.Area
}
@@ -249,7 +251,7 @@ func (s *ObligationService) Update(
return fmt.Errorf("cannot update obligation: %w", err)
}
if err := webhook.InsertData(ctx, conn, scope, obligation.OrganizationID, coredata.WebhookEventTypeObligationUpdated, webhooktypes.NewObligation(obligation)); err != nil {
if err := webhook.InsertUpdateData(ctx, conn, scope, obligation.OrganizationID, coredata.WebhookEventTypeObligationUpdated, webhooktypes.NewObligation(obligation), previousObligation); err != nil {
return fmt.Errorf("cannot insert webhook event: %w", err)
}

View File

@@ -368,6 +368,8 @@ func (s ThirdPartyService) Update(
return fmt.Errorf("cannot load thirdParty %q: %w", req.ID, err)
}
previousThirdParty := webhooktypes.NewThirdParty(thirdParty)
if req.Name != nil {
thirdParty.Name = *req.Name
}
@@ -478,13 +480,14 @@ func (s ThirdPartyService) Update(
return fmt.Errorf("cannot update thirdParty: %w", err)
}
if err := webhook.InsertData(
if err := webhook.InsertUpdateData(
ctx,
conn,
scope,
thirdParty.OrganizationID,
coredata.WebhookEventTypeThirdPartyUpdated,
webhooktypes.NewThirdParty(thirdParty),
previousThirdParty,
); err != nil {
return fmt.Errorf("cannot insert webhook event: %w", err)
}