@learncard/network-brain-service 4.1.2 → 5.0.0
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 +14 -0
- package/CHANGELOG.md +164 -0
- package/README.md +27 -1
- package/bootstrapLambda.test.ts +70 -32
- package/config/config.dev.json +1 -0
- package/config/config.example.json +2 -1
- package/config/config.production.json +1 -0
- package/config/config.scouts.dev.json +1 -0
- package/config/config.scouts.production.json +1 -0
- package/consentFlowDeadlineLambda.test.ts +108 -0
- package/consentFlowDeadlineLambda.ts +18 -0
- package/consentFlowDeadlineLambdaApp.ts +14 -0
- package/dist/accesslayer/boost/create.d.ts +8 -2
- package/dist/accesslayer/boost/create.d.ts.map +1 -1
- package/dist/accesslayer/campaign/audience.d.ts +4 -4
- package/dist/accesslayer/campaign/audience.d.ts.map +1 -1
- package/dist/accesslayer/campaign/create.d.ts +1 -1
- package/dist/accesslayer/campaign/create.d.ts.map +1 -1
- package/dist/accesslayer/campaign/delivery.d.ts +5 -0
- package/dist/accesslayer/campaign/delivery.d.ts.map +1 -0
- package/dist/accesslayer/campaign/dispatch.d.ts +5 -0
- package/dist/accesslayer/campaign/dispatch.d.ts.map +1 -0
- package/dist/accesslayer/campaign/dispatch.test.d.ts +2 -0
- package/dist/accesslayer/campaign/dispatch.test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/email.d.ts +10 -0
- package/dist/accesslayer/campaign/email.d.ts.map +1 -0
- package/dist/accesslayer/campaign/email.test.d.ts +2 -0
- package/dist/accesslayer/campaign/email.test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/preferences.d.ts +5 -0
- package/dist/accesslayer/campaign/preferences.d.ts.map +1 -0
- package/dist/accesslayer/campaign/read.d.ts.map +1 -1
- package/dist/accesslayer/campaign/test.d.ts +5 -0
- package/dist/accesslayer/campaign/test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/test.test.d.ts +2 -0
- package/dist/accesslayer/campaign/test.test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/update.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/delete.d.ts +1 -1
- package/dist/accesslayer/consentflowcontract/delete.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +2 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +19 -4
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +6 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/versions.d.ts +68 -0
- package/dist/accesslayer/consentflowcontract/versions.d.ts.map +1 -0
- package/dist/accesslayer/credential/create.d.ts +3 -1
- package/dist/accesslayer/credential/create.d.ts.map +1 -1
- package/dist/accesslayer/credential/delete.d.ts +1 -0
- package/dist/accesslayer/credential/delete.d.ts.map +1 -1
- package/dist/accesslayer/credential/relationships/create.d.ts +15 -3
- package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/credential/relationships/read.d.ts +2 -0
- package/dist/accesslayer/credential/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/credential/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/read.d.ts +1 -0
- package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/create.d.ts +1 -0
- package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/refresh.d.ts +7 -0
- package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -1
- package/dist/app.d.ts +11 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/cache/app-store.caches.d.ts +61 -1
- package/dist/cache/app-store.caches.d.ts.map +1 -1
- package/dist/campaign-unsubscribe.d.ts +7 -0
- package/dist/campaign-unsubscribe.d.ts.map +1 -0
- package/dist/campaign-unsubscribe.test.d.ts +2 -0
- package/dist/campaign-unsubscribe.test.d.ts.map +1 -0
- package/dist/config/environment.d.ts +42 -0
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/config/stageConfig.d.ts +4 -0
- package/dist/config/stageConfig.d.ts.map +1 -1
- package/dist/credential-refresh.d.ts.map +1 -1
- package/dist/helpers/boost.helpers.d.ts +4 -1
- package/dist/helpers/boost.helpers.d.ts.map +1 -1
- package/dist/helpers/campaign-app-origin.helpers.d.ts +2 -0
- package/dist/helpers/campaign-app-origin.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-email.helpers.d.ts +13 -0
- package/dist/helpers/campaign-email.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-notifications.helpers.d.ts +7 -0
- package/dist/helpers/campaign-notifications.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-queue.helpers.d.ts +7 -0
- package/dist/helpers/campaign-queue.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-queue.helpers.test.d.ts +2 -0
- package/dist/helpers/campaign-queue.helpers.test.d.ts.map +1 -0
- package/dist/helpers/campaign-unsubscribe-page.d.ts +2 -0
- package/dist/helpers/campaign-unsubscribe-page.d.ts.map +1 -0
- package/dist/helpers/campaign-unsubscribe.helpers.d.ts +7 -0
- package/dist/helpers/campaign-unsubscribe.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-webhook.helpers.test.d.ts +2 -0
- package/dist/helpers/campaign-webhook.helpers.test.d.ts.map +1 -0
- package/dist/helpers/consentflow-deadline.helpers.d.ts +5 -0
- package/dist/helpers/consentflow-deadline.helpers.d.ts.map +1 -0
- package/dist/helpers/consentflow-legacy-migration.helpers.d.ts +2 -0
- package/dist/helpers/consentflow-legacy-migration.helpers.d.ts.map +1 -0
- package/dist/helpers/contract-events.helpers.d.ts.map +1 -1
- package/dist/helpers/contract.helpers.d.ts +1 -1
- package/dist/helpers/contract.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-display.helpers.d.ts +5 -0
- package/dist/helpers/credential-display.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-display.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-display.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-refresh-materiality.helpers.d.ts +4 -0
- package/dist/helpers/credential-refresh-materiality.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-refresh.helpers.d.ts +30 -1
- package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-status-mutation.helpers.d.ts +5 -0
- package/dist/helpers/credential-status-mutation.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-status-mutation.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-status-mutation.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential.helpers.d.ts +1 -1
- package/dist/helpers/credential.helpers.d.ts.map +1 -1
- package/dist/helpers/inbox-refresh.helpers.d.ts +4 -2
- package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -1
- package/dist/helpers/inbox.helpers.d.ts +1 -0
- package/dist/helpers/inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/learnCard.helpers.d.ts +1 -0
- package/dist/helpers/learnCard.helpers.d.ts.map +1 -1
- package/dist/helpers/managed-status.helpers.d.ts +7 -0
- package/dist/helpers/managed-status.helpers.d.ts.map +1 -0
- package/dist/helpers/managed-status.helpers.test.d.ts +2 -0
- package/dist/helpers/managed-status.helpers.test.d.ts.map +1 -0
- package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts +2 -0
- package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts.map +1 -0
- package/dist/helpers/notification-webhook-fetch.helpers.d.ts +13 -0
- package/dist/helpers/notification-webhook-fetch.helpers.d.ts.map +1 -0
- package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts +2 -0
- package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts.map +1 -0
- package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
- package/dist/helpers/notifications.helpers.d.ts.map +1 -1
- package/dist/helpers/refresh-send-intent.helpers.d.ts +13 -5
- package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -1
- package/dist/helpers/refresh-send-intent.helpers.test.d.ts +2 -0
- package/dist/helpers/refresh-send-intent.helpers.test.d.ts.map +1 -0
- package/dist/helpers/send-contract.helpers.d.ts +11 -0
- package/dist/helpers/send-contract.helpers.d.ts.map +1 -0
- package/dist/helpers/status-list.helpers.d.ts +12 -1
- package/dist/helpers/status-list.helpers.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/models/ConsentFlowContract.d.ts.map +1 -1
- package/dist/models/ConsentFlowContractVersion.d.ts +12 -0
- package/dist/models/ConsentFlowContractVersion.d.ts.map +1 -0
- package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
- package/dist/models/Credential.d.ts +2 -0
- package/dist/models/Credential.d.ts.map +1 -1
- package/dist/models/CredentialRefresh.d.ts +5 -0
- package/dist/models/CredentialRefresh.d.ts.map +1 -1
- package/dist/models/Integration.d.ts +1 -1
- package/dist/models/Profile.d.ts.map +1 -1
- package/dist/models/campaign-constraints.d.ts.map +1 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/activity.d.ts +15 -5
- package/dist/routes/activity.d.ts.map +1 -1
- package/dist/routes/app-store.d.ts +15 -5
- package/dist/routes/app-store.d.ts.map +1 -1
- package/dist/routes/auth-grants.d.ts +12 -2
- package/dist/routes/auth-grants.d.ts.map +1 -1
- package/dist/routes/bench.d.ts +11 -1
- package/dist/routes/bench.d.ts.map +1 -1
- package/dist/routes/boosts.d.ts +70 -15
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/campaigns.d.ts +183 -51
- package/dist/routes/campaigns.d.ts.map +1 -1
- package/dist/routes/claim-hooks.d.ts +11 -1
- package/dist/routes/claim-hooks.d.ts.map +1 -1
- package/dist/routes/contact-methods.d.ts +11 -1
- package/dist/routes/contact-methods.d.ts.map +1 -1
- package/dist/routes/contracts.d.ts +374 -8
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/credential-input-privacy.test.d.ts +2 -0
- package/dist/routes/credential-input-privacy.test.d.ts.map +1 -0
- package/dist/routes/credential-refreshes.d.ts +210 -1
- package/dist/routes/credential-refreshes.d.ts.map +1 -1
- package/dist/routes/credential-share-previews.d.ts +11 -1
- package/dist/routes/credential-share-previews.d.ts.map +1 -1
- package/dist/routes/credentials.d.ts +132 -3
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/did-metadata.d.ts +11 -1
- package/dist/routes/did-metadata.d.ts.map +1 -1
- package/dist/routes/federation.d.ts +11 -1
- package/dist/routes/federation.d.ts.map +1 -1
- package/dist/routes/inbox.d.ts +54 -19
- package/dist/routes/inbox.d.ts.map +1 -1
- package/dist/routes/index.d.ts +100 -1
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/integrations.d.ts +14 -4
- package/dist/routes/integrations.d.ts.map +1 -1
- package/dist/routes/presentations.d.ts +11 -1
- package/dist/routes/presentations.d.ts.map +1 -1
- package/dist/routes/profile-manager.d.ts +11 -1
- package/dist/routes/profile-manager.d.ts.map +1 -1
- package/dist/routes/profiles.d.ts +11 -1
- package/dist/routes/profiles.d.ts.map +1 -1
- package/dist/routes/public-share-links.d.ts +22 -2
- package/dist/routes/public-share-links.d.ts.map +1 -1
- package/dist/routes/share-links.d.ts +36 -16
- package/dist/routes/share-links.d.ts.map +1 -1
- package/dist/routes/skill-frameworks.d.ts +11 -1
- package/dist/routes/skill-frameworks.d.ts.map +1 -1
- package/dist/routes/skills.d.ts +11 -1
- package/dist/routes/skills.d.ts.map +1 -1
- package/dist/routes/storage.d.ts +37 -23
- package/dist/routes/storage.d.ts.map +1 -1
- package/dist/routes/test.d.ts +79 -3
- package/dist/routes/test.d.ts.map +1 -1
- package/dist/routes/utilities.d.ts +11 -1
- package/dist/routes/utilities.d.ts.map +1 -1
- package/dist/routes/workflows.d.ts +11 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/services/delivery/adapters/postmark.adapter.d.ts +3 -0
- package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
- package/dist/services/delivery/delivery.service.d.ts +10 -0
- package/dist/services/delivery/delivery.service.d.ts.map +1 -1
- package/dist/types/activity.d.ts +3 -3
- package/dist/types/app-manifest.d.ts +10 -10
- package/dist/types/campaign.d.ts +13 -1
- package/dist/types/campaign.d.ts.map +1 -1
- package/dist/types/consentflowcontract.d.ts +175 -7
- package/dist/types/consentflowcontract.d.ts.map +1 -1
- package/dist/types/credential-refresh.d.ts +27 -0
- package/dist/types/credential-refresh.d.ts.map +1 -1
- package/dist/types/credential.d.ts +5 -0
- package/dist/types/credential.d.ts.map +1 -1
- package/dist/types/integration.d.ts +3 -3
- package/lambda.ts +44 -14
- package/lambdaApp.ts +20 -0
- package/package.json +20 -19
- package/project.json +5 -0
- package/scripts/migrate-consent-flow-versions.ts +9 -0
- package/scripts/report-send-template-exposure.ts +47 -0
- package/scripts/verify-python-contracts.ts +16 -1
- package/serverless-local.yml +15 -2
- package/serverless.function-env.cjs +4 -0
- package/serverless.local.yml +12 -0
- package/serverless.yml +48 -3
- package/src/accesslayer/boost/create.ts +62 -7
- package/src/accesslayer/campaign/README.md +275 -6
- package/src/accesslayer/campaign/audience.ts +39 -7
- package/src/accesslayer/campaign/create.ts +4 -2
- package/src/accesslayer/campaign/delivery.ts +66 -0
- package/src/accesslayer/campaign/dispatch.test.ts +59 -0
- package/src/accesslayer/campaign/dispatch.ts +172 -0
- package/src/accesslayer/campaign/email.test.ts +73 -0
- package/src/accesslayer/campaign/email.ts +91 -0
- package/src/accesslayer/campaign/preferences.ts +53 -0
- package/src/accesslayer/campaign/read.ts +3 -1
- package/src/accesslayer/campaign/state.ts +1 -1
- package/src/accesslayer/campaign/test.test.ts +31 -0
- package/src/accesslayer/campaign/test.ts +75 -0
- package/src/accesslayer/campaign/update.ts +15 -4
- package/src/accesslayer/consentflowcontract/create.ts +20 -1
- package/src/accesslayer/consentflowcontract/delete.ts +121 -3
- package/src/accesslayer/consentflowcontract/read.ts +15 -9
- package/src/accesslayer/consentflowcontract/relationships/create.ts +70 -36
- package/src/accesslayer/consentflowcontract/relationships/read.ts +305 -73
- package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +1 -1
- package/src/accesslayer/consentflowcontract/relationships/update.ts +269 -98
- package/src/accesslayer/consentflowcontract/versions.ts +790 -0
- package/src/accesslayer/credential/create.ts +33 -9
- package/src/accesslayer/credential/delete.ts +45 -0
- package/src/accesslayer/credential/relationships/create.ts +102 -47
- package/src/accesslayer/credential/relationships/update.ts +8 -116
- package/src/accesslayer/credential-refresh/read.ts +26 -2
- package/src/accesslayer/credential-refresh/update.ts +6 -2
- package/src/accesslayer/inbox-credential/create.ts +7 -1
- package/src/accesslayer/inbox-credential/refresh.ts +14 -4
- package/src/campaign-unsubscribe.test.ts +158 -0
- package/src/campaign-unsubscribe.ts +86 -0
- package/src/config/environment.test.ts +52 -0
- package/src/config/environment.ts +58 -0
- package/src/config/serverlessFunctionEnv.test.ts +59 -0
- package/src/credential-refresh.ts +1 -0
- package/src/docker-entry.ts +26 -1
- package/src/helpers/boost-storage.helpers.test.ts +107 -41
- package/src/helpers/boost.helpers.ts +88 -40
- package/src/helpers/campaign-app-origin.helpers.ts +25 -0
- package/src/helpers/campaign-email.helpers.ts +113 -0
- package/src/helpers/campaign-notifications.helpers.ts +127 -0
- package/src/helpers/campaign-queue.helpers.test.ts +442 -0
- package/src/helpers/campaign-queue.helpers.ts +122 -0
- package/src/helpers/campaign-unsubscribe-page.ts +107 -0
- package/src/helpers/campaign-unsubscribe.helpers.ts +56 -0
- package/src/helpers/campaign-webhook.helpers.test.ts +137 -0
- package/src/helpers/consentflow-deadline.helpers.ts +22 -0
- package/src/helpers/consentflow-legacy-migration.helpers.ts +179 -0
- package/src/helpers/contract-events.helpers.ts +35 -3
- package/src/helpers/contract-requests.helpers.ts +1 -1
- package/src/helpers/contract.helpers.ts +5 -1
- package/src/helpers/credential-display.helpers.test.ts +68 -0
- package/src/helpers/credential-display.helpers.ts +15 -0
- package/src/helpers/credential-refresh-email.helpers.ts +5 -12
- package/src/helpers/credential-refresh-materiality.helpers.test.ts +138 -1
- package/src/helpers/credential-refresh-materiality.helpers.ts +62 -1
- package/src/helpers/credential-refresh-proof.helpers.test.ts +1 -1
- package/src/helpers/credential-refresh-proof.helpers.ts +2 -2
- package/src/helpers/credential-refresh.helpers.ts +1166 -376
- package/src/helpers/credential-status-mutation.helpers.test.ts +155 -0
- package/src/helpers/credential-status-mutation.helpers.ts +166 -0
- package/src/helpers/credential.helpers.ts +19 -11
- package/src/helpers/finalize-inbox.helpers.ts +1 -1
- package/src/helpers/inbox-refresh.helpers.ts +34 -8
- package/src/helpers/inbox.helpers.ts +2 -0
- package/src/helpers/learnCard.helpers.ts +23 -0
- package/src/helpers/managed-status.helpers.test.ts +101 -0
- package/src/helpers/managed-status.helpers.ts +147 -0
- package/src/helpers/notification-delivery-boundary.helpers.test.ts +114 -0
- package/src/helpers/notification-webhook-fetch.helpers.test.ts +228 -0
- package/src/helpers/notification-webhook-fetch.helpers.ts +166 -0
- package/src/helpers/notificationQueue.helpers.ts +5 -0
- package/src/helpers/notifications.helpers.ts +19 -12
- package/src/helpers/refresh-send-intent.helpers.test.ts +60 -0
- package/src/helpers/refresh-send-intent.helpers.ts +81 -18
- package/src/helpers/send-contract.helpers.ts +36 -0
- package/src/helpers/status-list.helpers.test.ts +29 -12
- package/src/helpers/status-list.helpers.ts +329 -20
- package/src/models/ConsentFlowContract.ts +3 -0
- package/src/models/ConsentFlowContractVersion.ts +50 -0
- package/src/models/ConsentFlowTerms.ts +4 -0
- package/src/models/Credential.ts +2 -0
- package/src/models/CredentialRefresh.ts +2 -0
- package/src/models/Profile.ts +2 -0
- package/src/models/campaign-constraints.ts +13 -8
- package/src/models/index.ts +2 -0
- package/src/openapi.ts +21 -0
- package/src/routes/app-store.ts +2 -1
- package/src/routes/boosts.ts +219 -137
- package/src/routes/campaigns.ts +98 -19
- package/src/routes/contracts.ts +433 -115
- package/src/routes/credential-input-privacy.test.ts +92 -0
- package/src/routes/credential-refreshes.ts +273 -2
- package/src/routes/federation.ts +7 -2
- package/src/routes/inbox.ts +6 -1
- package/src/routes/index.ts +31 -2
- package/src/routes/workflows.ts +61 -46
- package/src/services/delivery/adapters/postmark.adapter.test.ts +70 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +84 -11
- package/src/services/delivery/delivery.service.ts +4 -0
- package/src/types/campaign.ts +14 -1
- package/src/types/consentflowcontract.ts +43 -2
- package/src/types/credential-refresh.ts +11 -1
- package/src/types/credential.ts +2 -0
- package/test/boost-status-cleanup.spec.ts +123 -0
- package/test/campaigns-email.unit.spec.ts +268 -0
- package/test/campaigns.spec.ts +804 -30
- package/test/consent-autoboost-idempotency.spec.ts +432 -0
- package/test/consent-data-boundaries.spec.ts +322 -26
- package/test/consent-legacy-migration.spec.ts +315 -0
- package/test/consent-recipients.spec.ts +7 -7
- package/test/consent-version-updates.spec.ts +761 -0
- package/test/consentflow.spec.ts +698 -226
- package/test/contract-requests.spec.ts +55 -9
- package/test/credential-refresh-allocation.spec.ts +212 -14
- package/test/credential-refresh-email.spec.ts +6 -10
- package/test/credential-refresh-endpoint.spec.ts +3 -0
- package/test/credential-refresh-publication.spec.ts +52 -15
- package/test/credential-storage.neo4j.spec.ts +33 -6
- package/test/helpers/legacy-autoboost.ts +48 -0
- package/test/holder-mode-send.spec.ts +710 -0
- package/test/inbox-refresh.spec.ts +105 -0
- package/test/inbox-security.e2e.ts +18 -22
- package/test/managed-status-revocation.spec.ts +970 -0
- package/test/notifications-worker.spec.ts +31 -25
- package/test/refresh-send.spec.ts +334 -5
- package/test/smart-resume-consent.spec.ts +1 -1
- package/test/verification/campaigns.ts +40 -0
- package/vitest.campaigns.config.ts +2 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CampaignEmailDeliveryError,
|
|
3
|
+
prepareCampaignEmailDelivery,
|
|
4
|
+
sendCampaignEmail,
|
|
5
|
+
} from './campaign-email.helpers';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
8
|
+
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
|
|
9
|
+
import { TRPCError } from '@trpc/server';
|
|
10
|
+
import { CampaignDispatchJobValidator, type CampaignDispatchJob } from 'types/campaign';
|
|
11
|
+
import { environment } from '@environment';
|
|
12
|
+
import { authorizeConfirmedCampaignDispatch } from '@accesslayer/campaign/dispatch';
|
|
13
|
+
import { getCampaign } from '@accesslayer/campaign/read';
|
|
14
|
+
import { pageCampaignAudience } from '@accesslayer/campaign/audience';
|
|
15
|
+
import { getCampaignDeliveryRecipient } from '@accesslayer/campaign/delivery';
|
|
16
|
+
import { trace } from '@tracing';
|
|
17
|
+
import {
|
|
18
|
+
buildCampaignNotification,
|
|
19
|
+
canDispatchCampaign,
|
|
20
|
+
enqueueCampaignNotification,
|
|
21
|
+
} from './campaign-notifications.helpers';
|
|
22
|
+
|
|
23
|
+
const sqs = new SQSClient({ region: environment.AWS_REGION || 'us-east-1' });
|
|
24
|
+
|
|
25
|
+
/** Local fallback is awaited, including delays; use SQS for durable production waves. */
|
|
26
|
+
export const addCampaignDispatchToQueue = async (
|
|
27
|
+
rawJob: CampaignDispatchJob,
|
|
28
|
+
delaySeconds = 0
|
|
29
|
+
): Promise<void> => {
|
|
30
|
+
const job = CampaignDispatchJobValidator.parse(rawJob);
|
|
31
|
+
if (environment.IS_OFFLINE || !environment.CAMPAIGN_QUEUE_URL) {
|
|
32
|
+
if (delaySeconds) await delay(delaySeconds * 1000);
|
|
33
|
+
await processCampaignDispatchJob(job);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
await sqs.send(
|
|
37
|
+
new SendMessageCommand({
|
|
38
|
+
QueueUrl: environment.CAMPAIGN_QUEUE_URL,
|
|
39
|
+
MessageBody: JSON.stringify(job),
|
|
40
|
+
DelaySeconds: delaySeconds,
|
|
41
|
+
})
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const dispatchCampaign = async (
|
|
46
|
+
campaignId: string,
|
|
47
|
+
expectedPreviewToken?: string
|
|
48
|
+
): Promise<{ dispatchId: string }> => {
|
|
49
|
+
// Keep existing SDK callers compatible; the composer always supplies its reviewed token.
|
|
50
|
+
const campaign = expectedPreviewToken
|
|
51
|
+
? await authorizeConfirmedCampaignDispatch(campaignId, expectedPreviewToken)
|
|
52
|
+
: await getCampaign(campaignId);
|
|
53
|
+
if (!canDispatchCampaign(campaign))
|
|
54
|
+
throw new TRPCError({
|
|
55
|
+
code: 'BAD_REQUEST',
|
|
56
|
+
message: 'Campaign must be live, in its time window, and have a delivery channel',
|
|
57
|
+
});
|
|
58
|
+
const dispatchId = randomUUID();
|
|
59
|
+
if (campaign.channels.includes('email')) await prepareCampaignEmailDelivery();
|
|
60
|
+
for (const channel of ['notify', 'email'] as const) {
|
|
61
|
+
if (campaign.channels.includes(channel))
|
|
62
|
+
await addCampaignDispatchToQueue({
|
|
63
|
+
campaignId,
|
|
64
|
+
campaignCreatedAt: campaign.createdAt,
|
|
65
|
+
dispatchId,
|
|
66
|
+
channel,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return { dispatchId };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const processCampaignDispatchJob = async (rawJob: CampaignDispatchJob): Promise<void> =>
|
|
73
|
+
trace('worker', 'campaign:dispatch', async () => {
|
|
74
|
+
const job = CampaignDispatchJobValidator.parse(rawJob);
|
|
75
|
+
let campaign;
|
|
76
|
+
try {
|
|
77
|
+
campaign = await getCampaign(job.campaignId);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error instanceof TRPCError && error.code === 'NOT_FOUND') return;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
if (campaign.createdAt !== job.campaignCreatedAt || !canDispatchCampaign(campaign)) return;
|
|
83
|
+
if (!campaign.channels.includes(job.channel)) return;
|
|
84
|
+
const emailAdapter =
|
|
85
|
+
job.channel === 'email' ? await prepareCampaignEmailDelivery() : undefined;
|
|
86
|
+
const pageSize =
|
|
87
|
+
job.channel === 'email'
|
|
88
|
+
? environment.CAMPAIGN_EMAIL_PAGE_SIZE
|
|
89
|
+
: environment.CAMPAIGN_DISPATCH_PAGE_SIZE;
|
|
90
|
+
const page = await pageCampaignAudience(
|
|
91
|
+
job.campaignId,
|
|
92
|
+
job.afterProfileId,
|
|
93
|
+
pageSize,
|
|
94
|
+
job.channel === 'email' ? 'email' : undefined
|
|
95
|
+
);
|
|
96
|
+
for (const { profileId } of page.records) {
|
|
97
|
+
if (job.channel === 'email') {
|
|
98
|
+
try {
|
|
99
|
+
await sendCampaignEmail(campaign, profileId, false, emailAdapter);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
// Only post-claim failures can be skipped safely. Configuration,
|
|
102
|
+
// rendering and database failures retry the page without consuming claims.
|
|
103
|
+
if (!(error instanceof CampaignEmailDeliveryError)) throw error;
|
|
104
|
+
console.error('Campaign email delivery failed; reservation retained');
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const recipient = await getCampaignDeliveryRecipient(
|
|
109
|
+
job.campaignId,
|
|
110
|
+
job.campaignCreatedAt,
|
|
111
|
+
profileId
|
|
112
|
+
);
|
|
113
|
+
if (!recipient) continue;
|
|
114
|
+
await enqueueCampaignNotification(await buildCampaignNotification(campaign, recipient));
|
|
115
|
+
}
|
|
116
|
+
if (page.records.length === pageSize) {
|
|
117
|
+
await addCampaignDispatchToQueue(
|
|
118
|
+
{ ...job, afterProfileId: page.records.at(-1)!.profileId },
|
|
119
|
+
environment.CAMPAIGN_DISPATCH_DELAY_SECONDS
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const messages = {
|
|
2
|
+
en: {
|
|
3
|
+
title: 'Email announcements',
|
|
4
|
+
confirm: 'Stop receiving email announcements for your account?',
|
|
5
|
+
success: 'You’re unsubscribed from email announcements.',
|
|
6
|
+
invalid: 'This link is invalid. Try a link from a newer email.',
|
|
7
|
+
error: 'We couldn’t update your preferences. Please try again.',
|
|
8
|
+
unsubscribe: 'Unsubscribe',
|
|
9
|
+
retry: 'Try Again',
|
|
10
|
+
},
|
|
11
|
+
es: {
|
|
12
|
+
title: 'Anuncios por correo electrónico',
|
|
13
|
+
confirm: '¿Quieres dejar de recibir anuncios por correo electrónico?',
|
|
14
|
+
success: 'Ya no recibirás anuncios por correo electrónico.',
|
|
15
|
+
invalid: 'Este enlace no es válido. Prueba un enlace de un correo más reciente.',
|
|
16
|
+
error: 'No pudimos actualizar tus preferencias. Inténtalo de nuevo.',
|
|
17
|
+
unsubscribe: 'Cancelar suscripción',
|
|
18
|
+
retry: 'Intentar de nuevo',
|
|
19
|
+
},
|
|
20
|
+
fr: {
|
|
21
|
+
title: 'Annonces par e-mail',
|
|
22
|
+
confirm: 'Ne plus recevoir d’annonces par e-mail pour votre compte ? ',
|
|
23
|
+
success: 'Vous ne recevrez plus d’annonces par e-mail.',
|
|
24
|
+
invalid: 'Ce lien est invalide. Essayez un lien provenant d’un e-mail plus récent.',
|
|
25
|
+
error: 'Vos préférences n’ont pas pu être mises à jour. Veuillez réessayer.',
|
|
26
|
+
unsubscribe: 'Se désinscrire',
|
|
27
|
+
retry: 'Réessayer',
|
|
28
|
+
},
|
|
29
|
+
ar: {
|
|
30
|
+
title: 'إعلانات البريد الإلكتروني',
|
|
31
|
+
confirm: 'هل تريد إيقاف تلقي إعلانات البريد الإلكتروني لحسابك؟',
|
|
32
|
+
success: 'تم إلغاء اشتراكك في إعلانات البريد الإلكتروني.',
|
|
33
|
+
invalid: 'هذا الرابط غير صالح. جرّب رابطًا من رسالة بريد إلكتروني أحدث.',
|
|
34
|
+
error: 'تعذر تحديث تفضيلاتك. يرجى المحاولة مرة أخرى.',
|
|
35
|
+
unsubscribe: 'إلغاء الاشتراك',
|
|
36
|
+
retry: 'حاول مرة أخرى',
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Standalone anonymous browser flow with no external assets or telemetry. */
|
|
41
|
+
export const renderCampaignUnsubscribePage = (
|
|
42
|
+
state: 'confirm' | 'success' | 'invalid' | 'error',
|
|
43
|
+
token?: string,
|
|
44
|
+
language = 'en'
|
|
45
|
+
): string => {
|
|
46
|
+
const requested = language.toLowerCase().split(/[-,;]/)[0] ?? 'en';
|
|
47
|
+
const locale = Object.hasOwn(messages, requested) ? (requested as keyof typeof messages) : 'en';
|
|
48
|
+
const copy = messages[locale];
|
|
49
|
+
const safeToken = token && /^[A-Za-z0-9_-]{1,2048}$/.test(token) ? token : undefined;
|
|
50
|
+
const content = {
|
|
51
|
+
success: `<p role="status">${copy.success}</p>`,
|
|
52
|
+
error: `<p role="alert">${copy.error}</p>`,
|
|
53
|
+
invalid: `<p role="alert">${copy.invalid}</p>`,
|
|
54
|
+
confirm: `<p>${copy.confirm}</p>`,
|
|
55
|
+
}[state];
|
|
56
|
+
const form =
|
|
57
|
+
state !== 'success' && safeToken
|
|
58
|
+
? `<form method="post" action="/campaigns/unsubscribe/browser">
|
|
59
|
+
<input type="hidden" name="token" value="${safeToken}">
|
|
60
|
+
<button type="submit">${state === 'error' ? copy.retry : copy.unsubscribe}</button>
|
|
61
|
+
</form>`
|
|
62
|
+
: '';
|
|
63
|
+
return `<!doctype html>
|
|
64
|
+
<html lang="${locale}" dir="${locale === 'ar' ? 'rtl' : 'ltr'}">
|
|
65
|
+
<head>
|
|
66
|
+
<meta charset="utf-8">
|
|
67
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
68
|
+
<meta name="referrer" content="no-referrer">
|
|
69
|
+
<title>${copy.title}</title>
|
|
70
|
+
<style>
|
|
71
|
+
body {
|
|
72
|
+
margin: 0;
|
|
73
|
+
background: #EFF0F5;
|
|
74
|
+
color: #18224E;
|
|
75
|
+
font-family: system-ui, sans-serif;
|
|
76
|
+
min-height: 100vh;
|
|
77
|
+
display: grid;
|
|
78
|
+
place-items: center;
|
|
79
|
+
}
|
|
80
|
+
main {
|
|
81
|
+
box-sizing: border-box;
|
|
82
|
+
background: white;
|
|
83
|
+
border-radius: 20px;
|
|
84
|
+
padding: 32px;
|
|
85
|
+
margin: 24px;
|
|
86
|
+
max-width: 480px;
|
|
87
|
+
text-align: center;
|
|
88
|
+
}
|
|
89
|
+
h1 { font-size: 24px; }
|
|
90
|
+
p { color: #6F7590; line-height: 1.6; }
|
|
91
|
+
button {
|
|
92
|
+
background: #18224E;
|
|
93
|
+
color: white;
|
|
94
|
+
border: 0;
|
|
95
|
+
border-radius: 20px;
|
|
96
|
+
padding: 12px 24px;
|
|
97
|
+
font: inherit;
|
|
98
|
+
cursor: pointer;
|
|
99
|
+
}
|
|
100
|
+
button:focus-visible { outline: 3px solid #059669; outline-offset: 3px; }
|
|
101
|
+
</style>
|
|
102
|
+
</head>
|
|
103
|
+
<body>
|
|
104
|
+
<main><h1>${copy.title}</h1>${content}${form}</main>
|
|
105
|
+
</body>
|
|
106
|
+
</html>`;
|
|
107
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { TRPCError } from '@trpc/server';
|
|
3
|
+
import { environment } from '@environment';
|
|
4
|
+
import { unsubscribeCampaignRecipient } from '@accesslayer/campaign/preferences';
|
|
5
|
+
|
|
6
|
+
const recipientIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
7
|
+
|
|
8
|
+
const secret = (): string => {
|
|
9
|
+
const value = environment.CAMPAIGN_UNSUBSCRIBE_SECRET;
|
|
10
|
+
if (!value || Buffer.byteLength(value) < 32)
|
|
11
|
+
throw new Error('Campaign unsubscribe secret requires at least 32 bytes');
|
|
12
|
+
return value;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Opaque, purpose-separated bearer token; it authorizes email opt-out only. */
|
|
16
|
+
export const signCampaignUnsubscribeToken = (unsubscribeId: string, campaignId: string): string => {
|
|
17
|
+
if (!recipientIdPattern.test(unsubscribeId)) throw new Error('Invalid unsubscribe recipient');
|
|
18
|
+
const payload = `v2.${unsubscribeId}.${campaignId}`;
|
|
19
|
+
const signature = createHmac('sha256', secret())
|
|
20
|
+
.update(`campaign-unsubscribe:${payload}`)
|
|
21
|
+
.digest('base64url');
|
|
22
|
+
return Buffer.from(`${payload}.${signature}`).toString('base64url');
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const verifyCampaignUnsubscribeToken = (
|
|
26
|
+
token: string
|
|
27
|
+
): { unsubscribeId: string; campaignId: string } => {
|
|
28
|
+
const invalid = (): never => {
|
|
29
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid unsubscribe link' });
|
|
30
|
+
};
|
|
31
|
+
if (!/^[A-Za-z0-9_-]{1,2048}$/.test(token)) return invalid();
|
|
32
|
+
const decoded = Buffer.from(token, 'base64url').toString('utf8');
|
|
33
|
+
if (Buffer.from(decoded).toString('base64url') !== token) return invalid();
|
|
34
|
+
const parts = decoded.split('.');
|
|
35
|
+
const [version, unsubscribeId = '', campaignId = '', signature = ''] = parts;
|
|
36
|
+
if (
|
|
37
|
+
parts.length !== 4 ||
|
|
38
|
+
version !== 'v2' ||
|
|
39
|
+
!recipientIdPattern.test(unsubscribeId) ||
|
|
40
|
+
!/^[a-z0-9-]{3,64}$/.test(campaignId) ||
|
|
41
|
+
!/^[A-Za-z0-9_-]{43}$/.test(signature)
|
|
42
|
+
)
|
|
43
|
+
return invalid();
|
|
44
|
+
const expected = signCampaignUnsubscribeToken(unsubscribeId, campaignId);
|
|
45
|
+
const a = Buffer.from(token),
|
|
46
|
+
b = Buffer.from(expected);
|
|
47
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) return invalid();
|
|
48
|
+
return { unsubscribeId, campaignId };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** No campaign lookup: old links remain valid after campaign archive/deletion. */
|
|
52
|
+
export const unsubscribeFromCampaignEmails = async (token: string): Promise<boolean> => {
|
|
53
|
+
const { unsubscribeId } = verifyCampaignUnsubscribeToken(token);
|
|
54
|
+
await unsubscribeCampaignRecipient(unsubscribeId);
|
|
55
|
+
return true;
|
|
56
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { Campaign, LCNNotification } from '@learncard/types';
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({
|
|
5
|
+
fetch: vi.fn(),
|
|
6
|
+
acknowledge: vi.fn(),
|
|
7
|
+
getCampaign: vi.fn(),
|
|
8
|
+
recipient: vi.fn(),
|
|
9
|
+
environment: {
|
|
10
|
+
NODE_ENV: 'test',
|
|
11
|
+
IS_OFFLINE: false,
|
|
12
|
+
DOMAIN_NAME: 'brain.example',
|
|
13
|
+
NOTIFICATIONS_SERVICE_WEBHOOK_URL: 'https://inbox.example/notifications',
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock('./notification-webhook-fetch.helpers', () => ({
|
|
18
|
+
fetchNotificationWebhook: (...args: unknown[]) => mocks.fetch(...args),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock('@environment', () => ({
|
|
22
|
+
environment: mocks.environment,
|
|
23
|
+
getNotificationRuntimeEnvironment: () => mocks.environment,
|
|
24
|
+
}));
|
|
25
|
+
vi.mock('@helpers/learnCard.helpers', () => ({
|
|
26
|
+
getDidWebLearnCard: async () => ({
|
|
27
|
+
id: { did: () => 'did:web:brain.example' },
|
|
28
|
+
invoke: { getDidAuthVp: async () => 'test-jwt' },
|
|
29
|
+
}),
|
|
30
|
+
}));
|
|
31
|
+
vi.mock('@helpers/did.helpers', () => ({
|
|
32
|
+
getDidWeb: (domain: string, profileId: string) => `did:web:${domain}:users:${profileId}`,
|
|
33
|
+
}));
|
|
34
|
+
vi.mock('@accesslayer/inbox-credential/relationships/create', () => ({
|
|
35
|
+
createWebhookSentRelationship: vi.fn(),
|
|
36
|
+
}));
|
|
37
|
+
vi.mock('@cache', () => ({ default: {} }));
|
|
38
|
+
// These suites exercise notification delivery without email transport or a database.
|
|
39
|
+
vi.mock('@helpers/campaign-email.helpers', () => ({ sendCampaignEmail: vi.fn() }));
|
|
40
|
+
vi.mock('@accesslayer/campaign/preferences', () => ({
|
|
41
|
+
getAnnouncementPreferences: async () => ({
|
|
42
|
+
announcementEmailOptOut: false,
|
|
43
|
+
announcementPushOptOut: false,
|
|
44
|
+
}),
|
|
45
|
+
}));
|
|
46
|
+
vi.mock('@accesslayer/campaign/read', () => ({ getCampaign: mocks.getCampaign }));
|
|
47
|
+
vi.mock('@accesslayer/campaign/test', () => ({ queueCampaignInAppTest: vi.fn() }));
|
|
48
|
+
vi.mock('@accesslayer/campaign/delivery', () => ({
|
|
49
|
+
getCampaignDeliveryRecipient: mocks.recipient,
|
|
50
|
+
acknowledgeCampaignNotification: mocks.acknowledge,
|
|
51
|
+
}));
|
|
52
|
+
vi.mock('@helpers/contract-events.helpers', () => ({
|
|
53
|
+
authorizeContractNotification: async () => true,
|
|
54
|
+
}));
|
|
55
|
+
vi.mock('@helpers/connectionPrompt.helpers', () => ({
|
|
56
|
+
acknowledgeConnectionPromptNotificationDelivery: vi.fn(),
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
import { buildCampaignNotification } from './campaign-notifications.helpers';
|
|
60
|
+
import { deliverQueuedNotification } from './notificationQueue.helpers';
|
|
61
|
+
|
|
62
|
+
const campaign = {
|
|
63
|
+
id: 'webhook-pilot',
|
|
64
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
65
|
+
status: 'live',
|
|
66
|
+
channels: ['notify'],
|
|
67
|
+
notification: { title: 'Explore', body: 'Find your next step' },
|
|
68
|
+
} as Campaign;
|
|
69
|
+
const recipient = {
|
|
70
|
+
profileId: 'learner',
|
|
71
|
+
did: 'did:web:brain.example:users:learner',
|
|
72
|
+
notificationsWebhook: 'https://receiver.example/notifications',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
vi.resetAllMocks();
|
|
77
|
+
mocks.environment.NOTIFICATIONS_SERVICE_WEBHOOK_URL = 'https://inbox.example/notifications';
|
|
78
|
+
mocks.getCampaign.mockResolvedValue(campaign);
|
|
79
|
+
mocks.recipient.mockResolvedValue(recipient);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('campaign custom webhook storage contract', () => {
|
|
83
|
+
it.each([
|
|
84
|
+
true,
|
|
85
|
+
{ sent: true },
|
|
86
|
+
{ success: true },
|
|
87
|
+
{ ok: true },
|
|
88
|
+
{ data: { success: true } },
|
|
89
|
+
{ result: { data: { sent: true } } },
|
|
90
|
+
])('acknowledges recognized durable storage response %j', async response => {
|
|
91
|
+
mocks.fetch.mockResolvedValue(new Response(JSON.stringify(response)));
|
|
92
|
+
const notification = await buildCampaignNotification(campaign, recipient);
|
|
93
|
+
|
|
94
|
+
await deliverQueuedNotification(JSON.stringify(notification));
|
|
95
|
+
|
|
96
|
+
expect(mocks.fetch).toHaveBeenCalledWith(
|
|
97
|
+
recipient.notificationsWebhook,
|
|
98
|
+
expect.objectContaining({ method: 'POST' }),
|
|
99
|
+
false
|
|
100
|
+
);
|
|
101
|
+
expect(mocks.acknowledge).toHaveBeenCalledOnce();
|
|
102
|
+
expect(mocks.acknowledge).toHaveBeenCalledWith(
|
|
103
|
+
expect.objectContaining({
|
|
104
|
+
data: notification.data,
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it.each([
|
|
110
|
+
{ status: 204, body: null },
|
|
111
|
+
{ status: 200, body: '' },
|
|
112
|
+
{ status: 202, body: 'accepted' },
|
|
113
|
+
{ status: 200, body: '{"status":"ok"}' },
|
|
114
|
+
{ status: 200, body: '{"sent":false}' },
|
|
115
|
+
{ status: 200, body: '{"success":false}' },
|
|
116
|
+
{ status: 200, body: '{"ok":false}' },
|
|
117
|
+
])('retries without counting storage for $status / $body', async ({ status, body }) => {
|
|
118
|
+
mocks.fetch.mockResolvedValueOnce(new Response(body, { status }));
|
|
119
|
+
const notification = await buildCampaignNotification(campaign, recipient);
|
|
120
|
+
const queuedBody = JSON.stringify(notification);
|
|
121
|
+
|
|
122
|
+
await expect(deliverQueuedNotification(queuedBody)).rejects.toThrow();
|
|
123
|
+
expect(mocks.acknowledge).not.toHaveBeenCalled();
|
|
124
|
+
|
|
125
|
+
mocks.fetch.mockResolvedValueOnce(new Response('{"sent":true}'));
|
|
126
|
+
await deliverQueuedNotification(queuedBody);
|
|
127
|
+
expect(mocks.fetch).toHaveBeenCalledTimes(2);
|
|
128
|
+
expect(mocks.acknowledge).toHaveBeenCalledOnce();
|
|
129
|
+
const attempts = mocks.fetch.mock.calls.map(
|
|
130
|
+
([, request]) => JSON.parse(request.body) as LCNNotification
|
|
131
|
+
);
|
|
132
|
+
// A receiver can deduplicate even if its first storage acknowledgement was lost.
|
|
133
|
+
expect(attempts[1]!.data?.metadata).toEqual(attempts[0]!.data?.metadata);
|
|
134
|
+
expect(attempts[1]!.to.did).toBe(attempts[0]!.to.did);
|
|
135
|
+
expect(attempts[1]!.from).toEqual(attempts[0]!.from);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { neogma } from '@instance';
|
|
2
|
+
|
|
3
|
+
export type ConsentFlowDeadlineSummary = { suspended: number };
|
|
4
|
+
|
|
5
|
+
/** Move expired pending re-consents to suspended; safe to run repeatedly. */
|
|
6
|
+
export const suspendExpiredConsentFlowTerms = async (
|
|
7
|
+
now = new Date().toISOString()
|
|
8
|
+
): Promise<ConsentFlowDeadlineSummary> => {
|
|
9
|
+
const result = await neogma.queryRunner.run(
|
|
10
|
+
`MATCH (terms:ConsentFlowTerms)
|
|
11
|
+
WHERE terms.status = 'needs_reconsent'
|
|
12
|
+
AND terms.reconsentDeadline IS NOT NULL
|
|
13
|
+
AND terms.reconsentDeadline <= $now
|
|
14
|
+
SET terms.status = 'suspended', terms.updatedAt = $now
|
|
15
|
+
RETURN count(terms) AS suspended`,
|
|
16
|
+
{ now }
|
|
17
|
+
);
|
|
18
|
+
const value = result.records[0]?.get('suspended');
|
|
19
|
+
return {
|
|
20
|
+
suspended: typeof value?.toNumber === 'function' ? value.toNumber() : Number(value ?? 0),
|
|
21
|
+
};
|
|
22
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { neogma } from '@instance';
|
|
2
|
+
import { inflateObject } from '@helpers/objects.helpers';
|
|
3
|
+
import { v4 as uuid } from 'uuid';
|
|
4
|
+
import { ConsentFlowContractValidator } from '@learncard/types';
|
|
5
|
+
import type { ConsentFlowContract } from '@learncard/types';
|
|
6
|
+
|
|
7
|
+
/** Recovers explicit review baselines without mistaking quiet publications or syncs for approval. */
|
|
8
|
+
const backfillConsentReviewBaselines = async (): Promise<void> => {
|
|
9
|
+
const marker = await neogma.queryRunner.run(`
|
|
10
|
+
MATCH (migration:ConsentFlowContractMigration {id:'holder-review-v1'})
|
|
11
|
+
WHERE migration.completedAt IS NOT NULL
|
|
12
|
+
RETURN migration.completedAt AS completedAt
|
|
13
|
+
`);
|
|
14
|
+
if (marker.records.length) return;
|
|
15
|
+
await neogma.queryRunner.run(`
|
|
16
|
+
MERGE (migration:ConsentFlowContractMigration {id:'holder-review-v1'})
|
|
17
|
+
SET migration._reviewLock=coalesce(migration._reviewLock,0)+1
|
|
18
|
+
WITH migration
|
|
19
|
+
CALL {
|
|
20
|
+
WITH migration
|
|
21
|
+
MATCH (terms:ConsentFlowTerms)-[:CONSENTS_TO]->(contract:ConsentFlowContract)
|
|
22
|
+
WHERE migration.completedAt IS NULL AND terms.lastReviewedVersion IS NULL
|
|
23
|
+
OPTIONAL MATCH (transaction:ConsentFlowTransaction)-[:IS_FOR]->(terms)
|
|
24
|
+
WHERE transaction.action IN ['consent','reconsent']
|
|
25
|
+
OR (transaction.action='update' AND transaction.status IS NOT NULL)
|
|
26
|
+
WITH terms, contract, max(transaction.date) AS reviewedAt
|
|
27
|
+
OPTIONAL MATCH (contract)-[:HAS_VERSION]->(version:ConsentFlowContractVersion)
|
|
28
|
+
WHERE version.version <= coalesce(terms.consentedVersion,1)
|
|
29
|
+
AND version.createdAt <= coalesce(reviewedAt,terms.createdAt,version.createdAt)
|
|
30
|
+
WITH terms, max(version.version) AS reviewedVersion
|
|
31
|
+
SET terms.lastReviewedVersion=coalesce(terms.lastReviewedVersion,reviewedVersion,1)
|
|
32
|
+
RETURN count(terms) AS initialized
|
|
33
|
+
}
|
|
34
|
+
SET migration.completedAt=coalesce(migration.completedAt,toString(datetime()))
|
|
35
|
+
REMOVE migration._reviewLock
|
|
36
|
+
RETURN initialized
|
|
37
|
+
`);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Backfills v1 snapshots and legacy consent markers idempotently. */
|
|
41
|
+
export const migrateLegacyConsentFlowContracts = async (contractId?: string): Promise<void> => {
|
|
42
|
+
const scoped = contractId !== undefined;
|
|
43
|
+
if (!scoped) {
|
|
44
|
+
const marker = await neogma.queryRunner.run(
|
|
45
|
+
`MATCH (migration:ConsentFlowContractMigration {id:'legacy-v1'})
|
|
46
|
+
WHERE migration.completedAt IS NOT NULL
|
|
47
|
+
RETURN migration.completedAt AS completedAt`
|
|
48
|
+
);
|
|
49
|
+
if (marker.records.length > 0) {
|
|
50
|
+
await backfillConsentReviewBaselines();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const legacy = await neogma.queryRunner.run(
|
|
55
|
+
`
|
|
56
|
+
MATCH (contract:ConsentFlowContract)
|
|
57
|
+
WHERE ($contractId IS NULL OR contract.id=$contractId)
|
|
58
|
+
AND contract.currentVersion IS NULL
|
|
59
|
+
AND NOT EXISTS { MATCH (contract)-[:HAS_VERSION]->(:ConsentFlowContractVersion) }
|
|
60
|
+
OPTIONAL MATCH (contract)-[autoReceive:AUTO_RECEIVE]->(boost:Boost)
|
|
61
|
+
WITH contract, collect(CASE WHEN boost IS NULL THEN null ELSE {
|
|
62
|
+
id: boost.id, signingAuthorityEndpoint: coalesce(autoReceive.signingAuthorityEndpoint, 'default'),
|
|
63
|
+
signingAuthorityName: coalesce(autoReceive.signingAuthorityName, 'default'), issuer: autoReceive.issuer
|
|
64
|
+
} END) AS rawAutoboosts
|
|
65
|
+
OPTIONAL MATCH (contract)-[:CAN_WRITE]->(writer:Profile)
|
|
66
|
+
WITH contract, rawAutoboosts, collect(DISTINCT writer.profileId) AS writers
|
|
67
|
+
RETURN properties(contract) AS properties,
|
|
68
|
+
[config IN rawAutoboosts WHERE config IS NOT NULL] AS autoboosts,
|
|
69
|
+
writers
|
|
70
|
+
`,
|
|
71
|
+
{ contractId: contractId ?? null }
|
|
72
|
+
);
|
|
73
|
+
if (scoped && !legacy.records.length) return;
|
|
74
|
+
let skippedCount = 0;
|
|
75
|
+
const skipped: { contractId: string; reason: string }[] = [];
|
|
76
|
+
for (const record of legacy.records) {
|
|
77
|
+
const properties = record.get('properties') as Record<string, unknown>;
|
|
78
|
+
const contractProps = Object.fromEntries(
|
|
79
|
+
Object.entries(properties)
|
|
80
|
+
.filter(([key]) => key.startsWith('contract.'))
|
|
81
|
+
.map(([key, value]) => [key.slice('contract.'.length), value])
|
|
82
|
+
);
|
|
83
|
+
let contract: ConsentFlowContract;
|
|
84
|
+
try {
|
|
85
|
+
contract = ConsentFlowContractValidator.parse(
|
|
86
|
+
inflateObject<Record<string, unknown>>(contractProps)
|
|
87
|
+
);
|
|
88
|
+
} catch {
|
|
89
|
+
skippedCount += 1;
|
|
90
|
+
if (skipped.length < 25)
|
|
91
|
+
skipped.push({ contractId: String(properties.id), reason: 'invalid_contract' });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const autoboosts = record.get('autoboosts') as StoredAutoBoost[];
|
|
95
|
+
const writers = (record.get('writers') as (string | null)[]).filter(
|
|
96
|
+
(writer): writer is string => !!writer
|
|
97
|
+
);
|
|
98
|
+
await neogma.queryRunner.run(
|
|
99
|
+
`MATCH (contract:ConsentFlowContract {id:$id})
|
|
100
|
+
WHERE contract.currentVersion IS NULL
|
|
101
|
+
AND NOT EXISTS { MATCH (contract)-[:HAS_VERSION]->(:ConsentFlowContractVersion) }
|
|
102
|
+
SET contract._legacyMigrationLock = true
|
|
103
|
+
REMOVE contract._legacyMigrationLock
|
|
104
|
+
WITH contract
|
|
105
|
+
WHERE contract.currentVersion IS NULL
|
|
106
|
+
AND NOT EXISTS { MATCH (contract)-[:HAS_VERSION]->(:ConsentFlowContractVersion) }
|
|
107
|
+
CREATE (version:ConsentFlowContractVersion {
|
|
108
|
+
id:$versionId, version:1, contract:$contractSnapshot, name:$name,
|
|
109
|
+
subtitle:$subtitle, description:$description, reasonForAccessing:$reasonForAccessing,
|
|
110
|
+
image:$image, needsGuardianConsent:$needsGuardianConsent, redirectUrl:$redirectUrl,
|
|
111
|
+
frontDoorBoostUri:$frontDoorBoostUri, expiresAt:$expiresAt,
|
|
112
|
+
autoboosts:$autoboostSnapshot, writers:$writerSnapshot, createdAt:$createdAt
|
|
113
|
+
})
|
|
114
|
+
CREATE (contract)-[:HAS_VERSION]->(version)
|
|
115
|
+
SET contract.currentVersion=1
|
|
116
|
+
WITH contract
|
|
117
|
+
MATCH (terms:ConsentFlowTerms)-[:CONSENTS_TO]->(contract)
|
|
118
|
+
SET terms.consentedVersion=coalesce(terms.consentedVersion,1),
|
|
119
|
+
terms.lastReviewedVersion=coalesce(terms.lastReviewedVersion,terms.consentedVersion,1),
|
|
120
|
+
terms.status=CASE WHEN terms.status='stale' THEN 'consumed' ELSE terms.status END`,
|
|
121
|
+
{
|
|
122
|
+
id: String(properties.id),
|
|
123
|
+
versionId: uuid(),
|
|
124
|
+
contractSnapshot: JSON.stringify(contract),
|
|
125
|
+
autoboostSnapshot: JSON.stringify(autoboosts),
|
|
126
|
+
writerSnapshot: JSON.stringify(writers),
|
|
127
|
+
createdAt: String(
|
|
128
|
+
properties.updatedAt ?? properties.createdAt ?? new Date().toISOString()
|
|
129
|
+
),
|
|
130
|
+
name: properties.name,
|
|
131
|
+
subtitle: properties.subtitle ?? null,
|
|
132
|
+
description: properties.description ?? null,
|
|
133
|
+
reasonForAccessing: properties.reasonForAccessing ?? null,
|
|
134
|
+
image: properties.image ?? null,
|
|
135
|
+
needsGuardianConsent: properties.needsGuardianConsent ?? false,
|
|
136
|
+
redirectUrl: properties.redirectUrl ?? null,
|
|
137
|
+
frontDoorBoostUri: properties.frontDoorBoostUri ?? null,
|
|
138
|
+
expiresAt: properties.expiresAt ?? null,
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (!scoped) {
|
|
143
|
+
await neogma.queryRunner.run(`
|
|
144
|
+
MATCH (contract:ConsentFlowContract)-[:HAS_VERSION]->(:ConsentFlowContractVersion {version:1})
|
|
145
|
+
WHERE contract.currentVersion IS NULL
|
|
146
|
+
SET contract.currentVersion=1
|
|
147
|
+
`);
|
|
148
|
+
await neogma.queryRunner.run(`
|
|
149
|
+
MATCH (terms:ConsentFlowTerms)-[:CONSENTS_TO]->(:ConsentFlowContract)
|
|
150
|
+
WHERE terms.consentedVersion IS NULL
|
|
151
|
+
SET terms.consentedVersion=1
|
|
152
|
+
`);
|
|
153
|
+
await neogma.queryRunner.run(`
|
|
154
|
+
MATCH (terms:ConsentFlowTerms)
|
|
155
|
+
WHERE terms.status='stale'
|
|
156
|
+
SET terms.status='consumed'
|
|
157
|
+
`);
|
|
158
|
+
await backfillConsentReviewBaselines();
|
|
159
|
+
}
|
|
160
|
+
if (skippedCount) {
|
|
161
|
+
console.warn('Skipped malformed legacy ConsentFlow contracts', {
|
|
162
|
+
count: skippedCount,
|
|
163
|
+
contracts: skipped,
|
|
164
|
+
truncated: skippedCount > skipped.length,
|
|
165
|
+
});
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (scoped) return;
|
|
169
|
+
await neogma.queryRunner.run(`
|
|
170
|
+
MERGE (migration:ConsentFlowContractMigration {id:'legacy-v1'})
|
|
171
|
+
SET migration.completedAt=coalesce(migration.completedAt, toString(datetime()))
|
|
172
|
+
`);
|
|
173
|
+
};
|
|
174
|
+
type StoredAutoBoost = {
|
|
175
|
+
id: string;
|
|
176
|
+
signingAuthorityEndpoint?: string;
|
|
177
|
+
signingAuthorityName?: string;
|
|
178
|
+
issuer?: string;
|
|
179
|
+
};
|