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
@@ -0,0 +1,236 @@
1
+ /**
2
+ * @file consent.ts
3
+ * @description The consent-grant writer (#5275 Part B) — `recordConsent`,
4
+ * the one function that turns a person's answers to a site's consent
5
+ * purposes into `ConsentGrant` rows, plus `recordLegacyConsent`, the D11
6
+ * path for a caller that could only say `consent: true`.
7
+ *
8
+ * SURFACE-AGNOSTIC BY DESIGN (D10). A grant's subject is a `subjectType` +
9
+ * `subjectId` pair of plain scalars with no relation into any other table
10
+ * (ADR 0009 invariant 1), so a new surface — the newsletter subscribe path
11
+ * is the named follow-up — adopts consent by adding ONE call here and NO
12
+ * migration. Nothing in this module knows what a project listing is.
13
+ *
14
+ * THE EVIDENCE RULE (spec §5.2): no row is ever written with statement text
15
+ * the system did not actually render to that person. Two consequences:
16
+ *
17
+ * - `recordConsent` resolves each statement from the SAME registry and the
18
+ * SAME `resolveConsentStatement` the renderer used (D7), so the recorded
19
+ * wording and the rendered wording are one string by construction, and a
20
+ * request body never carries wording at all. A purpose id the registry
21
+ * does not know is skipped, not written: no statement was rendered for it.
22
+ * - A grant whose wording cannot be proven — a legacy `consent: true` body,
23
+ * or the migration backfill — carries `UNRECOVERABLE_CONSENT_STATEMENT`,
24
+ * never today's default text. Stamping current wording onto a grant
25
+ * nobody read would fabricate evidence (D11).
26
+ *
27
+ * APPEND-ONLY (D9): every write is a `createMany`; this module never
28
+ * updates or upserts, so a later withdrawal sits beside the earlier grant
29
+ * rather than replacing it. Declines are written as `granted: false` (D8).
30
+ */
31
+
32
+ import { createHash } from "node:crypto";
33
+
34
+ import { logger } from "@working-theory/observability";
35
+ import {
36
+ consentPurposeById,
37
+ resolveConsentStatement,
38
+ SITE_CONSENT_CONFIG_DEFAULTS,
39
+ type SiteConsentConfig,
40
+ } from "@working-theory/validation";
41
+
42
+ import { prisma } from "../lib/prisma.js";
43
+
44
+ /**
45
+ * The one delegate this module writes through — the shape of `prisma` AND of
46
+ * the `tx` client an interactive `$transaction` hands out, so a caller that
47
+ * creates the subject row can write the grant in the SAME transaction (the
48
+ * listing's `start` does): either the row and its evidence both land, or
49
+ * neither does.
50
+ */
51
+ export interface ConsentWriteClient {
52
+ consentGrant: {
53
+ createMany(args: { data: ConsentGrantRow[] }): Promise<{ count: number }>;
54
+ };
55
+ }
56
+
57
+ interface ConsentGrantRow {
58
+ siteId: string;
59
+ subjectType: string;
60
+ subjectId: string;
61
+ purposeId: string;
62
+ granted: boolean;
63
+ locale: string;
64
+ statement: string;
65
+ statementHash: string;
66
+ }
67
+
68
+ /**
69
+ * The most keys a `consents` map may carry on an anonymous request. Any real
70
+ * registry is a handful of purposes; the cap bounds the per-request work an
71
+ * unbounded map could buy (security review, Low 3).
72
+ */
73
+ export const MAX_CONSENT_KEYS = 64;
74
+
75
+ /**
76
+ * The statement sentinel for a grant whose wording cannot be proven — a row
77
+ * carrying it records THAT a person consented (and when), not WHAT they read.
78
+ * Written on exactly two paths: `recordLegacyConsent` (a body carrying only
79
+ * the legacy `consent: true` literal, D11) and the consent_grants migration's
80
+ * backfill of pre-existing listings, which must carry this exact literal.
81
+ * It is never a substitute for real wording: a caller that rendered a
82
+ * statement records it through `recordConsent`.
83
+ */
84
+ export const UNRECOVERABLE_CONSENT_STATEMENT =
85
+ "[unrecoverable: no statement was rendered by this system for this grant]";
86
+
87
+ /** BCP-47 `und` — the locale of a statement nobody read. */
88
+ export const UNRECOVERABLE_CONSENT_LOCALE = "und";
89
+
90
+ /** sha256 hex of a statement — the `statementHash` that groups identical wordings. */
91
+ export function consentStatementHash(statement: string): string {
92
+ return createHash("sha256").update(statement).digest("hex");
93
+ }
94
+
95
+ /**
96
+ * The site's consent registry.
97
+ *
98
+ * There is no per-site consent config ROW yet (spec §4 deliberately adds
99
+ * none), so every site resolves to `SITE_CONSENT_CONFIG_DEFAULTS`. This
100
+ * function is the single seam a `SiteConsentConfig` row wires into later
101
+ * (the `listingConfigFor` shape: read the blob, `consentConfigFor(blob)`).
102
+ * The RENDERER (`packages/ui`) resolves its statements from the same
103
+ * `SITE_CONSENT_CONFIG_DEFAULTS`, which is what keeps the recorded statement
104
+ * identical to the rendered one until a config row exists — at which point
105
+ * both sides must read the same row.
106
+ */
107
+ export async function consentConfigForSite(
108
+ _siteId: string,
109
+ ): Promise<SiteConsentConfig> {
110
+ return SITE_CONSENT_CONFIG_DEFAULTS;
111
+ }
112
+
113
+ /**
114
+ * The id of the first REQUIRED registry purpose `consents` does not GRANT —
115
+ * declined (`false`) or simply absent — or `undefined` when every required
116
+ * purpose is `true`. Absence counts as unmet on purpose (security review,
117
+ * Medium 1): a body carrying `consents: {}` beside `consent: true` would
118
+ * otherwise create the subject with no grant row at all, which is a worse
119
+ * record than the legacy sentinel. A surface whose subject cannot exist
120
+ * without the required purposes (the listing: the row goes on to publish the
121
+ * founder's address) checks this before creating the subject.
122
+ */
123
+ export function requiredConsentUnmet(
124
+ config: SiteConsentConfig,
125
+ consents: Record<string, boolean>,
126
+ ): string | undefined {
127
+ for (const purpose of config.purposes) {
128
+ if (purpose.required && consents[purpose.id] !== true) return purpose.id;
129
+ }
130
+ return undefined;
131
+ }
132
+
133
+ export interface RecordConsentInput {
134
+ siteId: string;
135
+ /** e.g. `project_listing`, `subscriber` — a plain scalar, no relation (D10). */
136
+ subjectType: string;
137
+ subjectId: string;
138
+ /** The locale the statements were RENDERED in; the served locale is what gets recorded. */
139
+ locale: string;
140
+ config: SiteConsentConfig;
141
+ /** purposeId → the person's answer. `false` is a stored decline (D8). */
142
+ consents: Record<string, boolean>;
143
+ }
144
+
145
+ /**
146
+ * Write one `ConsentGrant` row per answered purpose in one `createMany`.
147
+ * Returns the number of rows written. Append-only — never update/upsert.
148
+ * `db` defaults to the shared client; pass a `$transaction` client to land
149
+ * the grant atomically with the subject row it belongs to.
150
+ */
151
+ export async function recordConsent(
152
+ input: RecordConsentInput,
153
+ db: ConsentWriteClient = prisma,
154
+ ): Promise<number> {
155
+ const rows: ConsentGrantRow[] = [];
156
+ const unknown: string[] = [];
157
+
158
+ for (const [purposeId, granted] of Object.entries(input.consents)) {
159
+ const purpose = consentPurposeById(input.config, purposeId);
160
+ if (!purpose) {
161
+ // Not in the registry ⇒ no statement was rendered for it ⇒ nothing to
162
+ // record (the evidence rule). Collected and logged ONCE per call below,
163
+ // because a client naming a purpose the site does not declare is a
164
+ // drift someone should see — and a map full of junk keys must not buy
165
+ // a log line each.
166
+ unknown.push(purposeId);
167
+ continue;
168
+ }
169
+ const { statement, locale } = resolveConsentStatement(purpose, input.locale);
170
+ rows.push({
171
+ siteId: input.siteId,
172
+ subjectType: input.subjectType,
173
+ subjectId: input.subjectId,
174
+ purposeId,
175
+ granted,
176
+ locale,
177
+ statement,
178
+ statementHash: consentStatementHash(statement),
179
+ });
180
+ }
181
+
182
+ if (unknown.length > 0) {
183
+ logger.warn(
184
+ {
185
+ siteId: input.siteId,
186
+ subjectType: input.subjectType,
187
+ unknownCount: unknown.length,
188
+ sample: unknown.slice(0, 5),
189
+ },
190
+ "[consent] skipping unknown purpose ids — not in the site's consent registry, no statement was rendered for them",
191
+ );
192
+ }
193
+
194
+ if (rows.length === 0) return 0;
195
+ const result = await db.consentGrant.createMany({ data: rows });
196
+ return result.count;
197
+ }
198
+
199
+ export interface RecordLegacyConsentInput {
200
+ siteId: string;
201
+ subjectType: string;
202
+ subjectId: string;
203
+ config: SiteConsentConfig;
204
+ }
205
+
206
+ /**
207
+ * The D11 path: a body that carried only the legacy `consent: true` literal.
208
+ * Writes ONE grant against the site's first `required` purpose (falling back
209
+ * to its first purpose), `granted: true`, with the statement marked
210
+ * unrecoverable — the caller affirmed, but this system rendered it no
211
+ * wording to affirm.
212
+ */
213
+ export async function recordLegacyConsent(
214
+ input: RecordLegacyConsentInput,
215
+ db: ConsentWriteClient = prisma,
216
+ ): Promise<number> {
217
+ const purpose =
218
+ input.config.purposes.find((p) => p.required) ?? input.config.purposes[0];
219
+ if (!purpose) return 0;
220
+
221
+ const result = await db.consentGrant.createMany({
222
+ data: [
223
+ {
224
+ siteId: input.siteId,
225
+ subjectType: input.subjectType,
226
+ subjectId: input.subjectId,
227
+ purposeId: purpose.id,
228
+ granted: true,
229
+ locale: UNRECOVERABLE_CONSENT_LOCALE,
230
+ statement: UNRECOVERABLE_CONSENT_STATEMENT,
231
+ statementHash: consentStatementHash(UNRECOVERABLE_CONSENT_STATEMENT),
232
+ },
233
+ ],
234
+ });
235
+ return result.count;
236
+ }
@@ -33,6 +33,7 @@ import { logger } from "@working-theory/observability";
33
33
  import {
34
34
  aeoScoreResultSchema,
35
35
  deriveFlags,
36
+ flowAddressValueSchema,
36
37
  flowConfigSchema,
37
38
  hiddenAnswers,
38
39
  isStepVisible,
@@ -388,6 +389,23 @@ function extractStepValues(
388
389
  };
389
390
  }
390
391
  if (!present) continue;
392
+ // #5279 — an `address` field submits ONE nested parts object under its
393
+ // own name (six separable values, never a concatenated line), so it is
394
+ // checked against that shape rather than against any of the string
395
+ // branches below. `raw` is `unknown` here (boundedStepValues), so this
396
+ // is the same "type it before it reaches JSONB" discipline the text/tel
397
+ // cap below documents: an anonymous POST cannot park an arbitrary
398
+ // object in `flow_sessions.state` by naming an address field.
399
+ if (field.inputType === "address") {
400
+ if (!flowAddressValueSchema.safeParse(raw).success) {
401
+ return {
402
+ ok: false,
403
+ message: `field "${field.name}" must be a structured address`,
404
+ };
405
+ }
406
+ data[field.name] = raw;
407
+ continue;
408
+ }
391
409
  if (field.inputType === "select") {
392
410
  const allowed = new Set(field.options?.map((o) => o.value) ?? []);
393
411
  if (typeof raw !== "string" || !allowed.has(raw)) {
@@ -0,0 +1,342 @@
1
+ /**
2
+ * @file listing-promotion.ts
3
+ * @description ProjectListing → owner-user linking across the inbox/auth
4
+ * namespace boundary (#5295; ADR 0009 D3 service-layer pattern).
5
+ *
6
+ * The sibling of `lead-promotion.ts`, for the same shape of problem. A
7
+ * `ProjectListing` is written on an ANONYMOUS route: `founderEmail` is a string
8
+ * a stranger typed into a form, and nothing about typing an address proves you
9
+ * own it. So `founderEmail` can never by itself say whose row it is — two
10
+ * people who type the same address are two people — and this service
11
+ * materializes the ownership claim that a read may actually join on.
12
+ *
13
+ * The link is stamped only when ALL THREE hold:
14
+ *
15
+ * 1. the reconciling user's OWN email is VERIFIED (`User.emailVerified`) —
16
+ * the consent basis, inherited from `lead-promotion.ts` D2, and a
17
+ * service-level invariant rather than a caller obligation, so a wrong
18
+ * call-site cannot bypass it;
19
+ * 2. that verified address matches the listing's `founderEmail`;
20
+ * 3. the listing's `siteId` is one of that user's OWN tenant workspaces —
21
+ * the tenant-safety invariant (`lead-promotion.ts` D3), which is what
22
+ * makes a row captured under another organization's workspace unmatchable
23
+ * by construction rather than by a later filter.
24
+ *
25
+ * Any one failing is a NO-OP returning `count: 0`, never a throw — the lead
26
+ * service's posture, for the same reason: this runs on a reconciliation path
27
+ * (an email-verification callback, a sign-in), where a throw would fail an
28
+ * unrelated request that had nothing to do with listings.
29
+ *
30
+ * And one condition the lead service does NOT have: the match must be
31
+ * UNAMBIGUOUS. Two strangers can type the same address into the anonymous form,
32
+ * and when they do nothing says which of the two rows is whose — so a row is
33
+ * claimed only when it is the only un-linked row carrying that address at its
34
+ * site. See the refusal block in the body; this is the difference between
35
+ * mirroring the lead service's mechanism and inheriting a disclosure from it.
36
+ *
37
+ * Workspace resolution is delegated to `tenant-context.ts`'s `listTenancy` —
38
+ * the sole cross-namespace tenancy assembler — so this file never issues a raw
39
+ * membership or workspace model query of its own (`architecture.yaml`'s
40
+ * tenant-scope forbidden pattern reserves those to
41
+ * `tenant-context.ts` / `tenant-db.ts`). That delegation is module-conditional;
42
+ * see the note above TENANT_CONTEXT_SPECIFIER.
43
+ *
44
+ * PII boundary: this module never logs or returns `founderEmail`,
45
+ * `founderName`, or any application answer. The result carries ids only — a
46
+ * listing id and the workspace id it sits in.
47
+ *
48
+ * NOTE for whoever wires this to a verification hook: `internal_error` carries
49
+ * the raw `Error.message`, which can be a database error string. It is for the
50
+ * caller's log, not for a response body. Same shape as `lead-promotion.ts`.
51
+ */
52
+
53
+ import type { Id } from "@working-theory/ids";
54
+ import { logger } from "@working-theory/observability";
55
+ import { PROJECT_LISTING_STORED_STATUSES } from "@working-theory/validation";
56
+
57
+ import { isModuleEnabled } from "../config/modules.js";
58
+ import { prisma } from "../lib/prisma.js";
59
+
60
+ /**
61
+ * `tenant-context.ts` is a multi-tenant SLICE file the OSS strip removes
62
+ * (#5182), and `listTenancy` is a VALUE — a runtime call, not an erasable type
63
+ * — so neither a static import nor a literal `await import("./tenant-context.js")`
64
+ * survives the strip: `tsc` resolves a string-literal specifier whether or not
65
+ * the branch containing it can ever run, and either form is TS2307 on the
66
+ * public tree.
67
+ *
68
+ * The remedy is the one `lead-promotion.ts`, `server.ts` and
69
+ * `routes/content/index.ts` already use: gate the call on
70
+ * `isModuleEnabled('multi-tenant')` and read the specifier from this constant
71
+ * so it is not a literal written at the call site. What defeats resolution is
72
+ * the INDIRECTION, not the type of the value.
73
+ *
74
+ * Restated here rather than shared with `lead-promotion.ts`: the mechanism is
75
+ * three lines and a constant, and extracting it would mean editing the Lead
76
+ * promotion path this file deliberately only mirrors.
77
+ *
78
+ * Semantics on a build without the module: there is no workspace concept at
79
+ * all, so a user has no workspaces, so no listing can match
80
+ * `siteId ∈ workspaceIds` — which is exactly the zero-workspace early return
81
+ * below. The no-op is correct rather than degraded.
82
+ */
83
+ const TENANT_CONTEXT_SPECIFIER = "./tenant-context.js";
84
+
85
+ /**
86
+ * The statuses a row must be in to be claimable: every stored status EXCEPT
87
+ * `draft`.
88
+ *
89
+ * Two reasons, and the second is a security one.
90
+ *
91
+ * A `draft` is addressed by its RESUME TOKEN, which is that draft's whole
92
+ * credential, and `resumeTokenHash` is cleared at submit — so the token and the
93
+ * owner link hand off to each other cleanly. Before submit the token is the
94
+ * access path; after it, the link is. A draft needs no link and gains nothing
95
+ * from one.
96
+ *
97
+ * And `POST /v1/project-listings` is ANONYMOUS with the concurrent-listing
98
+ * quota enforced at SUBMIT rather than at start, so anyone holding a site's
99
+ * public key — it ships in browser JavaScript by design — can create unbounded
100
+ * `draft` rows carrying any address they choose. If drafts were claimable, one
101
+ * such row would be enough either to hand a stranger's text to whoever verifies
102
+ * that address, or (under the ambiguity refusal below) to block that person
103
+ * from ever claiming their own listing. Excluding drafts raises the cost of
104
+ * that from one anonymous request to a COMPLETE application that passes submit
105
+ * validation, counts against the two-row quota for that address at that site,
106
+ * and sends an operator notification to the address itself.
107
+ *
108
+ * Derived from the exported vocabulary rather than restated, so a new stored
109
+ * status is a deliberate decision here rather than a silent inclusion.
110
+ */
111
+ const CLAIMABLE_STATUSES: readonly string[] = PROJECT_LISTING_STORED_STATUSES.filter(
112
+ (status) => status !== "draft",
113
+ );
114
+
115
+ /** The one export this file needs from the multi-tenant slice. */
116
+ interface TenantContextSlice {
117
+ listTenancy: (params: {
118
+ userId: Id<"user">;
119
+ sessionTenant: undefined;
120
+ }) => Promise<
121
+ | {
122
+ ok: true;
123
+ value: { organizations: Array<{ workspaces: Array<{ id: string }> }> };
124
+ }
125
+ | { ok: false; error: { message: string } }
126
+ >;
127
+ }
128
+
129
+ // ─── Types ────────────────────────────────────────────────────────────────────
130
+
131
+ export type PromotedListing = {
132
+ listingId: string;
133
+ siteId: string;
134
+ };
135
+
136
+ export type PromoteListingsResult =
137
+ | { ok: true; count: number; promoted: PromotedListing[] }
138
+ | {
139
+ ok: false;
140
+ error: { kind: "user_not_found" | "internal_error"; message: string };
141
+ };
142
+
143
+ // ─── Main export ──────────────────────────────────────────────────────────────
144
+
145
+ /**
146
+ * Link every un-linked `ProjectListing` whose `founderEmail` is the verifying
147
+ * user's own verified address, within that user's own tenant workspaces.
148
+ *
149
+ * No-ops (`count: 0`) — never throws — when: the user's email is unverified,
150
+ * the user belongs to no workspace, no matching un-linked listing exists, or
151
+ * the match is ambiguous (more than one un-linked listing carries the address
152
+ * at one site).
153
+ *
154
+ * Idempotent, and proved so on both sides of the write: a listing already
155
+ * carrying `ownerUserId` is excluded from the candidate read AND from the
156
+ * update's own `where`, so a re-invocation links nothing and a concurrent run
157
+ * cannot transfer a row away from the user who already claimed it.
158
+ */
159
+ export async function promoteListingsForVerifiedUser(params: {
160
+ userId: string;
161
+ }): Promise<PromoteListingsResult> {
162
+ const { userId } = params;
163
+
164
+ try {
165
+ const user = await prisma.user.findUnique({
166
+ where: { id: userId },
167
+ select: { id: true, email: true, emailVerified: true },
168
+ });
169
+
170
+ if (!user) {
171
+ return {
172
+ ok: false,
173
+ error: { kind: "user_not_found", message: "User not found" },
174
+ };
175
+ }
176
+
177
+ // Condition 1 — the verified-email gate. A service-level invariant, not a
178
+ // caller obligation: it cannot be bypassed by a wrong call-site.
179
+ if (!user.emailVerified) {
180
+ return { ok: true, count: 0, promoted: [] };
181
+ }
182
+
183
+ // Condition 3 — resolve the verifying user's OWN workspaces via the sole
184
+ // tenant-context assembler (never a raw membership or workspace model query
185
+ // in this file). Module-conditional per the note on
186
+ // TENANT_CONTEXT_SPECIFIER above: no multi-tenant module, no workspaces.
187
+ let workspaceIds: string[] = [];
188
+ if (isModuleEnabled("multi-tenant")) {
189
+ const specifier: string = TENANT_CONTEXT_SPECIFIER;
190
+ const { listTenancy } = (await import(specifier)) as TenantContextSlice;
191
+ const tenancy = await listTenancy({
192
+ userId: user.id as Id<"user">,
193
+ sessionTenant: undefined,
194
+ });
195
+ if (!tenancy.ok) {
196
+ return {
197
+ ok: false,
198
+ error: { kind: "internal_error", message: tenancy.error.message },
199
+ };
200
+ }
201
+
202
+ workspaceIds = tenancy.value.organizations.flatMap((org) =>
203
+ org.workspaces.map((workspace) => workspace.id as string),
204
+ );
205
+ }
206
+
207
+ if (workspaceIds.length === 0) {
208
+ return { ok: true, count: 0, promoted: [] };
209
+ }
210
+
211
+ /**
212
+ * Conditions 2 + 3, both in the WHERE clause so neither is a post-filter.
213
+ *
214
+ * The email comparison is EXACT equality against the same normalization
215
+ * `startProjectListingDraft` applies when it writes the column
216
+ * (`normalizeFounderEmail` — trim + lower-case), so the two sides of the
217
+ * match are normalized the same way by construction.
218
+ *
219
+ * Deliberately NOT `{ equals, mode: "insensitive" }`, the form
220
+ * `waitlist-signups.ts` uses on `Lead.email`: Prisma compiles an
221
+ * insensitive `equals` to `founder_email ILIKE $1`, which makes `_` and
222
+ * `%` in the CALLER'S own verified address LIKE wildcards. A user whose
223
+ * real address is `a_c@example.com` then matches a different founder's
224
+ * `abc@example.com` in the same workspace — verified in this repo against
225
+ * a live Postgres, two rows in, two rows out. That is precisely the
226
+ * cross-founder capture this link exists to remove, so the stricter
227
+ * comparison wins. The cost is a listing whose stored `founderEmail`
228
+ * predates `normalizeFounderEmail` and carries upper case: it stays
229
+ * un-linked, which is the safe direction (a missing link is a no-op; a
230
+ * wrong one hands over a stranger's submission) and the same resting
231
+ * state every un-promoted row already sits in.
232
+ *
233
+ * `ownerUserId: null` is what makes this idempotent.
234
+ */
235
+ const candidates = await prisma.projectListing.findMany({
236
+ where: {
237
+ founderEmail: user.email.trim().toLowerCase(),
238
+ siteId: { in: workspaceIds },
239
+ ownerUserId: null,
240
+ // See CLAIMABLE_STATUSES: a draft is token-addressed, and an anonymous
241
+ // caller can create them without passing the quota.
242
+ status: { in: [...CLAIMABLE_STATUSES] },
243
+ },
244
+ select: { id: true, siteId: true },
245
+ });
246
+
247
+ if (candidates.length === 0) {
248
+ return { ok: true, count: 0, promoted: [] };
249
+ }
250
+
251
+ /**
252
+ * AMBIGUITY REFUSAL — the case a verified email cannot resolve.
253
+ *
254
+ * Two strangers can both type the same address into the anonymous form, and
255
+ * when they do there are two un-linked rows carrying it and NOTHING that
256
+ * says which is whose. Linking both to whoever verifies the address hands
257
+ * one person the other's whole application — the exact disclosure this
258
+ * column exists to remove, arriving by the back door. `lead-promotion.ts`
259
+ * links every match and therefore has this shape; it is out of scope to
260
+ * change there, and it is not inherited here.
261
+ *
262
+ * So the rule is: within ONE site, a verified address claims a row only
263
+ * when it is the ONLY un-linked row carrying that address. Otherwise every
264
+ * row in that ambiguous group stays null. The grain is the site because
265
+ * that is where the collision happens and where the concurrent-listing
266
+ * limit is already counted (`countOtherNonTerminalListings`); a second site
267
+ * of the same user is judged on its own.
268
+ *
269
+ * The cost is a founder who legitimately submitted TWO applications at one
270
+ * site (`PROJECT_LISTING_CONCURRENT_LIMIT` is 2): neither is linked, and
271
+ * they reach those rows the way they do today. That is the deliberate
272
+ * trade. Nothing in the data distinguishes their second application from a
273
+ * stranger's first, so the honest answer is to claim neither — and an
274
+ * unclaimed row is the resting state this column already treats as
275
+ * legitimate, not a failure.
276
+ *
277
+ * `users.email` is UNIQUE, so there is never a SECOND verifier competing
278
+ * for the refused rows: refusing costs no other claimant anything.
279
+ */
280
+ const perSite = new Map<string, number>();
281
+ for (const listing of candidates) {
282
+ perSite.set(listing.siteId, (perSite.get(listing.siteId) ?? 0) + 1);
283
+ }
284
+ const attributable = candidates.filter(
285
+ (listing) => perSite.get(listing.siteId) === 1,
286
+ );
287
+
288
+ const ambiguousRows = candidates.length - attributable.length;
289
+ if (ambiguousRows > 0) {
290
+ // PII-safe: counts and the user id only — never the address, the
291
+ // founder's name, or a listing id that would identify whose row it is.
292
+ logger.warn(
293
+ {
294
+ kind: "ambiguous_listing_owner",
295
+ userId: user.id,
296
+ ambiguousRows,
297
+ attributableRows: attributable.length,
298
+ },
299
+ "[listing-promotion] more than one un-linked listing shares this address at one site; leaving those rows unlinked",
300
+ );
301
+ }
302
+
303
+ if (attributable.length === 0) {
304
+ return { ok: true, count: 0, promoted: [] };
305
+ }
306
+
307
+ /**
308
+ * `ownerUserId: null` is repeated on the WRITE, not only on the read above.
309
+ * Without it the update is a blind write over whatever the row holds now,
310
+ * and two reconciliation runs that both saw the row un-linked would let the
311
+ * second one TRANSFER ownership away from the first — which contradicts the
312
+ * idempotency this service documents. With it the write is a
313
+ * compare-and-swap: the loser of the race matches 0 rows.
314
+ *
315
+ * `count` therefore reports rows this call actually claimed, not rows it
316
+ * intended to.
317
+ */
318
+ const written = await prisma.projectListing.updateMany({
319
+ where: {
320
+ id: { in: attributable.map((listing) => listing.id) },
321
+ ownerUserId: null,
322
+ },
323
+ data: { ownerUserId: user.id, ownerLinkedAt: new Date() },
324
+ });
325
+
326
+ if (written.count === 0) {
327
+ return { ok: true, count: 0, promoted: [] };
328
+ }
329
+
330
+ return {
331
+ ok: true,
332
+ count: written.count,
333
+ promoted: attributable.map((listing) => ({
334
+ listingId: listing.id,
335
+ siteId: listing.siteId,
336
+ })),
337
+ };
338
+ } catch (err) {
339
+ const message = err instanceof Error ? err.message : String(err);
340
+ return { ok: false, error: { kind: "internal_error", message } };
341
+ }
342
+ }
@@ -80,7 +80,11 @@ import { prisma } from "../lib/prisma.js";
80
80
  import { listingConfigFor } from "./listing-config.js";
81
81
  import { generateListingFlow } from "./project-listing-flow.js";
82
82
  import { fileListingIssue } from "./project-listing-issue.js";
83
- import { computeListingStatus } from "./project-listings.js";
83
+ import {
84
+ addressPartsFromRow,
85
+ computeListingStatus,
86
+ siteAnswersFromRow,
87
+ } from "./project-listings.js";
84
88
 
85
89
  export type ListingDecision = "active" | "rejected";
86
90
 
@@ -102,7 +106,19 @@ const SELECT = {
102
106
  faqs: true,
103
107
  anythingElse: true,
104
108
  address: true,
109
+ // #5279 — the structured parts beside the display string.
110
+ addressLine1: true,
111
+ addressLine2: true,
112
+ addressCity: true,
113
+ addressRegion: true,
114
+ addressPostalCode: true,
115
+ addressCountry: true,
105
116
  questions: true,
117
+ // #5285 — the operator's review payload carries EVERY site answer, the
118
+ // `operator-only` ones included. That visibility is what it is for: an
119
+ // answer a site asked for its own triage reaches the reviewer and stops
120
+ // short of the public read, which narrows through `publicSiteAnswers`.
121
+ siteAnswers: true,
106
122
  founderName: true,
107
123
  founderEmail: true,
108
124
  founderCompany: true,
@@ -159,10 +175,14 @@ function toListing(row: Row, waitlistCount: number): ProjectListing {
159
175
  faqs: row.faqs as unknown as ProjectListingFaqs | null,
160
176
  anythingElse: row.anythingElse,
161
177
  address: row.address,
178
+ // #5279 — one reader of the parts, shared with `project-listings.ts`, so
179
+ // the two row renderers cannot drift on what "has parts" means.
180
+ addressParts: addressPartsFromRow(row),
162
181
  founderName: row.founderName,
163
182
  founderEmail: row.founderEmail,
164
183
  founderCompany: row.founderCompany,
165
184
  questions: row.questions as unknown as ProjectListingQuestions | null,
185
+ siteAnswers: siteAnswersFromRow(row.siteAnswers),
166
186
  logoMarkUrl: row.logoMarkUrl,
167
187
  logoWordmarkUrl: row.logoWordmarkUrl,
168
188
  featured: row.featured,