From eb45fb0168a2758fe8d9716ae519653c6142b73e Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 21 Sep 2026 13:15:01 +0200 Subject: [PATCH] feat(schema): intake_links.client, projects.show_in_intake, intake_link_submissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 9a Task 1. Additive only — the Phase 8 shape stays live. intake_links.project becomes optional so Phase 9 rows can leave it empty. intake_links.client is the new owner of the link, read by the picker page in 9b. projects.show_in_intake defaults to false and is the knob that hides a project from the picker. The new intake_link_submissions collection tracks the daily counter per (link, project, date) so the cap applies per project, not per link. Type additions: - ProjectRecord.show_in_intake: boolean - IntakeLinkRecord.client: string (Phase 9) - new IntakeLinkSubmissionRecord --- scripts/setup-remote.ts | 137 ++++++++++++++++++++++++++++++++++++++++ src/lib/pb/types.ts | 22 +++++++ 2 files changed, 159 insertions(+) diff --git a/scripts/setup-remote.ts b/scripts/setup-remote.ts index 7dfb8cb..5cddba8 100644 --- a/scripts/setup-remote.ts +++ b/scripts/setup-remote.ts @@ -634,6 +634,139 @@ async function ensureIntakeLinkCountersOptional(pb: PocketBase): Promise { console.log(" + intake_links submission_counters now optional"); } +/** + * `intake_links.project` was Phase 8's required field. Phase 9 makes it + * optional and adds `intake_links.client` so the link can be tied to a + * client (the picker then asks the user which project under that client). + * Both fields are optional during the migration window: Phase 8 rows + * keep `project` set and `client` empty; Phase 9 rows set `client` and + * leave `project` empty. + */ +async function ensureIntakeLinkProjectOptional(pb: PocketBase): Promise { + const links = await pb.collections.getOne("intake_links"); + const fields = links.fields.map((f: { name: string; required?: boolean }) => { + if (f.name === "project" && f.required !== false) { + return { ...f, required: false }; + } + return f; + }); + const changed = fields.some( + (f: { name: string; required?: boolean }, i: number) => + f.required !== links.fields[i].required + ); + if (!changed) { + console.log(" = intake_links.project already optional"); + return; + } + await pb.collections.update(links.id, { fields }); + console.log(" + intake_links.project now optional"); +} + +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)); + if (have.has("client")) { + console.log(" = intake_links.client already present"); + return; + } + const clients = await pb.collections.getOne("clients"); + const fields = [...(links.fields as unknown[])]; + fields.push({ + name: "client", + type: "relation", + required: false, + collectionId: clients.id, + cascadeDelete: false, + minSelect: 0, + maxSelect: 1, + }); + await pb.collections.update(links.id, { fields }); + console.log(" + intake_links field: client (relation → clients)"); +} + +/** + * Phase 9 opt-in knob on projects. Default off. Owners flip it on per + * project from the project admin page; the picker page only lists + * projects where this is true. + */ +async function ensureProjectShowInIntakeField(pb: PocketBase): Promise { + const projects = await pb.collections.getOne("projects"); + const have = new Set(projects.fields.map((f: { name: string }) => f.name)); + if (have.has("show_in_intake")) { + console.log(" = projects.show_in_intake already present"); + return; + } + const fields = [...(projects.fields as unknown[])]; + fields.push({ + name: "show_in_intake", + type: "bool", + required: false, + presentable: false, + hidden: false, + system: false, + }); + await pb.collections.update(projects.id, { fields }); + console.log(" + projects field: show_in_intake"); +} + +/** + * Per-(link, project, date) submission counter for Phase 9. The unique + * index lets us upsert: first submission of the day inserts, later ones + * increment the count column. Phase 8 per-link counters stay on the + * link row for the legacy shape. + */ +async function ensureIntakeLinkSubmissionsCollection(pb: PocketBase): Promise { + const existing = await pb.collections + .getOne("intake_link_submissions") + .catch(() => null); + if (existing) return; + + const links = await pb.collections.getOne("intake_links"); + const projects = await pb.collections.getOne("projects"); + + await pb.collections.create({ + name: "intake_link_submissions", + type: "base", + listRule: OWNER, + viewRule: OWNER, + createRule: null, + updateRule: null, + deleteRule: OWNER, + fields: [ + { + name: "link", + type: "relation", + required: true, + collectionId: links.id, + cascadeDelete: true, + minSelect: 0, + maxSelect: 1, + }, + { + name: "project", + type: "relation", + required: true, + collectionId: projects.id, + cascadeDelete: true, + minSelect: 0, + maxSelect: 1, + }, + { name: "date", type: "date", required: true }, + { + name: "count", + type: "number", + required: true, + onlyInt: true, + min: 0, + }, + ], + indexes: [ + "CREATE UNIQUE INDEX idx_intake_link_submissions_lpd ON intake_link_submissions (link, project, date)", + ], + }); + console.log(" + collection intake_link_submissions"); +} + /** * The activity row for an anonymous public submission has no actor by * definition. The route omits the field rather than sending an empty @@ -1370,6 +1503,10 @@ async function main(): Promise { // PocketBase rejects inserts to intake_links unless the counter fields are // optional — `required: true` + `min: 0` is contradictory there. await ensureIntakeLinkCountersOptional(pb); + await ensureIntakeLinkProjectOptional(pb); + await ensureIntakeLinkClientField(pb); + await ensureProjectShowInIntakeField(pb); + await ensureIntakeLinkSubmissionsCollection(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/pb/types.ts b/src/lib/pb/types.ts index 77dbd23..fe3be3a 100644 --- a/src/lib/pb/types.ts +++ b/src/lib/pb/types.ts @@ -63,6 +63,9 @@ export interface ProjectRecord extends BaseRecord { post_session_minutes: number; /** Developer's curated context for this app, sent into every wizard prompt. */ background_md: string; + /** Phase 9 opt-in knob: true when this project should appear in the public + * intake picker. Default false; owner opts in. */ + show_in_intake: boolean; } export interface ColumnRecord extends BaseRecord { @@ -177,7 +180,12 @@ export interface ActivityRecord extends Omit { } export interface IntakeLinkRecord extends BaseRecord { + /** Phase 8 path: project this link binds to directly. Empty on Phase 9 rows. */ project: string; + /** Phase 9 path: client that owns this link. Empty on Phase 8 rows. + * During the migration window either may be empty; the picker page + * treats a row with both empty as "not configured". */ + client: string; /** The owner's name for this link, e.g. "Acme — till phone". */ label: string; /** 32-char URL-safe token; never sent to a client manager. */ @@ -198,4 +206,18 @@ export interface IntakeLinkRecord extends BaseRecord { last_submission_at: string; /** Owner's private note. Never leaves the admin page. */ notes: string; +} + +/** + * Phase 9: one row per (link, project, date). Tracks today's submissions + * per `(link, project)` pair so the daily cap isolates one project from + * another under the same client-bound link. + */ +export interface IntakeLinkSubmissionRecord extends BaseRecord { + link: string; + project: string; + /** YYYY-MM-DD local date. */ + date: string; + /** Submissions today on this (link, project) pair. */ + count: number; } \ No newline at end of file