kanban-dev-webapp/scripts/setup-remote.ts

918 lines
No EOL
28 KiB
TypeScript

/**
* 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;
}
if (!have.has("active")) {
fields.push({
name: "active",
type: "bool",
required: false,
presentable: false,
hidden: false,
system: false,
});
changed = true;
}
if (changed) {
await pb.collections.update(users.id, { fields });
console.log(" + users fields: role, client, active");
} else {
console.log(" = users fields already present");
}
// 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 });
}
}
}
const OWNER = '@request.auth.role = "owner"';
const AUTHED = '@request.auth.id != ""';
const scopedRead = (pathToClient: string) =>
`${AUTHED} && (${OWNER} || ${pathToClient} = @request.auth.client)`;
/** For the users collection: the owner sees everyone, everyone else sees only themselves. */
const OWNER_OR_SELF = `${AUTHED} && (${OWNER} || id = @request.auth.id)`;
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}`);
}
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 },
{
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,
},
];
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");
}
}
/**
* 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");
}
}
/**
* 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.
*/
/**
* 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");
}
}
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");
}
}
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");
}
}
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,
values: ["owner", "client"],
},
{ 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",
"card_updated",
"priority_changed",
"date_changed",
"card_accepted",
"comment_added",
"subtask_toggled",
"card_archived",
],
},
{ 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 },
],
});
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,
});
// 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)",
],
});
/**
* 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,
});
await applyRules(pb, "commits", {
listRule: scopedRead("project.client"),
viewRule: scopedRead("project.client"),
createRule: null,
updateRule: null,
deleteRule: null,
});
// 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,
});
// Add ingest-related fields to projects (only if missing — the projects
// collection already exists from earlier migrations).
await ensureProjectIngestFields(pb);
// Add the per-commit summary fields used by Phase 3b's AI one-liner job.
await ensureCommitSummaryFields(pb);
// Add the card number field and backfill existing cards.
await ensureCardNumbers(pb);
// Add the AI-generated card narrative fields used by Phase 3c.
await ensureCardNarrativeFields(pb);
// Add the intake wizard's fields (brief, transcript, size_band) for Phase 4b.
await ensureCardIntakeFields(pb);
console.log("Done.");
}
main().catch((error) => {
console.error(error);
process.exit(1);
});