/** * 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 { 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 ): Promise { 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 { 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 (changed) { await pb.collections.update(users.id, { fields }); console.log(" + users fields: role, client"); } else { console.log(" = users fields already present"); } } const OWNER = '@request.auth.role = "owner"'; const AUTHED = '@request.auth.id != ""'; const scopedRead = (pathToClient: string) => `${AUTHED} && (${OWNER} || ${pathToClient} = @request.auth.client)`; async function applyRules( pb: PocketBase, name: string, rules: { listRule: string | null; viewRule: string | null; createRule: string | null; updateRule: string | null; deleteRule: string | null; } ): Promise { const c = await pb.collections.getOne(name); await pb.collections.update(c.id, rules); console.log(` = rules for ${name}`); } async function main(): Promise { 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, }); console.log("Done."); } main().catch((error) => { console.error(error); process.exit(1); });