initial commit
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
# PocketBase Schema Migrations
|
||||
|
||||
These changes are recommended by the spq-v2 code review (2026-06-30). They
|
||||
**alter the live database** and so must be applied to your **separate
|
||||
PocketBase install** (PocketBase does not live inside this repo).
|
||||
|
||||
## Two ways to apply
|
||||
|
||||
### Option A — Runnable JS migrations (recommended)
|
||||
Drop-in `.js` files are included in [`../pb_migrations/`](../pb_migrations/).
|
||||
Copy that entire folder into your PocketBase install's `pb_migrations/`
|
||||
directory and run:
|
||||
|
||||
```bash
|
||||
# from your PocketBase install directory
|
||||
./pocketbase migrate
|
||||
./pocketbase migrate up # apply pending migrations
|
||||
./pocketbase migrate collections # re-sync collection schema
|
||||
```
|
||||
|
||||
Files (apply in filename order):
|
||||
|
||||
| File | What |
|
||||
|------|------|
|
||||
| `1739999000001_add_customerType_to_repairOrders.js` | M1 — `customerType` text column + backfill from `financial` blob |
|
||||
| `1739999000002_add_estimatedDuration_to_repairOrders.js` | M2 — `estimatedDuration` (integer minutes) + backfill from `estimatedTime` |
|
||||
| `1739999000003_promote_financial_fields.js` | M3 — `grossTotal` / `grossCost` / `warrTotal` / `warrCost` / `shopCharge` numeric columns + backfill |
|
||||
| `1739999000004_user_scoped_api_rules.js` | M4 — per-user-scope List/View/Create/Update/Delete rules on all user-collections (security) |
|
||||
| `1739999000005_unique_roNumber.js` | M5 — unique index on `repairOrders.roNumber` (server-side collision guard) |
|
||||
| `1739999000006_quote_ro_link_columns.js` | M6 — `quotes.repairOrderId` + `repairOrders.quoteId` link columns (bidirectional RO↔Quote back-links) |
|
||||
|
||||
Each migration is **idempotent** (safe to re-run) and includes an `up` and
|
||||
`down` hook so `./pocketbase migrate down` cleanly reverses it.
|
||||
|
||||
### Option B — Manual via admin UI
|
||||
The next sections spell out each change for the admin UI. Use these if you
|
||||
prefer clicking through PocketBase Admin → Collections → ⚙.
|
||||
|
||||
---
|
||||
|
||||
## What the migrations do
|
||||
these columns are absent, but promoting them unlocks correct persistence,
|
||||
queryable aggregation, and atomic updates.
|
||||
|
||||
Apply each change in the **PocketBase Admin → Collections → repairOrders → Edit
|
||||
collection** UI. After adding a field, backfill existing rows as noted.
|
||||
|
||||
---
|
||||
|
||||
## M1 — `customerType` (promote from `financial` JSON blob)
|
||||
|
||||
**Today**: `customerType` lives only inside the stringified `financial` JSON
|
||||
blob. The frontend synthesizes it on read (`RepairOrders.fetchOrders`), which
|
||||
breaks whenever the blob is corrupt or missing.
|
||||
|
||||
**Add field**:
|
||||
- Name: `customerType`
|
||||
- Type: `Text`
|
||||
- Options: `Pattern` (optional) — restrict to `waiter|drop-off`
|
||||
|
||||
**Backfill** (run once in Admin → Collections → repairOrders → run a small
|
||||
script, or use the API):
|
||||
|
||||
```js
|
||||
// Node script — run against your PocketBase instance
|
||||
import PocketBase from 'pocketbase';
|
||||
const pb = new PocketBase('http://localhost:8091');
|
||||
await pb.admins.authWithPassword('admin@example.com', 'PASSWORD');
|
||||
|
||||
const all = await pb.collection('repairOrders').getFullList({ batch: 500 });
|
||||
for (const ro of all) {
|
||||
let fin = {};
|
||||
try { fin = typeof ro.financial === 'string' ? JSON.parse(ro.financial) : (ro.financial || {}); } catch {}
|
||||
const ct = fin.customerType === 'waiter' ? 'waiter' : 'drop-off';
|
||||
await pb.collection('repairOrders').update(ro.id, { customerType: ct });
|
||||
}
|
||||
```
|
||||
|
||||
After backfill, the redundant read/merge logic in
|
||||
`RepairOrders.fetchOrders` (lines ~1779-1787) and `handleToggleCustomerType`'s
|
||||
"persist into financial blob" path (~1842-1856) can be simplified to plain
|
||||
column updates. Leaving them as-is is safe — they will simply keep the blob in
|
||||
sync, which is harmless. **A future cleanup PR can remove the blob round-trip
|
||||
once the column is confirmed populated.**
|
||||
|
||||
---
|
||||
|
||||
## M2 — `estimatedDuration` (integer minutes; fixes lossy round-trip)
|
||||
|
||||
**Today**: stored as `estimatedTime` (hours string, e.g. `"0.3"`), converted
|
||||
back to minutes via `Math.round(hours * 60)` on read. 15 min → "0.3" → 18 min
|
||||
on every reload, drifting the due time.
|
||||
|
||||
**Add field**:
|
||||
- Name: `estimatedDuration`
|
||||
- Type: `Number`
|
||||
- Options: `Min`: 0, `Max`: empty (or 10080 = 7 days)
|
||||
|
||||
**Backfill**:
|
||||
|
||||
```js
|
||||
const all = await pb.collection('repairOrders').getFullList({ batch: 500 });
|
||||
for (const ro of all) {
|
||||
if (ro.estimatedDuration != null && ro.estimatedDuration !== '') continue;
|
||||
const hours = parseFloat(ro.estimatedTime || '0') || 0;
|
||||
await pb.collection('repairOrders').update(ro.id, {
|
||||
estimatedDuration: Math.round(hours * 60),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Frontend follow-up** (NOT yet applied — pending this migration): switch
|
||||
`RepairOrders.fetchOrders`, `handleSave`, and `handleAddTime` to read/write
|
||||
`estimatedDuration` directly and drop the `/ 60` ↔ `* 60` conversions. Until
|
||||
then the current code keeps writing `estimatedTime` and synthesizing
|
||||
`estimatedDuration` on read, so no data is lost.
|
||||
|
||||
---
|
||||
|
||||
## M3 — Promote financial fields out of the `financial` JSON blob
|
||||
|
||||
**Today**: `grossTotal`, `grossCost`, `warrTotal`, `warrCost`, `shopCharge`,
|
||||
and `satisfaction` are partly real columns (`satisfaction` already is) and
|
||||
partly loose JSON keys inside `financial`. The `FinancialDashboard` therefore
|
||||
must load **every** completed RO to the client to aggregate — it cannot run a
|
||||
server-side `sum()` over a JSON blob.
|
||||
|
||||
**Add fields** (all type `Number`, min 0):
|
||||
- `grossTotal`, `grossCost`
|
||||
- `warrTotal`, `warrCost`
|
||||
- `shopCharge` *(this overlaps conceptually with the existing `shopCharges`
|
||||
column — confirm which one the dashboard should sum and consolidate)*
|
||||
|
||||
**Backfill**:
|
||||
|
||||
```js
|
||||
const all = await pb.collection('repairOrders').getFullList({ batch: 500 });
|
||||
for (const ro of all) {
|
||||
let fin = {};
|
||||
try { fin = typeof ro.financial === 'string' ? JSON.parse(ro.financial) : (ro.financial || {}); } catch {}
|
||||
const patch = {
|
||||
grossTotal: Number(fin.grossTotal) || 0,
|
||||
grossCost: Number(fin.grossCost) || 0,
|
||||
warrTotal: Number(fin.warrTotal) || 0,
|
||||
warrCost: Number(fin.warrCost) || 0,
|
||||
shopCharge: Number(fin.shopCharge) || 0,
|
||||
};
|
||||
await pb.collection('repairOrders').update(ro.id, patch);
|
||||
}
|
||||
```
|
||||
|
||||
**Frontend follow-up**: after this migration, `ExpandedDetail.saveFinField`
|
||||
should write directly to the typed columns (and can stop stringifying the
|
||||
blob), and `FinancialDashboard` can issue a single `getFullList` with
|
||||
`fields` projection + client-side sum, or better, a server-side aggregation
|
||||
via PocketBase's `aggregate` API (0.20+) for monthly revenue/costs. This
|
||||
collapses thousands of records to one request.
|
||||
|
||||
---
|
||||
|
||||
## M4 — API Rules (security gate — apply even if you skip everything else)
|
||||
|
||||
Each user-scoped collection (`quotes`, `repairOrders`, `invoices`,
|
||||
`appointments`, `customers`, `services`, `settings`) MUST have API rules that
|
||||
scope every record to the authenticated owner. Without these, any logged-in
|
||||
user can read/write every other shop's data by passing a different `userId`
|
||||
field in the request body.
|
||||
|
||||
For each collection, in **Admin → Collections → ⚙ → API Rules**:
|
||||
|
||||
| Action | Rule |
|
||||
|--------|------|
|
||||
| List / View | `userId = @request.auth.id` |
|
||||
| Create | `userId = @request.auth.id` |
|
||||
| Update | `userId = @request.auth.id` |
|
||||
| Delete | `userId = @request.auth.id` |
|
||||
|
||||
The `users` collection itself should restrict List/View to admins only.
|
||||
|
||||
Verify with two test users — user A must NOT be able to `getOne(<B's record id>)`.
|
||||
|
||||
---
|
||||
|
||||
## M6 — Quote ↔ Repair Order link columns (bidirectional back-links)
|
||||
|
||||
**Today**: RO→Quote and Quote→RO conversions stamp each other's record IDs
|
||||
client-side, but there are no backing columns — the links are invisible to
|
||||
queries and break if a record is re-imported.
|
||||
|
||||
**Add fields**:
|
||||
- On `quotes`: `repairOrderId` — type `Text`, optional. Stores the RO id that
|
||||
originated this quote (set when the user clicks "Generate Quote" on an RO).
|
||||
- On `repairOrders`: `quoteId` — type `Text`, optional. Stores the quote id that
|
||||
was converted into this RO (set when the user clicks "Convert to RO" on a
|
||||
quote).
|
||||
|
||||
No backfill needed — existing records simply have empty values.
|
||||
|
||||
**Frontend follow-up**: after M6 is applied, the `handleConvertQuoteToRO` and
|
||||
`onGenerateQuote` handlers can stop encoding the link in the `notes` field and
|
||||
use the real columns instead.
|
||||
|
||||
---
|
||||
|
||||
## Applying & verifying
|
||||
|
||||
1. **Backup first**: `./pocketbase backup create` (or copy the SQLite file).
|
||||
2. Run **Option A** migrations in order, OR apply M4 (rules) manually first
|
||||
— it has zero data migration and closes the cross-tenant hole.
|
||||
3. Apply M1 → M2 → M3 → M5 → M6 in filename order (the JS files handle backfill
|
||||
inside their `up` hooks).
|
||||
4. After each migration, reload the spq-v2 app and confirm the corresponding
|
||||
screen still renders existing data correctly.
|
||||
5. Once M1 + M2 are confirmed in production, open a follow-up issue to clean
|
||||
up the now-redundant frontend compatibility code (noted inline above).
|
||||
Reference in New Issue
Block a user