@learncard/network-brain-service 3.19.1 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +44 -111
- package/AGENTS.md +52 -0
- package/CHANGELOG.md +120 -0
- package/README.md +9 -2
- package/bootstrapLambda.test.ts +72 -0
- package/bundle.mjs +9 -1
- package/config/config.dev.json +29 -0
- package/config/config.example.json +29 -0
- package/config/config.json +1 -0
- package/config/config.production.json +29 -0
- package/config/config.scouts.dev.json +24 -0
- package/config/config.scouts.production.json +24 -0
- package/contractEventsLambda.ts +18 -0
- package/contractEventsLambdaApp.ts +19 -0
- package/didWebLambda.ts +13 -18
- package/didWebLambdaApp.ts +22 -0
- package/dist/accesslayer/app-manifest-version/create.d.ts +10 -0
- package/dist/accesslayer/app-manifest-version/create.d.ts.map +1 -0
- package/dist/accesslayer/app-manifest-version/read.d.ts +11 -0
- package/dist/accesslayer/app-manifest-version/read.d.ts.map +1 -0
- package/dist/accesslayer/app-manifest-version/update.d.ts +7 -0
- package/dist/accesslayer/app-manifest-version/update.d.ts.map +1 -0
- package/dist/accesslayer/app-store-listing/read.d.ts +2 -1
- package/dist/accesslayer/app-store-listing/read.d.ts.map +1 -1
- package/dist/accesslayer/app-store-listing/relationships/create.d.ts +1 -0
- package/dist/accesslayer/app-store-listing/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/app-store-listing/relationships/read.d.ts +2 -0
- package/dist/accesslayer/app-store-listing/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/app-store-listing/update.d.ts +1 -0
- package/dist/accesslayer/app-store-listing/update.d.ts.map +1 -1
- package/dist/accesslayer/boost/relationships/create.d.ts +2 -1
- package/dist/accesslayer/boost/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/create.d.ts +9 -1
- package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/read.d.ts +34 -3
- package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +4 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/delete.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +3 -2
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts +12 -0
- package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts.map +1 -0
- package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts +12 -0
- package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts.map +1 -0
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +8 -5
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/credential/create.d.ts +2 -1
- package/dist/accesslayer/credential/create.d.ts.map +1 -1
- package/dist/accesslayer/credential/relationships/create.d.ts +4 -3
- package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/relationships/create.d.ts +2 -1
- package/dist/accesslayer/inbox-credential/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/update.d.ts +2 -1
- package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
- package/dist/accesslayer/share-link/abandon.d.ts.map +1 -1
- package/dist/accesslayer/share-link/attachment.d.ts +37 -0
- package/dist/accesslayer/share-link/attachment.d.ts.map +1 -0
- package/dist/accesslayer/share-link/finalize.d.ts.map +1 -1
- package/dist/accesslayer/share-link/helpers.d.ts +3 -0
- package/dist/accesslayer/share-link/helpers.d.ts.map +1 -1
- package/dist/accesslayer/share-link/recovery.d.ts.map +1 -1
- package/dist/accesslayer/share-link/reserve.d.ts.map +1 -1
- package/dist/accesslayer/share-link/revoke.d.ts.map +1 -1
- package/dist/accesslayer/share-link/types.d.ts +8 -3
- package/dist/accesslayer/share-link/types.d.ts.map +1 -1
- package/dist/cache/app-store.caches.d.ts +49 -0
- package/dist/cache/app-store.caches.d.ts.map +1 -1
- package/dist/config/applyDockerStageConfig.d.ts +2 -0
- package/dist/config/applyDockerStageConfig.d.ts.map +1 -0
- package/dist/config/environment.d.ts +5 -0
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/config/serverlessFunctionEnv.test.d.ts +2 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts.map +1 -0
- package/dist/config/stageConfig.d.ts +112 -0
- package/dist/config/stageConfig.d.ts.map +1 -0
- package/dist/config/stageConfig.test.d.ts +2 -0
- package/dist/config/stageConfig.test.d.ts.map +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/docker-entry.d.ts +1 -1
- package/dist/docker-entry.d.ts.map +1 -1
- package/dist/helpers/app-manifest.helpers.d.ts +5 -0
- package/dist/helpers/app-manifest.helpers.d.ts.map +1 -0
- package/dist/helpers/claim-hooks.helpers.d.ts +2 -1
- package/dist/helpers/claim-hooks.helpers.d.ts.map +1 -1
- package/dist/helpers/consent-referral.helpers.d.ts +2 -0
- package/dist/helpers/consent-referral.helpers.d.ts.map +1 -0
- package/dist/helpers/consent-scopes.helpers.d.ts +7 -0
- package/dist/helpers/consent-scopes.helpers.d.ts.map +1 -0
- package/dist/helpers/contract-event-maintenance.helpers.d.ts +3 -0
- package/dist/helpers/contract-event-maintenance.helpers.d.ts.map +1 -0
- package/dist/helpers/contract-events.helpers.d.ts +25 -0
- package/dist/helpers/contract-events.helpers.d.ts.map +1 -0
- package/dist/helpers/contract-requests.helpers.d.ts +19 -0
- package/dist/helpers/contract-requests.helpers.d.ts.map +1 -0
- package/dist/helpers/finalize-inbox.helpers.d.ts +3 -3
- package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/inline-template.helpers.d.ts +45 -0
- package/dist/helpers/inline-template.helpers.d.ts.map +1 -0
- package/dist/helpers/notificationMessages.d.ts +1 -1
- package/dist/helpers/notificationMessages.d.ts.map +1 -1
- package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
- package/dist/helpers/notifications.helpers.d.ts +6 -0
- package/dist/helpers/notifications.helpers.d.ts.map +1 -1
- package/dist/helpers/profile.helpers.d.ts +1 -0
- package/dist/helpers/profile.helpers.d.ts.map +1 -1
- package/dist/helpers/share-link-attachment-access.d.ts +4 -0
- package/dist/helpers/share-link-attachment-access.d.ts.map +1 -0
- package/dist/helpers/share-link-attachment.d.ts +18 -0
- package/dist/helpers/share-link-attachment.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/attachment-stat.d.ts +5 -0
- package/dist/helpers/share-link-coordinator/attachment-stat.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/coordinator.d.ts.map +1 -1
- package/dist/helpers/share-link-coordinator/recovery-runner.d.ts.map +1 -1
- package/dist/helpers/share-link-maintenance/runner.d.ts +1 -0
- package/dist/helpers/share-link-maintenance/runner.d.ts.map +1 -1
- package/dist/helpers/share-link-maintenance/runtime.d.ts +2 -0
- package/dist/helpers/share-link-maintenance/runtime.d.ts.map +1 -1
- package/dist/helpers/share-link-maintenance/telemetry.d.ts.map +1 -1
- package/dist/helpers/share-link-maintenance/types.d.ts +4 -1
- package/dist/helpers/share-link-maintenance/types.d.ts.map +1 -1
- package/dist/helpers/share-link-owner-projection.d.ts.map +1 -1
- package/dist/helpers/share-link-policy/production.d.ts +3 -5
- package/dist/helpers/share-link-policy/production.d.ts.map +1 -1
- package/dist/helpers/share-link-policy/resolver.d.ts +1 -1
- package/dist/helpers/share-link-policy/resolver.d.ts.map +1 -1
- package/dist/helpers/share-link-policy/types.d.ts +8 -2
- package/dist/helpers/share-link-policy/types.d.ts.map +1 -1
- package/dist/helpers/signingAuthority.helpers.d.ts.map +1 -1
- package/dist/helpers/smartResume.helpers.d.ts +3 -0
- package/dist/helpers/smartResume.helpers.d.ts.map +1 -0
- package/dist/index.js +2 -2
- package/dist/models/AppManifestVersion.d.ts +20 -0
- package/dist/models/AppManifestVersion.d.ts.map +1 -0
- package/dist/models/AppStoreListing.d.ts +13 -0
- package/dist/models/AppStoreListing.d.ts.map +1 -1
- package/dist/models/ConsentFlowContract.d.ts +13 -2
- package/dist/models/ConsentFlowContract.d.ts.map +1 -1
- package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
- package/dist/models/ConsentFlowTransaction.d.ts.map +1 -1
- package/dist/models/Integration.d.ts +2 -0
- package/dist/models/Integration.d.ts.map +1 -1
- package/dist/models/ShareLink.d.ts +2 -0
- package/dist/models/ShareLink.d.ts.map +1 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/models/share-link-constraints.d.ts.map +1 -1
- package/dist/openapi.d.ts +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/publish-metafile.json +1 -0
- package/dist/routes/app-store.d.ts +619 -0
- package/dist/routes/app-store.d.ts.map +1 -1
- package/dist/routes/boosts.d.ts +2 -2
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/contracts.d.ts +146 -3
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/credentials.d.ts +34 -0
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/inbox.d.ts +52 -2
- package/dist/routes/inbox.d.ts.map +1 -1
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/profile-manager.d.ts.map +1 -1
- package/dist/routes/profiles.d.ts.map +1 -1
- package/dist/routes/public-share-links.d.ts +55 -0
- package/dist/routes/public-share-links.d.ts.map +1 -1
- package/dist/routes/share-links.d.ts +175 -14
- package/dist/routes/share-links.d.ts.map +1 -1
- package/dist/routes/test.d.ts +5 -0
- package/dist/routes/test.d.ts.map +1 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/types/app-manifest.d.ts +339 -0
- package/dist/types/app-manifest.d.ts.map +1 -0
- package/dist/types/app-store-listing.d.ts +34 -0
- package/dist/types/app-store-listing.d.ts.map +1 -1
- package/dist/types/consentflowcontract.d.ts +30 -3
- package/dist/types/consentflowcontract.d.ts.map +1 -1
- package/dist/types/credential-refresh.d.ts +1 -1
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +49 -195
- package/lambdaApp.ts +203 -0
- package/package.json +20 -19
- package/project.json +8 -1
- package/scripts/export-openapi.ts +20 -0
- package/scripts/offline-schema-environment.ts +18 -0
- package/scripts/verify-python-contracts.ts +72 -0
- package/serverless-local.yml +3 -6
- package/serverless.function-env.cjs +50 -0
- package/serverless.local.yml +0 -1
- package/serverless.yml +29 -74
- package/shareLinkMaintenanceLambda.ts +13 -44
- package/shareLinkMaintenanceLambdaApp.ts +50 -0
- package/src/accesslayer/app-manifest-version/create.ts +57 -0
- package/src/accesslayer/app-manifest-version/read.ts +129 -0
- package/src/accesslayer/app-manifest-version/update.ts +61 -0
- package/src/accesslayer/app-store-listing/read.ts +6 -0
- package/src/accesslayer/app-store-listing/relationships/create.ts +20 -0
- package/src/accesslayer/app-store-listing/relationships/read.ts +24 -1
- package/src/accesslayer/app-store-listing/update.ts +15 -0
- package/src/accesslayer/boost/relationships/create.ts +8 -2
- package/src/accesslayer/consentflowcontract/create.ts +81 -8
- package/src/accesslayer/consentflowcontract/read.ts +84 -10
- package/src/accesslayer/consentflowcontract/relationships/create.ts +116 -93
- package/src/accesslayer/consentflowcontract/relationships/delete.ts +1 -0
- package/src/accesslayer/consentflowcontract/relationships/read.ts +184 -108
- package/src/accesslayer/consentflowcontract/relationships/recipients.ts +125 -0
- package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +125 -0
- package/src/accesslayer/consentflowcontract/relationships/update.ts +240 -189
- package/src/accesslayer/credential/create.test.ts +2 -115
- package/src/accesslayer/credential/create.ts +11 -6
- package/src/accesslayer/credential/relationships/create.ts +17 -10
- package/src/accesslayer/inbox-credential/relationships/create.ts +70 -48
- package/src/accesslayer/inbox-credential/update.ts +4 -2
- package/src/accesslayer/share-link/abandon.ts +20 -0
- package/src/accesslayer/share-link/attachment.ts +360 -0
- package/src/accesslayer/share-link/finalize.ts +55 -7
- package/src/accesslayer/share-link/helpers.ts +33 -2
- package/src/accesslayer/share-link/recovery.ts +20 -0
- package/src/accesslayer/share-link/reserve.ts +85 -6
- package/src/accesslayer/share-link/revoke.ts +35 -0
- package/src/accesslayer/share-link/types.ts +9 -4
- package/src/cache/app-store.caches.ts +35 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/environment.test.ts +17 -12
- package/src/config/environment.ts +1 -0
- package/src/config/serverlessFunctionEnv.test.ts +105 -0
- package/src/config/stageConfig.test.ts +25 -0
- package/src/config/stageConfig.ts +24 -0
- package/src/docker-entry.ts +24 -0
- package/src/helpers/app-manifest.helpers.ts +185 -0
- package/src/helpers/claim-hooks.helpers.ts +20 -7
- package/src/helpers/consent-referral.helpers.ts +6 -0
- package/src/helpers/consent-scopes.helpers.ts +58 -0
- package/src/helpers/contract-event-maintenance.helpers.ts +43 -0
- package/src/helpers/contract-events.helpers.ts +540 -0
- package/src/helpers/contract-requests.helpers.ts +211 -0
- package/src/helpers/finalize-inbox.helpers.ts +163 -44
- package/src/helpers/inbox.helpers.ts +51 -66
- package/src/helpers/inline-template.helpers.ts +560 -0
- package/src/helpers/notificationMessages.ts +59 -0
- package/src/helpers/notificationQueue.helpers.ts +23 -5
- package/src/helpers/notifications.helpers.ts +25 -1
- package/src/helpers/profile.helpers.ts +6 -0
- package/src/helpers/share-link-attachment-access.ts +87 -0
- package/src/helpers/share-link-attachment.ts +115 -0
- package/src/helpers/share-link-coordinator/attachment-stat.ts +41 -0
- package/src/helpers/share-link-coordinator/coordinator.ts +15 -2
- package/src/helpers/share-link-coordinator/recovery-runner.ts +15 -0
- package/src/helpers/share-link-maintenance/runner.ts +28 -0
- package/src/helpers/share-link-maintenance/runtime.ts +6 -0
- package/src/helpers/share-link-maintenance/telemetry.ts +3 -0
- package/src/helpers/share-link-maintenance/types.ts +2 -0
- package/src/helpers/share-link-owner-projection.ts +2 -0
- package/src/helpers/share-link-policy/production.ts +37 -17
- package/src/helpers/share-link-policy/resolver.ts +16 -11
- package/src/helpers/share-link-policy/types.ts +15 -7
- package/src/helpers/signingAuthority.helpers.ts +23 -14
- package/src/helpers/smartResume.helpers.ts +123 -0
- package/src/models/AppManifestVersion.ts +43 -0
- package/src/models/AppStoreListing.ts +32 -0
- package/src/models/ConsentFlowContract.ts +34 -3
- package/src/models/ConsentFlowTerms.ts +10 -0
- package/src/models/ConsentFlowTransaction.ts +3 -0
- package/src/models/Integration.ts +2 -0
- package/src/models/ShareLink.ts +4 -0
- package/src/models/index.ts +23 -0
- package/src/models/share-link-constraints.ts +5 -0
- package/src/openapi.ts +44 -8
- package/src/routes/app-store.ts +1054 -27
- package/src/routes/boosts.ts +12 -14
- package/src/routes/contracts.ts +382 -262
- package/src/routes/inbox.ts +4 -2
- package/src/routes/index.ts +4 -1
- package/src/routes/profile-manager.ts +19 -0
- package/src/routes/profiles.ts +12 -0
- package/src/routes/public-share-links.ts +169 -0
- package/src/routes/share-links.ts +69 -0
- package/src/routes/workflows.ts +3 -2
- package/src/types/app-manifest.ts +28 -0
- package/src/types/app-store-listing.ts +13 -0
- package/src/types/consentflowcontract.ts +25 -4
- package/test/app-manifest.helpers.spec.ts +170 -0
- package/test/app-manifest.routes.spec.ts +299 -0
- package/test/app-store-listing-updates.spec.ts +236 -0
- package/test/app-store-listing.spec.ts +65 -3
- package/test/consent-data-boundaries.spec.ts +352 -0
- package/test/consent-recipients.spec.ts +513 -0
- package/test/consent-scopes.helpers.spec.ts +67 -0
- package/test/consentflow.spec.ts +63 -2
- package/test/contract-requests.spec.ts +1588 -0
- package/test/credential-storage.neo4j.spec.ts +122 -0
- package/test/guardian-gated-routes.spec.ts +36 -0
- package/test/inbox-security.e2e.ts +26 -10
- package/test/inbox.spec.ts +184 -8
- package/test/inline-template.helpers.spec.ts +49 -0
- package/test/managed-recipient-inbox.spec.ts +24 -2
- package/test/openapi.spec.ts +14 -73
- package/test/profiles.spec.ts +30 -0
- package/test/share-link-attachment.neo4j.spec.ts +262 -0
- package/test/share-link-attachment.unit.spec.ts +244 -0
- package/test/share-link-lifecycle.neo4j.spec.ts +145 -2
- package/test/share-link-maintenance.unit.spec.ts +32 -5
- package/test/share-link-owner-route.unit.spec.ts +70 -2
- package/test/share-link-policy.unit.spec.ts +18 -4
- package/test/share-link-production-policy.unit.spec.ts +58 -9
- package/test/share-link-public-route.unit.spec.ts +279 -0
- package/test/share-link-service-policy.neo4j.spec.ts +403 -0
- package/test/signing-authority-encryption.spec.ts +75 -0
- package/test/smart-resume-consent.spec.ts +233 -0
- package/test/smart-resume-upload.spec.ts +65 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +13 -0
- package/vitest.inbox-e2e.config.ts +2 -0
- package/workerLambda.test.ts +100 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { QueryBuilder } from 'neogma';
|
|
3
|
+
import { int } from 'neo4j-driver';
|
|
4
|
+
import { DbTermsValidator } from 'types/consentflowcontract';
|
|
5
|
+
import { neogma } from '@instance';
|
|
6
|
+
import { environment } from '@environment';
|
|
7
|
+
import {
|
|
8
|
+
ConsentFlowTransactionValidator,
|
|
9
|
+
ConsentFlowWebhookMetadataValidator,
|
|
10
|
+
LCNNotificationTypeEnumValidator,
|
|
11
|
+
type ConsentFlowTransaction,
|
|
12
|
+
type ConsentFlowWebhookMetadata,
|
|
13
|
+
type LCNNotification,
|
|
14
|
+
} from '@learncard/types';
|
|
15
|
+
import { getContractById, getContractRequestAccess } from '@accesslayer/consentflowcontract/read';
|
|
16
|
+
import { getProfileByProfileId } from '@accesslayer/profile/read';
|
|
17
|
+
import { getContractTermsById } from '@accesslayer/consentflowcontract/relationships/read';
|
|
18
|
+
import {
|
|
19
|
+
addNotificationToQueue,
|
|
20
|
+
PermanentNotificationDeliveryError,
|
|
21
|
+
} from './notifications.helpers';
|
|
22
|
+
import { constructUri, getIdFromUri } from './uri.helpers';
|
|
23
|
+
import { getNotificationMessage, type NotificationMessageKey } from './notificationMessages';
|
|
24
|
+
import { sanitizeProfileForTier, stripSensitiveProfileListFields } from './profile-privacy.helpers';
|
|
25
|
+
import { getStoredContractRequest } from '@accesslayer/consentflowcontract/read';
|
|
26
|
+
import { resolveRecipientLocale } from './getRecipientLocale.helpers';
|
|
27
|
+
import { ensureContractEventMaintenance } from './contract-event-maintenance.helpers';
|
|
28
|
+
|
|
29
|
+
type EventKind = ConsentFlowWebhookMetadata['event'];
|
|
30
|
+
const defaultDomain = (): string => environment.DOMAIN_NAME ?? 'network.learncard.com';
|
|
31
|
+
|
|
32
|
+
const MAX_DELIVERY_ATTEMPTS = 12;
|
|
33
|
+
const DELIVERY_RETRY_WINDOW_MS = 24 * 60 * 60_000;
|
|
34
|
+
const INITIAL_RETRY_DELAY_MS = 60_000;
|
|
35
|
+
const MAX_RETRY_DELAY_MS = 60 * 60_000;
|
|
36
|
+
// At most 50 data recipients, the owner, and a requester outside the data audience.
|
|
37
|
+
const MAX_INLINE_EVENT_DELIVERIES = 50 + 1 + 1;
|
|
38
|
+
|
|
39
|
+
export type ContractEventDispatchSummary = {
|
|
40
|
+
delivered: number;
|
|
41
|
+
pending: number;
|
|
42
|
+
skipped: number;
|
|
43
|
+
rejected: number;
|
|
44
|
+
failed: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Persist a notification intent WITH the consent transaction, before any external work. */
|
|
48
|
+
export const appendConsentEvent = (
|
|
49
|
+
query: QueryBuilder,
|
|
50
|
+
{
|
|
51
|
+
consenterProfileId,
|
|
52
|
+
transaction,
|
|
53
|
+
domain = defaultDomain(),
|
|
54
|
+
messageKey,
|
|
55
|
+
}: {
|
|
56
|
+
consenterProfileId: string;
|
|
57
|
+
transaction: ConsentFlowTransaction;
|
|
58
|
+
domain?: string;
|
|
59
|
+
messageKey: NotificationMessageKey;
|
|
60
|
+
}
|
|
61
|
+
): QueryBuilder => {
|
|
62
|
+
const kind: EventKind =
|
|
63
|
+
transaction.action === 'consent'
|
|
64
|
+
? 'consent_created'
|
|
65
|
+
: transaction.action === 'update'
|
|
66
|
+
? 'consent_updated'
|
|
67
|
+
: transaction.action === 'withdraw'
|
|
68
|
+
? 'consent_withdrawn'
|
|
69
|
+
: 'credentials_synced';
|
|
70
|
+
query.getBindParam().add({
|
|
71
|
+
eventConsenter: consenterProfileId,
|
|
72
|
+
eventDomain: domain,
|
|
73
|
+
eventKind: kind,
|
|
74
|
+
eventMessageKey: messageKey,
|
|
75
|
+
eventPayload: JSON.stringify(transaction),
|
|
76
|
+
});
|
|
77
|
+
return query.raw(`
|
|
78
|
+
WITH contract, terms, transaction
|
|
79
|
+
MATCH (consenter:Profile {profileId: $eventConsenter})
|
|
80
|
+
OPTIONAL MATCH (contract)-[request:REQUESTED_FOR]->(consenter)
|
|
81
|
+
SET terms.\`referral.requestId\` = CASE WHEN request.requestId IS NOT NULL AND (request.status = 'accepted' OR (request.status = 'pending' AND $eventKind IN ['consent_created', 'consent_updated']))
|
|
82
|
+
THEN request.requestId ELSE terms.\`referral.requestId\` END,
|
|
83
|
+
terms.\`referral.requestedBy\` = CASE WHEN request.requestId IS NOT NULL AND (request.status = 'accepted' OR (request.status = 'pending' AND $eventKind IN ['consent_created', 'consent_updated']))
|
|
84
|
+
THEN request.requestedBy ELSE terms.\`referral.requestedBy\` END,
|
|
85
|
+
terms.\`referral.externalReferenceId\` = CASE WHEN request.requestId IS NOT NULL AND (request.status = 'accepted' OR (request.status = 'pending' AND $eventKind IN ['consent_created', 'consent_updated']))
|
|
86
|
+
THEN request.externalReferenceId ELSE terms.\`referral.externalReferenceId\` END,
|
|
87
|
+
transaction.\`referral.requestId\` = terms.\`referral.requestId\`,
|
|
88
|
+
transaction.\`referral.requestedBy\` = terms.\`referral.requestedBy\`,
|
|
89
|
+
transaction.\`referral.externalReferenceId\` = terms.\`referral.externalReferenceId\`
|
|
90
|
+
WITH contract, terms, transaction, consenter, request,
|
|
91
|
+
request.requestId IS NOT NULL AND request.status = 'pending' AND $eventKind IN ['consent_created', 'consent_updated'] AS requestAccepted
|
|
92
|
+
FOREACH (_ IN CASE WHEN $eventKind = 'consent_created' THEN [1] ELSE [] END |
|
|
93
|
+
MERGE (contract)-[accepted:REQUESTED_FOR]->(consenter)
|
|
94
|
+
ON CREATE SET accepted.status = 'accepted', accepted.readStatus = 'unseen'
|
|
95
|
+
SET accepted.status = CASE WHEN accepted.requestId IS NULL OR accepted.status = 'pending'
|
|
96
|
+
THEN 'accepted' ELSE accepted.status END)
|
|
97
|
+
FOREACH (_ IN CASE WHEN requestAccepted THEN [1] ELSE [] END |
|
|
98
|
+
SET request.status = 'accepted')
|
|
99
|
+
WITH contract, terms, transaction, requestAccepted
|
|
100
|
+
MATCH (contract)-[:CREATED_BY]->(owner:Profile)
|
|
101
|
+
OPTIONAL MATCH (contract)-[:SHARES_DATA_WITH]->(recipient:Profile)
|
|
102
|
+
WITH contract, terms, transaction, owner, requestAccepted, collect(DISTINCT recipient.profileId) AS recipientIds
|
|
103
|
+
CREATE (event:ConsentFlowEvent {id: transaction.id, contractId: contract.id, termsId: terms.id,
|
|
104
|
+
fromId: $eventConsenter, ownerId: owner.profileId, domain: $eventDomain,
|
|
105
|
+
kind: $eventKind, requestAccepted: requestAccepted, messageKey: $eventMessageKey, payload: $eventPayload, cleanupPending: true,
|
|
106
|
+
createdAt: transaction.date, requestId: terms.\`referral.requestId\`,
|
|
107
|
+
requestedBy: terms.\`referral.requestedBy\`, externalReferenceId: terms.\`referral.externalReferenceId\`})
|
|
108
|
+
WITH contract, terms, transaction, event, owner,
|
|
109
|
+
[owner.profileId] + [id IN recipientIds WHERE id <> owner.profileId] AS audience
|
|
110
|
+
WITH contract, terms, transaction, event, owner, audience,
|
|
111
|
+
audience + CASE WHEN event.requestAccepted AND event.requestedBy IS NOT NULL AND NOT event.requestedBy IN audience
|
|
112
|
+
THEN [event.requestedBy] ELSE [] END AS deliveries
|
|
113
|
+
FOREACH (recipientId IN [id IN deliveries WHERE id <> $eventConsenter] |
|
|
114
|
+
CREATE (event)-[:HAS_DELIVERY]->(delivery:ConsentFlowEventDelivery {
|
|
115
|
+
id: event.id + ':' + recipientId, toId: recipientId, state: 'pending', nextAttemptAt: event.createdAt,
|
|
116
|
+
eventCreatedAt: event.createdAt, attempts: 0,
|
|
117
|
+
role: CASE WHEN recipientId = owner.profileId THEN 'owner'
|
|
118
|
+
WHEN recipientId IN audience THEN 'recipient' ELSE 'requester' END}))
|
|
119
|
+
WITH DISTINCT contract, terms, transaction
|
|
120
|
+
`);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** Shared Cypher for request creation/decisions; caller holds the contract lock. */
|
|
124
|
+
export const requestEventCypher = `
|
|
125
|
+
WITH contract, request, target
|
|
126
|
+
MATCH (contract)-[:CREATED_BY]->(owner:Profile)
|
|
127
|
+
MERGE (event:ConsentFlowEvent {id: CASE WHEN $eventKind = 'request_sent' THEN request.requestId ELSE request.requestId + ':' + request.status END})
|
|
128
|
+
ON CREATE SET event += {contractId: contract.id, fromId: $eventFrom,
|
|
129
|
+
ownerId: owner.profileId, domain: $domain, kind: $eventKind, createdAt: $now,
|
|
130
|
+
requestId: request.requestId, requestedBy: request.requestedBy,
|
|
131
|
+
externalReferenceId: request.externalReferenceId, message: request.message, fanoutCreated: false, cleanupPending: true}
|
|
132
|
+
WITH request, event, owner, target,
|
|
133
|
+
CASE WHEN coalesce(event.fanoutCreated, false) THEN []
|
|
134
|
+
WHEN $eventKind = 'request_sent' THEN [target.profileId]
|
|
135
|
+
ELSE [owner.profileId] + CASE WHEN request.requestedBy <> owner.profileId
|
|
136
|
+
THEN [request.requestedBy] ELSE [] END END AS deliveries
|
|
137
|
+
FOREACH (recipientId IN [id IN deliveries WHERE id IS NOT NULL AND id <> $eventFrom] |
|
|
138
|
+
MERGE (event)-[:HAS_DELIVERY]->(delivery:ConsentFlowEventDelivery {id: event.id + ':' + recipientId})
|
|
139
|
+
ON CREATE SET delivery += {toId: recipientId, state: 'pending', nextAttemptAt: $now,
|
|
140
|
+
eventCreatedAt: event.createdAt, attempts: 0,
|
|
141
|
+
role: CASE WHEN $eventKind = 'request_sent' THEN 'target'
|
|
142
|
+
WHEN recipientId = owner.profileId THEN 'owner' ELSE 'requester' END})
|
|
143
|
+
SET event.fanoutCreated = true
|
|
144
|
+
RETURN DISTINCT request, event.id AS eventId
|
|
145
|
+
`;
|
|
146
|
+
|
|
147
|
+
type StoredEvent = {
|
|
148
|
+
id: string;
|
|
149
|
+
contractId: string;
|
|
150
|
+
termsId?: string;
|
|
151
|
+
fromId: string;
|
|
152
|
+
ownerId: string;
|
|
153
|
+
domain: string;
|
|
154
|
+
kind: EventKind;
|
|
155
|
+
createdAt: string;
|
|
156
|
+
payload?: string;
|
|
157
|
+
messageKey?: NotificationMessageKey;
|
|
158
|
+
requestId?: string;
|
|
159
|
+
requestedBy?: string;
|
|
160
|
+
externalReferenceId?: string;
|
|
161
|
+
message?: string;
|
|
162
|
+
requestAccepted?: boolean;
|
|
163
|
+
};
|
|
164
|
+
type StoredDelivery = {
|
|
165
|
+
id: string;
|
|
166
|
+
toId: string;
|
|
167
|
+
role: ConsentFlowWebhookMetadata['recipientRole'];
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const expiryActive = (expiry?: string): boolean =>
|
|
171
|
+
!expiry?.trim() || Date.parse(expiry) > Date.now();
|
|
172
|
+
|
|
173
|
+
/** Suppress queued audience data after recipient removal, withdrawal or expiry. */
|
|
174
|
+
export const authorizeContractNotification = async (
|
|
175
|
+
notification: LCNNotification
|
|
176
|
+
): Promise<boolean> => {
|
|
177
|
+
const parsed = ConsentFlowWebhookMetadataValidator.safeParse(notification.data?.metadata);
|
|
178
|
+
if (!parsed.success) return true; // Existing unrelated notifications retain their behavior.
|
|
179
|
+
const metadata = parsed.data;
|
|
180
|
+
if (metadata.event === 'request_sent') {
|
|
181
|
+
const request = await getStoredContractRequest(
|
|
182
|
+
getIdFromUri(metadata.contractUri),
|
|
183
|
+
notification.to.profileId ?? ''
|
|
184
|
+
);
|
|
185
|
+
return request?.requestId === metadata.requestId && request?.status === 'pending';
|
|
186
|
+
}
|
|
187
|
+
const toProfileId = notification.to.profileId;
|
|
188
|
+
if (!toProfileId) return false;
|
|
189
|
+
const access = await getContractRequestAccess(getIdFromUri(metadata.contractUri), toProfileId);
|
|
190
|
+
const isRequester = toProfileId === metadata.requestedBy;
|
|
191
|
+
if (metadata.event.startsWith('request_')) {
|
|
192
|
+
// Apply the narrowed audience to old pending deliveries and already queued messages too.
|
|
193
|
+
return access.isOwner || isRequester;
|
|
194
|
+
}
|
|
195
|
+
if (!access.isManager && !isRequester) {
|
|
196
|
+
// Consented data is shared with this recipient, but another referrer's CRM ID is not.
|
|
197
|
+
if (notification.data?.metadata) {
|
|
198
|
+
delete notification.data.metadata.externalReferenceId;
|
|
199
|
+
delete notification.data.metadata.message;
|
|
200
|
+
}
|
|
201
|
+
if (notification.data?.transaction?.referral)
|
|
202
|
+
delete notification.data.transaction.referral.externalReferenceId;
|
|
203
|
+
}
|
|
204
|
+
if (metadata.recipientRole !== 'owner' && metadata.recipientRole !== 'recipient') return false;
|
|
205
|
+
if (!access.isRecipient) return false;
|
|
206
|
+
if (metadata.event === 'consent_withdrawn') return true;
|
|
207
|
+
if (!metadata.termsUri) return false;
|
|
208
|
+
const relationship = await getContractTermsById(getIdFromUri(metadata.termsUri));
|
|
209
|
+
const parsedTerms = DbTermsValidator.safeParse(relationship?.terms);
|
|
210
|
+
if (!relationship || !parsedTerms.success) return false;
|
|
211
|
+
const terms = parsedTerms.data;
|
|
212
|
+
if (
|
|
213
|
+
!(terms.status === 'live' || (terms.status === 'stale' && terms.oneTime)) ||
|
|
214
|
+
!expiryActive(terms.expiresAt) ||
|
|
215
|
+
!expiryActive(relationship.contract.expiresAt)
|
|
216
|
+
)
|
|
217
|
+
return false;
|
|
218
|
+
const personal = notification.data?.transaction?.terms?.read.personal;
|
|
219
|
+
if (personal)
|
|
220
|
+
for (const [key, value] of Object.entries(personal))
|
|
221
|
+
if (terms.terms.read.personal[key] !== value) delete personal[key];
|
|
222
|
+
// Legacy sync payload shape is preserved, but only still-authorized URIs are delivered.
|
|
223
|
+
const shared = notification.data?.transaction?.terms?.read.credentials.categories;
|
|
224
|
+
if (shared)
|
|
225
|
+
for (const [category, grant] of Object.entries(shared)) {
|
|
226
|
+
const current = terms.terms.read.credentials.categories[category];
|
|
227
|
+
grant.shared =
|
|
228
|
+
terms.terms.read.credentials.sharing !== false &&
|
|
229
|
+
current?.sharing &&
|
|
230
|
+
expiryActive(current.shareUntil)
|
|
231
|
+
? (grant.shared ?? []).filter(uri => current.shared?.includes(uri))
|
|
232
|
+
: [];
|
|
233
|
+
}
|
|
234
|
+
return true;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const buildNotification = async (
|
|
238
|
+
event: StoredEvent,
|
|
239
|
+
delivery: StoredDelivery
|
|
240
|
+
): Promise<LCNNotification | null> => {
|
|
241
|
+
const [from, to, owner, contract] = await Promise.all([
|
|
242
|
+
getProfileByProfileId(event.fromId),
|
|
243
|
+
getProfileByProfileId(delivery.toId),
|
|
244
|
+
getProfileByProfileId(event.ownerId),
|
|
245
|
+
getContractById(event.contractId),
|
|
246
|
+
]);
|
|
247
|
+
if (!from || !to || !owner) return null;
|
|
248
|
+
const eventKind =
|
|
249
|
+
delivery.role === 'requester' && event.requestAccepted ? 'request_accepted' : event.kind;
|
|
250
|
+
const metadata = ConsentFlowWebhookMetadataValidator.parse({
|
|
251
|
+
eventId: event.id,
|
|
252
|
+
deliveryKey: delivery.id,
|
|
253
|
+
event: eventKind,
|
|
254
|
+
contractUri: constructUri('contract', event.contractId, event.domain),
|
|
255
|
+
...(event.termsId && delivery.role !== 'requester' && delivery.role !== 'target'
|
|
256
|
+
? { termsUri: constructUri('terms', event.termsId, event.domain) }
|
|
257
|
+
: {}),
|
|
258
|
+
requestId: event.requestId,
|
|
259
|
+
requestedBy: event.requestedBy,
|
|
260
|
+
externalReferenceId: event.externalReferenceId,
|
|
261
|
+
recipientRole: delivery.role,
|
|
262
|
+
});
|
|
263
|
+
const requesterDecision = delivery.role === 'requester';
|
|
264
|
+
const legacyAi = event.messageKey === 'consentFlowInsightsShared';
|
|
265
|
+
const transaction =
|
|
266
|
+
event.payload && delivery.role !== 'requester' && delivery.role !== 'target'
|
|
267
|
+
? ConsentFlowTransactionValidator.parse({
|
|
268
|
+
...JSON.parse(event.payload),
|
|
269
|
+
...(event.requestId
|
|
270
|
+
? {
|
|
271
|
+
referral: {
|
|
272
|
+
requestId: event.requestId,
|
|
273
|
+
requestedBy: event.requestedBy,
|
|
274
|
+
externalReferenceId: event.externalReferenceId,
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
: {}),
|
|
278
|
+
})
|
|
279
|
+
: undefined;
|
|
280
|
+
const notification: LCNNotification = {
|
|
281
|
+
type: LCNNotificationTypeEnumValidator.enum.CONSENT_FLOW_TRANSACTION,
|
|
282
|
+
from: {
|
|
283
|
+
...stripSensitiveProfileListFields(sanitizeProfileForTier(from, 'unauthenticated')),
|
|
284
|
+
did: from.did,
|
|
285
|
+
},
|
|
286
|
+
to,
|
|
287
|
+
sent: event.createdAt,
|
|
288
|
+
message: getNotificationMessage(
|
|
289
|
+
(requesterDecision ? undefined : event.messageKey) ??
|
|
290
|
+
(event.kind === 'request_sent'
|
|
291
|
+
? 'contractRequestReceived'
|
|
292
|
+
: eventKind === 'request_accepted'
|
|
293
|
+
? 'contractRequestAccepted'
|
|
294
|
+
: eventKind === 'request_denied'
|
|
295
|
+
? 'contractRequestDenied'
|
|
296
|
+
: 'contractRequestCancelled'),
|
|
297
|
+
resolveRecipientLocale(to),
|
|
298
|
+
{
|
|
299
|
+
name: from.displayName,
|
|
300
|
+
referrer: from.displayName || from.profileId,
|
|
301
|
+
contractOwner: owner.displayName || owner.profileId,
|
|
302
|
+
consenter: from.displayName,
|
|
303
|
+
contractName: contract?.name ?? '',
|
|
304
|
+
totalCredentials: String(
|
|
305
|
+
transaction?.terms
|
|
306
|
+
? Object.values(transaction.terms.read.credentials.categories).reduce(
|
|
307
|
+
(n, category) => n + (category.shared?.length ?? 0),
|
|
308
|
+
0
|
|
309
|
+
)
|
|
310
|
+
: 0
|
|
311
|
+
),
|
|
312
|
+
categoryCount: String(
|
|
313
|
+
Object.keys(transaction?.terms?.read.credentials.categories ?? {}).length
|
|
314
|
+
),
|
|
315
|
+
}
|
|
316
|
+
),
|
|
317
|
+
data: {
|
|
318
|
+
...(transaction ? { transaction } : {}),
|
|
319
|
+
metadata: {
|
|
320
|
+
...metadata,
|
|
321
|
+
...(legacyAi ? { type: 'AI Insight', contractId: event.contractId } : {}),
|
|
322
|
+
...(event.kind === 'request_sent'
|
|
323
|
+
? {
|
|
324
|
+
type: 'contract-request',
|
|
325
|
+
...(event.message ? { message: event.message } : {}),
|
|
326
|
+
}
|
|
327
|
+
: {}),
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
return (await authorizeContractNotification(notification)) ? notification : null;
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/** At-least-once dispatch. Leases fence overlapping workers; deliveryKey fences consumer retries. */
|
|
335
|
+
export const dispatchContractEvents = async ({
|
|
336
|
+
eventId,
|
|
337
|
+
limit = 25,
|
|
338
|
+
budgetMs = 20_000,
|
|
339
|
+
}: {
|
|
340
|
+
eventId?: string;
|
|
341
|
+
limit?: number;
|
|
342
|
+
budgetMs?: number;
|
|
343
|
+
} = {}): Promise<ContractEventDispatchSummary> => {
|
|
344
|
+
const deadline = Date.now() + budgetMs;
|
|
345
|
+
const summary = { delivered: 0, pending: 0, skipped: 0, rejected: 0, failed: 0 };
|
|
346
|
+
const retryCutoff = new Date(Date.now() - DELIVERY_RETRY_WINDOW_MS).toISOString();
|
|
347
|
+
const batchLimit = int(Math.max(1, Math.min(100, Math.trunc(limit))));
|
|
348
|
+
if (!eventId) await ensureContractEventMaintenance();
|
|
349
|
+
// Also recover cleanup after a crash and clear events with no eligible recipients.
|
|
350
|
+
await neogma.queryRunner.run(
|
|
351
|
+
`
|
|
352
|
+
${
|
|
353
|
+
eventId
|
|
354
|
+
? 'MATCH (event:ConsentFlowEvent {id: $eventId})'
|
|
355
|
+
: `MATCH (event:ConsentFlowEvent)
|
|
356
|
+
USING INDEX event:ConsentFlowEvent(cleanupPending)`
|
|
357
|
+
}
|
|
358
|
+
WHERE ${eventId ? '(event.payload IS NOT NULL OR event.message IS NOT NULL OR event.cleanupPending = true)' : 'event.cleanupPending = true'}
|
|
359
|
+
AND NOT EXISTS {
|
|
360
|
+
MATCH (event)-[:HAS_DELIVERY]->(delivery:ConsentFlowEventDelivery)
|
|
361
|
+
WHERE delivery.state = 'pending'
|
|
362
|
+
}
|
|
363
|
+
WITH event LIMIT $limit
|
|
364
|
+
SET event.deliveryLock = coalesce(event.deliveryLock, 0) + 1
|
|
365
|
+
WITH event WHERE NOT EXISTS {
|
|
366
|
+
MATCH (event)-[:HAS_DELIVERY]->(delivery:ConsentFlowEventDelivery)
|
|
367
|
+
WHERE delivery.state = 'pending'
|
|
368
|
+
}
|
|
369
|
+
SET event.payload = null, event.message = null, event.cleanupPending = null
|
|
370
|
+
`,
|
|
371
|
+
{ eventId: eventId ?? null, limit: batchLimit }
|
|
372
|
+
);
|
|
373
|
+
const rows = await neogma.queryRunner.run(
|
|
374
|
+
`
|
|
375
|
+
${
|
|
376
|
+
eventId
|
|
377
|
+
? `MATCH (event:ConsentFlowEvent {id: $eventId})-[:HAS_DELIVERY]->(delivery:ConsentFlowEventDelivery)
|
|
378
|
+
WHERE delivery.state = 'pending'
|
|
379
|
+
AND (delivery.nextAttemptAt <= $now OR event.createdAt <= $retryCutoff
|
|
380
|
+
OR coalesce(delivery.attempts, 0) >= $maxAttempts)`
|
|
381
|
+
: `CALL {
|
|
382
|
+
MATCH (delivery:ConsentFlowEventDelivery)
|
|
383
|
+
USING INDEX delivery:ConsentFlowEventDelivery(state, nextAttemptAt)
|
|
384
|
+
WHERE delivery.state = 'pending' AND delivery.nextAttemptAt <= $now
|
|
385
|
+
MATCH (event:ConsentFlowEvent)-[:HAS_DELIVERY]->(delivery)
|
|
386
|
+
RETURN event, delivery
|
|
387
|
+
UNION
|
|
388
|
+
MATCH (delivery:ConsentFlowEventDelivery)
|
|
389
|
+
USING INDEX delivery:ConsentFlowEventDelivery(state, eventCreatedAt)
|
|
390
|
+
WHERE delivery.state = 'pending' AND delivery.eventCreatedAt <= $retryCutoff
|
|
391
|
+
MATCH (event:ConsentFlowEvent)-[:HAS_DELIVERY]->(delivery)
|
|
392
|
+
RETURN event, delivery
|
|
393
|
+
UNION
|
|
394
|
+
MATCH (delivery:ConsentFlowEventDelivery)
|
|
395
|
+
USING INDEX delivery:ConsentFlowEventDelivery(state, attempts)
|
|
396
|
+
WHERE delivery.state = 'pending' AND delivery.attempts >= $maxAttempts
|
|
397
|
+
MATCH (event:ConsentFlowEvent)-[:HAS_DELIVERY]->(delivery)
|
|
398
|
+
RETURN event, delivery
|
|
399
|
+
}
|
|
400
|
+
WITH event, delivery WHERE true`
|
|
401
|
+
}
|
|
402
|
+
AND (delivery.leaseUntil IS NULL OR delivery.leaseUntil < $now)
|
|
403
|
+
RETURN event, delivery ORDER BY delivery.nextAttemptAt, delivery.id LIMIT $limit
|
|
404
|
+
`,
|
|
405
|
+
{
|
|
406
|
+
now: new Date().toISOString(),
|
|
407
|
+
eventId: eventId ?? null,
|
|
408
|
+
retryCutoff,
|
|
409
|
+
maxAttempts: int(MAX_DELIVERY_ATTEMPTS),
|
|
410
|
+
limit: batchLimit,
|
|
411
|
+
}
|
|
412
|
+
);
|
|
413
|
+
for (const row of rows.records) {
|
|
414
|
+
if (Date.now() >= deadline) break;
|
|
415
|
+
const event: StoredEvent = row.get('event').properties;
|
|
416
|
+
const delivery: StoredDelivery = row.get('delivery').properties;
|
|
417
|
+
const lease = randomUUID();
|
|
418
|
+
const now = new Date().toISOString();
|
|
419
|
+
const claimed = await neogma.queryRunner.run(
|
|
420
|
+
`
|
|
421
|
+
MATCH (event:ConsentFlowEvent)-[:HAS_DELIVERY]->(delivery:ConsentFlowEventDelivery {id: $id})
|
|
422
|
+
SET delivery.lock = coalesce(delivery.lock, 0) + 1
|
|
423
|
+
WITH event, delivery WHERE delivery.state = 'pending'
|
|
424
|
+
AND (delivery.nextAttemptAt <= $now OR event.createdAt <= $retryCutoff
|
|
425
|
+
OR coalesce(delivery.attempts, 0) >= $maxAttempts)
|
|
426
|
+
AND (delivery.leaseUntil IS NULL OR delivery.leaseUntil < $now)
|
|
427
|
+
WITH delivery, coalesce(delivery.attempts, 0) AS previousAttempts
|
|
428
|
+
SET delivery.lease = $lease, delivery.leaseUntil = $leaseUntil,
|
|
429
|
+
delivery.attempts = CASE WHEN previousAttempts < $maxAttempts
|
|
430
|
+
THEN previousAttempts + 1 ELSE previousAttempts END
|
|
431
|
+
RETURN previousAttempts
|
|
432
|
+
`,
|
|
433
|
+
{
|
|
434
|
+
id: delivery.id,
|
|
435
|
+
now,
|
|
436
|
+
lease,
|
|
437
|
+
leaseUntil: new Date(Date.now() + 60_000).toISOString(),
|
|
438
|
+
retryCutoff,
|
|
439
|
+
maxAttempts: int(MAX_DELIVERY_ATTEMPTS),
|
|
440
|
+
}
|
|
441
|
+
);
|
|
442
|
+
if (!claimed.records.length) continue;
|
|
443
|
+
// Count claims, including abandoned leases, so crashes cannot reset the retry budget.
|
|
444
|
+
const previousAttempts = Number(claimed.records[0]!.get('previousAttempts'));
|
|
445
|
+
const attempts = Math.min(previousAttempts + 1, MAX_DELIVERY_ATTEMPTS);
|
|
446
|
+
let state = 'pending';
|
|
447
|
+
let failureReason: string | null = null;
|
|
448
|
+
try {
|
|
449
|
+
if (event.createdAt <= retryCutoff || previousAttempts >= MAX_DELIVERY_ATTEMPTS) {
|
|
450
|
+
state = 'failed';
|
|
451
|
+
failureReason =
|
|
452
|
+
event.createdAt <= retryCutoff ? 'retry_expired' : 'retry_exhausted';
|
|
453
|
+
summary.failed++;
|
|
454
|
+
} else {
|
|
455
|
+
const notification = await buildNotification(event, delivery);
|
|
456
|
+
if (!notification) {
|
|
457
|
+
state = 'skipped';
|
|
458
|
+
summary.skipped++;
|
|
459
|
+
} else {
|
|
460
|
+
const delivered = await addNotificationToQueue(notification, {
|
|
461
|
+
propagateDirectWebhookTransportErrors: true,
|
|
462
|
+
throwOnPermanentFailure: true,
|
|
463
|
+
});
|
|
464
|
+
if (delivered === false)
|
|
465
|
+
throw new Error('Contract notification was not stored');
|
|
466
|
+
state = 'delivered';
|
|
467
|
+
summary.delivered++;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
} catch (error) {
|
|
471
|
+
if (error instanceof PermanentNotificationDeliveryError) {
|
|
472
|
+
state = 'rejected';
|
|
473
|
+
failureReason = error.reason;
|
|
474
|
+
summary.rejected++;
|
|
475
|
+
} else if (attempts >= MAX_DELIVERY_ATTEMPTS) {
|
|
476
|
+
state = 'failed';
|
|
477
|
+
failureReason = 'retry_exhausted';
|
|
478
|
+
summary.failed++;
|
|
479
|
+
} else {
|
|
480
|
+
summary.pending++;
|
|
481
|
+
}
|
|
482
|
+
// Never log request references, profile identifiers, or consent payloads.
|
|
483
|
+
console.warn(`contract_events: delivery_${state}`);
|
|
484
|
+
}
|
|
485
|
+
// Serialize fan-out completion so concurrent last deliveries cannot both miss cleanup.
|
|
486
|
+
// History lives on Terms/Transactions; finished intents retain IDs and coarse outcomes.
|
|
487
|
+
await neogma.queryRunner.run(
|
|
488
|
+
`
|
|
489
|
+
MATCH (event:ConsentFlowEvent)-[:HAS_DELIVERY]->(delivery:ConsentFlowEventDelivery {id: $id})
|
|
490
|
+
SET delivery.lock = coalesce(delivery.lock, 0) + 1
|
|
491
|
+
WITH event, delivery WHERE delivery.lease = $lease
|
|
492
|
+
SET event.deliveryLock = coalesce(event.deliveryLock, 0) + 1
|
|
493
|
+
WITH event, delivery WHERE delivery.lease = $lease
|
|
494
|
+
SET delivery.state = $state, delivery.lease = null, delivery.leaseUntil = null,
|
|
495
|
+
delivery.updatedAt = $now, delivery.failureReason = $failureReason,
|
|
496
|
+
delivery.nextAttemptAt = $nextAttemptAt
|
|
497
|
+
WITH event
|
|
498
|
+
WHERE NOT EXISTS {
|
|
499
|
+
MATCH (event)-[:HAS_DELIVERY]->(remaining:ConsentFlowEventDelivery)
|
|
500
|
+
WHERE remaining.state = 'pending'
|
|
501
|
+
}
|
|
502
|
+
SET event.payload = null, event.message = null, event.cleanupPending = null
|
|
503
|
+
`,
|
|
504
|
+
{
|
|
505
|
+
id: delivery.id,
|
|
506
|
+
lease,
|
|
507
|
+
state,
|
|
508
|
+
failureReason,
|
|
509
|
+
now: new Date().toISOString(),
|
|
510
|
+
nextAttemptAt:
|
|
511
|
+
state === 'pending'
|
|
512
|
+
? new Date(
|
|
513
|
+
Math.min(
|
|
514
|
+
Date.now() +
|
|
515
|
+
Math.min(
|
|
516
|
+
INITIAL_RETRY_DELAY_MS * 2 ** (attempts - 1),
|
|
517
|
+
MAX_RETRY_DELAY_MS
|
|
518
|
+
),
|
|
519
|
+
Date.parse(event.createdAt) + DELIVERY_RETRY_WINDOW_MS
|
|
520
|
+
)
|
|
521
|
+
).toISOString()
|
|
522
|
+
: null,
|
|
523
|
+
}
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
return summary;
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
/** A committed API mutation must succeed even if dispatch is temporarily unavailable. */
|
|
530
|
+
export const tryDispatchContractEvent = async (eventId: string): Promise<void> => {
|
|
531
|
+
try {
|
|
532
|
+
await dispatchContractEvents({
|
|
533
|
+
eventId,
|
|
534
|
+
limit: MAX_INLINE_EVENT_DELIVERIES,
|
|
535
|
+
budgetMs: 1_000,
|
|
536
|
+
});
|
|
537
|
+
} catch {
|
|
538
|
+
console.warn('contract_events: dispatch_pending');
|
|
539
|
+
}
|
|
540
|
+
};
|