@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.
Files changed (375) hide show
  1. package/.env.example +14 -0
  2. package/CHANGELOG.md +164 -0
  3. package/README.md +27 -1
  4. package/bootstrapLambda.test.ts +70 -32
  5. package/config/config.dev.json +1 -0
  6. package/config/config.example.json +2 -1
  7. package/config/config.production.json +1 -0
  8. package/config/config.scouts.dev.json +1 -0
  9. package/config/config.scouts.production.json +1 -0
  10. package/consentFlowDeadlineLambda.test.ts +108 -0
  11. package/consentFlowDeadlineLambda.ts +18 -0
  12. package/consentFlowDeadlineLambdaApp.ts +14 -0
  13. package/dist/accesslayer/boost/create.d.ts +8 -2
  14. package/dist/accesslayer/boost/create.d.ts.map +1 -1
  15. package/dist/accesslayer/campaign/audience.d.ts +4 -4
  16. package/dist/accesslayer/campaign/audience.d.ts.map +1 -1
  17. package/dist/accesslayer/campaign/create.d.ts +1 -1
  18. package/dist/accesslayer/campaign/create.d.ts.map +1 -1
  19. package/dist/accesslayer/campaign/delivery.d.ts +5 -0
  20. package/dist/accesslayer/campaign/delivery.d.ts.map +1 -0
  21. package/dist/accesslayer/campaign/dispatch.d.ts +5 -0
  22. package/dist/accesslayer/campaign/dispatch.d.ts.map +1 -0
  23. package/dist/accesslayer/campaign/dispatch.test.d.ts +2 -0
  24. package/dist/accesslayer/campaign/dispatch.test.d.ts.map +1 -0
  25. package/dist/accesslayer/campaign/email.d.ts +10 -0
  26. package/dist/accesslayer/campaign/email.d.ts.map +1 -0
  27. package/dist/accesslayer/campaign/email.test.d.ts +2 -0
  28. package/dist/accesslayer/campaign/email.test.d.ts.map +1 -0
  29. package/dist/accesslayer/campaign/preferences.d.ts +5 -0
  30. package/dist/accesslayer/campaign/preferences.d.ts.map +1 -0
  31. package/dist/accesslayer/campaign/read.d.ts.map +1 -1
  32. package/dist/accesslayer/campaign/test.d.ts +5 -0
  33. package/dist/accesslayer/campaign/test.d.ts.map +1 -0
  34. package/dist/accesslayer/campaign/test.test.d.ts +2 -0
  35. package/dist/accesslayer/campaign/test.test.d.ts.map +1 -0
  36. package/dist/accesslayer/campaign/update.d.ts.map +1 -1
  37. package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
  38. package/dist/accesslayer/consentflowcontract/delete.d.ts +1 -1
  39. package/dist/accesslayer/consentflowcontract/delete.d.ts.map +1 -1
  40. package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
  41. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +2 -1
  42. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  43. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +19 -4
  44. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
  45. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +6 -1
  46. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  47. package/dist/accesslayer/consentflowcontract/versions.d.ts +68 -0
  48. package/dist/accesslayer/consentflowcontract/versions.d.ts.map +1 -0
  49. package/dist/accesslayer/credential/create.d.ts +3 -1
  50. package/dist/accesslayer/credential/create.d.ts.map +1 -1
  51. package/dist/accesslayer/credential/delete.d.ts +1 -0
  52. package/dist/accesslayer/credential/delete.d.ts.map +1 -1
  53. package/dist/accesslayer/credential/relationships/create.d.ts +15 -3
  54. package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
  55. package/dist/accesslayer/credential/relationships/read.d.ts +2 -0
  56. package/dist/accesslayer/credential/relationships/read.d.ts.map +1 -1
  57. package/dist/accesslayer/credential/relationships/update.d.ts.map +1 -1
  58. package/dist/accesslayer/credential-refresh/read.d.ts +1 -0
  59. package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
  60. package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
  61. package/dist/accesslayer/inbox-credential/create.d.ts +1 -0
  62. package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
  63. package/dist/accesslayer/inbox-credential/refresh.d.ts +7 -0
  64. package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -1
  65. package/dist/app.d.ts +11 -1
  66. package/dist/app.d.ts.map +1 -1
  67. package/dist/cache/app-store.caches.d.ts +61 -1
  68. package/dist/cache/app-store.caches.d.ts.map +1 -1
  69. package/dist/campaign-unsubscribe.d.ts +7 -0
  70. package/dist/campaign-unsubscribe.d.ts.map +1 -0
  71. package/dist/campaign-unsubscribe.test.d.ts +2 -0
  72. package/dist/campaign-unsubscribe.test.d.ts.map +1 -0
  73. package/dist/config/environment.d.ts +42 -0
  74. package/dist/config/environment.d.ts.map +1 -1
  75. package/dist/config/stageConfig.d.ts +4 -0
  76. package/dist/config/stageConfig.d.ts.map +1 -1
  77. package/dist/credential-refresh.d.ts.map +1 -1
  78. package/dist/helpers/boost.helpers.d.ts +4 -1
  79. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  80. package/dist/helpers/campaign-app-origin.helpers.d.ts +2 -0
  81. package/dist/helpers/campaign-app-origin.helpers.d.ts.map +1 -0
  82. package/dist/helpers/campaign-email.helpers.d.ts +13 -0
  83. package/dist/helpers/campaign-email.helpers.d.ts.map +1 -0
  84. package/dist/helpers/campaign-notifications.helpers.d.ts +7 -0
  85. package/dist/helpers/campaign-notifications.helpers.d.ts.map +1 -0
  86. package/dist/helpers/campaign-queue.helpers.d.ts +7 -0
  87. package/dist/helpers/campaign-queue.helpers.d.ts.map +1 -0
  88. package/dist/helpers/campaign-queue.helpers.test.d.ts +2 -0
  89. package/dist/helpers/campaign-queue.helpers.test.d.ts.map +1 -0
  90. package/dist/helpers/campaign-unsubscribe-page.d.ts +2 -0
  91. package/dist/helpers/campaign-unsubscribe-page.d.ts.map +1 -0
  92. package/dist/helpers/campaign-unsubscribe.helpers.d.ts +7 -0
  93. package/dist/helpers/campaign-unsubscribe.helpers.d.ts.map +1 -0
  94. package/dist/helpers/campaign-webhook.helpers.test.d.ts +2 -0
  95. package/dist/helpers/campaign-webhook.helpers.test.d.ts.map +1 -0
  96. package/dist/helpers/consentflow-deadline.helpers.d.ts +5 -0
  97. package/dist/helpers/consentflow-deadline.helpers.d.ts.map +1 -0
  98. package/dist/helpers/consentflow-legacy-migration.helpers.d.ts +2 -0
  99. package/dist/helpers/consentflow-legacy-migration.helpers.d.ts.map +1 -0
  100. package/dist/helpers/contract-events.helpers.d.ts.map +1 -1
  101. package/dist/helpers/contract.helpers.d.ts +1 -1
  102. package/dist/helpers/contract.helpers.d.ts.map +1 -1
  103. package/dist/helpers/credential-display.helpers.d.ts +5 -0
  104. package/dist/helpers/credential-display.helpers.d.ts.map +1 -0
  105. package/dist/helpers/credential-display.helpers.test.d.ts +2 -0
  106. package/dist/helpers/credential-display.helpers.test.d.ts.map +1 -0
  107. package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -1
  108. package/dist/helpers/credential-refresh-materiality.helpers.d.ts +4 -0
  109. package/dist/helpers/credential-refresh-materiality.helpers.d.ts.map +1 -1
  110. package/dist/helpers/credential-refresh.helpers.d.ts +30 -1
  111. package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
  112. package/dist/helpers/credential-status-mutation.helpers.d.ts +5 -0
  113. package/dist/helpers/credential-status-mutation.helpers.d.ts.map +1 -0
  114. package/dist/helpers/credential-status-mutation.helpers.test.d.ts +2 -0
  115. package/dist/helpers/credential-status-mutation.helpers.test.d.ts.map +1 -0
  116. package/dist/helpers/credential.helpers.d.ts +1 -1
  117. package/dist/helpers/credential.helpers.d.ts.map +1 -1
  118. package/dist/helpers/inbox-refresh.helpers.d.ts +4 -2
  119. package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -1
  120. package/dist/helpers/inbox.helpers.d.ts +1 -0
  121. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  122. package/dist/helpers/learnCard.helpers.d.ts +1 -0
  123. package/dist/helpers/learnCard.helpers.d.ts.map +1 -1
  124. package/dist/helpers/managed-status.helpers.d.ts +7 -0
  125. package/dist/helpers/managed-status.helpers.d.ts.map +1 -0
  126. package/dist/helpers/managed-status.helpers.test.d.ts +2 -0
  127. package/dist/helpers/managed-status.helpers.test.d.ts.map +1 -0
  128. package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts +2 -0
  129. package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts.map +1 -0
  130. package/dist/helpers/notification-webhook-fetch.helpers.d.ts +13 -0
  131. package/dist/helpers/notification-webhook-fetch.helpers.d.ts.map +1 -0
  132. package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts +2 -0
  133. package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts.map +1 -0
  134. package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
  135. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  136. package/dist/helpers/refresh-send-intent.helpers.d.ts +13 -5
  137. package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -1
  138. package/dist/helpers/refresh-send-intent.helpers.test.d.ts +2 -0
  139. package/dist/helpers/refresh-send-intent.helpers.test.d.ts.map +1 -0
  140. package/dist/helpers/send-contract.helpers.d.ts +11 -0
  141. package/dist/helpers/send-contract.helpers.d.ts.map +1 -0
  142. package/dist/helpers/status-list.helpers.d.ts +12 -1
  143. package/dist/helpers/status-list.helpers.d.ts.map +1 -1
  144. package/dist/index.js +2 -2
  145. package/dist/models/ConsentFlowContract.d.ts.map +1 -1
  146. package/dist/models/ConsentFlowContractVersion.d.ts +12 -0
  147. package/dist/models/ConsentFlowContractVersion.d.ts.map +1 -0
  148. package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
  149. package/dist/models/Credential.d.ts +2 -0
  150. package/dist/models/Credential.d.ts.map +1 -1
  151. package/dist/models/CredentialRefresh.d.ts +5 -0
  152. package/dist/models/CredentialRefresh.d.ts.map +1 -1
  153. package/dist/models/Integration.d.ts +1 -1
  154. package/dist/models/Profile.d.ts.map +1 -1
  155. package/dist/models/campaign-constraints.d.ts.map +1 -1
  156. package/dist/models/index.d.ts +1 -0
  157. package/dist/models/index.d.ts.map +1 -1
  158. package/dist/openapi.d.ts.map +1 -1
  159. package/dist/publish-metafile.json +1 -1
  160. package/dist/routes/activity.d.ts +15 -5
  161. package/dist/routes/activity.d.ts.map +1 -1
  162. package/dist/routes/app-store.d.ts +15 -5
  163. package/dist/routes/app-store.d.ts.map +1 -1
  164. package/dist/routes/auth-grants.d.ts +12 -2
  165. package/dist/routes/auth-grants.d.ts.map +1 -1
  166. package/dist/routes/bench.d.ts +11 -1
  167. package/dist/routes/bench.d.ts.map +1 -1
  168. package/dist/routes/boosts.d.ts +70 -15
  169. package/dist/routes/boosts.d.ts.map +1 -1
  170. package/dist/routes/campaigns.d.ts +183 -51
  171. package/dist/routes/campaigns.d.ts.map +1 -1
  172. package/dist/routes/claim-hooks.d.ts +11 -1
  173. package/dist/routes/claim-hooks.d.ts.map +1 -1
  174. package/dist/routes/contact-methods.d.ts +11 -1
  175. package/dist/routes/contact-methods.d.ts.map +1 -1
  176. package/dist/routes/contracts.d.ts +374 -8
  177. package/dist/routes/contracts.d.ts.map +1 -1
  178. package/dist/routes/credential-input-privacy.test.d.ts +2 -0
  179. package/dist/routes/credential-input-privacy.test.d.ts.map +1 -0
  180. package/dist/routes/credential-refreshes.d.ts +210 -1
  181. package/dist/routes/credential-refreshes.d.ts.map +1 -1
  182. package/dist/routes/credential-share-previews.d.ts +11 -1
  183. package/dist/routes/credential-share-previews.d.ts.map +1 -1
  184. package/dist/routes/credentials.d.ts +132 -3
  185. package/dist/routes/credentials.d.ts.map +1 -1
  186. package/dist/routes/did-metadata.d.ts +11 -1
  187. package/dist/routes/did-metadata.d.ts.map +1 -1
  188. package/dist/routes/federation.d.ts +11 -1
  189. package/dist/routes/federation.d.ts.map +1 -1
  190. package/dist/routes/inbox.d.ts +54 -19
  191. package/dist/routes/inbox.d.ts.map +1 -1
  192. package/dist/routes/index.d.ts +100 -1
  193. package/dist/routes/index.d.ts.map +1 -1
  194. package/dist/routes/integrations.d.ts +14 -4
  195. package/dist/routes/integrations.d.ts.map +1 -1
  196. package/dist/routes/presentations.d.ts +11 -1
  197. package/dist/routes/presentations.d.ts.map +1 -1
  198. package/dist/routes/profile-manager.d.ts +11 -1
  199. package/dist/routes/profile-manager.d.ts.map +1 -1
  200. package/dist/routes/profiles.d.ts +11 -1
  201. package/dist/routes/profiles.d.ts.map +1 -1
  202. package/dist/routes/public-share-links.d.ts +22 -2
  203. package/dist/routes/public-share-links.d.ts.map +1 -1
  204. package/dist/routes/share-links.d.ts +36 -16
  205. package/dist/routes/share-links.d.ts.map +1 -1
  206. package/dist/routes/skill-frameworks.d.ts +11 -1
  207. package/dist/routes/skill-frameworks.d.ts.map +1 -1
  208. package/dist/routes/skills.d.ts +11 -1
  209. package/dist/routes/skills.d.ts.map +1 -1
  210. package/dist/routes/storage.d.ts +37 -23
  211. package/dist/routes/storage.d.ts.map +1 -1
  212. package/dist/routes/test.d.ts +79 -3
  213. package/dist/routes/test.d.ts.map +1 -1
  214. package/dist/routes/utilities.d.ts +11 -1
  215. package/dist/routes/utilities.d.ts.map +1 -1
  216. package/dist/routes/workflows.d.ts +11 -1
  217. package/dist/routes/workflows.d.ts.map +1 -1
  218. package/dist/services/delivery/adapters/postmark.adapter.d.ts +3 -0
  219. package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
  220. package/dist/services/delivery/delivery.service.d.ts +10 -0
  221. package/dist/services/delivery/delivery.service.d.ts.map +1 -1
  222. package/dist/types/activity.d.ts +3 -3
  223. package/dist/types/app-manifest.d.ts +10 -10
  224. package/dist/types/campaign.d.ts +13 -1
  225. package/dist/types/campaign.d.ts.map +1 -1
  226. package/dist/types/consentflowcontract.d.ts +175 -7
  227. package/dist/types/consentflowcontract.d.ts.map +1 -1
  228. package/dist/types/credential-refresh.d.ts +27 -0
  229. package/dist/types/credential-refresh.d.ts.map +1 -1
  230. package/dist/types/credential.d.ts +5 -0
  231. package/dist/types/credential.d.ts.map +1 -1
  232. package/dist/types/integration.d.ts +3 -3
  233. package/lambda.ts +44 -14
  234. package/lambdaApp.ts +20 -0
  235. package/package.json +20 -19
  236. package/project.json +5 -0
  237. package/scripts/migrate-consent-flow-versions.ts +9 -0
  238. package/scripts/report-send-template-exposure.ts +47 -0
  239. package/scripts/verify-python-contracts.ts +16 -1
  240. package/serverless-local.yml +15 -2
  241. package/serverless.function-env.cjs +4 -0
  242. package/serverless.local.yml +12 -0
  243. package/serverless.yml +48 -3
  244. package/src/accesslayer/boost/create.ts +62 -7
  245. package/src/accesslayer/campaign/README.md +275 -6
  246. package/src/accesslayer/campaign/audience.ts +39 -7
  247. package/src/accesslayer/campaign/create.ts +4 -2
  248. package/src/accesslayer/campaign/delivery.ts +66 -0
  249. package/src/accesslayer/campaign/dispatch.test.ts +59 -0
  250. package/src/accesslayer/campaign/dispatch.ts +172 -0
  251. package/src/accesslayer/campaign/email.test.ts +73 -0
  252. package/src/accesslayer/campaign/email.ts +91 -0
  253. package/src/accesslayer/campaign/preferences.ts +53 -0
  254. package/src/accesslayer/campaign/read.ts +3 -1
  255. package/src/accesslayer/campaign/state.ts +1 -1
  256. package/src/accesslayer/campaign/test.test.ts +31 -0
  257. package/src/accesslayer/campaign/test.ts +75 -0
  258. package/src/accesslayer/campaign/update.ts +15 -4
  259. package/src/accesslayer/consentflowcontract/create.ts +20 -1
  260. package/src/accesslayer/consentflowcontract/delete.ts +121 -3
  261. package/src/accesslayer/consentflowcontract/read.ts +15 -9
  262. package/src/accesslayer/consentflowcontract/relationships/create.ts +70 -36
  263. package/src/accesslayer/consentflowcontract/relationships/read.ts +305 -73
  264. package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +1 -1
  265. package/src/accesslayer/consentflowcontract/relationships/update.ts +269 -98
  266. package/src/accesslayer/consentflowcontract/versions.ts +790 -0
  267. package/src/accesslayer/credential/create.ts +33 -9
  268. package/src/accesslayer/credential/delete.ts +45 -0
  269. package/src/accesslayer/credential/relationships/create.ts +102 -47
  270. package/src/accesslayer/credential/relationships/update.ts +8 -116
  271. package/src/accesslayer/credential-refresh/read.ts +26 -2
  272. package/src/accesslayer/credential-refresh/update.ts +6 -2
  273. package/src/accesslayer/inbox-credential/create.ts +7 -1
  274. package/src/accesslayer/inbox-credential/refresh.ts +14 -4
  275. package/src/campaign-unsubscribe.test.ts +158 -0
  276. package/src/campaign-unsubscribe.ts +86 -0
  277. package/src/config/environment.test.ts +52 -0
  278. package/src/config/environment.ts +58 -0
  279. package/src/config/serverlessFunctionEnv.test.ts +59 -0
  280. package/src/credential-refresh.ts +1 -0
  281. package/src/docker-entry.ts +26 -1
  282. package/src/helpers/boost-storage.helpers.test.ts +107 -41
  283. package/src/helpers/boost.helpers.ts +88 -40
  284. package/src/helpers/campaign-app-origin.helpers.ts +25 -0
  285. package/src/helpers/campaign-email.helpers.ts +113 -0
  286. package/src/helpers/campaign-notifications.helpers.ts +127 -0
  287. package/src/helpers/campaign-queue.helpers.test.ts +442 -0
  288. package/src/helpers/campaign-queue.helpers.ts +122 -0
  289. package/src/helpers/campaign-unsubscribe-page.ts +107 -0
  290. package/src/helpers/campaign-unsubscribe.helpers.ts +56 -0
  291. package/src/helpers/campaign-webhook.helpers.test.ts +137 -0
  292. package/src/helpers/consentflow-deadline.helpers.ts +22 -0
  293. package/src/helpers/consentflow-legacy-migration.helpers.ts +179 -0
  294. package/src/helpers/contract-events.helpers.ts +35 -3
  295. package/src/helpers/contract-requests.helpers.ts +1 -1
  296. package/src/helpers/contract.helpers.ts +5 -1
  297. package/src/helpers/credential-display.helpers.test.ts +68 -0
  298. package/src/helpers/credential-display.helpers.ts +15 -0
  299. package/src/helpers/credential-refresh-email.helpers.ts +5 -12
  300. package/src/helpers/credential-refresh-materiality.helpers.test.ts +138 -1
  301. package/src/helpers/credential-refresh-materiality.helpers.ts +62 -1
  302. package/src/helpers/credential-refresh-proof.helpers.test.ts +1 -1
  303. package/src/helpers/credential-refresh-proof.helpers.ts +2 -2
  304. package/src/helpers/credential-refresh.helpers.ts +1166 -376
  305. package/src/helpers/credential-status-mutation.helpers.test.ts +155 -0
  306. package/src/helpers/credential-status-mutation.helpers.ts +166 -0
  307. package/src/helpers/credential.helpers.ts +19 -11
  308. package/src/helpers/finalize-inbox.helpers.ts +1 -1
  309. package/src/helpers/inbox-refresh.helpers.ts +34 -8
  310. package/src/helpers/inbox.helpers.ts +2 -0
  311. package/src/helpers/learnCard.helpers.ts +23 -0
  312. package/src/helpers/managed-status.helpers.test.ts +101 -0
  313. package/src/helpers/managed-status.helpers.ts +147 -0
  314. package/src/helpers/notification-delivery-boundary.helpers.test.ts +114 -0
  315. package/src/helpers/notification-webhook-fetch.helpers.test.ts +228 -0
  316. package/src/helpers/notification-webhook-fetch.helpers.ts +166 -0
  317. package/src/helpers/notificationQueue.helpers.ts +5 -0
  318. package/src/helpers/notifications.helpers.ts +19 -12
  319. package/src/helpers/refresh-send-intent.helpers.test.ts +60 -0
  320. package/src/helpers/refresh-send-intent.helpers.ts +81 -18
  321. package/src/helpers/send-contract.helpers.ts +36 -0
  322. package/src/helpers/status-list.helpers.test.ts +29 -12
  323. package/src/helpers/status-list.helpers.ts +329 -20
  324. package/src/models/ConsentFlowContract.ts +3 -0
  325. package/src/models/ConsentFlowContractVersion.ts +50 -0
  326. package/src/models/ConsentFlowTerms.ts +4 -0
  327. package/src/models/Credential.ts +2 -0
  328. package/src/models/CredentialRefresh.ts +2 -0
  329. package/src/models/Profile.ts +2 -0
  330. package/src/models/campaign-constraints.ts +13 -8
  331. package/src/models/index.ts +2 -0
  332. package/src/openapi.ts +21 -0
  333. package/src/routes/app-store.ts +2 -1
  334. package/src/routes/boosts.ts +219 -137
  335. package/src/routes/campaigns.ts +98 -19
  336. package/src/routes/contracts.ts +433 -115
  337. package/src/routes/credential-input-privacy.test.ts +92 -0
  338. package/src/routes/credential-refreshes.ts +273 -2
  339. package/src/routes/federation.ts +7 -2
  340. package/src/routes/inbox.ts +6 -1
  341. package/src/routes/index.ts +31 -2
  342. package/src/routes/workflows.ts +61 -46
  343. package/src/services/delivery/adapters/postmark.adapter.test.ts +70 -1
  344. package/src/services/delivery/adapters/postmark.adapter.ts +84 -11
  345. package/src/services/delivery/delivery.service.ts +4 -0
  346. package/src/types/campaign.ts +14 -1
  347. package/src/types/consentflowcontract.ts +43 -2
  348. package/src/types/credential-refresh.ts +11 -1
  349. package/src/types/credential.ts +2 -0
  350. package/test/boost-status-cleanup.spec.ts +123 -0
  351. package/test/campaigns-email.unit.spec.ts +268 -0
  352. package/test/campaigns.spec.ts +804 -30
  353. package/test/consent-autoboost-idempotency.spec.ts +432 -0
  354. package/test/consent-data-boundaries.spec.ts +322 -26
  355. package/test/consent-legacy-migration.spec.ts +315 -0
  356. package/test/consent-recipients.spec.ts +7 -7
  357. package/test/consent-version-updates.spec.ts +761 -0
  358. package/test/consentflow.spec.ts +698 -226
  359. package/test/contract-requests.spec.ts +55 -9
  360. package/test/credential-refresh-allocation.spec.ts +212 -14
  361. package/test/credential-refresh-email.spec.ts +6 -10
  362. package/test/credential-refresh-endpoint.spec.ts +3 -0
  363. package/test/credential-refresh-publication.spec.ts +52 -15
  364. package/test/credential-storage.neo4j.spec.ts +33 -6
  365. package/test/helpers/legacy-autoboost.ts +48 -0
  366. package/test/holder-mode-send.spec.ts +710 -0
  367. package/test/inbox-refresh.spec.ts +105 -0
  368. package/test/inbox-security.e2e.ts +18 -22
  369. package/test/managed-status-revocation.spec.ts +970 -0
  370. package/test/notifications-worker.spec.ts +31 -25
  371. package/test/refresh-send.spec.ts +334 -5
  372. package/test/smart-resume-consent.spec.ts +1 -1
  373. package/test/verification/campaigns.ts +40 -0
  374. package/vitest.campaigns.config.ts +2 -0
  375. package/vitest.config.ts +14 -0
@@ -1,8 +1,7 @@
1
1
  # Campaigns: setup, audiences and engagement (LC-2265)
2
2
 
3
- This slice stores campaign definitions, server-selected audiences and shared engagement
4
- state. It does not deliver notifications or render messages. LC-2266 owns dispatch and
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 does not send anything in this slice. Dates are ISO
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` is reserved for LC-2266 and is not client-writable.
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, notification delivery or device QA is claimed.
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<number> => {
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 result.records[0]!.get('count').toNumber();
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<number> => {
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 result.records[0]!.get('count').toNumber();
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
+ });