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.
- package/README.md +27 -14
- package/dist/add-module.js +6 -1
- package/dist/add-route.js +7 -1
- package/dist/derive-architecture.js +17 -0
- package/dist/lib/scaffold-render-sweep.d.ts +33 -0
- package/dist/lib/scaffold-render-sweep.js +45 -0
- package/dist/module-architecture.js +17 -0
- package/dist/sync-routes.d.ts +30 -4
- package/dist/sync-routes.js +271 -18
- package/package.json +3 -3
- package/templates/.env.example +4 -0
- package/templates/CLAUDE.md +4 -2
- package/templates/apps/api/.env.example +7 -0
- package/templates/apps/api/CLAUDE.md +25 -11
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +4 -1
- package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
- package/templates/apps/api/src/lib/site.ts +4 -1
- package/templates/apps/api/src/lib/stripe.ts +3 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
- package/templates/apps/api/src/openapi/spec.ts +333 -0
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +18 -11
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +126 -0
- package/templates/apps/api/src/routes/content/get-page.ts +34 -119
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
- package/templates/apps/api/src/routes/content/search.ts +16 -45
- package/templates/apps/api/src/routes/leads/index.ts +364 -298
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +1 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +2 -0
- package/templates/apps/api/src/server.ts +25 -0
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +10 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +28 -0
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +6 -0
- package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
- package/templates/apps/api/src/services/abandon-email.ts +28 -5
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +2 -7
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +53 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +107 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +4 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +4 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +10 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +23 -5
- package/templates/apps/api/src/services/offerings/connection.ts +83 -3
- package/templates/apps/api/src/services/offerings/index.ts +6 -6
- package/templates/apps/api/src/services/person.ts +23 -0
- package/templates/apps/api/src/services/project-listings.ts +43 -31
- package/templates/apps/mcp/CLAUDE.md +11 -5
- package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
- package/templates/apps/mcp/__tests__/http-transport.test.ts +91 -10
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
- package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
- package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
- package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
- package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
- package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
- package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
- package/templates/apps/mcp/package.json +1 -0
- package/templates/apps/mcp/src/abuse/index.ts +18 -11
- package/templates/apps/mcp/src/http-transport.ts +47 -9
- package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
- package/templates/apps/mcp/src/lib/site-config.ts +53 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
- package/templates/apps/mcp/src/tools/index.ts +22 -21
- package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
- package/templates/apps/mcp/src/tools/read_content.ts +46 -40
- package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
- package/templates/apps/mcp/src/tools/search_content.ts +20 -24
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
- package/templates/apps/web/CLAUDE.md +12 -3
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +14 -4
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +21 -4
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +7 -0
- package/templates/apps/web/lib/magnet-routes.ts +3 -1
- package/templates/apps/web/lib/newsletter.actions.ts +9 -6
- package/templates/database/CHANGELOG.md +20 -0
- package/templates/database/__tests__/seed.test.ts +33 -1
- package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
- package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
- package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +27 -0
- package/templates/tools/lint-architecture.ts +205 -1
- package/versions.json +34 -34
- 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
|
-
*
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
|
@@ -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
|
|