feat(schema): intake_links and anonymous submitter role

Phase 8a Task 1. Adds the new intake_links collection (per-token
public entry point, owner-only rules), extends cards.created_by_role
with 'anonymous', and extends activity.action with
'card_submitted_anonymously'.

Note: getOrCreateCollection is a no-op on existing collections, so
the two existing-value extensions are applied by new/extended
ensure functions (ensureCardsCreatedByRoleValues and
ensureActivityActionValues respectively). Re-runs are idempotent.
This commit is contained in:
Dave 2026-09-21 01:54:11 +02:00
parent 3e868f910a
commit c99be4d49e
2 changed files with 150 additions and 5 deletions

View file

@ -525,11 +525,16 @@ async function ensureActivityActionValues(pb: PocketBase): Promise<void> {
if (!field) {
throw new Error("activity.action field missing — cannot extend values");
}
const additions = ["column_changed", "date_proposed", "date_proposal_resolved"];
const additions = [
"column_changed",
"date_proposed",
"date_proposal_resolved",
"card_submitted_anonymously",
];
const existing = new Set(field.values ?? []);
const missing = additions.filter((v) => !existing.has(v));
if (missing.length === 0) {
console.log(" = activity.action values already include puzzle actions");
console.log(" = activity.action values already up to date");
return;
}
@ -542,6 +547,39 @@ async function ensureActivityActionValues(pb: PocketBase): Promise<void> {
console.log(` + activity.action values: ${missing.join(", ")}`);
}
/**
* Extend `cards.created_by_role` to include `"anonymous"` so the public
* intake route can produce Inbox cards without a session. The base set
* `["owner", "client"]` was set at first collection creation; re-running
* `getOrCreateCollection` on an existing `cards` row is a no-op, so the
* value set has to be patched explicitly here. Safe to re-run: values
* are not duplicated.
*/
async function ensureCardsCreatedByRoleValues(pb: PocketBase): Promise<void> {
const cards = await pb.collections.getOne("cards");
const field = cards.fields.find(
(f: { name: string }) => f.name === "created_by_role"
) as { values?: string[] } | undefined;
if (!field) {
throw new Error("cards.created_by_role field missing — cannot extend values");
}
const additions = ["anonymous"];
const existing = new Set(field.values ?? []);
const missing = additions.filter((v) => !existing.has(v));
if (missing.length === 0) {
console.log(" = cards.created_by_role values already include anonymous");
return;
}
const fields = cards.fields.map((f: { name: string; values?: string[] }) => {
if (f.name !== "created_by_role") return f;
return { ...f, values: [...(f.values ?? []), ...missing] };
});
await pb.collections.update(cards.id, { fields });
console.log(` + cards.created_by_role values: ${missing.join(", ")}`);
}
async function ensureProjectKindField(pb: PocketBase): Promise<void> {
const projects = await pb.collections.getOne("projects");
const have = new Set(projects.fields.map((f: { name: string }) => f.name));
@ -730,7 +768,7 @@ async function main(): Promise<void> {
type: "select",
required: true,
maxSelect: 1,
values: ["owner", "client"],
values: ["owner", "client", "anonymous"],
},
{ name: "accepted", type: "bool", required: false },
{ name: "start_date", type: "date", required: false },
@ -864,6 +902,7 @@ async function main(): Promise<void> {
"comment_added",
"subtask_toggled",
"card_archived",
"card_submitted_anonymously",
],
},
{ name: "field", type: "text", required: false, max: 60 },
@ -902,6 +941,76 @@ async function main(): Promise<void> {
],
});
// 11. intake_links — public, per-token entry points to a project's intake.
// Owner-only at the rule level: the public route reads and writes through
// the superuser connection, so PocketBase rules are not what protects
// them. They exist so a signed-in client manager cannot enumerate them.
await getOrCreateCollection(pb, {
name: "intake_links",
type: "base",
listRule: OWNER,
viewRule: OWNER,
createRule: null,
updateRule: null,
deleteRule: OWNER,
fields: [
{
name: "project",
type: "relation",
required: true,
collectionId: (await pb.collections.getOne("projects")).id,
cascadeDelete: true,
minSelect: 0,
maxSelect: 1,
},
{ name: "label", type: "text", required: true, max: 80 },
{
name: "token",
type: "text",
required: true,
max: 64,
pattern: "^[A-Za-z0-9_-]{32}$",
},
{ name: "active", type: "bool", required: true },
{ name: "expires_at", type: "date", required: false },
{
name: "max_submissions_per_day",
type: "number",
required: false,
onlyInt: true,
min: 0,
max: 10000,
},
{
name: "submission_count",
type: "number",
required: true,
onlyInt: true,
min: 0,
},
{
name: "submission_count_today",
type: "number",
required: true,
onlyInt: true,
min: 0,
},
{
name: "submission_count_today_date",
type: "date",
required: false,
},
{ name: "last_submission_at", type: "date", required: false },
{ name: "notes", type: "text", required: false, max: 1000 },
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_intake_links_token ON intake_links (token)",
"CREATE INDEX idx_intake_links_project ON intake_links (project, active)",
],
});
console.log("Applying API rules...");
await applyRules(pb, "clients", {
@ -960,6 +1069,14 @@ async function main(): Promise<void> {
deleteRule: null,
});
await applyRules(pb, "intake_links", {
listRule: OWNER,
viewRule: OWNER,
createRule: null,
updateRule: null,
deleteRule: OWNER,
});
// 9. commits — ingest target. Created via webhook/poll/backfill only; never
// from a browser.
await getOrCreateCollection(pb, {
@ -1135,6 +1252,9 @@ async function main(): Promise<void> {
// Extend activity.action with the three new puzzle actions.
await ensureActivityActionValues(pb);
// Extend cards.created_by_role to include "anonymous" for the public route.
await ensureCardsCreatedByRoleValues(pb);
console.log("Done.");
}

View file

@ -4,7 +4,7 @@ export type Role = "owner" | "client_manager";
export type Priority = "critical" | "high" | "medium" | "low";
export type ProjectStatus = "active" | "paused" | "completed" | "archived";
export type ProjectKind = "project" | "prototype";
export type CreatedByRole = "owner" | "client";
export type CreatedByRole = "owner" | "client" | "anonymous";
export type ActivityAction =
| "card_created"
@ -18,7 +18,8 @@ export type ActivityAction =
| "card_accepted"
| "comment_added"
| "subtask_toggled"
| "card_archived";
| "card_archived"
| "card_submitted_anonymously";
interface BaseRecord {
id: string;
@ -171,4 +172,28 @@ export interface ActivityRecord extends Omit<BaseRecord, "updated"> {
field: string;
old_value: string;
new_value: string;
}
export interface IntakeLinkRecord extends BaseRecord {
project: 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. */
token: string;
/** false stops every submission immediately, regardless of token shape. */
active: boolean;
/** Past this date the link behaves as if active = false. "" when unset. */
expires_at: string;
/** Per-local-day cap. 0 means "no submissions". Undefined → default in code. */
max_submissions_per_day: number;
/** Total successful submissions since creation. */
submission_count: number;
/** Counter reset lazily by `submission_count_today_date` not matching today. */
submission_count_today: number;
/** ISO date (YYYY-MM-DD) of the day this counter applies to. */
submission_count_today_date: string;
/** ISO timestamp of the most recent submission. "" when none yet. */
last_submission_at: string;
/** Owner's private note. Never leaves the admin page. */
notes: string;
}