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:
parent
882e9e20a7
commit
eb45fb0168
2 changed files with 159 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue