feat(schema): intake_links.client, projects.show_in_intake, intake_link_submissions

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
This commit is contained in:
Dave 2026-09-21 13:15:01 +02:00
parent 882e9e20a7
commit eb45fb0168
2 changed files with 159 additions and 0 deletions

View file

@ -634,6 +634,139 @@ async function ensureIntakeLinkCountersOptional(pb: PocketBase): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
// 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.

View file

@ -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<BaseRecord, "updated"> {
}
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;
}