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:
@@ -4,6 +4,10 @@ All notable changes to `probod` (the server, including the bundled `@probo/conso
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Webhook deliveries for `*:updated` events now include a top-level `updatedFrom` field alongside `data`, carrying a full snapshot of the entity as it was before the update (e.g. the prior membership role on `user:updated`). The field is omitted for non-update events
|
||||||
|
|
||||||
## [0.225.0] - 2026-07-13
|
## [0.225.0] - 2026-07-13
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ All notable changes to the `@probo/n8n-nodes-probo` package will be documented i
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Probo Trigger output for `*:updated` events now includes an `updatedFrom` object alongside `data`, holding the entity snapshot from before the update so workflows can diff old vs new values (for example `{{ $json.updatedFrom.membership.role }}` vs `{{ $json.data.membership.role }}`)
|
||||||
|
|
||||||
## [0.201.0] - 2026-07-09
|
## [0.201.0] - 2026-07-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -103,6 +103,15 @@ Probo Trigger → Slack
|
|||||||
|
|
||||||
4. **Activate the workflow.** n8n registers a webhook subscription in Probo. When a document version is published, Probo delivers the event and the Slack message is sent.
|
4. **Activate the workflow.** n8n registers a webhook subscription in Probo. When a document version is published, Probo delivers the event and the Slack message is sent.
|
||||||
|
|
||||||
|
### Update events carry the previous state
|
||||||
|
|
||||||
|
For `*:updated` events, the payload includes an `updatedFrom` object next to `data`, holding a full snapshot of the entity as it was before the update. This lets a workflow react to what actually changed — for example, only notify when a user's role changes:
|
||||||
|
|
||||||
|
- **Condition:** `{{ $json.data.membership.role !== $json.updatedFrom.membership.role }}`
|
||||||
|
- **Text:** `Role changed from {{ $json.updatedFrom.membership.role }} to {{ $json.data.membership.role }}`
|
||||||
|
|
||||||
|
`updatedFrom` is present only on update events; it is absent for created, deleted, and other lifecycle events.
|
||||||
|
|
||||||
### Alternative: list open tasks on a schedule
|
### Alternative: list open tasks on a schedule
|
||||||
|
|
||||||
Use the **Probo** action node without a trigger:
|
Use the **Probo** action node without a trigger:
|
||||||
|
|||||||
22
pkg/coredata/migrations/20260715T080636Z.sql
Normal file
22
pkg/coredata/migrations/20260715T080636Z.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
-- Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
--
|
||||||
|
-- Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
-- of this software and associated documentation files (the "Software"), to deal
|
||||||
|
-- in the Software without restriction, including without limitation the rights
|
||||||
|
-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
-- copies of the Software, and to permit persons to whom the Software is
|
||||||
|
-- furnished to do so, subject to the following conditions:
|
||||||
|
--
|
||||||
|
-- The above copyright notice and this permission notice shall be included in
|
||||||
|
-- all copies or substantial portions of the Software.
|
||||||
|
--
|
||||||
|
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
-- SOFTWARE.
|
||||||
|
|
||||||
|
ALTER TABLE webhook_data
|
||||||
|
ADD COLUMN updated_from JSONB;
|
||||||
@@ -39,6 +39,7 @@ type (
|
|||||||
OrganizationID gid.GID `db:"organization_id"`
|
OrganizationID gid.GID `db:"organization_id"`
|
||||||
EventType WebhookEventType `db:"event_type"`
|
EventType WebhookEventType `db:"event_type"`
|
||||||
Data json.RawMessage `db:"data"`
|
Data json.RawMessage `db:"data"`
|
||||||
|
UpdatedFrom json.RawMessage `db:"updated_from"`
|
||||||
CreatedAt time.Time `db:"created_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
ProcessedAt *time.Time `db:"processed_at"`
|
ProcessedAt *time.Time `db:"processed_at"`
|
||||||
}
|
}
|
||||||
@@ -58,6 +59,7 @@ INSERT INTO webhook_data (
|
|||||||
organization_id,
|
organization_id,
|
||||||
event_type,
|
event_type,
|
||||||
data,
|
data,
|
||||||
|
updated_from,
|
||||||
created_at
|
created_at
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
@@ -66,6 +68,7 @@ VALUES (
|
|||||||
@organization_id,
|
@organization_id,
|
||||||
@event_type,
|
@event_type,
|
||||||
@data,
|
@data,
|
||||||
|
@updated_from,
|
||||||
@created_at
|
@created_at
|
||||||
)
|
)
|
||||||
`
|
`
|
||||||
@@ -76,6 +79,7 @@ VALUES (
|
|||||||
"organization_id": w.OrganizationID,
|
"organization_id": w.OrganizationID,
|
||||||
"event_type": w.EventType,
|
"event_type": w.EventType,
|
||||||
"data": w.Data,
|
"data": w.Data,
|
||||||
|
"updated_from": w.UpdatedFrom,
|
||||||
"created_at": w.CreatedAt,
|
"created_at": w.CreatedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +101,7 @@ SELECT
|
|||||||
organization_id,
|
organization_id,
|
||||||
event_type,
|
event_type,
|
||||||
data,
|
data,
|
||||||
|
updated_from,
|
||||||
created_at,
|
created_at,
|
||||||
processed_at
|
processed_at
|
||||||
FROM webhook_data
|
FROM webhook_data
|
||||||
|
|||||||
@@ -293,6 +293,8 @@ func (s *OrganizationService) UpdateMembership(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previousUser := webhooktypes.NewUser(profile, &membership)
|
||||||
|
|
||||||
membership.Role = role
|
membership.Role = role
|
||||||
membership.UpdatedAt = time.Now()
|
membership.UpdatedAt = time.Now()
|
||||||
|
|
||||||
@@ -300,7 +302,7 @@ func (s *OrganizationService) UpdateMembership(
|
|||||||
return fmt.Errorf("cannot update membership: %w", err)
|
return fmt.Errorf("cannot update membership: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := webhook.InsertData(ctx, tx, scope, organizationID, coredata.WebhookEventTypeUserUpdated, webhooktypes.NewUser(profile, &membership)); err != nil {
|
if err := webhook.InsertUpdateData(ctx, tx, scope, organizationID, coredata.WebhookEventTypeUserUpdated, webhooktypes.NewUser(profile, &membership), previousUser); err != nil {
|
||||||
return fmt.Errorf("cannot insert webhook event: %w", err)
|
return fmt.Errorf("cannot insert webhook event: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,6 +441,8 @@ func (s *OrganizationService) ArchiveUser(
|
|||||||
return fmt.Errorf("cannot delete requested signatures: %w", err)
|
return fmt.Errorf("cannot delete requested signatures: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previousUser := webhooktypes.NewUser(&profile, membership)
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
if profile.State != coredata.ProfileStateInactive {
|
if profile.State != coredata.ProfileStateInactive {
|
||||||
@@ -455,7 +459,7 @@ func (s *OrganizationService) ArchiveUser(
|
|||||||
return fmt.Errorf("cannot update membership: %w", err)
|
return fmt.Errorf("cannot update membership: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := webhook.InsertData(ctx, tx, scope, profile.OrganizationID, coredata.WebhookEventTypeUserUpdated, webhooktypes.NewUser(&profile, membership)); err != nil {
|
if err := webhook.InsertUpdateData(ctx, tx, scope, profile.OrganizationID, coredata.WebhookEventTypeUserUpdated, webhooktypes.NewUser(&profile, membership), previousUser); err != nil {
|
||||||
return fmt.Errorf("cannot insert webhook event: %w", err)
|
return fmt.Errorf("cannot insert webhook event: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1099,6 +1103,8 @@ func (s *OrganizationService) UpdateUser(ctx context.Context, req *UpdateUserReq
|
|||||||
return fmt.Errorf("cannot load profile: %w", err)
|
return fmt.Errorf("cannot load profile: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previousProfile := *profile
|
||||||
|
|
||||||
if profile.Source != coredata.ProfileSourceSCIM {
|
if profile.Source != coredata.ProfileSourceSCIM {
|
||||||
profile.FullName = req.FullName
|
profile.FullName = req.FullName
|
||||||
profile.Kind = req.Kind
|
profile.Kind = req.Kind
|
||||||
@@ -1130,7 +1136,10 @@ func (s *OrganizationService) UpdateUser(ctx context.Context, req *UpdateUserReq
|
|||||||
|
|
||||||
membership := &coredata.Membership{}
|
membership := &coredata.Membership{}
|
||||||
|
|
||||||
var webhookPayload *webhooktypes.User
|
var (
|
||||||
|
webhookPayload *webhooktypes.User
|
||||||
|
previousUser *webhooktypes.User
|
||||||
|
)
|
||||||
|
|
||||||
if err := membership.LoadByIdentityIDAndOrganizationID(ctx, conn, scope, profile.IdentityID, profile.OrganizationID); err != nil {
|
if err := membership.LoadByIdentityIDAndOrganizationID(ctx, conn, scope, profile.IdentityID, profile.OrganizationID); err != nil {
|
||||||
if !errors.Is(err, coredata.ErrResourceNotFound) {
|
if !errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
@@ -1138,16 +1147,20 @@ func (s *OrganizationService) UpdateUser(ctx context.Context, req *UpdateUserReq
|
|||||||
}
|
}
|
||||||
|
|
||||||
webhookPayload = webhooktypes.NewUser(profile, nil)
|
webhookPayload = webhooktypes.NewUser(profile, nil)
|
||||||
|
previousUser = webhooktypes.NewUser(&previousProfile, nil)
|
||||||
} else {
|
} else {
|
||||||
webhookPayload = webhooktypes.NewUser(profile, membership)
|
webhookPayload = webhooktypes.NewUser(profile, membership)
|
||||||
|
previousUser = webhooktypes.NewUser(&previousProfile, membership)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := webhook.InsertData(ctx,
|
if err := webhook.InsertUpdateData(
|
||||||
|
ctx,
|
||||||
conn,
|
conn,
|
||||||
scope,
|
scope,
|
||||||
profile.OrganizationID,
|
profile.OrganizationID,
|
||||||
coredata.WebhookEventTypeUserUpdated,
|
coredata.WebhookEventTypeUserUpdated,
|
||||||
webhookPayload,
|
webhookPayload,
|
||||||
|
previousUser,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot insert webhook event: %w", err)
|
return fmt.Errorf("cannot insert webhook event: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ func (s *Service) CreateUser(
|
|||||||
eventType := coredata.WebhookEventTypeUserUpdated
|
eventType := coredata.WebhookEventTypeUserUpdated
|
||||||
profile = &coredata.MembershipProfile{}
|
profile = &coredata.MembershipProfile{}
|
||||||
|
|
||||||
|
var previousProfile *coredata.MembershipProfile
|
||||||
|
|
||||||
if err := profile.LoadByIdentityIDAndOrganizationID(
|
if err := profile.LoadByIdentityIDAndOrganizationID(
|
||||||
ctx,
|
ctx,
|
||||||
tx,
|
tx,
|
||||||
@@ -214,6 +216,9 @@ func (s *Service) CreateUser(
|
|||||||
*externalIdPtr,
|
*externalIdPtr,
|
||||||
config.OrganizationID,
|
config.OrganizationID,
|
||||||
); err == nil {
|
); err == nil {
|
||||||
|
snapshot := *profile
|
||||||
|
previousProfile = &snapshot
|
||||||
|
|
||||||
// Migrate the existing membership to the new identity
|
// Migrate the existing membership to the new identity
|
||||||
// so the user's role is preserved.
|
// so the user's role is preserved.
|
||||||
oldIdentityID := profile.IdentityID
|
oldIdentityID := profile.IdentityID
|
||||||
@@ -270,6 +275,11 @@ func (s *Service) CreateUser(
|
|||||||
eventType = coredata.WebhookEventTypeUserCreated
|
eventType = coredata.WebhookEventTypeUserCreated
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if previousProfile == nil {
|
||||||
|
snapshot := *profile
|
||||||
|
previousProfile = &snapshot
|
||||||
|
}
|
||||||
|
|
||||||
if profile.Source == coredata.ProfileSourceSCIM {
|
if profile.Source == coredata.ProfileSourceSCIM {
|
||||||
return scimerrors.ScimErrorUniqueness
|
return scimerrors.ScimErrorUniqueness
|
||||||
}
|
}
|
||||||
@@ -340,7 +350,12 @@ func (s *Service) CreateUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := webhook.InsertData(ctx, tx, scope, config.OrganizationID, eventType, webhooktypes.NewUser(profile, membership)); err != nil {
|
var previousUser any
|
||||||
|
if eventType == coredata.WebhookEventTypeUserUpdated && previousProfile != nil {
|
||||||
|
previousUser = webhooktypes.NewUser(previousProfile, membership)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := webhook.InsertUpdateData(ctx, tx, scope, config.OrganizationID, eventType, webhooktypes.NewUser(profile, membership), previousUser); err != nil {
|
||||||
return fmt.Errorf("cannot insert webhook event: %w", err)
|
return fmt.Errorf("cannot insert webhook event: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,6 +532,10 @@ func (s *Service) updateUser(
|
|||||||
return fmt.Errorf("cannot load membership: %w", err)
|
return fmt.Errorf("cannot load membership: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previousProfile := *profile
|
||||||
|
previousMembership := *membership
|
||||||
|
previousUser := webhooktypes.NewUser(&previousProfile, &previousMembership)
|
||||||
|
|
||||||
shouldReactivate := attrs.Active != nil && *attrs.Active && profile.State == coredata.ProfileStateInactive
|
shouldReactivate := attrs.Active != nil && *attrs.Active && profile.State == coredata.ProfileStateInactive
|
||||||
shouldDeactivate := attrs.Active != nil && !*attrs.Active && profile.State == coredata.ProfileStateActive
|
shouldDeactivate := attrs.Active != nil && !*attrs.Active && profile.State == coredata.ProfileStateActive
|
||||||
|
|
||||||
@@ -785,7 +804,7 @@ func (s *Service) updateUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := webhook.InsertData(ctx, tx, scope, config.OrganizationID, coredata.WebhookEventTypeUserUpdated, webhooktypes.NewUser(profile, membership)); err != nil {
|
if err := webhook.InsertUpdateData(ctx, tx, scope, config.OrganizationID, coredata.WebhookEventTypeUserUpdated, webhooktypes.NewUser(profile, membership), previousUser); err != nil {
|
||||||
return fmt.Errorf("cannot insert webhook event: %w", err)
|
return fmt.Errorf("cannot insert webhook event: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -931,6 +950,8 @@ func (s *Service) deactivateProfileInTx(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previousUser := webhooktypes.NewUser(profile, membership)
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
profile.State = coredata.ProfileStateInactive
|
profile.State = coredata.ProfileStateInactive
|
||||||
profile.UpdatedAt = now
|
profile.UpdatedAt = now
|
||||||
@@ -964,7 +985,7 @@ func (s *Service) deactivateProfileInTx(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := webhook.InsertData(ctx, tx, scope, config.OrganizationID, coredata.WebhookEventTypeUserUpdated, webhooktypes.NewUser(profile, membership)); err != nil {
|
if err := webhook.InsertUpdateData(ctx, tx, scope, config.OrganizationID, coredata.WebhookEventTypeUserUpdated, webhooktypes.NewUser(profile, membership), previousUser); err != nil {
|
||||||
return fmt.Errorf("cannot insert webhook event: %w", err)
|
return fmt.Errorf("cannot insert webhook event: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ func (s *DocumentApprovalService) BulkPublishVersions(
|
|||||||
dv,
|
dv,
|
||||||
nil,
|
nil,
|
||||||
&requestedQuorum.ID,
|
&requestedQuorum.ID,
|
||||||
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot emit approval quorum requested webhook: %w", err)
|
return fmt.Errorf("cannot emit approval quorum requested webhook: %w", err)
|
||||||
}
|
}
|
||||||
@@ -241,6 +242,7 @@ func (s *DocumentApprovalService) BulkPublishVersions(
|
|||||||
dv,
|
dv,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot emit version published webhook: %w", err)
|
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)
|
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.State = coredata.DocumentVersionApprovalDecisionStateApproved
|
||||||
decision.Comment = req.Comment
|
decision.Comment = req.Comment
|
||||||
decision.ElectronicSignatureID = &esig.ID
|
decision.ElectronicSignatureID = &esig.ID
|
||||||
@@ -433,6 +462,7 @@ func (s *DocumentApprovalService) Approve(
|
|||||||
documentVersion,
|
documentVersion,
|
||||||
nil,
|
nil,
|
||||||
&quorum.ID,
|
&quorum.ID,
|
||||||
|
updatedFromQuorum,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot emit approval quorum updated webhook: %w", err)
|
return fmt.Errorf("cannot emit approval quorum updated webhook: %w", err)
|
||||||
}
|
}
|
||||||
@@ -531,6 +561,7 @@ func (s *DocumentApprovalService) Reject(
|
|||||||
documentVersion,
|
documentVersion,
|
||||||
nil,
|
nil,
|
||||||
&quorum.ID,
|
&quorum.ID,
|
||||||
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot emit approval quorum rejected webhook: %w", err)
|
return fmt.Errorf("cannot emit approval quorum rejected webhook: %w", err)
|
||||||
}
|
}
|
||||||
@@ -544,6 +575,7 @@ func (s *DocumentApprovalService) Reject(
|
|||||||
documentVersion,
|
documentVersion,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot emit document version rejected webhook: %w", err)
|
return fmt.Errorf("cannot emit document version rejected webhook: %w", err)
|
||||||
}
|
}
|
||||||
@@ -635,6 +667,7 @@ func (s *DocumentApprovalService) VoidApproval(
|
|||||||
documentVersion,
|
documentVersion,
|
||||||
nil,
|
nil,
|
||||||
&quorum.ID,
|
&quorum.ID,
|
||||||
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot emit approval quorum voided webhook: %w", err)
|
return fmt.Errorf("cannot emit approval quorum voided webhook: %w", err)
|
||||||
}
|
}
|
||||||
@@ -998,6 +1031,7 @@ func (s *DocumentApprovalService) maybeApproveQuorum(
|
|||||||
version,
|
version,
|
||||||
nil,
|
nil,
|
||||||
&quorum.ID,
|
&quorum.ID,
|
||||||
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot emit approval quorum approved webhook: %w", err)
|
return fmt.Errorf("cannot emit approval quorum approved webhook: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1011,6 +1045,7 @@ func (s *DocumentApprovalService) maybeApproveQuorum(
|
|||||||
version,
|
version,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot emit document version published webhook: %w", err)
|
return fmt.Errorf("cannot emit document version published webhook: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -628,6 +628,7 @@ func (s *DocumentService) PublishVersion(
|
|||||||
version,
|
version,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot emit document version published webhook: %w", err)
|
return fmt.Errorf("cannot emit document version published webhook: %w", err)
|
||||||
}
|
}
|
||||||
@@ -653,6 +654,7 @@ func (s *DocumentService) PublishVersion(
|
|||||||
version,
|
version,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot emit document version published webhook: %w", err)
|
return fmt.Errorf("cannot emit document version published webhook: %w", err)
|
||||||
}
|
}
|
||||||
@@ -708,6 +710,7 @@ func (s *DocumentService) PublishVersion(
|
|||||||
dv,
|
dv,
|
||||||
nil,
|
nil,
|
||||||
&quorum.ID,
|
&quorum.ID,
|
||||||
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot emit approval quorum requested webhook: %w", err)
|
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)
|
return fmt.Errorf("cannot emit document created webhook: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,6 +821,7 @@ func (s *DocumentService) Create(
|
|||||||
documentVersion,
|
documentVersion,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot emit document version created webhook: %w", err)
|
return fmt.Errorf("cannot emit document version created webhook: %w", err)
|
||||||
}
|
}
|
||||||
@@ -993,6 +997,7 @@ func (s *DocumentService) SignDocumentVersionByIdentity(
|
|||||||
documentVersion,
|
documentVersion,
|
||||||
documentVersionSignature,
|
documentVersionSignature,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot emit document version signature signed webhook: %w", err)
|
return fmt.Errorf("cannot emit document version signature signed webhook: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1122,6 +1127,7 @@ func (s *DocumentService) BulkRequestSignatures(
|
|||||||
documentVersion,
|
documentVersion,
|
||||||
signature,
|
signature,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot emit signature requested webhook: %w", err)
|
return fmt.Errorf("cannot emit signature requested webhook: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1258,6 +1264,7 @@ func (s *DocumentService) RequestSignature(
|
|||||||
documentVersion,
|
documentVersion,
|
||||||
signature,
|
signature,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot emit document version signature requested webhook: %w", err)
|
return fmt.Errorf("cannot emit document version signature requested webhook: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1378,13 +1385,14 @@ func (s *DocumentService) emitDocumentEventInTx(
|
|||||||
version *coredata.DocumentVersion,
|
version *coredata.DocumentVersion,
|
||||||
signature *coredata.DocumentVersionSignature,
|
signature *coredata.DocumentVersionSignature,
|
||||||
quorumID *gid.GID,
|
quorumID *gid.GID,
|
||||||
|
updatedFrom any,
|
||||||
) error {
|
) error {
|
||||||
document := &coredata.Document{}
|
document := &coredata.Document{}
|
||||||
if err := document.LoadByID(ctx, tx, scope, documentID); err != nil {
|
if err := document.LoadByID(ctx, tx, scope, documentID); err != nil {
|
||||||
return fmt.Errorf("cannot load document for %q webhook: %w", eventType, err)
|
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(
|
func (s *DocumentService) emitLoadedDocumentEventInTx(
|
||||||
@@ -1395,6 +1403,7 @@ func (s *DocumentService) emitLoadedDocumentEventInTx(
|
|||||||
version *coredata.DocumentVersion,
|
version *coredata.DocumentVersion,
|
||||||
signature *coredata.DocumentVersionSignature,
|
signature *coredata.DocumentVersionSignature,
|
||||||
quorumID *gid.GID,
|
quorumID *gid.GID,
|
||||||
|
updatedFrom any,
|
||||||
) error {
|
) error {
|
||||||
subscriptions := coredata.WebhookSubscriptions{}
|
subscriptions := coredata.WebhookSubscriptions{}
|
||||||
|
|
||||||
@@ -1423,13 +1432,14 @@ func (s *DocumentService) emitLoadedDocumentEventInTx(
|
|||||||
payload = webhooktypes.NewDocument(document)
|
payload = webhooktypes.NewDocument(document)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := webhook.InsertData(
|
if err := webhook.InsertUpdateData(
|
||||||
ctx,
|
ctx,
|
||||||
tx,
|
tx,
|
||||||
scope,
|
scope,
|
||||||
document.OrganizationID,
|
document.OrganizationID,
|
||||||
eventType,
|
eventType,
|
||||||
payload,
|
payload,
|
||||||
|
updatedFrom,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot insert %q webhook event: %w", eventType, err)
|
return fmt.Errorf("cannot insert %q webhook event: %w", eventType, err)
|
||||||
}
|
}
|
||||||
@@ -1533,7 +1543,7 @@ func (s *DocumentService) SoftDelete(
|
|||||||
return s.svc.pg.WithTx(
|
return s.svc.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, tx pg.Tx) error {
|
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)
|
return fmt.Errorf("cannot emit document deleted webhook: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2134,6 +2144,8 @@ func (s *DocumentService) Update(
|
|||||||
return &ErrDocumentArchived{}
|
return &ErrDocumentArchived{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previousDocument := *document
|
||||||
|
|
||||||
if req.TrustCenterVisibility != nil {
|
if req.TrustCenterVisibility != nil {
|
||||||
document.TrustCenterVisibility = *req.TrustCenterVisibility
|
document.TrustCenterVisibility = *req.TrustCenterVisibility
|
||||||
}
|
}
|
||||||
@@ -2172,8 +2184,13 @@ func (s *DocumentService) Update(
|
|||||||
|
|
||||||
versionDeleted := false
|
versionDeleted := false
|
||||||
|
|
||||||
|
var versionPrevious any
|
||||||
|
|
||||||
if hasVersionChanges {
|
if hasVersionChanges {
|
||||||
if latestVersion.Status == coredata.DocumentVersionStatusDraft {
|
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 {
|
if err := s.updateVersionInTx(ctx, scope, tx, latestVersion, req.Content, req.Classification, req.DocumentType, req.Title); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -2231,6 +2248,7 @@ func (s *DocumentService) Update(
|
|||||||
latestVersion,
|
latestVersion,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot emit document version deleted webhook: %w", err)
|
return fmt.Errorf("cannot emit document version deleted webhook: %w", err)
|
||||||
}
|
}
|
||||||
@@ -2238,16 +2256,17 @@ func (s *DocumentService) Update(
|
|||||||
versionEvent := coredata.WebhookEventTypeDocumentVersionUpdated
|
versionEvent := coredata.WebhookEventTypeDocumentVersionUpdated
|
||||||
if draftCreated {
|
if draftCreated {
|
||||||
versionEvent = coredata.WebhookEventTypeDocumentVersionCreated
|
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)
|
return fmt.Errorf("cannot emit document version webhook: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if docLevelChanged {
|
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)
|
return fmt.Errorf("cannot emit document updated webhook: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2305,6 +2324,7 @@ func (s *DocumentService) DeleteDraft(
|
|||||||
latestVersion,
|
latestVersion,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot emit document version deleted webhook: %w", err)
|
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)
|
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)
|
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)
|
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)
|
return fmt.Errorf("cannot emit document unarchived webhook: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2466,6 +2486,7 @@ func (s *DocumentService) CancelSignatureRequest(
|
|||||||
documentVersion,
|
documentVersion,
|
||||||
documentVersionSignature,
|
documentVersionSignature,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot emit document version signature cancelled webhook: %w", err)
|
return fmt.Errorf("cannot emit document version signature cancelled webhook: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3296,7 +3296,7 @@ func (s *GeneratedDocumentService) publishOrRequestApproval(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if isFirstVersion {
|
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)
|
return fmt.Errorf("cannot emit document created webhook: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3310,6 +3310,7 @@ func (s *GeneratedDocumentService) publishOrRequestApproval(
|
|||||||
version,
|
version,
|
||||||
nil,
|
nil,
|
||||||
&quorum.ID,
|
&quorum.ID,
|
||||||
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot emit approval quorum requested webhook: %w", err)
|
return fmt.Errorf("cannot emit approval quorum requested webhook: %w", err)
|
||||||
}
|
}
|
||||||
@@ -3332,7 +3333,7 @@ func (s *GeneratedDocumentService) publishOrRequestApproval(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if isFirstVersion {
|
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)
|
return fmt.Errorf("cannot emit document created webhook: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3346,6 +3347,7 @@ func (s *GeneratedDocumentService) publishOrRequestApproval(
|
|||||||
version,
|
version,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot emit document version published webhook: %w", err)
|
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)
|
return fmt.Errorf("cannot load obligation: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previousObligation := webhooktypes.NewObligation(obligation)
|
||||||
|
|
||||||
if req.Area != nil {
|
if req.Area != nil {
|
||||||
obligation.Area = *req.Area
|
obligation.Area = *req.Area
|
||||||
}
|
}
|
||||||
@@ -249,7 +251,7 @@ func (s *ObligationService) Update(
|
|||||||
return fmt.Errorf("cannot update obligation: %w", err)
|
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)
|
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)
|
return fmt.Errorf("cannot load thirdParty %q: %w", req.ID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previousThirdParty := webhooktypes.NewThirdParty(thirdParty)
|
||||||
|
|
||||||
if req.Name != nil {
|
if req.Name != nil {
|
||||||
thirdParty.Name = *req.Name
|
thirdParty.Name = *req.Name
|
||||||
}
|
}
|
||||||
@@ -478,13 +480,14 @@ func (s ThirdPartyService) Update(
|
|||||||
return fmt.Errorf("cannot update thirdParty: %w", err)
|
return fmt.Errorf("cannot update thirdParty: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := webhook.InsertData(
|
if err := webhook.InsertUpdateData(
|
||||||
ctx,
|
ctx,
|
||||||
conn,
|
conn,
|
||||||
scope,
|
scope,
|
||||||
thirdParty.OrganizationID,
|
thirdParty.OrganizationID,
|
||||||
coredata.WebhookEventTypeThirdPartyUpdated,
|
coredata.WebhookEventTypeThirdPartyUpdated,
|
||||||
webhooktypes.NewThirdParty(thirdParty),
|
webhooktypes.NewThirdParty(thirdParty),
|
||||||
|
previousThirdParty,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot insert webhook event: %w", err)
|
return fmt.Errorf("cannot insert webhook event: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type Payload struct {
|
|||||||
EventType string `json:"eventType"`
|
EventType string `json:"eventType"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
Data json.RawMessage `json:"data"`
|
Data json.RawMessage `json:"data"`
|
||||||
|
UpdatedFrom json.RawMessage `json:"updatedFrom,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func InsertData(
|
func InsertData(
|
||||||
@@ -47,6 +48,22 @@ func InsertData(
|
|||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
eventType coredata.WebhookEventType,
|
eventType coredata.WebhookEventType,
|
||||||
data any,
|
data any,
|
||||||
|
) error {
|
||||||
|
if err := InsertUpdateData(ctx, tx, scope, organizationID, eventType, data, nil); err != nil {
|
||||||
|
return fmt.Errorf("cannot insert webhook data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func InsertUpdateData(
|
||||||
|
ctx context.Context,
|
||||||
|
tx pg.Tx,
|
||||||
|
scope coredata.Scoper,
|
||||||
|
organizationID gid.GID,
|
||||||
|
eventType coredata.WebhookEventType,
|
||||||
|
data any,
|
||||||
|
updatedFrom any,
|
||||||
) error {
|
) error {
|
||||||
var configs coredata.WebhookSubscriptions
|
var configs coredata.WebhookSubscriptions
|
||||||
|
|
||||||
@@ -64,11 +81,20 @@ func InsertData(
|
|||||||
return fmt.Errorf("cannot marshal webhook event data: %w", err)
|
return fmt.Errorf("cannot marshal webhook event data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var updatedFromRaw json.RawMessage
|
||||||
|
if updatedFrom != nil {
|
||||||
|
updatedFromRaw, err = json.Marshal(updatedFrom)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot marshal webhook event updated-from data: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
webhookData := &coredata.WebhookData{
|
webhookData := &coredata.WebhookData{
|
||||||
ID: gid.New(scope.GetTenantID(), coredata.WebhookDataEntityType),
|
ID: gid.New(scope.GetTenantID(), coredata.WebhookDataEntityType),
|
||||||
OrganizationID: organizationID,
|
OrganizationID: organizationID,
|
||||||
EventType: eventType,
|
EventType: eventType,
|
||||||
Data: raw,
|
Data: raw,
|
||||||
|
UpdatedFrom: updatedFromRaw,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
241
pkg/webhook/data_test.go
Normal file
241
pkg/webhook/data_test.go
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in
|
||||||
|
// all copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package webhook_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.gearno.de/kit/pg"
|
||||||
|
"go.probo.inc/probo/internal/test"
|
||||||
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
"go.probo.inc/probo/pkg/gid"
|
||||||
|
"go.probo.inc/probo/pkg/webhook"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInsertUpdateData_PersistsUpdatedFromSnapshot(t *testing.T) {
|
||||||
|
client := test.PGClient(t)
|
||||||
|
orgID := insertTestOrganization(t, client)
|
||||||
|
scope := coredata.NewScope(orgID.TenantID())
|
||||||
|
|
||||||
|
insertTestSubscription(t, client, orgID, coredata.WebhookEventTypeUserUpdated)
|
||||||
|
|
||||||
|
current := map[string]any{"role": "OWNER"}
|
||||||
|
updatedFrom := map[string]any{"role": "ADMIN"}
|
||||||
|
|
||||||
|
err := client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error {
|
||||||
|
return webhook.InsertUpdateData(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
scope,
|
||||||
|
orgID,
|
||||||
|
coredata.WebhookEventTypeUserUpdated,
|
||||||
|
current,
|
||||||
|
updatedFrom,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
data, updatedFromData := loadWebhookData(t, client, orgID)
|
||||||
|
|
||||||
|
assert.JSONEq(t, `{"role":"OWNER"}`, string(data))
|
||||||
|
require.NotNil(t, updatedFromData, "updated_from must be persisted for update events")
|
||||||
|
assert.JSONEq(t, `{"role":"ADMIN"}`, string(updatedFromData))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInsertData_StoresNullUpdatedFrom(t *testing.T) {
|
||||||
|
client := test.PGClient(t)
|
||||||
|
orgID := insertTestOrganization(t, client)
|
||||||
|
scope := coredata.NewScope(orgID.TenantID())
|
||||||
|
|
||||||
|
insertTestSubscription(t, client, orgID, coredata.WebhookEventTypeObligationUpdated)
|
||||||
|
|
||||||
|
err := client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error {
|
||||||
|
return webhook.InsertData(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
scope,
|
||||||
|
orgID,
|
||||||
|
coredata.WebhookEventTypeObligationUpdated,
|
||||||
|
map[string]any{"status": "OPEN"},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
data, updatedFromData := loadWebhookData(t, client, orgID)
|
||||||
|
|
||||||
|
assert.JSONEq(t, `{"status":"OPEN"}`, string(data))
|
||||||
|
assert.Nil(t, updatedFromData, "updated_from must be SQL NULL when no previous snapshot is provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInsertUpdateData_NoSubscriptionIsNoop(t *testing.T) {
|
||||||
|
client := test.PGClient(t)
|
||||||
|
orgID := insertTestOrganization(t, client)
|
||||||
|
scope := coredata.NewScope(orgID.TenantID())
|
||||||
|
|
||||||
|
// Subscribe to a different event than the one we emit.
|
||||||
|
insertTestSubscription(t, client, orgID, coredata.WebhookEventTypeObligationUpdated)
|
||||||
|
|
||||||
|
err := client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error {
|
||||||
|
return webhook.InsertUpdateData(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
scope,
|
||||||
|
orgID,
|
||||||
|
coredata.WebhookEventTypeUserUpdated,
|
||||||
|
map[string]any{"role": "OWNER"},
|
||||||
|
map[string]any{"role": "ADMIN"},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, countWebhookData(t, client, orgID), "no webhook_data row should be enqueued without a matching subscription")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPayload_UpdatedFromOmittedWhenAbsent(t *testing.T) {
|
||||||
|
withUpdatedFrom, err := json.Marshal(webhook.Payload{
|
||||||
|
EventType: "user:updated",
|
||||||
|
Data: json.RawMessage(`{"role":"OWNER"}`),
|
||||||
|
UpdatedFrom: json.RawMessage(`{"role":"ADMIN"}`),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(withUpdatedFrom), `"updatedFrom":{"role":"ADMIN"}`)
|
||||||
|
|
||||||
|
withoutUpdatedFrom, err := json.Marshal(webhook.Payload{
|
||||||
|
EventType: "user:created",
|
||||||
|
Data: json.RawMessage(`{"role":"OWNER"}`),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotContains(t, string(withoutUpdatedFrom), "updatedFrom")
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertTestOrganization(t *testing.T, client *pg.Client) gid.GID {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tenantID := gid.NewTenantID()
|
||||||
|
orgID := gid.New(tenantID, coredata.OrganizationEntityType)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
err := client.WithConn(
|
||||||
|
context.Background(),
|
||||||
|
func(ctx context.Context, conn pg.Querier) error {
|
||||||
|
_, err := conn.Exec(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO organizations (id, tenant_id, name, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
orgID.String(),
|
||||||
|
tenantID.String(),
|
||||||
|
"test-org-"+orgID.String(),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_ = client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||||
|
_, err := conn.Exec(ctx, "DELETE FROM organizations WHERE id = $1", orgID.String())
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return orgID
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertTestSubscription(
|
||||||
|
t *testing.T,
|
||||||
|
client *pg.Client,
|
||||||
|
orgID gid.GID,
|
||||||
|
events ...coredata.WebhookEventType,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
subscription := coredata.WebhookSubscription{
|
||||||
|
ID: gid.New(orgID.TenantID(), coredata.WebhookSubscriptionEntityType),
|
||||||
|
OrganizationID: orgID,
|
||||||
|
EndpointURL: "https://example.test/webhook",
|
||||||
|
SelectedEvents: coredata.WebhookEventTypes(events),
|
||||||
|
EncryptedSigningSecret: []byte("test-signing-secret"),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := client.WithTx(
|
||||||
|
context.Background(),
|
||||||
|
func(ctx context.Context, tx pg.Tx) error {
|
||||||
|
return subscription.Insert(ctx, tx, coredata.NewScope(orgID.TenantID()))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadWebhookData(t *testing.T, client *pg.Client, orgID gid.GID) (json.RawMessage, json.RawMessage) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var (
|
||||||
|
data []byte
|
||||||
|
updatedFrom []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
err := client.WithConn(
|
||||||
|
context.Background(),
|
||||||
|
func(ctx context.Context, conn pg.Querier) error {
|
||||||
|
return conn.QueryRow(
|
||||||
|
ctx,
|
||||||
|
"SELECT data, updated_from FROM webhook_data WHERE organization_id = $1",
|
||||||
|
orgID.String(),
|
||||||
|
).Scan(&data, &updatedFrom)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return data, updatedFrom
|
||||||
|
}
|
||||||
|
|
||||||
|
func countWebhookData(t *testing.T, client *pg.Client, orgID gid.GID) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var count int
|
||||||
|
|
||||||
|
err := client.WithConn(
|
||||||
|
context.Background(),
|
||||||
|
func(ctx context.Context, conn pg.Querier) error {
|
||||||
|
return conn.QueryRow(
|
||||||
|
ctx,
|
||||||
|
"SELECT COUNT(*) FROM webhook_data WHERE organization_id = $1",
|
||||||
|
orgID.String(),
|
||||||
|
).Scan(&count)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
@@ -321,6 +321,7 @@ func (h *webhookHandler) doHTTPCall(
|
|||||||
EventType: webhookData.EventType.String(),
|
EventType: webhookData.EventType.String(),
|
||||||
CreatedAt: webhookData.CreatedAt,
|
CreatedAt: webhookData.CreatedAt,
|
||||||
Data: webhookData.Data,
|
Data: webhookData.Data,
|
||||||
|
UpdatedFrom: webhookData.UpdatedFrom,
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := json.Marshal(payload)
|
body, err := json.Marshal(payload)
|
||||||
|
|||||||
Reference in New Issue
Block a user