create-ailk 0.19.0 → 0.20.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 (197) hide show
  1. package/README.md +64 -7
  2. package/dist/add-module.js +7 -2
  3. package/dist/cli.js +35 -0
  4. package/dist/copy.d.ts +41 -0
  5. package/dist/copy.js +87 -21
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  9. package/dist/lib/fetch-template-snapshot.js +205 -0
  10. package/dist/lib/merge-three-way.d.ts +31 -0
  11. package/dist/lib/merge-three-way.js +65 -0
  12. package/dist/lib/scaffold-render-sweep.d.ts +23 -0
  13. package/dist/lib/scaffold-render-sweep.js +52 -0
  14. package/dist/module-architecture.js +64 -5
  15. package/dist/programmatic.d.ts +7 -1
  16. package/dist/programmatic.js +40 -3
  17. package/dist/site-roster.js +21 -4
  18. package/dist/spoke-checkout.d.ts +7 -0
  19. package/dist/spoke-checkout.js +8 -1
  20. package/dist/surfaces.d.ts +14 -0
  21. package/dist/surfaces.js +98 -3
  22. package/dist/template-record.d.ts +59 -0
  23. package/dist/template-record.js +183 -0
  24. package/dist/topology.js +29 -1
  25. package/dist/update.d.ts +48 -0
  26. package/dist/update.js +504 -0
  27. package/package.json +3 -3
  28. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  29. package/templates/.claude/hooks/architecture-check.sh +8 -0
  30. package/templates/.claude/rules/loop-contract.md +129 -0
  31. package/templates/.claude/settings.json +3 -2
  32. package/templates/.env.example +13 -0
  33. package/templates/CLAUDE.md +1 -1
  34. package/templates/CONVENTIONS.md +3 -2
  35. package/templates/_gitignore +162 -0
  36. package/templates/apps/api/.env.example +17 -1
  37. package/templates/apps/api/CLAUDE.md +18 -8
  38. package/templates/apps/api/package.json +2 -1
  39. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
  40. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  41. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  42. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  43. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  44. package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
  45. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  46. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  47. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  48. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  49. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  50. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  51. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  52. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  53. package/templates/apps/api/src/lib/stripe.ts +97 -5
  54. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
  55. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  56. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  57. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  58. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
  59. package/templates/apps/api/src/middleware/auth.ts +16 -4
  60. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  61. package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
  62. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  63. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  64. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  65. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  67. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  68. package/templates/apps/api/src/routes/leads/index.ts +15 -0
  69. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  70. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  71. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  73. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  74. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  75. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  76. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  77. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  78. package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
  79. package/templates/apps/api/src/server.ts +125 -18
  80. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  81. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  82. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  83. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
  84. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  85. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  86. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  87. package/templates/apps/api/src/services/audit-log.ts +72 -10
  88. package/templates/apps/api/src/services/consent.ts +488 -3
  89. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  90. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  91. package/templates/apps/api/src/services/lead-routing.ts +105 -12
  92. package/templates/apps/api/src/services/person.ts +501 -1
  93. package/templates/apps/api/src/services/project-listings.ts +7 -2
  94. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  95. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  96. package/templates/apps/api/src/services/revalidation.ts +11 -1
  97. package/templates/apps/api/vercel.json +1 -1
  98. package/templates/apps/mcp/CLAUDE.md +19 -7
  99. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  100. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  101. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  102. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  103. package/templates/apps/web/_gitignore +6 -0
  104. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  105. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  106. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  107. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
  117. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
  120. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
  122. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  129. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  132. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
  134. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
  137. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
  138. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
  139. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  141. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  142. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  143. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
  146. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  148. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
  149. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
  150. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  151. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  152. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  153. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  154. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  155. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  156. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  157. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  158. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  159. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  160. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  161. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  162. package/templates/apps/web/auth.ts +20 -1
  163. package/templates/apps/web/jest.config.cjs +3 -0
  164. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  165. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  166. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  167. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  168. package/templates/apps/web/lib/account-config.ts +31 -16
  169. package/templates/apps/web/lib/auth-email.ts +45 -10
  170. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  171. package/templates/apps/web/lib/relay.ts +20 -0
  172. package/templates/apps/web/lib/topology.generated.ts +9 -0
  173. package/templates/apps/web/messages/en.json +121 -18
  174. package/templates/apps/web/next.config.mjs +8 -2
  175. package/templates/database/CHANGELOG.md +55 -0
  176. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  177. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  178. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  179. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  180. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  181. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  182. package/templates/database/__tests__/person.db.test.ts +3 -1
  183. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  184. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  185. package/templates/database/_gitignore +8 -0
  186. package/templates/database/inbox/schema.prisma +114 -22
  187. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  188. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  189. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  190. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  191. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  192. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  193. package/templates/database/ops/schema.prisma +16 -0
  194. package/templates/database/package.json +1 -1
  195. package/templates/project.yaml +6 -3
  196. package/versions.json +35 -35
  197. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -0,0 +1,563 @@
1
+ /**
2
+ * @file person-contact.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof of the contact columns on the client record (#8435,
5
+ * spec docs/specs/8435-contact-details-on-the-client-spec.md D1, D2; AC-1 and
6
+ * the backfill half of AC-3). Requires DATABASE_URL pointing at a Postgres
7
+ * with the migrations applied — every live suite SKIPS when DATABASE_URL is
8
+ * unset, keeping the workspace Jest run DB-free (the repo convention; the
9
+ * `apps/api Route Tests (Postgres)` workflow exercises it).
10
+ *
11
+ * Uses the `pg` driver directly, as `person.db.test.ts` does. What it proves
12
+ * that a mock cannot:
13
+ * 1. The people table's four new CHECKs (E.164 telephone, alpha-2 country,
14
+ * whole-or-nothing address, the confirmation pair) and the two new
15
+ * composite foreign keys (callbacks.person_id, subscribers.person_id).
16
+ * 2. The backfill: the committed fixture, backfilled by the migration's own
17
+ * text, yields exactly the expected Persons and stamps; the post-cutover
18
+ * script is byte-identical, changes nothing on a second run, and stamps
19
+ * a subscriber written after the first.
20
+ *
21
+ * Two cases need no database and always run: the script and the migration
22
+ * are byte-identical, and the Person model carries no deferred column.
23
+ */
24
+ import { readFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+
27
+ import { Client } from "pg";
28
+
29
+ type Fixture = {
30
+ workspaces: Record<string, string>;
31
+ input: {
32
+ people: Array<{
33
+ key: string;
34
+ workspace: string;
35
+ email: string;
36
+ name: string | null;
37
+ user: string | null;
38
+ }>;
39
+ subscribers: Array<{
40
+ key: string;
41
+ workspace: string;
42
+ email: string;
43
+ createdAt: string;
44
+ }>;
45
+ callbacks: Array<{
46
+ key: string;
47
+ workspace: string;
48
+ name: string;
49
+ phone: string;
50
+ payload: Record<string, unknown>;
51
+ buyerEmail: string | null;
52
+ createdAt: string;
53
+ }>;
54
+ };
55
+ expected: {
56
+ people: Array<{
57
+ workspace: string;
58
+ email: string;
59
+ name: string | null;
60
+ telephone: string | null;
61
+ }>;
62
+ stamps: Record<string, { workspace: string; email: string } | null>;
63
+ };
64
+ };
65
+
66
+ const fixture = JSON.parse(
67
+ readFileSync(
68
+ join(__dirname, "fixtures", "person-contact-backfill.json"),
69
+ "utf8",
70
+ ),
71
+ ) as Fixture;
72
+
73
+ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
74
+
75
+ const MIGRATION = join(
76
+ __dirname,
77
+ "..",
78
+ "migrations",
79
+ "20261014120000_person_contact",
80
+ "migration.sql",
81
+ );
82
+ const BACKFILL_MARK =
83
+ "-- ===== #8435 contact backfill (idempotent; runs after the DDL above) =====";
84
+
85
+ /** The backfill half of the single person_contact migration. */
86
+ function backfillSql(): string {
87
+ const text = readFileSync(MIGRATION, "utf8");
88
+ const at = text.indexOf(BACKFILL_MARK);
89
+ if (at < 0) throw new Error("the backfill marker is missing from the migration");
90
+ return text.slice(at);
91
+ }
92
+
93
+ describe("the Person contact files (#8435 D1, D2)", () => {
94
+ it("the backfill is the second half of the single person_contact migration", () => {
95
+ const text = readFileSync(MIGRATION, "utf8");
96
+ const at = text.indexOf(BACKFILL_MARK);
97
+ expect(at).toBeGreaterThan(0);
98
+ expect(text.slice(0, at)).toMatch(/ALTER TABLE "people"/);
99
+ expect(text.slice(at)).not.toMatch(/ALTER TABLE/);
100
+ });
101
+
102
+ it("the Person model carries no deferred column (map, second phone, company id)", () => {
103
+ const schema = readFileSync(
104
+ join(__dirname, "..", "inbox", "schema.prisma"),
105
+ "utf8",
106
+ );
107
+ const block = /^model Person \{[\s\S]*?^\}/m.exec(schema)?.[0] ?? "";
108
+ expect(block).not.toBe("");
109
+ expect(block).not.toMatch(/latitude|longitude|placeId|place_id|companyId/);
110
+ expect(schema).not.toMatch(/^model Company /m);
111
+ });
112
+ });
113
+
114
+ describeDb("the contact columns (#8435 D1, AC-1, live DB)", () => {
115
+ let db: Client;
116
+ const WS_1 = "wksp_01920000-8435-7000-8000-0000000000c1";
117
+ const WS_2 = "wksp_01920000-8435-7000-8000-0000000000c2";
118
+ const WORKSPACES = [WS_1, WS_2];
119
+
120
+ async function cleanup(): Promise<void> {
121
+ await db.query(`DELETE FROM "callbacks" WHERE "workspace_id" = ANY($1)`, [
122
+ WORKSPACES,
123
+ ]);
124
+ await db.query(`DELETE FROM "subscribers" WHERE "workspace_id" = ANY($1)`, [
125
+ WORKSPACES,
126
+ ]);
127
+ await db.query(`DELETE FROM "people" WHERE "workspace_id" = ANY($1)`, [
128
+ WORKSPACES,
129
+ ]);
130
+ }
131
+
132
+ beforeAll(async () => {
133
+ db = new Client({ connectionString: process.env.DATABASE_URL });
134
+ await db.connect();
135
+ await cleanup();
136
+ });
137
+
138
+ afterAll(async () => {
139
+ await cleanup();
140
+ await db.end();
141
+ });
142
+
143
+ let counter = 0;
144
+ const email = () => `p8435-${(counter += 1)}@example.com`;
145
+
146
+ type Cols = Record<string, unknown>;
147
+
148
+ /** Insert one Person; `cols` maps column name → value on top of the required pair. */
149
+ const insertPerson = async (cols: Cols = {}, workspace = WS_1) => {
150
+ const all: Cols = {
151
+ workspace_id: workspace,
152
+ email: email(),
153
+ updated_at: new Date(),
154
+ ...cols,
155
+ };
156
+ const names = Object.keys(all);
157
+ return db.query<{ id: string }>(
158
+ `INSERT INTO "people" (${names.map((n) => `"${n}"`).join(", ")})
159
+ VALUES (${names.map((_, i) => `$${i + 1}`).join(", ")}) RETURNING "id"`,
160
+ names.map((n) => all[n]),
161
+ );
162
+ };
163
+
164
+ const FULL = {
165
+ given_name: "Ada",
166
+ family_name: "Lovelace",
167
+ telephone: "+14155550123",
168
+ address_line1: "1 Analytical Way",
169
+ address_line2: "Suite 2",
170
+ address_city: "London",
171
+ address_region: "LDN",
172
+ address_postal_code: "N1 1AA",
173
+ address_country: "GB",
174
+ company_name: "Engines Ltd",
175
+ job_title: "Countess",
176
+ contact_confirmed_at: new Date("2026-10-12T00:00:00.000Z"),
177
+ contact_confirmed_by: "client",
178
+ };
179
+
180
+ it("a Person with all thirteen columns set inserts and reads back", async () => {
181
+ const { rows } = await insertPerson(FULL);
182
+ const read = await db.query(`SELECT * FROM "people" WHERE "id" = $1`, [
183
+ rows[0]!.id,
184
+ ]);
185
+ expect(read.rows[0]).toMatchObject({
186
+ given_name: "Ada",
187
+ family_name: "Lovelace",
188
+ telephone: "+14155550123",
189
+ address_line1: "1 Analytical Way",
190
+ address_line2: "Suite 2",
191
+ address_city: "London",
192
+ address_region: "LDN",
193
+ address_postal_code: "N1 1AA",
194
+ address_country: "GB",
195
+ company_name: "Engines Ltd",
196
+ job_title: "Countess",
197
+ contact_confirmed_by: "client",
198
+ });
199
+ });
200
+
201
+ it.each([
202
+ ["a stamp with no timestamp", { contact_confirmed_by: "client" }],
203
+ [
204
+ "an actor outside the two",
205
+ { contact_confirmed_at: new Date(), contact_confirmed_by: "agent" },
206
+ ],
207
+ ["a timestamp with no actor", { contact_confirmed_at: new Date() }],
208
+ ])("refuses %s (people_contact_confirmed)", async (_label, cols) => {
209
+ await expect(insertPerson(cols)).rejects.toMatchObject({
210
+ code: "23514",
211
+ constraint: "people_contact_confirmed",
212
+ });
213
+ });
214
+
215
+ it.each([
216
+ ["a national number", "4155550123"],
217
+ ["a leading +0", "+0123456789"],
218
+ ["spaces", "+1 415 555 0123"],
219
+ ["a letter inside", "+1415555012a"],
220
+ ])(
221
+ "refuses a telephone with %s (people_telephone_e164)",
222
+ async (_label, telephone) => {
223
+ await expect(insertPerson({ telephone })).rejects.toMatchObject({
224
+ code: "23514",
225
+ constraint: "people_telephone_e164",
226
+ });
227
+ },
228
+ );
229
+
230
+ it("accepts an E.164 telephone and NULL", async () => {
231
+ await expect(
232
+ insertPerson({ telephone: "+442071234567" }),
233
+ ).resolves.toHaveProperty("rowCount", 1);
234
+ await expect(insertPerson({ telephone: null })).resolves.toHaveProperty(
235
+ "rowCount",
236
+ 1,
237
+ );
238
+ });
239
+
240
+ it.each([
241
+ ["lower case", "us"],
242
+ ["a digit", "U1"],
243
+ ])(
244
+ "refuses a country in %s (people_address_country_format)",
245
+ async (_label, address_country) => {
246
+ await expect(
247
+ insertPerson({
248
+ address_line1: "1 Main",
249
+ address_city: "Austin",
250
+ address_region: "TX",
251
+ address_postal_code: "78701",
252
+ address_country,
253
+ }),
254
+ ).rejects.toMatchObject({
255
+ code: "23514",
256
+ constraint: "people_address_country_format",
257
+ });
258
+ },
259
+ );
260
+
261
+ it("refuses a sixteen-digit telephone and a three-letter country by column width", async () => {
262
+ await expect(
263
+ insertPerson({ telephone: "+1234567890123456" }),
264
+ ).rejects.toMatchObject({ code: "22001" });
265
+ await expect(
266
+ insertPerson({
267
+ address_line1: "1 Main",
268
+ address_city: "Austin",
269
+ address_region: "TX",
270
+ address_postal_code: "78701",
271
+ address_country: "USA",
272
+ }),
273
+ ).rejects.toMatchObject({ code: "22001" });
274
+ });
275
+
276
+ it("refuses a half-written address (people_address_complete)", async () => {
277
+ await expect(insertPerson({ address_country: "US" })).rejects.toMatchObject(
278
+ {
279
+ code: "23514",
280
+ constraint: "people_address_complete",
281
+ },
282
+ );
283
+ await expect(
284
+ insertPerson({
285
+ address_line1: "1 Main",
286
+ address_city: "Austin",
287
+ address_region: "TX",
288
+ address_country: "US",
289
+ }),
290
+ ).rejects.toMatchObject({
291
+ code: "23514",
292
+ constraint: "people_address_complete",
293
+ });
294
+ });
295
+
296
+ it("accepts an address with all six NULL, and one with five set and address_line2 NULL", async () => {
297
+ await expect(insertPerson({})).resolves.toHaveProperty("rowCount", 1);
298
+ await expect(
299
+ insertPerson({
300
+ address_line1: "1 Main",
301
+ address_line2: null,
302
+ address_city: "Austin",
303
+ address_region: "TX",
304
+ address_postal_code: "78701",
305
+ address_country: "US",
306
+ }),
307
+ ).resolves.toHaveProperty("rowCount", 1);
308
+ });
309
+
310
+ const insertCallback = (workspace: string, personId: string | null) =>
311
+ db.query(
312
+ `INSERT INTO "callbacks" ("site_id", "workspace_id", "name", "phone", "payload", "person_id")
313
+ VALUES ($1, $1, 'Cal', '+14155550123', '{}', $2)`,
314
+ [workspace, personId],
315
+ );
316
+ const insertSubscriber = (workspace: string, personId: string | null) =>
317
+ db.query(
318
+ `INSERT INTO "subscribers" ("site_id", "workspace_id", "email", "source", "person_id")
319
+ VALUES ($1, $1, $2, 'p8435', $3)`,
320
+ [workspace, email(), personId],
321
+ );
322
+
323
+ it.each([
324
+ ["callbacks", "callbacks_person_fk", insertCallback],
325
+ ["subscribers", "subscribers_person_fk", insertSubscriber],
326
+ ] as const)(
327
+ "%s.person_id accepts a Person of the row's workspace and NULL, refuses another workspace's (%s)",
328
+ async (_table, constraint, insert) => {
329
+ const own = (await insertPerson({}, WS_1)).rows[0]!.id;
330
+ const foreign = (await insertPerson({}, WS_2)).rows[0]!.id;
331
+ await expect(insert(WS_1, own)).resolves.toHaveProperty("rowCount", 1);
332
+ await expect(insert(WS_1, null)).resolves.toHaveProperty("rowCount", 1);
333
+ await expect(insert(WS_1, foreign)).rejects.toMatchObject({
334
+ code: "23503",
335
+ constraint,
336
+ });
337
+ },
338
+ );
339
+
340
+ it.each([
341
+ ["callbacks", "callbacks_person_id_format", insertCallback],
342
+ ["subscribers", "subscribers_person_id_format", insertSubscriber],
343
+ ] as const)(
344
+ "%s.person_id refuses a lead_ id (%s)",
345
+ async (_table, constraint, insert) => {
346
+ await expect(
347
+ insert(WS_1, "lead_01920000-8435-7000-8000-0000000000c9"),
348
+ ).rejects.toMatchObject({
349
+ code: "23514",
350
+ constraint,
351
+ });
352
+ },
353
+ );
354
+
355
+ it("refuses to delete a Person a callback or a subscriber names (RESTRICT)", async () => {
356
+ const callbackPerson = (await insertPerson({}, WS_1)).rows[0]!.id;
357
+ await insertCallback(WS_1, callbackPerson);
358
+ await expect(
359
+ db.query(`DELETE FROM "people" WHERE "id" = $1`, [callbackPerson]),
360
+ ).rejects.toMatchObject({
361
+ code: "23503",
362
+ constraint: "callbacks_person_fk",
363
+ });
364
+ const subscriberPerson = (await insertPerson({}, WS_1)).rows[0]!.id;
365
+ await insertSubscriber(WS_1, subscriberPerson);
366
+ await expect(
367
+ db.query(`DELETE FROM "people" WHERE "id" = $1`, [subscriberPerson]),
368
+ ).rejects.toMatchObject({
369
+ code: "23503",
370
+ constraint: "subscribers_person_fk",
371
+ });
372
+ });
373
+ });
374
+
375
+ describeDb("the contact backfill (#8435 D2, AC-3, live DB)", () => {
376
+ let db: Client;
377
+ const WS = fixture.workspaces;
378
+ const WORKSPACES = Object.values(WS);
379
+ const subscriberIds = new Map<string, string>();
380
+ const callbackIds = new Map<string, string>();
381
+ const preIds = new Map<string, string>();
382
+
383
+ async function cleanup(): Promise<void> {
384
+ await db.query(`DELETE FROM "callbacks" WHERE "workspace_id" = ANY($1)`, [
385
+ WORKSPACES,
386
+ ]);
387
+ await db.query(`DELETE FROM "subscribers" WHERE "workspace_id" = ANY($1)`, [
388
+ WORKSPACES,
389
+ ]);
390
+ await db.query(`DELETE FROM "people" WHERE "workspace_id" = ANY($1)`, [
391
+ WORKSPACES,
392
+ ]);
393
+ }
394
+
395
+ beforeAll(async () => {
396
+ db = new Client({ connectionString: process.env.DATABASE_URL });
397
+ await db.connect();
398
+ await cleanup();
399
+
400
+ for (const p of fixture.input.people) {
401
+ const { rows } = await db.query<{ id: string }>(
402
+ `INSERT INTO "people" ("workspace_id", "email", "name", "user_id", "updated_at")
403
+ VALUES ($1, $2, $3, $4, now()) RETURNING "id"`,
404
+ [WS[p.workspace], p.email, p.name, p.user],
405
+ );
406
+ preIds.set(p.key, rows[0]!.id);
407
+ }
408
+ for (const s of fixture.input.subscribers) {
409
+ const { rows } = await db.query<{ id: string }>(
410
+ `INSERT INTO "subscribers" ("site_id", "workspace_id", "email", "source", "created_at")
411
+ VALUES ($1, $1, $2, 'p8435', $3) RETURNING "id"`,
412
+ [WS[s.workspace], s.email, s.createdAt],
413
+ );
414
+ subscriberIds.set(s.key, rows[0]!.id);
415
+ }
416
+ for (const c of fixture.input.callbacks) {
417
+ const { rows } = await db.query<{ id: string }>(
418
+ `INSERT INTO "callbacks" ("site_id", "workspace_id", "name", "phone", "payload", "buyer_email", "created_at")
419
+ VALUES ($1, $1, $2, $3, $4, $5, $6) RETURNING "id"`,
420
+ [
421
+ WS[c.workspace],
422
+ c.name,
423
+ c.phone,
424
+ JSON.stringify(c.payload),
425
+ c.buyerEmail,
426
+ c.createdAt,
427
+ ],
428
+ );
429
+ callbackIds.set(c.key, rows[0]!.id);
430
+ }
431
+
432
+ await db.query(backfillSql());
433
+ });
434
+
435
+ afterAll(async () => {
436
+ await cleanup();
437
+ await db.end();
438
+ });
439
+
440
+ type PersonRow = {
441
+ id: string;
442
+ workspace_id: string;
443
+ email: string;
444
+ name: string | null;
445
+ telephone: string | null;
446
+ };
447
+
448
+ const readPeople = async () =>
449
+ (
450
+ await db.query<PersonRow>(
451
+ `SELECT "id", "workspace_id", "email", "name", "telephone"
452
+ FROM "people" WHERE "workspace_id" = ANY($1) ORDER BY "workspace_id", "email"`,
453
+ [WORKSPACES],
454
+ )
455
+ ).rows;
456
+
457
+ const snapshot = async () => ({
458
+ people: (
459
+ await db.query(
460
+ `SELECT * FROM "people" WHERE "workspace_id" = ANY($1) ORDER BY "id"`,
461
+ [WORKSPACES],
462
+ )
463
+ ).rows,
464
+ subscribers: (
465
+ await db.query(
466
+ `SELECT "id", "person_id" FROM "subscribers" WHERE "workspace_id" = ANY($1) ORDER BY "id"`,
467
+ [WORKSPACES],
468
+ )
469
+ ).rows,
470
+ callbacks: (
471
+ await db.query(
472
+ `SELECT "id", "person_id" FROM "callbacks" WHERE "workspace_id" = ANY($1) ORDER BY "id"`,
473
+ [WORKSPACES],
474
+ )
475
+ ).rows,
476
+ });
477
+
478
+ const wsKey = (id: string) => Object.keys(WS).find((k) => WS[k] === id);
479
+
480
+ it("makes exactly the expected Persons (email, name, telephone)", async () => {
481
+ const people = await readPeople();
482
+ expect(
483
+ people.map((p) => ({
484
+ workspace: wsKey(p.workspace_id),
485
+ email: p.email,
486
+ name: p.name,
487
+ telephone: p.telephone,
488
+ })),
489
+ ).toEqual(fixture.expected.people);
490
+ });
491
+
492
+ it("keeps the pre-existing Persons' ids, names and users", async () => {
493
+ const people = await readPeople();
494
+ for (const [key, id] of preIds) {
495
+ const pre = fixture.input.people.find((p) => p.key === key)!;
496
+ const row = people.find((p) => p.id === id);
497
+ expect({ key, email: row?.email, name: row?.name }).toEqual({
498
+ key,
499
+ email: pre.email,
500
+ name: pre.name,
501
+ });
502
+ }
503
+ });
504
+
505
+ it("stamps every subscriber and callback with a storable email with its workspace's Person; the rest stay NULL", async () => {
506
+ const people = await readPeople();
507
+ const personOf = (stamp: { workspace: string; email: string } | null) =>
508
+ stamp === null
509
+ ? null
510
+ : (people.find(
511
+ (p) =>
512
+ p.workspace_id === WS[stamp.workspace] && p.email === stamp.email,
513
+ )?.id ?? "MISSING");
514
+ for (const [key, stamp] of Object.entries(fixture.expected.stamps)) {
515
+ const table = key.startsWith("S") ? "subscribers" : "callbacks";
516
+ const id = (key.startsWith("S") ? subscriberIds : callbackIds).get(key);
517
+ const { rows } = await db.query<{ person_id: string | null }>(
518
+ `SELECT "person_id" FROM "${table}" WHERE "id" = $1`,
519
+ [id],
520
+ );
521
+ expect({ key, personId: rows[0]!.person_id }).toEqual({
522
+ key,
523
+ personId: personOf(stamp),
524
+ });
525
+ }
526
+ });
527
+
528
+ it("makes no Person for the buyer_email of a callback whose payload.email is storable", async () => {
529
+ const people = await readPeople();
530
+ expect(people.find((p) => p.email === "other@example.com")).toBeUndefined();
531
+ });
532
+
533
+ it("fills a telephone only from an E.164 callback phone on an uncurated Person", async () => {
534
+ const people = await readPeople();
535
+ const tel = (email: string) =>
536
+ people.find((p) => p.email === email && p.workspace_id === WS.A)
537
+ ?.telephone;
538
+ expect(tel("both@example.com")).toBe("+14155550123");
539
+ expect(tel("buyer@example.com")).toBeNull();
540
+ expect(tel("linked@example.com")).toBeNull();
541
+ });
542
+
543
+ it("a second run of the post-cutover script changes no row, and stamps a subscriber written after the first", async () => {
544
+ const before = await snapshot();
545
+ await db.query(backfillSql());
546
+ expect(await snapshot()).toEqual(before);
547
+
548
+ const { rows } = await db.query<{ id: string }>(
549
+ `INSERT INTO "subscribers" ("site_id", "workspace_id", "email", "source")
550
+ VALUES ($1, $1, 'Both@Example.com', 'p8435-late') RETURNING "id"`,
551
+ [WS.A],
552
+ );
553
+ await db.query(backfillSql());
554
+ const stamped = await db.query<{ person_id: string | null }>(
555
+ `SELECT "person_id" FROM "subscribers" WHERE "id" = $1`,
556
+ [rows[0]!.id],
557
+ );
558
+ const people = await readPeople();
559
+ expect(stamped.rows[0]!.person_id).toBe(
560
+ people.find((p) => p.email === "both@example.com")!.id,
561
+ );
562
+ });
563
+ });