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
@@ -7,7 +7,9 @@
7
7
  * chrome — a centred hero of the flow record's `landing` copy, a CTA to
8
8
  * `/<magnet>/start`, and the `Assessment` page type's single-layer
9
9
  * `WebApplication` JSON-LD) and a `noindex, follow` WALK (`/<magnet>/start`,
10
- * chrome-free by `lib/magnet-routes.ts`, the back arrow alone above step 1).
10
+ * or the `startPath` a landing names, as `/trial` names `/trial/request` —
11
+ * #9287 DD10; chrome-free by `lib/magnet-routes.ts`, the back arrow alone
12
+ * above step 1).
11
13
  * This module is the only code that renders either half: `/flows/<slug>`,
12
14
  * the careers apply pages and every pair `create-ailk add-route magnet`
13
15
  * writes are callers of the factories below.
@@ -23,7 +25,8 @@
23
25
  * with no hub to ask — the read answers 503 `flows_not_configured`, or a
24
26
  * failed read with no API origin configured — is `not-configured` (#9219);
25
27
  * a pair renders it as `unavailable`, and `/reports/<slug>` uses it to 404
26
- * an unknown slug where a hub outage shows the contact email. A walk
28
+ * an unknown slug where a hub outage shows the contact email; a landing that
29
+ * passes `notFoundWhenNotConfigured` (`/trial`, #9423) 404s on it too. A walk
27
30
  * that exists apart from its flow (`/careers/apply`) passes a `title`: its
28
31
  * fallback then carries that page heading, and an absent flow renders the
29
32
  * same fallback rather than a 404 (#9219).
@@ -170,6 +173,15 @@ export interface MagnetLandingInput {
170
173
  /** The landing's own path, e.g. `/diagnostic`; the CTA goes to `<path>/start`. */
171
174
  path: string;
172
175
  locale: string;
176
+ /** #9287 DD10 — the walk's path when it is not `<path>/start` (`/trial/request`). */
177
+ startPath?: string;
178
+ /**
179
+ * #9423 — a landing that exists only to sell its hub's flow (`/trial`) is a
180
+ * 404 on a site with no hub to ask (`not-configured`), as `/reports/<slug>`
181
+ * is (#9219). A configured hub that cannot answer still shows the contact
182
+ * email.
183
+ */
184
+ notFoundWhenNotConfigured?: boolean;
173
185
  }
174
186
 
175
187
  /** The indexable landing of a magnet pair (D8). */
@@ -177,9 +189,12 @@ export async function renderMagnetLanding({
177
189
  slug,
178
190
  path,
179
191
  locale,
192
+ startPath = `${path}/start`,
193
+ notFoundWhenNotConfigured = false,
180
194
  }: MagnetLandingInput): Promise<ReactElement> {
181
195
  const read = await readMagnet(slug);
182
196
  if (read.kind === "absent") notFound();
197
+ if (read.kind === "not-configured" && notFoundWhenNotConfigured) notFound();
183
198
  if (read.kind !== "ok") return magnetUnavailable(locale);
184
199
  // #8848 D7 — a deliverable's one route.
185
200
  if (read.config.deliverable !== undefined) redirect(localeHref(locale, `/reports/${slug}`));
@@ -194,7 +209,7 @@ export async function renderMagnetLanding({
194
209
  headline: copy.title,
195
210
  subheadline: copy.description,
196
211
  ...(copy.meta !== undefined ? { body: copy.meta } : {}),
197
- primaryCta: { label: copy.cta, href: localeHref(locale, `${path}/start`) },
212
+ primaryCta: { label: copy.cta, href: localeHref(locale, startPath) },
198
213
  },
199
214
  },
200
215
  ];
@@ -221,9 +236,11 @@ export async function renderMagnetLanding({
221
236
  export async function renderMagnetLandingMetadata({
222
237
  slug,
223
238
  locale,
224
- }: Pick<MagnetLandingInput, "slug" | "locale">): Promise<Metadata> {
239
+ notFoundWhenNotConfigured = false,
240
+ }: Pick<MagnetLandingInput, "slug" | "locale" | "notFoundWhenNotConfigured">): Promise<Metadata> {
225
241
  const read = await readMagnet(slug);
226
242
  if (read.kind === "absent") return {};
243
+ if (read.kind === "not-configured" && notFoundWhenNotConfigured) return {};
227
244
  if (read.kind !== "ok") return flowRouteMetadata();
228
245
  const copy = await landingCopy(read.config, locale);
229
246
  return { title: copy.title, description: copy.description };
@@ -14,6 +14,9 @@ describe("isMagnetWalkPath (#8840 D10)", () => {
14
14
  "/careers/writer/apply",
15
15
  "/fr/careers/writer/apply",
16
16
  "/diagnostic/start?step=2",
17
+ "/trial/request",
18
+ "/en/trial/request",
19
+ "/fr/trial/request",
17
20
  ])("is a walk: %s", (path) => {
18
21
  expect(isMagnetWalkPath(path, LOCALES)).toBe(true);
19
22
  });
@@ -28,6 +31,10 @@ describe("isMagnetWalkPath (#8840 D10)", () => {
28
31
  "",
29
32
  "/apply",
30
33
  "/careers/a/b/apply",
34
+ "/trial",
35
+ "/en/trial",
36
+ "/request",
37
+ "/trial/a/request",
31
38
  ])("is not a walk: %j", (path) => {
32
39
  expect(isMagnetWalkPath(path, LOCALES)).toBe(false);
33
40
  });
@@ -12,7 +12,8 @@
12
12
  * rather than writing a second predicate. #8848 D9 adds every page under
13
13
  * `/reports/<slug>` — a gated landing, an ungated reader and the grant
14
14
  * reader alike, since the path cannot tell them apart; `/reports` itself
15
- * keeps its chrome.
15
+ * keeps its chrome. #9287 DD10 adds `/trial/request`, the trial magnet's
16
+ * walk; `/trial`, its landing, keeps its chrome.
16
17
  *
17
18
  * Fail-safe direction, as `isAuthPath()`: an absent or empty pathname
18
19
  * answers `false`, so a missing `x-pathname` never strips a page's chrome.
@@ -34,6 +35,7 @@ export function isMagnetWalkPath(
34
35
  if (segments.length === 0) return false;
35
36
  if (segments[segments.length - 1] === "start") return true;
36
37
  if (segments[0] === "reports" && segments.length === 2) return true;
38
+ if (segments.length === 2 && segments[0] === "trial" && segments[1] === "request") return true;
37
39
  return (
38
40
  segments[0] === "careers" &&
39
41
  segments[segments.length - 1] === "apply" &&
@@ -6,7 +6,9 @@
6
6
  *
7
7
  * `/newsletter` and the footer render the newsletter magnet's
8
8
  * `NewsletterFormInline` bound to this action. Validates the address with
9
- * the tool's own input schema, calls `POST /v1/leads/subscribe` through
9
+ * its own `newsletterFormSchema` (the form's one field is the page's, not a
10
+ * wire contract — the `subscribe_newsletter` MCP tool takes the route body
11
+ * since #7172), calls `POST /v1/leads/subscribe` through
10
12
  * `@working-theory/api-client`, and maps the outcome the same way
11
13
  * `captureLeadAction` does: the API's 503 `lead_capture_not_configured` →
12
14
  * `unavailable`; any other non-2xx or thrown error → the generic `error`;
@@ -34,12 +36,10 @@
34
36
 
35
37
  import { flows, leads, visitorForwardHeaders } from "@working-theory/api-client";
36
38
  import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
37
- import {
38
- NEWSLETTER_FLOW_SLUG,
39
- subscribeNewsletterInputSchema,
40
- } from "@working-theory/validation";
39
+ import { NEWSLETTER_FLOW_SLUG } from "@working-theory/validation";
41
40
  import { cookies, headers } from "next/headers";
42
41
  import { getLocale } from "next-intl/server";
42
+ import { z } from "zod";
43
43
 
44
44
  import { checkRateLimit, clientIpFromHeaders } from "../src/lib/rate-limit";
45
45
 
@@ -55,6 +55,9 @@ const RATE_LIMITED = "Too many signups from here. Please try again in a few minu
55
55
  const RATE_LIMIT_MAX_CALLS = 30;
56
56
  const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
57
57
 
58
+ /** The form's one field; zod's default message is the field error shown. */
59
+ const newsletterFormSchema = z.object({ email: z.string().email() });
60
+
58
61
  /** The API's 503 for a deployment with nowhere to keep a lead. */
59
62
  function isNotConfigured(res: { status: number; body: unknown }): boolean {
60
63
  return (
@@ -122,7 +125,7 @@ export async function subscribeNewsletterAction(
122
125
  const honeypot = formData.get(LEAD_CAPTURE_HONEYPOT);
123
126
  if (typeof honeypot === "string" && honeypot !== "") return { kind: "success" };
124
127
 
125
- const parsed = subscribeNewsletterInputSchema.safeParse({
128
+ const parsed = newsletterFormSchema.safeParse({
126
129
  email: formData.get("email"),
127
130
  });
128
131
  if (!parsed.success) {
@@ -1,5 +1,25 @@
1
1
  # @working-theory/database
2
2
 
3
+ ## 0.45.0
4
+
5
+ ### Patch Changes
6
+
7
+ - [#9309](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/9309) [`42b37e2`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/42b37e237d0f424573346b158290f0ecc4ae77a4) Thanks [@samuelhenry](https://github.com/samuelhenry)! - Every workspace now holds a standard trial magnet at the fixed slug `trial` ([#9301](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/9301)).
8
+
9
+ - **`@working-theory/validation`**: new exports `TRIAL_FLOW_SLUG`, `TRIAL_FLOW_TITLE`, `TRIAL_FLOW_LANDING_PAGE`, `TRIAL_TERMS_STATEMENT` and `trialFlowConfig` — one step asking for first name, last name, work email and the terms box, ending on "Thanks, {name}.", with a landing whose button reads "Request a free trial". `RESERVED_FLOW_SLUGS` gains `trial`, so no flow-engine write, spoke seed or new listing can take the slug (a new listing named "Trial" now derives `trial-2`).
10
+ - **`@working-theory/database`**: the default workspace's seed writes the trial magnet (`diagnostic`, `live`, completion `lead`, landing page `/trial`), and the `20261025120000_trial_magnet` migration backfills it into every existing workspace, insert-only — a row already at `trial` is never changed.
11
+ - **`create-ailk`**: the API seeds the trial magnet when it creates a workspace and from `seed-presets`, and the magnet PATCH refuses to rename the trial magnet or rename another magnet to `trial` (`409`). A completed walk is a `flow:trial` Inbox submission whose Person carries the given name, family name, email and, when a site adds a `company`-tagged field, the company.
12
+
13
+ - [#9330](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/9330) [`569b348`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/569b348a95aa7b1b414ed62151d810e41877f238) Thanks [@samuelhenry](https://github.com/samuelhenry)! - The trial magnet gets its page pair: `/trial` and `/trial/request` ([#9302](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/9302)).
14
+
15
+ - **`create-ailk`**: a site with the `signal-collectors` module carries `/trial`, the indexable landing inside the site's Nav and Footer — the trial magnet's landing copy, one button ("Request a free trial") to `/trial/request`, and the `Assessment` JSON-LD — and `/trial/request`, the full-screen, `noindex, follow` walk of the trial magnet with a back arrow to `/trial`. When the trial flow is absent or the hub cannot answer, the walk shows the site's contact email under its own "Free trial" heading instead of a 404. The magnet landing renderer takes an optional `startPath` for a walk not at `<path>/start`.
16
+ - **`@working-theory/validation`**: `trialFlowConfig.landing` gains its proof line, `meta`: "A person on our team reviews every request." It is true for every site, because only an operator starts a trial, from the Inbox.
17
+ - **`@working-theory/database`**: the `20261026120000_trial_magnet_proof_line` migration gives that line to every workspace's trial magnet that still holds [#9301](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/9301)'s exact default. A trial magnet an owner has edited is never touched.
18
+ - **`@working-theory/templates`**: `ROUTES` gains the `/trial` and `/trial/request` rows (module `signal-collectors`).
19
+
20
+ - Updated dependencies [[`1d9b675`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/1d9b6755b3fbffebfcd7c57a93ea7cbc9941bba0), [`875714a`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/875714ad871e2b9b3778383ed106e8a1586fe3a6), [`42b37e2`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/42b37e237d0f424573346b158290f0ecc4ae77a4), [`569b348`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/569b348a95aa7b1b414ed62151d810e41877f238), [`d648b1a`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/d648b1a98a84dfee56f6847e7ec5f9fd0421a163), [`5008d1d`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/5008d1d13283fe5ed04504dcfb17122d18892a18), [`a5c5d1c`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/a5c5d1cc3443394d0da9507c7b11954b54e8c0da), [`67e75c8`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/67e75c87519235435f578ee22c2cf8fa2f74f17d), [`16c889b`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/16c889b7434ad49c3b18f900c42779c72e78f233)]:
21
+ - @working-theory/validation@0.45.0
22
+
3
23
  ## 0.44.0
4
24
 
5
25
  ### Minor Changes
@@ -21,6 +21,8 @@ import {
21
21
  REQUEST_QUOTE_FLOW_TITLE,
22
22
  requestQuoteFlowConfig,
23
23
  STANDARD_MAGNET_COMPLETIONS,
24
+ TRIAL_FLOW_SLUG,
25
+ trialFlowConfig,
24
26
  } from "@working-theory/validation";
25
27
 
26
28
  import {
@@ -114,7 +116,7 @@ describe("provisionDefaultWorkspace — the request-quote magnet (#8168 D8, C4,
114
116
 
115
117
  await provisionDefaultWorkspace(prisma);
116
118
 
117
- expect(flowDefinitionUpsert).toHaveBeenCalledTimes(4);
119
+ expect(flowDefinitionUpsert).toHaveBeenCalledTimes(5);
118
120
  expect(flowDefinitionUpsert).toHaveBeenNthCalledWith(1, {
119
121
  where: {
120
122
  siteId_slug: { siteId: DEFAULT_WORKSPACE_ID, slug: REQUEST_QUOTE_FLOW_SLUG },
@@ -238,3 +240,33 @@ describe("provisionDefaultWorkspace — the newsletter magnet (#8847 D4, AC-5)",
238
240
  });
239
241
  });
240
242
  });
243
+
244
+ describe("provisionDefaultWorkspace — the trial magnet (#9287 D11, #9301 AC-1)", () => {
245
+ it("upserts the default workspace's live trial magnet, insert-if-absent, as the fifth magnet", async () => {
246
+ const { prisma, flowDefinitionUpsert } = makeStubPrisma(0);
247
+
248
+ await provisionDefaultWorkspace(prisma);
249
+
250
+ expect(flowDefinitionUpsert).toHaveBeenCalledTimes(5);
251
+ expect(flowDefinitionUpsert).toHaveBeenNthCalledWith(5, {
252
+ where: {
253
+ siteId_slug: { siteId: DEFAULT_WORKSPACE_ID, slug: TRIAL_FLOW_SLUG },
254
+ },
255
+ create: {
256
+ siteId: DEFAULT_WORKSPACE_ID,
257
+ workspaceId: DEFAULT_WORKSPACE_ID,
258
+ slug: "trial",
259
+ config: trialFlowConfig,
260
+ kind: "diagnostic",
261
+ title: "Free trial",
262
+ status: "live",
263
+ customersEnabled: false,
264
+ // No account invite: the licence key's email is the invitation.
265
+ completion: "lead",
266
+ landingPage: "/trial",
267
+ },
268
+ // Empty: a re-run never overwrites the owner's edits.
269
+ update: {},
270
+ });
271
+ });
272
+ });
@@ -0,0 +1,186 @@
1
+ /**
2
+ * @file trial-magnet-migration.test.ts
3
+ *
4
+ * DB-free: migrations/20261025120000_trial_magnet writes the trial magnet's
5
+ * config as a JSONB literal, because SQL cannot import TS (#9287 spec
6
+ * docs/specs/9287-operator-started-trials-spec.md D11; #9301 AC-1). This test
7
+ * holds the literal to its one TS source so they can never drift, as
8
+ * `in-page-magnets-migration.test.ts` does for the newsletter backfill:
9
+ * `trial backfill` inserts `trialFlowConfig` into every workspace,
10
+ * insert-only on `(site_id, slug)`, so an owner's own row at `trial` is never
11
+ * updated.
12
+ */
13
+ import { readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ import {
17
+ flowConfigSchema,
18
+ TRIAL_FLOW_LANDING_PAGE,
19
+ TRIAL_FLOW_SLUG,
20
+ TRIAL_FLOW_TITLE,
21
+ trialFlowConfig,
22
+ } from "@working-theory/validation";
23
+
24
+ const MIGRATION_SQL = readFileSync(
25
+ join(
26
+ __dirname,
27
+ "..",
28
+ "migrations",
29
+ "20261025120000_trial_magnet",
30
+ "migration.sql",
31
+ ),
32
+ "utf8",
33
+ );
34
+
35
+ const PROOF_MIGRATION_SQL = readFileSync(
36
+ join(
37
+ __dirname,
38
+ "..",
39
+ "migrations",
40
+ "20261026120000_trial_magnet_proof_line",
41
+ "migration.sql",
42
+ ),
43
+ "utf8",
44
+ );
45
+
46
+ /**
47
+ * #9301's shipped trial magnet — `trialFlowConfig` as #9301 wrote it, before
48
+ * #9302 added the landing's proof line. Written out here, not imported: the
49
+ * constant has moved on, and this is the value the backfill wrote and the
50
+ * #9302 upgrade's guard must match exactly (the in-page-magnets precedent).
51
+ */
52
+ const TRIAL_9301_DEFAULT = {
53
+ slug: "trial",
54
+ defaultLocale: "en",
55
+ steps: [
56
+ {
57
+ key: "about",
58
+ type: "field",
59
+ copy: { title: { en: "Free trial" }, cta: { en: "Request my trial" } },
60
+ fields: [
61
+ { name: "first_name", inputType: "text", required: true, mapsTo: "givenName", copy: { label: { en: "First name" } } },
62
+ { name: "last_name", inputType: "text", required: true, mapsTo: "familyName", copy: { label: { en: "Last name" } } },
63
+ { name: "email", inputType: "email", required: true, mapsTo: "email", copy: { label: { en: "Work email" } } },
64
+ {
65
+ name: "terms",
66
+ inputType: "consent",
67
+ required: true,
68
+ purposes: ["service_terms"],
69
+ copy: { label: { en: "I accept the [Terms](terms) and [Privacy Policy](privacy)." } },
70
+ },
71
+ ],
72
+ },
73
+ ],
74
+ ending: {
75
+ default: {
76
+ kind: "screen",
77
+ headline: { en: "Thanks, {name}." },
78
+ body: { en: "We have your request and will be in touch about your trial." },
79
+ },
80
+ },
81
+ landing: {
82
+ title: { en: "Free trial" },
83
+ description: { en: "Try it free for 14 days. No card needed." },
84
+ cta: { en: "Request a free trial" },
85
+ },
86
+ };
87
+
88
+ /** The text between `-- BEGIN <name>` and `-- END <name>`. */
89
+ function block(name: string, source: string = MIGRATION_SQL): string {
90
+ const match = new RegExp(
91
+ `-- BEGIN ${name}\\n([\\s\\S]*?)-- END ${name}`,
92
+ ).exec(source);
93
+ if (!match?.[1]) throw new Error(`${name} markers not found`);
94
+ return match[1];
95
+ }
96
+
97
+ /** A SQL string literal's body, `''` unescaped, parsed as JSON. */
98
+ const parseLiteral = (body: string): unknown =>
99
+ JSON.parse(body.replace(/''/g, "'"));
100
+
101
+ /** The SQL with every string literal blanked, so only its structure is left. */
102
+ const withoutLiterals = (sql: string): string =>
103
+ sql.replace(/'(?:[^']|'')*'/g, "''");
104
+
105
+ describe("the trial backfill (#9287 D11, #9301 AC-1)", () => {
106
+ const sql = block("trial backfill");
107
+
108
+ it("carries exactly one JSONB config literal, and it deep-equals #9301's shipped default", () => {
109
+ const literals = [...sql.matchAll(/'(\{[\s\S]*?\})'::jsonb/g)];
110
+ expect(literals).toHaveLength(1);
111
+ const parsed = parseLiteral(literals[0]![1]!);
112
+ expect(parsed).toEqual(TRIAL_9301_DEFAULT);
113
+ expect(flowConfigSchema.safeParse(parsed).success).toBe(true);
114
+ });
115
+
116
+ it("#9301's default is trialFlowConfig minus the proof line #9302 added", () => {
117
+ const { meta, ...landing } = trialFlowConfig.landing!;
118
+ expect(meta).toEqual({ en: "A person on our team reviews every request." });
119
+ expect({ ...trialFlowConfig, landing }).toEqual(TRIAL_9301_DEFAULT);
120
+ });
121
+
122
+ it("writes the constants' slug, title and landing page, kind diagnostic, live, the lead completion, into every workspace", () => {
123
+ expect(sql).toContain(
124
+ `INSERT INTO "flow_definitions" ("site_id", "workspace_id", "slug", "config", "kind", "title", "status", "customers_enabled", "completion", "landing_page", "updated_at")`,
125
+ );
126
+ expect(sql).toContain(`SELECT w."id", w."id", '${TRIAL_FLOW_SLUG}',`);
127
+ expect(sql).toContain(
128
+ `'diagnostic', '${TRIAL_FLOW_TITLE}', 'live', false, 'lead', '${TRIAL_FLOW_LANDING_PAGE}', CURRENT_TIMESTAMP`,
129
+ );
130
+ expect(sql).toContain(`FROM "workspaces" w`);
131
+ });
132
+
133
+ it('ends ON CONFLICT ("site_id", "slug") DO NOTHING, so an existing row is never overwritten', () => {
134
+ expect(
135
+ sql.trimEnd().endsWith(`ON CONFLICT ("site_id", "slug") DO NOTHING;`),
136
+ ).toBe(true);
137
+ expect(sql).not.toMatch(/DO UPDATE/i);
138
+ });
139
+
140
+ it("the whole migration is that one INSERT — no UPDATE or DELETE anywhere", () => {
141
+ const statements = withoutLiterals(MIGRATION_SQL.replace(/^--.*$/gm, ""))
142
+ .split(";")
143
+ .filter((s) => s.trim().length > 0);
144
+ expect(statements).toHaveLength(1);
145
+ expect(statements[0]!.trim()).toMatch(/^INSERT INTO "flow_definitions" /);
146
+ expect(withoutLiterals(MIGRATION_SQL.replace(/^--.*$/gm, ""))).not.toMatch(
147
+ /\b(UPDATE|DELETE)\b/i,
148
+ );
149
+ });
150
+ });
151
+
152
+ describe("the trial proof-line upgrade (#9302)", () => {
153
+ const sql = block("trial proof-line upgrade", PROOF_MIGRATION_SQL);
154
+ const statements = withoutLiterals(sql).split(";").filter((s) => s.trim().length > 0);
155
+
156
+ it("is one UPDATE of flow_definitions, and the whole migration is only that", () => {
157
+ expect(statements).toHaveLength(1);
158
+ expect(statements[0]!.trim()).toMatch(/^UPDATE "flow_definitions" SET /);
159
+ const all = withoutLiterals(PROOF_MIGRATION_SQL.replace(/^--.*$/gm, ""))
160
+ .split(";")
161
+ .filter((s) => s.trim().length > 0);
162
+ expect(all).toHaveLength(1);
163
+ expect(withoutLiterals(PROOF_MIGRATION_SQL.replace(/^--.*$/gm, ""))).not.toMatch(
164
+ /\b(INSERT|DELETE)\b/i,
165
+ );
166
+ });
167
+
168
+ it("its SET literal deep-equals trialFlowConfig, and it sets only config and updated_at", () => {
169
+ const set = /SET "config" = '(\{[\s\S]*?\})'::jsonb/.exec(sql);
170
+ expect(set?.[1]).toEqual(expect.any(String));
171
+ const parsed = parseLiteral(set![1]!);
172
+ expect(parsed).toEqual(trialFlowConfig);
173
+ expect(flowConfigSchema.safeParse(parsed).success).toBe(true);
174
+ expect(withoutLiterals(sql)).toMatch(
175
+ /SET "config" = ''::jsonb, "updated_at" = CURRENT_TIMESTAMP\s+WHERE /,
176
+ );
177
+ });
178
+
179
+ it("its WHERE names the trial slug and matches only #9301's exact default, so an owner's edit is never touched", () => {
180
+ expect(withoutLiterals(sql)).toMatch(/WHERE "slug" = '' AND "config" = ''::jsonb;\s*$/);
181
+ expect(sql).toContain(`WHERE "slug" = '${TRIAL_FLOW_SLUG}' AND "config" = '`);
182
+ const guard = /AND "config" = '(\{[\s\S]*?\})'::jsonb;/.exec(sql);
183
+ expect(guard?.[1]).toEqual(expect.any(String));
184
+ expect(parseLiteral(guard![1]!)).toEqual(TRIAL_9301_DEFAULT);
185
+ });
186
+ });
@@ -0,0 +1,28 @@
1
+ -- #9301 — the standard trial magnet (spec
2
+ -- docs/specs/9287-operator-started-trials-spec.md D11, C1).
3
+ --
4
+ -- One marked block, re-runnable and held to its TypeScript constant by
5
+ -- database/__tests__/trial-magnet-migration.test.ts, on the newsletter
6
+ -- backfill's footing (20261022120000_in_page_magnets):
7
+ --
8
+ -- trial backfill — every existing workspace gets the standard trial
9
+ -- magnet (`trialFlowConfig`, `diagnostic`, `live`,
10
+ -- landing page /trial, completion `lead`). Insert-only
11
+ -- on `(site_id, slug)`: a row already at `trial` (an
12
+ -- owner's own magnet included) is never changed. A
13
+ -- workspace created after this runs is seeded by
14
+ -- `POST /v1/workspaces`, and the seed-presets CLI covers
15
+ -- one created between this migration and the route
16
+ -- code's deploy.
17
+ --
18
+ -- Rollback: DELETE FROM "flow_definitions" WHERE "slug" = 'trial' AND
19
+ -- "config" = <the backfill literal>::jsonb;
20
+
21
+ -- BEGIN trial backfill
22
+ INSERT INTO "flow_definitions" ("site_id", "workspace_id", "slug", "config", "kind", "title", "status", "customers_enabled", "completion", "landing_page", "updated_at")
23
+ SELECT w."id", w."id", 'trial',
24
+ '{"slug":"trial","defaultLocale":"en","steps":[{"key":"about","type":"field","copy":{"title":{"en":"Free trial"},"cta":{"en":"Request my trial"}},"fields":[{"name":"first_name","inputType":"text","required":true,"mapsTo":"givenName","copy":{"label":{"en":"First name"}}},{"name":"last_name","inputType":"text","required":true,"mapsTo":"familyName","copy":{"label":{"en":"Last name"}}},{"name":"email","inputType":"email","required":true,"mapsTo":"email","copy":{"label":{"en":"Work email"}}},{"name":"terms","inputType":"consent","required":true,"purposes":["service_terms"],"copy":{"label":{"en":"I accept the [Terms](terms) and [Privacy Policy](privacy)."}}}]}],"ending":{"default":{"kind":"screen","headline":{"en":"Thanks, {name}."},"body":{"en":"We have your request and will be in touch about your trial."}}},"landing":{"title":{"en":"Free trial"},"description":{"en":"Try it free for 14 days. No card needed."},"cta":{"en":"Request a free trial"}}}'::jsonb,
25
+ 'diagnostic', 'Free trial', 'live', false, 'lead', '/trial', CURRENT_TIMESTAMP
26
+ FROM "workspaces" w
27
+ ON CONFLICT ("site_id", "slug") DO NOTHING;
28
+ -- END trial backfill
@@ -0,0 +1,23 @@
1
+ -- #9302 — the trial magnet's landing gains its proof line (spec
2
+ -- docs/specs/9287-operator-started-trials-spec.md C2; operator decision on
3
+ -- #9302, option (a)).
4
+ --
5
+ -- One marked block, held to its TypeScript constant by
6
+ -- database/__tests__/trial-magnet-migration.test.ts, on the contact-form
7
+ -- upgrade's footing (20261022120000_in_page_magnets):
8
+ --
9
+ -- trial proof-line upgrade — a trial magnet whose config is still exactly
10
+ -- #9301's shipped default (the backfill literal
11
+ -- in 20261025120000_trial_magnet) becomes
12
+ -- `trialFlowConfig`, which adds
13
+ -- `landing.meta`. An owner's edited trial magnet
14
+ -- is never touched. Sets nothing but `config`
15
+ -- and `updated_at`.
16
+ --
17
+ -- Rollback: UPDATE "flow_definitions" SET "config" = <the WHERE literal>::jsonb
18
+ -- WHERE "slug" = 'trial' AND "config" = <the SET literal>::jsonb;
19
+
20
+ -- BEGIN trial proof-line upgrade
21
+ UPDATE "flow_definitions" SET "config" = '{"slug":"trial","defaultLocale":"en","steps":[{"key":"about","type":"field","copy":{"title":{"en":"Free trial"},"cta":{"en":"Request my trial"}},"fields":[{"name":"first_name","inputType":"text","required":true,"mapsTo":"givenName","copy":{"label":{"en":"First name"}}},{"name":"last_name","inputType":"text","required":true,"mapsTo":"familyName","copy":{"label":{"en":"Last name"}}},{"name":"email","inputType":"email","required":true,"mapsTo":"email","copy":{"label":{"en":"Work email"}}},{"name":"terms","inputType":"consent","required":true,"purposes":["service_terms"],"copy":{"label":{"en":"I accept the [Terms](terms) and [Privacy Policy](privacy)."}}}]}],"ending":{"default":{"kind":"screen","headline":{"en":"Thanks, {name}."},"body":{"en":"We have your request and will be in touch about your trial."}}},"landing":{"title":{"en":"Free trial"},"description":{"en":"Try it free for 14 days. No card needed."},"meta":{"en":"A person on our team reviews every request."},"cta":{"en":"Request a free trial"}}}'::jsonb, "updated_at" = CURRENT_TIMESTAMP
22
+ WHERE "slug" = 'trial' AND "config" = '{"slug":"trial","defaultLocale":"en","steps":[{"key":"about","type":"field","copy":{"title":{"en":"Free trial"},"cta":{"en":"Request my trial"}},"fields":[{"name":"first_name","inputType":"text","required":true,"mapsTo":"givenName","copy":{"label":{"en":"First name"}}},{"name":"last_name","inputType":"text","required":true,"mapsTo":"familyName","copy":{"label":{"en":"Last name"}}},{"name":"email","inputType":"email","required":true,"mapsTo":"email","copy":{"label":{"en":"Work email"}}},{"name":"terms","inputType":"consent","required":true,"purposes":["service_terms"],"copy":{"label":{"en":"I accept the [Terms](terms) and [Privacy Policy](privacy)."}}}]}],"ending":{"default":{"kind":"screen","headline":{"en":"Thanks, {name}."},"body":{"en":"We have your request and will be in touch about your trial."}}},"landing":{"title":{"en":"Free trial"},"description":{"en":"Try it free for 14 days. No card needed."},"cta":{"en":"Request a free trial"}}}'::jsonb;
23
+ -- END trial proof-line upgrade
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@working-theory/database",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "license": "Apache-2.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -41,6 +41,10 @@ import {
41
41
  REQUEST_QUOTE_FLOW_SLUG,
42
42
  REQUEST_QUOTE_FLOW_TITLE,
43
43
  requestQuoteFlowConfig,
44
+ TRIAL_FLOW_LANDING_PAGE,
45
+ TRIAL_FLOW_SLUG,
46
+ TRIAL_FLOW_TITLE,
47
+ trialFlowConfig,
44
48
  } from "@working-theory/validation";
45
49
 
46
50
  import type { PrismaClient } from "./generated/client/client.js";
@@ -217,6 +221,29 @@ export async function provisionDefaultWorkspace(
217
221
  update: {},
218
222
  });
219
223
 
224
+ // #9287 D11 — the standard trial magnet, the same way.
225
+ await prisma.flowDefinition.upsert({
226
+ where: {
227
+ siteId_slug: {
228
+ siteId: DEFAULT_WORKSPACE_ID,
229
+ slug: TRIAL_FLOW_SLUG,
230
+ },
231
+ },
232
+ create: {
233
+ siteId: DEFAULT_WORKSPACE_ID,
234
+ workspaceId: DEFAULT_WORKSPACE_ID,
235
+ slug: TRIAL_FLOW_SLUG,
236
+ config: trialFlowConfig,
237
+ kind: "diagnostic",
238
+ title: TRIAL_FLOW_TITLE,
239
+ status: "live",
240
+ customersEnabled: false,
241
+ completion: "lead",
242
+ landingPage: TRIAL_FLOW_LANDING_PAGE,
243
+ },
244
+ update: {},
245
+ });
246
+
220
247
  return { created: true };
221
248
  }
222
249