diff --git a/scripts/setup-remote.ts b/scripts/setup-remote.ts index 5cddba8..eeee903 100644 --- a/scripts/setup-remote.ts +++ b/scripts/setup-remote.ts @@ -662,6 +662,37 @@ async function ensureIntakeLinkProjectOptional(pb: PocketBase): Promise { console.log(" + intake_links.project now optional"); } +/** + * PocketBase's `date` field defaults to `unique: true`. That collides + * with our (link, project, date) index because two rows on the same + * date but different links/projects would share the date. Flip the + * `unique` flag off so the custom index is the only constraint. + */ +async function ensureIntakeLinkSubmissionsDateNotUnique( + pb: PocketBase +): Promise { + const col = await pb.collections + .getOne("intake_link_submissions") + .catch(() => null); + if (!col) return; + const fields = col.fields.map((f: { name: string; unique?: boolean }) => { + if (f.name === "date" && f.unique !== false) { + return { ...f, unique: false }; + } + return f; + }); + const changed = fields.some( + (f: { name: string; unique?: boolean }, i: number) => + f.unique !== col.fields[i].unique + ); + if (!changed) { + console.log(" = intake_link_submissions.date unique already false"); + return; + } + await pb.collections.update(col.id, { fields }); + console.log(" + intake_link_submissions.date now non-unique"); +} + async function ensureIntakeLinkClientField(pb: PocketBase): Promise { const links = await pb.collections.getOne("intake_links"); const have = new Set(links.fields.map((f: { name: string }) => f.name)); @@ -751,7 +782,7 @@ async function ensureIntakeLinkSubmissionsCollection(pb: PocketBase): Promise { await ensureIntakeLinkClientField(pb); await ensureProjectShowInIntakeField(pb); await ensureIntakeLinkSubmissionsCollection(pb); + await ensureIntakeLinkSubmissionsDateNotUnique(pb); // The public route writes activity rows with no actor (the submitter is // anonymous); the actor field must therefore be optional. diff --git a/src/lib/intake-links/index.ts b/src/lib/intake-links/index.ts index dad9e0c..3b2341e 100644 --- a/src/lib/intake-links/index.ts +++ b/src/lib/intake-links/index.ts @@ -158,22 +158,45 @@ export async function bumpDailyCapForProject( ): Promise { const pb = await getAdminPb(); const today = computeDateForCounter(); + const filter = `link = "${link.id}" && project = "${projectId}" && date = "${today}"`; - try { + // Read-after-write consistency: PocketBase's `getFirstListItem` may + // not see a row we just created in the same logical burst, even + // though the unique index will reject a duplicate create. So the + // upsert pattern is: read first; if missing, try create; if create + // races a peer's write and hits the unique constraint, fall back to + // the read again and increment. + const tryIncrement = async (): Promise => { const existing = await pb .collection("intake_link_submissions") - .getFirstListItem( - `link = "${link.id}" && project = "${projectId}" && date = "${today}"` - ); + .getFirstListItem(filter); await pb.collection("intake_link_submissions").update(existing.id, { count: nextCount(existing.count), }); + }; + + try { + await tryIncrement(); + return; } catch { + // No row yet — try to create it. + } + + try { await pb.collection("intake_link_submissions").create({ link: link.id, project: projectId, date: today, count: 1, }); + } catch (createErr) { + // Race: a peer (or our own prior attempt) created the row between + // our read and our create. The unique index will reject the + // duplicate; fall through to the read-then-update path. + try { + await tryIncrement(); + } catch { + throw createErr; + } } } diff --git a/tests/integration/intake-link-submissions.test.ts b/tests/integration/intake-link-submissions.test.ts new file mode 100644 index 0000000..28ffcba --- /dev/null +++ b/tests/integration/intake-link-submissions.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import PocketBase from "pocketbase"; +import { randomBytes } from "node:crypto"; + +import { APP_URL, DEMO, adminPb, requireServices } from "./helpers"; + +let admin: PocketBase; +let projectId: string; +let secondProjectId: string; +let clientId: string; + +const createdLinkIds: string[] = []; +const createdCardIds: string[] = []; +const createdSubmissionIds: string[] = []; + +function makeToken(): string { + return randomBytes(24).toString("base64url"); +} + +async function newPhase9Link() { + const token = makeToken(); + const link = await admin.collection("intake_links").create({ + project: "", + client: clientId, + label: "phase9 cap test", + token, + active: true, + expires_at: "", + max_submissions_per_day: 2, + submission_count: 0, + submission_count_today: 0, + submission_count_today_date: "", + last_submission_at: "", + notes: "", + }); + createdLinkIds.push(link.id); + return link; +} + +beforeAll(async () => { + await requireServices(); + admin = await adminPb(); + + const project = await admin + .collection("projects") + .getFirstListItem(`slug = "${DEMO.projectSlug}"`); + projectId = project.id; + clientId = project.client; + + const existing = await admin + .collection("projects") + .getFullList({ + filter: `client = "${clientId}" && name = "Phase9 cap test second"`, + }); + if (existing.length > 0) { + secondProjectId = existing[0].id; + } else { + const second = await admin.collection("projects").create({ + client: clientId, + name: "Phase9 cap test second", + slug: `phase9-cap-test-second-${Date.now()}`, + status: "active", + }); + secondProjectId = second.id; + } +}); + +afterAll(async () => { + for (const id of createdCardIds) { + await admin.collection("cards").delete(id).catch(() => {}); + } + for (const id of createdLinkIds) { + await admin.collection("intake_links").delete(id).catch(() => {}); + } + for (const id of createdSubmissionIds) { + await admin + .collection("intake_link_submissions") + .delete(id) + .catch(() => {}); + } + await admin + .collection("projects") + .update(projectId, { show_in_intake: false }) + .catch(() => {}); + await admin + .collection("projects") + .update(secondProjectId, { show_in_intake: false }) + .catch(() => {}); +}); + +describe("intake_link_submissions schema lands and tracks per-(link, project)", () => { + it("creates a row, reads it back with the expected fields", async () => { + const link = await newPhase9Link(); + const today = new Date().toISOString().slice(0, 10); + + // Direct PocketBase write — exercises the schema only. + const row = await admin.collection("intake_link_submissions").create({ + link: link.id, + project: projectId, + date: today, + count: 1, + }); + createdSubmissionIds.push(row.id); + + expect(row.link).toBe(link.id); + expect(row.project).toBe(projectId); + expect(row.date).toContain(today); + expect(row.count).toBe(1); + + const readBack = await admin + .collection("intake_link_submissions") + .getOne(row.id); + expect(readBack.count).toBe(1); + + // A second row for the same link, different project, same date — must + // succeed because the unique index is on the COMPOSITE (link, project, + // date), not on date alone. This is the property Phase 9 needs. + const row2 = await admin.collection("intake_link_submissions").create({ + link: link.id, + project: secondProjectId, + date: today, + count: 1, + }); + createdSubmissionIds.push(row2.id); + + expect(row2.id).not.toBe(row.id); + }); + + it("the unique index blocks a duplicate (link, project, date)", async () => { + const link = await newPhase9Link(); + const today = new Date().toISOString().slice(0, 10); + + await admin.collection("intake_link_submissions").create({ + link: link.id, + project: projectId, + date: today, + count: 1, + }).then((r) => createdSubmissionIds.push(r.id)); + + // Same (link, project, date) again — should fail. + let rejected = false; + try { + await admin.collection("intake_link_submissions").create({ + link: link.id, + project: projectId, + date: today, + count: 2, + }); + } catch (e) { + rejected = true; + // Cleanup the row the rejected create might have left. + try { + const todayDate = new Date().toISOString().slice(0, 10); + const all = await admin.collection("intake_link_submissions").getFullList({ + filter: `link = "${link.id}" && project = "${projectId}" && date = "${todayDate}"`, + }); + for (const r of all) createdSubmissionIds.push(r.id); + } catch { + // ignore + } + } + expect(rejected).toBe(true); + }); +}); + +describe("the public route exists for Phase 9", () => { + it("returns a sensible status, never 500, on a Phase 9 link", async () => { + const link = await newPhase9Link(); + const response = await fetch(`${APP_URL}/api/public/intake`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + token: link.token, + brief: { title: "x", summary: "y", subtasks: [], area: "", degraded: false }, + transcript: [], + }), + }); + expect([400, 404, 409]).toContain(response.status); + }); +});