create-ailk 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (638) hide show
  1. package/README.md +53 -22
  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 +2 -1
  14. package/dist/lib/fetch-template-snapshot.js +8 -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 +36 -15
  19. package/dist/lib/module-tier.d.ts +2 -8
  20. package/dist/lib/module-tier.js +13 -12
  21. package/dist/module-architecture.d.ts +20 -3
  22. package/dist/module-architecture.js +129 -552
  23. package/dist/paid-sections.d.ts +25 -1
  24. package/dist/paid-sections.js +88 -3
  25. package/dist/starter-grid.d.ts +6 -6
  26. package/dist/starter-grid.js +12 -5
  27. package/dist/surfaces.js +39 -2
  28. package/dist/template-record.d.ts +21 -3
  29. package/dist/template-record.js +38 -7
  30. package/dist/topology.d.ts +16 -3
  31. package/dist/topology.js +54 -23
  32. package/dist/update.d.ts +10 -1
  33. package/dist/update.js +715 -21
  34. package/package.json +3 -3
  35. package/release-scope.json +2 -2
  36. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +8 -2
  37. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +2 -2
  38. package/site-templates/marketing-site/ailk-template.json +7 -1
  39. package/site-templates/marketing-site/provenance.json +7 -1
  40. package/site-templates/saas-product/ailk-template.json +2 -1
  41. package/site-templates/saas-product/provenance.json +1 -0
  42. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  43. package/templates/.claude/hooks/template-guard.sh +137 -0
  44. package/templates/.claude/rules/extending.md +106 -0
  45. package/templates/.claude/rules/loop-contract.md +2 -2
  46. package/templates/.claude/settings.json +11 -0
  47. package/templates/.env.example +20 -5
  48. package/templates/.github/workflows/ci.yml +7 -4
  49. package/templates/CLAUDE.md +11 -1
  50. package/templates/CONVENTIONS.md +8 -10
  51. package/templates/README.md +14 -13
  52. package/templates/apps/api/.env.example +23 -2
  53. package/templates/apps/api/CLAUDE.md +119 -83
  54. package/templates/apps/api/jest.config.cjs +15 -5
  55. package/templates/apps/api/package.json +16 -20
  56. package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +2 -2
  57. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +4 -4
  58. package/templates/apps/api/src/index.ts +1 -1
  59. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -3
  61. package/templates/apps/api/src/modules/commerce.ts +10 -0
  62. package/templates/apps/api/src/modules/content-write.ts +10 -0
  63. package/templates/apps/api/src/modules/multi-tenant.ts +10 -0
  64. package/templates/apps/api/src/modules/scheduling.ts +10 -0
  65. package/templates/apps/api/src/modules/signal-collectors.ts +10 -0
  66. package/templates/apps/api/src/modules/workspace.ts +10 -0
  67. package/templates/apps/api/src/server.ts +117 -1882
  68. package/templates/apps/api/vercel.json +2 -2
  69. package/templates/apps/mcp/CLAUDE.md +90 -26
  70. package/templates/apps/mcp/__tests__/app-entry.test.ts +4 -254
  71. package/templates/apps/mcp/__tests__/boot-file.test.ts +92 -0
  72. package/templates/apps/mcp/jest.config.cjs +6 -1
  73. package/templates/apps/mcp/package.json +5 -13
  74. package/templates/apps/mcp/src/app.ts +8 -7
  75. package/templates/apps/mcp/src/index.ts +6 -34
  76. package/templates/apps/mcp/src/lib/paid-tools.generated.ts +10 -0
  77. package/templates/apps/mcp/src/lib/topology.generated.ts +2 -2
  78. package/templates/apps/mcp/src/site.ts +12 -0
  79. package/templates/apps/web/.env.example +1 -1
  80. package/templates/apps/web/CLAUDE.md +31 -3
  81. package/templates/apps/web/app/.well-known/mcp.json/route.ts +9 -3
  82. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +43 -4
  83. package/templates/apps/web/app/[locale]/(auth)/legal-footnote.tsx +35 -0
  84. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +117 -8
  85. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +14 -37
  86. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +6 -6
  87. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +20 -35
  88. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +108 -0
  89. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +111 -5
  90. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +78 -16
  91. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +132 -12
  92. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +53 -1
  93. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +38 -0
  94. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +4 -2
  95. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +47 -24
  96. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +23 -4
  97. package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +1 -1
  98. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +22 -1
  99. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +7 -3
  100. package/templates/apps/web/app/[locale]/layout.tsx +33 -13
  101. package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +128 -0
  102. package/templates/apps/web/app/[locale]/meet/confirmed/__tests__/BookingHandOff.test.tsx +126 -0
  103. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +43 -0
  104. package/templates/apps/web/app/[locale]/pricing/page.tsx +7 -3
  105. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +222 -0
  106. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +10 -3
  107. package/templates/apps/web/app/api/revalidate/route.ts +3 -3
  108. package/templates/apps/web/auth.ts +10 -6
  109. package/templates/apps/web/i18n/request.ts +49 -1
  110. package/templates/apps/web/jest.config.cjs +1 -0
  111. package/templates/apps/web/lib/__tests__/account-config.test.ts +7 -0
  112. package/templates/apps/web/lib/__tests__/auth-email.test.ts +276 -0
  113. package/templates/apps/web/lib/auth-email.ts +103 -21
  114. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +72 -23
  115. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +94 -70
  116. package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
  117. package/templates/apps/web/lib/relay.ts +2 -2
  118. package/templates/apps/web/lib/release-scope.ts +47 -0
  119. package/templates/apps/web/lib/server-api.ts +1 -1
  120. package/templates/apps/web/lib/sign-up-agreements.ts +291 -28
  121. package/templates/apps/web/lib/site-contributions.ts +18 -0
  122. package/templates/apps/web/messages/en.json +186 -20
  123. package/templates/apps/web/messages/site/en.json +1 -0
  124. package/templates/apps/web/src/lib/commerce-claim.ts +1 -1
  125. package/templates/customizations.yaml +37 -0
  126. package/templates/database/prisma.config.ts +10 -14
  127. package/templates/database/seed-test.ts +2 -2
  128. package/templates/database/seed.ts +5 -5
  129. package/templates/package.json +12 -6
  130. package/templates/pnpm-workspace.yaml +0 -1
  131. package/templates/project.yaml +16 -8
  132. package/templates/tools/jest/reporters.cjs +65 -0
  133. package/templates/tools/lint-architecture.ts +553 -7
  134. package/templates/tsconfig.json +1 -2
  135. package/versions.json +49 -36
  136. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +0 -409
  137. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +0 -102
  138. package/templates/apps/api/src/__tests__/factories/content-fixture.ts +0 -138
  139. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +0 -441
  140. package/templates/apps/api/src/__tests__/factories/index.ts +0 -70
  141. package/templates/apps/api/src/__tests__/factories/lead-fixture.ts +0 -82
  142. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +0 -135
  143. package/templates/apps/api/src/__tests__/services/enrichment-shim.test.ts +0 -126
  144. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +0 -132
  145. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +0 -362
  146. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +0 -184
  147. package/templates/apps/api/src/bin/abandon-sweep.ts +0 -42
  148. package/templates/apps/api/src/bin/rate-limit-probe.ts +0 -53
  149. package/templates/apps/api/src/bin/spokes.ts +0 -198
  150. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +0 -45
  151. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +0 -196
  152. package/templates/apps/api/src/config/__tests__/index.test.ts +0 -233
  153. package/templates/apps/api/src/config/__tests__/modules.test.ts +0 -76
  154. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +0 -185
  155. package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +0 -238
  156. package/templates/apps/api/src/config/index.ts +0 -596
  157. package/templates/apps/api/src/config/modules.ts +0 -83
  158. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +0 -581
  159. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +0 -143
  160. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +0 -237
  161. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +0 -353
  162. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +0 -201
  163. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +0 -224
  164. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +0 -120
  165. package/templates/apps/api/src/lib/__tests__/errors.test.ts +0 -94
  166. package/templates/apps/api/src/lib/__tests__/file-fallback.test.ts +0 -144
  167. package/templates/apps/api/src/lib/__tests__/fixtures/HomePage/home.mdx +0 -8
  168. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +0 -149
  169. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +0 -116
  170. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +0 -209
  171. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +0 -257
  172. package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +0 -96
  173. package/templates/apps/api/src/lib/__tests__/outbound-http.test.ts +0 -125
  174. package/templates/apps/api/src/lib/__tests__/prisma.test.ts +0 -165
  175. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +0 -317
  176. package/templates/apps/api/src/lib/__tests__/require-paid-tier.test.ts +0 -129
  177. package/templates/apps/api/src/lib/__tests__/site.test.ts +0 -308
  178. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +0 -89
  179. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +0 -202
  180. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +0 -217
  181. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -82
  182. package/templates/apps/api/src/lib/agent-store.ts +0 -613
  183. package/templates/apps/api/src/lib/auth-email-budget.ts +0 -161
  184. package/templates/apps/api/src/lib/consent-store.ts +0 -363
  185. package/templates/apps/api/src/lib/content-adapter.ts +0 -112
  186. package/templates/apps/api/src/lib/deliverable-asset-store.ts +0 -96
  187. package/templates/apps/api/src/lib/error-serializer.ts +0 -164
  188. package/templates/apps/api/src/lib/errors.ts +0 -95
  189. package/templates/apps/api/src/lib/file-fallback.ts +0 -87
  190. package/templates/apps/api/src/lib/flow-completion-store.ts +0 -136
  191. package/templates/apps/api/src/lib/hub-sign-in-store.ts +0 -455
  192. package/templates/apps/api/src/lib/lead-capture-store.ts +0 -122
  193. package/templates/apps/api/src/lib/lead-delivery.ts +0 -52
  194. package/templates/apps/api/src/lib/license-key-store.ts +0 -45
  195. package/templates/apps/api/src/lib/locales.ts +0 -201
  196. package/templates/apps/api/src/lib/outbound-http.ts +0 -127
  197. package/templates/apps/api/src/lib/prisma.ts +0 -21
  198. package/templates/apps/api/src/lib/proposal-paid-store.ts +0 -66
  199. package/templates/apps/api/src/lib/rate-limit-probe.ts +0 -165
  200. package/templates/apps/api/src/lib/rate-limit-store.ts +0 -142
  201. package/templates/apps/api/src/lib/require-commerce-claim.ts +0 -250
  202. package/templates/apps/api/src/lib/require-paid-tier.ts +0 -53
  203. package/templates/apps/api/src/lib/site.ts +0 -257
  204. package/templates/apps/api/src/lib/slice-load.ts +0 -90
  205. package/templates/apps/api/src/lib/spoke-store.ts +0 -180
  206. package/templates/apps/api/src/lib/stripe.ts +0 -321
  207. package/templates/apps/api/src/lib/webhook-idempotency.ts +0 -156
  208. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +0 -111
  209. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +0 -172
  210. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +0 -119
  211. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +0 -138
  212. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +0 -142
  213. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +0 -155
  214. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +0 -139
  215. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +0 -562
  216. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +0 -76
  217. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +0 -220
  218. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +0 -97
  219. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +0 -557
  220. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +0 -72
  221. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +0 -140
  222. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +0 -721
  223. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +0 -220
  224. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +0 -430
  225. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +0 -1409
  226. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +0 -174
  227. package/templates/apps/api/src/middleware/auth.ts +0 -864
  228. package/templates/apps/api/src/middleware/default-tenant.ts +0 -166
  229. package/templates/apps/api/src/middleware/rate-limit.ts +0 -983
  230. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +0 -391
  231. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +0 -182
  232. package/templates/apps/api/src/openapi/index.ts +0 -18
  233. package/templates/apps/api/src/openapi/spec.ts +0 -2148
  234. package/templates/apps/api/src/openapi/surface-drift.ts +0 -195
  235. package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +0 -175
  236. package/templates/apps/api/src/routes/aeo/index.ts +0 -107
  237. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +0 -196
  238. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +0 -249
  239. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +0 -532
  240. package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +0 -313
  241. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +0 -413
  242. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +0 -609
  243. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +0 -242
  244. package/templates/apps/api/src/routes/auth/tokens.ts +0 -211
  245. package/templates/apps/api/src/routes/auth/transactional-email.ts +0 -301
  246. package/templates/apps/api/src/routes/auth/user.ts +0 -305
  247. package/templates/apps/api/src/routes/billing/plan-checkout.ts +0 -332
  248. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +0 -178
  249. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +0 -1426
  250. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +0 -448
  251. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -1742
  252. package/templates/apps/api/src/routes/checkout/index.ts +0 -34
  253. package/templates/apps/api/src/routes/checkout/session.ts +0 -171
  254. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -540
  255. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +0 -386
  256. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +0 -160
  257. package/templates/apps/api/src/routes/consent/account.ts +0 -268
  258. package/templates/apps/api/src/routes/consent/cookie.ts +0 -90
  259. package/templates/apps/api/src/routes/consent/index.ts +0 -26
  260. package/templates/apps/api/src/routes/consent/unsubscribe.ts +0 -127
  261. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +0 -853
  262. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +0 -230
  263. package/templates/apps/api/src/routes/content/__tests__/list-types.test.ts +0 -44
  264. package/templates/apps/api/src/routes/content/__tests__/locale.test.ts +0 -118
  265. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +0 -731
  266. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +0 -753
  267. package/templates/apps/api/src/routes/content/__tests__/rate-limit.test.ts +0 -51
  268. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +0 -450
  269. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +0 -762
  270. package/templates/apps/api/src/routes/content/get-page.ts +0 -183
  271. package/templates/apps/api/src/routes/content/index.ts +0 -49
  272. package/templates/apps/api/src/routes/content/list-pages.ts +0 -74
  273. package/templates/apps/api/src/routes/content/list-types.ts +0 -19
  274. package/templates/apps/api/src/routes/content/offerings.ts +0 -184
  275. package/templates/apps/api/src/routes/content/pricing.ts +0 -176
  276. package/templates/apps/api/src/routes/content/rate-limit.ts +0 -22
  277. package/templates/apps/api/src/routes/content/search.ts +0 -100
  278. package/templates/apps/api/src/routes/content/shown-offerings.ts +0 -229
  279. package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +0 -88
  280. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +0 -171
  281. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +0 -515
  282. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +0 -392
  283. package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +0 -165
  284. package/templates/apps/api/src/routes/leads/index.ts +0 -639
  285. package/templates/apps/api/src/routes/leads/list.ts +0 -104
  286. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +0 -626
  287. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +0 -1268
  288. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +0 -847
  289. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +0 -491
  290. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +0 -476
  291. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +0 -872
  292. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +0 -1022
  293. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +0 -484
  294. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +0 -535
  295. package/templates/apps/api/src/routes/project-listings/index.ts +0 -164
  296. package/templates/apps/api/src/routes/project-listings/me.ts +0 -87
  297. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +0 -224
  298. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +0 -102
  299. package/templates/apps/api/src/routes/project-listings/public.ts +0 -98
  300. package/templates/apps/api/src/routes/project-listings/respond.ts +0 -48
  301. package/templates/apps/api/src/routes/project-listings/resume-token.ts +0 -27
  302. package/templates/apps/api/src/routes/project-listings/start.ts +0 -140
  303. package/templates/apps/api/src/routes/project-listings/submit.ts +0 -130
  304. package/templates/apps/api/src/routes/project-listings/verify-email.ts +0 -150
  305. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +0 -686
  306. package/templates/apps/api/src/routes/requests/confirm.ts +0 -601
  307. package/templates/apps/api/src/routes/requests/index.ts +0 -54
  308. package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +0 -306
  309. package/templates/apps/api/src/routes/test/auth/session.ts +0 -143
  310. package/templates/apps/api/src/routes/webhooks/README.md +0 -398
  311. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +0 -649
  312. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +0 -749
  313. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -449
  314. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -264
  315. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +0 -159
  316. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +0 -158
  317. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +0 -814
  318. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -301
  319. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +0 -369
  320. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -215
  321. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +0 -301
  322. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +0 -809
  323. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -1155
  324. package/templates/apps/api/src/routes/webhooks/index.ts +0 -141
  325. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +0 -384
  326. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -2028
  327. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +0 -645
  328. package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +0 -132
  329. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +0 -1028
  330. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +0 -313
  331. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +0 -503
  332. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +0 -65
  333. package/templates/apps/api/src/services/__tests__/consent.test.ts +0 -299
  334. package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +0 -147
  335. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +0 -877
  336. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +0 -379
  337. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +0 -368
  338. package/templates/apps/api/src/services/__tests__/entitlements.test.ts +0 -142
  339. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +0 -854
  340. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +0 -234
  341. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +0 -251
  342. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +0 -2660
  343. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +0 -166
  344. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +0 -389
  345. package/templates/apps/api/src/services/__tests__/person.test.ts +0 -1204
  346. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +0 -450
  347. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +0 -202
  348. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +0 -151
  349. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +0 -137
  350. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +0 -531
  351. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +0 -372
  352. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +0 -348
  353. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +0 -660
  354. package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +0 -941
  355. package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +0 -588
  356. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +0 -78
  357. package/templates/apps/api/src/services/abandon-email.ts +0 -336
  358. package/templates/apps/api/src/services/aeo-score.ts +0 -325
  359. package/templates/apps/api/src/services/answer-distributions.ts +0 -138
  360. package/templates/apps/api/src/services/audit-log.ts +0 -961
  361. package/templates/apps/api/src/services/checkout-buyer.ts +0 -168
  362. package/templates/apps/api/src/services/consent.ts +0 -834
  363. package/templates/apps/api/src/services/default-tenant.ts +0 -416
  364. package/templates/apps/api/src/services/deliverable-fulfillment.ts +0 -750
  365. package/templates/apps/api/src/services/dunning-policy.ts +0 -294
  366. package/templates/apps/api/src/services/enrichment-shim.ts +0 -53
  367. package/templates/apps/api/src/services/entitlements.ts +0 -338
  368. package/templates/apps/api/src/services/hub-sign-in.ts +0 -472
  369. package/templates/apps/api/src/services/lead-accept-user.ts +0 -126
  370. package/templates/apps/api/src/services/lead-promotion.ts +0 -160
  371. package/templates/apps/api/src/services/lead-routing.ts +0 -1372
  372. package/templates/apps/api/src/services/leads-inbox.ts +0 -312
  373. package/templates/apps/api/src/services/listing-config.ts +0 -178
  374. package/templates/apps/api/src/services/magnet-completion.ts +0 -242
  375. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +0 -348
  376. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +0 -268
  377. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +0 -975
  378. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +0 -416
  379. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +0 -400
  380. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +0 -685
  381. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +0 -231
  382. package/templates/apps/api/src/services/offerings/catalog.ts +0 -318
  383. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +0 -403
  384. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +0 -192
  385. package/templates/apps/api/src/services/offerings/connection.ts +0 -1083
  386. package/templates/apps/api/src/services/offerings/index.ts +0 -614
  387. package/templates/apps/api/src/services/offerings/source.ts +0 -140
  388. package/templates/apps/api/src/services/offerings/store.ts +0 -150
  389. package/templates/apps/api/src/services/owner-workspaces.ts +0 -132
  390. package/templates/apps/api/src/services/person.ts +0 -1211
  391. package/templates/apps/api/src/services/plan-offerings.ts +0 -125
  392. package/templates/apps/api/src/services/project-listing-resume-email.ts +0 -326
  393. package/templates/apps/api/src/services/project-listings.ts +0 -2085
  394. package/templates/apps/api/src/services/public-site-resolver.ts +0 -396
  395. package/templates/apps/api/src/services/request-confirm.ts +0 -698
  396. package/templates/apps/api/src/services/revalidation.ts +0 -235
  397. package/templates/apps/api/src/services/slack-notify.ts +0 -291
  398. package/templates/apps/api/src/services/usage-aggregation.ts +0 -516
  399. package/templates/apps/api/src/services/usage-credits.ts +0 -340
  400. package/templates/apps/api/src/services/verified-email.ts +0 -71
  401. package/templates/apps/mcp/__tests__/abuse/daily-cap.test.ts +0 -52
  402. package/templates/apps/mcp/__tests__/abuse/email-validator.test.ts +0 -46
  403. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +0 -198
  404. package/templates/apps/mcp/__tests__/abuse/honeypot.test.ts +0 -36
  405. package/templates/apps/mcp/__tests__/abuse/user-agent-deny.test.ts +0 -54
  406. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +0 -116
  407. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +0 -299
  408. package/templates/apps/mcp/__tests__/boot.test.ts +0 -280
  409. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +0 -359
  410. package/templates/apps/mcp/__tests__/config/modules.test.ts +0 -78
  411. package/templates/apps/mcp/__tests__/get_business_hours.test.ts +0 -82
  412. package/templates/apps/mcp/__tests__/get_faq.test.ts +0 -80
  413. package/templates/apps/mcp/__tests__/get_locations.test.ts +0 -65
  414. package/templates/apps/mcp/__tests__/get_offerings.test.ts +0 -557
  415. package/templates/apps/mcp/__tests__/get_pricing.test.ts +0 -524
  416. package/templates/apps/mcp/__tests__/get_site_info.test.ts +0 -81
  417. package/templates/apps/mcp/__tests__/http-transport.test.ts +0 -1232
  418. package/templates/apps/mcp/__tests__/index.test.ts +0 -195
  419. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +0 -191
  420. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +0 -531
  421. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +0 -70
  422. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -162
  423. package/templates/apps/mcp/__tests__/lib/content-root.test.ts +0 -93
  424. package/templates/apps/mcp/__tests__/lib/errors.test.ts +0 -75
  425. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +0 -180
  426. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +0 -124
  427. package/templates/apps/mcp/__tests__/list_pages.test.ts +0 -268
  428. package/templates/apps/mcp/__tests__/locale.test.ts +0 -120
  429. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +0 -175
  430. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +0 -101
  431. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +0 -189
  432. package/templates/apps/mcp/__tests__/read_content.test.ts +0 -511
  433. package/templates/apps/mcp/__tests__/request_callback.test.ts +0 -160
  434. package/templates/apps/mcp/__tests__/search_content.test.ts +0 -271
  435. package/templates/apps/mcp/__tests__/server.test.ts +0 -388
  436. package/templates/apps/mcp/__tests__/start_purchase.test.ts +0 -448
  437. package/templates/apps/mcp/__tests__/submit_lead.test.ts +0 -222
  438. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +0 -152
  439. package/templates/apps/mcp/__tests__/tools/create_project.test.ts +0 -243
  440. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +0 -451
  441. package/templates/apps/mcp/src/abuse/daily-cap.ts +0 -47
  442. package/templates/apps/mcp/src/abuse/email-validator.ts +0 -25
  443. package/templates/apps/mcp/src/abuse/honeypot.ts +0 -21
  444. package/templates/apps/mcp/src/abuse/index.ts +0 -349
  445. package/templates/apps/mcp/src/abuse/user-agent-deny.ts +0 -58
  446. package/templates/apps/mcp/src/boot.ts +0 -167
  447. package/templates/apps/mcp/src/config/modules.ts +0 -43
  448. package/templates/apps/mcp/src/http-transport.ts +0 -564
  449. package/templates/apps/mcp/src/lib/audit-single-page.ts +0 -308
  450. package/templates/apps/mcp/src/lib/commerce-claim.ts +0 -263
  451. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -116
  452. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +0 -140
  453. package/templates/apps/mcp/src/lib/errors.ts +0 -61
  454. package/templates/apps/mcp/src/lib/license-key-store.ts +0 -45
  455. package/templates/apps/mcp/src/lib/locales.ts +0 -171
  456. package/templates/apps/mcp/src/lib/prisma.ts +0 -39
  457. package/templates/apps/mcp/src/lib/public-workspace.ts +0 -133
  458. package/templates/apps/mcp/src/lib/resolve-locale.ts +0 -30
  459. package/templates/apps/mcp/src/lib/shown-offerings.ts +0 -105
  460. package/templates/apps/mcp/src/lib/site-config.ts +0 -53
  461. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +0 -59
  462. package/templates/apps/mcp/src/request-context.ts +0 -52
  463. package/templates/apps/mcp/src/server.ts +0 -343
  464. package/templates/apps/mcp/src/serverless-app.ts +0 -88
  465. package/templates/apps/mcp/src/tools/audit.ts +0 -127
  466. package/templates/apps/mcp/src/tools/capture_lead.ts +0 -66
  467. package/templates/apps/mcp/src/tools/create_project.ts +0 -134
  468. package/templates/apps/mcp/src/tools/get_business_hours.ts +0 -68
  469. package/templates/apps/mcp/src/tools/get_faq.ts +0 -71
  470. package/templates/apps/mcp/src/tools/get_locations.ts +0 -88
  471. package/templates/apps/mcp/src/tools/get_offerings.ts +0 -96
  472. package/templates/apps/mcp/src/tools/get_pricing.ts +0 -94
  473. package/templates/apps/mcp/src/tools/get_site_info.ts +0 -86
  474. package/templates/apps/mcp/src/tools/index.ts +0 -138
  475. package/templates/apps/mcp/src/tools/list_pages.ts +0 -68
  476. package/templates/apps/mcp/src/tools/person_notes.ts +0 -25
  477. package/templates/apps/mcp/src/tools/proposals.ts +0 -27
  478. package/templates/apps/mcp/src/tools/purchase.ts +0 -205
  479. package/templates/apps/mcp/src/tools/read_content.ts +0 -81
  480. package/templates/apps/mcp/src/tools/request_callback.ts +0 -52
  481. package/templates/apps/mcp/src/tools/search_content.ts +0 -63
  482. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +0 -54
  483. package/templates/apps/mcp/src/workspace-server.ts +0 -224
  484. package/templates/database/CHANGELOG.md +0 -981
  485. package/templates/database/LICENSE +0 -202
  486. package/templates/database/NOTICE +0 -2
  487. package/templates/database/README.md +0 -243
  488. package/templates/database/__tests__/agents.db.test.ts +0 -320
  489. package/templates/database/__tests__/appointment.db.test.ts +0 -106
  490. package/templates/database/__tests__/artifact-content-store.db.test.ts +0 -316
  491. package/templates/database/__tests__/composite-fence.db.test.ts +0 -206
  492. package/templates/database/__tests__/consent-ledger.db.test.ts +0 -345
  493. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +0 -79
  494. package/templates/database/__tests__/fence-fan-out.db.test.ts +0 -715
  495. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +0 -79
  496. package/templates/database/__tests__/fixtures/person-backfill.json +0 -264
  497. package/templates/database/__tests__/fixtures/person-contact-backfill.json +0 -182
  498. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +0 -100
  499. package/templates/database/__tests__/flow-completion-migration.test.ts +0 -115
  500. package/templates/database/__tests__/flow-completion.db.test.ts +0 -136
  501. package/templates/database/__tests__/flow-workspace-key.db.test.ts +0 -263
  502. package/templates/database/__tests__/flows-listing-diagnostic.test.ts +0 -392
  503. package/templates/database/__tests__/flows-presets.test.ts +0 -173
  504. package/templates/database/__tests__/in-page-magnets-migration.test.ts +0 -161
  505. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +0 -92
  506. package/templates/database/__tests__/member-area.db.test.ts +0 -257
  507. package/templates/database/__tests__/member-contract-migration.test.ts +0 -85
  508. package/templates/database/__tests__/member-contract.db.test.ts +0 -413
  509. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +0 -270
  510. package/templates/database/__tests__/offerings-migration.test.ts +0 -197
  511. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +0 -86
  512. package/templates/database/__tests__/offerings.db.test.ts +0 -303
  513. package/templates/database/__tests__/outbound-message-action.db.test.ts +0 -80
  514. package/templates/database/__tests__/person-contact.db.test.ts +0 -563
  515. package/templates/database/__tests__/person-notes.db.test.ts +0 -249
  516. package/templates/database/__tests__/person.db.test.ts +0 -586
  517. package/templates/database/__tests__/piece-kind-migration.test.ts +0 -67
  518. package/templates/database/__tests__/piece-kind.db.test.ts +0 -208
  519. package/templates/database/__tests__/prefixed-ids.db.test.ts +0 -222
  520. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +0 -111
  521. package/templates/database/__tests__/seed.test.ts +0 -272
  522. package/templates/database/__tests__/spoke-registry.db.test.ts +0 -191
  523. package/templates/database/__tests__/tenancy.test.ts +0 -141
  524. package/templates/database/__tests__/trial-magnet-migration.test.ts +0 -186
  525. package/templates/database/__tests__/user-cascade.test.ts +0 -140
  526. package/templates/database/_gitignore +0 -8
  527. package/templates/database/auth/.gitkeep +0 -0
  528. package/templates/database/auth/schema.prisma +0 -641
  529. package/templates/database/config/.gitkeep +0 -0
  530. package/templates/database/config/schema.prisma +0 -441
  531. package/templates/database/content/.gitkeep +0 -0
  532. package/templates/database/content/schema.prisma +0 -339
  533. package/templates/database/eslint.config.mjs +0 -69
  534. package/templates/database/flows/listing-diagnostic.ts +0 -182
  535. package/templates/database/flows/presets.ts +0 -315
  536. package/templates/database/inbox/.gitkeep +0 -0
  537. package/templates/database/inbox/README.md +0 -3
  538. package/templates/database/inbox/__tests__/schema.test.ts +0 -44
  539. package/templates/database/inbox/schema.prisma +0 -1447
  540. package/templates/database/jest.config.cjs +0 -36
  541. package/templates/database/migrations/20260508000000_add_inbox_namespace/migration.sql +0 -328
  542. package/templates/database/migrations/20260513000000_better_auth_migration/migration.sql +0 -100
  543. package/templates/database/migrations/20260515000000_add_user_billing_fields/migration.sql +0 -28
  544. package/templates/database/migrations/20260516000000_add_user_tier_care_plan_seat/migration.sql +0 -25
  545. package/templates/database/migrations/20260518000000_add_telemetry_records/migration.sql +0 -42
  546. package/templates/database/migrations/20260525000000_theme_wiring/migration.sql +0 -67
  547. package/templates/database/migrations/20260710000000_retire_commercial_tier/migration.sql +0 -21
  548. package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +0 -201
  549. package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +0 -67
  550. package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +0 -46
  551. package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +0 -25
  552. package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +0 -49
  553. package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +0 -197
  554. package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +0 -32
  555. package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +0 -22
  556. package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +0 -151
  557. package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +0 -223
  558. package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +0 -22
  559. package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +0 -32
  560. package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +0 -40
  561. package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +0 -4
  562. package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +0 -2
  563. package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +0 -2
  564. package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +0 -29
  565. package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +0 -6
  566. package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +0 -39
  567. package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +0 -24
  568. package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +0 -3
  569. package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +0 -33
  570. package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +0 -30
  571. package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +0 -47
  572. package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +0 -16
  573. package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +0 -47
  574. package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +0 -10
  575. package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +0 -26
  576. package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +0 -90
  577. package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +0 -174
  578. package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +0 -14
  579. package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +0 -25
  580. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +0 -32
  581. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +0 -71
  582. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +0 -30
  583. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +0 -49
  584. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +0 -123
  585. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +0 -15
  586. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +0 -158
  587. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +0 -40
  588. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +0 -67
  589. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +0 -70
  590. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +0 -181
  591. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +0 -172
  592. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +0 -41
  593. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +0 -185
  594. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +0 -70
  595. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +0 -57
  596. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +0 -68
  597. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +0 -1067
  598. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +0 -210
  599. package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +0 -80
  600. package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +0 -49
  601. package/templates/database/migrations/20261005120000_agents/migration.sql +0 -84
  602. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +0 -35
  603. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +0 -20
  604. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +0 -33
  605. package/templates/database/migrations/20261010120000_person/migration.sql +0 -106
  606. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +0 -132
  607. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +0 -20
  608. package/templates/database/migrations/20261011120000_member_area/migration.sql +0 -158
  609. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +0 -21
  610. package/templates/database/migrations/20261012120000_member_contract/migration.sql +0 -192
  611. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +0 -20
  612. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +0 -122
  613. package/templates/database/migrations/20261014120000_person_contact/migration.sql +0 -231
  614. package/templates/database/migrations/20261015120000_person_notes/migration.sql +0 -77
  615. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +0 -43
  616. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +0 -95
  617. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +0 -30
  618. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +0 -52
  619. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +0 -27
  620. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +0 -146
  621. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +0 -77
  622. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +0 -45
  623. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +0 -47
  624. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +0 -16
  625. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +0 -28
  626. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +0 -23
  627. package/templates/database/migrations/migration_lock.toml +0 -3
  628. package/templates/database/ops/.gitkeep +0 -0
  629. package/templates/database/ops/schema.prisma +0 -43
  630. package/templates/database/package.json +0 -89
  631. package/templates/database/schema.prisma +0 -71
  632. package/templates/database/scripts/db-generate-locked.sh +0 -80
  633. package/templates/database/scripts/person-backfill.sql +0 -132
  634. package/templates/database/tenancy.ts +0 -183
  635. package/templates/database/tsconfig.build.json +0 -16
  636. package/templates/database/tsconfig.flows.json +0 -15
  637. package/templates/database/tsconfig.json +0 -16
  638. 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
- }