create-ailk 0.24.0 → 0.25.1

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 (599) hide show
  1. package/README.md +42 -20
  2. package/component-catalog.json +3 -3
  3. package/component-catalog.md +3 -3
  4. package/dist/add-module.js +4 -2
  5. package/dist/add-route.d.ts +12 -1
  6. package/dist/add-route.js +31 -3
  7. package/dist/cli.js +12 -7
  8. package/dist/derive-architecture.d.ts +3 -4
  9. package/dist/derive-architecture.js +65 -29
  10. package/dist/lib/design-checks.d.ts +9 -4
  11. package/dist/lib/design-checks.js +13 -7
  12. package/dist/lib/extract-module-bundle.js +29 -2
  13. package/dist/lib/fetch-template-snapshot.d.ts +1 -1
  14. package/dist/lib/fetch-template-snapshot.js +7 -3
  15. package/dist/lib/import-graph.d.ts +1 -1
  16. package/dist/lib/import-graph.js +4 -4
  17. package/dist/lib/module-license-gate.d.ts +12 -1
  18. package/dist/lib/module-license-gate.js +34 -12
  19. package/dist/module-architecture.d.ts +20 -3
  20. package/dist/module-architecture.js +117 -550
  21. package/dist/paid-sections.d.ts +25 -1
  22. package/dist/paid-sections.js +88 -3
  23. package/dist/surfaces.js +10 -2
  24. package/dist/template-record.d.ts +14 -4
  25. package/dist/template-record.js +23 -5
  26. package/dist/topology.d.ts +16 -3
  27. package/dist/topology.js +31 -19
  28. package/dist/update.d.ts +10 -1
  29. package/dist/update.js +540 -16
  30. package/package.json +3 -3
  31. package/release-scope.json +1 -1
  32. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  33. package/templates/.claude/hooks/template-guard.sh +137 -0
  34. package/templates/.claude/rules/extending.md +106 -0
  35. package/templates/.claude/rules/loop-contract.md +2 -2
  36. package/templates/.claude/settings.json +11 -0
  37. package/templates/.env.example +5 -5
  38. package/templates/.github/workflows/ci.yml +7 -4
  39. package/templates/CLAUDE.md +11 -1
  40. package/templates/CONVENTIONS.md +8 -10
  41. package/templates/README.md +14 -13
  42. package/templates/apps/api/.env.example +2 -2
  43. package/templates/apps/api/CLAUDE.md +102 -76
  44. package/templates/apps/api/jest.config.cjs +15 -5
  45. package/templates/apps/api/package.json +16 -20
  46. package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +2 -2
  47. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +4 -4
  48. package/templates/apps/api/src/index.ts +1 -1
  49. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -3
  50. package/templates/apps/api/src/modules/commerce.ts +10 -0
  51. package/templates/apps/api/src/modules/content-write.ts +10 -0
  52. package/templates/apps/api/src/modules/multi-tenant.ts +10 -0
  53. package/templates/apps/api/src/modules/scheduling.ts +10 -0
  54. package/templates/apps/api/src/modules/signal-collectors.ts +10 -0
  55. package/templates/apps/api/src/modules/workspace.ts +10 -0
  56. package/templates/apps/api/src/server.ts +117 -1882
  57. package/templates/apps/api/vercel.json +2 -2
  58. package/templates/apps/mcp/CLAUDE.md +67 -20
  59. package/templates/apps/mcp/__tests__/app-entry.test.ts +4 -254
  60. package/templates/apps/mcp/__tests__/boot-file.test.ts +92 -0
  61. package/templates/apps/mcp/jest.config.cjs +6 -1
  62. package/templates/apps/mcp/package.json +5 -13
  63. package/templates/apps/mcp/src/app.ts +8 -7
  64. package/templates/apps/mcp/src/index.ts +6 -34
  65. package/templates/apps/mcp/src/lib/paid-tools.generated.ts +10 -0
  66. package/templates/apps/mcp/src/lib/topology.generated.ts +2 -2
  67. package/templates/apps/mcp/src/site.ts +12 -0
  68. package/templates/apps/web/.env.example +1 -1
  69. package/templates/apps/web/CLAUDE.md +10 -3
  70. package/templates/apps/web/app/.well-known/mcp.json/route.ts +9 -3
  71. package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +1 -1
  72. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +22 -1
  73. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +7 -3
  74. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +43 -0
  75. package/templates/apps/web/app/[locale]/pricing/page.tsx +7 -3
  76. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +222 -0
  77. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +10 -3
  78. package/templates/apps/web/app/api/revalidate/route.ts +3 -3
  79. package/templates/apps/web/auth.ts +2 -2
  80. package/templates/apps/web/jest.config.cjs +1 -0
  81. package/templates/apps/web/lib/auth-email.ts +1 -1
  82. package/templates/apps/web/lib/relay.ts +2 -2
  83. package/templates/apps/web/lib/release-scope.ts +47 -0
  84. package/templates/apps/web/lib/server-api.ts +1 -1
  85. package/templates/apps/web/src/lib/commerce-claim.ts +1 -1
  86. package/templates/customizations.yaml +37 -0
  87. package/templates/database/prisma.config.ts +10 -14
  88. package/templates/database/seed-test.ts +2 -2
  89. package/templates/database/seed.ts +5 -5
  90. package/templates/package.json +12 -6
  91. package/templates/pnpm-workspace.yaml +0 -1
  92. package/templates/project.yaml +16 -8
  93. package/templates/tools/jest/reporters.cjs +65 -0
  94. package/templates/tools/lint-architecture.ts +553 -7
  95. package/templates/tsconfig.json +1 -2
  96. package/versions.json +48 -35
  97. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +0 -409
  98. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +0 -102
  99. package/templates/apps/api/src/__tests__/factories/content-fixture.ts +0 -138
  100. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +0 -441
  101. package/templates/apps/api/src/__tests__/factories/index.ts +0 -70
  102. package/templates/apps/api/src/__tests__/factories/lead-fixture.ts +0 -82
  103. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +0 -135
  104. package/templates/apps/api/src/__tests__/services/enrichment-shim.test.ts +0 -126
  105. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +0 -132
  106. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +0 -362
  107. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +0 -184
  108. package/templates/apps/api/src/bin/abandon-sweep.ts +0 -42
  109. package/templates/apps/api/src/bin/rate-limit-probe.ts +0 -53
  110. package/templates/apps/api/src/bin/spokes.ts +0 -198
  111. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +0 -45
  112. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +0 -196
  113. package/templates/apps/api/src/config/__tests__/index.test.ts +0 -233
  114. package/templates/apps/api/src/config/__tests__/modules.test.ts +0 -76
  115. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +0 -185
  116. package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +0 -238
  117. package/templates/apps/api/src/config/index.ts +0 -596
  118. package/templates/apps/api/src/config/modules.ts +0 -83
  119. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +0 -581
  120. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +0 -143
  121. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +0 -237
  122. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +0 -353
  123. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +0 -201
  124. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +0 -224
  125. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +0 -120
  126. package/templates/apps/api/src/lib/__tests__/errors.test.ts +0 -94
  127. package/templates/apps/api/src/lib/__tests__/file-fallback.test.ts +0 -144
  128. package/templates/apps/api/src/lib/__tests__/fixtures/HomePage/home.mdx +0 -8
  129. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +0 -149
  130. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +0 -116
  131. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +0 -209
  132. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +0 -257
  133. package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +0 -96
  134. package/templates/apps/api/src/lib/__tests__/outbound-http.test.ts +0 -125
  135. package/templates/apps/api/src/lib/__tests__/prisma.test.ts +0 -165
  136. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +0 -317
  137. package/templates/apps/api/src/lib/__tests__/require-paid-tier.test.ts +0 -129
  138. package/templates/apps/api/src/lib/__tests__/site.test.ts +0 -308
  139. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +0 -89
  140. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +0 -202
  141. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +0 -217
  142. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -82
  143. package/templates/apps/api/src/lib/agent-store.ts +0 -613
  144. package/templates/apps/api/src/lib/auth-email-budget.ts +0 -161
  145. package/templates/apps/api/src/lib/consent-store.ts +0 -363
  146. package/templates/apps/api/src/lib/content-adapter.ts +0 -112
  147. package/templates/apps/api/src/lib/deliverable-asset-store.ts +0 -96
  148. package/templates/apps/api/src/lib/error-serializer.ts +0 -164
  149. package/templates/apps/api/src/lib/errors.ts +0 -95
  150. package/templates/apps/api/src/lib/file-fallback.ts +0 -87
  151. package/templates/apps/api/src/lib/flow-completion-store.ts +0 -136
  152. package/templates/apps/api/src/lib/hub-sign-in-store.ts +0 -455
  153. package/templates/apps/api/src/lib/lead-capture-store.ts +0 -122
  154. package/templates/apps/api/src/lib/lead-delivery.ts +0 -52
  155. package/templates/apps/api/src/lib/license-key-store.ts +0 -45
  156. package/templates/apps/api/src/lib/locales.ts +0 -201
  157. package/templates/apps/api/src/lib/outbound-http.ts +0 -127
  158. package/templates/apps/api/src/lib/prisma.ts +0 -21
  159. package/templates/apps/api/src/lib/proposal-paid-store.ts +0 -66
  160. package/templates/apps/api/src/lib/rate-limit-probe.ts +0 -165
  161. package/templates/apps/api/src/lib/rate-limit-store.ts +0 -142
  162. package/templates/apps/api/src/lib/require-commerce-claim.ts +0 -250
  163. package/templates/apps/api/src/lib/require-paid-tier.ts +0 -53
  164. package/templates/apps/api/src/lib/site.ts +0 -257
  165. package/templates/apps/api/src/lib/slice-load.ts +0 -90
  166. package/templates/apps/api/src/lib/spoke-store.ts +0 -180
  167. package/templates/apps/api/src/lib/stripe.ts +0 -321
  168. package/templates/apps/api/src/lib/webhook-idempotency.ts +0 -156
  169. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +0 -111
  170. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +0 -172
  171. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +0 -119
  172. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +0 -138
  173. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +0 -142
  174. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +0 -155
  175. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +0 -139
  176. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +0 -562
  177. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +0 -76
  178. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +0 -220
  179. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +0 -97
  180. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +0 -557
  181. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +0 -72
  182. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +0 -140
  183. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +0 -721
  184. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +0 -220
  185. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +0 -430
  186. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +0 -1409
  187. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +0 -174
  188. package/templates/apps/api/src/middleware/auth.ts +0 -864
  189. package/templates/apps/api/src/middleware/default-tenant.ts +0 -166
  190. package/templates/apps/api/src/middleware/rate-limit.ts +0 -983
  191. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +0 -391
  192. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +0 -182
  193. package/templates/apps/api/src/openapi/index.ts +0 -18
  194. package/templates/apps/api/src/openapi/spec.ts +0 -2148
  195. package/templates/apps/api/src/openapi/surface-drift.ts +0 -195
  196. package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +0 -175
  197. package/templates/apps/api/src/routes/aeo/index.ts +0 -107
  198. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +0 -196
  199. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +0 -249
  200. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +0 -532
  201. package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +0 -313
  202. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +0 -413
  203. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +0 -609
  204. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +0 -242
  205. package/templates/apps/api/src/routes/auth/tokens.ts +0 -211
  206. package/templates/apps/api/src/routes/auth/transactional-email.ts +0 -301
  207. package/templates/apps/api/src/routes/auth/user.ts +0 -305
  208. package/templates/apps/api/src/routes/billing/plan-checkout.ts +0 -332
  209. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +0 -178
  210. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +0 -1426
  211. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +0 -448
  212. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -1742
  213. package/templates/apps/api/src/routes/checkout/index.ts +0 -34
  214. package/templates/apps/api/src/routes/checkout/session.ts +0 -171
  215. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -540
  216. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +0 -386
  217. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +0 -160
  218. package/templates/apps/api/src/routes/consent/account.ts +0 -268
  219. package/templates/apps/api/src/routes/consent/cookie.ts +0 -90
  220. package/templates/apps/api/src/routes/consent/index.ts +0 -26
  221. package/templates/apps/api/src/routes/consent/unsubscribe.ts +0 -127
  222. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +0 -853
  223. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +0 -230
  224. package/templates/apps/api/src/routes/content/__tests__/list-types.test.ts +0 -44
  225. package/templates/apps/api/src/routes/content/__tests__/locale.test.ts +0 -118
  226. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +0 -731
  227. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +0 -753
  228. package/templates/apps/api/src/routes/content/__tests__/rate-limit.test.ts +0 -51
  229. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +0 -450
  230. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +0 -762
  231. package/templates/apps/api/src/routes/content/get-page.ts +0 -183
  232. package/templates/apps/api/src/routes/content/index.ts +0 -49
  233. package/templates/apps/api/src/routes/content/list-pages.ts +0 -74
  234. package/templates/apps/api/src/routes/content/list-types.ts +0 -19
  235. package/templates/apps/api/src/routes/content/offerings.ts +0 -184
  236. package/templates/apps/api/src/routes/content/pricing.ts +0 -176
  237. package/templates/apps/api/src/routes/content/rate-limit.ts +0 -22
  238. package/templates/apps/api/src/routes/content/search.ts +0 -100
  239. package/templates/apps/api/src/routes/content/shown-offerings.ts +0 -229
  240. package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +0 -88
  241. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +0 -171
  242. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +0 -515
  243. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +0 -392
  244. package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +0 -165
  245. package/templates/apps/api/src/routes/leads/index.ts +0 -639
  246. package/templates/apps/api/src/routes/leads/list.ts +0 -104
  247. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +0 -626
  248. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +0 -1268
  249. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +0 -847
  250. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +0 -491
  251. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +0 -476
  252. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +0 -872
  253. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +0 -1022
  254. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +0 -484
  255. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +0 -535
  256. package/templates/apps/api/src/routes/project-listings/index.ts +0 -164
  257. package/templates/apps/api/src/routes/project-listings/me.ts +0 -87
  258. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +0 -224
  259. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +0 -102
  260. package/templates/apps/api/src/routes/project-listings/public.ts +0 -98
  261. package/templates/apps/api/src/routes/project-listings/respond.ts +0 -48
  262. package/templates/apps/api/src/routes/project-listings/resume-token.ts +0 -27
  263. package/templates/apps/api/src/routes/project-listings/start.ts +0 -140
  264. package/templates/apps/api/src/routes/project-listings/submit.ts +0 -130
  265. package/templates/apps/api/src/routes/project-listings/verify-email.ts +0 -150
  266. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +0 -686
  267. package/templates/apps/api/src/routes/requests/confirm.ts +0 -601
  268. package/templates/apps/api/src/routes/requests/index.ts +0 -54
  269. package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +0 -306
  270. package/templates/apps/api/src/routes/test/auth/session.ts +0 -143
  271. package/templates/apps/api/src/routes/webhooks/README.md +0 -398
  272. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +0 -649
  273. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +0 -749
  274. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -449
  275. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -264
  276. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +0 -159
  277. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +0 -158
  278. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +0 -814
  279. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -301
  280. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +0 -369
  281. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -215
  282. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +0 -301
  283. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +0 -809
  284. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -1155
  285. package/templates/apps/api/src/routes/webhooks/index.ts +0 -141
  286. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +0 -384
  287. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -2028
  288. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +0 -645
  289. package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +0 -132
  290. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +0 -1028
  291. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +0 -313
  292. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +0 -503
  293. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +0 -65
  294. package/templates/apps/api/src/services/__tests__/consent.test.ts +0 -299
  295. package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +0 -147
  296. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +0 -877
  297. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +0 -379
  298. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +0 -368
  299. package/templates/apps/api/src/services/__tests__/entitlements.test.ts +0 -142
  300. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +0 -854
  301. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +0 -234
  302. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +0 -251
  303. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +0 -2660
  304. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +0 -166
  305. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +0 -389
  306. package/templates/apps/api/src/services/__tests__/person.test.ts +0 -1204
  307. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +0 -450
  308. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +0 -202
  309. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +0 -151
  310. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +0 -137
  311. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +0 -531
  312. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +0 -372
  313. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +0 -348
  314. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +0 -660
  315. package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +0 -941
  316. package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +0 -588
  317. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +0 -78
  318. package/templates/apps/api/src/services/abandon-email.ts +0 -336
  319. package/templates/apps/api/src/services/aeo-score.ts +0 -325
  320. package/templates/apps/api/src/services/answer-distributions.ts +0 -138
  321. package/templates/apps/api/src/services/audit-log.ts +0 -961
  322. package/templates/apps/api/src/services/checkout-buyer.ts +0 -168
  323. package/templates/apps/api/src/services/consent.ts +0 -834
  324. package/templates/apps/api/src/services/default-tenant.ts +0 -416
  325. package/templates/apps/api/src/services/deliverable-fulfillment.ts +0 -750
  326. package/templates/apps/api/src/services/dunning-policy.ts +0 -294
  327. package/templates/apps/api/src/services/enrichment-shim.ts +0 -53
  328. package/templates/apps/api/src/services/entitlements.ts +0 -338
  329. package/templates/apps/api/src/services/hub-sign-in.ts +0 -472
  330. package/templates/apps/api/src/services/lead-accept-user.ts +0 -126
  331. package/templates/apps/api/src/services/lead-promotion.ts +0 -160
  332. package/templates/apps/api/src/services/lead-routing.ts +0 -1372
  333. package/templates/apps/api/src/services/leads-inbox.ts +0 -312
  334. package/templates/apps/api/src/services/listing-config.ts +0 -178
  335. package/templates/apps/api/src/services/magnet-completion.ts +0 -242
  336. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +0 -348
  337. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +0 -268
  338. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +0 -975
  339. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +0 -416
  340. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +0 -400
  341. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +0 -685
  342. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +0 -231
  343. package/templates/apps/api/src/services/offerings/catalog.ts +0 -318
  344. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +0 -403
  345. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +0 -192
  346. package/templates/apps/api/src/services/offerings/connection.ts +0 -1083
  347. package/templates/apps/api/src/services/offerings/index.ts +0 -614
  348. package/templates/apps/api/src/services/offerings/source.ts +0 -140
  349. package/templates/apps/api/src/services/offerings/store.ts +0 -150
  350. package/templates/apps/api/src/services/owner-workspaces.ts +0 -132
  351. package/templates/apps/api/src/services/person.ts +0 -1211
  352. package/templates/apps/api/src/services/plan-offerings.ts +0 -125
  353. package/templates/apps/api/src/services/project-listing-resume-email.ts +0 -326
  354. package/templates/apps/api/src/services/project-listings.ts +0 -2085
  355. package/templates/apps/api/src/services/public-site-resolver.ts +0 -396
  356. package/templates/apps/api/src/services/request-confirm.ts +0 -698
  357. package/templates/apps/api/src/services/revalidation.ts +0 -235
  358. package/templates/apps/api/src/services/slack-notify.ts +0 -291
  359. package/templates/apps/api/src/services/usage-aggregation.ts +0 -516
  360. package/templates/apps/api/src/services/usage-credits.ts +0 -340
  361. package/templates/apps/api/src/services/verified-email.ts +0 -71
  362. package/templates/apps/mcp/__tests__/abuse/daily-cap.test.ts +0 -52
  363. package/templates/apps/mcp/__tests__/abuse/email-validator.test.ts +0 -46
  364. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +0 -198
  365. package/templates/apps/mcp/__tests__/abuse/honeypot.test.ts +0 -36
  366. package/templates/apps/mcp/__tests__/abuse/user-agent-deny.test.ts +0 -54
  367. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +0 -116
  368. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +0 -299
  369. package/templates/apps/mcp/__tests__/boot.test.ts +0 -280
  370. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +0 -359
  371. package/templates/apps/mcp/__tests__/config/modules.test.ts +0 -78
  372. package/templates/apps/mcp/__tests__/get_business_hours.test.ts +0 -82
  373. package/templates/apps/mcp/__tests__/get_faq.test.ts +0 -80
  374. package/templates/apps/mcp/__tests__/get_locations.test.ts +0 -65
  375. package/templates/apps/mcp/__tests__/get_offerings.test.ts +0 -557
  376. package/templates/apps/mcp/__tests__/get_pricing.test.ts +0 -524
  377. package/templates/apps/mcp/__tests__/get_site_info.test.ts +0 -81
  378. package/templates/apps/mcp/__tests__/http-transport.test.ts +0 -1232
  379. package/templates/apps/mcp/__tests__/index.test.ts +0 -195
  380. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +0 -191
  381. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +0 -531
  382. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +0 -70
  383. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -162
  384. package/templates/apps/mcp/__tests__/lib/content-root.test.ts +0 -93
  385. package/templates/apps/mcp/__tests__/lib/errors.test.ts +0 -75
  386. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +0 -180
  387. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +0 -124
  388. package/templates/apps/mcp/__tests__/list_pages.test.ts +0 -268
  389. package/templates/apps/mcp/__tests__/locale.test.ts +0 -120
  390. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +0 -175
  391. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +0 -101
  392. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +0 -189
  393. package/templates/apps/mcp/__tests__/read_content.test.ts +0 -511
  394. package/templates/apps/mcp/__tests__/request_callback.test.ts +0 -160
  395. package/templates/apps/mcp/__tests__/search_content.test.ts +0 -271
  396. package/templates/apps/mcp/__tests__/server.test.ts +0 -388
  397. package/templates/apps/mcp/__tests__/start_purchase.test.ts +0 -448
  398. package/templates/apps/mcp/__tests__/submit_lead.test.ts +0 -222
  399. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +0 -152
  400. package/templates/apps/mcp/__tests__/tools/create_project.test.ts +0 -243
  401. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +0 -451
  402. package/templates/apps/mcp/src/abuse/daily-cap.ts +0 -47
  403. package/templates/apps/mcp/src/abuse/email-validator.ts +0 -25
  404. package/templates/apps/mcp/src/abuse/honeypot.ts +0 -21
  405. package/templates/apps/mcp/src/abuse/index.ts +0 -349
  406. package/templates/apps/mcp/src/abuse/user-agent-deny.ts +0 -58
  407. package/templates/apps/mcp/src/boot.ts +0 -167
  408. package/templates/apps/mcp/src/config/modules.ts +0 -43
  409. package/templates/apps/mcp/src/http-transport.ts +0 -564
  410. package/templates/apps/mcp/src/lib/audit-single-page.ts +0 -308
  411. package/templates/apps/mcp/src/lib/commerce-claim.ts +0 -263
  412. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -116
  413. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +0 -140
  414. package/templates/apps/mcp/src/lib/errors.ts +0 -61
  415. package/templates/apps/mcp/src/lib/license-key-store.ts +0 -45
  416. package/templates/apps/mcp/src/lib/locales.ts +0 -171
  417. package/templates/apps/mcp/src/lib/prisma.ts +0 -39
  418. package/templates/apps/mcp/src/lib/public-workspace.ts +0 -133
  419. package/templates/apps/mcp/src/lib/resolve-locale.ts +0 -30
  420. package/templates/apps/mcp/src/lib/shown-offerings.ts +0 -105
  421. package/templates/apps/mcp/src/lib/site-config.ts +0 -53
  422. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +0 -59
  423. package/templates/apps/mcp/src/request-context.ts +0 -52
  424. package/templates/apps/mcp/src/server.ts +0 -343
  425. package/templates/apps/mcp/src/serverless-app.ts +0 -88
  426. package/templates/apps/mcp/src/tools/audit.ts +0 -127
  427. package/templates/apps/mcp/src/tools/capture_lead.ts +0 -66
  428. package/templates/apps/mcp/src/tools/create_project.ts +0 -134
  429. package/templates/apps/mcp/src/tools/get_business_hours.ts +0 -68
  430. package/templates/apps/mcp/src/tools/get_faq.ts +0 -71
  431. package/templates/apps/mcp/src/tools/get_locations.ts +0 -88
  432. package/templates/apps/mcp/src/tools/get_offerings.ts +0 -96
  433. package/templates/apps/mcp/src/tools/get_pricing.ts +0 -94
  434. package/templates/apps/mcp/src/tools/get_site_info.ts +0 -86
  435. package/templates/apps/mcp/src/tools/index.ts +0 -138
  436. package/templates/apps/mcp/src/tools/list_pages.ts +0 -68
  437. package/templates/apps/mcp/src/tools/person_notes.ts +0 -25
  438. package/templates/apps/mcp/src/tools/proposals.ts +0 -27
  439. package/templates/apps/mcp/src/tools/purchase.ts +0 -205
  440. package/templates/apps/mcp/src/tools/read_content.ts +0 -81
  441. package/templates/apps/mcp/src/tools/request_callback.ts +0 -52
  442. package/templates/apps/mcp/src/tools/search_content.ts +0 -63
  443. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +0 -54
  444. package/templates/apps/mcp/src/workspace-server.ts +0 -224
  445. package/templates/database/CHANGELOG.md +0 -981
  446. package/templates/database/LICENSE +0 -202
  447. package/templates/database/NOTICE +0 -2
  448. package/templates/database/README.md +0 -243
  449. package/templates/database/__tests__/agents.db.test.ts +0 -320
  450. package/templates/database/__tests__/appointment.db.test.ts +0 -106
  451. package/templates/database/__tests__/artifact-content-store.db.test.ts +0 -316
  452. package/templates/database/__tests__/composite-fence.db.test.ts +0 -206
  453. package/templates/database/__tests__/consent-ledger.db.test.ts +0 -345
  454. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +0 -79
  455. package/templates/database/__tests__/fence-fan-out.db.test.ts +0 -715
  456. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +0 -79
  457. package/templates/database/__tests__/fixtures/person-backfill.json +0 -264
  458. package/templates/database/__tests__/fixtures/person-contact-backfill.json +0 -182
  459. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +0 -100
  460. package/templates/database/__tests__/flow-completion-migration.test.ts +0 -115
  461. package/templates/database/__tests__/flow-completion.db.test.ts +0 -136
  462. package/templates/database/__tests__/flow-workspace-key.db.test.ts +0 -263
  463. package/templates/database/__tests__/flows-listing-diagnostic.test.ts +0 -392
  464. package/templates/database/__tests__/flows-presets.test.ts +0 -173
  465. package/templates/database/__tests__/in-page-magnets-migration.test.ts +0 -161
  466. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +0 -92
  467. package/templates/database/__tests__/member-area.db.test.ts +0 -257
  468. package/templates/database/__tests__/member-contract-migration.test.ts +0 -85
  469. package/templates/database/__tests__/member-contract.db.test.ts +0 -413
  470. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +0 -270
  471. package/templates/database/__tests__/offerings-migration.test.ts +0 -197
  472. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +0 -86
  473. package/templates/database/__tests__/offerings.db.test.ts +0 -303
  474. package/templates/database/__tests__/outbound-message-action.db.test.ts +0 -80
  475. package/templates/database/__tests__/person-contact.db.test.ts +0 -563
  476. package/templates/database/__tests__/person-notes.db.test.ts +0 -249
  477. package/templates/database/__tests__/person.db.test.ts +0 -586
  478. package/templates/database/__tests__/piece-kind-migration.test.ts +0 -67
  479. package/templates/database/__tests__/piece-kind.db.test.ts +0 -208
  480. package/templates/database/__tests__/prefixed-ids.db.test.ts +0 -222
  481. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +0 -111
  482. package/templates/database/__tests__/seed.test.ts +0 -272
  483. package/templates/database/__tests__/spoke-registry.db.test.ts +0 -191
  484. package/templates/database/__tests__/tenancy.test.ts +0 -141
  485. package/templates/database/__tests__/trial-magnet-migration.test.ts +0 -186
  486. package/templates/database/__tests__/user-cascade.test.ts +0 -140
  487. package/templates/database/_gitignore +0 -8
  488. package/templates/database/auth/.gitkeep +0 -0
  489. package/templates/database/auth/schema.prisma +0 -641
  490. package/templates/database/config/.gitkeep +0 -0
  491. package/templates/database/config/schema.prisma +0 -441
  492. package/templates/database/content/.gitkeep +0 -0
  493. package/templates/database/content/schema.prisma +0 -339
  494. package/templates/database/eslint.config.mjs +0 -69
  495. package/templates/database/flows/listing-diagnostic.ts +0 -182
  496. package/templates/database/flows/presets.ts +0 -315
  497. package/templates/database/inbox/.gitkeep +0 -0
  498. package/templates/database/inbox/README.md +0 -3
  499. package/templates/database/inbox/__tests__/schema.test.ts +0 -44
  500. package/templates/database/inbox/schema.prisma +0 -1447
  501. package/templates/database/jest.config.cjs +0 -36
  502. package/templates/database/migrations/20260508000000_add_inbox_namespace/migration.sql +0 -328
  503. package/templates/database/migrations/20260513000000_better_auth_migration/migration.sql +0 -100
  504. package/templates/database/migrations/20260515000000_add_user_billing_fields/migration.sql +0 -28
  505. package/templates/database/migrations/20260516000000_add_user_tier_care_plan_seat/migration.sql +0 -25
  506. package/templates/database/migrations/20260518000000_add_telemetry_records/migration.sql +0 -42
  507. package/templates/database/migrations/20260525000000_theme_wiring/migration.sql +0 -67
  508. package/templates/database/migrations/20260710000000_retire_commercial_tier/migration.sql +0 -21
  509. package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +0 -201
  510. package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +0 -67
  511. package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +0 -46
  512. package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +0 -25
  513. package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +0 -49
  514. package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +0 -197
  515. package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +0 -32
  516. package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +0 -22
  517. package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +0 -151
  518. package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +0 -223
  519. package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +0 -22
  520. package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +0 -32
  521. package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +0 -40
  522. package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +0 -4
  523. package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +0 -2
  524. package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +0 -2
  525. package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +0 -29
  526. package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +0 -6
  527. package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +0 -39
  528. package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +0 -24
  529. package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +0 -3
  530. package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +0 -33
  531. package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +0 -30
  532. package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +0 -47
  533. package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +0 -16
  534. package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +0 -47
  535. package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +0 -10
  536. package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +0 -26
  537. package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +0 -90
  538. package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +0 -174
  539. package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +0 -14
  540. package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +0 -25
  541. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +0 -32
  542. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +0 -71
  543. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +0 -30
  544. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +0 -49
  545. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +0 -123
  546. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +0 -15
  547. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +0 -158
  548. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +0 -40
  549. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +0 -67
  550. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +0 -70
  551. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +0 -181
  552. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +0 -172
  553. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +0 -41
  554. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +0 -185
  555. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +0 -70
  556. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +0 -57
  557. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +0 -68
  558. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +0 -1067
  559. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +0 -210
  560. package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +0 -80
  561. package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +0 -49
  562. package/templates/database/migrations/20261005120000_agents/migration.sql +0 -84
  563. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +0 -35
  564. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +0 -20
  565. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +0 -33
  566. package/templates/database/migrations/20261010120000_person/migration.sql +0 -106
  567. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +0 -132
  568. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +0 -20
  569. package/templates/database/migrations/20261011120000_member_area/migration.sql +0 -158
  570. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +0 -21
  571. package/templates/database/migrations/20261012120000_member_contract/migration.sql +0 -192
  572. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +0 -20
  573. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +0 -122
  574. package/templates/database/migrations/20261014120000_person_contact/migration.sql +0 -231
  575. package/templates/database/migrations/20261015120000_person_notes/migration.sql +0 -77
  576. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +0 -43
  577. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +0 -95
  578. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +0 -30
  579. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +0 -52
  580. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +0 -27
  581. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +0 -146
  582. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +0 -77
  583. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +0 -45
  584. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +0 -47
  585. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +0 -16
  586. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +0 -28
  587. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +0 -23
  588. package/templates/database/migrations/migration_lock.toml +0 -3
  589. package/templates/database/ops/.gitkeep +0 -0
  590. package/templates/database/ops/schema.prisma +0 -43
  591. package/templates/database/package.json +0 -89
  592. package/templates/database/schema.prisma +0 -71
  593. package/templates/database/scripts/db-generate-locked.sh +0 -80
  594. package/templates/database/scripts/person-backfill.sql +0 -132
  595. package/templates/database/tenancy.ts +0 -183
  596. package/templates/database/tsconfig.build.json +0 -16
  597. package/templates/database/tsconfig.flows.json +0 -15
  598. package/templates/database/tsconfig.json +0 -16
  599. package/templates/database/tsconfig.tenancy.json +0 -14
@@ -1,1211 +0,0 @@
1
- /**
2
- * @file person.ts
3
- * @description Person — one client of one workspace (#852, spec
4
- * docs/specs/852-person-the-per-workspace-client-spec.md D3, D5, D6). This
5
- * file is the ONLY writer of `Person`:
6
- *
7
- * - `findOrCreatePerson` — the one creator, called by lead capture
8
- * (`lead-routing.ts`), the hub's own Stripe checkout webhook (through
9
- * `recordCheckoutPerson` below), appointment creation
10
- * (`scheduling/appointments.ts`) and the Admin inbox accept
11
- * (`inbox-messages.ts`).
12
- * - `stampPersonUser` — links a Person to a User, only on a verified-email
13
- * match, never overwriting a link.
14
- * - `recordCheckoutPerson` — the webhook's step: the Person of a completed
15
- * hub checkout, with its live-mode customer id.
16
- * `resolveCheckoutPerson` is the same step answering that Person too, for
17
- * the checkout's terms grant (#8765 D14); the webhook calls it, and
18
- * `recordCheckoutPerson` stays as its outcome-only form for the #852
19
- * suites that pin that outcome.
20
- * - `resolveMemberPersonId` — the member reads' Person (#7877 D8): the one
21
- * linked to the caller, else the one whose email is the caller's
22
- * verified email, linked on first read by `stampPersonUser`'s rule. That
23
- * first link — and no other — releases the Person's held checkout plan
24
- * (#8793 D2).
25
- * - `resolveCallerBillingCustomerId` — the customer id the hub vouches for
26
- * in `GET /v1/hub-sign-in/me` (#7961, spec 7894 D13): that same Person's,
27
- * only while its `userId` is the caller.
28
- * - `setPersonPlanOffering` — the plan's only writer (#7877 D8), called by
29
- * the Admin's plan route and `releaseHeldCheckoutPlan`.
30
- * - `releaseHeldCheckoutPlan` and `checkoutPlanKey` — the checkout plan
31
- * rule (#7877 D10, #8793 D1–D4). A spoke's checkout lead never writes a
32
- * plan: it holds it (`lead-routing.ts`), because its email is whatever
33
- * the buyer typed at Stripe Checkout. The hold is released once, at the
34
- * Person's first member-path link to the user whose VERIFIED email is the
35
- * Person's own, into a blank plan only, and only when every held purchase
36
- * under that email names the same plan key.
37
- * - the contact details (#8435): `findOrCreatePerson` fills BLANK contact
38
- * fields of an uncurated Person (D4); `updatePersonContact` (the
39
- * operator's save), `ensureMemberPerson` and `setOwnPersonContact` (the
40
- * client's save) overwrite and stamp who confirmed them (D9, D11).
41
- *
42
- * ## Match order (D3) — fixed, and there is no merge
43
- *
44
- * 1. Normalize the email (trim ASCII whitespace, lowercase ASCII letters);
45
- * not storable → `invalid_email`, before any read.
46
- * 2. A customer id given → the oldest Person of this workspace carrying it.
47
- * A hit is returned with NO write — its email is never rewritten.
48
- * 3. The Person of this workspace with this email. A hit gains the customer
49
- * id only when it has none AND is linked to no user (first writer wins,
50
- * else `billing: "kept"`), and a name only when it has none. The email is
51
- * whatever the caller typed, never verified here, so it never pins a
52
- * customer on a Person whose `userId` the verified-email stamp set
53
- * (SEC-7894-6, decided on #7961 2026-10-09).
54
- * 4. No hit → `createMany({ skipDuplicates })` — Postgres INSERT … ON
55
- * CONFLICT DO NOTHING — then a re-read that finds the winner of any
56
- * concurrent create. The duplicate is absorbed IN THE DATABASE, never as
57
- * a caught P2002: the appointment path calls this inside an interactive
58
- * transaction, where a raised unique violation aborts the transaction
59
- * (C4). One implementation serves both.
60
- *
61
- * ## Why public, and what it may import (D3)
62
- *
63
- * The base-tree `lead-routing.ts` imports this file, so it ships in every
64
- * build and must never reach the paid import closure: it imports
65
- * `lib/prisma.js`, `verified-email.js`, `planOfferingSchema` from the
66
- * base-tree kernel `@working-theory/validation` and types only — never
67
- * `lead-promotion.ts`, `owner-workspaces.ts`, `lead-accept-user.ts` (whose
68
- * find-or-create shape it mirrors) or `lib/tenant-db.ts`.
69
- * `packages/create-ailk/__tests__/paid-module-exclusions.test.ts` holds this
70
- * file and `lead-routing.ts` in `MUST_SHIP`.
71
- *
72
- * ## Tenancy
73
- *
74
- * Every `Person` statement is scoped by a server-resolved `workspaceId`;
75
- * there is no `Person` list read here (the reads are
76
- * `tenantDb(tenant).people`). The one list read is `releaseHeldCheckoutPlan`'s
77
- * (#8793 D3): the newest 51 `spokeCheckout` leads of a Person just linked to
78
- * the caller, by `(workspaceId, personId)`, the workspace the member read
79
- * resolved.
80
- * `recordCheckoutPerson`'s two other reads are by a server-minted
81
- * `FlowCheckout` id from a signature-verified webhook, matched on that
82
- * session's own `stripeSessionId`, then that row's lead scoped by the row's
83
- * own `workspaceId`. `resolveMemberPersonId` reads by `(workspaceId, userId)`
84
- * and by `(workspaceId, email)`, and `resolveCallerBillingCustomerId` by
85
- * `(id, workspaceId, userId)`, the workspace server-resolved and the user
86
- * the authenticated caller. `architecture.yaml` names this file as an
87
- * exclusion with that reason.
88
- *
89
- * ## Errors and logs
90
- *
91
- * A store error is caught and returned as a bare kind; no Prisma message is
92
- * returned or logged (SEC-006). These functions log nothing: each caller
93
- * logs the outcome kind, never an email, a name or a customer id. The
94
- * exceptions are `resolveMemberPersonId`, whose `null` hides a refused stamp,
95
- * and `resolveCallerBillingCustomerId`, whose `null` hides a failed read: each
96
- * logs that outcome's kind through the logger its caller passes.
97
- */
98
-
99
- import type { Prisma } from "@working-theory/database";
100
- import type { Id } from "@working-theory/ids";
101
- import { planOfferingSchema, type PersonContactPatch } from "@working-theory/validation";
102
-
103
- import { prisma } from "../lib/prisma.js";
104
-
105
- import { findVerifiedEmail } from "./verified-email.js";
106
-
107
- // ─── Normalization ────────────────────────────────────────────────────────────
108
-
109
- /** The ASCII whitespace the backfill's btrim set names: SP, HT, LF, CR, FF, VT. */
110
- const EDGE_WHITESPACE = /^[ \t\n\r\f\v]+|[ \t\n\r\f\v]+$/g;
111
- /** Printable ASCII, one `@`, a non-empty side each — the `people_email_normalized` CHECK. */
112
- const STORABLE_EMAIL = /^[!-?A-~]+@[!-?A-~]+$/;
113
- const MAX_EMAIL_LENGTH = 254;
114
- const MAX_NAME_CODE_POINTS = 200;
115
- const MAX_CUSTOMER_ID_LENGTH = 255;
116
- /** C0 and C1 controls and the bidi controls, removed from a name (SEC-852-I). */
117
- // eslint-disable-next-line no-control-regex -- the control-character range is exactly what a stored name must not carry.
118
- const NAME_STRIP = /[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
119
-
120
- /**
121
- * Trim ASCII whitespace, then lowercase ASCII letters only — the same value
122
- * Postgres computes as `lower(btrim(x, E' \t\n\r\f\x0B') COLLATE "C")`.
123
- */
124
- export function normalizePersonEmail(raw: string): string {
125
- return raw
126
- .replace(EDGE_WHITESPACE, "")
127
- .replace(/[A-Z]/g, (c) => c.toLowerCase());
128
- }
129
-
130
- /**
131
- * Printable ASCII, one `@` with a non-empty side each, at most 254
132
- * characters — the `people_email_normalized` CHECK's regex. ASCII only, so
133
- * the TypeScript length and the Postgres length agree.
134
- */
135
- export function isStorablePersonEmail(email: string): boolean {
136
- return email.length <= MAX_EMAIL_LENGTH && STORABLE_EMAIL.test(email);
137
- }
138
-
139
- /**
140
- * A name as stored: controls and bidi controls removed, trimmed of spaces, cut
141
- * to 200 code points; empty → `null`.
142
- */
143
- export function normalizePersonName(raw: unknown): string | null {
144
- if (typeof raw !== "string") return null;
145
- // Spaces only, as the backfill's `btrim(x)` trims, so a live-written
146
- // name and a backfilled one agree.
147
- const cleaned = Array.from(raw.replace(NAME_STRIP, "").replace(/^ +| +$/g, ""))
148
- .slice(0, MAX_NAME_CODE_POINTS)
149
- .join("");
150
- return cleaned === "" ? null : cleaned;
151
- }
152
-
153
- // ─── Contact normalization (#8435 D3) ────────────────────────────────────────
154
-
155
- export type PersonAddress = {
156
- addressLine1: string;
157
- addressLine2: string | null;
158
- city: string;
159
- region: string;
160
- postalCode: string;
161
- country: string;
162
- };
163
-
164
- export type PersonContact = {
165
- givenName: string | null;
166
- familyName: string | null;
167
- telephone: string | null;
168
- address: PersonAddress | null;
169
- companyName: string | null;
170
- jobTitle: string | null;
171
- };
172
-
173
- /**
174
- * What a capture offers, as untrusted `unknown` values: the magnet's tagged
175
- * answers or a caller's explicit contact (`RouteLeadOptions.contact`).
176
- */
177
- export type LeadContact = {
178
- name?: unknown;
179
- givenName?: unknown;
180
- familyName?: unknown;
181
- phone?: unknown;
182
- address?: unknown;
183
- company?: unknown;
184
- jobTitle?: unknown;
185
- };
186
-
187
- /** The widths of the person-level text columns, in code points. */
188
- const CONTACT_MAX = {
189
- givenName: 100,
190
- familyName: 100,
191
- companyName: 200,
192
- jobTitle: 100,
193
- } as const;
194
- /** The address parts' widths — `ADDRESS_PART_MAX_LENGTHS` in `packages/validation`. */
195
- const ADDRESS_MAX = {
196
- addressLine1: 100,
197
- addressLine2: 60,
198
- city: 60,
199
- region: 40,
200
- postalCode: 20,
201
- } as const;
202
- const E164 = /^\+[1-9]\d{6,14}$/;
203
- const SHORT_REGION = /^[A-Za-z0-9]{1,3}$/;
204
- const ALPHA2 = /^[A-Z]{2}$/;
205
- /**
206
- * The code-point ranges `CONTACT_STRIP` removes (SEC-8435-L): `NAME_STRIP`'s
207
- * C0/C1 and bidi controls plus the zero-width characters U+200B–U+200D and
208
- * U+FEFF and the separators U+2028/U+2029. Numbers, not a regex literal, so no
209
- * tool can rewrite a separator into a line break inside it. The same set
210
- * `packages/validation`'s `people-contact.ts` strips on the wire.
211
- */
212
- const CONTACT_STRIPPED: ReadonlyArray<readonly [number, number]> = [
213
- [0x0000, 0x001f],
214
- [0x007f, 0x009f],
215
- [0x200b, 0x200f],
216
- [0x2028, 0x2029],
217
- [0x202a, 0x202e],
218
- [0x2066, 0x2069],
219
- [0xfeff, 0xfeff],
220
- ];
221
-
222
- function stripContactChars(raw: string): string {
223
- let out = "";
224
- for (const ch of raw) {
225
- const cp = ch.codePointAt(0) ?? 0;
226
- if (!CONTACT_STRIPPED.some(([lo, hi]) => cp >= lo && cp <= hi)) out += ch;
227
- }
228
- return out;
229
- }
230
-
231
- const cutToCodePoints = (value: string, max: number): string =>
232
- Array.from(value).slice(0, max).join("");
233
-
234
- /** Stripped, trimmed, cut to `max` code points; empty or not a string → `null`. */
235
- function normalizeContactText(raw: unknown, max: number): string | null {
236
- if (typeof raw !== "string") return null;
237
- const cleaned = cutToCodePoints(stripContactChars(raw).trim(), max);
238
- return cleaned === "" ? null : cleaned;
239
- }
240
-
241
- /**
242
- * Strip spaces, `( ) . -` (and the invisible characters), then the result
243
- * must be an E.164 number — else `null`.
244
- */
245
- export function normalizeTelephone(raw: unknown): string | null {
246
- if (typeof raw !== "string") return null;
247
- const compact = stripContactChars(raw)
248
- .replace(/[ ().-]/g, "")
249
- .trim();
250
- return E164.test(compact) ? compact : null;
251
- }
252
-
253
- /**
254
- * An address as stored: whole or not at all. Every required part must be a
255
- * non-empty string within its width after stripping; a short region is
256
- * upper-cased (C3); the country must be an upper-case alpha-2 code.
257
- */
258
- function normalizeAddress(raw: unknown): PersonAddress | null {
259
- if (typeof raw !== "object" || raw === null || Array.isArray(raw))
260
- return null;
261
- const value = raw as Record<string, unknown>;
262
- const part = (key: keyof typeof ADDRESS_MAX): string | null => {
263
- const field = value[key];
264
- if (typeof field !== "string") return null;
265
- const cleaned = stripContactChars(field).trim();
266
- return cleaned === "" || Array.from(cleaned).length > ADDRESS_MAX[key]
267
- ? null
268
- : cleaned;
269
- };
270
- const addressLine1 = part("addressLine1");
271
- const city = part("city");
272
- const region = part("region");
273
- const postalCode = part("postalCode");
274
- const country = value["country"];
275
- if (
276
- addressLine1 === null ||
277
- city === null ||
278
- region === null ||
279
- postalCode === null ||
280
- typeof country !== "string" ||
281
- !ALPHA2.test(country)
282
- ) {
283
- return null;
284
- }
285
- const line2Raw = value["addressLine2"];
286
- const line2 = part("addressLine2");
287
- // An over-long or non-string second line makes the address unusable; an
288
- // absent or blank one is simply null.
289
- if (line2 === null && line2Raw !== undefined && line2Raw !== null) {
290
- if (
291
- typeof line2Raw !== "string" ||
292
- stripContactChars(line2Raw).trim() !== ""
293
- )
294
- return null;
295
- }
296
- return {
297
- addressLine1,
298
- addressLine2: line2,
299
- city,
300
- region: SHORT_REGION.test(region) ? region.toUpperCase() : region,
301
- postalCode,
302
- country,
303
- };
304
- }
305
-
306
- /** Capture-side: every usable field, each normalized; unusable ones dropped. */
307
- export function normalizeCapturedContact(
308
- raw: LeadContact,
309
- ): Partial<PersonContact> {
310
- const out: Partial<PersonContact> = {};
311
- const givenName = normalizeContactText(raw.givenName, CONTACT_MAX.givenName);
312
- if (givenName !== null) out.givenName = givenName;
313
- const familyName = normalizeContactText(
314
- raw.familyName,
315
- CONTACT_MAX.familyName,
316
- );
317
- if (familyName !== null) out.familyName = familyName;
318
- const telephone = normalizeTelephone(raw.phone);
319
- if (telephone !== null) out.telephone = telephone;
320
- const address = normalizeAddress(raw.address);
321
- if (address !== null) out.address = address;
322
- const companyName = normalizeContactText(
323
- raw.company,
324
- CONTACT_MAX.companyName,
325
- );
326
- if (companyName !== null) out.companyName = companyName;
327
- const jobTitle = normalizeContactText(raw.jobTitle, CONTACT_MAX.jobTitle);
328
- if (jobTitle !== null) out.jobTitle = jobTitle;
329
- return out;
330
- }
331
-
332
- // ─── The contact columns, as rows and as the wire's shape ───────────────────
333
-
334
- /** The thirteen contact columns a read names (`select`). */
335
- export const PERSON_CONTACT_SELECT = {
336
- givenName: true,
337
- familyName: true,
338
- telephone: true,
339
- addressLine1: true,
340
- addressLine2: true,
341
- addressCity: true,
342
- addressRegion: true,
343
- addressPostalCode: true,
344
- addressCountry: true,
345
- companyName: true,
346
- jobTitle: true,
347
- contactConfirmedAt: true,
348
- contactConfirmedBy: true,
349
- } as const;
350
-
351
- export type PersonContactRow = {
352
- givenName: string | null;
353
- familyName: string | null;
354
- telephone: string | null;
355
- addressLine1: string | null;
356
- addressLine2: string | null;
357
- addressCity: string | null;
358
- addressRegion: string | null;
359
- addressPostalCode: string | null;
360
- addressCountry: string | null;
361
- companyName: string | null;
362
- jobTitle: string | null;
363
- contactConfirmedAt: Date | null;
364
- contactConfirmedBy: string | null;
365
- };
366
-
367
- /** The wire's contact: every key present, `null` where blank, the address whole or `null`. */
368
- export function personContactFromRow(row: PersonContactRow): PersonContact {
369
- // `?? null` so a row that does not carry a column reads as blank, never as
370
- // `undefined` on the wire.
371
- const line1 = row.addressLine1 ?? null;
372
- const city = row.addressCity ?? null;
373
- const region = row.addressRegion ?? null;
374
- const postalCode = row.addressPostalCode ?? null;
375
- const country = row.addressCountry ?? null;
376
- const address: PersonAddress | null =
377
- line1 !== null && city !== null && region !== null && postalCode !== null && country !== null
378
- ? {
379
- addressLine1: line1,
380
- addressLine2: row.addressLine2 ?? null,
381
- city,
382
- region,
383
- postalCode,
384
- country,
385
- }
386
- : null;
387
- return {
388
- givenName: row.givenName ?? null,
389
- familyName: row.familyName ?? null,
390
- telephone: row.telephone ?? null,
391
- address,
392
- companyName: row.companyName ?? null,
393
- jobTitle: row.jobTitle ?? null,
394
- };
395
- }
396
-
397
- /** Who last saved the contact by hand, and when — `null` while only captures have written it. */
398
- export function contactConfirmedFromRow(
399
- row: Pick<PersonContactRow, "contactConfirmedAt" | "contactConfirmedBy">,
400
- ): { by: "operator" | "client"; at: string } | null {
401
- if (row.contactConfirmedAt === null || row.contactConfirmedAt === undefined)
402
- return null;
403
- if (
404
- row.contactConfirmedBy !== "operator" &&
405
- row.contactConfirmedBy !== "client"
406
- )
407
- return null;
408
- return {
409
- by: row.contactConfirmedBy,
410
- at: row.contactConfirmedAt.toISOString(),
411
- };
412
- }
413
-
414
- type AddressColumns = {
415
- addressLine1: string | null;
416
- addressLine2: string | null;
417
- addressCity: string | null;
418
- addressRegion: string | null;
419
- addressPostalCode: string | null;
420
- addressCountry: string | null;
421
- };
422
-
423
- /** The six address columns for an address — all six `null` for `null`. */
424
- function addressColumns(address: PersonAddress | null): AddressColumns {
425
- return {
426
- addressLine1: address?.addressLine1 ?? null,
427
- addressLine2: address?.addressLine2 ?? null,
428
- addressCity: address?.city ?? null,
429
- addressRegion: address?.region ?? null,
430
- addressPostalCode: address?.postalCode ?? null,
431
- addressCountry: address?.country ?? null,
432
- };
433
- }
434
-
435
- /** The scalar contact fields, each its own column of the same name. */
436
- const SCALAR_CONTACT_FIELDS = [
437
- "givenName",
438
- "familyName",
439
- "telephone",
440
- "companyName",
441
- "jobTitle",
442
- ] as const;
443
-
444
- /** Every supplied contact field as insert columns. */
445
- function contactInsertColumns(
446
- contact: Partial<PersonContact>,
447
- ): Record<string, string | null> {
448
- const columns: Record<string, string | null> = {};
449
- for (const field of SCALAR_CONTACT_FIELDS) {
450
- const value = contact[field];
451
- if (value !== undefined && value !== null) columns[field] = value;
452
- }
453
- if (contact.address) Object.assign(columns, addressColumns(contact.address));
454
- return columns;
455
- }
456
-
457
- /** `"Ada" + "Lovelace"` → `"Ada Lovelace"`, `"Ada"` alone → `"Ada"`, neither → `null`. */
458
- function joinedName(contact: Partial<PersonContact>): string | null {
459
- return normalizePersonName(
460
- [contact.givenName, contact.familyName].filter((part) => part).join(" "),
461
- );
462
- }
463
-
464
- function usableCustomerId(raw: string | null | undefined): string | null {
465
- return typeof raw === "string" &&
466
- raw.length > 0 &&
467
- raw.length <= MAX_CUSTOMER_ID_LENGTH
468
- ? raw
469
- : null;
470
- }
471
-
472
- /** A Stripe customer id: `cus_` and letters or digits only, no separator. */
473
- const STRIPE_CUSTOMER_ID = /^cus_[A-Za-z0-9]+$/;
474
-
475
- /**
476
- * The Stripe Dashboard link to one customer (#9349 DD5). Pure: the caller
477
- * reads the mode. `null` unless `customerId` is a well-formed `cus_` id of
478
- * at most 255 characters, so a stored value can never add a path segment,
479
- * a query or a fragment to the fixed Dashboard host.
480
- */
481
- export function stripeCustomerDashboardUrl(
482
- customerId: string,
483
- mode: "test" | "live",
484
- ): string | null {
485
- if (
486
- customerId.length > MAX_CUSTOMER_ID_LENGTH ||
487
- !STRIPE_CUSTOMER_ID.test(customerId)
488
- ) {
489
- return null;
490
- }
491
- const segment = mode === "test" ? "test/customers" : "customers";
492
- return `https://dashboard.stripe.com/${segment}/${customerId}`;
493
- }
494
-
495
- // ─── findOrCreatePerson ───────────────────────────────────────────────────────
496
-
497
- /** The one delegate this file writes through — the shared client or a transaction's. */
498
- export type PersonDb = Pick<Prisma.TransactionClient, "person">;
499
-
500
- export type FindOrCreatePersonResult =
501
- | {
502
- ok: true;
503
- value: {
504
- personId: Id<"person">;
505
- created: boolean;
506
- billing: "written" | "kept" | "unchanged";
507
- };
508
- }
509
- | { ok: false; error: { kind: "invalid_email" | "internal_error" } };
510
-
511
- type MatchedPerson = {
512
- id: string;
513
- name: string | null;
514
- userId: string | null;
515
- billingCustomerId: string | null;
516
- // #8435 D4.4 — the blank-sentinels the contact fill reads. A value reading
517
- // `undefined` (a test double that does not carry the key) is "not blank",
518
- // and an undefined `contactConfirmedAt` is "confirmed": no fill runs.
519
- givenName?: string | null;
520
- familyName?: string | null;
521
- telephone?: string | null;
522
- addressLine1?: string | null;
523
- companyName?: string | null;
524
- jobTitle?: string | null;
525
- contactConfirmedAt?: Date | null;
526
- };
527
-
528
- const MATCH_SELECT = {
529
- id: true,
530
- name: true,
531
- userId: true,
532
- billingCustomerId: true,
533
- givenName: true,
534
- familyName: true,
535
- telephone: true,
536
- addressLine1: true,
537
- companyName: true,
538
- jobTitle: true,
539
- contactConfirmedAt: true,
540
- } as const;
541
-
542
- /**
543
- * Step 3's writes on an email match: the customer id only when the Person
544
- * has none and is linked to no user, the name only when it has none — each
545
- * one guarded `updateMany`, so a concurrent writer's value, or a stamp that
546
- * lands between the read and the write, is never overwritten or bypassed.
547
- */
548
- async function enrichMatched(
549
- db: PersonDb,
550
- workspaceId: string,
551
- person: MatchedPerson,
552
- name: string | null,
553
- customerId: string | null,
554
- ): Promise<"written" | "kept" | "unchanged"> {
555
- let billing: "written" | "kept" | "unchanged" = "unchanged";
556
- if (customerId !== null) {
557
- if (person.billingCustomerId !== null || person.userId !== null) {
558
- billing = "kept";
559
- } else {
560
- const written = await db.person.updateMany({
561
- where: { id: person.id, workspaceId, userId: null, billingCustomerId: null },
562
- data: { billingProvider: "stripe", billingCustomerId: customerId },
563
- });
564
- billing = written.count === 1 ? "written" : "kept";
565
- }
566
- }
567
- if (name !== null && person.name === null) {
568
- await db.person.updateMany({
569
- where: { id: person.id, workspaceId, name: null },
570
- data: { name },
571
- });
572
- }
573
- return billing;
574
- }
575
-
576
- /**
577
- * The contact fill on an email match (#8435 D4.3): for each supplied field
578
- * whose stored value is `null`, one guarded `updateMany`; the address is one
579
- * statement guarded on `addressLine1: null` writing all six. It runs ONLY
580
- * while the Person is uncurated — linked to no user and never saved by hand
581
- * (`contactConfirmedAt` null) — and every guard repeats that condition, so a
582
- * link or a save that lands between the read and the write still wins
583
- * (SEC-8435-A). A stored value, whoever wrote it, is never overwritten.
584
- * Best-effort: a failed write is swallowed, never refusing the capture.
585
- */
586
- async function fillBlankContact(
587
- db: PersonDb,
588
- workspaceId: string,
589
- person: MatchedPerson,
590
- contact: Partial<PersonContact>,
591
- plainName: string | null,
592
- ): Promise<void> {
593
- if (person.userId !== null || person.contactConfirmedAt !== null) return;
594
- const uncurated = { id: person.id, workspaceId, userId: null, contactConfirmedAt: null };
595
- try {
596
- for (const field of SCALAR_CONTACT_FIELDS) {
597
- const value = contact[field];
598
- if (value !== undefined && value !== null && person[field] === null) {
599
- await db.person.updateMany({
600
- where: { ...uncurated, [field]: null } as Prisma.PersonWhereInput,
601
- data: { [field]: value } as Prisma.PersonUpdateManyMutationInput,
602
- });
603
- }
604
- }
605
- if (contact.address && person.addressLine1 === null) {
606
- await db.person.updateMany({
607
- where: { ...uncurated, addressLine1: null },
608
- data: addressColumns(contact.address),
609
- });
610
- }
611
- // The display name from given + family, only when nothing named it.
612
- if (plainName === null && person.name === null) {
613
- const derived = joinedName(contact);
614
- if (derived !== null) {
615
- await db.person.updateMany({ where: { ...uncurated, name: null }, data: { name: derived } });
616
- }
617
- }
618
- } catch {
619
- // A contact fill never changes the capture's outcome.
620
- }
621
- }
622
-
623
- /**
624
- * Find this workspace's Person for an email, or create it.
625
- *
626
- * @param workspaceId the SERVER-RESOLVED workspace id — never a request value.
627
- * @param input.email the raw address; normalized here.
628
- * @param input.name an optional display name, from anonymous text.
629
- * @param input.billingCustomerId an optional provider customer id.
630
- * @param input.contact optional, already-normalized contact fields
631
- * (`normalizeCapturedContact`): a created Person carries them all; a matched
632
- * uncurated Person gains only the ones it has blank (#8435 D4).
633
- * @param db the shared client by default; the appointment path passes its
634
- * transaction client so the step runs on the lock's own connection.
635
- */
636
- export async function findOrCreatePerson(
637
- workspaceId: string,
638
- input: {
639
- email: string;
640
- name?: string | null;
641
- billingCustomerId?: string | null;
642
- contact?: Partial<PersonContact>;
643
- },
644
- db: PersonDb = prisma,
645
- ): Promise<FindOrCreatePersonResult> {
646
- const email =
647
- typeof input.email === "string" ? normalizePersonEmail(input.email) : "";
648
- if (!isStorablePersonEmail(email)) {
649
- return { ok: false, error: { kind: "invalid_email" } };
650
- }
651
- const name = normalizePersonName(input.name);
652
- const customerId = usableCustomerId(input.billingCustomerId);
653
- const contact = input.contact ?? {};
654
- const hasContact = Object.keys(contact).length > 0;
655
-
656
- try {
657
- // Step 2 — a customer id names its Person; nothing is written.
658
- if (customerId !== null) {
659
- const byCustomer = await db.person.findFirst({
660
- where: { workspaceId, billingCustomerId: customerId },
661
- orderBy: [{ createdAt: "asc" }, { id: "asc" }],
662
- select: { id: true },
663
- });
664
- if (byCustomer) {
665
- return {
666
- ok: true,
667
- value: {
668
- personId: byCustomer.id as Id<"person">,
669
- created: false,
670
- billing: "unchanged",
671
- },
672
- };
673
- }
674
- }
675
-
676
- // Step 3 — the email's Person.
677
- const byEmail = await db.person.findFirst({
678
- where: { workspaceId, email },
679
- select: MATCH_SELECT,
680
- });
681
- if (byEmail) {
682
- const billing = await enrichMatched(db, workspaceId, byEmail, name, customerId);
683
- if (hasContact) await fillBlankContact(db, workspaceId, byEmail, contact, name);
684
- return {
685
- ok: true,
686
- value: { personId: byEmail.id as Id<"person">, created: false, billing },
687
- };
688
- }
689
-
690
- // Step 4 — insert, absorbing a concurrent duplicate in the database.
691
- const inserted = await db.person.createMany({
692
- data: [
693
- {
694
- workspaceId,
695
- email,
696
- // A supplied name wins; else given + family joined (#8435 D4.2).
697
- name: name ?? joinedName(contact),
698
- ...(customerId !== null && {
699
- billingProvider: "stripe",
700
- billingCustomerId: customerId,
701
- }),
702
- ...(hasContact && contactInsertColumns(contact)),
703
- },
704
- ],
705
- skipDuplicates: true,
706
- });
707
- const winner = await db.person.findFirst({
708
- where: { workspaceId, email },
709
- select: MATCH_SELECT,
710
- });
711
- if (!winner) return { ok: false, error: { kind: "internal_error" } };
712
- if (inserted.count === 1) {
713
- return {
714
- ok: true,
715
- value: {
716
- personId: winner.id as Id<"person">,
717
- created: true,
718
- billing: customerId !== null ? "written" : "unchanged",
719
- },
720
- };
721
- }
722
- // A concurrent create won the race: treat its row as an email match.
723
- const billing = await enrichMatched(db, workspaceId, winner, name, customerId);
724
- if (hasContact) await fillBlankContact(db, workspaceId, winner, contact, name);
725
- return {
726
- ok: true,
727
- value: { personId: winner.id as Id<"person">, created: false, billing },
728
- };
729
- } catch {
730
- return { ok: false, error: { kind: "internal_error" } };
731
- }
732
- }
733
-
734
- // ─── stampPersonUser (D5) ─────────────────────────────────────────────────────
735
-
736
- export type StampPersonUserOutcome =
737
- | "stamped"
738
- | "already_linked"
739
- | "user_unverified"
740
- | "email_mismatch"
741
- | "linked_to_other_user"
742
- | "user_on_other_person"
743
- | "not_found"
744
- | "internal_error";
745
-
746
- function isUniqueViolation(err: unknown): boolean {
747
- return (
748
- typeof err === "object" &&
749
- err !== null &&
750
- (err as { code?: unknown }).code === "P2002"
751
- );
752
- }
753
-
754
- /**
755
- * Link a Person to a User — only when the User's VERIFIED email normalizes
756
- * to the Person's (`verified-email.ts`'s rule), and never over an existing
757
- * link. Creating a Person never calls this: no verified email is needed to
758
- * exist. Runs outside any transaction, so catching a P2002 is safe here.
759
- */
760
- export async function stampPersonUser(
761
- workspaceId: string,
762
- personId: string,
763
- userId: string,
764
- ): Promise<StampPersonUserOutcome> {
765
- try {
766
- const person = await prisma.person.findFirst({
767
- where: { id: personId, workspaceId },
768
- select: { email: true, userId: true },
769
- });
770
- if (!person) return "not_found";
771
- if (person.userId === userId) return "already_linked";
772
- if (person.userId !== null) return "linked_to_other_user";
773
-
774
- const verified = await findVerifiedEmail(userId);
775
- if (!verified.ok) return "user_unverified";
776
- if (normalizePersonEmail(verified.value.email) !== person.email) {
777
- return "email_mismatch";
778
- }
779
-
780
- let count: number;
781
- try {
782
- ({ count } = await prisma.person.updateMany({
783
- where: { id: personId, workspaceId, userId: null },
784
- data: { userId },
785
- }));
786
- } catch (err) {
787
- if (isUniqueViolation(err)) return "user_on_other_person";
788
- throw err;
789
- }
790
- if (count === 1) return "stamped";
791
-
792
- const reread = await prisma.person.findFirst({
793
- where: { id: personId, workspaceId },
794
- select: { userId: true },
795
- });
796
- if (!reread) return "not_found";
797
- return reread.userId === userId ? "already_linked" : "linked_to_other_user";
798
- } catch {
799
- return "internal_error";
800
- }
801
- }
802
-
803
- // ─── recordCheckoutPerson (D6) ────────────────────────────────────────────────
804
-
805
- export type CheckoutPersonOutcome =
806
- | "person_written"
807
- | "person_kept"
808
- | "person_unchanged"
809
- | "person_skipped"
810
- | "person_failed";
811
-
812
- /** `recordCheckoutPerson`'s outcome plus the Person it found or created (#8765 D14). */
813
- export type CheckoutPersonResult = {
814
- outcome: CheckoutPersonOutcome;
815
- /** The checkout's Person, or `null` when the step skipped or failed. */
816
- person: { workspaceId: string; personId: string } | null;
817
- };
818
-
819
- /**
820
- * The hub webhook's Person step for a paid `checkout.session.completed` of
821
- * its OWN site carrying `metadata.flowCheckoutId`.
822
- *
823
- * Reads the checkout row THIS session completed (`stripeSessionId` must be
824
- * this session's id and the row `completed`), so a session that merely names
825
- * another checkout's id matches nothing (SEC-852-H); then that row's lead,
826
- * scoped by the row's workspace. The customer id's one source is
827
- * `session.customer`, and it is passed only for a LIVE-mode event: a
828
- * test-mode checkout typed with someone else's email cannot pin a customer
829
- * id on their Person (SEC-852-A). Never throws.
830
- */
831
- export async function recordCheckoutPerson(input: {
832
- flowCheckoutId: string;
833
- stripeSessionId: string | null | undefined;
834
- customer: unknown;
835
- livemode: boolean;
836
- }): Promise<CheckoutPersonOutcome> {
837
- return (await resolveCheckoutPerson(input)).outcome;
838
- }
839
-
840
- /**
841
- * `recordCheckoutPerson`, also answering the Person it found or created —
842
- * the subject of the checkout's terms grant (#8765 D14), which reads the
843
- * flow checkout's Person from here and never creates one itself.
844
- */
845
- export async function resolveCheckoutPerson(input: {
846
- flowCheckoutId: string;
847
- stripeSessionId: string | null | undefined;
848
- customer: unknown;
849
- livemode: boolean;
850
- }): Promise<CheckoutPersonResult> {
851
- const skipped = { outcome: "person_skipped", person: null } as const;
852
- if (typeof input.stripeSessionId !== "string" || input.stripeSessionId === "") {
853
- return skipped;
854
- }
855
- try {
856
- const checkout = await prisma.flowCheckout.findFirst({
857
- where: {
858
- id: input.flowCheckoutId,
859
- stripeSessionId: input.stripeSessionId,
860
- status: "completed",
861
- },
862
- select: { leadId: true, workspaceId: true },
863
- });
864
- if (!checkout) return skipped;
865
-
866
- const lead = await prisma.lead.findFirst({
867
- where: { id: checkout.leadId, workspaceId: checkout.workspaceId },
868
- select: { email: true },
869
- });
870
- if (!lead) return skipped;
871
-
872
- const customerId =
873
- input.livemode === true && typeof input.customer === "string"
874
- ? input.customer
875
- : undefined;
876
- const result = await findOrCreatePerson(checkout.workspaceId, {
877
- email: lead.email,
878
- ...(customerId !== undefined && { billingCustomerId: customerId }),
879
- });
880
- if (!result.ok) {
881
- return result.error.kind === "invalid_email"
882
- ? skipped
883
- : { outcome: "person_failed", person: null };
884
- }
885
- const person = {
886
- workspaceId: checkout.workspaceId,
887
- personId: result.value.personId,
888
- };
889
- switch (result.value.billing) {
890
- case "written":
891
- return { outcome: "person_written", person };
892
- case "kept":
893
- return { outcome: "person_kept", person };
894
- default:
895
- return { outcome: "person_unchanged", person };
896
- }
897
- } catch {
898
- return { outcome: "person_failed", person: null };
899
- }
900
- }
901
-
902
- // ─── The member area (#7877 D8) ───────────────────────────────────────────────
903
-
904
- /**
905
- * The Person a member read answers for: this workspace's Person linked to
906
- * the caller, else the one whose email is the caller's VERIFIED email —
907
- * linked now through `stampPersonUser` (its rule, unchanged) and the answer
908
- * only on `stamped` or `already_linked`. `null` is "not a client here";
909
- * every other stamp outcome, and a store error, is `null` too, logged by its
910
- * kind only. Never throws.
911
- *
912
- * A fresh `stamped` link — and only that — releases the Person's held
913
- * checkout plan (#8793 D2, `releaseHeldCheckoutPlan`): the caller has just
914
- * proven they own the email the purchases were made under. A Person linked
915
- * earlier, a refused stamp and every other caller of `stampPersonUser` (the
916
- * Inbox accept above all) release nothing. The release never changes the
917
- * answer; an `ambiguous` or `internal_error` release is logged by its kind
918
- * only, as `{ event: "checkout-plan-release", outcome }`.
919
- *
920
- * @param workspaceId the SERVER-RESOLVED workspace (the calling spoke's).
921
- * @param userId the authenticated caller.
922
- * @param verifiedEmail the caller's verified address, read by the caller.
923
- */
924
- export async function resolveMemberPersonId(
925
- workspaceId: string,
926
- userId: string,
927
- verifiedEmail: string,
928
- log?: { warn: (obj: object, msg: string) => void },
929
- ): Promise<string | null> {
930
- let byEmail: { id: string } | null;
931
- try {
932
- const linked = await prisma.person.findFirst({
933
- where: { workspaceId, userId },
934
- select: { id: true },
935
- });
936
- if (linked) return linked.id;
937
-
938
- byEmail = await prisma.person.findFirst({
939
- where: { workspaceId, email: normalizePersonEmail(verifiedEmail) },
940
- select: { id: true },
941
- });
942
- } catch {
943
- // A store error is "not a client here" for this read: never thrown.
944
- log?.warn({ event: "member-person-stamp", outcome: "internal_error" }, "[person] member read failed");
945
- return null;
946
- }
947
- if (!byEmail) return null;
948
-
949
- const outcome = await stampPersonUser(workspaceId, byEmail.id, userId);
950
- if (outcome === "stamped") {
951
- const release = await releaseHeldCheckoutPlan(
952
- workspaceId,
953
- byEmail.id,
954
- userId,
955
- normalizePersonEmail(verifiedEmail),
956
- );
957
- if (release === "ambiguous" || release === "internal_error") {
958
- log?.warn({ event: "checkout-plan-release", outcome: release }, "[person] held checkout plan not released");
959
- }
960
- return byEmail.id;
961
- }
962
- if (outcome === "already_linked") return byEmail.id;
963
- log?.warn({ event: "member-person-stamp", outcome }, "[person] member link refused");
964
- return null;
965
- }
966
-
967
- /**
968
- * The customer id the hub vouches for to a spoke's buy route (#7961, spec
969
- * 7894 D13): the `billingCustomerId` of the Person `resolveMemberPersonId`
970
- * answers for (workspace, caller), read only while that Person's `userId` is
971
- * the caller — never a Person matched by email alone (SEC-7894-6). `null` is
972
- * "no customer to reuse"; a store error is `null` too, logged by its kind
973
- * only. Never throws.
974
- *
975
- * @param workspaceId the SERVER-RESOLVED workspace (the calling spoke's).
976
- * @param userId the authenticated caller.
977
- * @param verifiedEmail the caller's verified address, read by the caller.
978
- */
979
- export async function resolveCallerBillingCustomerId(
980
- workspaceId: string,
981
- userId: string,
982
- verifiedEmail: string,
983
- log?: { warn: (obj: object, msg: string) => void },
984
- ): Promise<string | null> {
985
- const personId = await resolveMemberPersonId(workspaceId, userId, verifiedEmail, log);
986
- if (personId === null) return null;
987
- try {
988
- const person = await prisma.person.findFirst({
989
- where: { id: personId, workspaceId, userId },
990
- select: { billingCustomerId: true },
991
- });
992
- return person?.billingCustomerId ?? null;
993
- } catch {
994
- log?.warn({ event: "buyer-customer-read", outcome: "internal_error" }, "[person] buyer read failed");
995
- return null;
996
- }
997
- }
998
-
999
- export type SetPlanOfferingOutcome = "set" | "not_found" | "internal_error";
1000
-
1001
- /**
1002
- * Set — or, with `null`, clear — a Person's plan: the ONE writer of
1003
- * `Person.planOffering`, called by the Admin's plan route and the held
1004
- * checkout plan's release (#8793). One `updateMany` scoped by the
1005
- * server-resolved workspace; no row → `not_found`. Never throws.
1006
- *
1007
- * With a `guard` (#8793 D5, the release's own write) the statement also
1008
- * requires a blank plan and the Person still linked to `guard.linkedUserId`,
1009
- * so a plan already set — or one the operator PUTs at the same moment — wins
1010
- * and the answer is `not_found`. Without one it is the operator's overwrite.
1011
- */
1012
- export async function setPersonPlanOffering(
1013
- workspaceId: string,
1014
- personId: string,
1015
- planOffering: string | null,
1016
- guard?: { linkedUserId: string },
1017
- ): Promise<SetPlanOfferingOutcome> {
1018
- try {
1019
- const { count } = await prisma.person.updateMany({
1020
- where: {
1021
- id: personId,
1022
- workspaceId,
1023
- ...(guard !== undefined && { planOffering: null, userId: guard.linkedUserId }),
1024
- },
1025
- data: { planOffering },
1026
- });
1027
- return count === 0 ? "not_found" : "set";
1028
- } catch {
1029
- return "internal_error";
1030
- }
1031
- }
1032
-
1033
- // ─── The held checkout plan (#8793 D3, D4) ───────────────────────────────────
1034
-
1035
- /**
1036
- * A spoke checkout's plan key: the offering minus a leading `<site>_` (when
1037
- * the site is a string), answered only when `planOfferingSchema` — the key
1038
- * the operator's PUT accepts — admits it; else `null`. Shared by the
1039
- * checkout's hold (`lead-routing.ts`) and the release below.
1040
- */
1041
- export function checkoutPlanKey(offering: unknown, site: unknown): string | null {
1042
- if (typeof offering !== "string") return null;
1043
- const prefix = typeof site === "string" ? `${site}_` : null;
1044
- const key = prefix !== null && offering.startsWith(prefix) ? offering.slice(prefix.length) : offering;
1045
- return planOfferingSchema.safeParse(key).success ? key : null;
1046
- }
1047
-
1048
- export type HeldPlanOutcome = "set" | "none" | "ambiguous" | "kept" | "internal_error";
1049
-
1050
- /** The most held purchases a release weighs; one more is `ambiguous`. */
1051
- const HELD_PURCHASES_READ = 50;
1052
-
1053
- /**
1054
- * Release a Person's held checkout plan (#8793 D3) — called only by
1055
- * `resolveMemberPersonId` on a fresh `stamped` link, so `verifiedEmail` (the
1056
- * caller's, normalized) is the Person's own. The Person's marked spoke
1057
- * checkout leads (`Lead.spokeCheckout`, newest 51) under that email are taken
1058
- * as a SET: one distinct plan key is written to a blank plan through the
1059
- * guarded `setPersonPlanOffering` (`set`, or `kept` when the guard matched
1060
- * nothing — a plan was already there, or the Person is no longer linked to
1061
- * `userId`); no key is `none`; more than one key, or more than 50 leads, is
1062
- * `ambiguous` and writes nothing — the operator sets the plan. So a
1063
- * stranger's purchase under the owner's email can deny the automatic plan,
1064
- * never choose it (SEC-8793-A). A store error is `internal_error`. Never
1065
- * throws, and logs nothing: the caller logs the kind.
1066
- */
1067
- export async function releaseHeldCheckoutPlan(
1068
- workspaceId: string,
1069
- personId: string,
1070
- userId: string,
1071
- verifiedEmail: string,
1072
- ): Promise<HeldPlanOutcome> {
1073
- try {
1074
- const rows = await prisma.lead.findMany({
1075
- where: { workspaceId, personId, spokeCheckout: true },
1076
- orderBy: [{ createdAt: "desc" }, { id: "desc" }],
1077
- take: HELD_PURCHASES_READ + 1,
1078
- select: { email: true, payload: true },
1079
- });
1080
- if (rows.length > HELD_PURCHASES_READ) return "ambiguous";
1081
-
1082
- const keys = new Set<string>();
1083
- for (const row of rows) {
1084
- // The purchase read's own rule: a purchase is its Person's only under
1085
- // the Person's own email.
1086
- if (normalizePersonEmail(row.email) !== verifiedEmail) continue;
1087
- const payload = (row.payload ?? {}) as Record<string, unknown>;
1088
- const key = checkoutPlanKey(payload["offering"], payload["site"]);
1089
- if (key !== null) keys.add(key);
1090
- }
1091
- if (keys.size > 1) return "ambiguous";
1092
- const [key] = keys;
1093
- if (key === undefined) return "none";
1094
-
1095
- const written = await setPersonPlanOffering(workspaceId, personId, key, { linkedUserId: userId });
1096
- if (written === "set") return "set";
1097
- return written === "not_found" ? "kept" : "internal_error";
1098
- } catch {
1099
- return "internal_error";
1100
- }
1101
- }
1102
-
1103
- // ─── The two human edits of the contact (#8435 D9, D11) ───────────────────────
1104
-
1105
- export type SavedContact = {
1106
- contact: PersonContact;
1107
- confirmed: { by: "operator" | "client"; at: string } | null;
1108
- };
1109
-
1110
- export type SaveContactOutcome =
1111
- | { kind: "saved"; value: SavedContact }
1112
- | { kind: "not_found" | "internal_error" };
1113
-
1114
- /** The columns a patch writes: only the keys present; the address key writes (or clears) all six. */
1115
- function patchColumns(patch: PersonContactPatch): Record<string, string | null> {
1116
- const columns: Record<string, string | null> = {};
1117
- for (const field of SCALAR_CONTACT_FIELDS) {
1118
- const value = patch[field];
1119
- if (value !== undefined) columns[field] = value;
1120
- }
1121
- if (patch.address !== undefined) Object.assign(columns, addressColumns(patch.address));
1122
- return columns;
1123
- }
1124
-
1125
- async function readSavedContact(workspaceId: string, personId: string): Promise<SaveContactOutcome> {
1126
- const row = await prisma.person.findFirst({
1127
- where: { id: personId, workspaceId },
1128
- select: PERSON_CONTACT_SELECT,
1129
- });
1130
- if (!row) return { kind: "not_found" };
1131
- return { kind: "saved", value: { contact: personContactFromRow(row), confirmed: contactConfirmedFromRow(row) } };
1132
- }
1133
-
1134
- /**
1135
- * The operator's save (#8435 D9): one `updateMany` scoped by the
1136
- * server-resolved workspace, writing only the keys present plus the
1137
- * confirmation stamp (`operator`). No row → `not_found`. Never throws.
1138
- */
1139
- export async function updatePersonContact(
1140
- workspaceId: string,
1141
- personId: string,
1142
- patch: PersonContactPatch,
1143
- ): Promise<SaveContactOutcome> {
1144
- try {
1145
- const { count } = await prisma.person.updateMany({
1146
- where: { id: personId, workspaceId },
1147
- data: {
1148
- ...patchColumns(patch),
1149
- contactConfirmedAt: new Date(),
1150
- contactConfirmedBy: "operator",
1151
- } as Prisma.PersonUpdateManyMutationInput,
1152
- });
1153
- if (count === 0) return { kind: "not_found" };
1154
- return await readSavedContact(workspaceId, personId);
1155
- } catch {
1156
- return { kind: "internal_error" };
1157
- }
1158
- }
1159
-
1160
- /**
1161
- * The Person a client's first contact save belongs to (#8435 D11): the
1162
- * caller's VERIFIED email's Person in this workspace, found or created, and
1163
- * linked to the caller through `stampPersonUser`'s own rule. Anything but a
1164
- * stamped or already-linked outcome is `unavailable` — nothing is written for
1165
- * the contact. Never throws.
1166
- */
1167
- export async function ensureMemberPerson(
1168
- workspaceId: string,
1169
- userId: string,
1170
- ): Promise<{ ok: true; personId: string } | { ok: false }> {
1171
- try {
1172
- const verified = await findVerifiedEmail(userId);
1173
- if (!verified.ok) return { ok: false };
1174
- const person = await findOrCreatePerson(workspaceId, { email: verified.value.email });
1175
- if (!person.ok) return { ok: false };
1176
- const outcome = await stampPersonUser(workspaceId, person.value.personId, userId);
1177
- if (outcome !== "stamped" && outcome !== "already_linked") return { ok: false };
1178
- return { ok: true, personId: person.value.personId };
1179
- } catch {
1180
- return { ok: false };
1181
- }
1182
- }
1183
-
1184
- /**
1185
- * The client's save (#8435 D11): ONE `updateMany` guarded on `{ id,
1186
- * workspaceId, userId }` — only the caller's own linked Person is ever
1187
- * written — replacing all six contact keys with the body and stamping
1188
- * `client`, so no value the client never saw survives it (SEC-8435-B). No
1189
- * row → `not_found`. Never throws.
1190
- */
1191
- export async function setOwnPersonContact(
1192
- workspaceId: string,
1193
- personId: string,
1194
- userId: string,
1195
- contact: PersonContactPatch,
1196
- ): Promise<SaveContactOutcome> {
1197
- try {
1198
- const { count } = await prisma.person.updateMany({
1199
- where: { id: personId, workspaceId, userId },
1200
- data: {
1201
- ...patchColumns(contact),
1202
- contactConfirmedAt: new Date(),
1203
- contactConfirmedBy: "client",
1204
- } as Prisma.PersonUpdateManyMutationInput,
1205
- });
1206
- if (count === 0) return { kind: "not_found" };
1207
- return await readSavedContact(workspaceId, personId);
1208
- } catch {
1209
- return { kind: "internal_error" };
1210
- }
1211
- }