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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user