@learncard/network-brain-service 4.1.3 → 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 +143 -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
@@ -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.enabled) return [];
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.enabled ||
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 = updates.message ?? existing.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
- updatedAt: new Date().toISOString(),
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
- return inflateObject<DbContractType>(created.properties);
149
+ const createdContract = inflateObject<DbContractType>(created.properties);
150
+ createdContract.contract = contract;
151
+ return createdContract;
133
152
  };