DieselDepot-APP/scripts/create-collections.mjs
SJCoetz d01f779d9e feat: add login and 404 pages, update build files
- Update app path manifests to include new authentication and not-found routes
- Reorder vendor chunk dependencies for existing and new pages
- Add missing Lucide React icons to server vendor chunks
- Refresh webpack runtime hashes and build outputs
2026-07-20 12:19:27 +02:00

223 lines
12 KiB
JavaScript

// One-shot script to create the Diesel Depot collections in PocketBase.
// Run with: node scripts/create-collections.mjs
// Requires env: PB_URL, PB_ADMIN_TOKEN (a superuser auth token).
//
// IMPORTANT (from AGENTS.md-style memory):
// - Field options go at the top level of each field object (flat shape), NOT nested under `options`.
// - Auth collections need explicit `created` + `updated` autodate fields added manually.
// - The PocketBase REST API takes the field array under the `fields` key, not `schema`.
// (The JS SDK accepts both, but the raw API only honours `fields`. Found the hard way
// on 2026-07-19 — `schema` is silently ignored.)
// - Relation fields need the relation's actual collectionId (the PB UUID), not its name.
// We do a two-pass: first create collections without relations, then patch in relations.
import PocketBase from 'pocketbase';
const PB_URL = process.env.PB_URL || 'http://pocketbase-zinlbpsext2ro0lgb2558vfy.169.239.180.130.sslip.io';
const PB_ADMIN_TOKEN = process.env.PB_ADMIN_TOKEN;
if (!PB_ADMIN_TOKEN) {
console.error('❌ PB_ADMIN_TOKEN env var required (a superuser auth token).');
process.exit(1);
}
const pb = new PocketBase(PB_URL);
pb.authStore.save(PB_ADMIN_TOKEN, null);
const depsOnAllAuthUsers = '@request.auth.id != ""';
// ─── Pass 1: collections with no relations ───────────────────────────────────
const BASE_SCHEMAS = {
dd_depots: [
{ name: 'name', type: 'text', required: true, max: 100, pattern: '' },
{ name: 'code', type: 'text', required: true, max: 20, pattern: '^[A-Za-z0-9_-]+$' },
{ name: 'capacity_litres', type: 'number', required: true, min: 0, onlyInt: false },
{ name: 'current_balance_litres', type: 'number', required: false, min: 0, onlyInt: false },
{ name: 'address', type: 'text', required: false, max: 500, pattern: '' },
{ name: 'latitude', type: 'number', required: false, onlyInt: false },
{ name: 'longitude', type: 'number', required: false, onlyInt: false },
{ name: 'is_active', type: 'bool', required: false },
{ name: 'created', type: 'autodate', required: true, onCreate: true, onUpdate: false },
{ name: 'updated', type: 'autodate', required: true, onCreate: true, onUpdate: true },
],
dd_trucks: [
{ name: 'registration', type: 'text', required: true, max: 30, pattern: '' },
{ name: 'description', type: 'text', required: false, max: 200, pattern: '' },
{ name: 'capacity_litres', type: 'number', required: true, min: 0, onlyInt: false },
{ name: 'current_balance_litres', type: 'number', required: false, min: 0, onlyInt: false },
{ name: 'current_km', type: 'number', required: false, min: 0, onlyInt: true },
{ name: 'is_active', type: 'bool', required: false },
{ name: 'created', type: 'autodate', required: true, onCreate: true, onUpdate: false },
{ name: 'updated', type: 'autodate', required: true, onCreate: true, onUpdate: true },
],
dd_clients: [
{ name: 'name', type: 'text', required: true, max: 200, pattern: '' },
{ name: 'code', type: 'text', required: false, max: 30, pattern: '^[A-Za-z0-9_-]*$' },
{ name: 'contact_person', type: 'text', required: false, max: 200, pattern: '' },
{ name: 'phone', type: 'text', required: false, max: 50, pattern: '' },
{ name: 'email', type: 'email', required: false, exceptDomains: null, onlyDomains: null },
{ name: 'address', type: 'text', required: false, max: 500, pattern: '' },
{ name: 'default_lat', type: 'number', required: false, onlyInt: false },
{ name: 'default_lng', type: 'number', required: false, onlyInt: false },
{ name: 'is_active', type: 'bool', required: false },
{ name: 'created', type: 'autodate', required: true, onCreate: true, onUpdate: false },
{ name: 'updated', type: 'autodate', required: true, onCreate: true, onUpdate: true },
],
dd_users: [
{ name: 'name', type: 'text', required: true, max: 200, pattern: '' },
{ name: 'role', type: 'select', required: true, maxSelect: 1, values: ['driver', 'pump_attendant', 'admin', 'owner'] },
{ name: 'phone', type: 'text', required: false, max: 50, pattern: '' },
{ name: 'is_active', type: 'bool', required: false },
{ name: 'created', type: 'autodate', required: true, onCreate: true, onUpdate: false },
{ name: 'updated', type: 'autodate', required: true, onCreate: true, onUpdate: true },
],
dd_delivery_notes: [
{ name: 'note_number', type: 'text', required: true, max: 30, pattern: '^[A-Z0-9]+$' },
{ name: 'delivery_date', type: 'date', required: true, min: '', max: '' },
{ name: 'opening_meter_reading', type: 'number', required: true, min: 0, onlyInt: false },
{ name: 'closing_meter_reading', type: 'number', required: true, min: 0, onlyInt: false },
{ name: 'qty_litres', type: 'number', required: true, min: 0, onlyInt: false },
{ name: 'meter_photo', type: 'file', required: false, maxSelect: 1, maxSize: 10_485_760, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] },
{ name: 'client_signature', type: 'file', required: false, maxSelect: 1, maxSize: 5_242_880, mimeTypes: ['image/png', 'image/jpeg', 'image/webp'] },
{ name: 'notes', type: 'text', required: false, max: 1000, pattern: '' },
{ name: 'is_internal_transfer', type: 'bool', required: false },
{ name: 'meter_continuity_status', type: 'select', required: true, maxSelect: 1, values: ['green', 'yellow', 'red'] },
{ name: 'status', type: 'select', required: true, maxSelect: 1, values: ['draft', 'completed', 'voided'] },
{ name: 'captured_lat', type: 'number', required: false, onlyInt: false },
{ name: 'captured_lng', type: 'number', required: false, onlyInt: false },
{ name: 'captured_at', type: 'date', required: true, min: '', max: '' },
{ name: 'created', type: 'autodate', required: true, onCreate: true, onUpdate: false },
{ name: 'updated', type: 'autodate', required: true, onCreate: true, onUpdate: true },
],
dd_fuel_purchases: [
{ name: 'purchase_number', type: 'text', required: true, max: 30, pattern: '^[A-Z0-9]+$' },
{ name: 'purchase_date', type: 'date', required: true, min: '', max: '' },
{ name: 'supplier_name', type: 'text', required: true, max: 200, pattern: '' },
{ name: 'quantity_litres', type: 'number', required: true, min: 0, onlyInt: false },
{ name: 'price_per_litre', type: 'number', required: false, min: 0, onlyInt: false },
{ name: 'total_amount', type: 'number', required: false, min: 0, onlyInt: false },
{ name: 'reference', type: 'text', required: false, max: 200, pattern: '' },
{ name: 'notes', type: 'text', required: false, max: 1000, pattern: '' },
{ name: 'receipt', type: 'file', required: false, maxSelect: 1, maxSize: 10_485_760, mimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'] },
{ name: 'created', type: 'autodate', required: true, onCreate: true, onUpdate: false },
{ name: 'updated', type: 'autodate', required: true, onCreate: true, onUpdate: true },
],
};
// ─── Pass 2: relations to add after base schemas exist ───────────────────────
// (uses the live collection IDs)
const RELATION_PATCHES = {
dd_trucks: [
{ name: 'home_depot', type: 'relation', required: false, collectionId: 'PENDING_DD_DEPOTS', maxSelect: 1, cascadeDelete: false },
],
dd_users: [
{ name: 'assigned_depot', type: 'relation', required: false, collectionId: 'PENDING_DD_DEPOTS', maxSelect: 1, cascadeDelete: false },
{ name: 'assigned_truck', type: 'relation', required: false, collectionId: 'PENDING_DD_TRUCKS', maxSelect: 1, cascadeDelete: false },
],
dd_delivery_notes: [
{ name: 'truck', type: 'relation', required: true, collectionId: 'PENDING_DD_TRUCKS', maxSelect: 1, cascadeDelete: false },
{ name: 'depot', type: 'relation', required: true, collectionId: 'PENDING_DD_DEPOTS', maxSelect: 1, cascadeDelete: false },
{ name: 'client', type: 'relation', required: false, collectionId: 'PENDING_DD_CLIENTS', maxSelect: 1, cascadeDelete: false },
{ name: 'driver', type: 'relation', required: true, collectionId: 'PENDING_DD_USERS', maxSelect: 1, cascadeDelete: false },
],
dd_fuel_purchases: [
{ name: 'depot', type: 'relation', required: true, collectionId: 'PENDING_DD_DEPOTS', maxSelect: 1, cascadeDelete: false },
{ name: 'purchased_by', type: 'relation', required: false, collectionId: 'PENDING_DD_USERS', maxSelect: 1, cascadeDelete: false },
],
};
// ─── Pass 1: create / update base schemas ────────────────────────────────────
const existing = await pb.collections.getFullList();
const byName = new Map(existing.map((c) => [c.name, c]));
const idByName = new Map(existing.map((c) => [c.name, c.id]));
for (const [name, fields] of Object.entries(BASE_SCHEMAS)) {
const ex = byName.get(name);
if (ex) {
console.log(`✓ ${name} already exists (${ex.id}) — ensuring base fields present`);
const existingByName = new Map((ex.fields || []).map((f) => [f.name, f]));
const nextFields = fields.map((want) => {
const have = existingByName.get(want.name);
if (have) {
const merged = { ...have };
for (const k of Object.keys(want)) {
if (k === 'id') continue;
const w = want[k];
const h = merged[k];
if (Array.isArray(w) && (!Array.isArray(h) || h.length === 0)) merged[k] = w;
else if (typeof w === 'number' && w > 0 && (h === null || h === 0)) merged[k] = w;
else if (typeof w === 'boolean' && h === undefined) merged[k] = w;
}
return merged;
}
return want;
});
for (const want of fields) {
if (!existingByName.has(want.name)) nextFields.push(want);
}
await pb.collections.update(ex.id, { fields: nextFields, ...allRules() });
idByName.set(name, ex.id);
continue;
}
console.log(`+ Creating ${name}`);
const created = await pb.collections.create({
name,
type: name === 'dd_users' ? 'auth' : 'base',
fields,
...allRules(),
});
idByName.set(name, created.id);
}
function allRules() {
return {
listRule: depsOnAllAuthUsers,
viewRule: depsOnAllAuthUsers,
createRule: depsOnAllAuthUsers,
updateRule: depsOnAllAuthUsers,
deleteRule: depsOnAllAuthUsers,
};
}
// ─── Pass 2: add relation fields using live IDs ──────────────────────────────
// Re-fetch in case pass 1 created new collections
const pass2Existing = await pb.collections.getFullList();
for (const [name, patches] of Object.entries(RELATION_PATCHES)) {
const ex = pass2Existing.find((c) => c.name === name);
if (!ex) continue; // shouldn't happen — pass 1 created it
const existingByName = new Map((ex.fields || []).map((f) => [f.name, f]));
let dirty = false;
const nextFields = (ex.fields || []).slice();
for (const want of patches) {
if (existingByName.has(want.name)) continue; // already present
const resolved = { ...want };
// Resolve the placeholder to the real collectionId
const placeholder = resolved.collectionId;
if (typeof placeholder === 'string' && placeholder.startsWith('PENDING_')) {
const targetName = placeholder.replace(/^PENDING_/, '').toLowerCase();
const realId = idByName.get(targetName);
if (!realId) {
console.warn(` ! could not resolve ${placeholder} for ${name}.${want.name}`);
continue;
}
resolved.collectionId = realId;
}
nextFields.push(resolved);
dirty = true;
console.log(` → adding ${name}.${want.name} → ${resolved.collectionId}`);
}
if (dirty) await pb.collections.update(ex.id, { fields: nextFields });
}
// ─── Done ────────────────────────────────────────────────────────────────────
console.log('\n✅ All Diesel Depot collections ready.\n');
const final = await pb.collections.getFullList({ filter: 'name ~ "dd_"' });
for (const c of final) {
const fieldCount = (c.fields || []).length;
console.log(` ${c.id} ${c.name} (${c.type}, ${fieldCount} fields)`);
}