create-ailk 0.20.0 → 0.22.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 (222) hide show
  1. package/README.md +21 -1
  2. package/component-catalog.json +220 -8
  3. package/component-catalog.md +21 -9
  4. package/dist/add-module.js +9 -5
  5. package/dist/apply-design.d.ts +9 -0
  6. package/dist/apply-design.js +78 -31
  7. package/dist/derive-architecture.d.ts +8 -1
  8. package/dist/derive-architecture.js +38 -3
  9. package/dist/design-home.d.ts +54 -11
  10. package/dist/design-home.js +160 -22
  11. package/dist/design-package.js +7 -2
  12. package/dist/lib/design-checks.d.ts +6 -1
  13. package/dist/lib/design-checks.js +23 -2
  14. package/dist/lib/env-catalog.js +5 -1
  15. package/dist/module-architecture.js +4 -2
  16. package/dist/programmatic.js +63 -25
  17. package/dist/site-chrome.d.ts +8 -0
  18. package/dist/site-chrome.js +17 -1
  19. package/dist/site-roster.d.ts +4 -2
  20. package/dist/site-roster.js +7 -5
  21. package/dist/spoke-checkout.d.ts +39 -18
  22. package/dist/spoke-checkout.js +177 -236
  23. package/dist/starter-grid.js +16 -0
  24. package/dist/surfaces.js +30 -14
  25. package/dist/sync-routes.d.ts +28 -0
  26. package/dist/sync-routes.js +124 -1
  27. package/dist/template-record.d.ts +4 -3
  28. package/dist/template-record.js +6 -3
  29. package/dist/topology.js +25 -1
  30. package/dist/update.d.ts +6 -1
  31. package/dist/update.js +125 -18
  32. package/package.json +3 -3
  33. package/site-templates/_base/ailk-template.json +2 -2
  34. package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
  35. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  36. package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
  37. package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
  38. package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
  39. package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
  41. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
  43. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
  46. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
  48. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
  49. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
  50. package/templates/README.md +26 -25
  51. package/templates/apps/api/CLAUDE.md +20 -7
  52. package/templates/apps/api/jest.config.cjs +1 -0
  53. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  54. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  55. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  56. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  57. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  58. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  59. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  60. package/templates/apps/api/src/middleware/auth.ts +13 -6
  61. package/templates/apps/api/src/openapi/spec.ts +30 -3
  62. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  63. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  64. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  65. package/templates/apps/api/src/routes/consent/account.ts +266 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  67. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  68. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  69. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  70. package/templates/apps/api/src/routes/leads/index.ts +161 -7
  71. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  73. package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
  74. package/templates/apps/api/src/server.ts +28 -0
  75. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
  76. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  77. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  78. package/templates/apps/api/src/services/consent.ts +95 -0
  79. package/templates/apps/api/src/services/lead-routing.ts +337 -8
  80. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  81. package/templates/apps/api/src/services/person.ts +40 -8
  82. package/templates/apps/api/src/services/plan-offerings.ts +59 -0
  83. package/templates/apps/api/src/services/project-listings.ts +6 -1
  84. package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
  85. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
  86. package/templates/apps/mcp/jest.config.cjs +1 -0
  87. package/templates/apps/web/.env.example +14 -0
  88. package/templates/apps/web/CLAUDE.md +139 -3
  89. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  90. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  91. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  92. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  93. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  94. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  95. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  96. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  97. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  98. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  99. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  100. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  101. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  102. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  103. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  104. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  105. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  106. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  114. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  115. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  116. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  117. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  119. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  120. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  121. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  122. package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
  123. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
  124. package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
  125. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
  126. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
  127. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
  128. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
  129. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
  130. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
  131. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
  132. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  133. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  134. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  135. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  136. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  137. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  138. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +24 -1
  139. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  140. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
  141. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
  142. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
  143. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
  144. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
  145. package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
  146. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
  147. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
  148. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  149. package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
  150. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  151. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
  152. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  153. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
  154. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  155. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  156. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  157. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  158. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  159. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  160. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  161. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  162. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  163. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  164. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  165. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  166. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  167. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  168. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  169. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  170. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  171. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  172. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  173. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  174. package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
  175. package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
  176. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  177. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  178. package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
  179. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
  180. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
  181. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  182. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  183. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
  184. package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
  185. package/templates/apps/web/auth.ts +28 -2
  186. package/templates/apps/web/jest.config.cjs +1 -0
  187. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  188. package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
  189. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  190. package/templates/apps/web/lib/__tests__/deck-page.test.ts +249 -0
  191. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
  192. package/templates/apps/web/lib/account-invite.ts +37 -0
  193. package/templates/apps/web/lib/checkout-config.ts +27 -0
  194. package/templates/apps/web/lib/checkout.ts +93 -0
  195. package/templates/apps/web/lib/consent-policies.ts +22 -0
  196. package/templates/apps/web/lib/deck-page.ts +192 -0
  197. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  198. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  199. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  200. package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
  201. package/templates/apps/web/lib/site-chrome.ts +2 -0
  202. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  203. package/templates/apps/web/messages/en.json +40 -12
  204. package/templates/apps/web/package.json +1 -0
  205. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  206. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  207. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  208. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  209. package/templates/database/CHANGELOG.md +47 -0
  210. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  211. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  212. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  213. package/templates/database/__tests__/seed.test.ts +72 -2
  214. package/templates/database/inbox/schema.prisma +17 -2
  215. package/templates/database/jest.config.cjs +1 -0
  216. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  217. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  218. package/templates/database/package.json +1 -1
  219. package/templates/database/seed.ts +60 -0
  220. package/templates/tools/jest/pin-timezone.cjs +34 -0
  221. package/versions.json +34 -34
  222. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
@@ -81,6 +81,43 @@
81
81
  * relies on the column default. The plan rule above is unchanged: it still
82
82
  * runs after the write, from the same candidate.
83
83
  *
84
+ * PURCHASE EMAIL AND INBOX MESSAGE (#8757 D14). After this request's own
85
+ * write of a lead marked `spokeCheckout` with a Person — so never on a
86
+ * replay, a test-mode or relayed checkout, or a lead with no Person — the
87
+ * buyer gets one outbound `member` `Interaction` on that Person
88
+ * (`createdBy: "system:checkout"`, subject "Your purchase", no link, payload
89
+ * or attachment) and, fired with `void`, one `sendPurchaseConfirmationEmail`.
90
+ * Both name the offering only when its stripped key is one
91
+ * `listPlanOfferings` lists for the workspace, else "your purchase" — never
92
+ * the spoke-supplied `payload.offering` (SEC-8757-G) — and both bodies are
93
+ * stripped of control and bidi characters. The email's "Open your account"
94
+ * link is `options.accountOrigin` (the route resolves it, as for the invite
95
+ * email) plus the hub sign-in path: a marked lead is always a registered
96
+ * spoke's. With no origin the email goes without the link. The email is
97
+ * capped per recipient: more than `PURCHASE_EMAILS_PER_RECIPIENT_PER_DAY`
98
+ * marked leads of this workspace for the address (any case) in the last 24
99
+ * hours, this one included, skips it; and per workspace: more than
100
+ * `PURCHASE_EMAILS_PER_SITE_PER_HOUR` marked leads in the last hour skips
101
+ * it. The message is still written. Neither
102
+ * step ever throws or changes the lead's result, and their one log line,
103
+ * `{ event: "checkout-purchase", step, outcome }`, names no address or
104
+ * offering.
105
+ *
106
+ * CHECKOUT CONSENT GRANT (#8778). After the same first write of a marked
107
+ * lead with a Person, a lead whose `payload.termsAccepted` equals the current
108
+ * `account_terms@<version>` stamp (`currentAccountTermsStamp`) — the spoke's
109
+ * fulfilment sends the stamp of the terms the buyer ticked in Stripe's box,
110
+ * else `null` — records the buyer's checkout `ConsentGrant` through #8765's
111
+ * `recordCheckoutConsent`: one `account_terms` row for that Person, source
112
+ * `checkout`, `sourceRef` the Checkout Session id (the lead's
113
+ * `Idempotency-Key`), at most once per Person and session. A `null` or absent
114
+ * value writes nothing (`consent_absent`), as does any other value
115
+ * (`terms_unstamped`). A replay never reaches it, and the key is left out of
116
+ * the replay comparison (`comparablePayload`). Never throws and never
117
+ * changes the lead's result; its one log line,
118
+ * `{ event: "checkout-consent", outcome, siteId }` (plus `errName` on
119
+ * `failed`), names no address or Person.
120
+ *
84
121
  * VISITOR LINK (#8438 D11). When the request carried a valid consent visitor
85
122
  * id (`SourceMeta.consentVisitorId`, set by the leads and flows routes from
86
123
  * `x-ailk-consent-visitor` only when it is a v4 UUID) and the submit resolved
@@ -95,12 +132,17 @@ import { randomUUID } from "node:crypto";
95
132
  import { isDeepStrictEqual } from "node:util";
96
133
 
97
134
  import type { Prisma } from "@working-theory/database";
98
- import { sendLeadNotification } from "@working-theory/email";
135
+ import { sendLeadNotification, sendPurchaseConfirmationEmail } from "@working-theory/email";
99
136
  import { logger } from "@working-theory/observability";
100
137
  import {
138
+ accountInvitePath,
139
+ isContactLeadSource,
101
140
  LEAD_SITE_MARKER_KEY,
102
141
  leadInputSchema,
142
+ PURCHASE_EMAIL_DEFAULTS,
143
+ PURCHASE_EMAILS_PER_RECIPIENT_PER_DAY,
103
144
  planOfferingSchema,
145
+ stripPlainText,
104
146
  } from "@working-theory/validation";
105
147
 
106
148
  import { writeLeadFallback } from "../lib/file-fallback.js";
@@ -108,7 +150,12 @@ import { appendLeadRecord } from "../lib/lead-capture-store.js";
108
150
  import { acceptsKind, resolveLeadDelivery } from "../lib/lead-delivery.js";
109
151
  import { prisma } from "../lib/prisma.js";
110
152
 
111
- import { linkConsentVisitor } from "./consent.js";
153
+ import {
154
+ currentAccountTermsStamp,
155
+ linkConsentVisitor,
156
+ recordCheckoutConsent,
157
+ type RecordCheckoutConsentOutcome,
158
+ } from "./consent.js";
112
159
  import { enrichLead } from "./enrichment-shim.js";
113
160
  import {
114
161
  findOrCreatePerson,
@@ -117,6 +164,7 @@ import {
117
164
  setPersonPlanOffering,
118
165
  } from "./person.js";
119
166
  import type { LeadContact, PersonContact } from "./person.js";
167
+ import { listPlanOfferings } from "./plan-offerings.js";
120
168
  import { requestConfirmSupersedesNotification } from "./request-confirm.js";
121
169
 
122
170
  // ─── Types ────────────────────────────────────────────────────────────────────
@@ -178,6 +226,12 @@ export type RouteLeadOptions = {
178
226
  * `payload` stays the raw answers.
179
227
  */
180
228
  contact?: LeadContact;
229
+ /**
230
+ * Where the buyer's account lives (#8757 D14): the origin the purchase
231
+ * email's "Open your account" link starts sign-in on, resolved by the route
232
+ * from the caller, never the body. Read for a marked checkout lead only.
233
+ */
234
+ accountOrigin?: string;
181
235
  };
182
236
 
183
237
  export type RouteLeadResult =
@@ -252,15 +306,46 @@ async function findLeadByKey(
252
306
  })) as KeyedRow | null;
253
307
  }
254
308
 
309
+ /**
310
+ * A contact lead's `payload.consent` is written by the route on every request
311
+ * (#8730 D3) — its `acceptedAt` is that request's receive time — so it is left
312
+ * out of the replay comparison; every key the client sent still counts.
313
+ *
314
+ * A checkout lead's `payload.termsAccepted` (#8778) is left out too: a spoke
315
+ * updated between a stored attempt and Stripe's redelivery sends the key the
316
+ * stored body lacks, and a conflict there would fail every retry. A replay
317
+ * writes no grant, so the key decides nothing on one.
318
+ */
319
+ function comparablePayload(payload: unknown, source: string): unknown {
320
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
321
+ return payload;
322
+ }
323
+ const record = payload as Record<string, unknown>;
324
+ if (isContactLeadSource(source)) {
325
+ const { consent: _serverWritten, ...rest } = record;
326
+ return rest;
327
+ }
328
+ if (record["page"] === "checkout") {
329
+ const { termsAccepted: _addedByUpdate, ...rest } = record;
330
+ return rest;
331
+ }
332
+ return payload;
333
+ }
334
+
255
335
  /**
256
336
  * A stored row is a replay only when it carries this request's exact `email`
257
337
  * and `source` and a deep-equal `payload` (key order ignored, SEC-7869-3);
258
- * any other row is a conflict (#7869 D4).
338
+ * any other row is a conflict (#7869 D4). A contact lead's server-written
339
+ * `payload.consent` is not compared (#8730), nor a checkout lead's
340
+ * `payload.termsAccepted` (#8778).
259
341
  */
260
342
  function classifyKeyedRow(row: KeyedRow, input: SubmitInput): KeyedOutcome {
261
343
  return row.email === input.email &&
262
344
  row.source === input.source &&
263
- isDeepStrictEqual(row.payload, input.payload)
345
+ isDeepStrictEqual(
346
+ comparablePayload(row.payload, row.source),
347
+ comparablePayload(input.payload, input.source),
348
+ )
264
349
  ? { kind: "replay", id: row.id }
265
350
  : { kind: "conflict" };
266
351
  }
@@ -381,6 +466,14 @@ function checkoutPlanCandidate(
381
466
  return { offering, site };
382
467
  }
383
468
 
469
+ /** The candidate's offering minus a leading `<site>_` (#7877 D10). */
470
+ function stripSitePrefix(candidate: { offering: string; site: string }): string {
471
+ const prefix = `${candidate.site}_`;
472
+ return candidate.offering.startsWith(prefix)
473
+ ? candidate.offering.slice(prefix.length)
474
+ : candidate.offering;
475
+ }
476
+
384
477
  /**
385
478
  * Set the Person's plan from a qualifying checkout lead (#7877 D10). Never
386
479
  * throws and never changes the lead's result; logs the outcome kind and the
@@ -391,10 +484,7 @@ async function applyCheckoutPlan(
391
484
  personId: string,
392
485
  candidate: { offering: string; site: string },
393
486
  ): Promise<void> {
394
- const prefix = `${candidate.site}_`;
395
- const offering = candidate.offering.startsWith(prefix)
396
- ? candidate.offering.slice(prefix.length)
397
- : candidate.offering;
487
+ const offering = stripSitePrefix(candidate);
398
488
  let outcome: "set" | "skipped" | "failed";
399
489
  // The operator's PUT accepts the same keys (`planOfferingSchema`).
400
490
  if (!planOfferingSchema.safeParse(offering).success) {
@@ -412,12 +502,242 @@ async function applyCheckoutPlan(
412
502
  else logger.info(line, "checkout plan");
413
503
  }
414
504
 
505
+ // ─── Purchase email and Inbox message (#8757 D14) ────────────────────────────
506
+
507
+ const DAY_MS = 24 * 60 * 60 * 1000;
508
+ const HOUR_MS = 60 * 60 * 1000;
509
+ /**
510
+ * A workspace's purchase emails per hour, whatever the address (SEC review of
511
+ * #8770, M1): a marked lead's inputs are all the spoke's own, so the
512
+ * per-recipient cap alone would let a spoke key mail any number of distinct
513
+ * addresses. The invite email's per-site cap
514
+ * (`INVITE_EMAILS_PER_SITE_PER_HOUR`) has the same value.
515
+ */
516
+ const PURCHASE_EMAILS_PER_SITE_PER_HOUR = 50;
517
+ const GENERIC_PURCHASE_BODY = "Thanks for your purchase.";
518
+ /** How long the lead write waits on the offering's name before it says "your purchase". */
519
+ const OFFERING_NAME_BUDGET_MS = 1500;
520
+ /**
521
+ * Stripe's special-case currencies, whose minor units differ from the ISO
522
+ * decimals `Intl` formats with: the amount is left out rather than misstated.
523
+ */
524
+ const AMOUNT_UNSTATED_CURRENCIES = new Set(["HUF", "ISK", "TWD", "UGX"]);
525
+
526
+ /**
527
+ * The purchase email's sender: every completion email's rule
528
+ * (`AILK_DELIVERABLE_EMAIL_FROM`, else `AILK_LEAD_NOTIFICATION_FROM`; `||`, so
529
+ * "" names none), the same as `magnet-completion.ts`'s `inviteSenderAddress`,
530
+ * kept here so the lead path does not load that module's graph.
531
+ */
532
+ function purchaseSenderAddress(): string | undefined {
533
+ return (
534
+ process.env.AILK_DELIVERABLE_EMAIL_FROM ||
535
+ process.env.AILK_LEAD_NOTIFICATION_FROM ||
536
+ undefined
537
+ );
538
+ }
539
+
540
+ type PurchaseStep = "message" | "email";
541
+
542
+ function logPurchase(siteId: string, step: PurchaseStep, outcome: string): void {
543
+ const line = { event: "checkout-purchase", step, outcome, siteId };
544
+ if (outcome === "failed") logger.error(line, "checkout purchase step failed");
545
+ else logger.info(line, "checkout purchase step");
546
+ }
547
+
548
+ /**
549
+ * The offering's listed name, or `null` when its stripped key is not listed,
550
+ * the read fails, or it outlasts `OFFERING_NAME_BUDGET_MS`.
551
+ */
552
+ async function listedOfferingName(siteId: string, offering: string): Promise<string | null> {
553
+ let timer: NodeJS.Timeout | undefined;
554
+ const budget = new Promise<null>((resolve) => {
555
+ timer = setTimeout(() => resolve(null), OFFERING_NAME_BUDGET_MS);
556
+ });
557
+ try {
558
+ const listed = await Promise.race([listPlanOfferings(siteId), budget]);
559
+ if (!Array.isArray(listed)) return null;
560
+ const name = listed.find((item) => item.key === offering)?.name;
561
+ const clean = name === undefined ? "" : stripPlainText(name).trim();
562
+ return clean === "" ? null : clean;
563
+ } catch {
564
+ return null;
565
+ } finally {
566
+ clearTimeout(timer);
567
+ }
568
+ }
569
+
570
+ /** `amountTotal` (minor units) in `currency`, formatted, or `null` when either is unusable. */
571
+ function formattedAmount(amountTotal: unknown, currency: unknown): string | null {
572
+ if (typeof amountTotal !== "number" || !Number.isInteger(amountTotal) || amountTotal <= 0) {
573
+ return null;
574
+ }
575
+ if (typeof currency !== "string" || !/^[A-Za-z]{3}$/.test(currency)) return null;
576
+ const code = currency.toUpperCase();
577
+ if (AMOUNT_UNSTATED_CURRENCIES.has(code)) return null;
578
+ try {
579
+ const format = new Intl.NumberFormat("en", { style: "currency", currency: code });
580
+ const digits = format.resolvedOptions().maximumFractionDigits ?? 2;
581
+ return format.format(amountTotal / 10 ** digits);
582
+ } catch {
583
+ return null;
584
+ }
585
+ }
586
+
587
+ /** The one plain-text body the email and the message share. */
588
+ function purchaseBody(name: string | null, amount: string | null): string {
589
+ const sentence =
590
+ name === null ? GENERIC_PURCHASE_BODY : PURCHASE_EMAIL_DEFAULTS.body.replaceAll("{offering}", name);
591
+ return stripPlainText(amount === null ? sentence : `${sentence} Total: ${amount}.`);
592
+ }
593
+
594
+ /** The purchase email, capped per recipient (D14). Never throws; fired with `void`. */
595
+ async function sendPurchaseEmail(
596
+ input: SubmitInput,
597
+ body: string,
598
+ accountOrigin: string | undefined,
599
+ ): Promise<void> {
600
+ let outcome: string;
601
+ try {
602
+ const recent = await prisma.lead.count({
603
+ where: {
604
+ workspaceId: input.siteId,
605
+ spokeCheckout: true,
606
+ email: { equals: input.email, mode: "insensitive" },
607
+ createdAt: { gte: new Date(Date.now() - DAY_MS) },
608
+ },
609
+ });
610
+ const siteHour = await prisma.lead.count({
611
+ where: {
612
+ workspaceId: input.siteId,
613
+ spokeCheckout: true,
614
+ createdAt: { gte: new Date(Date.now() - HOUR_MS) },
615
+ },
616
+ });
617
+ const fromAddress = purchaseSenderAddress();
618
+ if (recent > PURCHASE_EMAILS_PER_RECIPIENT_PER_DAY) {
619
+ outcome = "capped_recipient";
620
+ } else if (siteHour > PURCHASE_EMAILS_PER_SITE_PER_HOUR) {
621
+ outcome = "capped_site";
622
+ } else if (!fromAddress) {
623
+ outcome = "sender_unconfigured";
624
+ } else {
625
+ let accountLink: string | undefined;
626
+ if (accountOrigin !== undefined) {
627
+ try {
628
+ accountLink = accountOrigin + accountInvitePath("hub", input.email);
629
+ } catch {
630
+ // An address the sign-in hint refuses: the email goes without the link.
631
+ }
632
+ }
633
+ const result = await sendPurchaseConfirmationEmail({
634
+ to: input.email,
635
+ fromAddress,
636
+ subject: PURCHASE_EMAIL_DEFAULTS.subject,
637
+ body,
638
+ cta: PURCHASE_EMAIL_DEFAULTS.cta,
639
+ ...(accountLink !== undefined && { accountLink }),
640
+ });
641
+ outcome = result.ok ? "sent" : "failed";
642
+ }
643
+ } catch {
644
+ outcome = "failed";
645
+ }
646
+ logPurchase(input.siteId, "email", outcome);
647
+ }
648
+
649
+ /**
650
+ * The buyer's Inbox message and purchase email for a marked checkout lead
651
+ * just written (D14). Never throws and never changes the lead's result.
652
+ */
653
+ async function recordPurchase(
654
+ input: SubmitInput,
655
+ personId: string,
656
+ candidate: { offering: string; site: string },
657
+ accountOrigin: string | undefined,
658
+ ): Promise<void> {
659
+ // One guard around the whole step: it runs inside `routeLead`'s persist
660
+ // `try`, where a throw after the row is written would be read as a failed
661
+ // write and the lead captured a second time.
662
+ let body: string;
663
+ try {
664
+ const name = await listedOfferingName(input.siteId, stripSitePrefix(candidate));
665
+ body = purchaseBody(name, formattedAmount(input.payload["amountTotal"], input.payload["currency"]));
666
+ } catch {
667
+ logPurchase(input.siteId, "message", "failed");
668
+ return;
669
+ }
670
+ try {
671
+ await prisma.interaction.create({
672
+ data: {
673
+ workspaceId: input.siteId,
674
+ personId,
675
+ channel: "member",
676
+ direction: "outbound",
677
+ subject: PURCHASE_EMAIL_DEFAULTS.subject,
678
+ body,
679
+ createdBy: "system:checkout",
680
+ },
681
+ });
682
+ logPurchase(input.siteId, "message", "written");
683
+ } catch {
684
+ logPurchase(input.siteId, "message", "failed");
685
+ }
686
+ void sendPurchaseEmail(input, body, accountOrigin);
687
+ }
688
+
689
+ /**
690
+ * The buyer's checkout consent grant for a marked checkout lead just written
691
+ * (#8778). Never throws and never changes the lead's result.
692
+ */
693
+ async function recordSpokeCheckoutConsent(
694
+ input: SubmitInput,
695
+ personId: string,
696
+ sessionId: string,
697
+ ): Promise<void> {
698
+ const accepted = input.payload["termsAccepted"];
699
+ let outcome:
700
+ | RecordCheckoutConsentOutcome
701
+ | "consent_absent"
702
+ | "terms_unstamped"
703
+ | "failed";
704
+ let errName: string | undefined;
705
+ try {
706
+ const expected = currentAccountTermsStamp();
707
+ if (accepted === undefined || accepted === null) {
708
+ outcome = "consent_absent";
709
+ } else if (expected === null || accepted !== expected.stamp) {
710
+ outcome = "terms_unstamped";
711
+ } else {
712
+ outcome = await recordCheckoutConsent({
713
+ workspaceId: input.siteId,
714
+ personId,
715
+ sessionId,
716
+ policyVersion: expected.policyVersion,
717
+ });
718
+ }
719
+ } catch (err) {
720
+ // The error's class only (a Prisma error's message can carry values).
721
+ outcome = "failed";
722
+ errName = err instanceof Error ? err.name : "UnknownError";
723
+ }
724
+ const line = {
725
+ event: "checkout-consent",
726
+ outcome,
727
+ siteId: input.siteId,
728
+ ...(errName !== undefined && { errName }),
729
+ };
730
+ if (outcome === "failed") logger.error(line, "checkout terms grant failed");
731
+ else logger.info(line, "checkout terms grant");
732
+ }
733
+
415
734
  async function persistToDb(
416
735
  input: RouteLeadInput,
417
736
  idempotencyKey?: string,
418
737
  viaSpoke = false,
419
738
  relayed = false,
420
739
  contact?: LeadContact,
740
+ accountOrigin?: string,
421
741
  ): Promise<PersistResult> {
422
742
  if (input.kind === "submit") {
423
743
  const personId = await resolveLeadPersonId(input, contact);
@@ -473,6 +793,14 @@ async function persistToDb(
473
793
  // #7877 D10 — only after THIS request's own write, so a replay sets nothing.
474
794
  if (candidate !== null && personId !== null) {
475
795
  await applyCheckoutPlan(input, personId, candidate);
796
+ // #8757 D14 — the marked lead only: live, unrelayed, this write.
797
+ if (spokeCheckout) {
798
+ await recordPurchase(input, personId, candidate, accountOrigin);
799
+ // #8778 — a marked lead's key is its live Checkout Session id.
800
+ if (idempotencyKey !== undefined) {
801
+ await recordSpokeCheckoutConsent(input, personId, idempotencyKey);
802
+ }
803
+ }
476
804
  }
477
805
  return { ok: true, id: row.id, source: "db", skipEmail: false, personId };
478
806
  }
@@ -836,6 +1164,7 @@ export async function routeLead(
836
1164
  sourceMeta.viaSpoke === true,
837
1165
  sourceMeta.relayed === true,
838
1166
  input.kind === "submit" ? options.contact : undefined,
1167
+ input.kind === "submit" ? options.accountOrigin : undefined,
839
1168
  );
840
1169
  // #7869 D5 — a lost race answers like the lookup above: no capture
841
1170
  // record, no notification.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * What happens after someone submits a magnet (#8757, spec
3
+ * docs/specs/8757-a-per-magnet-completion-setting-spec.md D6).
4
+ *
5
+ * `POST /v1/leads/submit` calls `magnetCompletionFor` after a `flow:<slug>`
6
+ * lead is written to the database. It reads the LIVE magnet at that slug
7
+ * (`lib/flow-completion-store.ts`) and answers the 201's `next`:
8
+ *
9
+ * - `lead`, a missing or a draft magnet → `null` (the 201 is unchanged);
10
+ * - `invite` → `{ completion: "invite" }`, only where the caller has an
11
+ * account to sign in to (`accountTargetFor`); else `null` (NFR4);
12
+ * - `checkout` → `{ completion: "checkout", checkoutOffering }`, re-checked
13
+ * at submit time (SEC-8757-E): the `commerce` claim must read `claimed`
14
+ * and the offering must still be listed. Anything else is `null`.
15
+ *
16
+ * `sendInviteConfirmation` is the invite email: one `sendDeliverableEmail` to
17
+ * the lead's address whose one link (`downloadLink`, labelled by `cta`) starts
18
+ * sign-in with that address prefilled. It never signs anyone in (NFR2). Two
19
+ * caps run first, counted in the database (SEC-8757-A, SEC-8757-B): one email
20
+ * per recipient per magnet per day — which also stops the second email of an
21
+ * unkeyed double submit — and fifty per magnet per hour. A capped send still
22
+ * leaves the page's link in place. Every outcome is one
23
+ * `{ event: "magnet-invite", outcome }` line that never names the address.
24
+ *
25
+ * Where the link points (`accountTargetFor`): a keyless caller gets
26
+ * `FRONTEND_URL`'s origin with the site sign-in path, and only on a build that
27
+ * has an account area (`workspace` or `multi-tenant`); a spoke gets the origin
28
+ * of its enabled sign-in client's registered callback with the hub sign-in
29
+ * path, `https:` unless `NODE_ENV` is `development` (SEC-8757-I). The origin is never
30
+ * taken from the request.
31
+ *
32
+ * Base tree (SEC-8757-D, NFR6): no `oss: false` marker and no `MODULE_SLICES`
33
+ * entry, and it imports none — the never-gate lead route imports it. So the
34
+ * sender is resolved here (`inviteSenderAddress`, the same rule as
35
+ * `deliverable-fulfillment.ts`'s `deliverableSenderAddress`, which
36
+ * `module-architecture.ts` holds out of a slice only until #5364), and
37
+ * `@working-theory/email` — the templates the lead route otherwise never
38
+ * loads — is imported on the first send, not with the route.
39
+ */
40
+ import { logger } from "@working-theory/observability";
41
+ import {
42
+ accountInvitePath,
43
+ completionEmailSchema,
44
+ INVITE_EMAIL_DEFAULTS,
45
+ INVITE_EMAILS_PER_RECIPIENT_PER_DAY,
46
+ INVITE_EMAILS_PER_SITE_PER_HOUR,
47
+ type AccountInviteProvider,
48
+ type CompletionEmailConfig,
49
+ type LeadSubmitNext,
50
+ } from "@working-theory/validation";
51
+
52
+ import { isModuleEnabled } from "../config/modules.js";
53
+ import { inviteSendCounts, liveCompletionBySlug } from "../lib/flow-completion-store.js";
54
+ import { findClientByClientId } from "../lib/hub-sign-in-store.js";
55
+ import { resolveCommerceClaim } from "../lib/require-commerce-claim.js";
56
+
57
+ import { listPlanOfferings } from "./plan-offerings.js";
58
+ import type { CallerSite } from "./public-site-resolver.js";
59
+
60
+ /**
61
+ * The invite email's sender: `AILK_DELIVERABLE_EMAIL_FROM`, else
62
+ * `AILK_LEAD_NOTIFICATION_FROM` — every completion email's sender. `||`, not
63
+ * `??`: an env var set to "" names no sender.
64
+ */
65
+ export function inviteSenderAddress(): string | undefined {
66
+ return (
67
+ process.env.AILK_DELIVERABLE_EMAIL_FROM ||
68
+ process.env.AILK_LEAD_NOTIFICATION_FROM ||
69
+ undefined
70
+ );
71
+ }
72
+
73
+ /** Where an account link starts sign-in: the site's origin and its sign-in path. */
74
+ export interface AccountTarget {
75
+ origin: string;
76
+ provider: AccountInviteProvider;
77
+ }
78
+
79
+ /** The origin of `raw` when it is `https:` (or `http:` in development), else `null`. */
80
+ function linkOrigin(raw: string | undefined): string | null {
81
+ if (!raw) return null;
82
+ let url: URL;
83
+ try {
84
+ url = new URL(raw);
85
+ } catch {
86
+ return null;
87
+ }
88
+ if (url.protocol === "https:") return url.origin;
89
+ if (url.protocol === "http:" && process.env.NODE_ENV === "development") return url.origin;
90
+ return null;
91
+ }
92
+
93
+ /** Where the caller's account link points, or `null` when it has nowhere to sign in. */
94
+ export async function accountTargetFor(
95
+ caller: Exclude<CallerSite, { kind: "refused" }>,
96
+ ): Promise<AccountTarget | null> {
97
+ if (caller.kind === "spoke") {
98
+ const client = await findClientByClientId(caller.spokeId);
99
+ if (!client?.enabled) return null;
100
+ const origin = linkOrigin(client.redirectUris[0]);
101
+ return origin === null ? null : { origin, provider: "hub" };
102
+ }
103
+ // A build with no account area has no `/account` to land on.
104
+ if (!isModuleEnabled("workspace") && !isModuleEnabled("multi-tenant")) return null;
105
+ const origin = linkOrigin(process.env.FRONTEND_URL);
106
+ return origin === null ? null : { origin, provider: "site" };
107
+ }
108
+
109
+ /** A submit's completion: the 201's `next`, plus what the invite email needs. */
110
+ export interface MagnetCompletion {
111
+ next: LeadSubmitNext;
112
+ title: string;
113
+ config: unknown;
114
+ target: AccountTarget | null;
115
+ }
116
+
117
+ /**
118
+ * How long the anonymous submit waits on `magnetCompletionFor` before it
119
+ * answers without `next`: the lead is already written, and a slow licence or
120
+ * content read must not turn it into a client-side timeout and a resubmit.
121
+ */
122
+ export const MAGNET_COMPLETION_BUDGET_MS = 1500;
123
+
124
+ /** `magnetCompletionFor`, or `null` once `MAGNET_COMPLETION_BUDGET_MS` has passed. */
125
+ export async function magnetCompletionWithinBudget(
126
+ ...args: Parameters<typeof magnetCompletionFor>
127
+ ): Promise<MagnetCompletion | null> {
128
+ let timer: NodeJS.Timeout | undefined;
129
+ const budget = new Promise<null>((resolve) => {
130
+ timer = setTimeout(() => resolve(null), MAGNET_COMPLETION_BUDGET_MS);
131
+ });
132
+ try {
133
+ return await Promise.race([magnetCompletionFor(...args), budget]);
134
+ } finally {
135
+ clearTimeout(timer);
136
+ }
137
+ }
138
+
139
+ /** The completion of a `flow:<slug>` lead just written to `workspaceId`, or `null` for `lead`. */
140
+ export async function magnetCompletionFor(
141
+ workspaceId: string,
142
+ slug: string,
143
+ resolveTarget: () => Promise<AccountTarget | null>,
144
+ ): Promise<MagnetCompletion | null> {
145
+ const magnet = await liveCompletionBySlug(workspaceId, slug);
146
+ if (magnet === null) return null;
147
+ const { title, config } = magnet;
148
+ if (magnet.completion === "invite") {
149
+ const target = await resolveTarget();
150
+ return target === null ? null : { next: { completion: "invite" }, title, config, target };
151
+ }
152
+ if (magnet.completion === "checkout") {
153
+ // A claim or offerings read that fails is never a pass: it degrades to `lead`.
154
+ if ((await resolveCommerceClaim()) !== "claimed") return null;
155
+ const offerings = await listPlanOfferings(workspaceId);
156
+ if (!Array.isArray(offerings)) return null;
157
+ const offering = magnet.checkoutOffering;
158
+ if (!offerings.some((item) => item.key === offering)) return null;
159
+ return { next: { completion: "checkout", checkoutOffering: offering }, title, config, target: null };
160
+ }
161
+ return null;
162
+ }
163
+
164
+ export type InviteOutcome =
165
+ | "sent"
166
+ | "send_failed"
167
+ | "capped_recipient"
168
+ | "capped_site"
169
+ | "sender_unconfigured"
170
+ | "no_link";
171
+
172
+ export interface InviteConfirmationInput {
173
+ workspaceId: string;
174
+ slug: string;
175
+ email: string;
176
+ locale: string | undefined;
177
+ completion: MagnetCompletion;
178
+ now: Date;
179
+ }
180
+
181
+ const fill = (template: string, title: string) => template.replaceAll("{title}", title);
182
+
183
+ /**
184
+ * `copy[locale] ?? copy[defaultLocale]`, the completion email's own
185
+ * resolution. Own keys only: `locale` comes from the anonymous payload, and
186
+ * `"constructor"` must not read an inherited member.
187
+ */
188
+ function resolveCopy(copy: Record<string, string>, locale: string, defaultLocale: string) {
189
+ const own = (key: string) => (Object.hasOwn(copy, key) ? copy[key] : undefined);
190
+ return own(locale) ?? own(defaultLocale) ?? Object.values(copy)[0] ?? "";
191
+ }
192
+
193
+ function declaredCopy(config: unknown): { copy: CompletionEmailConfig; defaultLocale: string } | null {
194
+ if (typeof config !== "object" || config === null) return null;
195
+ const record = config as { completionEmail?: unknown; defaultLocale?: unknown };
196
+ const parsed = completionEmailSchema.safeParse(record.completionEmail);
197
+ if (!parsed.success) return null;
198
+ const defaultLocale = typeof record.defaultLocale === "string" ? record.defaultLocale : "en";
199
+ return { copy: parsed.data, defaultLocale };
200
+ }
201
+
202
+ /**
203
+ * The invite email (D6). Never throws and never names the address in a log;
204
+ * the caller fires it with `void` after a first, unreplayed write.
205
+ */
206
+ export async function sendInviteConfirmation(input: InviteConfirmationInput): Promise<InviteOutcome> {
207
+ const outcome = await send().catch(() => "send_failed" as const);
208
+ logger.info({ event: "magnet-invite", outcome }, "[magnet-completion] invite email");
209
+ return outcome;
210
+
211
+ async function send(): Promise<InviteOutcome> {
212
+ const { workspaceId, slug, email, locale, completion, now } = input;
213
+ if (completion.target === null) return "no_link";
214
+ const counts = await inviteSendCounts(workspaceId, slug, email, now);
215
+ if (counts.recipientDay > INVITE_EMAILS_PER_RECIPIENT_PER_DAY) return "capped_recipient";
216
+ if (counts.siteHour > INVITE_EMAILS_PER_SITE_PER_HOUR) return "capped_site";
217
+ const fromAddress = inviteSenderAddress();
218
+ if (!fromAddress) return "sender_unconfigured";
219
+
220
+ let downloadLink: string;
221
+ try {
222
+ downloadLink = completion.target.origin + accountInvitePath(completion.target.provider, email);
223
+ } catch {
224
+ return "no_link";
225
+ }
226
+ const declared = declaredCopy(completion.config);
227
+ const lang = locale ?? declared?.defaultLocale ?? "en";
228
+ const subject = declared
229
+ ? resolveCopy(declared.copy.subject, lang, declared.defaultLocale)
230
+ : fill(INVITE_EMAIL_DEFAULTS.subject, completion.title);
231
+ const body = declared
232
+ ? resolveCopy(declared.copy.body, lang, declared.defaultLocale)
233
+ : fill(INVITE_EMAIL_DEFAULTS.body, completion.title);
234
+ const cta = declared?.copy.cta
235
+ ? resolveCopy(declared.copy.cta, lang, declared.defaultLocale)
236
+ : INVITE_EMAIL_DEFAULTS.cta;
237
+
238
+ const { sendDeliverableEmail } = await import("@working-theory/email");
239
+ const result = await sendDeliverableEmail({ to: email, fromAddress, subject, body, cta, downloadLink });
240
+ return result.ok ? "sent" : "send_failed";
241
+ }
242
+ }