@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
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# Campaigns: setup, audiences and engagement (LC-2265)
|
|
2
2
|
|
|
3
|
-
This
|
|
4
|
-
state. It does not
|
|
5
|
-
`sendTestToMe`; LC-2267 owns app integration. It does not require the in-app messages V2
|
|
3
|
+
This service stores campaign definitions, server-selected audiences, shared engagement
|
|
4
|
+
state and paced notification delivery. LC-2267 owns app rendering and tap routing. It does not require the in-app messages V2
|
|
6
5
|
branches: `message` uses the shared `InAppMessage` schema available in this checkout.
|
|
7
6
|
|
|
8
7
|
## Administration and lifecycle
|
|
@@ -34,7 +33,7 @@ the campaign and its relationships.
|
|
|
34
33
|
Use a new ID for a new campaign; deleting/reusing an ID cannot clear clients' local seen state.
|
|
35
34
|
|
|
36
35
|
`channels` must contain `inApp`, `notify`, or both without duplicates. `notify` requires
|
|
37
|
-
`notification: { title, body }`, but
|
|
36
|
+
`notification: { title, body }`, but sends only after an explicit test or dispatch. Dates are ISO
|
|
38
37
|
timestamps normalized to UTC; `startsAt` is inclusive and `endsAt` is exclusive. If both
|
|
39
38
|
exist, the end must follow the start. Partial updates preserve omitted fields; `null`
|
|
40
39
|
clears optional notification/start/end fields. Updates replace `message` as a whole.
|
|
@@ -144,7 +143,7 @@ duplicate state edges and lost increments under concurrent devices. Repeated req
|
|
|
144
143
|
are separate events, not transport-idempotent retries.
|
|
145
144
|
|
|
146
145
|
Stats count distinct profiles, not impressions; `enrolled` counts memberships originally
|
|
147
|
-
created by self-enrollment. `notified`
|
|
146
|
+
created by self-enrollment. `notified` counts durable inbox acknowledgements and is not client-writable.
|
|
148
147
|
All user/discovery calls share a 60-call/profile/minute rate limit; admin operations do not.
|
|
149
148
|
|
|
150
149
|
The network plugin exposes `learnCard.invoke.getMyCampaigns()`,
|
|
@@ -166,9 +165,279 @@ does not read the developer's database. Docker is required. The suite covers all
|
|
|
166
165
|
types, permissions, injection rejection, minor rules, paging, lifecycle, enrollment and
|
|
167
166
|
concurrent shared state. Schema tests live in `@learncard/types`; SDK forwarding tests
|
|
168
167
|
live in `@learncard/network-plugin`. Hosted CI runs the Neo4j suite separately from the
|
|
169
|
-
service's default unit suite. No app UI
|
|
168
|
+
service's default unit suite. No app UI or device QA is claimed. Delivery coverage is described below.
|
|
170
169
|
|
|
171
170
|
The hosted Linux verification workflow also runs `test/verification/campaigns.ts` via
|
|
172
171
|
`lc verify script` after the full catalog: real signed SDK calls prove non-admin denial
|
|
173
172
|
and user isolation over HTTP. The default stack grants no admin access, so positive
|
|
174
173
|
administration and graph mutations are covered by the dedicated Neo4j suite instead.
|
|
174
|
+
|
|
175
|
+
## Notification delivery (LC-2266)
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
// Allowlisted administrator, with campaigns:admin scope.
|
|
179
|
+
await learnCard.invoke.campaignsSendTestToMe('transcript-pilot-v1');
|
|
180
|
+
await learnCard.invoke.campaignsSetStatus('transcript-pilot-v1', 'live');
|
|
181
|
+
const { dispatchId } = await learnCard.invoke.campaignsDispatch('transcript-pilot-v1');
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`sendTestToMe` always targets the authenticated administrator and tests the selected channels.
|
|
185
|
+
It ignores audience, status and dates. `notify` uses the saved notification copy and existing
|
|
186
|
+
notification queue; `inApp` stores a private snapshot on a `CAMPAIGN_TEST` relationship.
|
|
187
|
+
In-app JSON is optional for notification-only campaigns, and notification copy is optional
|
|
188
|
+
for in-app-only campaigns. At least one channel and its corresponding content are required.
|
|
189
|
+
Each explicit test gets a fresh test ID; notification queue retries preserve it.
|
|
190
|
+
|
|
191
|
+
The existing `getMyCampaigns` endpoint includes pending private snapshots only for their
|
|
192
|
+
requesting administrator, with a `testId`. Reading does not consume a test. After the real
|
|
193
|
+
renderer presents it, `recordEvent` with `{ campaignId, event: 'shown', testId } removes that
|
|
194
|
+
snapshot without writing engagement state. A stale acknowledgement cannot remove a newer
|
|
195
|
+
snapshot. The latest test replaces the pending one for that administrator/campaign; deleting
|
|
196
|
+
the campaign deletes its pending tests. LearnCard App fetches on entry/return outside the
|
|
197
|
+
composer, keeps tests account/network-scoped, and does not send their events to analytics.
|
|
198
|
+
Test sends neither enroll the caller nor increment campaign stats.
|
|
199
|
+
`dispatch`requires a live, in-window campaign with the`notify` channel. The queue job
|
|
200
|
+
contains campaign ID, creation timestamp, dispatch ID and an optional profile-ID cursor.
|
|
201
|
+
The creation timestamp prevents old jobs from applying to a deleted/recreated campaign.
|
|
202
|
+
|
|
203
|
+
Audience pages are ordered by profile ID, reapply the existing age policy and default to
|
|
204
|
+
1000 recipients. Full pages schedule the next cursor after 60 seconds. Thus 2500 recipients
|
|
205
|
+
use three waves; an exact multiple can schedule an empty final page. The defaults are
|
|
206
|
+
configurable through `CAMPAIGN_DISPATCH_PAGE_SIZE` and `CAMPAIGN_DISPATCH_DELAY_SECONDS`.
|
|
207
|
+
A re-dispatch starts at the beginning, including newly added members with earlier IDs,
|
|
208
|
+
and skips recipients with `notifiedAt`.
|
|
209
|
+
|
|
210
|
+
Announcements include `metadata.campaignHasInApp`, true only for an enabled in-app
|
|
211
|
+
channel on an ordinary delivery. Notification-only campaigns, disabled messages,
|
|
212
|
+
and admin notification self-tests fall back to the inbox. Clients require an explicit
|
|
213
|
+
true flag before routing to `/m/:id` or showing the inbox card's View action. Older
|
|
214
|
+
stored announcements without the flag also stay in the inbox.
|
|
215
|
+
|
|
216
|
+
The worker sends ANNOUNCEMENT via the existing notification queue. Each recipient is
|
|
217
|
+
checked again before enqueue and before webhook delivery. Paused, archived, expired,
|
|
218
|
+
deleted, no-longer-notify and age-ineligible campaigns/recipients stop future delivery;
|
|
219
|
+
a send already in flight at a pause can still complete. Resume with an explicit dispatch.
|
|
220
|
+
The local fallback awaits all waves and their delays and loses unfinished work on process
|
|
221
|
+
exit; use a small page size/delay during development and SQS for production.
|
|
222
|
+
|
|
223
|
+
### Retry and statistics boundary
|
|
224
|
+
|
|
225
|
+
Enqueue and Neo4j are separate systems: duplicate queue messages are expected after a
|
|
226
|
+
lost enqueue response, concurrent dispatches, worker retries or acknowledgement failure.
|
|
227
|
+
There is deliberately no pre-send `notifiedAt` marker that could silently lose deliveries.
|
|
228
|
+
lca-api atomically inserts one inbox record using its Mongo `_id` uniqueness constraint,
|
|
229
|
+
keyed by sender, recipient, campaign ID and creation timestamp (plus test ID for tests).
|
|
230
|
+
Only the inserting request attempts push. Retries preserve existing read/archive state
|
|
231
|
+
and copy. This is a narrowly approved extension of the ticket's original enum-only scope;
|
|
232
|
+
it leaves the existing FCM delivery path intact.
|
|
233
|
+
|
|
234
|
+
After lca-api acknowledges durable storage, brain records `notifiedAt` under the same
|
|
235
|
+
profile lock as engagement writes. Queue acceptance alone is not counted. If that graph
|
|
236
|
+
write fails, the notification retries; lca-api returns success for the existing record
|
|
237
|
+
without another push attempt. Stats measure acknowledged inbox storage, **not** device
|
|
238
|
+
receipt. FCM remains best effort: a crash between inbox insertion and push, or a push
|
|
239
|
+
failure, can leave an inbox card without push. There is no exactly-once device guarantee.
|
|
240
|
+
Receiver deduplication lasts while the inbox record exists; hard-deleting it before an
|
|
241
|
+
unacknowledged retry can permit reinsertion. Production pilot QA must include this existing
|
|
242
|
+
notification retention limitation.
|
|
243
|
+
|
|
244
|
+
### Custom webhook compatibility
|
|
245
|
+
|
|
246
|
+
Audience selection does not exclude profiles with a custom `notificationsWebhook`.
|
|
247
|
+
Their endpoint takes precedence over the default lca-api webhook in hosted environments;
|
|
248
|
+
do not assume every audience member uses the LearnCard inbox. An arbitrary HTTP 2xx
|
|
249
|
+
response is not proof of durable storage for ANNOUNCEMENT notifications. Compatible
|
|
250
|
+
receivers must persist the notification before returning JSON `true`, or a boolean
|
|
251
|
+
`sent`, `success`, or `ok` flag set to `true` (also accepted under `data` or `result.data`).
|
|
252
|
+
An explicit `false` is a negative acknowledgement. Empty responses (including 204),
|
|
253
|
+
plain text, and unrecognized JSON such as `{"status":"ok"}` remain uncertain and retry;
|
|
254
|
+
they do not advance `notifiedAt`.
|
|
255
|
+
|
|
256
|
+
Custom receivers must deduplicate retries by sender DID, recipient DID, `campaignId`,
|
|
257
|
+
and `campaignCreatedAt`, adding `campaignTestId` for test sends. Return the same positive
|
|
258
|
+
acknowledgement for an already stored notification without repeating downstream effects.
|
|
259
|
+
Only lca-api's implementation is covered by the inbox/push deduplication guarantee above.
|
|
260
|
+
For a custom receiver, `notified` means its durable-storage acknowledgement was received,
|
|
261
|
+
not that a LearnCard inbox card or device push exists. Before including custom-webhook
|
|
262
|
+
profiles in a pilot, confirm this contract with the receiver owner using disposable test
|
|
263
|
+
profiles; exclude incompatible receivers from the pilot audience until adapted. Production
|
|
264
|
+
audiences and third-party implementations have not been inventoried by this change.
|
|
265
|
+
|
|
266
|
+
### Worker network access
|
|
267
|
+
|
|
268
|
+
The shared notifications worker uses `PrivateSubnetA` because campaign eligibility and
|
|
269
|
+
acknowledgement need private graph/cache access. This applies to all notification types.
|
|
270
|
+
`serverless.yml` declares the outbound path as `PrivateSubnetA` → `PrivateRouteTable`
|
|
271
|
+
→ `PrivateRoute` (`0.0.0.0/0`) → `NatGateway` in `PublicSubnetA` → `PublicRouteTable`
|
|
272
|
+
→ `PublicRoute` → `InternetGateway` attached to the VPC. The NAT has an Elastic IP;
|
|
273
|
+
`ServerlessSecurityGroup` uses AWS's default outbound allowance. A configuration
|
|
274
|
+
regression test protects this complete path.
|
|
275
|
+
|
|
276
|
+
Private connectivity does not authorize profile-selected HTTP destinations. All hosted
|
|
277
|
+
notification delivery (ordinary notifications, campaigns, explicit queue overrides and
|
|
278
|
+
the default configured webhook) goes through `notification-webhook-fetch.helpers.ts`.
|
|
279
|
+
It accepts HTTP(S) URLs without credentials, rejects nonpublic IP literals, and checks
|
|
280
|
+
every DNS answer before pinning the HTTP connection to a validated literal IP. HTTP Host,
|
|
281
|
+
TLS SNI and certificate verification retain the original hostname. Mixed public/private
|
|
282
|
+
answers and resolution errors fail closed; every attempt resolves again. The transport
|
|
283
|
+
never resolves the hostname a second time, preventing DNS rebinding.
|
|
284
|
+
The IPv6 policy permits global unicast only, excluding special-purpose, documentation
|
|
285
|
+
and transition ranges. Redirects are rejected, so neither the JWT nor notification body
|
|
286
|
+
can be forwarded to a redirect target. The explicit HTTP(S) transport avoids runtime fetch dispatcher differences
|
|
287
|
+
on Node and Bun, and caps acknowledgement bodies at 64 KiB. A blocked delivery does not advance `notifiedAt`; queue failures retain
|
|
288
|
+
the existing retry/dead-letter behavior.
|
|
289
|
+
|
|
290
|
+
Hosted custom webhooks must resolve exclusively to public addresses and respond directly
|
|
291
|
+
at the configured URL. Private default endpoints are also rejected; no profile or queued
|
|
292
|
+
payload can opt out. Only the trusted `IS_OFFLINE` runtime flag permits local development
|
|
293
|
+
destinations, with redirects still disabled. `NODE_ENV=test` is not an exemption.
|
|
294
|
+
|
|
295
|
+
This establishes the source configuration, not the deployed AWS state. Before rollout,
|
|
296
|
+
verify the target stack's subnet/route associations, available NAT and internet gateway,
|
|
297
|
+
and effective security-group/network-ACL egress, then exercise an operator-controlled
|
|
298
|
+
external webhook from that worker using a disposable profile. Check both an ordinary
|
|
299
|
+
notification and a campaign acknowledgement. Local and hosted CI do not prove live NAT
|
|
300
|
+
connectivity; no deployment or live delivery is performed by these tests.
|
|
301
|
+
|
|
302
|
+
### Deployment and proof
|
|
303
|
+
|
|
304
|
+
Deploy lca-api (enum plus campaign deduplication), then brain, then the separate app
|
|
305
|
+
integration. Pilot with draft → audience preview → test-to-me → live → dispatch. This
|
|
306
|
+
implementation does not authorize deployment or any real audience send. Preserve LC-2265's
|
|
307
|
+
age policy and obtain its documented product/legal sign-off before a pilot.
|
|
308
|
+
|
|
309
|
+
Focused tests cover three-wave paging, delays, eligibility, stable retry identities,
|
|
310
|
+
receiver deduplication and acknowledgement failures. The Neo4j campaign suite covers
|
|
311
|
+
admin denial, caller-only tests, concurrent state acknowledgements and stats. The lca-api
|
|
312
|
+
notification integration suite covers concurrent receiver storage and push suppression.
|
|
313
|
+
Hosted `lc verify` additionally exercises SDK authorization; it does not provision FCM
|
|
314
|
+
or prove iOS/Android device receipt. Those require separately authorized device QA.
|
|
315
|
+
|
|
316
|
+
## Reviewed dispatch from the composer
|
|
317
|
+
|
|
318
|
+
`campaigns.previewDispatch` is an admin-only, read-only query taking `{ campaignId }`.
|
|
319
|
+
The SDK exposes it as `learnCard.invoke.campaignsPreviewDispatch(campaignId)`.
|
|
320
|
+
It returns `audienceCount`, `alreadyNotifiedCount`, `recipientCount`, `notificationRecipientCount`, `emailRecipientCount`, `previewToken`,
|
|
321
|
+
and an optional `blockedReason` (`not_live`, `notify_disabled`, `not_started`, `ended`,
|
|
322
|
+
`no_recipients`). The total counts distinct profiles across the selected delivery channels.
|
|
323
|
+
Inbox counts follow the campaign age policy and exclude push opt-outs and profiles with `notifiedAt`.
|
|
324
|
+
Email counts require adults, announcements enabled, exactly one valid verified primary email,
|
|
325
|
+
and neither `emailClaimedAt` nor `emailedAt`. Neither count guarantees receipt.
|
|
326
|
+
|
|
327
|
+
After reviewing the count, pass `{ expectedPreviewToken: preview.previewToken }` as
|
|
328
|
+
the second argument to `learnCard.invoke.campaignsDispatch`. The route accepts that
|
|
329
|
+
optional field alongside `campaignId`. Legacy dispatch callers can omit it; the composer
|
|
330
|
+
always supplies it. Under the campaign node lock, the server recomputes a digest over
|
|
331
|
+
the definition, mutation revision, sorted eligible profile IDs and email destinations, rejects stale previews
|
|
332
|
+
with `CONFLICT`, and advances the revision to consume the confirmation once. This catches
|
|
333
|
+
same-count recipient changes as well as ordinary audience edits. No recipients or invalid
|
|
334
|
+
status/schedule/channel is rejected before enqueue. Preview does not mark anyone notified.
|
|
335
|
+
|
|
336
|
+
The count query collects eligible profile IDs internally to fingerprint the recipient set;
|
|
337
|
+
those IDs are not returned by the API. Confirmed sends support at most 10,000 eligible
|
|
338
|
+
recipients. Cypher limits the collection to 10,001 IDs (one overflow sentinel) before
|
|
339
|
+
transferring them to Node. Larger previews and confirmations return `BAD_REQUEST` and
|
|
340
|
+
require reducing the audience; the server never authorizes a truncated recipient set.
|
|
341
|
+
The confirmation lock is released before queue/network work. Later waves still use current
|
|
342
|
+
eligibility and audience semantics, so the reviewed count is a snapshot rather than a frozen
|
|
343
|
+
send list. Existing queue retries and receiver deduplication remain unchanged. If enqueue
|
|
344
|
+
fails or the HTTP response is lost, review again before retrying; never reuse the old token.
|
|
345
|
+
|
|
346
|
+
### Concurrent authoring
|
|
347
|
+
|
|
348
|
+
Content editors should pass the loaded campaign's `updatedAt` as `expectedUpdatedAt`
|
|
349
|
+
to `campaignsUpdate`, and leave lifecycle status changes to `campaignsSetStatus`.
|
|
350
|
+
The server compares the token after acquiring the campaign write lock and rejects
|
|
351
|
+
stale saves with `CONFLICT`. Reload the latest definition before reconciling edits;
|
|
352
|
+
never automatically retry a full stale form against a fresh token. Successful
|
|
353
|
+
writes advance the timestamp monotonically, including same-millisecond writes.
|
|
354
|
+
Legacy SDK callers may omit the optional token; those callers retain existing
|
|
355
|
+
last-write behavior and do not receive optimistic concurrency protection.
|
|
356
|
+
|
|
357
|
+
## Announcement email (LC-2268)
|
|
358
|
+
|
|
359
|
+
Campaigns can include `email` alongside `inApp` and `notify`. Supply `email.subject`,
|
|
360
|
+
`title`, `body`, and `ctaLabel`; `heroImageUrl` is optional and must be HTTPS. Body
|
|
361
|
+
copy is plain text, escaped by React. Keep announcement copy free of private learner
|
|
362
|
+
or credential data. Create records capture the request's resolved tenant; queued
|
|
363
|
+
waves use that stored tenant for branding and `/m/<id>?utm_source=email&utm_campaign=<id>`.
|
|
364
|
+
Legacy records without a tenant use the configured default tenant.
|
|
365
|
+
The CTA origin comes from the deployment-owned `CAMPAIGN_APP_ORIGINS` JSON map.
|
|
366
|
+
Stage configs map LearnCard staging to `staging.learncard.ai`, VetPass staging to
|
|
367
|
+
`staging.vetpass.app`, and ScoutPass staging to `staging.pass.scout.org`. Production
|
|
368
|
+
configs supply their corresponding production origins. Queued sends do not use
|
|
369
|
+
request headers to choose an app origin. Custom deployments must configure a map;
|
|
370
|
+
missing, malformed, credential-bearing or non-origin URLs fail preparation before
|
|
371
|
+
the durable email claim. HTTP is allowed only for loopback development origins.
|
|
372
|
+
|
|
373
|
+
In bundle-enabled Lambda deployments, set `POSTMARK_BROADCAST_STREAM` and
|
|
374
|
+
`CAMPAIGN_UNSUBSCRIBE_SECRET` in **LearnCard Runtime** →
|
|
375
|
+
`/learncard/brain-service/runtime` (`staging` / `prod`), alongside `POSTMARK_API_KEY`.
|
|
376
|
+
Infisical syncs the existing AWS bundle selected by `RUNTIME_SECRETS_ID`; no new
|
|
377
|
+
GitHub per-key mapping is needed. The API, queue worker and unsubscribe handler
|
|
378
|
+
load the bundle before application imports. Recycle/redeploy Brain after sync;
|
|
379
|
+
keep the signing secret stable so previously sent unsubscribe links remain valid.
|
|
380
|
+
Local/Docker uses private `.env` values from the separate LearnCard project.
|
|
381
|
+
See [runtime configuration](../../../../../../environment-variables.md#campaign-email-runtime-settings-brain-service).
|
|
382
|
+
|
|
383
|
+
Email dispatch requires `POSTMARK_API_KEY`, `DOMAIN_NAME` (the public Brain host),
|
|
384
|
+
`POSTMARK_BROADCAST_STREAM` (default `broadcast`) and `CAMPAIGN_UNSUBSCRIBE_SECRET`
|
|
385
|
+
(a dedicated random secret of at least 32 bytes). The adapter checks the actual
|
|
386
|
+
Postmark stream type is `Broadcasts`; transactional streams are rejected. Provisioning
|
|
387
|
+
these values, enabling a stream, deployment and live sends are separate operator
|
|
388
|
+
steps. No production configuration is included here. `CAMPAIGN_EMAIL_PAGE_SIZE`
|
|
389
|
+
defaults to 500 (1–1000); email and notification jobs keep independent cursors and
|
|
390
|
+
use `CAMPAIGN_DISPATCH_DELAY_SECONDS` between waves.
|
|
391
|
+
|
|
392
|
+
Email requires exactly one valid primary verified ContactMethod email, a known adult
|
|
393
|
+
profile under the existing campaign adult policy, and no announcement email opt-out.
|
|
394
|
+
Email waves filter adult eligibility and opt-outs before pagination, then recheck both
|
|
395
|
+
under the profile lock before reserving a send. Audience management still lists opted-out members.
|
|
396
|
+
Unknown ages, children, managed non-service profiles and minors fail closed even if
|
|
397
|
+
`excludeMinors` is false. Parent age-policy sign-off is still outstanding: this does
|
|
398
|
+
not authorize marketing to minors. Self-tests bypass audience/status only; they retain
|
|
399
|
+
email eligibility and preferences and do not change production statistics.
|
|
400
|
+
|
|
401
|
+
A profile lock serializes durable `emailClaimedAt` reservations. A claim commits before
|
|
402
|
+
provider I/O; accepted sends set `emailedAt` and contribute to `getStats().emailed`.
|
|
403
|
+
Retries, concurrent workers and redispatch cannot resend a reserved recipient. This
|
|
404
|
+
is **at-most-once attempt**, not guaranteed delivery: a crash before sending or lost
|
|
405
|
+
provider acknowledgement leaves a reservation with no `emailedAt`. There is no automatic
|
|
406
|
+
lease expiry/replay; an operator must investigate delivery with the provider before any
|
|
407
|
+
future recovery design permits another send. Never clear claims blindly. Provider
|
|
408
|
+
acceptance is not proof of inbox delivery. Archive/pause, generation, membership,
|
|
409
|
+
age, contact and opt-out state are checked at the reservation boundary.
|
|
410
|
+
|
|
411
|
+
Browser unsubscribe links use the sending Brain service's anonymous
|
|
412
|
+
`/campaigns/unsubscribe/browser?token=...` confirmation page, including ScoutPass.
|
|
413
|
+
GET renders a form; POST applies the opt-out and renders confirmation. This page
|
|
414
|
+
uses no external assets or analytics, sends no-store/no-referrer/CSP headers, and
|
|
415
|
+
supports English, Spanish, French and Arabic using the browser language.
|
|
416
|
+
The `List-Unsubscribe` header uses the Brain HTTPS
|
|
417
|
+
`/campaigns/unsubscribe?token=...` endpoint: it accepts the actual RFC 8058
|
|
418
|
+
`application/x-www-form-urlencoded` POST `List-Unsubscribe=One-Click`, without login,
|
|
419
|
+
cookies, redirects or JavaScript. GET does not mutate. Both POST paths verify the same
|
|
420
|
+
purpose-separated HMAC token, then idempotently set the profile's email opt-out.
|
|
421
|
+
Version 2 tokens bind to a unique, durable `campaignUnsubscribeId` allocated under
|
|
422
|
+
the recipient lock before sending. Renaming a profile preserves its links; reusing
|
|
423
|
+
or deleting a profile name never transfers those links to another account. Legacy
|
|
424
|
+
name-based tokens are rejected because their original recipient cannot be resolved
|
|
425
|
+
safely after a rename. Generate a new test email to replace an old test link.
|
|
426
|
+
Links remain valid after campaign archive/deletion; rotating the secret invalidates
|
|
427
|
+
old links. Tokens are bearer authority for opt-out only, not authentication. Do not
|
|
428
|
+
log token URLs or capture them in analytics; ingress/proxy access-log redaction for
|
|
429
|
+
this endpoint must be checked at deployment. The public endpoint returns no profile data.
|
|
430
|
+
|
|
431
|
+
Authenticated `getAnnouncementPreferences` and `updateAnnouncementPreferences` return
|
|
432
|
+
and patch `announcementEmailOptOut` / `announcementPushOptOut`. SDK methods use these
|
|
433
|
+
same names, plus anonymous `unsubscribeFromAnnouncements(token)`. Preference fields
|
|
434
|
+
are private graph properties, omitted from public profile projections. Push opt-out
|
|
435
|
+
suppresses both campaign push and inbox at enqueue/delivery checks; in-app selection
|
|
436
|
+
is unaffected. Partial patches leave the other choice unchanged.
|
|
437
|
+
|
|
438
|
+
Before rollout, use mocked adapter/HTTP tests and the campaign Neo4j suite. Separately
|
|
439
|
+
verify real broadcast receipt and headers, configured public POST ingress, and cold/warm
|
|
440
|
+
`/m/` universal-link handoff on web, iOS and Android in an authorized environment.
|
|
441
|
+
The composer still needs its email controls, counts/wiring and CSV result follow-up.
|
|
442
|
+
|
|
443
|
+
Audience preview/add responses retain `count` for eligible matches and include `added` (new memberships, zero for preview) and `notFound` (unresolved input DIDs). Age-excluded profiles are not missing. Addition counts are computed under the campaign lock, so retries and aliases cannot inflate them.
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { TRPCError } from '@trpc/server';
|
|
2
|
-
import { int } from 'neo4j-driver';
|
|
2
|
+
import { int, type ManagedTransaction } from 'neo4j-driver';
|
|
3
3
|
import {
|
|
4
4
|
CampaignAudienceSourceValidator,
|
|
5
5
|
type CampaignAudienceSource,
|
|
6
|
+
type CampaignAudienceResult,
|
|
6
7
|
type PaginatedCampaignAudience,
|
|
7
8
|
} from '@learncard/types';
|
|
8
9
|
|
|
@@ -13,6 +14,7 @@ import { constructUri, getUriParts } from '@helpers/uri.helpers';
|
|
|
13
14
|
import type { CampaignAudienceQuery } from 'types/campaign';
|
|
14
15
|
import {
|
|
15
16
|
assertCampaignEditable,
|
|
17
|
+
adultProfilePredicate,
|
|
16
18
|
campaignAgePredicate,
|
|
17
19
|
campaignTimeParams,
|
|
18
20
|
lockCampaign,
|
|
@@ -91,11 +93,28 @@ export const buildCampaignAudienceQuery = (
|
|
|
91
93
|
}
|
|
92
94
|
};
|
|
93
95
|
|
|
96
|
+
/** Resolve missing input identifiers separately from age-based exclusion. */
|
|
97
|
+
const missingDids = async (
|
|
98
|
+
source: CampaignAudienceSource,
|
|
99
|
+
domain: string,
|
|
100
|
+
transaction?: ManagedTransaction
|
|
101
|
+
): Promise<string[]> => {
|
|
102
|
+
if (source.type !== 'dids') return [];
|
|
103
|
+
const query = `UNWIND $dids AS did
|
|
104
|
+
OPTIONAL MATCH (p:Profile) WHERE p.did = did OR ($didWebPrefix + p.profileId) = did
|
|
105
|
+
WITH did, count(p) AS matches WHERE matches = 0 RETURN did`;
|
|
106
|
+
const params = { dids: [...new Set(source.dids)], didWebPrefix: getDidWeb(domain, '') };
|
|
107
|
+
const result = await (transaction
|
|
108
|
+
? transaction.run(query, params)
|
|
109
|
+
: neogma.queryRunner.run(query, params));
|
|
110
|
+
return result.records.map(row => row.get('did') as string);
|
|
111
|
+
};
|
|
112
|
+
|
|
94
113
|
export const previewCampaignAudience = async (
|
|
95
114
|
campaignId: string,
|
|
96
115
|
source: CampaignAudienceSource,
|
|
97
116
|
domain: string
|
|
98
|
-
): Promise<
|
|
117
|
+
): Promise<CampaignAudienceResult> => {
|
|
99
118
|
await getCampaign(campaignId);
|
|
100
119
|
const query = buildCampaignAudienceQuery(source, domain);
|
|
101
120
|
const result = await traceDb('campaign:previewAudience', () =>
|
|
@@ -105,39 +124,52 @@ export const previewCampaignAudience = async (
|
|
|
105
124
|
{ ...query.params, campaignId, ...campaignTimeParams() }
|
|
106
125
|
)
|
|
107
126
|
);
|
|
108
|
-
return
|
|
127
|
+
return {
|
|
128
|
+
count: result.records[0]!.get('count').toNumber(),
|
|
129
|
+
added: 0,
|
|
130
|
+
notFound: await missingDids(source, domain),
|
|
131
|
+
};
|
|
109
132
|
};
|
|
110
133
|
|
|
111
134
|
export const addCampaignAudience = async (
|
|
112
135
|
campaignId: string,
|
|
113
136
|
source: CampaignAudienceSource,
|
|
114
137
|
domain: string
|
|
115
|
-
): Promise<
|
|
138
|
+
): Promise<CampaignAudienceResult> => {
|
|
116
139
|
const query = buildCampaignAudienceQuery(source, domain);
|
|
117
140
|
return withCampaignTransaction('campaign:addAudience', async transaction => {
|
|
118
141
|
assertCampaignEditable(await lockCampaign(transaction, campaignId));
|
|
142
|
+
const notFound = await missingDids(source, domain, transaction);
|
|
119
143
|
const result = await transaction.run(
|
|
120
144
|
`${query.match} WITH DISTINCT p MATCH (c:Campaign {id: $campaignId})
|
|
121
145
|
WHERE ${campaignAgePredicate}
|
|
146
|
+
OPTIONAL MATCH (p)-[existing:IN_CAMPAIGN_AUDIENCE]->(c)
|
|
147
|
+
WITH p, c, existing IS NULL AS isNew
|
|
122
148
|
MERGE (p)-[audience:IN_CAMPAIGN_AUDIENCE]->(c)
|
|
123
149
|
ON CREATE SET audience.addedAt = $now, audience.source = $source
|
|
124
|
-
RETURN count(p) AS count`,
|
|
150
|
+
RETURN count(p) AS count, sum(CASE WHEN isNew THEN 1 ELSE 0 END) AS added`,
|
|
125
151
|
{ ...query.params, campaignId, source: source.type, ...campaignTimeParams() }
|
|
126
152
|
);
|
|
127
|
-
return
|
|
153
|
+
return {
|
|
154
|
+
count: result.records[0]!.get('count').toNumber(),
|
|
155
|
+
added: result.records[0]!.get('added').toNumber(),
|
|
156
|
+
notFound,
|
|
157
|
+
};
|
|
128
158
|
});
|
|
129
159
|
};
|
|
130
160
|
|
|
131
161
|
export const pageCampaignAudience = async (
|
|
132
162
|
campaignId: string,
|
|
133
163
|
cursor?: string,
|
|
134
|
-
limit = 100
|
|
164
|
+
limit = 100,
|
|
165
|
+
deliveryChannel?: 'email'
|
|
135
166
|
): Promise<PaginatedCampaignAudience> => {
|
|
136
167
|
await getCampaign(campaignId);
|
|
137
168
|
const result = await traceDb('campaign:pageAudience', () =>
|
|
138
169
|
neogma.queryRunner.run(
|
|
139
170
|
`MATCH (p:Profile)-[:IN_CAMPAIGN_AUDIENCE]->(c:Campaign {id: $campaignId})
|
|
140
171
|
WHERE ($cursor IS NULL OR p.profileId > $cursor) AND ${campaignAgePredicate}
|
|
172
|
+
${deliveryChannel === 'email' ? `AND ${adultProfilePredicate} AND coalesce(p.announcementEmailOptOut, false) = false` : ''}
|
|
141
173
|
RETURN DISTINCT p.profileId AS profileId, p.did AS did ORDER BY profileId LIMIT $limit`,
|
|
142
174
|
{ campaignId, cursor: cursor ?? null, limit: int(limit + 1), ...campaignTimeParams() }
|
|
143
175
|
)
|
|
@@ -13,13 +13,15 @@ import { campaignProperties } from './shared';
|
|
|
13
13
|
|
|
14
14
|
export const createCampaign = async (
|
|
15
15
|
input: CreateCampaignInput,
|
|
16
|
-
createdByProfileId: string
|
|
16
|
+
createdByProfileId: string,
|
|
17
|
+
tenantId?: string
|
|
17
18
|
): Promise<Campaign> => {
|
|
18
19
|
const parsed = CreateCampaignInputValidator.parse(input);
|
|
19
20
|
const now = new Date().toISOString();
|
|
20
21
|
const campaign = CampaignValidator.parse({
|
|
21
22
|
...parsed,
|
|
22
|
-
message: { ...parsed.message, id: parsed.id },
|
|
23
|
+
message: parsed.message ? { ...parsed.message, id: parsed.id } : undefined,
|
|
24
|
+
tenantId,
|
|
23
25
|
createdAt: now,
|
|
24
26
|
updatedAt: now,
|
|
25
27
|
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { LCNNotification } from '@learncard/types';
|
|
2
|
+
import { neogma } from '@instance';
|
|
3
|
+
import { traceDb } from '@tracing';
|
|
4
|
+
import type { ProfileType } from 'types/profile';
|
|
5
|
+
import {
|
|
6
|
+
campaignAgePredicate,
|
|
7
|
+
campaignTimeParams,
|
|
8
|
+
liveCampaignPredicate,
|
|
9
|
+
parseCampaign,
|
|
10
|
+
withCampaignTransaction,
|
|
11
|
+
} from './shared';
|
|
12
|
+
|
|
13
|
+
/** Recheck eligibility at enqueue and delivery time; queued work can outlive a pause. */
|
|
14
|
+
export const getCampaignDeliveryRecipient = async (
|
|
15
|
+
campaignId: string,
|
|
16
|
+
campaignCreatedAt: string,
|
|
17
|
+
profileId: string
|
|
18
|
+
): Promise<ProfileType | undefined> => {
|
|
19
|
+
const result = await traceDb('campaign:deliveryRecipient', () =>
|
|
20
|
+
neogma.queryRunner.run(
|
|
21
|
+
`MATCH (p:Profile {profileId: $profileId})-[:IN_CAMPAIGN_AUDIENCE]->(c:Campaign {id: $campaignId})
|
|
22
|
+
WHERE c.createdAt = $campaignCreatedAt AND ${liveCampaignPredicate}
|
|
23
|
+
AND ${campaignAgePredicate}
|
|
24
|
+
AND coalesce(p.announcementPushOptOut, false) = false
|
|
25
|
+
OPTIONAL MATCH (p)-[state:CAMPAIGN_STATE]->(c)
|
|
26
|
+
WITH p, c, state WHERE state.notifiedAt IS NULL
|
|
27
|
+
RETURN properties(p) AS profile, c.definition AS definition`,
|
|
28
|
+
{ campaignId, campaignCreatedAt, profileId, ...campaignTimeParams() }
|
|
29
|
+
)
|
|
30
|
+
);
|
|
31
|
+
const row = result.records[0];
|
|
32
|
+
if (!row || !parseCampaign(row.get('definition')).channels.includes('notify')) return undefined;
|
|
33
|
+
return row.get('profile') as ProfileType;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Only durable inbox acknowledgement counts as notified; enqueueing alone does not. */
|
|
37
|
+
export const acknowledgeCampaignNotification = async (
|
|
38
|
+
notification: LCNNotification
|
|
39
|
+
): Promise<void> => {
|
|
40
|
+
const metadata = notification.data?.metadata;
|
|
41
|
+
if (notification.type !== 'ANNOUNCEMENT' || metadata?.campaignTestId) return;
|
|
42
|
+
if (!metadata?.campaignId || !metadata.campaignCreatedAt || !notification.to.profileId)
|
|
43
|
+
throw new Error('Campaign delivery is missing its identity');
|
|
44
|
+
|
|
45
|
+
await withCampaignTransaction('campaign:acknowledgeNotification', async transaction => {
|
|
46
|
+
const params = {
|
|
47
|
+
campaignId: metadata.campaignId,
|
|
48
|
+
campaignCreatedAt: metadata.campaignCreatedAt,
|
|
49
|
+
profileId: notification.to.profileId,
|
|
50
|
+
...campaignTimeParams(),
|
|
51
|
+
};
|
|
52
|
+
// Use the same profile lock as engagement writes to keep one state relationship.
|
|
53
|
+
await transaction.run(
|
|
54
|
+
`MATCH (p:Profile {profileId: $profileId})
|
|
55
|
+
SET p.campaignStateLock = coalesce(p.campaignStateLock, 0) + 1`,
|
|
56
|
+
params
|
|
57
|
+
);
|
|
58
|
+
await transaction.run(
|
|
59
|
+
`MATCH (p:Profile {profileId: $profileId})-[:IN_CAMPAIGN_AUDIENCE]->(c:Campaign {id: $campaignId})
|
|
60
|
+
WHERE c.createdAt = $campaignCreatedAt
|
|
61
|
+
MERGE (p)-[state:CAMPAIGN_STATE]->(c)
|
|
62
|
+
SET state.notifiedAt = coalesce(state.notifiedAt, $now)`,
|
|
63
|
+
params
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { beforeEach, expect, it, vi } from 'vitest';
|
|
2
|
+
import { CampaignValidator } from '@learncard/types';
|
|
3
|
+
import { int } from 'neo4j-driver';
|
|
4
|
+
|
|
5
|
+
const mocks = vi.hoisted(() => ({ run: vi.fn() }));
|
|
6
|
+
vi.mock('@instance', () => ({ neogma: { queryRunner: { run: mocks.run } } }));
|
|
7
|
+
vi.mock('@helpers/campaign-notifications.helpers', () => ({ canDispatchCampaign: () => true }));
|
|
8
|
+
import { getCampaignDispatchPreview, MAX_CONFIRMED_DISPATCH_RECIPIENTS } from './dispatch';
|
|
9
|
+
|
|
10
|
+
const definition = JSON.stringify(
|
|
11
|
+
CampaignValidator.parse({
|
|
12
|
+
id: 'campaign',
|
|
13
|
+
name: 'Campaign',
|
|
14
|
+
status: 'live',
|
|
15
|
+
channels: ['notify'],
|
|
16
|
+
notification: { title: 'Title', body: 'Body' },
|
|
17
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
18
|
+
updatedAt: '2026-01-01T00:00:00Z',
|
|
19
|
+
})
|
|
20
|
+
);
|
|
21
|
+
const recipients = (ids: string[]) => {
|
|
22
|
+
const row = {
|
|
23
|
+
definition,
|
|
24
|
+
recipients: ids,
|
|
25
|
+
revision: int(0),
|
|
26
|
+
audience: int(ids.length),
|
|
27
|
+
notified: int(0),
|
|
28
|
+
};
|
|
29
|
+
mocks.run.mockResolvedValue({ records: [{ get: (key: keyof typeof row) => row[key] }] });
|
|
30
|
+
};
|
|
31
|
+
beforeEach(() => mocks.run.mockReset());
|
|
32
|
+
|
|
33
|
+
it('fingerprints identities independent of row ordering while detecting equal-count swaps', async () => {
|
|
34
|
+
recipients(['b', 'a']);
|
|
35
|
+
const first = await getCampaignDispatchPreview('campaign');
|
|
36
|
+
recipients(['a', 'b']);
|
|
37
|
+
expect(await getCampaignDispatchPreview('campaign')).toEqual(first);
|
|
38
|
+
recipients(['a', 'c']);
|
|
39
|
+
const swapped = await getCampaignDispatchPreview('campaign');
|
|
40
|
+
expect(swapped.recipientCount).toBe(first.recipientCount);
|
|
41
|
+
expect(swapped.previewToken).not.toBe(first.previewToken);
|
|
42
|
+
});
|
|
43
|
+
it('bounds collection before transfer and refuses to produce a token for an overflowing set', async () => {
|
|
44
|
+
recipients(Array.from({ length: MAX_CONFIRMED_DISPATCH_RECIPIENTS }, (_, i) => `profile-${i}`));
|
|
45
|
+
await expect(getCampaignDispatchPreview('campaign')).resolves.toMatchObject({
|
|
46
|
+
recipientCount: 10_000,
|
|
47
|
+
});
|
|
48
|
+
const [query, params] = mocks.run.mock.calls[0]!;
|
|
49
|
+
expect(params.recipientLimit.toNumber()).toBe(10_001);
|
|
50
|
+
expect(query.indexOf('LIMIT $recipientLimit')).toBeLessThan(
|
|
51
|
+
query.indexOf('collect(profileId)')
|
|
52
|
+
);
|
|
53
|
+
recipients(
|
|
54
|
+
Array.from({ length: MAX_CONFIRMED_DISPATCH_RECIPIENTS + 1 }, (_, i) => `profile-${i}`)
|
|
55
|
+
);
|
|
56
|
+
await expect(getCampaignDispatchPreview('campaign')).rejects.toMatchObject({
|
|
57
|
+
code: 'BAD_REQUEST',
|
|
58
|
+
});
|
|
59
|
+
});
|