331 lines
9.8 KiB
JavaScript
331 lines
9.8 KiB
JavaScript
|
|
/**
|
||
|
|
* 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);
|
||
|
|
});
|