2026-09-16 07:41:20 +00:00
|
|
|
/**
|
|
|
|
|
* One-shot remote schema setup.
|
|
|
|
|
*
|
|
|
|
|
* The plan's migrations live in pocketbase/pb_migrations/ and would auto-apply
|
|
|
|
|
* on a local PocketBase start. The shared instance is already running, so
|
|
|
|
|
* this script performs the equivalent actions over the admin API:
|
|
|
|
|
*
|
|
|
|
|
* - creates the 8 application collections
|
|
|
|
|
* - adds the role/client fields to users
|
|
|
|
|
* - applies the role-scoped API rules
|
|
|
|
|
*
|
|
|
|
|
* Re-runs are safe: existing collections are reused, fields are not
|
|
|
|
|
* duplicated, and rules are reapplied each time.
|
|
|
|
|
*
|
|
|
|
|
* Run with: npm run setup-remote (PocketBase must be reachable)
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import PocketBase from "pocketbase";
|
|
|
|
|
|
|
|
|
|
const PB_URL =
|
|
|
|
|
process.env.NEXT_PUBLIC_PB_URL ??
|
|
|
|
|
"http://127.0.0.1:8090";
|
|
|
|
|
|
|
|
|
|
async function authAdmin(pb: PocketBase): Promise<void> {
|
|
|
|
|
const email = process.env.PB_SUPERUSER_EMAIL;
|
|
|
|
|
const password = process.env.PB_SUPERUSER_PASSWORD;
|
|
|
|
|
if (!email || !password) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
"PB_SUPERUSER_EMAIL and PB_SUPERUSER_PASSWORD must be set. Copy .env.example to .env.local."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
pb.autoCancellation(false);
|
|
|
|
|
await pb.collection("_superusers").authWithPassword(email, password);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getOrCreateCollection(
|
|
|
|
|
pb: PocketBase,
|
|
|
|
|
body: Record<string, unknown>
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
const name = body.name as string;
|
|
|
|
|
const existing = await pb.collections
|
|
|
|
|
.getOne(name)
|
|
|
|
|
.catch(() => null);
|
|
|
|
|
if (existing) return existing.id;
|
|
|
|
|
|
|
|
|
|
const created = await pb.collections.create(body);
|
|
|
|
|
console.log(` + collection ${name} (${created.id})`);
|
|
|
|
|
return created.id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function ensureUsersFields(pb: PocketBase): Promise<void> {
|
|
|
|
|
const users = await pb.collections.getOne("users");
|
|
|
|
|
const have = new Set(users.fields.map((f: { name: string }) => f.name));
|
|
|
|
|
|
|
|
|
|
let changed = false;
|
|
|
|
|
const fields = [...(users.fields as unknown[])];
|
|
|
|
|
|
|
|
|
|
if (!have.has("role")) {
|
|
|
|
|
fields.push({
|
|
|
|
|
name: "role",
|
|
|
|
|
type: "select",
|
|
|
|
|
required: true,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
values: ["owner", "client_manager"],
|
|
|
|
|
});
|
|
|
|
|
changed = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!have.has("client")) {
|
|
|
|
|
const clients = await pb.collections.getOne("clients");
|
|
|
|
|
fields.push({
|
|
|
|
|
name: "client",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: false,
|
|
|
|
|
collectionId: clients.id,
|
|
|
|
|
cascadeDelete: false,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
});
|
|
|
|
|
changed = true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 09:26:16 +00:00
|
|
|
if (!have.has("active")) {
|
|
|
|
|
fields.push({
|
|
|
|
|
name: "active",
|
|
|
|
|
type: "bool",
|
|
|
|
|
required: false,
|
|
|
|
|
presentable: false,
|
|
|
|
|
hidden: false,
|
|
|
|
|
system: false,
|
|
|
|
|
});
|
|
|
|
|
changed = true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 07:41:20 +00:00
|
|
|
if (changed) {
|
|
|
|
|
await pb.collections.update(users.id, { fields });
|
2026-09-16 09:26:16 +00:00
|
|
|
console.log(" + users fields: role, client, active");
|
2026-09-16 07:41:20 +00:00
|
|
|
} else {
|
|
|
|
|
console.log(" = users fields already present");
|
|
|
|
|
}
|
2026-09-16 09:26:16 +00:00
|
|
|
|
|
|
|
|
// Backfill: an account with active unset must not be locked out by this
|
|
|
|
|
// field appearing.
|
|
|
|
|
const existing = await pb.collection("users").getFullList();
|
|
|
|
|
for (const user of existing) {
|
|
|
|
|
if (user.active !== true) {
|
|
|
|
|
await pb.collection("users").update(user.id, { active: true });
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-16 07:41:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const OWNER = '@request.auth.role = "owner"';
|
|
|
|
|
const AUTHED = '@request.auth.id != ""';
|
|
|
|
|
const scopedRead = (pathToClient: string) =>
|
|
|
|
|
`${AUTHED} && (${OWNER} || ${pathToClient} = @request.auth.client)`;
|
|
|
|
|
|
2026-09-16 09:18:13 +00:00
|
|
|
/** For the users collection: the owner sees everyone, everyone else sees only themselves. */
|
|
|
|
|
const OWNER_OR_SELF = `${AUTHED} && (${OWNER} || id = @request.auth.id)`;
|
|
|
|
|
|
2026-09-16 07:41:20 +00:00
|
|
|
async function applyRules(
|
|
|
|
|
pb: PocketBase,
|
|
|
|
|
name: string,
|
|
|
|
|
rules: {
|
|
|
|
|
listRule: string | null;
|
|
|
|
|
viewRule: string | null;
|
|
|
|
|
createRule: string | null;
|
|
|
|
|
updateRule: string | null;
|
|
|
|
|
deleteRule: string | null;
|
|
|
|
|
}
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const c = await pb.collections.getOne(name);
|
|
|
|
|
await pb.collections.update(c.id, rules);
|
|
|
|
|
console.log(` = rules for ${name}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 11:20:24 +00:00
|
|
|
async function ensureProjectIngestFields(pb: PocketBase): Promise<void> {
|
|
|
|
|
const projects = await pb.collections.getOne("projects");
|
|
|
|
|
const have = new Set(projects.fields.map((f: { name: string }) => f.name));
|
|
|
|
|
|
|
|
|
|
const additions = [
|
|
|
|
|
{ name: "webhook_secret", type: "text", required: false, max: 80 },
|
|
|
|
|
{ name: "last_ingest_at", type: "date", required: false },
|
2026-09-16 11:51:03 +00:00
|
|
|
{
|
|
|
|
|
name: "session_gap_minutes",
|
|
|
|
|
type: "number",
|
|
|
|
|
required: false,
|
|
|
|
|
onlyInt: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "pre_session_minutes",
|
|
|
|
|
type: "number",
|
|
|
|
|
required: false,
|
|
|
|
|
onlyInt: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "post_session_minutes",
|
|
|
|
|
type: "number",
|
|
|
|
|
required: false,
|
|
|
|
|
onlyInt: true,
|
|
|
|
|
},
|
2026-09-16 11:20:24 +00:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const fields = [...(projects.fields as unknown[])];
|
|
|
|
|
let changed = false;
|
|
|
|
|
|
|
|
|
|
for (const field of additions) {
|
|
|
|
|
if (!have.has(field.name)) {
|
|
|
|
|
fields.push({ ...field, presentable: false, hidden: false, system: false });
|
|
|
|
|
changed = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (changed) {
|
|
|
|
|
await pb.collections.update(projects.id, { fields });
|
|
|
|
|
console.log(
|
|
|
|
|
` + projects fields: ${additions
|
|
|
|
|
.map((f) => f.name)
|
|
|
|
|
.join(", ")}`
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
console.log(" = projects ingest fields already present");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 14:15:18 +00:00
|
|
|
/**
|
|
|
|
|
* Add the `summary`, `summary_version`, `summary_attempts` fields to commits.
|
|
|
|
|
*
|
|
|
|
|
* Stored alongside each generated one-liner:
|
|
|
|
|
* - summary_version — which PROMPT_VERSION produced it, so a stale summary
|
|
|
|
|
* can be detected and re-generated rather than served to a client as if it
|
|
|
|
|
* were current.
|
|
|
|
|
* - summary_attempts — consecutive failures. A message that trips the
|
|
|
|
|
* provider every time must not be retried forever on every ingest, so
|
|
|
|
|
* summarise.ts caps it.
|
|
|
|
|
*
|
|
|
|
|
* Re-runs are safe: fields are not duplicated.
|
|
|
|
|
*/
|
|
|
|
|
async function ensureCommitSummaryFields(pb: PocketBase): Promise<void> {
|
|
|
|
|
const commits = await pb.collections.getOne("commits");
|
|
|
|
|
const have = new Set(commits.fields.map((f: { name: string }) => f.name));
|
|
|
|
|
|
|
|
|
|
const additions = [
|
|
|
|
|
{ name: "summary", type: "text", required: false, max: 500 },
|
|
|
|
|
{ name: "summary_version", type: "number", required: false, onlyInt: true },
|
|
|
|
|
{ name: "summary_attempts", type: "number", required: false, onlyInt: true },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const fields = [...(commits.fields as unknown[])];
|
|
|
|
|
let changed = false;
|
|
|
|
|
|
|
|
|
|
for (const field of additions) {
|
|
|
|
|
if (!have.has(field.name)) {
|
|
|
|
|
fields.push({ ...field, presentable: false, hidden: false, system: false });
|
|
|
|
|
changed = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (changed) {
|
|
|
|
|
await pb.collections.update(commits.id, { fields });
|
|
|
|
|
console.log(" + commits fields: summary, summary_version, summary_attempts");
|
|
|
|
|
} else {
|
|
|
|
|
console.log(" = commit summary fields already present");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 08:14:51 +00:00
|
|
|
/**
|
|
|
|
|
* Add the `source` and `manual_minutes` fields to commits, for Phase 7's
|
|
|
|
|
* logged work entries.
|
|
|
|
|
*
|
|
|
|
|
* Backfilled to "git" for every existing row — every commit ingested before
|
|
|
|
|
* this phase came from the repository, by definition.
|
|
|
|
|
*
|
|
|
|
|
* Re-runs are safe: fields are not duplicated, and backfill only touches rows
|
|
|
|
|
* where source is still unset.
|
|
|
|
|
*/
|
2026-09-19 08:22:26 +00:00
|
|
|
async function ensureCommitSourceFields(pb: PocketBase): Promise<void> {
|
2026-09-19 08:14:51 +00:00
|
|
|
const commits = await pb.collections.getOne("commits");
|
|
|
|
|
const have = new Set(commits.fields.map((f: { name: string }) => f.name));
|
|
|
|
|
|
|
|
|
|
const additions = [
|
|
|
|
|
{
|
|
|
|
|
name: "source",
|
|
|
|
|
type: "select",
|
|
|
|
|
required: false,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
values: ["git", "manual"],
|
|
|
|
|
},
|
2026-09-19 08:22:26 +00:00
|
|
|
{ name: "manual_minutes", type: "number", required: false, onlyInt: true, min: 0 },
|
2026-09-19 08:14:51 +00:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const fields = [...(commits.fields as unknown[])];
|
|
|
|
|
let changed = false;
|
|
|
|
|
|
|
|
|
|
for (const field of additions) {
|
|
|
|
|
if (!have.has(field.name)) {
|
|
|
|
|
fields.push({ ...field, presentable: false, hidden: false, system: false });
|
|
|
|
|
changed = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (changed) {
|
|
|
|
|
await pb.collections.update(commits.id, { fields });
|
|
|
|
|
console.log(" + commits fields: source, manual_minutes");
|
|
|
|
|
} else {
|
2026-09-19 08:22:26 +00:00
|
|
|
console.log(" = commit source fields already present");
|
2026-09-19 08:14:51 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const unset = await pb.collection("commits").getFullList({
|
|
|
|
|
filter: 'source = ""',
|
|
|
|
|
fields: "id",
|
|
|
|
|
});
|
|
|
|
|
for (const commit of unset) {
|
|
|
|
|
await pb.collection("commits").update(commit.id, { source: "git" });
|
|
|
|
|
}
|
2026-09-19 08:22:26 +00:00
|
|
|
const prefix = unset.length > 0 ? "+" : "=";
|
|
|
|
|
console.log(` ${prefix} commit source backfilled (${unset.length} row(s))`);
|
2026-09-19 08:14:51 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-16 14:24:18 +00:00
|
|
|
/**
|
|
|
|
|
* Add the `narrative`, `narrative_version`, `narrative_shas` fields to cards.
|
|
|
|
|
*
|
|
|
|
|
* `narrative` is the AI-generated client-facing progress update. `narrative_shas`
|
|
|
|
|
* is the set fingerprint (see shaFingerprint in src/lib/ai/narrative.ts) of
|
|
|
|
|
* the linked commits that produced it, so the service can detect when the
|
|
|
|
|
* commit set changes and re-generate rather than overwriting fresh text with
|
|
|
|
|
* stale text. `narrative_version` is the prompt version, same idea as
|
|
|
|
|
* commit summary_version.
|
|
|
|
|
*
|
|
|
|
|
* Re-runs are safe: fields are not duplicated.
|
|
|
|
|
*/
|
2026-09-16 16:04:22 +00:00
|
|
|
/**
|
|
|
|
|
* Add the `brief`, `transcript`, `size_band` fields used by Phase 4b's intake.
|
|
|
|
|
*
|
|
|
|
|
* `brief` is the JSON object the client confirmed in the wizard, kept verbatim
|
|
|
|
|
* after re-parsing through the same parseBrief whitelist. `transcript` is the
|
|
|
|
|
* question-and-answer pairs. `size_band` is derived in code from the
|
|
|
|
|
* decomposition, never taken from the model's output.
|
|
|
|
|
*
|
|
|
|
|
* Re-runs are safe: fields are not duplicated.
|
|
|
|
|
*/
|
|
|
|
|
async function ensureCardIntakeFields(pb: PocketBase): Promise<void> {
|
|
|
|
|
const cards = await pb.collections.getOne("cards");
|
|
|
|
|
const have = new Set(cards.fields.map((f: { name: string }) => f.name));
|
|
|
|
|
|
|
|
|
|
const additions = [
|
|
|
|
|
{ name: "brief", type: "json", required: false, maxSize: 200_000 },
|
|
|
|
|
{ name: "transcript", type: "json", required: false, maxSize: 200_000 },
|
|
|
|
|
{
|
|
|
|
|
name: "size_band",
|
|
|
|
|
type: "select",
|
|
|
|
|
required: false,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
values: ["small", "medium", "large"],
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const fields = [...(cards.fields as unknown[])];
|
|
|
|
|
let changed = false;
|
|
|
|
|
|
|
|
|
|
for (const field of additions) {
|
|
|
|
|
if (!have.has(field.name)) {
|
|
|
|
|
fields.push({ ...field, presentable: false, hidden: false, system: false });
|
|
|
|
|
changed = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (changed) {
|
|
|
|
|
await pb.collections.update(cards.id, { fields });
|
|
|
|
|
console.log(" + cards fields: brief, transcript, size_band");
|
|
|
|
|
} else {
|
|
|
|
|
console.log(" = card intake fields already present");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 14:24:18 +00:00
|
|
|
async function ensureCardNarrativeFields(pb: PocketBase): Promise<void> {
|
|
|
|
|
const cards = await pb.collections.getOne("cards");
|
|
|
|
|
const have = new Set(cards.fields.map((f: { name: string }) => f.name));
|
|
|
|
|
|
|
|
|
|
const additions = [
|
|
|
|
|
{ name: "narrative", type: "text", required: false, max: 8000 },
|
|
|
|
|
{ name: "narrative_version", type: "number", required: false, onlyInt: true },
|
|
|
|
|
{ name: "narrative_shas", type: "text", required: false, max: 20000 },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const fields = [...(cards.fields as unknown[])];
|
|
|
|
|
let changed = false;
|
|
|
|
|
|
|
|
|
|
for (const field of additions) {
|
|
|
|
|
if (!have.has(field.name)) {
|
|
|
|
|
fields.push({ ...field, presentable: false, hidden: false, system: false });
|
|
|
|
|
changed = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (changed) {
|
|
|
|
|
await pb.collections.update(cards.id, { fields });
|
|
|
|
|
console.log(" + cards fields: narrative, narrative_version, narrative_shas");
|
|
|
|
|
} else {
|
|
|
|
|
console.log(" = card narrative fields already present");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 11:20:24 +00:00
|
|
|
async function ensureCardNumbers(pb: PocketBase): Promise<void> {
|
|
|
|
|
const cards = await pb.collections.getOne("cards");
|
|
|
|
|
const have = new Set(cards.fields.map((f: { name: string }) => f.name));
|
|
|
|
|
|
|
|
|
|
if (!have.has("number")) {
|
|
|
|
|
const fields = [...(cards.fields as unknown[])];
|
|
|
|
|
fields.push({
|
|
|
|
|
name: "number",
|
|
|
|
|
type: "number",
|
|
|
|
|
required: false,
|
|
|
|
|
onlyInt: true,
|
|
|
|
|
presentable: false,
|
|
|
|
|
hidden: false,
|
|
|
|
|
system: false,
|
|
|
|
|
});
|
|
|
|
|
await pb.collections.update(cards.id, { fields });
|
|
|
|
|
console.log(" + cards field: number");
|
|
|
|
|
} else {
|
|
|
|
|
console.log(" = cards.number already present");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Backfill per project, oldest card first, so numbering matches the order
|
|
|
|
|
// the work was created in rather than the order PocketBase returns.
|
|
|
|
|
const projects = await pb.collection("projects").getFullList({ fields: "id" });
|
|
|
|
|
for (const project of projects) {
|
|
|
|
|
const all = await pb.collection("cards").getFullList({
|
|
|
|
|
filter: `project = "${project.id}"`,
|
|
|
|
|
sort: "created",
|
|
|
|
|
fields: "id,number",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let next = 1;
|
|
|
|
|
for (const card of all) {
|
|
|
|
|
if (typeof card.number === "number" && card.number > 0) {
|
|
|
|
|
next = Math.max(next, card.number + 1);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
await pb.collection("cards").update(card.id, { number: next });
|
|
|
|
|
next += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
console.log(" = card numbers backfilled");
|
|
|
|
|
|
|
|
|
|
// A unique index makes "never reused" enforceable rather than hoped for.
|
|
|
|
|
const cardsCollection = await pb.collections.getOne("cards");
|
|
|
|
|
const indexes = new Set((cardsCollection.indexes as string[]) ?? []);
|
|
|
|
|
const unique = "CREATE UNIQUE INDEX idx_cards_project_number ON cards (project, number)";
|
|
|
|
|
if (!indexes.has(unique)) {
|
|
|
|
|
indexes.add(unique);
|
|
|
|
|
await pb.collections.update(cardsCollection.id, {
|
|
|
|
|
indexes: [...indexes],
|
|
|
|
|
});
|
|
|
|
|
console.log(" + unique index idx_cards_project_number");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 08:42:46 +00:00
|
|
|
/**
|
|
|
|
|
* Add the `kind` field to projects, for Phase 6's project/prototype
|
|
|
|
|
* distinction — a prototype is a full project (board, dashboard, same
|
|
|
|
|
* permissions), just tagged as not yet a committed piece of work.
|
|
|
|
|
*
|
|
|
|
|
* Backfilled to "project" for every existing row. Nothing already in
|
|
|
|
|
* production should read as a prototype just because the field is new.
|
|
|
|
|
*
|
|
|
|
|
* Re-runs are safe: the field is not duplicated, and backfill only touches
|
|
|
|
|
* rows where kind is still unset.
|
|
|
|
|
*/
|
2026-09-19 16:35:33 +00:00
|
|
|
/**
|
|
|
|
|
* Add the Plan Puzzle v1 proposal fields to `cards` (proposed_target_date,
|
|
|
|
|
* proposed_by, proposed_at) and the partial index that backs the
|
|
|
|
|
* "cards with pending proposals" query.
|
|
|
|
|
*
|
|
|
|
|
* `proposed_by` is a relation to users. `cascadeDelete: false` because a
|
|
|
|
|
* deactivated user must not silently drop their pending proposals — the
|
|
|
|
|
* history keeps "Someone" tied to a still-existing user record.
|
|
|
|
|
*
|
|
|
|
|
* Re-runs are safe: fields and the index are not duplicated.
|
|
|
|
|
*/
|
|
|
|
|
async function ensureCardProposalFields(pb: PocketBase): Promise<void> {
|
|
|
|
|
const cards = await pb.collections.getOne("cards");
|
|
|
|
|
const users = await pb.collections.getOne("users");
|
|
|
|
|
|
|
|
|
|
const have = new Set(cards.fields.map((f: { name: string }) => f.name));
|
|
|
|
|
|
|
|
|
|
const fields = [...(cards.fields as unknown[])];
|
|
|
|
|
let fieldChanged = false;
|
|
|
|
|
|
|
|
|
|
if (!have.has("proposed_target_date")) {
|
|
|
|
|
fields.push({
|
|
|
|
|
name: "proposed_target_date",
|
|
|
|
|
type: "date",
|
|
|
|
|
required: false,
|
|
|
|
|
presentable: false,
|
|
|
|
|
hidden: false,
|
|
|
|
|
system: false,
|
|
|
|
|
});
|
|
|
|
|
fieldChanged = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!have.has("proposed_by")) {
|
|
|
|
|
fields.push({
|
|
|
|
|
name: "proposed_by",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: false,
|
|
|
|
|
collectionId: users.id,
|
|
|
|
|
cascadeDelete: false,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
presentable: false,
|
|
|
|
|
hidden: false,
|
|
|
|
|
system: false,
|
|
|
|
|
});
|
|
|
|
|
fieldChanged = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!have.has("proposed_at")) {
|
|
|
|
|
fields.push({
|
|
|
|
|
name: "proposed_at",
|
|
|
|
|
type: "date",
|
|
|
|
|
required: false,
|
|
|
|
|
presentable: false,
|
|
|
|
|
hidden: false,
|
|
|
|
|
system: false,
|
|
|
|
|
});
|
|
|
|
|
fieldChanged = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (fieldChanged) {
|
|
|
|
|
await pb.collections.update(cards.id, { fields });
|
|
|
|
|
console.log(" + cards fields: proposed_target_date, proposed_by, proposed_at");
|
|
|
|
|
} else {
|
|
|
|
|
console.log(" = card proposal fields already present");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const reloaded = await pb.collections.getOne("cards");
|
|
|
|
|
const indexes = new Set((reloaded.indexes as string[]) ?? []);
|
|
|
|
|
const proposalIndex =
|
|
|
|
|
"CREATE INDEX idx_cards_proposal ON cards (proposed_target_date) WHERE proposed_target_date IS NOT NULL";
|
|
|
|
|
if (!indexes.has(proposalIndex)) {
|
|
|
|
|
indexes.add(proposalIndex);
|
|
|
|
|
await pb.collections.update(reloaded.id, { indexes: [...indexes] });
|
|
|
|
|
console.log(" + cards index: idx_cards_proposal");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Extend the activity `action` select with Plan Puzzle v1's three new values:
|
|
|
|
|
* `column_changed`, `date_proposed`, `date_proposal_resolved`. Existing rows
|
|
|
|
|
* are unaffected; only the schema's allowed values list grows.
|
|
|
|
|
*
|
|
|
|
|
* `column_changed` is new despite the plan referring to it as "already
|
|
|
|
|
* existing" — the kanban owner's drag-from-board move route today writes no
|
|
|
|
|
* activity row at all, so the value had never been added. The Puzzle's
|
|
|
|
|
* "return to scheduled" action is its first real consumer.
|
|
|
|
|
*
|
|
|
|
|
* Re-runs are safe: values are not duplicated.
|
|
|
|
|
*/
|
|
|
|
|
async function ensureActivityActionValues(pb: PocketBase): Promise<void> {
|
|
|
|
|
const activity = await pb.collections.getOne("activity");
|
|
|
|
|
const field = activity.fields.find(
|
|
|
|
|
(f: { name: string }) => f.name === "action"
|
|
|
|
|
) as { values?: string[] } | undefined;
|
|
|
|
|
if (!field) {
|
|
|
|
|
throw new Error("activity.action field missing — cannot extend values");
|
|
|
|
|
}
|
2026-09-20 23:54:11 +00:00
|
|
|
const additions = [
|
|
|
|
|
"column_changed",
|
|
|
|
|
"date_proposed",
|
|
|
|
|
"date_proposal_resolved",
|
|
|
|
|
"card_submitted_anonymously",
|
|
|
|
|
];
|
2026-09-19 16:35:33 +00:00
|
|
|
const existing = new Set(field.values ?? []);
|
|
|
|
|
const missing = additions.filter((v) => !existing.has(v));
|
|
|
|
|
if (missing.length === 0) {
|
2026-09-20 23:54:11 +00:00
|
|
|
console.log(" = activity.action values already up to date");
|
2026-09-19 16:35:33 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fields = activity.fields.map((f: { name: string; values?: string[] }) => {
|
|
|
|
|
if (f.name !== "action") return f;
|
|
|
|
|
return { ...f, values: [...(f.values ?? []), ...missing] };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await pb.collections.update(activity.id, { fields });
|
|
|
|
|
console.log(` + activity.action values: ${missing.join(", ")}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 23:54:11 +00:00
|
|
|
/**
|
|
|
|
|
* 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(", ")}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 04:30:49 +00:00
|
|
|
/**
|
|
|
|
|
* The intake_links schema first shipped with `submission_count` and
|
|
|
|
|
* `submission_count_today` as `required: true` and `min: 0`. PocketBase
|
|
|
|
|
* treats `0` as blank for required number fields, so any insert is rejected
|
|
|
|
|
* with `validation_required` until the field is marked optional. The
|
|
|
|
|
* counters default to 0 on creation; existing rows are unaffected.
|
|
|
|
|
*/
|
|
|
|
|
async function ensureIntakeLinkCountersOptional(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 === "submission_count" || f.name === "submission_count_today") &&
|
|
|
|
|
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 submission_counters already optional");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await pb.collections.update(links.id, { fields });
|
|
|
|
|
console.log(" + intake_links submission_counters now optional");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The activity row for an anonymous public submission has no actor by
|
|
|
|
|
* definition. The route omits the field rather than sending an empty
|
|
|
|
|
* string (which the relation would reject). Making `actor` optional in
|
|
|
|
|
* the schema lets the route write that row cleanly.
|
|
|
|
|
*/
|
|
|
|
|
async function ensureActivityActorOptional(pb: PocketBase): Promise<void> {
|
|
|
|
|
const activity = await pb.collections.getOne("activity");
|
|
|
|
|
const fields = activity.fields.map((f: { name: string; required?: boolean }) => {
|
|
|
|
|
if (f.name === "actor" && f.required !== false) {
|
|
|
|
|
return { ...f, required: false };
|
|
|
|
|
}
|
|
|
|
|
return f;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const changed = fields.some(
|
|
|
|
|
(f: { name: string; required?: boolean }, i: number) =>
|
|
|
|
|
f.required !== activity.fields[i].required
|
|
|
|
|
);
|
|
|
|
|
if (!changed) {
|
|
|
|
|
console.log(" = activity.actor already optional");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await pb.collections.update(activity.id, { fields });
|
|
|
|
|
console.log(" + activity.actor now optional");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* `active: false` fails the same PocketBase quirk as `0` does for number
|
|
|
|
|
* fields and `""` does for relations — `required: true` rejects the falsy
|
|
|
|
|
* value. The owner can create an already-revoked link, so the field has to
|
|
|
|
|
* accept both values at insert time. Every code path that creates a link
|
|
|
|
|
* sets `active: true` explicitly.
|
|
|
|
|
*/
|
|
|
|
|
async function ensureIntakeLinkActiveOptional(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 === "active" && 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.active already optional");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await pb.collections.update(links.id, { fields });
|
|
|
|
|
console.log(" + intake_links.active now optional");
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 08:42:46 +00:00
|
|
|
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));
|
|
|
|
|
|
|
|
|
|
if (!have.has("kind")) {
|
|
|
|
|
const fields = [...(projects.fields as unknown[])];
|
|
|
|
|
fields.push({
|
|
|
|
|
name: "kind",
|
|
|
|
|
type: "select",
|
|
|
|
|
required: false,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
values: ["project", "prototype"],
|
|
|
|
|
presentable: false,
|
|
|
|
|
hidden: false,
|
|
|
|
|
system: false,
|
|
|
|
|
});
|
|
|
|
|
await pb.collections.update(projects.id, { fields });
|
|
|
|
|
console.log(" + projects field: kind");
|
|
|
|
|
} else {
|
|
|
|
|
console.log(" = projects.kind already present");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const unset = await pb.collection("projects").getFullList({
|
|
|
|
|
filter: 'kind = ""',
|
|
|
|
|
fields: "id",
|
|
|
|
|
});
|
|
|
|
|
for (const project of unset) {
|
|
|
|
|
await pb.collection("projects").update(project.id, { kind: "project" });
|
|
|
|
|
}
|
|
|
|
|
console.log(` = project kind backfilled (${unset.length} row(s))`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 07:41:20 +00:00
|
|
|
async function main(): Promise<void> {
|
|
|
|
|
const pb = new PocketBase(PB_URL);
|
|
|
|
|
await authAdmin(pb);
|
|
|
|
|
|
|
|
|
|
console.log("Creating collections...");
|
|
|
|
|
|
|
|
|
|
// 1. clients
|
|
|
|
|
await getOrCreateCollection(pb, {
|
|
|
|
|
name: "clients",
|
|
|
|
|
type: "base",
|
|
|
|
|
listRule: AUTHED,
|
|
|
|
|
viewRule: AUTHED,
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
fields: [
|
|
|
|
|
{ name: "name", type: "text", required: true, max: 120, presentable: true },
|
|
|
|
|
{ name: "slug", type: "text", required: true, max: 60, pattern: "^[a-z0-9-]+$" },
|
|
|
|
|
{ name: "brand_color", type: "text", required: false, max: 7 },
|
|
|
|
|
{ name: "active", type: "bool", required: false },
|
|
|
|
|
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
|
|
|
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
|
|
|
|
],
|
|
|
|
|
indexes: ["CREATE UNIQUE INDEX idx_clients_slug ON clients (slug)"],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 2. projects
|
|
|
|
|
const clientsId = (await pb.collections.getOne("clients")).id;
|
|
|
|
|
await getOrCreateCollection(pb, {
|
|
|
|
|
name: "projects",
|
|
|
|
|
type: "base",
|
|
|
|
|
listRule: AUTHED,
|
|
|
|
|
viewRule: AUTHED,
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
fields: [
|
|
|
|
|
{
|
|
|
|
|
name: "client",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: true,
|
|
|
|
|
collectionId: clientsId,
|
|
|
|
|
cascadeDelete: false,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{ name: "name", type: "text", required: true, max: 120, presentable: true },
|
|
|
|
|
{ name: "slug", type: "text", required: true, max: 60, pattern: "^[a-z0-9-]+$" },
|
|
|
|
|
{ name: "description", type: "text", required: false, max: 20000 },
|
|
|
|
|
{
|
|
|
|
|
name: "status",
|
|
|
|
|
type: "select",
|
|
|
|
|
required: true,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
values: ["active", "paused", "completed", "archived"],
|
|
|
|
|
},
|
|
|
|
|
{ name: "color", type: "text", required: false, max: 7 },
|
|
|
|
|
{ name: "repo_owner", type: "text", required: false, max: 120 },
|
|
|
|
|
{ name: "repo_name", type: "text", required: false, max: 120 },
|
|
|
|
|
{ name: "repo_url", type: "url", required: false },
|
|
|
|
|
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
|
|
|
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
|
|
|
|
],
|
|
|
|
|
indexes: [
|
|
|
|
|
"CREATE UNIQUE INDEX idx_projects_client_slug ON projects (client, slug)",
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Extend users
|
|
|
|
|
await ensureUsersFields(pb);
|
|
|
|
|
|
|
|
|
|
// 3. columns
|
|
|
|
|
const projectsId = (await pb.collections.getOne("projects")).id;
|
|
|
|
|
await getOrCreateCollection(pb, {
|
|
|
|
|
name: "columns",
|
|
|
|
|
type: "base",
|
|
|
|
|
listRule: AUTHED,
|
|
|
|
|
viewRule: AUTHED,
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
fields: [
|
|
|
|
|
{
|
|
|
|
|
name: "project",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: true,
|
|
|
|
|
collectionId: projectsId,
|
|
|
|
|
cascadeDelete: true,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{ name: "name", type: "text", required: true, max: 60, presentable: true },
|
|
|
|
|
{ name: "position", type: "number", required: true },
|
|
|
|
|
{ name: "is_inbox", type: "bool", required: false },
|
|
|
|
|
{ name: "is_done", type: "bool", required: false },
|
|
|
|
|
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
|
|
|
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
|
|
|
|
],
|
|
|
|
|
indexes: ["CREATE INDEX idx_columns_project ON columns (project, position)"],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 4. cards
|
|
|
|
|
const columnsId = (await pb.collections.getOne("columns")).id;
|
|
|
|
|
const usersId = (await pb.collections.getOne("users")).id;
|
|
|
|
|
await getOrCreateCollection(pb, {
|
|
|
|
|
name: "cards",
|
|
|
|
|
type: "base",
|
|
|
|
|
listRule: AUTHED,
|
|
|
|
|
viewRule: AUTHED,
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
fields: [
|
|
|
|
|
{
|
|
|
|
|
name: "project",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: true,
|
|
|
|
|
collectionId: projectsId,
|
|
|
|
|
cascadeDelete: true,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "column",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: true,
|
|
|
|
|
collectionId: columnsId,
|
|
|
|
|
cascadeDelete: false,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{ name: "title", type: "text", required: true, max: 200, presentable: true },
|
|
|
|
|
{ name: "description", type: "text", required: false, max: 50000 },
|
|
|
|
|
{ name: "position", type: "number", required: true },
|
|
|
|
|
{
|
|
|
|
|
name: "priority",
|
|
|
|
|
type: "select",
|
|
|
|
|
required: true,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
values: ["critical", "high", "medium", "low"],
|
|
|
|
|
},
|
|
|
|
|
{ name: "target_date", type: "date", required: false },
|
|
|
|
|
{
|
|
|
|
|
name: "created_by",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: false,
|
|
|
|
|
collectionId: usersId,
|
|
|
|
|
cascadeDelete: false,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "created_by_role",
|
|
|
|
|
type: "select",
|
|
|
|
|
required: true,
|
|
|
|
|
maxSelect: 1,
|
2026-09-20 23:54:11 +00:00
|
|
|
values: ["owner", "client", "anonymous"],
|
2026-09-16 07:41:20 +00:00
|
|
|
},
|
|
|
|
|
{ name: "accepted", type: "bool", required: false },
|
|
|
|
|
{ name: "start_date", type: "date", required: false },
|
|
|
|
|
{ name: "scope_rule", type: "text", required: false, max: 60 },
|
|
|
|
|
{ name: "archived", type: "bool", required: false },
|
|
|
|
|
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
|
|
|
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
|
|
|
|
],
|
|
|
|
|
indexes: [
|
|
|
|
|
"CREATE INDEX idx_cards_column ON cards (`column`, position)",
|
|
|
|
|
"CREATE INDEX idx_cards_project ON cards (project, archived)",
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 5. subtasks
|
|
|
|
|
const cardsId = (await pb.collections.getOne("cards")).id;
|
|
|
|
|
await getOrCreateCollection(pb, {
|
|
|
|
|
name: "subtasks",
|
|
|
|
|
type: "base",
|
|
|
|
|
listRule: AUTHED,
|
|
|
|
|
viewRule: AUTHED,
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
fields: [
|
|
|
|
|
{
|
|
|
|
|
name: "card",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: true,
|
|
|
|
|
collectionId: cardsId,
|
|
|
|
|
cascadeDelete: true,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{ name: "title", type: "text", required: true, max: 200, presentable: true },
|
|
|
|
|
{ name: "done", type: "bool", required: false },
|
|
|
|
|
{ name: "position", type: "number", required: true },
|
|
|
|
|
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
|
|
|
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
|
|
|
|
],
|
|
|
|
|
indexes: ["CREATE INDEX idx_subtasks_card ON subtasks (card, position)"],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 6. comments
|
|
|
|
|
await getOrCreateCollection(pb, {
|
|
|
|
|
name: "comments",
|
|
|
|
|
type: "base",
|
|
|
|
|
listRule: AUTHED,
|
|
|
|
|
viewRule: AUTHED,
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
fields: [
|
|
|
|
|
{
|
|
|
|
|
name: "card",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: true,
|
|
|
|
|
collectionId: cardsId,
|
|
|
|
|
cascadeDelete: true,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "author",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: true,
|
|
|
|
|
collectionId: usersId,
|
|
|
|
|
cascadeDelete: false,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{ name: "body", type: "text", required: true, max: 20000 },
|
|
|
|
|
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
|
|
|
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
|
|
|
|
],
|
|
|
|
|
indexes: ["CREATE INDEX idx_comments_card ON comments (card, created)"],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 7. activity
|
|
|
|
|
await getOrCreateCollection(pb, {
|
|
|
|
|
name: "activity",
|
|
|
|
|
type: "base",
|
|
|
|
|
listRule: AUTHED,
|
|
|
|
|
viewRule: AUTHED,
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
fields: [
|
|
|
|
|
{
|
|
|
|
|
name: "project",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: true,
|
|
|
|
|
collectionId: projectsId,
|
|
|
|
|
cascadeDelete: true,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "card",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: false,
|
|
|
|
|
collectionId: cardsId,
|
|
|
|
|
cascadeDelete: true,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "actor",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: true,
|
|
|
|
|
collectionId: usersId,
|
|
|
|
|
cascadeDelete: false,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "action",
|
|
|
|
|
type: "select",
|
|
|
|
|
required: true,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
values: [
|
|
|
|
|
"card_created",
|
|
|
|
|
"card_moved",
|
2026-09-19 16:35:33 +00:00
|
|
|
"column_changed",
|
2026-09-16 07:41:20 +00:00
|
|
|
"card_updated",
|
|
|
|
|
"priority_changed",
|
|
|
|
|
"date_changed",
|
2026-09-19 16:35:33 +00:00
|
|
|
"date_proposed",
|
|
|
|
|
"date_proposal_resolved",
|
2026-09-16 07:41:20 +00:00
|
|
|
"card_accepted",
|
|
|
|
|
"comment_added",
|
|
|
|
|
"subtask_toggled",
|
|
|
|
|
"card_archived",
|
2026-09-20 23:54:11 +00:00
|
|
|
"card_submitted_anonymously",
|
2026-09-16 07:41:20 +00:00
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{ name: "field", type: "text", required: false, max: 60 },
|
|
|
|
|
{ name: "old_value", type: "text", required: false, max: 2000 },
|
|
|
|
|
{ name: "new_value", type: "text", required: false, max: 2000 },
|
|
|
|
|
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
|
|
|
|
],
|
|
|
|
|
indexes: [
|
|
|
|
|
"CREATE INDEX idx_activity_card ON activity (card, created)",
|
|
|
|
|
"CREATE INDEX idx_activity_project ON activity (project, created)",
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 8. settings (superuser-only by design)
|
|
|
|
|
await getOrCreateCollection(pb, {
|
|
|
|
|
name: "settings",
|
|
|
|
|
type: "base",
|
|
|
|
|
listRule: null,
|
|
|
|
|
viewRule: null,
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
fields: [
|
|
|
|
|
{
|
|
|
|
|
name: "ai_provider",
|
|
|
|
|
type: "select",
|
|
|
|
|
required: false,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
values: ["anthropic", "openai", "minimax"],
|
|
|
|
|
},
|
|
|
|
|
{ name: "ai_api_key", type: "text", required: false, max: 400 },
|
|
|
|
|
{ name: "ai_model", type: "text", required: false, max: 120 },
|
|
|
|
|
{ name: "ai_system_prompt_extra", type: "text", required: false, max: 20000 },
|
|
|
|
|
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
|
|
|
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-20 23:54:11 +00:00
|
|
|
// 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",
|
2026-09-21 04:30:49 +00:00
|
|
|
required: false,
|
2026-09-20 23:54:11 +00:00
|
|
|
onlyInt: true,
|
|
|
|
|
min: 0,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "submission_count_today",
|
|
|
|
|
type: "number",
|
2026-09-21 04:30:49 +00:00
|
|
|
required: false,
|
2026-09-20 23:54:11 +00:00
|
|
|
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)",
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-16 07:41:20 +00:00
|
|
|
console.log("Applying API rules...");
|
|
|
|
|
|
|
|
|
|
await applyRules(pb, "clients", {
|
|
|
|
|
listRule: `${AUTHED} && (${OWNER} || id = @request.auth.client)`,
|
|
|
|
|
viewRule: `${AUTHED} && (${OWNER} || id = @request.auth.client)`,
|
|
|
|
|
createRule: OWNER,
|
|
|
|
|
updateRule: OWNER,
|
|
|
|
|
deleteRule: OWNER,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await applyRules(pb, "projects", {
|
|
|
|
|
listRule: scopedRead("client"),
|
|
|
|
|
viewRule: scopedRead("client"),
|
|
|
|
|
createRule: OWNER,
|
|
|
|
|
updateRule: OWNER,
|
|
|
|
|
deleteRule: OWNER,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await applyRules(pb, "columns", {
|
|
|
|
|
listRule: scopedRead("project.client"),
|
|
|
|
|
viewRule: scopedRead("project.client"),
|
|
|
|
|
createRule: OWNER,
|
|
|
|
|
updateRule: OWNER,
|
|
|
|
|
deleteRule: OWNER,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await applyRules(pb, "cards", {
|
|
|
|
|
listRule: scopedRead("project.client"),
|
|
|
|
|
viewRule: scopedRead("project.client"),
|
|
|
|
|
createRule: OWNER,
|
|
|
|
|
updateRule: OWNER,
|
|
|
|
|
deleteRule: OWNER,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await applyRules(pb, "subtasks", {
|
|
|
|
|
listRule: scopedRead("card.project.client"),
|
|
|
|
|
viewRule: scopedRead("card.project.client"),
|
|
|
|
|
createRule: OWNER,
|
|
|
|
|
updateRule: OWNER,
|
|
|
|
|
deleteRule: OWNER,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await applyRules(pb, "comments", {
|
|
|
|
|
listRule: scopedRead("card.project.client"),
|
|
|
|
|
viewRule: scopedRead("card.project.client"),
|
|
|
|
|
createRule: OWNER,
|
|
|
|
|
updateRule: OWNER,
|
|
|
|
|
deleteRule: OWNER,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await applyRules(pb, "activity", {
|
|
|
|
|
listRule: scopedRead("project.client"),
|
|
|
|
|
viewRule: scopedRead("project.client"),
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-20 23:54:11 +00:00
|
|
|
await applyRules(pb, "intake_links", {
|
|
|
|
|
listRule: OWNER,
|
|
|
|
|
viewRule: OWNER,
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: OWNER,
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-16 09:46:57 +00:00
|
|
|
// 9. commits — ingest target. Created via webhook/poll/backfill only; never
|
|
|
|
|
// from a browser.
|
|
|
|
|
await getOrCreateCollection(pb, {
|
|
|
|
|
name: "commits",
|
|
|
|
|
type: "base",
|
|
|
|
|
listRule: AUTHED,
|
|
|
|
|
viewRule: AUTHED,
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
fields: [
|
|
|
|
|
{
|
|
|
|
|
name: "project",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: true,
|
|
|
|
|
collectionId: (await pb.collections.getOne("projects")).id,
|
|
|
|
|
cascadeDelete: true,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{ name: "sha", type: "text", required: true, max: 64 },
|
|
|
|
|
{ name: "short_sha", type: "text", required: false, max: 12 },
|
|
|
|
|
{ name: "message", type: "text", required: false, max: 50000 },
|
|
|
|
|
{
|
|
|
|
|
name: "subject",
|
|
|
|
|
type: "text",
|
|
|
|
|
required: false,
|
|
|
|
|
max: 300,
|
|
|
|
|
presentable: true,
|
|
|
|
|
},
|
|
|
|
|
{ name: "type", type: "text", required: false, max: 30 },
|
|
|
|
|
{ name: "scope", type: "text", required: false, max: 60 },
|
|
|
|
|
{ name: "author_name", type: "text", required: false, max: 200 },
|
|
|
|
|
{ name: "author_email", type: "text", required: false, max: 200 },
|
|
|
|
|
{ name: "authored_at", type: "date", required: true },
|
|
|
|
|
{ name: "is_merge", type: "bool", required: false },
|
|
|
|
|
{ name: "additions", type: "number", required: false },
|
|
|
|
|
{ name: "deletions", type: "number", required: false },
|
|
|
|
|
{
|
|
|
|
|
name: "card",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: false,
|
|
|
|
|
collectionId: (await pb.collections.getOne("cards")).id,
|
|
|
|
|
cascadeDelete: false,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "link_source",
|
|
|
|
|
type: "select",
|
|
|
|
|
required: false,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
values: ["explicit", "scope", "manual", "none"],
|
|
|
|
|
},
|
|
|
|
|
{ name: "url", type: "url", required: false },
|
|
|
|
|
{
|
|
|
|
|
name: "created",
|
|
|
|
|
type: "autodate",
|
|
|
|
|
onCreate: true,
|
|
|
|
|
onUpdate: false,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "updated",
|
|
|
|
|
type: "autodate",
|
|
|
|
|
onCreate: true,
|
|
|
|
|
onUpdate: true,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
indexes: [
|
|
|
|
|
"CREATE UNIQUE INDEX idx_commits_project_sha ON commits (project, sha)",
|
|
|
|
|
"CREATE INDEX idx_commits_project_date ON commits (project, authored_at)",
|
|
|
|
|
"CREATE INDEX idx_commits_card ON commits (card)",
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-16 09:18:13 +00:00
|
|
|
/**
|
|
|
|
|
* The users collection. Do not leave this on PocketBase's defaults.
|
|
|
|
|
*
|
|
|
|
|
* Out of the box `users` has createRule "" — anyone may sign up — and
|
|
|
|
|
* updateRule "id = @request.auth.id" — anyone may edit their own record.
|
|
|
|
|
* Because `role` is an ordinary field here, each of those is a privilege
|
|
|
|
|
* escalation on its own:
|
|
|
|
|
*
|
|
|
|
|
* - an anonymous POST could create an account with role "owner"
|
|
|
|
|
* - a client manager could promote themselves by patching their own record
|
|
|
|
|
*
|
|
|
|
|
* Both were live and both were verified exploitable before this was added.
|
|
|
|
|
*
|
|
|
|
|
* Accounts are created and edited only by the owner, through server-side
|
|
|
|
|
* code running as a superuser, so all three write rules are superuser-only.
|
|
|
|
|
* Any future self-service profile editing must go through a route handler
|
|
|
|
|
* with an explicit field whitelist — the same reason /api/client/* exists.
|
|
|
|
|
*/
|
|
|
|
|
await applyRules(pb, "users", {
|
|
|
|
|
listRule: OWNER_OR_SELF,
|
|
|
|
|
viewRule: OWNER_OR_SELF,
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-16 09:46:57 +00:00
|
|
|
await applyRules(pb, "commits", {
|
|
|
|
|
listRule: scopedRead("project.client"),
|
|
|
|
|
viewRule: scopedRead("project.client"),
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-16 11:51:03 +00:00
|
|
|
// 10. project_metrics — a computed cache, rebuilt on ingest. The dashboard
|
|
|
|
|
// reads this rather than aggregating hundreds of commit rows per page view.
|
|
|
|
|
await getOrCreateCollection(pb, {
|
|
|
|
|
name: "project_metrics",
|
|
|
|
|
type: "base",
|
|
|
|
|
listRule: AUTHED,
|
|
|
|
|
viewRule: AUTHED,
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
fields: [
|
|
|
|
|
{
|
|
|
|
|
name: "project",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: true,
|
|
|
|
|
collectionId: (await pb.collections.getOne("projects")).id,
|
|
|
|
|
cascadeDelete: true,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{ name: "computed_at", type: "date", required: true },
|
|
|
|
|
{ name: "payload", type: "json", required: true, maxSize: 4_000_000 },
|
|
|
|
|
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
|
|
|
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
|
|
|
|
],
|
|
|
|
|
indexes: [
|
|
|
|
|
"CREATE UNIQUE INDEX idx_metrics_project ON project_metrics (project)",
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await applyRules(pb, "project_metrics", {
|
|
|
|
|
listRule: scopedRead("project.client"),
|
|
|
|
|
viewRule: scopedRead("project.client"),
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-16 09:46:57 +00:00
|
|
|
// Add ingest-related fields to projects (only if missing — the projects
|
|
|
|
|
// collection already exists from earlier migrations).
|
2026-09-16 11:20:24 +00:00
|
|
|
await ensureProjectIngestFields(pb);
|
|
|
|
|
|
2026-09-17 08:42:46 +00:00
|
|
|
// Add the project/prototype kind field for Phase 6.
|
|
|
|
|
await ensureProjectKindField(pb);
|
|
|
|
|
|
2026-09-16 14:15:18 +00:00
|
|
|
// Add the per-commit summary fields used by Phase 3b's AI one-liner job.
|
|
|
|
|
await ensureCommitSummaryFields(pb);
|
|
|
|
|
|
2026-09-19 08:14:51 +00:00
|
|
|
// Add the source/manual_minutes fields for Phase 7's logged work entries.
|
2026-09-19 08:22:26 +00:00
|
|
|
await ensureCommitSourceFields(pb);
|
2026-09-19 08:14:51 +00:00
|
|
|
|
2026-09-16 11:20:24 +00:00
|
|
|
// Add the card number field and backfill existing cards.
|
|
|
|
|
await ensureCardNumbers(pb);
|
2026-09-16 09:46:57 +00:00
|
|
|
|
2026-09-16 14:24:18 +00:00
|
|
|
// Add the AI-generated card narrative fields used by Phase 3c.
|
|
|
|
|
await ensureCardNarrativeFields(pb);
|
|
|
|
|
|
2026-09-16 16:04:22 +00:00
|
|
|
// Add the intake wizard's fields (brief, transcript, size_band) for Phase 4b.
|
|
|
|
|
await ensureCardIntakeFields(pb);
|
|
|
|
|
|
2026-09-19 16:35:33 +00:00
|
|
|
// Add the Plan Puzzle v1 proposal fields and the partial index.
|
|
|
|
|
await ensureCardProposalFields(pb);
|
|
|
|
|
|
|
|
|
|
// Extend activity.action with the three new puzzle actions.
|
|
|
|
|
await ensureActivityActionValues(pb);
|
|
|
|
|
|
2026-09-20 23:54:11 +00:00
|
|
|
// Extend cards.created_by_role to include "anonymous" for the public route.
|
|
|
|
|
await ensureCardsCreatedByRoleValues(pb);
|
|
|
|
|
|
2026-09-21 04:30:49 +00:00
|
|
|
// PocketBase rejects inserts to intake_links unless the counter fields are
|
|
|
|
|
// optional — `required: true` + `min: 0` is contradictory there.
|
|
|
|
|
await ensureIntakeLinkCountersOptional(pb);
|
|
|
|
|
|
|
|
|
|
// The public route writes activity rows with no actor (the submitter is
|
|
|
|
|
// anonymous); the actor field must therefore be optional.
|
|
|
|
|
await ensureActivityActorOptional(pb);
|
|
|
|
|
|
|
|
|
|
// Same PocketBase quirk as submission_count: `required: true` on a bool
|
|
|
|
|
// field rejects `false` as blank. The owner can mint an already-revoked
|
|
|
|
|
// link, so the field has to be optional.
|
|
|
|
|
await ensureIntakeLinkActiveOptional(pb);
|
|
|
|
|
|
2026-09-16 07:41:20 +00:00
|
|
|
console.log("Done.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
main().catch((error) => {
|
|
|
|
|
console.error(error);
|
|
|
|
|
process.exit(1);
|
|
|
|
|
});
|