initial commit
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user