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
|
@@ -42,10 +42,14 @@ import {
|
|
|
42
42
|
clampWindowDays,
|
|
43
43
|
buildProjectListingDraftPatchSchema,
|
|
44
44
|
buildProjectListingSubmitSchema,
|
|
45
|
+
composeAddressLine,
|
|
46
|
+
flowAddressValueSchema,
|
|
47
|
+
publicSiteAnswers,
|
|
45
48
|
PROJECT_LISTING_CONCURRENT_LIMIT,
|
|
46
49
|
PROJECT_LISTING_NON_TERMINAL_STATUSES,
|
|
47
50
|
} from "@working-theory/validation";
|
|
48
51
|
import type {
|
|
52
|
+
FlowAddressValue,
|
|
49
53
|
ProjectListing,
|
|
50
54
|
ProjectListingDraftPatchInput,
|
|
51
55
|
ProjectListingFaqs,
|
|
@@ -56,6 +60,13 @@ import type {
|
|
|
56
60
|
|
|
57
61
|
import { prisma } from "../lib/prisma.js";
|
|
58
62
|
|
|
63
|
+
import {
|
|
64
|
+
consentConfigForSite,
|
|
65
|
+
MAX_CONSENT_KEYS,
|
|
66
|
+
recordConsent,
|
|
67
|
+
recordLegacyConsent,
|
|
68
|
+
requiredConsentUnmet,
|
|
69
|
+
} from "./consent.js";
|
|
59
70
|
import { routeLead, type SourceMeta } from "./lead-routing.js";
|
|
60
71
|
import { listingConfigFor } from "./listing-config.js";
|
|
61
72
|
|
|
@@ -205,7 +216,19 @@ const PROJECT_LISTING_SELECT = {
|
|
|
205
216
|
faqs: true,
|
|
206
217
|
anythingElse: true,
|
|
207
218
|
address: true,
|
|
219
|
+
// #5279 — the structured parts beside the display string.
|
|
220
|
+
addressLine1: true,
|
|
221
|
+
addressLine2: true,
|
|
222
|
+
addressCity: true,
|
|
223
|
+
addressRegion: true,
|
|
224
|
+
addressPostalCode: true,
|
|
225
|
+
addressCountry: true,
|
|
208
226
|
questions: true,
|
|
227
|
+
// #5285 — the site's own answers, and the window the applicant asked for.
|
|
228
|
+
// `windowDays` rides the select because `submit` re-validates the assembled
|
|
229
|
+
// application, and `requestedWindowDays` is one of its fields now.
|
|
230
|
+
siteAnswers: true,
|
|
231
|
+
windowDays: true,
|
|
209
232
|
founderName: true,
|
|
210
233
|
founderEmail: true,
|
|
211
234
|
founderCompany: true,
|
|
@@ -228,6 +251,81 @@ type ProjectListingRow = Prisma.ProjectListingGetPayload<{
|
|
|
228
251
|
select: typeof PROJECT_LISTING_SELECT;
|
|
229
252
|
}>;
|
|
230
253
|
|
|
254
|
+
/**
|
|
255
|
+
* The structured address parts of a row, or null (#5279).
|
|
256
|
+
*
|
|
257
|
+
* Exported because `project-listing-decision.ts` renders the same row shape
|
|
258
|
+
* and must answer the same way — two readers guessing separately at what
|
|
259
|
+
* "has parts" means is exactly how one of them starts emitting a half-filled
|
|
260
|
+
* object.
|
|
261
|
+
*
|
|
262
|
+
* The rule is the composite's REQUIRED set: the five non-optional parts are
|
|
263
|
+
* all present, or there are no parts. `addressLine2` is optional in the
|
|
264
|
+
* fieldset, so it rides along when present and is simply absent when not. A
|
|
265
|
+
* row written before #5279 has all six columns null and answers null here —
|
|
266
|
+
* never a partial object reconstructed from `address`, which nothing in this
|
|
267
|
+
* repo parses.
|
|
268
|
+
*
|
|
269
|
+
* `.safeParse` rather than a cast: these are six nullable TEXT columns, and
|
|
270
|
+
* the emitted object must satisfy `flowAddressValueSchema` or the row fails
|
|
271
|
+
* its own response schema downstream. A shape that somehow does not (a
|
|
272
|
+
* `country` that is not an alpha-2 code, say) answers null rather than
|
|
273
|
+
* putting an invalid address on the PUBLIC read.
|
|
274
|
+
*/
|
|
275
|
+
export function addressPartsFromRow(row: {
|
|
276
|
+
addressLine1: string | null;
|
|
277
|
+
addressLine2: string | null;
|
|
278
|
+
addressCity: string | null;
|
|
279
|
+
addressRegion: string | null;
|
|
280
|
+
addressPostalCode: string | null;
|
|
281
|
+
addressCountry: string | null;
|
|
282
|
+
}): FlowAddressValue | null {
|
|
283
|
+
if (
|
|
284
|
+
row.addressLine1 === null ||
|
|
285
|
+
row.addressCity === null ||
|
|
286
|
+
row.addressRegion === null ||
|
|
287
|
+
row.addressPostalCode === null ||
|
|
288
|
+
row.addressCountry === null
|
|
289
|
+
) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
const parsed = flowAddressValueSchema.safeParse({
|
|
293
|
+
addressLine1: row.addressLine1,
|
|
294
|
+
...(row.addressLine2 !== null && { addressLine2: row.addressLine2 }),
|
|
295
|
+
city: row.addressCity,
|
|
296
|
+
region: row.addressRegion,
|
|
297
|
+
postalCode: row.addressPostalCode,
|
|
298
|
+
country: row.addressCountry,
|
|
299
|
+
});
|
|
300
|
+
return parsed.success ? parsed.data : null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The site-answers object of a row, or null (#5285).
|
|
305
|
+
*
|
|
306
|
+
* Exported for the same reason `addressPartsFromRow` is: the operator's
|
|
307
|
+
* decision payload renders the same row shape and must answer the same way.
|
|
308
|
+
*
|
|
309
|
+
* The column is `Json?`, so Prisma types it as `JsonValue` — an array, a
|
|
310
|
+
* number and a string all satisfy it. Only ever written by
|
|
311
|
+
* `draftUpdateData` from a body the site's own declarations validated, so a
|
|
312
|
+
* row holding anything else is a row somebody wrote around this service.
|
|
313
|
+
* A non-object answers null rather than being cast: a value of the wrong
|
|
314
|
+
* SHAPE reaching the response schema would fail the whole read, and one
|
|
315
|
+
* malformed row must not take a listing page down.
|
|
316
|
+
*
|
|
317
|
+
* Non-string members are dropped for the same reason — the store is
|
|
318
|
+
* `Record<string, string>`, and what is handed back has to actually be one.
|
|
319
|
+
*/
|
|
320
|
+
export function siteAnswersFromRow(value: Prisma.JsonValue | null): Record<string, string> | null {
|
|
321
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
|
|
322
|
+
return Object.fromEntries(
|
|
323
|
+
Object.entries(value).filter(
|
|
324
|
+
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
325
|
+
),
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
231
329
|
/**
|
|
232
330
|
* `now` is passed rather than read inside so one read renders every row of a
|
|
233
331
|
* list against a single instant. `featuredEnabled` is the site's config
|
|
@@ -257,12 +355,19 @@ function toResponseRow(
|
|
|
257
355
|
faqs: row.faqs as unknown as ProjectListingFaqs | null,
|
|
258
356
|
anythingElse: row.anythingElse,
|
|
259
357
|
address: row.address,
|
|
358
|
+
addressParts: addressPartsFromRow(row),
|
|
260
359
|
founderName: row.founderName,
|
|
261
360
|
founderEmail: row.founderEmail,
|
|
262
361
|
founderCompany: row.founderCompany,
|
|
263
362
|
questions: row.questions as unknown as ProjectListingQuestions | null,
|
|
264
363
|
logoMarkUrl: row.logoMarkUrl,
|
|
265
364
|
logoWordmarkUrl: row.logoWordmarkUrl,
|
|
365
|
+
// #5285 — UNFILTERED here, deliberately. This row shape serves the tenant
|
|
366
|
+
// dashboard and the founder's own draft read, both of which are entitled
|
|
367
|
+
// to every answer. The PUBLIC read is the one that must not carry an
|
|
368
|
+
// `operator-only` answer, and it narrows this through `publicSiteAnswers`
|
|
369
|
+
// in `publicProjectListings` — one filter, on the one read that publishes.
|
|
370
|
+
siteAnswers: siteAnswersFromRow(row.siteAnswers),
|
|
266
371
|
featured: featuredEnabled && row.featured,
|
|
267
372
|
// The row's OWN flag decides the closed exemption (#5036 D6b), not the
|
|
268
373
|
// site's `featuredEnabled` switch: that switch governs whether the flag
|
|
@@ -410,17 +515,25 @@ async function findDraftByToken(
|
|
|
410
515
|
// ─── start (POST, anonymous) ────────────────────────────────────────────────
|
|
411
516
|
|
|
412
517
|
/**
|
|
413
|
-
* `start` has
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
518
|
+
* `start` has one failure mode of its own to report (#5275): a `consents`
|
|
519
|
+
* map that does not GRANT every purpose the site's registry marks required —
|
|
520
|
+
* declined, or simply absent. The legacy literal says the founder consented;
|
|
521
|
+
* a `false` (or nothing) beside it for the required purpose says they did
|
|
522
|
+
* not — the body contradicts itself, and the row must not be created on it
|
|
523
|
+
* (it would go on to publish the founder's address against a recorded
|
|
524
|
+
* decline, or with no evidence row at all). An oversized map is the same
|
|
525
|
+
* 400. Everything else is as before: the site's config falls back to the
|
|
526
|
+
* platform defaults rather than erroring, and a Prisma failure is a 500
|
|
527
|
+
* through the framework. The Lead write — the step that used to give this
|
|
528
|
+
* operation a `persist_failed`/`config_missing` surface — moved to `submit`.
|
|
418
529
|
*/
|
|
419
|
-
export type StartProjectListingDraftResult =
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
530
|
+
export type StartProjectListingDraftResult =
|
|
531
|
+
| {
|
|
532
|
+
ok: true;
|
|
533
|
+
id: string;
|
|
534
|
+
resumeToken: string;
|
|
535
|
+
}
|
|
536
|
+
| { ok: false; error: { kind: "invalid_input"; message: string } };
|
|
424
537
|
|
|
425
538
|
/**
|
|
426
539
|
* Create a `draft` from step one — the founder's name, email and consent.
|
|
@@ -450,18 +563,64 @@ export async function startProjectListingDraft(
|
|
|
450
563
|
const listingConfig = await listingConfigFor(siteId);
|
|
451
564
|
const windowDays = clampWindowDays(requestedWindowDays, listingConfig);
|
|
452
565
|
|
|
566
|
+
// #5275 — refuse BEFORE the row exists: a required purpose not granted
|
|
567
|
+
// (declined or absent) is a body that contradicts its own `consent: true`,
|
|
568
|
+
// and an oversized map is not a form's answer.
|
|
569
|
+
const consentConfig = await consentConfigForSite(siteId);
|
|
570
|
+
if (input.consents) {
|
|
571
|
+
if (Object.keys(input.consents).length > MAX_CONSENT_KEYS) {
|
|
572
|
+
return {
|
|
573
|
+
ok: false,
|
|
574
|
+
error: {
|
|
575
|
+
kind: "invalid_input",
|
|
576
|
+
message: `consents carries more than ${MAX_CONSENT_KEYS} purposes`,
|
|
577
|
+
},
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
const unmet = requiredConsentUnmet(consentConfig, input.consents);
|
|
581
|
+
if (unmet !== undefined) {
|
|
582
|
+
return {
|
|
583
|
+
ok: false,
|
|
584
|
+
error: {
|
|
585
|
+
kind: "invalid_input",
|
|
586
|
+
message: `required consent purpose "${unmet}" was not granted`,
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
453
592
|
const resumeToken = mintResumeToken();
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
593
|
+
// #5275 — the row and its grant record land in ONE transaction: from here
|
|
594
|
+
// on `consentAt` and the ConsentGrant row are stamped by the SAME
|
|
595
|
+
// affirmative act, and a grant write that fails takes the row with it
|
|
596
|
+
// rather than stranding a "consented" listing with no evidence (security
|
|
597
|
+
// review, Medium 2). A body carrying `consents` records the wording the
|
|
598
|
+
// founder read (resolved server-side from the same registry the renderer
|
|
599
|
+
// used, D7); a body carrying only the legacy `consent: true` literal
|
|
600
|
+
// records one grant marked unrecoverable (D11) rather than today's
|
|
601
|
+
// wording, which nobody was shown.
|
|
602
|
+
const consents = input.consents;
|
|
603
|
+
const locale = input.locale ?? "en";
|
|
604
|
+
const row = await prisma.$transaction(async (tx) => {
|
|
605
|
+
const created = await tx.projectListing.create({
|
|
606
|
+
data: {
|
|
607
|
+
siteId,
|
|
608
|
+
founderName,
|
|
609
|
+
founderEmail,
|
|
610
|
+
status: "draft",
|
|
611
|
+
consentAt: new Date(),
|
|
612
|
+
resumeTokenHash: hashResumeToken(resumeToken),
|
|
613
|
+
windowDays,
|
|
614
|
+
},
|
|
615
|
+
select: { id: true },
|
|
616
|
+
});
|
|
617
|
+
const subject = { siteId, subjectType: "project_listing", subjectId: created.id };
|
|
618
|
+
if (consents) {
|
|
619
|
+
await recordConsent({ ...subject, locale, config: consentConfig, consents }, tx);
|
|
620
|
+
} else {
|
|
621
|
+
await recordLegacyConsent({ ...subject, config: consentConfig }, tx);
|
|
622
|
+
}
|
|
623
|
+
return created;
|
|
465
624
|
});
|
|
466
625
|
|
|
467
626
|
return { ok: true, id: row.id, resumeToken };
|
|
@@ -478,15 +637,47 @@ export type PatchProjectListingDraftResult =
|
|
|
478
637
|
| { kind: "invalid_input"; message: string };
|
|
479
638
|
};
|
|
480
639
|
|
|
640
|
+
/**
|
|
641
|
+
* What `draftUpdateData` cannot read off the patch body (#5285).
|
|
642
|
+
*
|
|
643
|
+
* Both members are the SERVER's, and that is why they are a second argument
|
|
644
|
+
* rather than two more payload fields: `windowDays` is the applicant's
|
|
645
|
+
* request already clamped against the site's own maximum, and
|
|
646
|
+
* `storedSiteAnswers` is what is on the row, which the merge needs and a
|
|
647
|
+
* request body must never be able to supply.
|
|
648
|
+
*/
|
|
649
|
+
interface DraftUpdateContext {
|
|
650
|
+
/** The clamped window, when the patch asked for one. */
|
|
651
|
+
windowDays?: number;
|
|
652
|
+
/** The answers already stored, for the merge. */
|
|
653
|
+
storedSiteAnswers?: Record<string, string>;
|
|
654
|
+
}
|
|
655
|
+
|
|
481
656
|
/**
|
|
482
657
|
* Translate a validated partial application into a Prisma update.
|
|
483
658
|
*
|
|
484
659
|
* Only the keys the caller actually sent are written: an absent key is
|
|
485
660
|
* `undefined`, which Prisma omits from the UPDATE, so patching one field
|
|
486
661
|
* cannot blank the nine the founder filled in on earlier steps.
|
|
662
|
+
*
|
|
663
|
+
* #5279 — THIS IS WHERE THE DISPLAY ADDRESS IS COMPOSED, and the only place.
|
|
664
|
+
* `address` is a union of the legacy display STRING and the structured PARTS
|
|
665
|
+
* (`flowAddressValueSchema`), and the two branches write different columns:
|
|
666
|
+
*
|
|
667
|
+
* string → `address` verbatim, no part column touched. A founder who typed
|
|
668
|
+
* a line gets that line stored exactly as typed, and a draft
|
|
669
|
+
* written before #5279 keeps patching as it always did.
|
|
670
|
+
* parts → the six part columns, PLUS `address` composed from them by
|
|
671
|
+
* `composeAddressLine`.
|
|
672
|
+
*
|
|
673
|
+
* The derivation runs ONE WAY ONLY. Nothing anywhere derives parts from a
|
|
674
|
+
* line: address parsing guesses, and a wrong guess silently corrupts the
|
|
675
|
+
* party identification a listing's privacy notice points at (#5036 D6), with
|
|
676
|
+
* nothing on the row to mark it as invented.
|
|
487
677
|
*/
|
|
488
678
|
function draftUpdateData(
|
|
489
679
|
patch: ProjectListingDraftPatchInput,
|
|
680
|
+
context: DraftUpdateContext,
|
|
490
681
|
): Prisma.ProjectListingUpdateInput {
|
|
491
682
|
return {
|
|
492
683
|
...(patch.name !== undefined && { name: patch.name }),
|
|
@@ -499,11 +690,45 @@ function draftUpdateData(
|
|
|
499
690
|
...(patch.teamComposition !== undefined && { teamComposition: patch.teamComposition }),
|
|
500
691
|
...(patch.faqs !== undefined && { faqs: patch.faqs as unknown as Prisma.InputJsonValue }),
|
|
501
692
|
...(patch.anythingElse !== undefined && { anythingElse: patch.anythingElse }),
|
|
502
|
-
...(patch.address !== undefined &&
|
|
693
|
+
...(patch.address !== undefined &&
|
|
694
|
+
(typeof patch.address === "string"
|
|
695
|
+
? { address: patch.address }
|
|
696
|
+
: {
|
|
697
|
+
address: composeAddressLine(patch.address),
|
|
698
|
+
addressLine1: patch.address.addressLine1,
|
|
699
|
+
addressLine2: patch.address.addressLine2 ?? null,
|
|
700
|
+
addressCity: patch.address.city,
|
|
701
|
+
addressRegion: patch.address.region,
|
|
702
|
+
addressPostalCode: patch.address.postalCode,
|
|
703
|
+
addressCountry: patch.address.country,
|
|
704
|
+
})),
|
|
503
705
|
...(patch.founderCompany !== undefined && { founderCompany: patch.founderCompany }),
|
|
504
706
|
...(patch.questions !== undefined && {
|
|
505
707
|
questions: patch.questions as unknown as Prisma.InputJsonValue,
|
|
506
708
|
}),
|
|
709
|
+
...(patch.logoMarkUrl !== undefined && { logoMarkUrl: patch.logoMarkUrl }),
|
|
710
|
+
...(patch.logoWordmarkUrl !== undefined && {
|
|
711
|
+
logoWordmarkUrl: patch.logoWordmarkUrl,
|
|
712
|
+
}),
|
|
713
|
+
// #5285 — the applicant's REQUEST, already clamped by the caller against
|
|
714
|
+
// the site's own `maxWindowDays`. It is written to `windowDays`, the same
|
|
715
|
+
// column `start` writes, because there is one window per listing and the
|
|
716
|
+
// screen that asks for it is the site's choice of screen, not a second
|
|
717
|
+
// field. Clamped and not refused, for the reason `startProjectListingDraft`
|
|
718
|
+
// gives: a founder who could post their own bound could post an unbounded
|
|
719
|
+
// one.
|
|
720
|
+
...(context.windowDays !== undefined && { windowDays: context.windowDays }),
|
|
721
|
+
// #5285 — MERGED, never replaced. A founder patches one screen at a time,
|
|
722
|
+
// so a body carrying screen four's two answers must not blank screen
|
|
723
|
+
// two's. The incoming keys win; everything already stored is carried.
|
|
724
|
+
// Every incoming id was checked against the site's declarations by the
|
|
725
|
+
// compiled patch schema, so nothing undeclared can enter here.
|
|
726
|
+
...(patch.siteAnswers !== undefined && {
|
|
727
|
+
siteAnswers: {
|
|
728
|
+
...context.storedSiteAnswers,
|
|
729
|
+
...patch.siteAnswers,
|
|
730
|
+
} as Prisma.InputJsonValue,
|
|
731
|
+
}),
|
|
507
732
|
};
|
|
508
733
|
}
|
|
509
734
|
|
|
@@ -526,7 +751,13 @@ export async function patchProjectListingDraft(
|
|
|
526
751
|
// still answers `not_found` before any body is inspected.
|
|
527
752
|
const owner = await prisma.projectListing.findFirst({
|
|
528
753
|
where: { id, status: "draft", resumeTokenHash: hashResumeToken(resumeToken) },
|
|
529
|
-
|
|
754
|
+
// #5285 — `siteAnswers` rides this read because the write MERGES into it
|
|
755
|
+
// (a founder patches one screen at a time). It is still not the write's
|
|
756
|
+
// guard: the compare-and-swap below is, and a concurrent patch of a
|
|
757
|
+
// DIFFERENT screen can still lose its answers to this one, exactly as it
|
|
758
|
+
// can already lose a column. The store is one row's worth of one
|
|
759
|
+
// founder's own form, not a shared document.
|
|
760
|
+
select: { siteId: true, siteAnswers: true },
|
|
530
761
|
});
|
|
531
762
|
if (!owner) {
|
|
532
763
|
return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
|
|
@@ -536,6 +767,7 @@ export async function patchProjectListingDraft(
|
|
|
536
767
|
const parsed = buildProjectListingDraftPatchSchema(
|
|
537
768
|
listingConfig.wordCaps,
|
|
538
769
|
listingConfig.collectedFields,
|
|
770
|
+
listingConfig.siteAnswers,
|
|
539
771
|
).safeParse(body);
|
|
540
772
|
if (!parsed.success) {
|
|
541
773
|
return {
|
|
@@ -548,6 +780,21 @@ export async function patchProjectListingDraft(
|
|
|
548
780
|
}
|
|
549
781
|
const patch: ProjectListingDraftPatchInput = parsed.data;
|
|
550
782
|
|
|
783
|
+
const stored = siteAnswersFromRow(owner.siteAnswers);
|
|
784
|
+
const context: DraftUpdateContext = {
|
|
785
|
+
// THE CLAMP, and the reason `requestedWindowDays` is safe to accept from
|
|
786
|
+
// an anonymous body at all (#5285). The site's `maxWindowDays` is read
|
|
787
|
+
// server-side and caps everything; the payload carries a REQUEST. A
|
|
788
|
+
// founder who could post their own bound could post an unbounded one, and
|
|
789
|
+
// this is the only place a patched window reaches a column.
|
|
790
|
+
...(patch.requestedWindowDays !== undefined && {
|
|
791
|
+
windowDays: clampWindowDays(patch.requestedWindowDays, listingConfig),
|
|
792
|
+
}),
|
|
793
|
+
// What is on the row already, so the merge below adds to it rather than
|
|
794
|
+
// replacing it. It comes from the ROW and never from the body.
|
|
795
|
+
...(stored !== null && { storedSiteAnswers: stored }),
|
|
796
|
+
};
|
|
797
|
+
|
|
551
798
|
// A GUARDED `updateMany`, not a read-then-write: the token check and the
|
|
552
799
|
// write are one statement, so a concurrent `submit` cannot flip the row to
|
|
553
800
|
// `requested` in between and let this patch land on an application the
|
|
@@ -555,7 +802,7 @@ export async function patchProjectListingDraft(
|
|
|
555
802
|
// same compare-and-swap shape `submit` and `decideListing` already use.
|
|
556
803
|
const updated = await prisma.projectListing.updateMany({
|
|
557
804
|
where: { id, status: "draft", resumeTokenHash: hashResumeToken(resumeToken) },
|
|
558
|
-
data: draftUpdateData(patch),
|
|
805
|
+
data: draftUpdateData(patch, context),
|
|
559
806
|
});
|
|
560
807
|
if (updated.count === 0) {
|
|
561
808
|
return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
|
|
@@ -613,6 +860,15 @@ function applicationFromRow(
|
|
|
613
860
|
address: row.address,
|
|
614
861
|
...(row.founderCompany !== null && { founderCompany: row.founderCompany }),
|
|
615
862
|
questions: row.questions,
|
|
863
|
+
// #5285 — the three fields that became writable. Each is optional in the
|
|
864
|
+
// application schema, so a row that never carried one simply omits it and
|
|
865
|
+
// `submit` is unaffected. `requestedWindowDays` reads back the CLAMPED
|
|
866
|
+
// `windowDays` the row actually holds, not what was asked for: the
|
|
867
|
+
// request is not stored anywhere, and re-asserting it would be asserting
|
|
868
|
+
// a number nothing ever agreed to.
|
|
869
|
+
...(row.logoMarkUrl !== null && { logoMarkUrl: row.logoMarkUrl }),
|
|
870
|
+
...(row.logoWordmarkUrl !== null && { logoWordmarkUrl: row.logoWordmarkUrl }),
|
|
871
|
+
...(row.windowDays !== null && { requestedWindowDays: row.windowDays }),
|
|
616
872
|
};
|
|
617
873
|
return Object.fromEntries(
|
|
618
874
|
Object.entries(all).filter(([key]) => collected.has(key)),
|
|
@@ -879,6 +1135,88 @@ export async function getProjectListing(siteId: string, id: string): Promise<Get
|
|
|
879
1135
|
return { ok: true, value: toResponseRow(row, waitlistCountFor(row, counts), new Date(), true) };
|
|
880
1136
|
}
|
|
881
1137
|
|
|
1138
|
+
// ─── The founder's own listings (auth scope; #5295) ─────────────────────────
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* The listings the CALLING USER owns, newest first.
|
|
1142
|
+
*
|
|
1143
|
+
* The join is the owner link and ONLY the owner link:
|
|
1144
|
+
* `ownerUserId === userId`, the scalar `listing-promotion.ts` stamps once that
|
|
1145
|
+
* user's own email is verified, matches the row's `founderEmail`, and the row's
|
|
1146
|
+
* site resolves to one of the user's own workspaces. `founderEmail` is NEVER
|
|
1147
|
+
* compared here, at any stage — an un-linked listing that happens to carry the
|
|
1148
|
+
* caller's address is not theirs until a promotion said so, and a read-time
|
|
1149
|
+
* email match would reintroduce exactly the cross-founder capture the link
|
|
1150
|
+
* exists to remove. (`flow-checkouts.ts` draws the same line for `Lead`.)
|
|
1151
|
+
*
|
|
1152
|
+
* No `siteId` parameter and no `currentSiteId()` scope, deliberately — and the
|
|
1153
|
+
* reason is NOT that the two can never coincide. `siteIdForPublicKey`
|
|
1154
|
+
* (`listing-config.ts`) does fall back to `currentSiteId()`, so a listing's
|
|
1155
|
+
* `siteId` CAN be `AILK_SITE_ID` or the literal `"default"` on a single-tenant
|
|
1156
|
+
* deployment. The reason is that the owner link is already the narrower scope:
|
|
1157
|
+
* it is only ever stamped for a site inside the user's own tenancy
|
|
1158
|
+
* (`listing-promotion.ts`, condition 3), so adding a site term could only
|
|
1159
|
+
* remove rows the caller owns — and on the multi-tenant shape, where a
|
|
1160
|
+
* listing's `siteId` is a WORKSPACE id rather than `AILK_SITE_ID`, it would
|
|
1161
|
+
* remove all of them. The response omits `siteId` regardless, so no workspace
|
|
1162
|
+
* id leaves the read.
|
|
1163
|
+
*
|
|
1164
|
+
* The one thing this gives up against the sibling `/me` routes: on a database
|
|
1165
|
+
* shared by more than one site, a caller's rows from every such site come back
|
|
1166
|
+
* together. They are all the caller's own rows, which is why that is acceptable
|
|
1167
|
+
* here and not a disclosure.
|
|
1168
|
+
*
|
|
1169
|
+
* No verified-email re-check either. `flow-checkouts.ts` performs one as
|
|
1170
|
+
* defence in depth beside its email-matching sibling
|
|
1171
|
+
* (`waitlist-signups.ts`, where the gate is load-bearing because the match IS
|
|
1172
|
+
* an email). Here the link is the proof, and it was only issuable under
|
|
1173
|
+
* verification — so a re-check adds no safety, while failing a caller whose
|
|
1174
|
+
* address later changed to an unverified one would wrongly hide the rows they
|
|
1175
|
+
* already proved they own.
|
|
1176
|
+
*
|
|
1177
|
+
* `waitlistCount` is resolved PER SITE: the count is keyed by
|
|
1178
|
+
* `(siteId, experimentKey)`, and a founder may own listings in more than one of
|
|
1179
|
+
* their own workspaces, so counting them all under one site id would report
|
|
1180
|
+
* another site's number.
|
|
1181
|
+
*
|
|
1182
|
+
* An empty array for a caller who owns nothing is the normal answer, not an
|
|
1183
|
+
* error.
|
|
1184
|
+
*/
|
|
1185
|
+
export async function listMyProjectListings(userId: string): Promise<ProjectListing[]> {
|
|
1186
|
+
const rows = await prisma.projectListing.findMany({
|
|
1187
|
+
where: { ownerUserId: userId },
|
|
1188
|
+
orderBy: { createdAt: "desc" },
|
|
1189
|
+
select: PROJECT_LISTING_SELECT,
|
|
1190
|
+
});
|
|
1191
|
+
if (rows.length === 0) return [];
|
|
1192
|
+
|
|
1193
|
+
const rowsBySite = new Map<string, ProjectListingRow[]>();
|
|
1194
|
+
for (const row of rows) {
|
|
1195
|
+
const forSite = rowsBySite.get(row.siteId);
|
|
1196
|
+
if (forSite) forSite.push(row);
|
|
1197
|
+
else rowsBySite.set(row.siteId, [row]);
|
|
1198
|
+
}
|
|
1199
|
+
const countsBySite = new Map<string, Map<string, number>>();
|
|
1200
|
+
await Promise.all(
|
|
1201
|
+
[...rowsBySite].map(async ([siteId, siteRows]) => {
|
|
1202
|
+
countsBySite.set(siteId, await attachWaitlistCounts(siteId, siteRows));
|
|
1203
|
+
}),
|
|
1204
|
+
);
|
|
1205
|
+
|
|
1206
|
+
const now = new Date();
|
|
1207
|
+
// `featuredEnabled: true` — the founder's own row, like the operator's
|
|
1208
|
+
// dashboard row, reports the flag as stored. It is the PUBLIC shelf the
|
|
1209
|
+
// site's config switch governs, not what the row's owner may see.
|
|
1210
|
+
return rows.map((row) =>
|
|
1211
|
+
toResponseRow(
|
|
1212
|
+
row,
|
|
1213
|
+
waitlistCountFor(row, countsBySite.get(row.siteId) ?? new Map()),
|
|
1214
|
+
now,
|
|
1215
|
+
true,
|
|
1216
|
+
),
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
882
1220
|
// ─── Patch (tenant scope; the featured flag) ───────────────────────────────
|
|
883
1221
|
|
|
884
1222
|
export type SetListingFeaturedResult =
|
|
@@ -1119,6 +1457,16 @@ export async function publicProjectListings(siteId: string): Promise<ProjectList
|
|
|
1119
1457
|
now,
|
|
1120
1458
|
listingConfig.featuredEnabled,
|
|
1121
1459
|
);
|
|
1122
|
-
|
|
1460
|
+
// #5285 — the `public`-visibility answers ONLY. An `operator-only` answer
|
|
1461
|
+
// is something a site asked for its own triage, and a founder who was
|
|
1462
|
+
// told it would not be published has to be right about that; an answer
|
|
1463
|
+
// whose id the site no longer declares is dropped for the same reason,
|
|
1464
|
+
// because "no declaration" cannot be read as "public" on the read that
|
|
1465
|
+
// publishes. The tenant dashboard and the review payload are the audience
|
|
1466
|
+
// that sees the rest, and they do not come through here.
|
|
1467
|
+
return {
|
|
1468
|
+
...publicRow,
|
|
1469
|
+
siteAnswers: publicSiteAnswers(publicRow.siteAnswers, listingConfig.siteAnswers),
|
|
1470
|
+
};
|
|
1123
1471
|
});
|
|
1124
1472
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// Chrome extract — Nav + Footer from @working-theory/ui (#1451)
|
|
5
5
|
// Shell/Chrome composition — Nav + Footer wrap via @working-theory/templates MarketingShell (#2820)
|
|
6
6
|
// Session-aware Nav actions — Sign in / Account + Sign out from the server-read session (#4984)
|
|
7
|
+
// Site-declared theme — `_site.mdx` `theme:` block → token overlay → CSS vars
|
|
7
8
|
//
|
|
8
9
|
// Sits under app/[locale]/ and sets the <html lang> attribute to the
|
|
9
10
|
// active locale. next-intl's NextIntlClientProvider is wired here so
|
|
@@ -48,6 +49,7 @@ import { SignOutTrigger } from "../../components/sign-out-trigger";
|
|
|
48
49
|
import { navItems } from "../../lib/nav-config";
|
|
49
50
|
import { getOgImageUrl } from "../../lib/og";
|
|
50
51
|
import { SITE_NAME, siteLogo } from "../../lib/site-brand";
|
|
52
|
+
import { loadSiteBrandTokens } from "../../lib/site-theme";
|
|
51
53
|
import { resolveBaseUrl } from "../../src/lib/routes";
|
|
52
54
|
|
|
53
55
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
@@ -238,11 +240,21 @@ export default async function LocaleLayout({
|
|
|
238
240
|
// Shared brand mark injected into both Nav and Footer chrome slots.
|
|
239
241
|
const brandLogo = siteLogo();
|
|
240
242
|
|
|
243
|
+
// The site's own declared colours, read from `content/_site.mdx`'s `theme:`
|
|
244
|
+
// block and translated from that customer-facing vocabulary onto a token
|
|
245
|
+
// overlay (`siteThemeToBrandTokens`). `undefined` when the site declares no
|
|
246
|
+
// theme — which is the regression floor: no `brand` key is passed, the
|
|
247
|
+
// resolver takes its untouched fast path, and the emitted CSS is
|
|
248
|
+
// byte-for-byte what it was before this wiring existed.
|
|
249
|
+
const siteBrandTokens = loadSiteBrandTokens();
|
|
250
|
+
|
|
241
251
|
// Both theme modes, gated by [data-theme="dark"] (#1532 — sibling fix; the
|
|
242
252
|
// single-mode call inlined an unconditional :root block that never
|
|
243
253
|
// responded to a client-side data-theme toggle). themeMode still drives
|
|
244
254
|
// the initial data-theme attribute below for a no-flash first paint.
|
|
245
|
-
const inlineThemeStyle = themeToCssVarsBothModes(
|
|
255
|
+
const inlineThemeStyle = themeToCssVarsBothModes(
|
|
256
|
+
siteBrandTokens ? { brand: siteBrandTokens } : {},
|
|
257
|
+
);
|
|
246
258
|
|
|
247
259
|
return (
|
|
248
260
|
<html lang={locale} dir={isRtl(locale) ? "rtl" : "ltr"} data-theme={themeMode}>
|
|
@@ -83,6 +83,12 @@ module.exports = {
|
|
|
83
83
|
"^@working-theory/ui/slices/scheduling$":
|
|
84
84
|
"<rootDir>/node_modules/@working-theory/ui/dist/sections/slices/scheduling.js",
|
|
85
85
|
"^@working-theory/theme$": "<rootDir>/node_modules/@working-theory/theme/dist/index.js",
|
|
86
|
+
// site-config publishes ESM in dist/; map to source so ts-jest compiles it
|
|
87
|
+
// through the same transformer as every sibling above. Only the
|
|
88
|
+
// client-safe /schema entry is mapped — architecture.yaml forbids apps/web
|
|
89
|
+
// from importing /server at all (#1567).
|
|
90
|
+
"^@working-theory/site-config/schema$":
|
|
91
|
+
"<rootDir>/node_modules/@working-theory/site-config/dist/schema/index.js",
|
|
86
92
|
"^@working-theory/validation$": "<rootDir>/node_modules/@working-theory/validation/dist/index.js",
|
|
87
93
|
"^@working-theory/web-i18n$": "<rootDir>/node_modules/@working-theory/web-i18n/dist/index.js",
|
|
88
94
|
// #5025: pnpm keys next-intl's virtual-store path on its resolved peers,
|