create-ailk 0.23.0 → 0.24.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 (96) hide show
  1. package/README.md +27 -14
  2. package/dist/add-module.js +6 -1
  3. package/dist/add-route.js +7 -1
  4. package/dist/derive-architecture.js +17 -0
  5. package/dist/lib/scaffold-render-sweep.d.ts +33 -0
  6. package/dist/lib/scaffold-render-sweep.js +45 -0
  7. package/dist/module-architecture.js +17 -0
  8. package/dist/sync-routes.d.ts +30 -4
  9. package/dist/sync-routes.js +271 -18
  10. package/package.json +3 -3
  11. package/templates/.env.example +4 -0
  12. package/templates/CLAUDE.md +4 -2
  13. package/templates/apps/api/.env.example +7 -0
  14. package/templates/apps/api/CLAUDE.md +25 -11
  15. package/templates/apps/api/src/lib/__mocks__/prisma.ts +4 -1
  16. package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
  17. package/templates/apps/api/src/lib/site.ts +4 -1
  18. package/templates/apps/api/src/lib/stripe.ts +3 -0
  19. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
  20. package/templates/apps/api/src/openapi/spec.ts +333 -0
  21. package/templates/apps/api/src/routes/billing/plan-checkout.ts +18 -11
  22. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +126 -0
  23. package/templates/apps/api/src/routes/content/get-page.ts +34 -119
  24. package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
  25. package/templates/apps/api/src/routes/content/search.ts +16 -45
  26. package/templates/apps/api/src/routes/leads/index.ts +364 -298
  27. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
  28. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
  29. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
  30. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
  31. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +1 -0
  32. package/templates/apps/api/src/routes/webhooks/stripe.ts +2 -0
  33. package/templates/apps/api/src/server.ts +25 -0
  34. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
  35. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +10 -0
  36. package/templates/apps/api/src/services/__tests__/person.test.ts +28 -0
  37. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +6 -0
  38. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
  39. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
  40. package/templates/apps/api/src/services/abandon-email.ts +28 -5
  41. package/templates/apps/api/src/services/deliverable-fulfillment.ts +2 -7
  42. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +53 -0
  43. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +107 -0
  44. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +4 -0
  45. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +4 -0
  46. package/templates/apps/api/src/services/offerings/catalog.ts +10 -0
  47. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +23 -5
  48. package/templates/apps/api/src/services/offerings/connection.ts +83 -3
  49. package/templates/apps/api/src/services/offerings/index.ts +6 -6
  50. package/templates/apps/api/src/services/person.ts +23 -0
  51. package/templates/apps/api/src/services/project-listings.ts +43 -31
  52. package/templates/apps/mcp/CLAUDE.md +11 -5
  53. package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
  54. package/templates/apps/mcp/__tests__/http-transport.test.ts +91 -10
  55. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
  56. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
  57. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
  58. package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
  59. package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
  60. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
  61. package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
  62. package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
  63. package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
  64. package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
  65. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
  66. package/templates/apps/mcp/package.json +1 -0
  67. package/templates/apps/mcp/src/abuse/index.ts +18 -11
  68. package/templates/apps/mcp/src/http-transport.ts +47 -9
  69. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
  70. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
  71. package/templates/apps/mcp/src/lib/site-config.ts +53 -0
  72. package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
  73. package/templates/apps/mcp/src/tools/index.ts +22 -21
  74. package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
  75. package/templates/apps/mcp/src/tools/read_content.ts +46 -40
  76. package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
  77. package/templates/apps/mcp/src/tools/search_content.ts +20 -24
  78. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
  79. package/templates/apps/web/CLAUDE.md +12 -3
  80. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
  81. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +14 -4
  82. package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
  83. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +21 -4
  84. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +7 -0
  85. package/templates/apps/web/lib/magnet-routes.ts +3 -1
  86. package/templates/apps/web/lib/newsletter.actions.ts +9 -6
  87. package/templates/database/CHANGELOG.md +20 -0
  88. package/templates/database/__tests__/seed.test.ts +33 -1
  89. package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
  90. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
  91. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
  92. package/templates/database/package.json +1 -1
  93. package/templates/database/seed.ts +27 -0
  94. package/templates/tools/lint-architecture.ts +205 -1
  95. package/versions.json +34 -34
  96. package/templates/apps/api/src/routes/content/slug.ts +0 -9
@@ -50,7 +50,7 @@ jest.mock("../../../lib/prisma.js", () => ({
50
50
  },
51
51
  user: { findUnique: jest.fn() },
52
52
  projectListing: { findMany: jest.fn() },
53
- waitlistScore: { count: jest.fn() },
53
+ waitlistScore: { groupBy: jest.fn() },
54
54
  },
55
55
  }));
56
56
 
@@ -258,9 +258,16 @@ function installFixtureMocks(): void {
258
258
  },
259
259
  );
260
260
 
261
- (prisma.waitlistScore.count as jest.Mock).mockImplementation(
262
- ({ where }: { where: { siteId: string; experimentKey: string } }) =>
263
- Promise.resolve(WAITLIST_COUNTS[where.siteId]?.[where.experimentKey] ?? 0),
261
+ (prisma.waitlistScore.groupBy as jest.Mock).mockImplementation(
262
+ ({ where }: { where: { siteId: string; experimentKey: { in: string[] } } }) =>
263
+ Promise.resolve(
264
+ where.experimentKey.in
265
+ .filter((experimentKey) => (WAITLIST_COUNTS[where.siteId]?.[experimentKey] ?? 0) > 0)
266
+ .map((experimentKey) => ({
267
+ experimentKey,
268
+ _count: { _all: WAITLIST_COUNTS[where.siteId]![experimentKey] },
269
+ })),
270
+ ),
264
271
  );
265
272
  }
266
273
 
@@ -378,7 +385,7 @@ describe("GET /v1/project-listings/me — the empty case is not a refusal", () =
378
385
  it("no waitlist count is read when the caller owns nothing", async () => {
379
386
  await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_cleo") });
380
387
 
381
- expect(prisma.waitlistScore.count).not.toHaveBeenCalled();
388
+ expect(prisma.waitlistScore.groupBy).not.toHaveBeenCalled();
382
389
  });
383
390
  });
384
391
 
@@ -419,13 +426,17 @@ describe("GET /v1/project-listings/me — the response shape", () => {
419
426
  // One shared site id would have reported 0 for one of them.
420
427
  expect(byId["prjl_ada_one"]).toBe(7);
421
428
  expect(byId["prjl_ada_two"]).toBe(3);
422
- expect(prisma.waitlistScore.count).toHaveBeenCalledWith({
429
+ expect(prisma.waitlistScore.groupBy).toHaveBeenCalledWith({
430
+ by: ["experimentKey"],
423
431
  // #6258 AC-6 — the fenced read now filters the CONSTRAINED column beside
424
432
  // `siteId`; the two are equal by the workspace_site_agree CHECK.
425
- where: { siteId: SITE_A, workspaceId: SITE_A, experimentKey: "atlas-waitlist" },
433
+ where: { siteId: SITE_A, workspaceId: SITE_A, experimentKey: { in: ["atlas-waitlist"] } },
434
+ _count: { _all: true },
426
435
  });
427
- expect(prisma.waitlistScore.count).toHaveBeenCalledWith({
428
- where: { siteId: SITE_B, workspaceId: SITE_B, experimentKey: "atlas-two-waitlist" },
436
+ expect(prisma.waitlistScore.groupBy).toHaveBeenCalledWith({
437
+ by: ["experimentKey"],
438
+ where: { siteId: SITE_B, workspaceId: SITE_B, experimentKey: { in: ["atlas-two-waitlist"] } },
439
+ _count: { _all: true },
429
440
  });
430
441
  });
431
442
 
@@ -61,7 +61,7 @@ jest.mock("../../../lib/prisma.js", () => ({
61
61
  projectListing: { findMany: jest.fn() },
62
62
  siteListingConfig: { findUnique: jest.fn() },
63
63
  waitlistExperiment: { findMany: jest.fn() },
64
- waitlistScore: { count: jest.fn() },
64
+ waitlistScore: { groupBy: jest.fn() },
65
65
  },
66
66
  }));
67
67
 
@@ -236,9 +236,13 @@ function installFixtureMocks(): void {
236
236
  // count is keyed on the `experimentKey` the listing already holds, so the
237
237
  // id-to-key hop this route used to make is gone. A test below asserts it
238
238
  // stays gone.
239
- (prisma.waitlistScore.count as jest.Mock).mockImplementation(
240
- ({ where }: { where: { experimentKey: string } }) =>
241
- Promise.resolve(COUNTS_BY_KEY[where.experimentKey] ?? 0),
239
+ (prisma.waitlistScore.groupBy as jest.Mock).mockImplementation(
240
+ ({ where }: { where: { experimentKey: { in: string[] } } }) =>
241
+ Promise.resolve(
242
+ where.experimentKey.in
243
+ .filter((experimentKey) => (COUNTS_BY_KEY[experimentKey] ?? 0) > 0)
244
+ .map((experimentKey) => ({ experimentKey, _count: { _all: COUNTS_BY_KEY[experimentKey] } })),
245
+ ),
242
246
  );
243
247
  }
244
248
 
@@ -413,10 +417,17 @@ describe("GET /v1/project-listings/public", () => {
413
417
 
414
418
  it("counts the waitlist through the listing's own experimentKey, in one hop", async () => {
415
419
  await app.inject({ method: "GET", url: `/v1/project-listings/public?siteKey=${SITE_KEY}` });
416
- expect(prisma.waitlistScore.count).toHaveBeenCalledWith({
420
+ expect(prisma.waitlistScore.groupBy).toHaveBeenCalledTimes(1);
421
+ expect(prisma.waitlistScore.groupBy).toHaveBeenCalledWith({
422
+ by: ["experimentKey"],
417
423
  // #6258 AC-6 — the fenced read now filters the CONSTRAINED column
418
424
  // beside `siteId`; the two are equal by the workspace_site_agree CHECK.
419
- where: { siteId: SITE, workspaceId: SITE, experimentKey: "exp-newest" },
425
+ where: {
426
+ siteId: SITE,
427
+ workspaceId: SITE,
428
+ experimentKey: { in: expect.arrayContaining(["exp-newest"]) },
429
+ },
430
+ _count: { _all: true },
420
431
  });
421
432
  // The id-to-key translation read is gone with #5036 D6d.
422
433
  expect(prisma.waitlistExperiment.findMany).not.toHaveBeenCalled();
@@ -885,7 +885,7 @@ describe("a captured draft answers its listicle's Application fields (#7521 AC-2
885
885
  ({ where }: { where: Record<string, unknown> }) =>
886
886
  Promise.resolve(flows.filter((f) => matches(f, where))),
887
887
  );
888
- (prisma.waitlistScore.count as jest.Mock).mockResolvedValue(0);
888
+ (prisma.waitlistScore.groupBy as jest.Mock).mockResolvedValue([]);
889
889
  app = Fastify();
890
890
  await app.register(startProjectListingRoute);
891
891
  await app.register(patchProjectListingDraftRoute);
@@ -179,6 +179,7 @@ describe("AC-1 — completed stamps the row once, guarded, beside the legacy tas
179
179
  });
180
180
  expect(prisma.user.findFirst).toHaveBeenCalledWith({
181
181
  where: { billingProvider: "stripe", billingCustomerId: CUSTOMER_ID },
182
+ select: { id: true },
182
183
  });
183
184
  expect(prisma.user.findUnique).not.toHaveBeenCalled();
184
185
  });
@@ -200,6 +200,7 @@ function expectHandledAsToday() {
200
200
  expect(prisma.callback.updateMany).toHaveBeenCalledTimes(1);
201
201
  expect(prisma.user.findFirst).toHaveBeenCalledWith({
202
202
  where: { billingProvider: "stripe", billingCustomerId: CUSTOMER_ID },
203
+ select: { id: true },
203
204
  });
204
205
  expect(foreignSiteLogs()).toHaveLength(0);
205
206
  }
@@ -1279,6 +1279,7 @@ async function upsertBillingIdentity(
1279
1279
  billingProvider: "stripe",
1280
1280
  billingCustomerId: customerId,
1281
1281
  },
1282
+ select: { id: true },
1282
1283
  });
1283
1284
 
1284
1285
  if (existingByBilling) {
@@ -1424,6 +1425,7 @@ async function upsertOrgBillingIdentity(
1424
1425
  billingProvider: "stripe",
1425
1426
  billingCustomerId: customerId,
1426
1427
  },
1428
+ select: { id: true },
1427
1429
  });
1428
1430
 
1429
1431
  if (existingByBilling && existingByBilling.id === organizationId) {
@@ -246,6 +246,11 @@ const WORKSPACES_MESSAGES_OUT = "./routes/workspaces/messages-out.js";
246
246
  // #8171 D8 — the workspace's Requests read (the inbound member rows), the
247
247
  // SAME scope.
248
248
  const WORKSPACES_REQUESTS = "./routes/workspaces/requests.js";
249
+ // #9254 DD5 — the workspace's Callbacks read (request-and-confirm rows), the
250
+ // SAME scope.
251
+ const WORKSPACES_CALLBACKS = "./routes/workspaces/callbacks.js";
252
+ // #9287 DD2 — the owner's Start, Get and Extend trial, the SAME scope.
253
+ const WORKSPACES_TRIAL = "./routes/workspaces/trial.js";
249
254
  // #7877 D11 — the member reads: auth only, under the SAME module predicate.
250
255
  const MEMBER_PIECES = "./routes/member/pieces.js";
251
256
  const MEMBER_MESSAGES = "./routes/member/messages.js";
@@ -372,6 +377,16 @@ const SLICE_PLUGINS = {
372
377
  specifier: WORKSPACES_REQUESTS,
373
378
  load: () => import(WORKSPACES_REQUESTS),
374
379
  },
380
+ // #9254 DD5 — the Callbacks read, the SAME scope.
381
+ workspaceCallbackRoutes: {
382
+ specifier: WORKSPACES_CALLBACKS,
383
+ load: () => import(WORKSPACES_CALLBACKS),
384
+ },
385
+ // #9287 DD2 — the trial routes, the SAME scope.
386
+ workspaceTrialRoutes: {
387
+ specifier: WORKSPACES_TRIAL,
388
+ load: () => import(WORKSPACES_TRIAL),
389
+ },
375
390
  workspacePlanRoutes: {
376
391
  specifier: WORKSPACES_PLAN,
377
392
  load: () => import(WORKSPACES_PLAN),
@@ -1340,6 +1355,16 @@ export async function build() {
1340
1355
  await tenantScope.register(
1341
1356
  await sliceRoutePlugin("workspaceRequestRoutes"),
1342
1357
  );
1358
+ // #9254 DD5 — the Callbacks read: the request-and-confirm rows the
1359
+ // Inbox lists under Interested, behind `inbox-read`.
1360
+ await tenantScope.register(
1361
+ await sliceRoutePlugin("workspaceCallbackRoutes"),
1362
+ );
1363
+ // #9287 DD2 — Start, Get and Extend trial, behind `billing-manage`
1364
+ // (the owner alone; never an agent).
1365
+ await tenantScope.register(
1366
+ await sliceRoutePlugin("workspaceTrialRoutes"),
1367
+ );
1343
1368
  await tenantScope.register(
1344
1369
  await sliceRoutePlugin("workspacePlanRoutes"),
1345
1370
  );
@@ -29,7 +29,16 @@ jest.mock("@working-theory/observability", () => ({
29
29
  }));
30
30
 
31
31
  import { prisma } from "../../lib/prisma.js";
32
- import { runAbandonEmailSweep } from "../abandon-email";
32
+ import {
33
+ ABANDON_CANDIDATE_PAGE_SIZE,
34
+ runAbandonEmailSweep,
35
+ } from "../abandon-email";
36
+
37
+ // `flowSession.updateMany` — the dark-mode batch stamp (#9249) — is not on
38
+ // the shared double; installed here the way lead-routing.test.ts installs
39
+ // `lead.count`.
40
+ const mockFlowSessionUpdateMany = jest.fn();
41
+ Object.assign(prisma.flowSession, { updateMany: mockFlowSessionUpdateMany });
33
42
 
34
43
  // ─── Fixtures ─────────────────────────────────────────────────────────────────
35
44
 
@@ -183,15 +192,16 @@ describe("runAbandonEmailSweep", () => {
183
192
  (prisma.flowSession.findMany as jest.Mock).mockResolvedValue([
184
193
  sessionRow(),
185
194
  ]);
186
- (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
195
+ mockFlowSessionUpdateMany.mockResolvedValue({ count: 1 });
187
196
 
188
197
  const result = await runAbandonEmailSweep({ now: NOW });
189
198
 
190
199
  expect(mockSendAbandonEmail).not.toHaveBeenCalled();
191
- expect(prisma.flowSession.update).toHaveBeenCalledWith({
192
- where: { id: SESSION_ID },
200
+ expect(mockFlowSessionUpdateMany).toHaveBeenCalledWith({
201
+ where: { id: { in: [SESSION_ID] }, abandonEmailStatus: null },
193
202
  data: { abandonEmailStatus: "dark", abandonEmailAt: NOW },
194
203
  });
204
+ expect(prisma.flowSession.update).not.toHaveBeenCalled();
195
205
  expect(result).toMatchObject({
196
206
  ok: true,
197
207
  summary: {
@@ -205,6 +215,113 @@ describe("runAbandonEmailSweep", () => {
205
215
  });
206
216
  });
207
217
 
218
+ // ── #9249 — dark mode stamps the whole candidate set in ONE updateMany ──
219
+
220
+ it("dark mode stamps N≥3 candidates with exactly one flowSession.updateMany, never a per-row update", async () => {
221
+ const ids = ["flsess_a", "flsess_b", "flsess_c"];
222
+ (prisma.flowDefinition.findMany as jest.Mock).mockResolvedValue([
223
+ flowDefRow({
224
+ abandonEmail: {
225
+ mode: "dark",
226
+ delayMinutes: 30,
227
+ copy: { subject: en("Still interested?"), body: en("Come back!") },
228
+ },
229
+ }),
230
+ ]);
231
+ (prisma.flowSession.findMany as jest.Mock).mockResolvedValue(
232
+ ids.map((id) => sessionRow({ id })),
233
+ );
234
+ mockFlowSessionUpdateMany.mockResolvedValue({ count: ids.length });
235
+
236
+ const result = await runAbandonEmailSweep({ now: NOW });
237
+
238
+ expect(mockFlowSessionUpdateMany).toHaveBeenCalledTimes(1);
239
+ expect(mockFlowSessionUpdateMany).toHaveBeenCalledWith({
240
+ // The `abandonEmailStatus: null` guard keeps a row stamped concurrently
241
+ // from being re-stamped — the same term the candidate read selects on.
242
+ where: { id: { in: ids }, abandonEmailStatus: null },
243
+ data: { abandonEmailStatus: "dark", abandonEmailAt: NOW },
244
+ });
245
+ expect(prisma.flowSession.update).not.toHaveBeenCalled();
246
+ expect(mockSendAbandonEmail).not.toHaveBeenCalled();
247
+ // Per-candidate audit logging is unchanged — one line per session.
248
+ expect(
249
+ mockLoggerInfo.mock.calls.filter(
250
+ ([fields]) => fields.outcome === "dark" && fields.mode === "dark",
251
+ ),
252
+ ).toHaveLength(ids.length);
253
+ // Summary counters report the same numbers as the per-row stamps did.
254
+ expect(result).toEqual({
255
+ ok: true,
256
+ summary: { scanned: 3, selected: 3, dark: 3, sent: 0, failed: 0, skipped: 0 },
257
+ });
258
+ });
259
+
260
+ it("dark mode with no candidates issues no updateMany at all", async () => {
261
+ (prisma.flowDefinition.findMany as jest.Mock).mockResolvedValue([
262
+ flowDefRow({
263
+ abandonEmail: {
264
+ mode: "dark",
265
+ delayMinutes: 30,
266
+ copy: { subject: en("Still interested?"), body: en("Come back!") },
267
+ },
268
+ }),
269
+ ]);
270
+ (prisma.flowSession.findMany as jest.Mock).mockResolvedValue([]);
271
+
272
+ const result = await runAbandonEmailSweep({ now: NOW });
273
+
274
+ expect(mockFlowSessionUpdateMany).not.toHaveBeenCalled();
275
+ expect(prisma.flowSession.update).not.toHaveBeenCalled();
276
+ expect(result).toEqual({
277
+ ok: true,
278
+ summary: { scanned: 0, selected: 0, dark: 0, sent: 0, failed: 0, skipped: 0 },
279
+ });
280
+ });
281
+
282
+ // ── #9249 — the candidate read is paged ──────────────────────────────────
283
+
284
+ it("pages the candidate read with take: ABANDON_CANDIDATE_PAGE_SIZE and processes exactly one page per run", async () => {
285
+ expect(ABANDON_CANDIDATE_PAGE_SIZE).toBeGreaterThan(0);
286
+ (prisma.flowDefinition.findMany as jest.Mock).mockResolvedValue([
287
+ flowDefRow({
288
+ abandonEmail: {
289
+ mode: "dark",
290
+ delayMinutes: 30,
291
+ copy: { subject: en("Still interested?"), body: en("Come back!") },
292
+ },
293
+ }),
294
+ ]);
295
+ // The database honours `take`: a run with more candidates than the page
296
+ // sees exactly one page of them. The rest stay `abandonEmailStatus: null`
297
+ // and are selected by the next run.
298
+ const page = Array.from({ length: ABANDON_CANDIDATE_PAGE_SIZE }, (_, i) =>
299
+ sessionRow({ id: `flsess_${i}` }),
300
+ );
301
+ (prisma.flowSession.findMany as jest.Mock).mockResolvedValue(page);
302
+ mockFlowSessionUpdateMany.mockResolvedValue({ count: page.length });
303
+
304
+ const result = await runAbandonEmailSweep({ now: NOW });
305
+
306
+ expect(prisma.flowSession.findMany).toHaveBeenCalledTimes(1);
307
+ expect(prisma.flowSession.findMany).toHaveBeenCalledWith(
308
+ expect.objectContaining({ take: ABANDON_CANDIDATE_PAGE_SIZE }),
309
+ );
310
+ // No second read within the run — the remainder is the next run's.
311
+ expect(mockFlowSessionUpdateMany).toHaveBeenCalledTimes(1);
312
+ expect(mockFlowSessionUpdateMany.mock.calls[0][0].where.id.in).toHaveLength(
313
+ ABANDON_CANDIDATE_PAGE_SIZE,
314
+ );
315
+ expect(result).toMatchObject({
316
+ ok: true,
317
+ summary: {
318
+ scanned: ABANDON_CANDIDATE_PAGE_SIZE,
319
+ selected: ABANDON_CANDIDATE_PAGE_SIZE,
320
+ dark: ABANDON_CANDIDATE_PAGE_SIZE,
321
+ },
322
+ });
323
+ });
324
+
208
325
  // ── AC2 — enabled mode: selected + sent ──────────────────────────────────
209
326
 
210
327
  it("enabled mode calls sendAbandonEmail and stamps abandonEmailStatus:'sent'", async () => {
@@ -144,6 +144,16 @@ describe("fulfillDeliverable", () => {
144
144
  );
145
145
  });
146
146
 
147
+ it("selects only the id on the legacy DeliverableAsset lookup, never the bytes blob (#9248)", async () => {
148
+ await fulfillDeliverable(baseInput({ segment: "small" }));
149
+
150
+ // `DeliverableAsset.bytes` is the file itself; this read exists only to
151
+ // learn the row's id, so the query must name that one column.
152
+ const [[args]] = (prisma.deliverableAsset.findUnique as jest.Mock).mock
153
+ .calls as [[{ select?: Record<string, unknown> }]];
154
+ expect(args.select).toEqual({ id: true });
155
+ });
156
+
147
157
  it("resolves the OTHER segment's entry when segment differs (two-segment divergence, AC1)", async () => {
148
158
  (prisma.deliverableAsset.findUnique as jest.Mock).mockResolvedValue({
149
159
  id: "dasset_00000000-0000-7000-8000-0000000000a2",
@@ -55,6 +55,7 @@ import {
55
55
  setOwnPersonContact,
56
56
  setPersonPlanOffering,
57
57
  stampPersonUser,
58
+ stripeCustomerDashboardUrl,
58
59
  updatePersonContact,
59
60
  } from "../person";
60
61
 
@@ -1174,3 +1175,30 @@ describe("resolveMemberPersonId releases a held plan on a fresh link only (#8793
1174
1175
  expect(log.warn).not.toHaveBeenCalled();
1175
1176
  });
1176
1177
  });
1178
+
1179
+ describe("stripeCustomerDashboardUrl (#9349 DD5, AC-9)", () => {
1180
+ it("builds the live and the test Dashboard links", () => {
1181
+ expect(stripeCustomerDashboardUrl("cus_ABC123", "live")).toBe(
1182
+ "https://dashboard.stripe.com/customers/cus_ABC123",
1183
+ );
1184
+ expect(stripeCustomerDashboardUrl("cus_ABC123", "test")).toBe(
1185
+ "https://dashboard.stripe.com/test/customers/cus_ABC123",
1186
+ );
1187
+ });
1188
+
1189
+ it("refuses an id outside the cus_ grammar or over 255 characters", () => {
1190
+ for (const id of [
1191
+ "cus_a/../../settings",
1192
+ "cus_",
1193
+ "acct_1",
1194
+ "cus_a?x=1",
1195
+ "cus_a#x",
1196
+ `cus_${"a".repeat(252)}`,
1197
+ ]) {
1198
+ expect(stripeCustomerDashboardUrl(id, "live")).toBeNull();
1199
+ }
1200
+ expect(
1201
+ stripeCustomerDashboardUrl(`cus_${"a".repeat(251)}`, "live"),
1202
+ ).not.toBeNull();
1203
+ });
1204
+ });
@@ -200,6 +200,8 @@ const ENV_KEYS = [
200
200
  "AILK_LEAD_NOTIFICATION_TO",
201
201
  "AILK_LEAD_NOTIFICATION_FROM",
202
202
  "AILK_DELIVERABLE_EMAIL_FROM",
203
+ "AILK_ENABLED_MODULES",
204
+ "STRIPE_ENV_ORGANIZATION_ID",
203
205
  ] as const;
204
206
  let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
205
207
 
@@ -208,6 +210,10 @@ beforeEach(async () => {
208
210
  saved = {};
209
211
  for (const k of ENV_KEYS) saved[k] = process.env[k];
210
212
  process.env.DATABASE_URL = "postgres://stub";
213
+ // With the multi-tenant module and no STRIPE_ENV_ORGANIZATION_ID nobody owns the
214
+ // environment key (#9285 AC-8), so "no connection" stays no connection here.
215
+ process.env.AILK_ENABLED_MODULES = "multi-tenant";
216
+ delete process.env.STRIPE_ENV_ORGANIZATION_ID;
211
217
  delete process.env.NODE_ENV;
212
218
  delete process.env.AILK_LICENSE_KEY;
213
219
  process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
@@ -0,0 +1,202 @@
1
+ // oss: false
2
+ /**
3
+ * @file project-listings.counts.test.ts
4
+ *
5
+ * #9243 — `attachWaitlistCounts` counts waitlist scores with ONE
6
+ * `waitlistScore.groupBy` per call, however many distinct experiment keys the
7
+ * rows carry, and `publicProjectListings` — the anonymous page-render read —
8
+ * bounds its `findMany` with a `take`. Both are the AC evidence for audit HIGH
9
+ * #1 (docs/audits/query-efficiency-audit-2026-10-19.md): the per-key
10
+ * `count()` loop and the unbounded public read.
11
+ *
12
+ * Mock pattern follows `project-listings.fence.test.ts` (jest.fn() doubles on
13
+ * `lib/prisma.js`).
14
+ */
15
+ import { LISTING_DIRECTORY_MAX_ENTRIES } from "@working-theory/validation";
16
+
17
+ import {
18
+ listProjectListings,
19
+ publicProjectListings,
20
+ } from "../project-listings.js";
21
+
22
+ // Boundary mocks — `project-listings.ts`'s import chain reaches the Resend
23
+ // client and the licence check; neither is exercised by the reads under test.
24
+ jest.mock("@working-theory/email", () => ({
25
+ sendLeadNotification: jest
26
+ .fn()
27
+ .mockResolvedValue({ ok: true, messageId: "msg-test" }),
28
+ sendDeliverableEmail: jest
29
+ .fn()
30
+ .mockResolvedValue({ ok: true, messageId: "msg-test" }),
31
+ }));
32
+
33
+ jest.mock("@working-theory/license", () => ({
34
+ checkLicense: jest.fn().mockResolvedValue({
35
+ tier: "free",
36
+ plan: null,
37
+ features: [],
38
+ expiresAt: null,
39
+ revoked: false,
40
+ }),
41
+ }));
42
+
43
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
44
+ jest.mock("../../lib/prisma.js", () => ({
45
+ prisma: {
46
+ projectListing: { findMany: jest.fn() },
47
+ siteListingConfig: { findUnique: jest.fn() },
48
+ waitlistScore: { count: jest.fn(), groupBy: jest.fn() },
49
+ },
50
+ }));
51
+
52
+ import { prisma } from "../../lib/prisma.js";
53
+
54
+ const SITE = "wksp_00000000-0000-7000-8000-000000000001";
55
+ const EXPERIMENT_A = "ailk-founder-diagnostic";
56
+ const EXPERIMENT_B = "ailk-agency-waitlist";
57
+ const EXPERIMENT_C = "ailk-studio-waitlist";
58
+
59
+ function listingRow(
60
+ id: string,
61
+ experimentKey: string | null,
62
+ ): Record<string, unknown> {
63
+ return {
64
+ id,
65
+ siteId: SITE,
66
+ workspaceId: SITE,
67
+ leadId: null,
68
+ slug: `slug-${id}`,
69
+ name: "Acme",
70
+ headline: null,
71
+ oneLiner: null,
72
+ problem: null,
73
+ solution: null,
74
+ url: null,
75
+ product: null,
76
+ commitment: null,
77
+ teamComposition: null,
78
+ faqs: null,
79
+ anythingElse: null,
80
+ address: null,
81
+ addressLine1: null,
82
+ addressLine2: null,
83
+ addressCity: null,
84
+ addressRegion: null,
85
+ addressPostalCode: null,
86
+ addressCountry: null,
87
+ questions: null,
88
+ siteAnswers: null,
89
+ windowDays: null,
90
+ founderName: null,
91
+ founderFirstName: null,
92
+ founderLastName: null,
93
+ founderEmail: null,
94
+ founderPhone: null,
95
+ founderCompany: null,
96
+ emailVerifiedAt: null,
97
+ logoMarkUrl: null,
98
+ logoWordmarkUrl: null,
99
+ featured: false,
100
+ status: "active",
101
+ consentAt: new Date("2026-09-01T00:00:00.000Z"),
102
+ decidedAt: new Date("2026-09-02T00:00:00.000Z"),
103
+ decidedBy: null,
104
+ issueUrl: null,
105
+ experimentKey,
106
+ windowEndsAt: new Date("2099-01-01T00:00:00.000Z"),
107
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
108
+ updatedAt: new Date("2026-09-01T00:00:00.000Z"),
109
+ };
110
+ }
111
+
112
+ /** The shape `prisma.waitlistScore.groupBy({ by: ["experimentKey"], _count: { _all: true } })` returns. */
113
+ function grouped(
114
+ experimentKey: string,
115
+ count: number,
116
+ ): { experimentKey: string; _count: { _all: number } } {
117
+ return { experimentKey, _count: { _all: count } };
118
+ }
119
+
120
+ beforeEach(() => {
121
+ jest.clearAllMocks();
122
+ (prisma.siteListingConfig.findUnique as jest.Mock).mockResolvedValue(null);
123
+ (prisma.waitlistScore.groupBy as jest.Mock).mockResolvedValue([]);
124
+ (prisma.waitlistScore.count as jest.Mock).mockResolvedValue(0);
125
+ });
126
+
127
+ describe("attachWaitlistCounts — one waitlistScore query per call (#9243 AC-1)", () => {
128
+ it("three distinct experiment keys across four rows → exactly ONE groupBy, no count()", async () => {
129
+ (prisma.projectListing.findMany as jest.Mock).mockResolvedValue([
130
+ listingRow("plst_a", EXPERIMENT_A),
131
+ listingRow("plst_b", EXPERIMENT_B),
132
+ listingRow("plst_c", EXPERIMENT_C),
133
+ listingRow("plst_d", EXPERIMENT_A),
134
+ ]);
135
+ (prisma.waitlistScore.groupBy as jest.Mock).mockResolvedValue([
136
+ grouped(EXPERIMENT_A, 5),
137
+ grouped(EXPERIMENT_B, 2),
138
+ ]);
139
+
140
+ const rows = await listProjectListings(SITE);
141
+
142
+ expect(prisma.waitlistScore.groupBy).toHaveBeenCalledTimes(1);
143
+ expect(prisma.waitlistScore.count).not.toHaveBeenCalled();
144
+ expect(
145
+ (prisma.waitlistScore.groupBy as jest.Mock).mock.calls[0]![0],
146
+ ).toEqual({
147
+ by: ["experimentKey"],
148
+ // fence: workspaceId === siteId beside siteId, by value (#6258 AC-6),
149
+ // and every DISTINCT key in one `in` — no per-key fan-out.
150
+ where: {
151
+ siteId: SITE,
152
+ workspaceId: SITE,
153
+ experimentKey: { in: [EXPERIMENT_A, EXPERIMENT_B, EXPERIMENT_C] },
154
+ },
155
+ _count: { _all: true },
156
+ });
157
+
158
+ const countsById = new Map(rows.map((r) => [r.id, r.waitlistCount]));
159
+ expect(countsById.get("plst_a")).toBe(5);
160
+ expect(countsById.get("plst_d")).toBe(5);
161
+ expect(countsById.get("plst_b")).toBe(2);
162
+ // A key with ZERO score rows is absent from a GROUP BY result; it reads 0,
163
+ // exactly the value the per-key `count()` produced for it.
164
+ expect(countsById.get("plst_c")).toBe(0);
165
+ });
166
+
167
+ it("no listing carries an experimentKey → no waitlistScore read is issued at all", async () => {
168
+ (prisma.projectListing.findMany as jest.Mock).mockResolvedValue([
169
+ listingRow("plst_a", null),
170
+ ]);
171
+
172
+ const rows = await listProjectListings(SITE);
173
+
174
+ expect(prisma.waitlistScore.groupBy).not.toHaveBeenCalled();
175
+ expect(prisma.waitlistScore.count).not.toHaveBeenCalled();
176
+ expect(rows[0]!.waitlistCount).toBe(0);
177
+ });
178
+ });
179
+
180
+ describe("publicProjectListings — the anonymous read is bounded (#9243 AC-2)", () => {
181
+ it("passes a take; more active listings than the bound returns exactly the bound", async () => {
182
+ const overBound = Array.from(
183
+ { length: LISTING_DIRECTORY_MAX_ENTRIES + 1 },
184
+ (_, i) => listingRow(`plst_${i}`, EXPERIMENT_A),
185
+ );
186
+ // Honour `take` the way the database would, so an unbounded read is the
187
+ // failure this test sees rather than one the double hides.
188
+ (prisma.projectListing.findMany as jest.Mock).mockImplementation(
189
+ ({ take }: { take?: number }) =>
190
+ Promise.resolve(overBound.slice(0, take)),
191
+ );
192
+
193
+ const result = await publicProjectListings(SITE);
194
+
195
+ expect(
196
+ (prisma.projectListing.findMany as jest.Mock).mock.calls[0]![0].take,
197
+ ).toBe(LISTING_DIRECTORY_MAX_ENTRIES);
198
+ expect(result).toHaveLength(LISTING_DIRECTORY_MAX_ENTRIES);
199
+ // Still one count query for the whole bounded page.
200
+ expect(prisma.waitlistScore.groupBy).toHaveBeenCalledTimes(1);
201
+ });
202
+ });