/** * One-shot PocketBase schema setup for RJ Bulk rebuild. * * Run with: pnpm tsx scripts/setup-pb.mjs * * Notes about the deployed PB instance: * - It is a pre-v0.22 build that does not support first-class `relation` or * `select` field types. We work around it by: * - Using `text` fields for foreign keys (joined via expand= at query * time — see lib/pocketbase.ts for helpers). * - Using `text` fields for enums (status, action) and validating the * allowed values at the app layer with Zod schemas. * * Idempotent: re-running is safe; existing schema is updated, not duplicated. */ import PocketBase from "pocketbase"; const PB_URL = process.env.POCKETBASE_URL || "http://pocketbase-zinlbpsext2ro0lgb2558vfy.169.239.180.130.sslip.io"; const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL || "David.els1@outlook.com"; const ADMIN_PASS = process.env.PB_ADMIN_PASS || "1234567890"; const APP_USER_EMAIL = process.env.APP_USER_EMAIL || "david@rjbulk.local"; const APP_USER_PASS = process.env.APP_USER_PASS || "ChangeMe123!"; const APP_USER_NAME = process.env.APP_USER_NAME || "David Els"; const pb = new PocketBase(PB_URL); function log(msg) { console.log(`[setup-pb] ${msg}`); } async function authAsAdmin() { log(`Authenticating as superuser ${ADMIN_EMAIL}...`); await pb.admins.authWithPassword(ADMIN_EMAIL, ADMIN_PASS); log(`Authenticated.`); } async function getCollection(name) { return await pb.collections.getOne(name).catch(() => null); } async function deleteCollection(name) { try { await pb.collections.delete(name); log(`Deleted collection: ${name}`); } catch (err) { if (err.status === 404) return; throw err; } } async function createCollection(payload) { return await pb.collections.create(payload); } async function updateCollection(id, payload) { return await pb.collections.update(id, payload); } function text(name, required = false, max = 200) { return { name, type: "text", required, system: false, options: { min: required ? 1 : 0, max, pattern: "" }, }; } function number(name, required = false, noDecimal = false) { return { name, type: "number", required, system: false, options: { min: null, max: null, noDecimal }, }; } function date(name, required = true) { return { name, type: "date", required, system: false, options: { min: "", max: "" }, }; } function email(name, required = false) { return { name, type: "email", required, system: false, options: { min: 0, max: 200, exceptDomains: null, onlyDomains: null }, }; } function bool(name, required = false) { return { name, type: "bool", required, system: false, options: {}, }; } function id() { return { name: "id", type: "text", required: true, system: true, options: { autogeneratePattern: "[a-z0-9]{15}", min: 15, max: 15, pattern: "^[a-z0-9]+$" }, }; } const AUTH_RULES = { listRule: '@request.auth.id != ""', viewRule: '@request.auth.id != ""', createRule: '@request.auth.id != ""', updateRule: '@request.auth.id != ""', deleteRule: '@request.auth.id != ""', }; async function ensureCollection(name, fields, indexes = []) { const existing = await getCollection(name); // First pass: schema only, no indexes (avoids "no such column" race) if (existing) { log(`Updating ${name} (id=${existing.id}) — schema`); await updateCollection(existing.id, { ...AUTH_RULES, name, type: "base", fields, indexes: [] }); } else { log(`Creating ${name} collection`); await createCollection({ ...AUTH_RULES, name, type: "base", fields, indexes: [], system: false }); } // Second pass: add indexes (columns now exist) if (indexes.length > 0) { const cur = await getCollection(name); await updateCollection(cur.id, { indexes }); log(` ${name}: added ${indexes.length} index(es)`); } } async function buildCustomers() { await ensureCollection("customers", [ id(), text("name", true, 200), text("contact_number", false, 50), email("email", false), text("address", false, 1000), text("vat_number", false, 50), ], [ "CREATE INDEX idx_customers_name ON customers (name)", ]); } async function buildSuppliers() { await ensureCollection("suppliers", [ id(), text("name", true, 200), text("contact_number", false, 50), email("email", false), text("address", false, 1000), text("vat_number", false, 50), ], [ "CREATE INDEX idx_suppliers_name ON suppliers (name)", ]); } async function buildDeliveryNotes() { // status is validated as enum at app layer (see lib/delivery-status.ts) await ensureCollection("delivery_notes", [ id(), text("number", true, 100), // DN-... date("date", true), text("customer", true, 30), // FK -> customers.id (joined via expand=customer) text("vehicle_registration", false, 100), number("qty_litres_supplied", true, false), // decimal allowed text("plant_dispensing", false, 100), text("status", false, 50), // enum: Pending | EFT | CASH | CARD text("old_status", false, 50), // legacy value preserved (Statement Viewed, Invoiced, etc.) number("price_per_litre", false, false), number("total_value", false, false), text("pricing_source", false, 50), // enum: special | monthly | default ], [ "CREATE INDEX idx_dn_customer_date ON delivery_notes (customer, date)", "CREATE INDEX idx_dn_status_date ON delivery_notes (status, date)", "CREATE UNIQUE INDEX idx_dn_number ON delivery_notes (number)", ]); } async function buildFuelPurchases() { await ensureCollection("fuel_purchases", [ id(), text("supplier", true, 30), // FK -> suppliers.id number("purchase_qty", true, false), number("purchase_price", true, false), date("purchase_date", true), ], [ "CREATE INDEX idx_fp_supplier ON fuel_purchases (supplier)", "CREATE INDEX idx_fp_date ON fuel_purchases (purchase_date)", ]); } async function buildCustomerPricing() { await ensureCollection("customer_pricing", [ id(), text("customer", true, 30), // FK -> customers.id number("year", true, true), number("month", true, true), // 1-12 number("price_per_litre", true, false), number("special_price", false, false), text("created_by", false, 30), // FK -> users.id ], [ "CREATE UNIQUE INDEX idx_cp_unique ON customer_pricing (customer, year, month)", ]); } async function buildMonthlyPriceIncreases() { await ensureCollection("monthly_price_increases", [ id(), number("year", true, true), number("month", true, true), // 1-12 number("percentage_increase", true, false), // actually cents text("applied_by", false, 30), // FK -> users.id ], [ "CREATE UNIQUE INDEX idx_mpi_unique ON monthly_price_increases (year, month)", ]); } async function buildFuelSettings() { await ensureCollection("fuel_settings", [ id(), number("default_price_per_litre", true, false), email("statement_cc_email", false), email("statement_from_email", false), text("statement_email_password", false, 500), // plain text — production should encrypt email("statement_app_email", false), ], []); // Seed default price singleton if not already seeded const list = await pb.collection("fuel_settings").getList(1, 1); if (list.items.length === 0) { log("Seeding fuel_settings singleton with default price 20.50"); await pb.collection("fuel_settings").create({ default_price_per_litre: 20.5 }); } } async function buildStatementLog() { await ensureCollection("statement_log", [ id(), text("action", true, 50), // enum: Email Sent | Preview Generated text("details", false, 500), text("actor", false, 30), // FK -> users.id ], []); } async function ensureAppUser() { log(`Ensuring app user ${APP_USER_EMAIL} exists...`); try { const existing = await pb.collection("users").getFirstListItem(`email = "${APP_USER_EMAIL}"`); log(`App user already exists (id=${existing.id}).`); } catch (err) { if (err.status === 404) { const created = await pb.collection("users").create({ email: APP_USER_EMAIL, password: APP_USER_PASS, passwordConfirm: APP_USER_PASS, name: APP_USER_NAME, emailVisibility: true, verified: true, }); log(`✅ Created app user (id=${created.id}). Login: ${APP_USER_EMAIL} / ${APP_USER_PASS}`); } else { throw err; } } } async function ensureUsersCollection() { // 'users' is a built-in auth collection. Patch its rules so any authed user // can read the directory (needed for `expand=` to resolve FKs). const cur = await getCollection("users"); if (cur) { await updateCollection(cur.id, { listRule: '@request.auth.id != ""', viewRule: '@request.auth.id != ""', // createRule null = admin only (invite flow) // updateRule / deleteRule stay as self-only (PB default) }); log("Users collection rules updated"); } } async function main() { await authAsAdmin(); // Drop leftover junk await deleteCollection("test_fields_key"); await deleteCollection("test_h1"); await deleteCollection("test_h2"); await deleteCollection("test_h3"); await deleteCollection("test_h4"); await deleteCollection("test_sel"); // Build all collections await ensureUsersCollection(); await buildCustomers(); await buildSuppliers(); await buildDeliveryNotes(); await buildFuelPurchases(); await buildCustomerPricing(); await buildMonthlyPriceIncreases(); await buildFuelSettings(); await buildStatementLog(); // App user await ensureAppUser(); log("✅ Schema setup complete."); log(" App user: " + APP_USER_EMAIL + " / " + APP_USER_PASS); } main().catch((err) => { console.error("[setup-pb] FAILED:", err); process.exit(1); });