create-ailk 0.1.1 → 0.2.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 (40) hide show
  1. package/component-catalog.md +175 -14
  2. package/dist/cli.js +0 -0
  3. package/package.json +14 -15
  4. package/templates/apps/api/CLAUDE.md +4 -1
  5. package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
  6. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +11 -0
  7. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +127 -0
  8. package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +221 -0
  9. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +468 -0
  10. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +707 -0
  11. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +11 -0
  12. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +467 -0
  13. package/templates/apps/api/src/routes/project-listings/index.ts +15 -0
  14. package/templates/apps/api/src/routes/project-listings/me.ts +76 -0
  15. package/templates/apps/api/src/routes/project-listings/start.ts +8 -0
  16. package/templates/apps/api/src/server.ts +14 -0
  17. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  18. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  19. package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +684 -0
  20. package/templates/apps/api/src/services/consent.ts +236 -0
  21. package/templates/apps/api/src/services/flow-engine.ts +18 -0
  22. package/templates/apps/api/src/services/listing-promotion.ts +342 -0
  23. package/templates/apps/api/src/services/project-listing-decision.ts +21 -1
  24. package/templates/apps/api/src/services/project-listings.ts +373 -25
  25. package/templates/apps/web/app/[locale]/layout.tsx +13 -1
  26. package/templates/apps/web/jest.config.cjs +6 -0
  27. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  28. package/templates/apps/web/lib/site-brand.tsx +4 -1
  29. package/templates/apps/web/lib/site-theme.ts +74 -0
  30. package/templates/apps/web/package.json +1 -0
  31. package/templates/content/_site.mdx +12 -0
  32. package/templates/database/CHANGELOG.md +61 -0
  33. package/templates/database/inbox/schema.prisma +98 -0
  34. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  35. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  36. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  37. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  38. package/templates/database/package.json +1 -1
  39. package/templates/database/scripts/db-generate-locked.sh +0 -0
  40. package/templates/package.json +1 -1
@@ -109,6 +109,17 @@ function installStore(): void {
109
109
  },
110
110
  );
111
111
 
112
+ // #5275 — the grant write `start` makes after the row. Append-only; it only
113
+ // needs to resolve here (drafts.test.ts asserts on what it is called with).
114
+ (prisma.consentGrant.createMany as jest.Mock).mockImplementation(
115
+ ({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
116
+ );
117
+
118
+ // #5275 — `start` writes the row and its grant in one interactive
119
+ // transaction; the mock hands the same store-backed client through.
120
+ (prisma.$transaction as jest.Mock).mockImplementation(
121
+ async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma),
122
+ );
112
123
  (prisma.projectListing.create as jest.Mock).mockImplementation(({ data }: { data: Row }) => {
113
124
  const now = new Date();
114
125
  const row: Row = {
@@ -0,0 +1,467 @@
1
+ /**
2
+ * @file structured-address.test.ts
3
+ *
4
+ * THE ACCEPTANCE GATE for the structured founder address (#5279).
5
+ *
6
+ * The application collected a whole business address in ONE free-text box.
7
+ * It now renders a fieldset, and the payload field `address` became a UNION:
8
+ * the legacy display STRING or the structured PARTS. The row keeps `address`
9
+ * as the single-line DISPLAY string and gains six nullable part columns
10
+ * beside it, which the server writes — and composes the display line from —
11
+ * in `draftUpdateData`.
12
+ *
13
+ * Two properties carry this change, and both look fine when broken, which is
14
+ * why they each get a test that fails when they are:
15
+ *
16
+ * 1. The parts arrive as PARTS. A patch carrying the structured object must
17
+ * land six DISTINCT column values, not one concatenated string in
18
+ * `address` with the columns left null. A service that quietly stringified
19
+ * the object would produce a row that reads correctly on the listing page
20
+ * and has silently thrown away the split the fieldset exists to capture.
21
+ *
22
+ * 2. A legacy free-text address round-trips CHARACTER-FOR-CHARACTER with its
23
+ * parts null. Nothing may ever split an existing `address` into parts:
24
+ * address parsing guesses, and a wrong guess silently corrupts the party
25
+ * identification the listing's privacy notice points at (#5036 D6), with
26
+ * nothing on the row to mark it as invented. A null part is honest; a
27
+ * guessed one is not.
28
+ *
29
+ * Same harness as `configured-application.test.ts` and `drafts.test.ts`: real
30
+ * routes, real services, mocked `lib/prisma.js` over a small in-memory row
31
+ * store, `routeLead` running for real against a per-test temp path.
32
+ */
33
+
34
+ // eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock
35
+ jest.mock("../../../lib/prisma.js");
36
+
37
+ jest.mock("@working-theory/email", () => ({
38
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
39
+ }));
40
+
41
+ jest.mock("@working-theory/license", () => ({
42
+ checkLicense: jest.fn().mockResolvedValue({
43
+ tier: "free",
44
+ plan: null,
45
+ features: [],
46
+ expiresAt: null,
47
+ revoked: false,
48
+ }),
49
+ }));
50
+
51
+ import Fastify from "fastify";
52
+ import type { FastifyInstance } from "fastify";
53
+
54
+ import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
55
+ import { prisma } from "../../../lib/prisma.js";
56
+ import { patchProjectListingDraftRoute } from "../patch-draft.js";
57
+ import { startProjectListingRoute } from "../start.js";
58
+ import { submitProjectListingRoute } from "../submit.js";
59
+
60
+ // ─── Fixtures ───────────────────────────────────────────────────────────────
61
+
62
+ const SITE_ID = "site-1";
63
+ const SITE_KEY = "pk-site-1";
64
+
65
+ const opt = (value: string, label: string) => ({ value, label });
66
+
67
+ const VALID_QUESTIONS = {
68
+ q2: {
69
+ prompt: "Why are you here?",
70
+ options: [opt("scale", "Scaling my team"), opt("just-curious", "Just curious")],
71
+ },
72
+ q3: { prompt: "Stage?", options: [opt("a", "A"), opt("b", "B"), opt("c", "C")] },
73
+ q4: { prompt: "Team size?", options: [opt("a", "A"), opt("b", "B"), opt("c", "C")] },
74
+ q5: {
75
+ prompt: "Urgency?",
76
+ options: [opt("urgent", "Now"), opt("planning", "Soon"), opt("exploring", "Someday")],
77
+ },
78
+ q6: {
79
+ prompt: "Pain?",
80
+ options: [
81
+ opt("low", "Low"),
82
+ opt("medium", "Medium"),
83
+ opt("high", "High"),
84
+ opt("critical", "Critical"),
85
+ ],
86
+ },
87
+ q7: { prompt: "Anything else?" },
88
+ extras: [],
89
+ };
90
+
91
+ const START_PAYLOAD = {
92
+ siteKey: SITE_KEY,
93
+ founderName: "Ada Founder",
94
+ founderEmail: "ada@example.com",
95
+ consent: true,
96
+ };
97
+
98
+ /** Everything the ten steps after step one collect, minus the address. */
99
+ const APPLICATION_WITHOUT_ADDRESS = {
100
+ name: "My Project",
101
+ headline: "Ten words or fewer for the tile title here",
102
+ oneLiner: "A short sentence describing what the project does for its people.",
103
+ problem: "The problem is real and this describes it.",
104
+ product: "wireframes",
105
+ commitment: "part-time",
106
+ teamComposition: "co-founders",
107
+ faqs: [
108
+ { question: "Q one?", answer: "A one." },
109
+ { question: "Q two?", answer: "A two." },
110
+ { question: "Q three?", answer: "A three." },
111
+ ],
112
+ questions: VALID_QUESTIONS,
113
+ };
114
+
115
+ /** What `AddressFieldset` posts — six controls under six fixed keys. */
116
+ const ADDRESS_PARTS = {
117
+ addressLine1: "221B Baker Street",
118
+ addressLine2: "Flat B",
119
+ city: "London",
120
+ region: "Greater London",
121
+ postalCode: "NW1 6XE",
122
+ country: "GB",
123
+ };
124
+
125
+ /**
126
+ * The display line the SERVER must compose from `ADDRESS_PARTS` — the parts
127
+ * in reading order, joined with ", ". Written out literally rather than by
128
+ * calling `composeAddressLine`, so a change to the joiner fails this test
129
+ * instead of travelling through it.
130
+ */
131
+ const COMPOSED_ADDRESS_LINE =
132
+ "221B Baker Street, Flat B, London, Greater London, NW1 6XE, GB";
133
+
134
+ /**
135
+ * An address a founder typed into the ONE free-text box, before #5279. It is
136
+ * not in the composed order, its parts are not separable by any rule, and
137
+ * nothing may ever try: it is here to be returned unchanged.
138
+ */
139
+ const LEGACY_ADDRESS_LINE =
140
+ "221B Baker Street, Marylebone, London NW1 6XE, United Kingdom";
141
+
142
+ // ─── The in-memory row store the Prisma mock is backed by ───────────────────
143
+
144
+ type Row = Record<string, unknown>;
145
+
146
+ let rows: Row[];
147
+ let nextId: number;
148
+
149
+ function matches(row: Row, where: Record<string, unknown>): boolean {
150
+ return Object.entries(where).every(([key, value]) => {
151
+ if (key === "OR") {
152
+ return (value as Record<string, unknown>[]).some((clause) => matches(row, clause));
153
+ }
154
+ if (value !== null && typeof value === "object" && !(value instanceof Date)) {
155
+ const clause = value as Record<string, unknown>;
156
+ if ("in" in clause) return (clause["in"] as unknown[]).includes(row[key]);
157
+ if ("not" in clause) return row[key] !== clause["not"];
158
+ if ("gt" in clause) return (row[key] as Date) > (clause["gt"] as Date);
159
+ if ("startsWith" in clause) {
160
+ return (
161
+ typeof row[key] === "string" &&
162
+ (row[key] as string).startsWith(clause["startsWith"] as string)
163
+ );
164
+ }
165
+ }
166
+ return row[key] === value;
167
+ });
168
+ }
169
+
170
+ function installStore(): void {
171
+ rows = [];
172
+ nextId = 1;
173
+
174
+ (prisma.lead.create as jest.Mock).mockImplementation(() =>
175
+ Promise.resolve({ id: `lead_00000000-0000-7000-8000-00000000000${nextId}` }),
176
+ );
177
+
178
+ (prisma.siteListingConfig.findUnique as jest.Mock).mockImplementation(
179
+ ({ where }: { where: { siteId?: string; publicKey?: string } }) => {
180
+ if (where.publicKey !== undefined) {
181
+ return Promise.resolve(where.publicKey === SITE_KEY ? { siteId: SITE_ID } : null);
182
+ }
183
+ // No config row — the platform defaults, which collect `address`.
184
+ return Promise.resolve(null);
185
+ },
186
+ );
187
+
188
+ // #5275 — the grant write `start` makes after the row. Append-only; it only
189
+ // needs to resolve here (drafts.test.ts asserts on what it is called with).
190
+ (prisma.consentGrant.createMany as jest.Mock).mockImplementation(
191
+ ({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
192
+ );
193
+
194
+ // #5275 — `start` writes the row and its grant in one interactive
195
+ // transaction; the mock hands the same store-backed client through.
196
+ (prisma.$transaction as jest.Mock).mockImplementation(
197
+ async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma),
198
+ );
199
+
200
+ (prisma.projectListing.create as jest.Mock).mockImplementation(({ data }: { data: Row }) => {
201
+ const now = new Date();
202
+ const row: Row = {
203
+ id: `prjl_00000000-0000-7000-8000-00000000000${nextId++}`,
204
+ slug: null,
205
+ name: null,
206
+ oneLiner: null,
207
+ problem: null,
208
+ url: null,
209
+ product: null,
210
+ commitment: null,
211
+ teamComposition: null,
212
+ faqs: null,
213
+ anythingElse: null,
214
+ address: null,
215
+ // #5279 — a freshly-started draft has no parts, exactly like every row
216
+ // written before the columns existed.
217
+ addressLine1: null,
218
+ addressLine2: null,
219
+ addressCity: null,
220
+ addressRegion: null,
221
+ addressPostalCode: null,
222
+ addressCountry: null,
223
+ questions: null,
224
+ founderCompany: null,
225
+ leadId: null,
226
+ headline: null,
227
+ logoMarkUrl: null,
228
+ logoWordmarkUrl: null,
229
+ featured: false,
230
+ decidedAt: null,
231
+ decidedBy: null,
232
+ issueUrl: null,
233
+ experimentKey: null,
234
+ windowEndsAt: null,
235
+ createdAt: now,
236
+ updatedAt: now,
237
+ ...data,
238
+ };
239
+ rows.push(row);
240
+ return Promise.resolve(row);
241
+ });
242
+
243
+ (prisma.projectListing.findFirst as jest.Mock).mockImplementation(
244
+ ({ where }: { where: Record<string, unknown> }) =>
245
+ Promise.resolve(rows.find((row) => matches(row, where)) ?? null),
246
+ );
247
+
248
+ (prisma.projectListing.findMany as jest.Mock).mockImplementation(
249
+ ({ where }: { where: Record<string, unknown> }) =>
250
+ Promise.resolve(rows.filter((row) => matches(row, where))),
251
+ );
252
+
253
+ (prisma.projectListing.update as jest.Mock).mockImplementation(
254
+ ({ where, data }: { where: Record<string, unknown>; data: Row }) => {
255
+ const row = rows.find((r) => matches(r, where));
256
+ Object.assign(row!, data, { updatedAt: new Date() });
257
+ return Promise.resolve(row);
258
+ },
259
+ );
260
+
261
+ (prisma.projectListing.updateMany as jest.Mock).mockImplementation(
262
+ ({ where, data }: { where: Record<string, unknown>; data: Row }) => {
263
+ const matched = rows.filter((r) => matches(r, where));
264
+ matched.forEach((r) => Object.assign(r, data, { updatedAt: new Date() }));
265
+ return Promise.resolve({ count: matched.length });
266
+ },
267
+ );
268
+ }
269
+
270
+ // ─── Helpers ────────────────────────────────────────────────────────────────
271
+
272
+ const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
273
+
274
+ async function buildApp(): Promise<FastifyInstance> {
275
+ const app = Fastify();
276
+ await app.register(startProjectListingRoute);
277
+ await app.register(patchProjectListingDraftRoute);
278
+ await app.register(submitProjectListingRoute);
279
+ await app.ready();
280
+ return app;
281
+ }
282
+
283
+ async function startDraft(
284
+ app: FastifyInstance,
285
+ ): Promise<{ id: string; resumeToken: string }> {
286
+ const res = await app.inject({
287
+ method: "POST",
288
+ url: "/v1/project-listings",
289
+ payload: START_PAYLOAD,
290
+ });
291
+ expect(res.statusCode).toBe(201);
292
+ return (res.json() as { data: { id: string; resumeToken: string } }).data;
293
+ }
294
+
295
+ const patch = (
296
+ app: FastifyInstance,
297
+ draft: { id: string; resumeToken: string },
298
+ payload: Record<string, unknown>,
299
+ ) =>
300
+ app.inject({
301
+ method: "PATCH",
302
+ url: `/v1/project-listings/drafts/${draft.id}`,
303
+ headers: bearer(draft.resumeToken),
304
+ payload,
305
+ });
306
+
307
+ const submit = (app: FastifyInstance, draft: { id: string; resumeToken: string }) =>
308
+ app.inject({
309
+ method: "POST",
310
+ url: `/v1/project-listings/drafts/${draft.id}/submit`,
311
+ headers: bearer(draft.resumeToken),
312
+ });
313
+
314
+ const storedRow = (id: string): Row => {
315
+ const row = rows.find((r) => r["id"] === id);
316
+ expect(row).toBeDefined();
317
+ return row!;
318
+ };
319
+
320
+ const draftBody = (res: { json: () => unknown }) =>
321
+ (res.json() as { data: Record<string, unknown> }).data;
322
+
323
+ // ─── Tests ──────────────────────────────────────────────────────────────────
324
+
325
+ describe("the structured founder address (#5279)", () => {
326
+ setupLeadFileEnv("project-listing-structured-address");
327
+ let app: FastifyInstance;
328
+
329
+ beforeEach(async () => {
330
+ jest.clearAllMocks();
331
+ installStore();
332
+ app = await buildApp();
333
+ });
334
+
335
+ afterEach(async () => {
336
+ await app.close();
337
+ });
338
+
339
+ describe("the parts arrive as PARTS, and the server composes the display line", () => {
340
+ it("stores six DISTINCT column values, not one concatenated string", async () => {
341
+ const draft = await startDraft(app);
342
+
343
+ const res = await patch(app, draft, { address: ADDRESS_PARTS });
344
+
345
+ expect(res.statusCode).toBe(200);
346
+
347
+ // The column-level half. Each part is in its OWN column — a service that
348
+ // stringified the object would leave every one of these null and this
349
+ // test is the thing that notices.
350
+ const row = storedRow(draft.id);
351
+ expect(row["addressLine1"]).toBe("221B Baker Street");
352
+ expect(row["addressLine2"]).toBe("Flat B");
353
+ expect(row["addressCity"]).toBe("London");
354
+ expect(row["addressRegion"]).toBe("Greater London");
355
+ expect(row["addressPostalCode"]).toBe("NW1 6XE");
356
+ expect(row["addressCountry"]).toBe("GB");
357
+
358
+ // The display half. `address` is DERIVED here and only here — the parts
359
+ // in reading order, joined with ", ".
360
+ expect(row["address"]).toBe(COMPOSED_ADDRESS_LINE);
361
+ });
362
+
363
+ it("answers the save with the composed line AND the parts, separably", async () => {
364
+ const draft = await startDraft(app);
365
+
366
+ const body = draftBody(await patch(app, draft, { address: ADDRESS_PARTS }));
367
+
368
+ expect(body["address"]).toBe(COMPOSED_ADDRESS_LINE);
369
+ expect(body["addressParts"]).toEqual(ADDRESS_PARTS);
370
+ });
371
+
372
+ it("omits the blank addressLine2 from both the columns and the composed line", async () => {
373
+ const draft = await startDraft(app);
374
+ const { addressLine2: _line2, ...withoutLine2 } = ADDRESS_PARTS;
375
+
376
+ const body = draftBody(await patch(app, draft, { address: withoutLine2 }));
377
+
378
+ expect(storedRow(draft.id)["addressLine2"]).toBeNull();
379
+ expect(body["address"]).toBe("221B Baker Street, London, Greater London, NW1 6XE, GB");
380
+ expect(body["addressParts"]).toEqual(withoutLine2);
381
+ });
382
+
383
+ it("a composed address submits, and reaches the row as the display line", async () => {
384
+ const draft = await startDraft(app);
385
+ await patch(app, draft, { ...APPLICATION_WITHOUT_ADDRESS, address: ADDRESS_PARTS });
386
+
387
+ const res = await submit(app, draft);
388
+
389
+ expect(res.statusCode).toBe(200);
390
+ expect(storedRow(draft.id)["address"]).toBe(COMPOSED_ADDRESS_LINE);
391
+ expect(storedRow(draft.id)["addressCountry"]).toBe("GB");
392
+ });
393
+
394
+ it("refuses parts that are not a whole address, rather than storing half of one", async () => {
395
+ const draft = await startDraft(app);
396
+ const { city: _city, ...incomplete } = ADDRESS_PARTS;
397
+
398
+ const res = await patch(app, draft, { address: incomplete });
399
+
400
+ expect(res.statusCode).toBe(400);
401
+ expect(storedRow(draft.id)["addressLine1"]).toBeNull();
402
+ expect(storedRow(draft.id)["address"]).toBeNull();
403
+ });
404
+ });
405
+
406
+ describe("a legacy free-text address round-trips unchanged, with its parts null", () => {
407
+ // The load-bearing guard. Nothing may split an existing `address` into
408
+ // parts — a wrong split silently corrupts the party identification the
409
+ // listing's privacy notice points at, and a null part is honest where a
410
+ // guessed one is not.
411
+ it("comes back CHARACTER-FOR-CHARACTER from a save, with addressParts null", async () => {
412
+ const draft = await startDraft(app);
413
+
414
+ const body = draftBody(await patch(app, draft, { address: LEGACY_ADDRESS_LINE }));
415
+
416
+ expect(body["address"]).toBe(LEGACY_ADDRESS_LINE);
417
+ expect(body["addressParts"]).toBeNull();
418
+ });
419
+
420
+ it("leaves EVERY part column untouched — a plain string writes none of them", async () => {
421
+ const draft = await startDraft(app);
422
+
423
+ await patch(app, draft, { address: LEGACY_ADDRESS_LINE });
424
+
425
+ const row = storedRow(draft.id);
426
+ expect(row["address"]).toBe(LEGACY_ADDRESS_LINE);
427
+ for (const column of [
428
+ "addressLine1",
429
+ "addressLine2",
430
+ "addressCity",
431
+ "addressRegion",
432
+ "addressPostalCode",
433
+ "addressCountry",
434
+ ]) {
435
+ expect(row[column]).toBeNull();
436
+ }
437
+ });
438
+
439
+ it("submits exactly as it did before #5279, and the stored line is unchanged", async () => {
440
+ const draft = await startDraft(app);
441
+ await patch(app, draft, {
442
+ ...APPLICATION_WITHOUT_ADDRESS,
443
+ address: LEGACY_ADDRESS_LINE,
444
+ });
445
+
446
+ const res = await submit(app, draft);
447
+
448
+ expect(res.statusCode).toBe(200);
449
+ expect(storedRow(draft.id)["address"]).toBe(LEGACY_ADDRESS_LINE);
450
+ expect(storedRow(draft.id)["addressLine1"]).toBeNull();
451
+ });
452
+
453
+ // A row written before #5279 holds a line and six nulls. Overwriting the
454
+ // line with a NEW line must not start inventing parts for it either.
455
+ it("re-patching a legacy line with another legacy line still writes no parts", async () => {
456
+ const draft = await startDraft(app);
457
+ await patch(app, draft, { address: LEGACY_ADDRESS_LINE });
458
+
459
+ await patch(app, draft, { address: "1 Example Street, Exampleton, EX1 2AM" });
460
+
461
+ const row = storedRow(draft.id);
462
+ expect(row["address"]).toBe("1 Example Street, Exampleton, EX1 2AM");
463
+ expect(row["addressCity"]).toBeNull();
464
+ expect(row["addressCountry"]).toBeNull();
465
+ });
466
+ });
467
+ });
@@ -7,6 +7,7 @@
7
7
  * PATCH /v1/project-listings/drafts/:id — anonymous save-as-you-go, token-gated
8
8
  * POST /v1/project-listings/drafts/:id/submit — anonymous submit, token-gated
9
9
  * GET /v1/project-listings/public — anonymous page-render read (leadsScope)
10
+ * GET /v1/project-listings/me — the founder's OWN listings (authScope)
10
11
  * GET /v1/project-listings — tenant-scoped dashboard list
11
12
  * GET /v1/project-listings/:id — tenant-scoped single read
12
13
  * PATCH /v1/project-listings/:id — tenant-scoped decision + featured flag
@@ -19,6 +20,13 @@
19
20
  * anonymous token-gated PATCH and the operator's tenant-guarded PATCH apart
20
21
  * at the router — they differ in segment count, so neither shadows the other.
21
22
  *
23
+ * `/me` (#5295) sits between the two scopes: `authScope` only, no tenant guard.
24
+ * A founder is not a workspace member, so the guard's default-deny answers 403
25
+ * for their own submission — and the fix is this ADDITIVE route, joined on the
26
+ * owner link, rather than a relaxed `PROJECT_LISTING_ROLES`. Its static `/me`
27
+ * segment takes router precedence over the parametric `/:id` below, exactly as
28
+ * the already-shipped static `/public` does.
29
+ *
22
30
  * API<->MCP parity: no MCP twin. The anonymous set mirrors the leads routes'
23
31
  * own identity-blind posture (an applying founder has no account, and the
24
32
  * resume token is a per-draft capability rather than an identity); the
@@ -27,6 +35,7 @@
27
35
  */
28
36
  import type { FastifyPluginAsync } from "fastify";
29
37
 
38
+ import { projectListingMeRoute as meRoute } from "./me.js";
30
39
  import { patchProjectListingDraftRoute } from "./patch-draft.js";
31
40
  import { publicProjectListingsRoute } from "./public.js";
32
41
  import { startProjectListingRoute } from "./start.js";
@@ -36,6 +45,12 @@ export const projectListingStartRoute = startProjectListingRoute;
36
45
  export const projectListingDraftPatchRoute = patchProjectListingDraftRoute;
37
46
  export const projectListingSubmitRoute = submitProjectListingRoute;
38
47
  export const projectListingPublicRoute = publicProjectListingsRoute;
48
+ /**
49
+ * The founder's own listings read (#5295). Static, not a slice: it depends on
50
+ * the owner link alone, so it is registered — and works — on a build with no
51
+ * multi-tenant module, the same as `flowCheckoutRoutes`.
52
+ */
53
+ export const projectListingMeRoute = meRoute;
39
54
 
40
55
  /**
41
56
  * Tenant-scoped handlers, as { specifier, exportName }, reached at REGISTRATION
@@ -0,0 +1,76 @@
1
+ /**
2
+ * GET /v1/project-listings/me
3
+ *
4
+ * The FOUNDER'S OWN listings read (#5295) — the `/v1/flow-checkouts/me` and
5
+ * `/v1/waitlist-signups/me` posture, for `ProjectListing`.
6
+ *
7
+ * Registered INSIDE `server.ts`'s `authScope` (auth only — NOT the inner
8
+ * `tenantScope`): a founder is not a workspace member, and the tenant guard is
9
+ * default-deny, so a founder reaching the tenant-scoped list route gets a 403
10
+ * for their own submission. That is the problem this route exists to solve, and
11
+ * it is solved by ADDING this route rather than by relaxing
12
+ * `PROJECT_LISTING_ROLES` — the operator floor on
13
+ * `GET|PATCH /v1/project-listings[/:id]` is unchanged and still `owner|admin`.
14
+ *
15
+ * Identity is `request.userId` ONLY. The route declares no query schema and
16
+ * reads no query or body key — an `email` parameter is never parsed and never
17
+ * read — and the service joins on the owner link and nothing else
18
+ * (`services/project-listings.ts`'s `listMyProjectListings`: `ownerUserId ===
19
+ * userId`, never a `founderEmail` comparison at read time).
20
+ *
21
+ * Route precedence: this static path is registered alongside the parametric
22
+ * `/v1/project-listings/:id` in the tenant scope. Fastify's router prefers a
23
+ * static segment over a parametric one, so `/me` reaches this handler, exactly
24
+ * as the already-shipped static `/v1/project-listings/public` coexists with the
25
+ * same parametric route.
26
+ *
27
+ * Envelope per apps/api/CLAUDE.md: `{ ok: true, data }` /
28
+ * `{ ok: false, error: { code, message } }`, real HTTP status codes:
29
+ * 401 unauthorized · 500 internal_error (the 200 body failed its own
30
+ * contract schema — a drift is never a leak). A signed-in caller who owns
31
+ * NOTHING is a 200 with an empty list, never a 403: they are legitimately
32
+ * signed in and simply own no listing yet.
33
+ *
34
+ * API↔MCP parity: identity-bound (the session IS the identity; the OSS MCP
35
+ * surface is identity-blind) — no MCP twin (#3591 exception, the same shape as
36
+ * `/v1/flow-checkouts/me` and `/v1/waitlist-signups/me`).
37
+ */
38
+ import { projectListingMeResponseSchema } from "@working-theory/validation";
39
+ import type { FastifyPluginAsync, FastifyReply } from "fastify";
40
+
41
+ import { listMyProjectListings } from "../../services/project-listings.js";
42
+
43
+ import { sendValidated } from "./respond.js";
44
+
45
+ function unauthorized(reply: FastifyReply) {
46
+ return reply.status(401).send({
47
+ ok: false,
48
+ error: { code: "unauthorized", message: "Authentication required" },
49
+ });
50
+ }
51
+
52
+ export const projectListingMeRoute: FastifyPluginAsync = async (fastify) => {
53
+ fastify.get("/v1/project-listings/me", async (request, reply) => {
54
+ const userId = request.userId;
55
+ if (!userId) return unauthorized(reply);
56
+
57
+ const listings = await listMyProjectListings(userId);
58
+
59
+ // The omission is explicit at the send site rather than hidden in a schema
60
+ // strip, the idiom `patch-draft.ts` uses: `projectListingDraftSchema` is
61
+ // `.strict()`, so a key that stops being dropped here becomes a 500 instead
62
+ // of quietly shipping an internal reference to the founder.
63
+ const data = listings.map(
64
+ ({
65
+ siteId: _siteId,
66
+ leadId: _leadId,
67
+ decidedBy: _decidedBy,
68
+ issueUrl: _issueUrl,
69
+ experimentKey: _experimentKey,
70
+ ...mine
71
+ }) => mine,
72
+ );
73
+
74
+ return sendValidated(reply, projectListingMeResponseSchema, data, "me");
75
+ });
76
+ };
@@ -83,6 +83,14 @@ export const startProjectListingRoute: FastifyPluginAsync = async (fastify) => {
83
83
  }
84
84
 
85
85
  const result = await startProjectListingDraft(siteId, parsed.data);
86
+ // #5275 — a `consents` map declining a REQUIRED purpose contradicts the
87
+ // body's own `consent: true`; no row was created.
88
+ if (!result.ok) {
89
+ return reply.status(400).send({
90
+ ok: false,
91
+ error: { code: result.error.kind, message: result.error.message },
92
+ });
93
+ }
86
94
 
87
95
  request.log.info(
88
96
  { siteId, listingId: result.id, ip: request.ip },
@@ -47,6 +47,7 @@ import {
47
47
  } from './routes/leads/index.js';
48
48
  import {
49
49
  projectListingDraftPatchRoute,
50
+ projectListingMeRoute,
50
51
  projectListingPublicRoute,
51
52
  projectListingStartRoute,
52
53
  projectListingSubmitRoute,
@@ -272,6 +273,19 @@ export async function build() {
272
273
  // the only join is the lead-promotion link (never an email match).
273
274
  await authScope.register(flowCheckoutRoutes);
274
275
 
276
+ // The founder's OWN listings read (#5295) — the same posture again: auth
277
+ // only, NOT tenant-guarded, because a founder is not a workspace member
278
+ // and the tenant guard is default-deny. Identity is request.userId; the
279
+ // only join is the owner link (ProjectListing.ownerUserId, stamped by
280
+ // services/listing-promotion.ts), never an email match. The tenant-scoped
281
+ // listing routes below keep their owner|admin floor untouched — this route
282
+ // is additive, not a relaxation of that floor. Registered here rather than
283
+ // inside the multi-tenant scope so a founder can read their own rows on a
284
+ // build without the module; the static `/me` path takes router precedence
285
+ // over the tenant scope's parametric `/v1/project-listings/:id`, exactly as
286
+ // the already-shipped static `/v1/project-listings/public` does.
287
+ await authScope.register(projectListingMeRoute);
288
+
275
289
  // Tenant-guarded domain routes (multi-tenant module) — the tenant
276
290
  // preHandler (default-deny guard, see middleware/tenant.ts) runs after
277
291
  // auth for every route in this inner scope. No domain handler executes