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