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
+215
View File
@@ -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).