@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,172 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { TRPCError } from '@trpc/server';
|
|
4
|
+
import { int, type ManagedTransaction } from 'neo4j-driver';
|
|
5
|
+
import type { Campaign, CampaignDispatchPreview } from '@learncard/types';
|
|
6
|
+
import { neogma } from '@instance';
|
|
7
|
+
import { traceDb } from '@tracing';
|
|
8
|
+
import { canDispatchCampaign } from '@helpers/campaign-notifications.helpers';
|
|
9
|
+
import {
|
|
10
|
+
campaignAgePredicate,
|
|
11
|
+
adultProfilePredicate,
|
|
12
|
+
campaignTimeParams,
|
|
13
|
+
parseCampaign,
|
|
14
|
+
withCampaignTransaction,
|
|
15
|
+
} from './shared';
|
|
16
|
+
|
|
17
|
+
export const MAX_CONFIRMED_DISPATCH_RECIPIENTS = 10_000;
|
|
18
|
+
|
|
19
|
+
/** Review eligible inbox and email recipients using the delivery predicates. */
|
|
20
|
+
const readPreview = async (
|
|
21
|
+
campaignId: string,
|
|
22
|
+
transaction?: ManagedTransaction
|
|
23
|
+
): Promise<{ campaign: Campaign; preview: CampaignDispatchPreview }> => {
|
|
24
|
+
const params = {
|
|
25
|
+
campaignId,
|
|
26
|
+
...campaignTimeParams(),
|
|
27
|
+
recipientLimit: int(MAX_CONFIRMED_DISPATCH_RECIPIENTS + 1),
|
|
28
|
+
};
|
|
29
|
+
const query = `MATCH (c:Campaign {id: $campaignId})
|
|
30
|
+
OPTIONAL MATCH (p:Profile)-[:IN_CAMPAIGN_AUDIENCE]->(c)
|
|
31
|
+
OPTIONAL MATCH (p)-[state:CAMPAIGN_STATE]->(c)
|
|
32
|
+
WITH c, count(DISTINCT p) AS audience,
|
|
33
|
+
count(DISTINCT CASE WHEN state.notifiedAt IS NOT NULL THEN p END) AS notified
|
|
34
|
+
CALL {
|
|
35
|
+
WITH c
|
|
36
|
+
MATCH (p:Profile)-[:IN_CAMPAIGN_AUDIENCE]->(c)
|
|
37
|
+
OPTIONAL MATCH (p)-[state:CAMPAIGN_STATE]->(c)
|
|
38
|
+
WITH c, p, state WHERE ${campaignAgePredicate} AND state.notifiedAt IS NULL
|
|
39
|
+
AND coalesce(p.announcementPushOptOut, false) = false
|
|
40
|
+
WITH DISTINCT p.profileId AS profileId LIMIT $recipientLimit
|
|
41
|
+
RETURN collect(profileId) AS recipients
|
|
42
|
+
}
|
|
43
|
+
RETURN c.definition AS definition, coalesce(c.lockVersion, 0) AS revision,
|
|
44
|
+
audience, notified, recipients`;
|
|
45
|
+
const result = await traceDb('campaign:dispatchPreview', () =>
|
|
46
|
+
transaction ? transaction.run(query, params) : neogma.queryRunner.run(query, params)
|
|
47
|
+
);
|
|
48
|
+
const row = result.records[0];
|
|
49
|
+
if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: 'Campaign not found' });
|
|
50
|
+
const campaign = parseCampaign(row.get('definition'));
|
|
51
|
+
const recipients =
|
|
52
|
+
campaign.channels.includes('notify') && campaign.notification
|
|
53
|
+
? (row.get('recipients') as string[])
|
|
54
|
+
: [];
|
|
55
|
+
let emailRecipients: { profileId: string; email: string }[] = [];
|
|
56
|
+
if (campaign.channels.includes('email') && campaign.email) {
|
|
57
|
+
// Match claimCampaignEmail: adults, opt-in, one verified primary contact,
|
|
58
|
+
// and no previous claim/acceptance. Include the address in the review digest.
|
|
59
|
+
const emailQuery = `MATCH (p:Profile)-[:IN_CAMPAIGN_AUDIENCE]->(c:Campaign {id: $campaignId})
|
|
60
|
+
WHERE ${adultProfilePredicate} AND coalesce(p.announcementEmailOptOut, false) = false
|
|
61
|
+
OPTIONAL MATCH (p)-[state:CAMPAIGN_STATE]->(c)
|
|
62
|
+
WITH p, state WHERE state.emailClaimedAt IS NULL AND state.emailedAt IS NULL
|
|
63
|
+
MATCH (p)-[:HAS_CONTACT_METHOD]->(email:ContactMethod)
|
|
64
|
+
WHERE email.type = 'email' AND email.isPrimary = true AND email.isVerified = true
|
|
65
|
+
WITH p, collect(email) AS emails WHERE size(emails) = 1
|
|
66
|
+
RETURN p.profileId AS profileId, emails[0].value AS email LIMIT $recipientLimit`;
|
|
67
|
+
const emails = await (transaction
|
|
68
|
+
? transaction.run(emailQuery, params)
|
|
69
|
+
: neogma.queryRunner.run(emailQuery, params));
|
|
70
|
+
if (emails.records.length > MAX_CONFIRMED_DISPATCH_RECIPIENTS)
|
|
71
|
+
throw new TRPCError({
|
|
72
|
+
code: 'BAD_REQUEST',
|
|
73
|
+
message: 'Reduce the email audience and preview again',
|
|
74
|
+
});
|
|
75
|
+
emailRecipients = emails.records
|
|
76
|
+
.map(record => ({
|
|
77
|
+
profileId: record.get('profileId') as string,
|
|
78
|
+
email: record.get('email') as string,
|
|
79
|
+
}))
|
|
80
|
+
.filter(recipient => z.email().safeParse(recipient.email).success)
|
|
81
|
+
.sort((a, b) => a.profileId.localeCompare(b.profileId));
|
|
82
|
+
}
|
|
83
|
+
// Bound collection in Cypher before transferring or sorting IDs. Never authorize a partial set.
|
|
84
|
+
if (recipients.length > MAX_CONFIRMED_DISPATCH_RECIPIENTS)
|
|
85
|
+
throw new TRPCError({
|
|
86
|
+
code: 'BAD_REQUEST',
|
|
87
|
+
message: `Confirmed dispatch supports at most ${MAX_CONFIRMED_DISPATCH_RECIPIENTS} eligible recipients; reduce the audience and preview again`,
|
|
88
|
+
});
|
|
89
|
+
recipients.sort();
|
|
90
|
+
let blockedReason: CampaignDispatchPreview['blockedReason'];
|
|
91
|
+
const recipientCount = new Set([...recipients, ...emailRecipients.map(item => item.profileId)])
|
|
92
|
+
.size;
|
|
93
|
+
if (recipientCount > MAX_CONFIRMED_DISPATCH_RECIPIENTS)
|
|
94
|
+
throw new TRPCError({
|
|
95
|
+
code: 'BAD_REQUEST',
|
|
96
|
+
message: 'Reduce the audience and preview again',
|
|
97
|
+
});
|
|
98
|
+
if (!canDispatchCampaign(campaign, params.now)) {
|
|
99
|
+
blockedReason =
|
|
100
|
+
campaign.status !== 'live'
|
|
101
|
+
? 'not_live'
|
|
102
|
+
: !(
|
|
103
|
+
(campaign.channels.includes('notify') && campaign.notification) ||
|
|
104
|
+
(campaign.channels.includes('email') && campaign.email)
|
|
105
|
+
)
|
|
106
|
+
? 'notify_disabled'
|
|
107
|
+
: campaign.startsAt && campaign.startsAt > params.now
|
|
108
|
+
? 'not_started'
|
|
109
|
+
: 'ended';
|
|
110
|
+
} else if (!recipientCount) blockedReason = 'no_recipients';
|
|
111
|
+
// A digest avoids exposing recipient identities and detects equal-count membership swaps.
|
|
112
|
+
// Revision changes on audience edits, definition changes, or a confirmed dispatch.
|
|
113
|
+
const previewToken = createHash('sha256')
|
|
114
|
+
.update(
|
|
115
|
+
JSON.stringify({
|
|
116
|
+
definition: row.get('definition'),
|
|
117
|
+
revision: row.get('revision').toString(),
|
|
118
|
+
recipients,
|
|
119
|
+
emailRecipients,
|
|
120
|
+
blockedReason,
|
|
121
|
+
})
|
|
122
|
+
)
|
|
123
|
+
.digest('hex');
|
|
124
|
+
return {
|
|
125
|
+
campaign,
|
|
126
|
+
preview: {
|
|
127
|
+
campaignId,
|
|
128
|
+
audienceCount: row.get('audience').toNumber(),
|
|
129
|
+
alreadyNotifiedCount: row.get('notified').toNumber(),
|
|
130
|
+
recipientCount: blockedReason ? 0 : recipientCount,
|
|
131
|
+
notificationRecipientCount: blockedReason ? 0 : recipients.length,
|
|
132
|
+
emailRecipientCount: blockedReason ? 0 : emailRecipients.length,
|
|
133
|
+
previewToken,
|
|
134
|
+
...(blockedReason ? { blockedReason } : {}),
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export const getCampaignDispatchPreview = async (
|
|
140
|
+
campaignId: string
|
|
141
|
+
): Promise<CampaignDispatchPreview> => (await readPreview(campaignId)).preview;
|
|
142
|
+
|
|
143
|
+
/** Recheck and consume a confirmation once, serialized with campaign/audience edits. */
|
|
144
|
+
export const authorizeConfirmedCampaignDispatch = async (
|
|
145
|
+
campaignId: string,
|
|
146
|
+
expectedPreviewToken: string
|
|
147
|
+
): Promise<Campaign> =>
|
|
148
|
+
withCampaignTransaction('campaign:confirmDispatch', async transaction => {
|
|
149
|
+
// Acquire the same node write lock as campaign/audience edits without changing their revision yet.
|
|
150
|
+
await transaction.run(
|
|
151
|
+
`MATCH (c:Campaign {id: $campaignId})
|
|
152
|
+
SET c.dispatchCheckLock = coalesce(c.dispatchCheckLock, 0) + 1`,
|
|
153
|
+
{ campaignId }
|
|
154
|
+
);
|
|
155
|
+
const { campaign, preview } = await readPreview(campaignId, transaction);
|
|
156
|
+
if (preview.previewToken !== expectedPreviewToken)
|
|
157
|
+
throw new TRPCError({
|
|
158
|
+
code: 'CONFLICT',
|
|
159
|
+
message: 'Campaign or recipients changed; preview again',
|
|
160
|
+
});
|
|
161
|
+
if (preview.blockedReason)
|
|
162
|
+
throw new TRPCError({
|
|
163
|
+
code: 'BAD_REQUEST',
|
|
164
|
+
message: 'Campaign has no eligible notification recipients now',
|
|
165
|
+
});
|
|
166
|
+
await transaction.run(
|
|
167
|
+
`MATCH (c:Campaign {id: $campaignId})
|
|
168
|
+
SET c.lockVersion = coalesce(c.lockVersion, 0) + 1`,
|
|
169
|
+
{ campaignId }
|
|
170
|
+
);
|
|
171
|
+
return campaign;
|
|
172
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { beforeEach, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
run: vi.fn(),
|
|
5
|
+
campaign: { id: 'news', channels: ['email'], email: { title: 'News' } },
|
|
6
|
+
}));
|
|
7
|
+
vi.mock('./shared', () => ({
|
|
8
|
+
adultProfilePredicate: 'true',
|
|
9
|
+
liveCampaignPredicate: 'true',
|
|
10
|
+
campaignTimeParams: () => ({ now: '2026-01-01T00:00:00Z' }),
|
|
11
|
+
parseCampaign: () => mocks.campaign,
|
|
12
|
+
withCampaignTransaction: async (_name: string, work: (tx: unknown) => Promise<unknown>) =>
|
|
13
|
+
work({ run: mocks.run }),
|
|
14
|
+
}));
|
|
15
|
+
vi.mock('../../models/campaign-constraints', () => ({
|
|
16
|
+
ensureCampaignConstraints: vi.fn().mockResolvedValue(undefined),
|
|
17
|
+
}));
|
|
18
|
+
import { claimCampaignEmail } from './email';
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.resetAllMocks();
|
|
22
|
+
mocks.run.mockImplementation(async (query: string) => ({
|
|
23
|
+
records: query.includes('RETURN c.definition')
|
|
24
|
+
? [
|
|
25
|
+
{
|
|
26
|
+
get: (key: string) =>
|
|
27
|
+
(
|
|
28
|
+
({
|
|
29
|
+
definition: '{}',
|
|
30
|
+
email: 'adult@example.com',
|
|
31
|
+
locale: 'es',
|
|
32
|
+
unsubscribeId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
|
33
|
+
}) as Record<string, string>
|
|
34
|
+
)[key],
|
|
35
|
+
},
|
|
36
|
+
]
|
|
37
|
+
: [],
|
|
38
|
+
}));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('does not write a reservation when preparation fails', async () => {
|
|
42
|
+
const prepare = vi.fn().mockRejectedValue(new Error('Cannot render'));
|
|
43
|
+
await expect(claimCampaignEmail('news', 'created', 'adult', false, prepare)).rejects.toThrow(
|
|
44
|
+
'Cannot render'
|
|
45
|
+
);
|
|
46
|
+
expect(prepare).toHaveBeenCalledWith({
|
|
47
|
+
campaign: mocks.campaign,
|
|
48
|
+
email: 'adult@example.com',
|
|
49
|
+
locale: 'es',
|
|
50
|
+
unsubscribeId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
|
51
|
+
});
|
|
52
|
+
expect(mocks.run.mock.calls.some(([query]) => query.includes('SET state.emailClaimedAt'))).toBe(
|
|
53
|
+
false
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('prepares the selected recipient before writing its reservation', async () => {
|
|
58
|
+
const prepare = vi.fn(async () => {
|
|
59
|
+
expect(
|
|
60
|
+
mocks.run.mock.calls.some(([query]) => query.includes('SET state.emailClaimedAt'))
|
|
61
|
+
).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
await claimCampaignEmail('news', 'created', 'adult', false, prepare);
|
|
64
|
+
expect(prepare).toHaveBeenCalledOnce();
|
|
65
|
+
expect(mocks.run.mock.calls.at(-1)?.[0]).toContain('SET state.emailClaimedAt');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('does not prepare an ineligible recipient', async () => {
|
|
69
|
+
mocks.run.mockResolvedValue({ records: [] });
|
|
70
|
+
const prepare = vi.fn();
|
|
71
|
+
expect(await claimCampaignEmail('news', 'created', 'adult', false, prepare)).toBeUndefined();
|
|
72
|
+
expect(prepare).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { Campaign } from '@learncard/types';
|
|
3
|
+
import { ensureCampaignConstraints } from '../../models/campaign-constraints';
|
|
4
|
+
import {
|
|
5
|
+
adultProfilePredicate,
|
|
6
|
+
campaignTimeParams,
|
|
7
|
+
liveCampaignPredicate,
|
|
8
|
+
parseCampaign,
|
|
9
|
+
withCampaignTransaction,
|
|
10
|
+
} from './shared';
|
|
11
|
+
|
|
12
|
+
export type CampaignEmailRecipient = {
|
|
13
|
+
campaign: Campaign;
|
|
14
|
+
email: string;
|
|
15
|
+
unsubscribeId: string;
|
|
16
|
+
locale?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Reserve before external I/O. A lost acknowledgement remains reserved and is never retried automatically. */
|
|
20
|
+
export const claimCampaignEmail = async (
|
|
21
|
+
campaignId: string,
|
|
22
|
+
campaignCreatedAt: string,
|
|
23
|
+
profileId: string,
|
|
24
|
+
test = false,
|
|
25
|
+
prepare?: (recipient: CampaignEmailRecipient) => Promise<void>
|
|
26
|
+
): Promise<CampaignEmailRecipient | undefined> => {
|
|
27
|
+
await ensureCampaignConstraints();
|
|
28
|
+
return withCampaignTransaction('campaign:claimEmail', async transaction => {
|
|
29
|
+
const params = { campaignId, campaignCreatedAt, profileId, ...campaignTimeParams() };
|
|
30
|
+
await transaction.run(
|
|
31
|
+
`MATCH (p:Profile {profileId: $profileId})
|
|
32
|
+
SET p.campaignStateLock = coalesce(p.campaignStateLock, 0) + 1,
|
|
33
|
+
p.campaignUnsubscribeId = coalesce(p.campaignUnsubscribeId, randomUUID())`,
|
|
34
|
+
params
|
|
35
|
+
);
|
|
36
|
+
const result = await transaction.run(
|
|
37
|
+
`MATCH (p:Profile {profileId: $profileId}), (c:Campaign {id: $campaignId})
|
|
38
|
+
WHERE c.createdAt = $campaignCreatedAt AND ${adultProfilePredicate}
|
|
39
|
+
AND coalesce(p.announcementEmailOptOut, false) = false
|
|
40
|
+
${test ? '' : `AND ${liveCampaignPredicate} AND EXISTS { MATCH (p)-[:IN_CAMPAIGN_AUDIENCE]->(c) }`}
|
|
41
|
+
MATCH (p)-[:HAS_CONTACT_METHOD]->(email:ContactMethod)
|
|
42
|
+
WHERE email.type = 'email' AND email.isPrimary = true AND email.isVerified = true
|
|
43
|
+
OPTIONAL MATCH (p)-[state:CAMPAIGN_STATE]->(c)
|
|
44
|
+
WITH p, c, email, state
|
|
45
|
+
${test ? '' : 'WHERE state.emailClaimedAt IS NULL AND state.emailedAt IS NULL'}
|
|
46
|
+
WITH p, c, email ORDER BY email.id LIMIT 2
|
|
47
|
+
RETURN c.definition AS definition, email.value AS email, p.locale AS locale,
|
|
48
|
+
p.campaignUnsubscribeId AS unsubscribeId`,
|
|
49
|
+
params
|
|
50
|
+
);
|
|
51
|
+
// Ambiguous primary contacts fail closed, as do malformed legacy addresses.
|
|
52
|
+
if (result.records.length !== 1) return undefined;
|
|
53
|
+
const row = result.records[0]!;
|
|
54
|
+
const campaign = parseCampaign(row.get('definition'));
|
|
55
|
+
const email = z.email().safeParse(row.get('email'));
|
|
56
|
+
if (!email.success || !campaign.channels.includes('email') || !campaign.email)
|
|
57
|
+
return undefined;
|
|
58
|
+
const recipient = {
|
|
59
|
+
campaign,
|
|
60
|
+
email: email.data,
|
|
61
|
+
unsubscribeId: row.get('unsubscribeId') as string,
|
|
62
|
+
locale: row.get('locale') ?? undefined,
|
|
63
|
+
};
|
|
64
|
+
// Local preparation only: no provider send inside a retriable transaction.
|
|
65
|
+
// A render failure aborts before writing the durable claim.
|
|
66
|
+
await prepare?.(recipient);
|
|
67
|
+
if (!test)
|
|
68
|
+
await transaction.run(
|
|
69
|
+
`MATCH (p:Profile {profileId: $profileId}), (c:Campaign {id: $campaignId})
|
|
70
|
+
MERGE (p)-[state:CAMPAIGN_STATE]->(c) SET state.emailClaimedAt = $now`,
|
|
71
|
+
params
|
|
72
|
+
);
|
|
73
|
+
return recipient;
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Provider acceptance only; a claim alone is not an emailed statistic. */
|
|
78
|
+
export const acknowledgeCampaignEmail = async (
|
|
79
|
+
campaignId: string,
|
|
80
|
+
campaignCreatedAt: string,
|
|
81
|
+
profileId: string
|
|
82
|
+
): Promise<void> => {
|
|
83
|
+
await withCampaignTransaction('campaign:acknowledgeEmail', async transaction => {
|
|
84
|
+
await transaction.run(
|
|
85
|
+
`MATCH (p:Profile {profileId: $profileId})-[state:CAMPAIGN_STATE]->(c:Campaign {id: $campaignId})
|
|
86
|
+
WHERE c.createdAt = $campaignCreatedAt AND state.emailClaimedAt IS NOT NULL
|
|
87
|
+
SET state.emailedAt = coalesce(state.emailedAt, $now)`,
|
|
88
|
+
{ campaignId, campaignCreatedAt, profileId, ...campaignTimeParams() }
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
|
2
|
+
import type { AnnouncementPreferences } from '@learncard/types';
|
|
3
|
+
import { neogma } from '@instance';
|
|
4
|
+
import { traceDb } from '@tracing';
|
|
5
|
+
|
|
6
|
+
/** Resolve the durable recipient directly so renames and reused names cannot redirect opt-outs. */
|
|
7
|
+
export const unsubscribeCampaignRecipient = async (unsubscribeId: string): Promise<void> => {
|
|
8
|
+
await traceDb('campaign:unsubscribeRecipient', () =>
|
|
9
|
+
neogma.queryRunner.run(
|
|
10
|
+
`MATCH (p:Profile {campaignUnsubscribeId: $unsubscribeId})
|
|
11
|
+
SET p.announcementEmailOptOut = true`,
|
|
12
|
+
{ unsubscribeId }
|
|
13
|
+
)
|
|
14
|
+
);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Private preferences are read from the graph, never a cached public profile. */
|
|
18
|
+
export const getAnnouncementPreferences = async (
|
|
19
|
+
profileId: string
|
|
20
|
+
): Promise<AnnouncementPreferences> => {
|
|
21
|
+
const result = await traceDb('campaign:preferences', () =>
|
|
22
|
+
neogma.queryRunner.run(
|
|
23
|
+
`MATCH (p:Profile {profileId: $profileId})
|
|
24
|
+
RETURN coalesce(p.announcementEmailOptOut, false) AS email,
|
|
25
|
+
coalesce(p.announcementPushOptOut, false) AS push`,
|
|
26
|
+
{ profileId }
|
|
27
|
+
)
|
|
28
|
+
);
|
|
29
|
+
const row = result.records[0];
|
|
30
|
+
if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found' });
|
|
31
|
+
return { announcementEmailOptOut: row.get('email'), announcementPushOptOut: row.get('push') };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Patch only supplied choices so concurrent email and push updates cannot clobber each other. */
|
|
35
|
+
export const updateAnnouncementPreferences = async (
|
|
36
|
+
profileId: string,
|
|
37
|
+
updates: Partial<AnnouncementPreferences>
|
|
38
|
+
): Promise<AnnouncementPreferences> => {
|
|
39
|
+
const properties = Object.fromEntries(
|
|
40
|
+
Object.entries(updates).filter(
|
|
41
|
+
([key, value]) =>
|
|
42
|
+
['announcementEmailOptOut', 'announcementPushOptOut'].includes(key) &&
|
|
43
|
+
typeof value === 'boolean'
|
|
44
|
+
)
|
|
45
|
+
);
|
|
46
|
+
await traceDb('campaign:updatePreferences', () =>
|
|
47
|
+
neogma.queryRunner.run('MATCH (p:Profile {profileId: $profileId}) SET p += $properties', {
|
|
48
|
+
profileId,
|
|
49
|
+
properties,
|
|
50
|
+
})
|
|
51
|
+
);
|
|
52
|
+
return getAnnouncementPreferences(profileId);
|
|
53
|
+
};
|
|
@@ -73,7 +73,7 @@ export const getLiveCampaignsForProfile = async (
|
|
|
73
73
|
});
|
|
74
74
|
return [];
|
|
75
75
|
}
|
|
76
|
-
if (!campaign.channels.includes('inApp') || !campaign.message
|
|
76
|
+
if (!campaign.channels.includes('inApp') || !campaign.message?.enabled) return [];
|
|
77
77
|
const state = Object.fromEntries(
|
|
78
78
|
Object.entries(record.get('state') ?? {}).map(([key, value]) => [
|
|
79
79
|
key,
|
|
@@ -104,6 +104,7 @@ export const getCampaignStats = async (campaignId: string): Promise<CampaignStat
|
|
|
104
104
|
OPTIONAL MATCH (p)-[state:CAMPAIGN_STATE]->(c)
|
|
105
105
|
RETURN count(DISTINCT p) AS audience,
|
|
106
106
|
count(DISTINCT CASE WHEN audience.source = 'selfEnrolled' THEN p END) AS enrolled,
|
|
107
|
+
count(DISTINCT CASE WHEN state.emailedAt IS NOT NULL THEN p END) AS emailed,
|
|
107
108
|
count(DISTINCT CASE WHEN state.notifiedAt IS NOT NULL THEN p END) AS notified,
|
|
108
109
|
count(DISTINCT CASE WHEN state.firstShownAt IS NOT NULL THEN p END) AS shown,
|
|
109
110
|
count(DISTINCT CASE WHEN state.clickedAt IS NOT NULL THEN p END) AS clicked,
|
|
@@ -117,6 +118,7 @@ export const getCampaignStats = async (campaignId: string): Promise<CampaignStat
|
|
|
117
118
|
audience: record.get('audience').toNumber(),
|
|
118
119
|
enrolled: record.get('enrolled').toNumber(),
|
|
119
120
|
notified: record.get('notified').toNumber(),
|
|
121
|
+
emailed: record.get('emailed').toNumber(),
|
|
120
122
|
shown: record.get('shown').toNumber(),
|
|
121
123
|
clicked: record.get('clicked').toNumber(),
|
|
122
124
|
dismissed: record.get('dismissed').toNumber(),
|
|
@@ -17,7 +17,7 @@ export const enrollInCampaign = async (profileId: string, campaignId: string): P
|
|
|
17
17
|
if (
|
|
18
18
|
!campaign.selfEnroll ||
|
|
19
19
|
campaign.status !== 'live' ||
|
|
20
|
-
!campaign.message
|
|
20
|
+
!campaign.message?.enabled ||
|
|
21
21
|
!campaign.channels.includes('inApp') ||
|
|
22
22
|
(campaign.startsAt && campaign.startsAt > params.now) ||
|
|
23
23
|
(campaign.endsAt && campaign.endsAt <= params.now)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { beforeEach, expect, it, vi } from 'vitest';
|
|
2
|
+
import { inAppMessageValidator } from '@learncard/types';
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({ run: vi.fn() }));
|
|
5
|
+
vi.mock('@instance', () => ({ neogma: { queryRunner: { run: mocks.run } } }));
|
|
6
|
+
import { getCampaignTests } from './test';
|
|
7
|
+
|
|
8
|
+
beforeEach(() => mocks.run.mockReset());
|
|
9
|
+
|
|
10
|
+
it('isolates malformed JSON and obsolete message schemas from valid private tests', async () => {
|
|
11
|
+
const id = 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa';
|
|
12
|
+
const valid = {
|
|
13
|
+
id,
|
|
14
|
+
campaignId: 'campaign',
|
|
15
|
+
message: JSON.stringify({ id: 'test', title: 'Valid' }),
|
|
16
|
+
};
|
|
17
|
+
mocks.run.mockResolvedValue({
|
|
18
|
+
records: [
|
|
19
|
+
{ ...valid, message: '{' },
|
|
20
|
+
{ ...valid, message: JSON.stringify({ id: 'obsolete', presentation: 'removed-type' }) },
|
|
21
|
+
valid,
|
|
22
|
+
].map(row => ({ get: (key: keyof typeof row) => row[key] })),
|
|
23
|
+
});
|
|
24
|
+
await expect(getCampaignTests('admin')).resolves.toEqual([
|
|
25
|
+
{
|
|
26
|
+
id,
|
|
27
|
+
campaignId: 'campaign',
|
|
28
|
+
message: inAppMessageValidator.parse({ id: 'test', title: 'Valid' }),
|
|
29
|
+
},
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { CampaignTestValidator, type Campaign, type CampaignTest } from '@learncard/types';
|
|
2
|
+
import { TRPCError } from '@trpc/server';
|
|
3
|
+
import { neogma } from '@instance';
|
|
4
|
+
import { traceDb } from '@tracing';
|
|
5
|
+
|
|
6
|
+
/** Keep the latest private test per administrator/campaign, without enrolling the administrator. */
|
|
7
|
+
export const queueCampaignInAppTest = async (
|
|
8
|
+
campaign: Campaign,
|
|
9
|
+
profileId: string,
|
|
10
|
+
testId: string
|
|
11
|
+
): Promise<void> => {
|
|
12
|
+
if (!campaign.message)
|
|
13
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Campaign has no in-app message' });
|
|
14
|
+
const message = { ...campaign.message, id: `campaign-test-${testId}` };
|
|
15
|
+
const result = await traceDb('campaign:queueTest', () =>
|
|
16
|
+
neogma.queryRunner.run(
|
|
17
|
+
`MATCH (p:Profile {profileId: $profileId}), (c:Campaign {id: $campaignId})
|
|
18
|
+
WHERE c.createdAt = $createdAt
|
|
19
|
+
MERGE (p)-[test:CAMPAIGN_TEST]->(c)
|
|
20
|
+
SET test.id = $testId, test.message = $message, test.createdAt = $now
|
|
21
|
+
RETURN test.id AS id`,
|
|
22
|
+
{
|
|
23
|
+
profileId,
|
|
24
|
+
campaignId: campaign.id,
|
|
25
|
+
createdAt: campaign.createdAt,
|
|
26
|
+
testId,
|
|
27
|
+
message: JSON.stringify(message),
|
|
28
|
+
now: new Date().toISOString(),
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
);
|
|
32
|
+
if (!result.records.length)
|
|
33
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Campaign or administrator not found' });
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Reading does not consume a test; only a renderer acknowledgement removes it. */
|
|
37
|
+
export const getCampaignTests = async (profileId: string): Promise<CampaignTest[]> => {
|
|
38
|
+
const result = await traceDb('campaign:getTests', () =>
|
|
39
|
+
neogma.queryRunner.run(
|
|
40
|
+
`MATCH (p:Profile {profileId: $profileId})-[test:CAMPAIGN_TEST]->(c:Campaign)
|
|
41
|
+
RETURN test.id AS id, c.id AS campaignId, test.message AS message
|
|
42
|
+
ORDER BY test.createdAt, test.id LIMIT 20`,
|
|
43
|
+
{ profileId }
|
|
44
|
+
)
|
|
45
|
+
);
|
|
46
|
+
return result.records.flatMap(record => {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = CampaignTestValidator.safeParse({
|
|
49
|
+
id: record.get('id'),
|
|
50
|
+
campaignId: record.get('campaignId'),
|
|
51
|
+
message: JSON.parse(record.get('message')),
|
|
52
|
+
});
|
|
53
|
+
return parsed.success ? [parsed.data] : [];
|
|
54
|
+
} catch {
|
|
55
|
+
// A stale or malformed private snapshot must not hide live campaigns or valid tests.
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** An acknowledgement of an older snapshot cannot consume a newly queued test. */
|
|
62
|
+
export const acknowledgeCampaignTest = async (
|
|
63
|
+
profileId: string,
|
|
64
|
+
campaignId: string,
|
|
65
|
+
testId: string
|
|
66
|
+
): Promise<boolean> => {
|
|
67
|
+
await traceDb('campaign:acknowledgeTest', () =>
|
|
68
|
+
neogma.queryRunner.run(
|
|
69
|
+
`MATCH (:Profile {profileId: $profileId})-[test:CAMPAIGN_TEST {id: $testId}]->(:Campaign {id: $campaignId})
|
|
70
|
+
DELETE test`,
|
|
71
|
+
{ profileId, campaignId, testId }
|
|
72
|
+
)
|
|
73
|
+
);
|
|
74
|
+
return true;
|
|
75
|
+
};
|
|
@@ -14,24 +14,35 @@ import {
|
|
|
14
14
|
} from './shared';
|
|
15
15
|
|
|
16
16
|
export const updateCampaign = async (input: UpdateCampaignInput): Promise<Campaign> => {
|
|
17
|
-
const { campaignId, updates } = UpdateCampaignInputValidator.parse(input);
|
|
17
|
+
const { campaignId, updates, expectedUpdatedAt } = UpdateCampaignInputValidator.parse(input);
|
|
18
18
|
return withCampaignTransaction('campaign:update', async transaction => {
|
|
19
19
|
const existing = await lockCampaign(transaction, campaignId);
|
|
20
|
+
if (expectedUpdatedAt !== undefined && expectedUpdatedAt !== existing.updatedAt) {
|
|
21
|
+
throw new TRPCError({
|
|
22
|
+
code: 'CONFLICT',
|
|
23
|
+
message: 'Campaign changed since it was loaded. Reload before saving.',
|
|
24
|
+
});
|
|
25
|
+
}
|
|
20
26
|
assertCampaignEditable(existing);
|
|
21
|
-
const message =
|
|
27
|
+
const message =
|
|
28
|
+
updates.message === null ? undefined : (updates.message ?? existing.message);
|
|
22
29
|
const result = CampaignValidator.safeParse({
|
|
23
30
|
...existing,
|
|
24
31
|
...updates,
|
|
25
32
|
id: campaignId,
|
|
26
|
-
message: { ...message, id: campaignId },
|
|
33
|
+
message: message ? { ...message, id: campaignId } : undefined,
|
|
27
34
|
notification:
|
|
28
35
|
updates.notification === null
|
|
29
36
|
? undefined
|
|
30
37
|
: (updates.notification ?? existing.notification),
|
|
38
|
+
email: updates.email === null ? undefined : (updates.email ?? existing.email),
|
|
31
39
|
startsAt:
|
|
32
40
|
updates.startsAt === null ? undefined : (updates.startsAt ?? existing.startsAt),
|
|
33
41
|
endsAt: updates.endsAt === null ? undefined : (updates.endsAt ?? existing.endsAt),
|
|
34
|
-
|
|
42
|
+
// Every successful write must invalidate a loaded revision, even within one millisecond.
|
|
43
|
+
updatedAt: new Date(
|
|
44
|
+
Math.max(Date.now(), Date.parse(existing.updatedAt) + 1)
|
|
45
|
+
).toISOString(),
|
|
35
46
|
});
|
|
36
47
|
if (!result.success)
|
|
37
48
|
throw new TRPCError({ code: 'BAD_REQUEST', message: result.error.message });
|
|
@@ -83,7 +83,14 @@ export const createConsentFlowContract = async ({
|
|
|
83
83
|
updatedAt: createdAt,
|
|
84
84
|
audienceVersion: uniqueRecipientIds.length,
|
|
85
85
|
expiresAt,
|
|
86
|
+
currentVersion: 1,
|
|
87
|
+
status: 'active',
|
|
86
88
|
}),
|
|
89
|
+
versionId: uuid(),
|
|
90
|
+
version: 1,
|
|
91
|
+
contractSnapshot: JSON.stringify(contract),
|
|
92
|
+
autoboostSnapshot: JSON.stringify(uniqueAutoBoosts),
|
|
93
|
+
writerSnapshot: JSON.stringify(uniqueWriterIds),
|
|
87
94
|
})
|
|
88
95
|
)
|
|
89
96
|
// Resolve every target before writing; a missing target produces no writes.
|
|
@@ -106,6 +113,16 @@ export const createConsentFlowContract = async ({
|
|
|
106
113
|
.set('contract += $params')
|
|
107
114
|
.raw(
|
|
108
115
|
`
|
|
116
|
+
CREATE (version:ConsentFlowContractVersion {
|
|
117
|
+
id: $versionId, version: $version, contract: $contractSnapshot,
|
|
118
|
+
autoboosts: $autoboostSnapshot, writers: $writerSnapshot,
|
|
119
|
+
name: $params.name, subtitle: $params.subtitle, description: $params.description,
|
|
120
|
+
reasonForAccessing: $params.reasonForAccessing, image: $params.image,
|
|
121
|
+
needsGuardianConsent: $params.needsGuardianConsent, redirectUrl: $params.redirectUrl,
|
|
122
|
+
frontDoorBoostUri: $params.frontDoorBoostUri, expiresAt: $params.expiresAt,
|
|
123
|
+
createdAt: $createdAt
|
|
124
|
+
})
|
|
125
|
+
CREATE (contract)-[:HAS_VERSION]->(version)
|
|
109
126
|
CREATE (contract)-[:CREATED_BY]->(owner)
|
|
110
127
|
FOREACH (recipient IN recipients |
|
|
111
128
|
CREATE (contract)-[:SHARES_DATA_WITH {addedAt: $createdAt}]->(recipient))
|
|
@@ -129,5 +146,7 @@ export const createConsentFlowContract = async ({
|
|
|
129
146
|
message: 'Could not find all contract configuration targets',
|
|
130
147
|
});
|
|
131
148
|
}
|
|
132
|
-
|
|
149
|
+
const createdContract = inflateObject<DbContractType>(created.properties);
|
|
150
|
+
createdContract.contract = contract;
|
|
151
|
+
return createdContract;
|
|
133
152
|
};
|