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
|
@@ -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 {
|
|
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,
|