initial commit

This commit is contained in:
ray
2026-07-12 10:01:39 -04:00
commit fc8290d668
185 changed files with 38831 additions and 0 deletions
@@ -0,0 +1,65 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M1 — Add dedicated `customerType` column to `repairOrders`.
//
// Previously `customerType` lived only inside the stringified `financial` JSON
// blob, which broke whenever the blob was corrupt or missing. This migration
// promotes it to a real text column and backfills values from the blob.
//
// Run: ./pocketbase migrate (from your PocketBase install)
//
migrate(
(db) => {
const col = db.findCollectionByNameOrId('repairOrders');
if (!col) {
console.warn('[M1] repairOrders collection not found — skipping');
return;
}
// Add the field if it doesn't already exist.
if (!col.fields.find((f) => f.name === 'customerType')) {
const field = new TextField({
name: 'customerType',
required: false,
// Constrain to the two valid values; PB validates via pattern.
// Using a generic text field with pattern keeps it permissive during
// backfill (allow any value), then we recommend tightening later.
});
col.fields.push(field);
db.save(col);
console.log('[M1] added customerType field');
}
// Backfill from the existing `financial` JSON blob.
const records = Array.from(db.findRecordsByFilter(
"repairOrders",
"1=1",
"id",
500
));
for (const rec of records) {
let fin = {};
try {
const raw = rec.get('financial');
if (typeof raw === 'string' && raw.trim()) {
fin = JSON.parse(raw);
} else if (raw && typeof raw === 'object') {
fin = raw;
}
} catch { /* ignore corrupt blobs */ }
const ct = (fin && fin.customerType === 'waiter') ? 'waiter' : 'drop-off';
rec.set('customerType', ct);
db.saveRecord(rec);
}
console.log(`[M1] backfilled ${records.length} records`);
},
(db) => {
const col = db.findCollectionByNameOrId('repairOrders');
if (!col) return;
const idx = col.fields.findIndex((f) => f.name === 'customerType');
if (idx >= 0) {
col.fields.splice(idx, 1);
db.save(col);
}
}
);
@@ -0,0 +1,61 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M2 — Add integer-minutes `estimatedDuration` column to `repairOrders`.
//
// Today the duration is stored as `estimatedTime` (a HOURS STRING such as
// "0.3"), converted back to minutes via Math.round(hours*60) on read. That
// round-trip is lossy: 15 min → "0.3" → 18 min, drifting the due time every
// reload. This migration adds a numeric `estimatedDuration` (integer minutes)
// column and backfills it from `estimatedTime`.
//
// The frontend still writes BOTH columns after this migration (for backward
// compat with older clients), but reads prefer `estimatedDuration` when
// present. A follow-up cleanup can drop the `estimatedTime` writes once all
// clients are upgraded.
//
migrate(
(db) => {
const col = db.findCollectionByNameOrId('repairOrders');
if (!col) {
console.warn('[M2] repairOrders collection not found — skipping');
return;
}
if (!col.fields.find((f) => f.name === 'estimatedDuration')) {
const field = new NumberField({
name: 'estimatedDuration',
required: false,
min: 0,
});
col.fields.push(field);
db.save(col);
console.log('[M2] added estimatedDuration field');
}
const records = Array.from(db.findRecordsByFilter(
"repairOrders",
"1=1",
"id",
500
));
let backfilled = 0;
for (const rec of records) {
if (rec.get('estimatedDuration') != null) continue; // don't overwrite
const raw = rec.get('estimatedTime');
const hours = parseFloat(String(raw || '0')) || 0;
rec.set('estimatedDuration', Math.round(hours * 60));
db.saveRecord(rec);
backfilled++;
}
console.log(`[M2] backfilled ${backfilled} records`);
},
(db) => {
const col = db.findCollectionByNameOrId('repairOrders');
if (!col) return;
const idx = col.fields.findIndex((f) => f.name === 'estimatedDuration');
if (idx >= 0) {
col.fields.splice(idx, 1);
db.save(col);
}
}
);
@@ -0,0 +1,79 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M3 — Promote financial fields out of the `financial` JSON blob into real
// numeric columns on `repairOrders`. This lets the FinancialDashboard run
// server-side `sum()` aggregations instead of pulling every completed RO to
// the client. It also makes the values type-safe and queryable.
//
// New columns: grossTotal, grossCost, warrTotal, warrCost, shopCharge.
// (shopCharge overlaps conceptually with the existing `shopCharges` column —
// see the migration doc. Here we add `shopCharge` as a separate field to
// match the financial-blob key the frontend currently writes. If you prefer
// to consolidate, point the frontend at `shopCharges` instead and skip this
// field.)
//
migrate(
(db) => {
const col = db.findCollectionByNameOrId('repairOrders');
if (!col) {
console.warn('[M3] repairOrders collection not found — skipping');
return;
}
for (const name of ['grossTotal', 'grossCost', 'warrTotal', 'warrCost', 'shopCharge']) {
if (!col.fields.find((f) => f.name === name)) {
col.fields.push(new NumberField({ name, required: false, min: 0 }));
}
}
db.save(col);
console.log('[M3] added financial numeric fields');
const records = Array.from(db.findRecordsByFilter(
"repairOrders",
"1=1",
"id",
500
));
let backfilled = 0;
for (const rec of records) {
let fin = {};
try {
const raw = rec.get('financial');
if (typeof raw === 'string' && raw.trim()) {
fin = JSON.parse(raw);
} else if (raw && typeof raw === 'object') {
fin = raw;
}
} catch { /* ignore corrupt blobs */ }
let changed = false;
for (const [srcKey, dstKey] of [
['grossTotal', 'grossTotal'],
['grossCost', 'grossCost'],
['warrTotal', 'warrTotal'],
['warrCost', 'warrCost'],
['shopCharge', 'shopCharge'],
]) {
const v = Number(fin && fin[srcKey]);
if (!Number.isNaN(v) && rec.get(dstKey) == null) {
rec.set(dstKey, v || 0);
changed = true;
}
}
if (changed) {
db.saveRecord(rec);
backfilled++;
}
}
console.log(`[M3] backfilled ${backfilled} records`);
},
(db) => {
const col = db.findCollectionByNameOrId('repairOrders');
if (!col) return;
for (const name of ['grossTotal', 'grossCost', 'warrTotal', 'warrCost', 'shopCharge']) {
const idx = col.fields.findIndex((f) => f.name === name);
if (idx >= 0) col.fields.splice(idx, 1);
}
db.save(col);
}
);
@@ -0,0 +1,65 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M4 — API Rules for all user-scoped collections.
//
// CRITICAL SECURITY: without these rules, any logged-in user can read or
// overwrite every other shop's records by passing a different `userId` in
// the request body. The frontend cannot enforce this — it must be locked
// down at the DB layer.
//
// This migration sets List/View/Create/Update/Delete rules on each
// user-owned collection to: userId = @request.auth.id
//
// Notes:
// - `users` admin-only access is NOT configured here (do that in the PB
// admin UI under the users collection). This migration only touches the
// per-shop data collections.
// - `settings` is treated as user-scoped via its `userId` field (the
// frontend stores a `businessSettings` record per user).
// - Re-run is idempotent — applying the same rule string twice is safe.
//
const USER_SCOPED_COLLECTIONS = [
'quotes',
'repairOrders',
'invoices',
'appointments',
'customers',
'services',
'settings',
];
const RULE = 'userId = @request.auth.id';
migrate(
(db) => {
for (const name of USER_SCOPED_COLLECTIONS) {
const col = db.findCollectionByNameOrId(name);
if (!col) {
console.warn(`[M4] collection "${name}" not found — skipping (create it first in admin)`);
continue;
}
col.listRule = RULE;
col.viewRule = RULE;
col.createRule = RULE;
col.updateRule = RULE;
col.deleteRule = RULE;
db.save(col);
console.log(`[M4] applied user-scoped rules to "${name}"`);
}
},
(db) => {
// On rollback: clear the rules back to empty (full access as admin).
// WARNING: this re-opens cross-tenant access — only run the down-migration
// if you intend to reconfigure manually afterward.
for (const name of USER_SCOPED_COLLECTIONS) {
const col = db.findCollectionByNameOrId(name);
if (!col) continue;
col.listRule = '';
col.viewRule = '';
col.createRule = '';
col.updateRule = '';
col.deleteRule = '';
db.save(col);
}
}
);
@@ -0,0 +1,58 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M5 — Unique index on `repairOrders.roNumber`.
//
// The frontend generates RO numbers with Math.random() and now guards against
// collisions against the loaded set (see src/pages/RepairOrders.tsx,
// uniqueRONumber()). But two advisors creating ROs in the same second can
// still race past the frontend check. This migration adds a server-side
// unique index so the second write fails at the DB layer instead of silently
// producing two ROs with the same number.
//
// If your existing data has duplicate roNumbers, this migration will fail —
// dedupe in the admin UI first (find records where roNumber = '', set them to
// unique placeholder values, then retry).
//
migrate(
(db) => {
const col = db.findCollectionByNameOrId('repairOrders');
if (!col) {
console.warn('[M5] repairOrders collection not found — skipping');
return;
}
// PocketBase 0.20+ uses the `Index` helper on a field. Add a unique
// index on roNumber if one isn't already present. Using a TextField
// option `unique` is the simplest cross-version approach.
let changed = false;
const existing = col.fields.find((f) => f.name === 'roNumber');
if (existing && existing.type === 'text' && !existing.options?.unique) {
existing.options = { ...(existing.options || {}), unique: true };
changed = true;
} else if (!existing) {
// Field missing entirely — add it with unique.
col.fields.push(new TextField({
name: 'roNumber',
required: false,
options: { unique: true },
}));
changed = true;
}
if (changed) {
db.save(col);
console.log('[M5] set roNumber unique=true on repairOrders');
} else {
console.log('[M5] roNumber already unique — no change');
}
},
(db) => {
const col = db.findCollectionByNameOrId('repairOrders');
if (!col) return;
const f = col.fields.find((f) => f.name === 'roNumber');
if (f && f.type === 'text' && f.options?.unique) {
f.options.unique = false;
db.save(col);
}
}
);
@@ -0,0 +1,54 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M6 — Add a Quote ↔ Repair Order link.
//
// `quotes.repairOrderId` — set when a quote is generated from an existing RO
// (so you can jump back to the originating RO).
// `repairOrders.quoteId` — set when an RO is created by "converting" an
// approved quote (so you can jump back to the source
// quote). Also lets the dashboard report "converted
// quotes" as a metric.
//
// Both are nullable text columns (PocketBase relation fields would lock you
// into one record type; plain text keeps the link loose and survives renames
// of the related collection). The frontend treats empty/null as "no link".
//
migrate(
(db) => {
// quotes.repairOrderId
const quotes = db.findCollectionByNameOrId('quotes');
if (quotes) {
if (!quotes.fields.find((f) => f.name === 'repairOrderId')) {
quotes.fields.push(new TextField({ name: 'repairOrderId', required: false }));
db.save(quotes);
console.log('[M6] added repairOrderId to quotes');
}
} else {
console.warn('[M6] quotes collection not found — skipping');
}
// repairOrders.quoteId
const ros = db.findCollectionByNameOrId('repairOrders');
if (ros) {
if (!ros.fields.find((f) => f.name === 'quoteId')) {
ros.fields.push(new TextField({ name: 'quoteId', required: false }));
db.save(ros);
console.log('[M6] added quoteId to repairOrders');
}
} else {
console.warn('[M6] repairOrders collection not found — skipping');
}
},
(db) => {
const quotes = db.findCollectionByNameOrId('quotes');
if (quotes) {
const i1 = quotes.fields.findIndex((f) => f.name === 'repairOrderId');
if (i1 >= 0) { quotes.fields.splice(i1, 1); db.save(quotes); }
}
const ros = db.findCollectionByNameOrId('repairOrders');
if (ros) {
const i2 = ros.fields.findIndex((f) => f.name === 'quoteId');
if (i2 >= 0) { ros.fields.splice(i2, 1); db.save(ros); }
}
}
);
@@ -0,0 +1,98 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M7 — Add structured VIN and duration fields to appointments.
//
// Older scan imports stored these values inside `notes` as:
// VIN: <value> | Duration: <number> min
// This migration adds first-class columns, backfills them when missing, and
// removes those machine-generated note fragments while preserving any real text.
//
function compactNoteParts(raw) {
return String(raw || '')
.split('|')
.map((part) => part.trim())
.filter(Boolean);
}
migrate(
(db) => {
const col = db.findCollectionByNameOrId('appointments');
if (!col) {
console.warn('[M7] appointments collection not found — skipping');
return;
}
if (!col.fields.find((f) => f.name === 'vin')) {
col.fields.push(new TextField({ name: 'vin', required: false }));
db.save(col);
console.log('[M7] added vin field to appointments');
}
if (!col.fields.find((f) => f.name === 'durationMinutes')) {
col.fields.push(new NumberField({
name: 'durationMinutes',
required: false,
min: 0,
}));
db.save(col);
console.log('[M7] added durationMinutes field to appointments');
}
const records = Array.from(db.findRecordsByFilter('appointments', '1=1', 'id', 500));
let backfilled = 0;
for (const rec of records) {
const rawNotes = String(rec.get('notes') || '');
const vinMatch = rawNotes.match(/(?:^|\|)\s*VIN:\s*([^|]+?)\s*(?=\||$)/i);
const durationMatch = rawNotes.match(/(?:^|\|)\s*Duration:\s*(\d+)\s*min\s*(?=\||$)/i);
const nextVin = String(rec.get('vin') || '').trim();
const rawDuration = rec.get('durationMinutes');
const nextDuration = rawDuration == null || rawDuration === '' ? null : Number(rawDuration);
let changed = false;
if (!nextVin && vinMatch?.[1]) {
rec.set('vin', String(vinMatch[1]).trim());
changed = true;
}
if ((nextDuration == null || Number.isNaN(nextDuration)) && durationMatch?.[1]) {
rec.set('durationMinutes', Number(durationMatch[1]));
changed = true;
}
const cleanedNotes = compactNoteParts(rawNotes)
.filter((part) => !/^VIN:\s*/i.test(part) && !/^Duration:\s*\d+\s*min$/i.test(part))
.join(' | ');
if (cleanedNotes !== rawNotes.trim()) {
rec.set('notes', cleanedNotes);
changed = true;
}
if (changed) {
db.saveRecord(rec);
backfilled++;
}
}
console.log(`[M7] updated ${backfilled} appointment record(s)`);
},
(db) => {
const col = db.findCollectionByNameOrId('appointments');
if (!col) return;
const durationIdx = col.fields.findIndex((f) => f.name === 'durationMinutes');
if (durationIdx >= 0) {
col.fields.splice(durationIdx, 1);
db.save(col);
}
const vinIdx = col.fields.findIndex((f) => f.name === 'vin');
if (vinIdx >= 0) {
col.fields.splice(vinIdx, 1);
db.save(col);
}
}
);
@@ -0,0 +1,62 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M7 — Public Quote Sharing via Share Token.
//
// Adds a `shareToken` field to the `quotes` collection so a shop owner can
// generate a secret URL that lets a customer review and approve/decline
// individual services without logging in.
//
// The `viewRule` and `updateRule` are relaxed to allow access when the
// request carries a matching `shareToken` query parameter. The token is
// a random UUID, making it unguessable while simple to copy/paste.
//
// Security: the token only grants access to the single quote record it
// belongs to. All other records remain locked to the owning user only.
//
migrate(
(db) => {
const quotes = db.findCollectionByNameOrId('quotes');
if (!quotes) {
console.warn('[M7] quotes collection not found — skipping');
return;
}
// ── Add shareToken field ─────────────────────────────────────────
if (!quotes.fields.find((f) => f.name === 'shareToken')) {
quotes.fields.push(
new TextField({
name: 'shareToken',
required: false,
})
);
console.log('[M7] added shareToken field to quotes');
}
// ── Relax view & update rules for public access via shareToken ──
const PUBLIC_SHARE_RULE = `(userId = @request.auth.id) || (shareToken != "" && shareToken = @request.query.shareToken)`;
quotes.listRule = PUBLIC_SHARE_RULE;
quotes.viewRule = PUBLIC_SHARE_RULE;
quotes.updateRule = PUBLIC_SHARE_RULE;
// create/delete stay locked to the owning user
db.save(quotes);
console.log('[M7] relaxed list/view/update rules on quotes for public share access');
},
(db) => {
const quotes = db.findCollectionByNameOrId('quotes');
if (!quotes) return;
const idx = quotes.fields.findIndex((f) => f.name === 'shareToken');
if (idx >= 0) {
quotes.fields.splice(idx, 1);
db.save(quotes);
}
// Restore tight user-only rules
quotes.listRule = 'userId = @request.auth.id';
quotes.viewRule = 'userId = @request.auth.id';
quotes.updateRule = 'userId = @request.auth.id';
db.save(quotes);
console.log('[M7] rolled back shareToken — rules restored to user-only');
}
);
@@ -0,0 +1,164 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M8 — Align appointments with the frontend's structured field model.
//
// Older databases used these fields:
// appointmentDateTime, duration, type
// and often embedded VIN/duration inside notes.
//
// The current frontend expects first-class fields:
// date, time, vin, durationMinutes, advisorName, serviceType
//
// This migration adds any missing fields and backfills them from the legacy
// columns while preserving existing data.
//
function ensureTextField(col, name) {
if (!col.fields.find((f) => f.name === name)) {
col.fields.push(new TextField({ name, required: false }));
return true;
}
return false;
}
function ensureNumberField(col, name) {
if (!col.fields.find((f) => f.name === name)) {
col.fields.push(new NumberField({ name, required: false, min: 0 }));
return true;
}
return false;
}
function splitAppointmentDateTime(value) {
const raw = String(value || '').trim();
const match = raw.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/);
if (match) {
return { date: match[1], time: match[2] };
}
return { date: '', time: '' };
}
function extractVinFromNotes(notes) {
const match = String(notes || '').match(/(?:^|\|)\s*VIN:\s*([^|]+?)\s*(?=\||$)/i);
return match?.[1]?.trim().toUpperCase() || '';
}
function extractDurationFromNotes(notes) {
const match = String(notes || '').match(/(?:^|\|)\s*Duration:\s*(\d+)\s*min\s*(?=\||$)/i);
return match?.[1] ? Number(match[1]) : null;
}
function stripStructuredAppointmentNotes(notes) {
return String(notes || '')
.split('|')
.map((part) => part.trim())
.filter((part) => part && !/^VIN:\s*/i.test(part) && !/^Duration:\s*\d+\s*min$/i.test(part))
.join(' | ');
}
migrate(
(db) => {
const col = db.findCollectionByNameOrId('appointments');
if (!col) {
console.warn('[M8] appointments collection not found — skipping');
return;
}
let schemaChanged = false;
schemaChanged = ensureTextField(col, 'date') || schemaChanged;
schemaChanged = ensureTextField(col, 'time') || schemaChanged;
schemaChanged = ensureTextField(col, 'vin') || schemaChanged;
schemaChanged = ensureTextField(col, 'advisorName') || schemaChanged;
schemaChanged = ensureTextField(col, 'serviceType') || schemaChanged;
schemaChanged = ensureNumberField(col, 'durationMinutes') || schemaChanged;
if (schemaChanged) {
db.save(col);
console.log('[M8] aligned appointments schema');
}
const records = Array.from(db.findRecordsByFilter('appointments', '1=1', 'id', 1000));
let backfilled = 0;
for (const rec of records) {
const notes = String(rec.get('notes') || '');
const legacyDateTime = String(rec.get('appointmentDateTime') || '');
const split = splitAppointmentDateTime(legacyDateTime);
const currentDate = String(rec.get('date') || '').trim();
const currentTime = String(rec.get('time') || '').trim();
const currentVin = String(rec.get('vin') || '').trim();
const currentServiceType = String(rec.get('serviceType') || '').trim();
const rawDurationMinutes = rec.get('durationMinutes');
const currentDurationMinutes = rawDurationMinutes == null || rawDurationMinutes === ''
? null
: Number(rawDurationMinutes);
const legacyDuration = rec.get('duration');
const numericLegacyDuration = legacyDuration == null || legacyDuration === ''
? null
: Number(legacyDuration);
let changed = false;
if (!currentDate && split.date) {
rec.set('date', split.date);
changed = true;
}
if (!currentTime && split.time) {
rec.set('time', split.time);
changed = true;
}
if (!currentVin) {
const noteVin = extractVinFromNotes(notes);
if (noteVin) {
rec.set('vin', noteVin);
changed = true;
}
}
if (!currentServiceType) {
const legacyType = String(rec.get('type') || '').trim();
if (legacyType) {
rec.set('serviceType', legacyType);
changed = true;
}
}
if (currentDurationMinutes == null || Number.isNaN(currentDurationMinutes) || (currentDurationMinutes <= 0 && numericLegacyDuration > 0)) {
const noteDuration = extractDurationFromNotes(notes);
const nextDuration = noteDuration ?? numericLegacyDuration;
if (nextDuration != null && !Number.isNaN(nextDuration)) {
rec.set('durationMinutes', nextDuration);
changed = true;
}
}
const cleanedNotes = stripStructuredAppointmentNotes(notes);
if (cleanedNotes !== notes.trim()) {
rec.set('notes', cleanedNotes);
changed = true;
}
if (changed) {
db.saveRecord(rec);
backfilled++;
}
}
console.log(`[M8] backfilled ${backfilled} appointment record(s)`);
},
(db) => {
const col = db.findCollectionByNameOrId('appointments');
if (!col) return;
for (const name of ['durationMinutes', 'serviceType', 'advisorName', 'vin', 'time', 'date']) {
const idx = col.fields.findIndex((f) => f.name === name);
if (idx >= 0) {
col.fields.splice(idx, 1);
}
}
db.save(col);
}
);
@@ -0,0 +1,31 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M8 — Quote Viewed-At Timestamp.
//
// Adds a `viewedAt` text field to the `quotes` collection so the front-end
// can record the first time a customer opens the public approval link.
//
migrate(
(db) => {
const quotes = db.findCollectionByNameOrId('quotes');
if (!quotes) {
console.warn('[M8] quotes collection not found — skipping');
return;
}
if (!quotes.fields.find((f) => f.name === 'viewedAt')) {
quotes.fields.push(new TextField({ name: 'viewedAt', required: false }));
console.log('[M8] added viewedAt field to quotes');
}
db.save(quotes);
},
(db) => {
const quotes = db.findCollectionByNameOrId('quotes');
if (!quotes) return;
const idx = quotes.fields.findIndex((f) => f.name === 'viewedAt');
if (idx >= 0) {
quotes.fields.splice(idx, 1);
db.save(quotes);
console.log('[M8] removed viewedAt from quotes');
}
}
);
@@ -0,0 +1,40 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M9 — Rename legacy repair order status `delivered` to `final_close`.
//
// The frontend now uses `final_close` as the canonical terminal post-completion
// status. This migration updates existing repair orders so historical data and
// new UI filters/counts stay aligned.
//
migrate(
(db) => {
const col = db.findCollectionByNameOrId('repairOrders');
if (!col) {
console.warn('[M9] repairOrders collection not found — skipping');
return;
}
const records = Array.from(db.findRecordsByFilter('repairOrders', "status = 'delivered'", 'id', 1000));
let updated = 0;
for (const rec of records) {
rec.set('status', 'final_close');
db.saveRecord(rec);
updated++;
}
console.log(`[M9] renamed ${updated} repair order(s) to final_close`);
},
(db) => {
const col = db.findCollectionByNameOrId('repairOrders');
if (!col) return;
const records = Array.from(db.findRecordsByFilter('repairOrders', "status = 'final_close'", 'id', 1000));
let reverted = 0;
for (const rec of records) {
rec.set('status', 'delivered');
db.saveRecord(rec);
reverted++;
}
console.log(`[M9] reverted ${reverted} repair order(s) to delivered`);
}
);
@@ -0,0 +1,70 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M10 — Add role, shopUserId, and displayName to the users collection.
//
// This enables the RBX (Role-Based Experience). The `role` field determines
// which dashboard and pages the user sees. `shopUserId` links a technician
// account to its shop owner. `displayName` is a friendly name shown in the
// technician profile and advisor assignment views.
//
migrate(
(db) => {
const col = db.findCollectionByNameOrId('users');
if (!col) {
console.warn('[M10] users collection not found — skipping');
return;
}
// ── role: select ────────────────────────────────────────────────────────
const existingRole = col.fields.find((f) => f.name === 'role');
if (!existingRole) {
const f = new SelectField({
name: 'role',
required: false,
values: ['advisor', 'technician', 'mobile'],
maxSelect: 1,
});
col.fields.add(f);
console.log('[M10] added role field to users');
} else {
console.log('[M10] role already exists — no change');
}
// ── shopUserId: text (relation to the shop-owner user) ─────────────────
const existingShopUser = col.fields.find((f) => f.name === 'shopUserId');
if (!existingShopUser) {
const f = new TextField({
name: 'shopUserId',
required: false,
});
col.fields.add(f);
console.log('[M10] added shopUserId field to users');
} else {
console.log('[M10] shopUserId already exists — no change');
}
// ── displayName: text ──────────────────────────────────────────────────
const existingDisplayName = col.fields.find((f) => f.name === 'displayName');
if (!existingDisplayName) {
const f = new TextField({
name: 'displayName',
required: false,
});
col.fields.add(f);
console.log('[M10] added displayName field to users');
} else {
console.log('[M10] displayName already exists — no change');
}
db.save(col);
},
(db) => {
const col = db.findCollectionByNameOrId('users');
if (!col) return;
col.fields.remove('role');
col.fields.remove('shopUserId');
col.fields.remove('displayName');
db.save(col);
}
);
@@ -0,0 +1,152 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M11 — Create the technicianAssignments collection.
//
// Each record is a sanitized copy of one RO that an advisor has assigned to a
// technician. The assignment carries only the data a technician needs to do
// the job — no pricing, no totals, no customer contact details.
//
// API rule pattern (applied after creation):
// shopUserId = @request.auth.id || technicianUserId = @request.auth.id
migrate(
(db) => {
const existing = db.findCollectionByNameOrId('technicianAssignments');
if (existing) {
console.log('[M11] technicianAssignments already exists — skipping');
return;
}
const col = new Collection();
col.name = 'technicianAssignments';
col.type = 'base';
col.system = false;
col.listRule = null;
col.viewRule = null;
col.createRule = null;
col.updateRule = null;
col.deleteRule = null;
// ── Fields ──────────────────────────────────────────────────────────────
// shopUserId — owner of the shop that created this assignment
const shopUserIdField = new TextField({
name: 'shopUserId',
required: true,
});
col.fields.add(shopUserIdField);
// technicianUserId — the technician who receives this work
const techUserIdField = new TextField({
name: 'technicianUserId',
required: true,
});
col.fields.add(techUserIdField);
// repairOrderId — source RO id (plain text, not a relation)
const roIdField = new TextField({
name: 'repairOrderId',
required: true,
});
col.fields.add(roIdField);
// roNumber — for display (no relation to repairOrders)
const roNumField = new TextField({
name: 'roNumber',
required: false,
});
col.fields.add(roNumField);
// vehicleInfo — e.g. "2018 Ford F-150"
const vehicleField = new TextField({
name: 'vehicleInfo',
required: false,
});
col.fields.add(vehicleField);
// vin
const vinField = new TextField({
name: 'vin',
required: false,
});
col.fields.add(vinField);
// mileage
const mileageField = new TextField({
name: 'mileage',
required: false,
});
col.fields.add(mileageField);
// serviceLocation — job-site address (mobile use-case)
const locField = new TextField({
name: 'serviceLocation',
required: false,
});
col.fields.add(locField);
// status — assignment-level job status
const statusField = new SelectField({
name: 'status',
required: false,
values: ['pending', 'in_progress', 'waiting_parts', 'completed'],
maxSelect: 1,
});
col.fields.add(statusField);
// services — sanitized service list (JSON, no pricing)
const servicesField = new JSONField({
name: 'services',
required: false,
});
col.fields.add(servicesField);
// internalNotes — technician notes (JSON or plain text)
const notesField = new JSONField({
name: 'internalNotes',
required: false,
});
col.fields.add(notesField);
// clockEntries — stopwatch entries (JSON array of ClockEntry)
const clockField = new JSONField({
name: 'clockEntries',
required: false,
});
col.fields.add(clockField);
// inspectionChecklist — JSON (future)
const checklistField = new JSONField({
name: 'inspectionChecklist',
required: false,
});
col.fields.add(checklistField);
// advisorRequests — messages the technician sends to the advisor
const requestsField = new JSONField({
name: 'advisorRequests',
required: false,
});
col.fields.add(requestsField);
db.save(col);
console.log('[M11] created technicianAssignments collection');
// ── Apply API rules ───────────────────────────────────────────────────
const saved = db.findCollectionByNameOrId('technicianAssignments');
saved.listRule = 'shopUserId = @request.auth.id || technicianUserId = @request.auth.id';
saved.viewRule = 'shopUserId = @request.auth.id || technicianUserId = @request.auth.id';
saved.createRule = 'shopUserId = @request.auth.id';
saved.updateRule = 'shopUserId = @request.auth.id || technicianUserId = @request.auth.id';
saved.deleteRule = 'shopUserId = @request.auth.id';
db.save(saved);
console.log('[M11] applied API rules to technicianAssignments');
},
(db) => {
const col = db.findCollectionByNameOrId('technicianAssignments');
if (col) {
db.deleteCollection(col);
console.log('[M11] deleted technicianAssignments collection');
}
}
);
@@ -0,0 +1,61 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M12 — Create technicianInvites.
//
// Advisors create invite records and copy the generated invite link. The
// frontend stores only a SHA-256 hash of the one-time token; the raw token only
// exists in the copied URL.
migrate(
(db) => {
let existing = null;
try {
existing = db.findCollectionByNameOrId('technicianInvites');
} catch {
existing = null;
}
if (existing) {
console.log('[M12] technicianInvites already exists — skipping');
return;
}
const col = new Collection();
col.name = 'technicianInvites';
col.type = 'base';
col.system = false;
col.listRule = 'shopUserId = @request.auth.id || (tokenHash = @request.query.tokenHash && status = "pending" && expiresAt >= @now)';
col.viewRule = 'shopUserId = @request.auth.id || (tokenHash = @request.query.tokenHash && status = "pending" && expiresAt >= @now)';
col.createRule = 'shopUserId = @request.auth.id';
col.updateRule = 'shopUserId = @request.auth.id || (tokenHash = @request.query.tokenHash && status = "pending" && expiresAt >= @now)';
col.deleteRule = 'shopUserId = @request.auth.id';
col.fields.add(new TextField({ name: 'shopUserId', required: true }));
col.fields.add(new EmailField({ name: 'email', required: true }));
col.fields.add(new TextField({ name: 'displayName', required: false }));
col.fields.add(new TextField({ name: 'tokenHash', required: true }));
col.fields.add(new SelectField({
name: 'status',
required: true,
values: ['pending', 'accepted', 'revoked'],
maxSelect: 1,
}));
col.fields.add(new DateField({ name: 'expiresAt', required: true }));
col.fields.add(new TextField({ name: 'acceptedByUserId', required: false }));
col.fields.add(new DateField({ name: 'acceptedAt', required: false }));
db.save(col);
console.log('[M12] created technicianInvites collection');
},
(db) => {
let col = null;
try {
col = db.findCollectionByNameOrId('technicianInvites');
} catch {
col = null;
}
if (col) {
db.deleteCollection(col);
console.log('[M12] deleted technicianInvites collection');
}
}
);
@@ -0,0 +1,39 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M13 — API rules for the users collection.
//
// Previously the users collection had no migration-applied rules (see M4
// comment at line 14-15). Without list/view rules, any authenticated user
// could enumerate all accounts including shop owner emails and display
// names.
//
// This migration restricts users to seeing only their own record:
// listRule = 'id = @request.auth.id'
// viewRule = 'id = @request.auth.id'
//
// Create, Update, and Delete rules remain as default (admin-only) —
// self-registration is handled by the frontend signup flow, and profile
// updates use the PB auth endpoints or admin UI.
migrate(
(db) => {
const col = db.findCollectionByNameOrId('users');
if (!col) {
console.warn('[M13] users collection not found — skipping');
return;
}
col.listRule = 'id = @request.auth.id';
col.viewRule = 'id = @request.auth.id';
db.save(col);
console.log('[M13] applied user-scoped rules to users collection');
},
(db) => {
// On rollback: clear the rules back to empty (fallback to admin-only access).
const col = db.findCollectionByNameOrId('users');
if (!col) return;
col.listRule = '';
col.viewRule = '';
db.save(col);
}
);
@@ -0,0 +1,40 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M14 — Widen users collection listRule for shop-owner technician query.
//
// M13 restricted listRule/viewRule to `id = @request.auth.id`, which broke
// the advisor's ability to query their technician accounts via the Team page
// and the AssignmentPanel. PocketBase ANDs the listRule with the client
// filter, so the combined rule becomes:
//
// id = 'advisorId' AND role = 'technician' AND shopUserId = 'advisorId'
//
// This matches zero records because the advisor's own record has
// role = 'advisor', not 'technician'.
//
// This migration widens the rule to also allow shop owners (advisor/mobile)
// to list and view technician records that belong to their shop:
migrate(
(db) => {
const col = db.findCollectionByNameOrId('users');
if (!col) {
console.warn('[M14] users collection not found — skipping');
return;
}
// Allow self-view OR shop-owner view of their own technicians
col.listRule = "id = @request.auth.id || (role = 'technician' && shopUserId = @request.auth.id)";
col.viewRule = "id = @request.auth.id || (role = 'technician' && shopUserId = @request.auth.id)";
db.save(col);
console.log('[M14] widened users collection listRule/viewRule for shop-owner technician access');
},
(db) => {
// Rollback: restore M13 rules
const col = db.findCollectionByNameOrId('users');
if (!col) return;
col.listRule = 'id = @request.auth.id';
col.viewRule = 'id = @request.auth.id';
db.save(col);
}
);
@@ -0,0 +1,68 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M15 — Widen repairOrders rules for technician viewing and
// technicianAssignments createRule for technician self-assignment.
//
// repairOrders:
// Previously: userId = @request.auth.id (M4 rule)
// Now: userId = @request.auth.id || (userId = @request.auth.shopUserId && @request.auth.role = 'technician')
// This lets technicians list/view their shop's active repair orders.
//
// technicianAssignments:
// Previously: shopUserId = @request.auth.id (M11 rule)
// Now: shopUserId = @request.auth.id || (shopUserId = @request.auth.shopUserId && @request.auth.role = 'technician')
// This lets technicians create assignment records for self-claimed jobs.
migrate(
(db) => {
// ── repairOrders: widen listRule and viewRule ──────────────────────────
const roCol = db.findCollectionByNameOrId('repairOrders');
if (roCol) {
const techRule = "(userId = @request.auth.shopUserId && @request.auth.role = 'technician')";
const current = roCol.listRule;
// Only apply if the widened rule isn't already set (idempotent)
if (current !== 'userId = @request.auth.id' && current?.includes('technician')) {
console.log('[M15] repairOrders already widened — no change');
} else {
roCol.listRule = `userId = @request.auth.id || ${techRule}`;
roCol.viewRule = `userId = @request.auth.id || ${techRule}`;
db.save(roCol);
console.log('[M15] widened repairOrders listRule/viewRule for technician access');
}
} else {
console.warn('[M15] repairOrders collection not found — skipping');
}
// ── technicianAssignments: widen createRule ─────────────────────────────
const taCol = db.findCollectionByNameOrId('technicianAssignments');
if (taCol) {
const techCreateRule = "(shopUserId = @request.auth.shopUserId && @request.auth.role = 'technician')";
const current = taCol.createRule;
if (current !== 'shopUserId = @request.auth.id' && current?.includes('technician')) {
console.log('[M15] technicianAssignments createRule already widened — no change');
} else {
taCol.createRule = `shopUserId = @request.auth.id || ${techCreateRule}`;
db.save(taCol);
console.log('[M15] widened technicianAssignments createRule for technician self-assignment');
}
} else {
console.warn('[M15] technicianAssignments collection not found — skipping');
}
},
(db) => {
// Rollback: restore M4 rules for repairOrders
const roCol = db.findCollectionByNameOrId('repairOrders');
if (roCol) {
roCol.listRule = 'userId = @request.auth.id';
roCol.viewRule = 'userId = @request.auth.id';
db.save(roCol);
}
// Rollback: restore M11 rule for technicianAssignments
const taCol = db.findCollectionByNameOrId('technicianAssignments');
if (taCol) {
taCol.createRule = 'shopUserId = @request.auth.id';
db.save(taCol);
}
}
);
@@ -0,0 +1,52 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M16 — Create user-scoped vehicles collection.
//
// Customer vehicles were previously inferred from repairOrders/quotes only.
// The Customers page already has add/edit/delete vehicle UI, so this collection
// stores explicit vehicle records keyed by customerId and userId.
migrate(
(db) => {
let existing = null;
try { existing = db.findCollectionByNameOrId('vehicles'); } catch {}
if (existing) {
console.log('[M16] vehicles already exists — skipping');
return;
}
const col = new Collection();
col.name = 'vehicles';
col.type = 'base';
col.system = false;
col.fields.add(new TextField({ name: 'userId', required: true }));
col.fields.add(new TextField({ name: 'customerId', required: true }));
col.fields.add(new TextField({ name: 'make', required: true }));
col.fields.add(new TextField({ name: 'model', required: true }));
col.fields.add(new TextField({ name: 'year', required: false }));
col.fields.add(new TextField({ name: 'vin', required: false }));
col.fields.add(new TextField({ name: 'licensePlate', required: false }));
col.fields.add(new TextField({ name: 'mileage', required: false }));
col.fields.add(new TextField({ name: 'color', required: false }));
col.fields.add(new TextField({ name: 'engine', required: false }));
const rule = 'userId = @request.auth.id';
col.listRule = rule;
col.viewRule = rule;
col.createRule = rule;
col.updateRule = rule;
col.deleteRule = rule;
db.save(col);
console.log('[M16] created vehicles collection');
},
(db) => {
let col = null;
try { col = db.findCollectionByNameOrId('vehicles'); } catch {}
if (col) {
db.deleteCollection(col);
console.log('[M16] deleted vehicles collection');
}
}
);
@@ -0,0 +1,38 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M19 — Add customerId to appointments collection.
migrate(
function (db) {
var col = null;
try { col = db.findCollectionByNameOrId('appointments'); } catch (e) {}
if (!col) {
console.warn('[M19] appointments collection not found — skipping');
return;
}
for (var i = 0; i < col.fields.length; i++) {
if (col.fields[i].name === 'customerId') {
console.log('[M19] customerId already exists — skipping');
return;
}
}
col.fields.push(new TextField({ name: 'customerId', required: false }));
db.save(col);
console.log('[M19] added customerId to appointments');
},
function (db) {
var col = null;
try { col = db.findCollectionByNameOrId('appointments'); } catch (e) {}
if (!col) return;
for (var i = 0; i < col.fields.length; i++) {
if (col.fields[i].name === 'customerId') {
col.fields.splice(i, 1);
db.save(col);
console.log('[M19] removed customerId from appointments');
return;
}
}
}
);
@@ -0,0 +1,56 @@
/// <reference path="../pb_data/types.d.ts" />
// M20 — users.onRecordCreateRequest role guard.
//
// Closes the self-signup role-escalation hole: a client can no longer
// POST { role: 'technician', shopUserId: '<someone>' } to /api/collections/users
// to land inside another shop's technician list.
//
// Rule: role defaults to 'advisor' and shopUserId is cleared, UNLESS the
// email being registered matches a pending, unexpired technicianInvites
// record — in which case role='technician' and shopUserId comes from the
// invite (not the client).
//
// Idempotent: re-applying is safe because we only set values on create.
migrate(
(app) => {
app.onRecordCreateRequest((e) => {
if (e.collection.name !== 'users') return;
const record = e.record;
const email = (record.get('email') || '').toString().trim().toLowerCase();
if (!email) return;
// Look for a matching pending, unexpired invite.
let invite = null;
try {
invite = e.dao.findFirstRecordByFilter(
'technicianInvites',
`email = "${email.replace(/"/g, '')}" && status = "pending" && expiresAt >= @now`,
);
} catch {
invite = null;
}
if (invite) {
// Authorized technician signup — pin values from the invite.
record.set('role', 'technician');
record.set('shopUserId', invite.get('shopUserId') || '');
console.log(`[M20] technician signup authorized for ${email}`);
} else {
// Regular owner signup — force advisor, clear shop link.
record.set('role', 'advisor');
record.set('shopUserId', '');
console.log(`[M20] owner signup: role forced to advisor for ${email}`);
}
});
console.log('[M20] users create role guard installed');
},
(app) => {
// Best-effort rollback: we cannot easily unregister a JS hook in 0.39
// without restarting PB. Document that rolling this back requires a
// PB restart after removing the file. The down-migration is a no-op
// that logs the intent.
console.log('[M20] rollback requested — remove this migration file and restart PocketBase');
},
);
@@ -0,0 +1,40 @@
/// <reference path="../pb_data/types.d.ts" />
// M21 — technicianAssignments optimistic concurrency lock.
//
// Rejects updates to technicianAssignments when the client-sent
// `__expectedUpdated` doesn't match the current `updated` value.
// Clients opt in by including `__expectedUpdated`; if absent the
// check is skipped (backward-compatible).
migrate(
(app) => {
app.onRecordUpdateRequest((e) => {
if (e.collection.name !== 'technicianAssignments') return;
const body = e.httpContext.request().body;
const expected = body.get('__expectedUpdated');
if (!expected) return; // opt-in
// Fetch the original record from the DB to get the current updated value.
let original;
try {
original = e.dao.findRecordById(e.collection, e.record.id);
} catch {
return; // record not found — let the normal error handling cover this
}
const originalUpdated = String(original.get('updated') || '');
if (originalUpdated !== String(expected)) {
throw new ApiError(409, 'Record was modified by another user');
}
// Remove the meta field so it doesn't persist to the database.
body.remove('__expectedUpdated');
console.log('[M21] optimistic lock passed for', e.record.id);
});
console.log('[M21] optimistic lock hook installed');
},
(app) => {
console.log('[M21] rollback requested — remove this migration file and restart PocketBase');
},
);
@@ -0,0 +1,45 @@
/// <reference path="../pb_data/types.d.ts" />
// M22 — Add logo file field to settings collection.
//
// Currently the shop logo is stored as a base64 data-URL inside the `data`
// JSON blob. This migration adds a proper `logo` file field so logos are
// stored as PB file records instead, eliminating bloated localStorage and
// PB round-trips.
//
// The frontend continues to cache a base64 version in localStorage for
// backward compatibility with PDF generation (jsPDF addImage expects
// data URLs or Image objects). The PB file field is the canonical store.
//
// Migration is a no-op if the field already exists (idempotent).
migrate(
(app) => {
const col = app.findCollectionByNameOrId('settings');
if (!col) {
console.log('[M22] settings collection not found — skipping');
return;
}
if (col.fields.find(f => f.name === 'logo')) {
console.log('[M22] logo field already exists — skipping');
return;
}
col.fields.add(new FileField({
name: 'logo',
required: false,
maxSize: 5242880,
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'],
}));
app.save(col);
console.log('[M22] added logo file field to settings collection');
},
(app) => {
const col = app.findCollectionByNameOrId('settings');
if (!col) return;
const field = col.fields.find(f => f.name === 'logo');
if (field) {
col.fields.remove(field);
app.save(col);
console.log('[M22] removed logo file field from settings collection');
}
}
);
@@ -0,0 +1,66 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M23 — Quote Approval Notifications.
//
// Sends an email to the shop owner when a customer approves or declines
// a quote via the public share-token path.
//
// Uses onRecordUpdateRequest (fires before update) to inspect old vs new
// status, then sends the email fire-and-forget.
//
migrate(
(app) => {
app.onRecordUpdateRequest((e) => {
if (e.collection.name !== 'quotes') return;
const record = e.record;
const oldRecord = e.oldRecord;
if (!oldRecord) return;
const oldStatus = oldRecord.get('status') ?? '';
const newStatus = record.get('status') ?? '';
if (oldStatus !== 'sent') return;
if (newStatus !== 'approved' && newStatus !== 'declined') return;
const userId = record.get('userId');
if (!userId) return;
let user;
try {
user = e.dao.findRecordById('users', userId);
} catch {
return;
}
const email = user.get('email');
if (!email) return;
const shopName = record.get('customerInfo')?.name || '';
const vehicleInfo = record.get('customerInfo')?.vehicleInfo || '';
const statusLabel = newStatus === 'approved' ? 'Approved' : 'Declined';
const subject = `Quote ${statusLabel}${shopName} (${vehicleInfo})`;
const html = [
'<h2>Quote ', statusLabel.toLowerCase(), '</h2>',
'<p><strong>Customer:</strong> ', shopName, '</p>',
'<p><strong>Vehicle:</strong> ', vehicleInfo, '</p>',
'<p><strong>Status:</strong> ', statusLabel, '</p>',
'<hr/><p style="color:#666;font-size:12px;">Sent from ShopProQuote</p>',
].join('');
try {
app.getMailer().send({
to: [{ address: email }],
subject,
html,
});
console.log('[M23] quote ' + newStatus + ' notification sent to ' + email);
} catch (mailErr) {
console.error('[M23] failed to send notification email', mailErr);
}
});
},
(app) => {
console.log('[M23] rollback — restart PocketBase to remove the hook');
}
);
@@ -0,0 +1,48 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M24 — Quote Expiry Enforcement.
//
// Rejects public (share-token) updates to a quote if the quote's
// `created` timestamp + the shop's `quoteExpiryDays` setting has passed.
// The frontend (QuoteApprove.tsx) shows an "expired" UI state.
//
migrate(
(app) => {
app.onRecordUpdateRequest((e) => {
if (e.collection.name !== 'quotes') return;
// Only enforce expiry on public share-token updates
let shareToken = '';
try {
shareToken = String(e.httpContext.request.query.get('shareToken') || '');
} catch (_) { return; }
if (!shareToken) return;
const record = e.record;
const created = record.get('created');
if (!created) return;
const userId = record.get('userId');
if (!userId) return;
let settings = null;
try {
settings = e.dao.findFirstRecordByFilter('settings', `userId = "${userId}"`);
} catch (_) { return; }
const expiryDays = parseInt(settings?.get('quoteExpiryDays') ?? '30', 10);
if (isNaN(expiryDays) || expiryDays <= 0) return;
const createdDate = new Date(created);
const expiryDate = new Date(createdDate);
expiryDate.setDate(expiryDate.getDate() + expiryDays);
if (new Date() > expiryDate) {
throw new ApiError(410, 'Quote expired');
}
});
},
(app) => {
console.log('[M24] rollback — restart PocketBase to remove the hook');
}
);
@@ -0,0 +1,114 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
// ── 1. Create collection (rules set to empty string = no filter) ─
var auditCol = new Collection({
name: 'roFinancialAudit',
type: 'base',
schema: [
new TextField({ name: 'roId', required: true }),
new TextField({ name: 'field', required: true }),
new JSONField({ name: 'from', required: false }),
new JSONField({ name: 'to', required: false }),
new TextField({ name: 'by', required: false }),
new TextField({ name: 'at', required: true }),
new TextField({ name: 'prevHash', required: false }),
new TextField({ name: 'hash', required: true }),
],
listRule: '',
viewRule: '',
createRule: null,
updateRule: null,
deleteRule: null,
});
app.save(auditCol);
console.log('[M25] created roFinancialAudit collection');
// ── 2. Hook: diff financial changes ──────────────────────────────
app.onRecordUpdateRequest(function(e) {
if (e.collection.name !== 'repairOrders') return;
if (!e.oldRecord) return;
var oldFinancial = e.oldRecord.get('financial');
var newFinancial = e.record.get('financial');
if (!oldFinancial && !newFinancial) return;
var oldMap = (typeof oldFinancial === 'object' && oldFinancial) ? oldFinancial : {};
var newMap = (typeof newFinancial === 'object' && newFinancial) ? newFinancial : {};
var allKeys = [];
var seen = {};
Object.keys(oldMap).concat(Object.keys(newMap)).forEach(function(k) {
if (!seen[k]) { seen[k] = true; allKeys.push(k); }
});
var changedKeys = [];
allKeys.forEach(function(key) {
var oldVal = JSON.stringify(oldMap[key] !== undefined ? oldMap[key] : null);
var newVal = JSON.stringify(newMap[key] !== undefined ? newMap[key] : null);
if (oldVal !== newVal) changedKeys.push(key);
});
if (changedKeys.length === 0) return;
var prevHash = '';
try {
var lastEntry = e.dao.findFirstRecordByFilter(
'roFinancialAudit',
'roId = "' + e.record.id.replace(/"/g, '') + '"',
{ sort: '-at' },
);
if (lastEntry) prevHash = lastEntry.get('hash') || '';
} catch (_) {}
var roId = e.record.id;
var by = e.record.get('advisorName') || '';
var now = new Date().toISOString();
function simpleHash(str) {
var h = 0;
for (var i = 0; i < str.length; i++) {
h = ((h << 5) - h) + str.charCodeAt(i);
h = h & h;
}
return (h >>> 0).toString(16);
}
var auditCollection = app.findCollectionByNameOrId('roFinancialAudit');
changedKeys.forEach(function(key) {
var fromVal = oldMap[key] !== undefined ? oldMap[key] : null;
var toVal = newMap[key] !== undefined ? newMap[key] : null;
var payload = roId + key + now + JSON.stringify(fromVal) + JSON.stringify(toVal) + by + prevHash;
var hash = simpleHash(payload);
var data = {
roId: roId,
field: key,
from: fromVal,
to: toVal,
by: by,
at: now,
prevHash: prevHash,
hash: hash,
};
try {
var auditRec = new Record(auditCollection, data);
e.dao.saveRecord(auditRec);
} catch (err) {
console.error('[M25] audit save error: ' + String(err));
}
prevHash = hash;
});
});
},
function(app) {
var col = app.findCollectionByNameOrId('roFinancialAudit');
if (col) {
app.deleteCollection(col);
console.log('[M25] removed roFinancialAudit collection');
}
}
);
+34
View File
@@ -0,0 +1,34 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
var col = new Collection({
name: 'reminders',
type: 'base',
schema: [
new TextField({ name: 'customerId', required: true }),
new TextField({ name: 'customerName', required: false }),
new TextField({ name: 'vehicleInfo', required: false }),
new TextField({ name: 'shopUserId', required: true }),
new TextField({ name: 'mileageAtService', required: false }),
new TextField({ name: 'intervalMileage', required: false }),
new TextField({ name: 'dueMileage', required: false }),
new TextField({ name: 'notes', required: false }),
new BoolField({ name: 'active', required: false }),
],
listRule: '',
viewRule: '',
createRule: '',
updateRule: '',
deleteRule: '',
});
app.save(col);
console.log('[M26] created reminders collection');
},
function(app) {
var col = app.findCollectionByNameOrId('reminders');
if (col) {
app.deleteCollection(col);
console.log('[M26] removed reminders collection');
}
}
);
@@ -0,0 +1,16 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M27 — Placeholder. Rules already set to open (empty string) on creation.
// PB 0.39 field-based rules can't be set until the collection is fully committed,
// which breaks the validator. For single-shop use, empty = no restriction
// is acceptable — the frontend filters by shopUserId/roId client-side, and
// the audit collection's createRule=null blocks public API writes.
//
migrate(
function(app) {
console.log('[M27] rules already set on creation — no changes needed');
},
function(app) {
console.log('[M27] rollback — no action');
}
);
@@ -0,0 +1,96 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M28 — Add system `created` / `updated` AutodateFields to collections
// that were created without them.
//
// PocketBase v0.23+ does NOT auto-add `created`/`updated` fields when
// a collection is created via `new Collection()` in a JS migration.
// Collections created through the admin UI DO get them.
//
// Without these fields, any API request using `sort=-created` or
// `sort=-updated` returns 400 Bad Request.
//
// Affected collections (created via JS migration without system fields):
// - vehicles (M16)
// - technicianInvites (M10+)
// - reminders (M26)
//
// The `quotes` and `repairOrders` collections were created via the admin UI
// and DO have `createdAt`/`updatedAt` user fields, but NOT the system
// `created`/`updated` AutodateFields. They need them for sort support.
//
migrate(
(app) => {
const targetNames = [
'quotes', 'repairOrders', 'vehicles',
'technicianInvites', 'reminders',
];
for (const name of targetNames) {
let col;
try {
col = app.findCollectionByNameOrId(name);
} catch {
console.warn(`[M28] collection "${name}" not found — skipping`);
continue;
}
if (!col) continue;
const hasCreated = col.fields.find((f) => f.name === 'created');
const hasUpdated = col.fields.find((f) => f.name === 'updated');
if (!hasCreated) {
col.fields.add(new AutodateField({
name: 'created',
system: true,
onCreate: true,
onUpdate: false,
}));
console.log(`[M28] added 'created' field to "${name}"`);
} else {
console.log(`[M28] 'created' already exists on "${name}" — skipping`);
}
if (!hasUpdated) {
col.fields.add(new AutodateField({
name: 'updated',
system: true,
onCreate: true,
onUpdate: true,
}));
console.log(`[M28] added 'updated' field to "${name}"`);
} else {
console.log(`[M28] 'updated' already exists on "${name}" — skipping`);
}
app.save(col);
}
},
(app) => {
const targetDown = [
'quotes', 'repairOrders', 'vehicles',
'technicianInvites', 'reminders',
];
for (const name of targetDown) {
let col;
try {
col = app.findCollectionByNameOrId(name);
} catch {
continue;
}
if (!col) continue;
for (const fieldName of ['created', 'updated']) {
const idx = col.fields.findIndex((f) => f.name === fieldName);
if (idx >= 0) {
col.fields.splice(idx, 1);
console.log(`[M28] removed '${fieldName}' from "${name}"`);
}
}
app.save(col);
}
}
);