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.
- package/component-catalog.md +175 -14
- package/dist/cli.js +0 -0
- package/package.json +14 -15
- package/templates/apps/api/CLAUDE.md +4 -1
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +11 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +127 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +221 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +468 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +707 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +11 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +467 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +15 -0
- package/templates/apps/api/src/routes/project-listings/me.ts +76 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +8 -0
- package/templates/apps/api/src/server.ts +14 -0
- package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
- package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +684 -0
- package/templates/apps/api/src/services/consent.ts +236 -0
- package/templates/apps/api/src/services/flow-engine.ts +18 -0
- package/templates/apps/api/src/services/listing-promotion.ts +342 -0
- package/templates/apps/api/src/services/project-listing-decision.ts +21 -1
- package/templates/apps/api/src/services/project-listings.ts +373 -25
- package/templates/apps/web/app/[locale]/layout.tsx +13 -1
- package/templates/apps/web/jest.config.cjs +6 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
- package/templates/apps/web/lib/site-brand.tsx +4 -1
- package/templates/apps/web/lib/site-theme.ts +74 -0
- package/templates/apps/web/package.json +1 -0
- package/templates/content/_site.mdx +12 -0
- package/templates/database/CHANGELOG.md +61 -0
- package/templates/database/inbox/schema.prisma +98 -0
- package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
- package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
- package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
- package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/db-generate-locked.sh +0 -0
- 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
|