RJBULK-APP/scripts/setup-pb.mjs

331 lines
9.8 KiB
JavaScript
Raw Permalink Normal View History

/**
* 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);
});