initial commit
This commit is contained in:
@@ -0,0 +1,912 @@
|
||||
---
|
||||
name: pocketbase-development
|
||||
description: "PocketBase patterns: collection schemas, API quirks, sort-field traps, useToast provider, auth flow."
|
||||
version: 1.2.0
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [pocketbase, backend, collections, debugging]
|
||||
related_skills: []
|
||||
---
|
||||
|
||||
# PocketBase Development
|
||||
|
||||
## Overview
|
||||
|
||||
Common patterns, pitfalls, and fixes when building React apps backed by PocketBase. Covers collection creation, API query patterns, auth integration, SPQ legacy data model, and runtime debugging.
|
||||
|
||||
> **Reference:** `references/customer-db-integration.md` documents the SPQ Customer DB integration pattern: name splitting (firstName/middleName/lastName with backward-compat auto-compose), customer record auto-creation from quote/appointment/RO saves (`ensureCustomerRecord`), client-side duplicate detection, customerId pass-through across quotes → ROs → appointments, and multi-source data population (vehicles → quotes → ROs) when selecting a customer.
|
||||
>
|
||||
> **Reference:** `references/spq-legacy-data-model.md` documents the actual PocketBase schema for the ShopProQuote project — collection field names, vehicle data storage (no separate `vehicles` collection), the combine-and-deduplicate pattern across repairOrders/quotes, `customerName` vs `customerId` linkage (ALL existing ROs and quotes have empty `customerId`), quote services JSON display patterns (expandable rows with service line items), and migration patterns from legacy flat-text fields.
|
||||
>
|
||||
> **See also:** `pocketbase-react-apps/references/spq-pb-field-mapping.md` for the critical frontend-to-PB field name translation table (estimatedDuration → estimatedTime, customerType → financial JSON). Saves to wrong field names are silently dropped by PocketBase.
|
||||
>
|
||||
> **Reference:** `references/glitchtip-sentry-dsn-path-prefix.md` — Sentry SDK v10 DSN parser rejects multi-segment paths (`/glitchtip/1`). Fix: change DSN to bare project ID (`/1`) and add nginx regex location.
|
||||
>
|
||||
> **Reference:** `references/cross-origin-debugging.md` — Debugging the same React+PB app behaving differently on two domains (e.g., `shopproquote.graj-media.com` vs `grajmedia.duckdns.org`). Nginx config check → identical code → same backend → client-side localStorage stale state. Covers Zod validation failure tracing and the `spq-quote` localStorage key.
|
||||
>
|
||||
> **Reference:** `references/quote-to-ro-approved-services-sync.md` — Syncing approved quote services back to the originating repair order when saving a quote generated from an RO (`?fromRO=RO_ID`). Covers the merge logic (same-name update vs append), status preservation, and the two save paths (`handleSave` and `ensureShareToken`).
|
||||
|
||||
## Pitfalls
|
||||
|
||||
### Filter `~` (contains) operator can return 400 on some collections
|
||||
|
||||
PocketBase 0.39.1 may return `400 Bad Request` for the `~` (contains) filter operator on text fields in certain collections — even when the field and syntax are valid. The error message is the generic `"Something went wrong while processing your request."` with no field-level detail. This was observed on the `customers` collection's `name` and `phone` fields while the same SDK version handles `=` on `userId` without issue.
|
||||
|
||||
**Fix:** For collections with small record counts (single-shop data: customers, settings, etc.), skip server-side filters entirely and filter client-side:
|
||||
|
||||
```typescript
|
||||
// ❌ Server-side filter — may return 400 for unknown reasons
|
||||
const result = await pb.collection('customers').getList(1, 10, {
|
||||
filter: `name ~ '${query}'`, // may 400 on some collections
|
||||
fields: 'id,name,phone',
|
||||
});
|
||||
|
||||
// ✅ Client-side filter — fetch all + filter in JS
|
||||
const result = await pb.collection('customers').getList(1, 500, {
|
||||
fields: 'id,name,phone',
|
||||
batch: 500,
|
||||
});
|
||||
const matched = (result.items as any[]).filter((c: any) =>
|
||||
(c.name || '').toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
```
|
||||
|
||||
The `batch: 500` parameter fetches all records in a single request (PocketBase's default max batch is 500). For single-shop deployments this is always fast. Client-side filtering also avoids issues with escaping, case sensitivity, and operator support across PB versions.
|
||||
|
||||
**Key insight:** PocketBase's access rules (`userId = @request.auth.id`) are applied server-side automatically even without a `filter` parameter, so fetching all records and filtering client-side is a safe and reliable pattern for user-scoped data.
|
||||
|
||||
### sort `-created` fails with 400 on collections created via JS migrations
|
||||
|
||||
PocketBase v0.23+ does NOT auto-add system `created`/`updated` AutodateFields when a collection is created via `new Collection()` in a JS migration. Collections created through the admin UI DO get them. Using `sort: '-created'`, `sort: '-updated'`, or requesting `fields: '...,created,...'` in `getList()` queries on these collections returns a 400 error: "Something went wrong while processing your request."
|
||||
|
||||
**Workaround:** Use `sort: '-id'` instead. PocketBase IDs are time-sortable KSUIDs, so sorting by `-id` gives most-recent-first behavior.
|
||||
|
||||
**Proper fix (migration):** Add `AutodateField` instances to the affected collections:
|
||||
|
||||
```javascript
|
||||
col.fields.add(new AutodateField({
|
||||
name: 'created',
|
||||
system: true,
|
||||
onCreate: true,
|
||||
onUpdate: false,
|
||||
}));
|
||||
col.fields.add(new AutodateField({
|
||||
name: 'updated',
|
||||
system: true,
|
||||
onCreate: true,
|
||||
onUpdate: true,
|
||||
}));
|
||||
app.save(col);
|
||||
```
|
||||
|
||||
**Diagnosis steps when you see generic 400 errors on PB getList/getFullList:**
|
||||
|
||||
When PocketBase returns the generic `{"data":{},"message":"Something went wrong while processing your request.","status":400}`, the cause can be any query parameter (filter, sort, fields) referencing a non-existent field. Isolate the culprit systematically:
|
||||
|
||||
1. **Test without sort and fields first:**
|
||||
```
|
||||
GET /api/collections/{name}/records?perPage=1
|
||||
```
|
||||
If this works, the collection exists and is accessible.
|
||||
|
||||
2. **Add filter only:**
|
||||
```
|
||||
GET /api/collections/{name}/records?filter=customerId='test'&perPage=1
|
||||
```
|
||||
If this fails, the filter references a non-existent field.
|
||||
|
||||
3. **Add sort only:**
|
||||
```
|
||||
GET /api/collections/{name}/records?sort=-created&perPage=1
|
||||
```
|
||||
If this fails, the sort column doesn't exist on the collection.
|
||||
|
||||
4. **Add fields only:**
|
||||
```
|
||||
GET /api/collections/{name}/records?fields=year,make,model&perPage=1
|
||||
```
|
||||
If this fails, one of the requested fields doesn't exist.
|
||||
|
||||
5. **Check the actual SQLite schema:**
|
||||
```bash
|
||||
# Host path — use docker inspect if unsure where the volume is
|
||||
sqlite3 /path/to/pb_data/data.db "PRAGMA table_info(collectionName);"
|
||||
# Or find the volume mount path via docker inspect (faster than find on large filesystems)
|
||||
docker inspect pocketbase | python3 -c "
|
||||
import json, sys
|
||||
data = json.load(sys.stdin)
|
||||
for mount in data[0].get('Mounts', []):
|
||||
if 'pb_data' in mount.get('Destination', ''):
|
||||
print(mount['Source'])
|
||||
break
|
||||
"
|
||||
```
|
||||
Or check the `_collections` table's `fields` JSON:
|
||||
```bash
|
||||
sqlite3 /path/to/pb_data/data.db "SELECT fields FROM _collections WHERE name='collectionName';" | python3 -c "import json, sys; [print(f['name']) for f in json.load(sys.stdin)]"
|
||||
```
|
||||
Note: the `_collections` table has a `fields` column (not `schema`) that contains the full JSON array of field definitions.
|
||||
|
||||
6. **Verify with a `sort: '-id'` test** — PocketBase IDs are time-sortable KSUIDs, so `-id` sort is always available:
|
||||
```
|
||||
GET /api/collections/{name}/records?sort=-id&perPage=1
|
||||
```
|
||||
If this works but `sort=-created` doesn't, the collection is missing the system `created`/`updated` fields.
|
||||
|
||||
**Real-world affected collections (SPQ v2 project — all confirmed missing `created`/`updated` via PRAGMA table_info):**
|
||||
|
||||
| Collection | Created via | Missing fields? |
|
||||
|---|---|---|
|
||||
| `vehicles` | M16 JS migration (`new Collection()`) | Missing `created` and `updated` |
|
||||
| `technicianInvites` | JS migration | Missing `created` and `updated` |
|
||||
| `reminders` | JS migration (M26) | Missing `created` and `updated` |
|
||||
| `quotes` | Admin UI | Has `createdAt`/`updatedAt` user fields but NO system `created`/`updated` |
|
||||
| `repairOrders` | Admin UI | Has `createdAt`/`updatedAt` user fields but NO system `created`/`updated` |
|
||||
|
||||
All five were fixed in M28 by adding `AutodateField({ name: 'created', system: true, onCreate: true })` and `AutodateField({ name: 'updated', system: true, onCreate: true, onUpdate: true })`.
|
||||
|
||||
**Defensive fallback pattern when `created` is missing:** If the collection has a user-defined timestamp field like `createdAt`, sort by that instead:
|
||||
|
||||
```typescript
|
||||
// ✅ Works even before M28 — createdAt is a user-defined text field
|
||||
sort: '-createdAt'
|
||||
|
||||
// ❌ Fails on collections missing system created field
|
||||
sort: '-created'
|
||||
```
|
||||
|
||||
This pattern is used in `CustomerInfoPanel.tsx` for the quotes and repairOrders fallback queries. The `vehicles` query keeps `-created` because its try/catch wrapper handles failures gracefully while the migration is pending.
|
||||
|
||||
### Collection naming: camelCase not snake_case
|
||||
|
||||
PocketBase SDK auto-creates collections with camelCase names. A Firebase-based app may use snake_case (`repair_orders`) while the actual PB collection is `repairOrders`. Always verify collection names against the live PB instance before writing queries.
|
||||
|
||||
### useToast requires ToastProvider
|
||||
|
||||
The `useToast()` hook from `../components/ui/Toast` throws at runtime if `<ToastProvider>` is not wrapping the component tree. Always wrap `<BrowserRouter>` in `<ToastProvider>` in `App.tsx`:
|
||||
|
||||
```tsx
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<Routes>...</Routes>
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
```
|
||||
|
||||
### Vite build exit code 1 is often a false positive
|
||||
|
||||
Rolldown can emit non-fatal chunk-size warnings that cause exit code 1 even when the build succeeded. Check `dist/index.html` exists instead of trusting the exit code:
|
||||
```bash
|
||||
ls -la dist/index.html # if it exists, build succeeded
|
||||
```
|
||||
|
||||
### Creating collections without admin access
|
||||
|
||||
Non-admin PocketBase users cannot create collections via the REST API (returns 403). Collections must be created by an admin through the PocketBase dashboard at `http://host:8091/_/` or via admin-authenticated API calls.
|
||||
|
||||
### Filtering by userId on non-relational fields
|
||||
|
||||
When using `userId` as a plain text field (not a relation), the filter must use exact match:
|
||||
```typescript
|
||||
pb.collection('quotes').getList(1, 50, {
|
||||
filter: `userId = '${userId}'`,
|
||||
sort: '-id',
|
||||
});
|
||||
```
|
||||
|
||||
### Restrictive `fields` parameter can break queries
|
||||
|
||||
Using a `fields` parameter on `getList()` that lists fields not present on the collection will cause a 400 error (not just omit them). When in doubt, omit `fields` entirely and let PB return all fields:
|
||||
|
||||
```typescript
|
||||
// ❌ Breaks if any field in the list doesn't exist
|
||||
pb.collection('appointments').getList(1, 200, {
|
||||
fields: 'id,customerName,date,time,status,created,updated',
|
||||
});
|
||||
|
||||
// ✅ Safe — returns whatever fields exist
|
||||
pb.collection('appointments').getList(1, 200, {
|
||||
sort: '-id',
|
||||
});
|
||||
```
|
||||
|
||||
Same applies to `sort`: using `sort: 'date,time'` (two bare field names) may fail. Use the `+` or `-` prefix syntax: `sort: '-date,+time'` or just `sort: '-id'`.
|
||||
|
||||
### getFullList — load ALL records in one call (no pagination)
|
||||
|
||||
Use `getFullList()` to bypass the paged `getList()` pattern when you always want every matching record:
|
||||
|
||||
```typescript
|
||||
// ❌ Paginated — requires "Load more" button + hasMore state
|
||||
const records = await pb.collection('repairOrders').getList(page, perPage, {
|
||||
filter: `userId = '${userId}'`,
|
||||
sort: '-id',
|
||||
});
|
||||
// items in records.items, check records.totalPages for hasMore
|
||||
|
||||
// ✅ Un-paginated — returns ALL matching records in one array
|
||||
const records = await pb.collection('repairOrders').getFullList({
|
||||
filter: `userId = '${userId}'`,
|
||||
sort: '-id',
|
||||
});
|
||||
// items returned directly as an array (no .items wrapper)
|
||||
```
|
||||
|
||||
**When to use each:**
|
||||
|
||||
| Pattern | Use case | Return shape |
|
||||
|---------|----------|-------------|
|
||||
| `getList(page, perPage)` | Data you **want** paginated (big collections, server-side pages, "Load more" UX) | `{ items, page, perPage, totalItems, totalPages }` — items in `.items` |
|
||||
| `getFullList({...})` | Small-to-medium collections you always want entirely in memory (typical single-shop data: ROs, customers, quotes) | Plain `T[]` — items returned directly from the SDK |
|
||||
|
||||
**Teardown the `usePagedList` hook when going un-paginated:**
|
||||
|
||||
If your app uses a `usePagedList<T>(fetchPage, perPage)` hook (like SPQ v2's `src/hooks/usePagedList.ts`), switching `fetchPage` from `getList` to `getFullList` means you can:
|
||||
|
||||
1. Change `fetchPage` to return a single-page result:
|
||||
```typescript
|
||||
const fetchAll = useCallback(async () => {
|
||||
const records = await pb.collection('repairOrders').getFullList({ filter: `userId = '${userId}'` });
|
||||
return { items: records, page: 1, perPage: 999999, totalItems: records.length, totalPages: 1 };
|
||||
}, [userId]);
|
||||
```
|
||||
2. `hasMore` becomes always `false` (page 1 of 1 total pages)
|
||||
3. The "Load more" button never renders
|
||||
4. `refresh` re-fetches the full list — no accumulator state to manage
|
||||
|
||||
### JSON text fields: PocketBase may auto-parse or return raw strings
|
||||
|
||||
PocketBase stores JSON data as TEXT in the SQLite database. When the API returns these fields, **they may arrive as either a parsed JavaScript object/array OR as a raw JSON string**, depending on how the SDK serializes them. Always normalize:
|
||||
|
||||
```typescript
|
||||
let items = raw;
|
||||
if (typeof raw === 'string') {
|
||||
try { items = JSON.parse(raw); } catch { items = []; }
|
||||
}
|
||||
if (!Array.isArray(items)) items = [];
|
||||
```
|
||||
|
||||
This applies to the `services` field on both `quotes` and `repairOrders` collections. A `parseQuoteServices` function that only handles strings will silently return empty for pre-parsed arrays.
|
||||
|
||||
### Row-level security blocks cross-user data access
|
||||
|
||||
All SPQ collections use the access rule:
|
||||
```
|
||||
listRule: userId = @request.auth.id
|
||||
viewRule: userId = @request.auth.id
|
||||
```
|
||||
|
||||
This means **a user can only see records where `userId` matches their own auth ID**. If `demo@shop.com` logs in, they see zero customers/quotes/ROs because the data belongs to `mani8994@gmail.com`. The query filter doesn't matter — PocketBase applies the access rule first.
|
||||
|
||||
**Implications:**
|
||||
- You cannot query across users with a user-level auth token
|
||||
- To work around this for admin-style views, you need an admin/superuser token that bypasses the rules
|
||||
- Or modify the collection rules to allow broader access (e.g., remove the userId restriction, or add an OR condition)
|
||||
- The legacy site worked because it was deployed in the same PocketBase instance with the data owner's credentials
|
||||
|
||||
**To verify access:** Always test API calls directly with the authenticated user's token before assuming data exists. A 404 `"not found"` or empty result set often means the userId rule is blocking access.
|
||||
|
||||
### Detecting missing collections gracefully
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const records = await pb.collection('name').getList(1, 1);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '';
|
||||
if (msg.includes('not found') || msg.includes('Missing collection')) {
|
||||
setCollectionMissing(true); // show setup banner
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PocketBase SDK `import` keyword breaks Vite/esbuild
|
||||
|
||||
The PocketBase JS SDK (v0.27.0) uses `import` as a method name in `CollectionService.import()`. Both Vite's esbuild pre-bundler and the browser's native parser treat `import(` as a dynamic import expression and throw `Uncaught SyntaxError: Unexpected token '('`.
|
||||
|
||||
**Fix:** Patch `node_modules/pocketbase/dist/pocketbase.es.mjs` to rename the method:
|
||||
|
||||
```bash
|
||||
sed -i 's/async import(/async _import(/g' node_modules/pocketbase/dist/pocketbase.es.mjs
|
||||
```
|
||||
|
||||
Then add to `vite.config.ts` to serve the raw ESM file (skip pre-bundling):
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
optimizeDeps: {
|
||||
exclude: ['pocketbase'],
|
||||
},
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### Creating NEW collections via JS migrations (v0.39+)
|
||||
|
||||
The `new Collection({...})` constructor works in v0.39 JS migrations, but field-based API rules **cannot be set at all via JS migrations** — the rule validator runs against a schema snapshot that never catches up to include the new fields, even in a separate migration file with `findCollectionByNameOrId()`.
|
||||
|
||||
**✅ Working pattern — save with empty rules, skip field-based rules:**
|
||||
|
||||
```javascript
|
||||
migrate(
|
||||
(app) => {
|
||||
var col = new Collection({
|
||||
name: 'myNewCollection',
|
||||
type: 'base',
|
||||
schema: [
|
||||
new TextField({ name: 'fieldName', required: true }),
|
||||
new JSONField({ name: 'jsonField', required: false }),
|
||||
new BoolField({ name: 'active', required: false }),
|
||||
],
|
||||
listRule: '', // empty string = unrestricted (no filter)
|
||||
viewRule: '', // empty = unrestricted
|
||||
createRule: null, // null = no public API creates
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
});
|
||||
app.save(col);
|
||||
console.log('[MX] created collection');
|
||||
},
|
||||
function(app) {
|
||||
var col = app.findCollectionByNameOrId('myNewCollection');
|
||||
if (col) { app.deleteCollection(col); }
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**❌ Do NOT attempt to set field-referencing rules** (`shopUserId = @request.auth.id`, `roId = @request.query.roId`) in any migration file — it fails with `listRule: Invalid rule. Raw error: invalid left operand "fieldName" - unknown field "fieldName"`. This affects:
|
||||
- `app.save(col)` with rules set on the constructor object
|
||||
- `app.save(col)` with `.listRule = '...'` on the same collection object after a prior save
|
||||
- `app.save(col)` after `findCollectionByNameOrId()` in a completely separate migration file (M25+1)
|
||||
- Every technique tested on PB 0.39.1 with `app.save()` on collections created via JS migration
|
||||
|
||||
**Workaround for single-shop deployments:** The collections are usable without field-based rules — the frontend API queries filter by userId/shopUserId client-side, and `createRule: null` / `updateRule: null` / `deleteRule: null` prevents public API writes on write-protected collections (like the audit log). Accept that the collection's list/view rules are unrestricted at the API level and scope in the frontend.
|
||||
|
||||
**⚠️ REST API workaround:** If field-based rules are genuinely required, create the collection AND set rules via the REST API (admin auth token), not JS migrations:
|
||||
|
||||
```python
|
||||
# Step 1: POST the collection with rules directly (works via REST API!)
|
||||
result = api("POST", "/api/collections", {
|
||||
"name": "myCollection",
|
||||
"type": "base",
|
||||
"listRule": "shopUserId = @request.auth.id",
|
||||
"fields": [
|
||||
{"name": "shopUserId", "type": "text", "required": True},
|
||||
{"name": "customerName", "type": "text"},
|
||||
],
|
||||
}, token=admin_token)
|
||||
```
|
||||
|
||||
The REST API's schema validator works correctly because it validates against the full collection body before committing. Only the Goja JS migration runtime has this schema-snapshot limitation.
|
||||
|
||||
**Available field constructors (PB 0.39):**
|
||||
- `new TextField({ name, required })` — text values, also used for ISO date strings
|
||||
- `new JSONField({ name, required })` — JSON blobs (stored as TEXT in SQLite)
|
||||
- `new BoolField({ name, required })` — boolean
|
||||
- `new NumberField({ name, required })` — numeric
|
||||
- `new EmailField({ name, required })` — email
|
||||
- `new SelectField({ name, values, required })` — dropdown
|
||||
- `new FileField({ name, maxSize, allowedMimeTypes })` — file upload
|
||||
|
||||
**`DateTimeField` does NOT exist** — use `new TextField({ name: 'at', required: true })` and store ISO 8601 strings.
|
||||
|
||||
**Field names must be unique within a collection's schema.** The `find()` check `!col.fields.find(f => f.name === 'existingField')` guards against duplicate field attempts during re-migration.
|
||||
|
||||
### Creating records in migration hooks (v0.39+)
|
||||
|
||||
```javascript
|
||||
app.onRecordUpdateRequest(function(e) {
|
||||
if (e.collection.name !== 'sourceCollection') return;
|
||||
if (!e.oldRecord) return;
|
||||
|
||||
var col = app.findCollectionByNameOrId('targetCollection');
|
||||
var data = {
|
||||
sourceId: e.record.id,
|
||||
value: e.record.get('someField'),
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
var rec = new Record(col, data);
|
||||
e.dao.saveRecord(rec);
|
||||
} catch (err) {
|
||||
console.error('[MX] save error: ' + String(err));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
The `new Record(col, data)` constructor accepts a collection object and a plain data object. The record is persisted via `e.dao.saveRecord(rec)` (not `app.save()` or `rec.save()`).
|
||||
|
||||
### Hook API names (v0.39+)
|
||||
|
||||
| Hook name | Fires | Use case |
|
||||
|---|---|---|
|
||||
| `app.onRecordCreateRequest(e)` | Before create | Modify/validate new records |
|
||||
| `app.onRecordUpdateRequest(e)` | Before update | Check changes, send emails, write audit logs. **NOT** `onRecordBeforeUpdateRequest` or `onRecordAfterUpdateRequest` — those names don't exist. |
|
||||
| `app.onRecordDeleteRequest(e)` | Before delete | Prevent deletion, cleanup references |
|
||||
|
||||
`onRecordUpdateRequest` has access to `e.oldRecord` for diffing changes. This is the only hook needed for both pre-update validation and post-update notification — since it fires before the DB write, throw an `ApiError(code, message)` to reject the update, or add side-effects (email, audit records) that are best-effort.
|
||||
|
||||
The `e.httpContext` object exists on update hooks for accessing request data:
|
||||
```javascript
|
||||
var shareToken = '';
|
||||
try {
|
||||
shareToken = String(e.httpContext.request.query.get('shareToken') || '');
|
||||
} catch (_) { return; }
|
||||
```
|
||||
|
||||
### Throwing errors in hooks
|
||||
|
||||
```javascript
|
||||
if (new Date() > expiryDate) {
|
||||
throw new ApiError(410, 'Quote expired');
|
||||
}
|
||||
```
|
||||
|
||||
`throw new ApiError(statusCode, message)` rejects the request with the given HTTP status code and message. Valid in `onRecordCreateRequest`, `onRecordUpdateRequest`, and `onRecordDeleteRequest`. The frontend receives the status code in `err.status`.
|
||||
|
||||
### Sending emails from hooks (PB 0.39 mailer)
|
||||
|
||||
PB 0.39 SMTP must be configured via env vars (`PB_SMTP_ENABLED`, `PB_SMTP_HOST`, etc.). The mailer API:
|
||||
|
||||
```javascript
|
||||
try {
|
||||
app.getMailer().send({
|
||||
to: [{ address: 'user@example.com' }],
|
||||
subject: 'Quote Approved',
|
||||
html: '<h2>Quote approved</h2><p>Details...</p>',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[MX] email failed: ' + String(err));
|
||||
}
|
||||
```
|
||||
|
||||
The `to` field is an array of `{ address, name }` objects. `name` is optional. The mailer is fire-and-forget in hook context — if it fails, log and proceed.
|
||||
|
||||
### JS migration API: v0.22 vs v0.39+ incompatibility
|
||||
|
||||
PocketBase v0.23+ completely changed the embedded JS migration API. Old v0.22-style migration files silently skip on v0.39+.
|
||||
|
||||
**❌ OLD syntax (v0.22, does NOT work on v0.39+):**
|
||||
```javascript
|
||||
migrate((db) => {
|
||||
const col = db.findCollectionByNameOrId('users');
|
||||
const f = new CollectionField({
|
||||
name: 'role', type: 'select', values: ['advisor', 'technician', 'mobile']
|
||||
});
|
||||
col.fields.add(f);
|
||||
db.save(col);
|
||||
});
|
||||
```
|
||||
|
||||
**Error on v0.39+:** `ReferenceError: CollectionField is not defined`
|
||||
|
||||
**✅ REST API alternative (works on all versions >v0.23):**
|
||||
|
||||
When a JS migration file fails due to API incompatibility, use the REST API to PATCH the collection schema directly. This avoids writing Go-compatible JS migrations altogether.
|
||||
|
||||
```python
|
||||
import subprocess, json
|
||||
|
||||
# 1. Auth as superuser
|
||||
auth = subprocess.run([
|
||||
"curl", "-s", "-X", "POST",
|
||||
"http://127.0.0.1:8091/api/collections/_superusers/auth-with-password",
|
||||
"-H", "Content-Type: application/json",
|
||||
"-d", '{"identity":"admin@shop.com","password":"..."}'
|
||||
], capture_output=True, text=True, timeout=10)
|
||||
token = json.loads(auth.stdout)["token"]
|
||||
|
||||
# 2. GET current schema (must include ALL existing fields in PATCH payload)
|
||||
get_req = subprocess.run([
|
||||
"curl", "-s", "http://127.0.0.1:8091/api/collections/users",
|
||||
"-H", f"Authorization: Bearer *** ], capture_output=True, text=True, timeout=10)
|
||||
collection = json.loads(get_req.stdout)
|
||||
new_fields = list(collection["fields"])
|
||||
|
||||
# 3. Append new fields
|
||||
new_fields.append({"name": "role", "type": "select", "values": ["advisor","technician","mobile"]})
|
||||
new_fields.append({"name": "shopUserId", "type": "text"})
|
||||
new_fields.append({"name": "displayName", "type": "text"})
|
||||
|
||||
# 4. PATCH with full fields array (replace entire field list)
|
||||
subprocess.run([
|
||||
"curl", "-s", "-X", "PATCH", "http://127.0.0.1:8091/api/collections/users",
|
||||
"-H", f"Authorization: Bearer *** "-H", "Content-Type: application/json",
|
||||
"-d", json.dumps({"fields": new_fields})
|
||||
], capture_output=True, text=True, timeout=10)
|
||||
```
|
||||
|
||||
**CRITICAL:** The PATCH payload must include ALL existing fields, not just the new ones — the `fields` array replaces the entire schema. Omitting existing fields drops them.
|
||||
|
||||
**Detection:** Run the migration explicitly to surface errors:
|
||||
```bash
|
||||
docker exec pocketbase /usr/local/bin/pocketbase --dir=/pb_data --migrationsDir=/pb_data/migrations migrate
|
||||
```
|
||||
If this passes cleanly, the migration will also apply on container restart. If it errors, use the REST API fallback above.
|
||||
|
||||
**Cleanup failed JS files after REST API workaround:** After applying the schema changes via REST API (PATCH fields or POST new collections), you MUST remove the failing JS migration file from `pb_data/migrations/`. PocketBase keeps trying it on every restart and logs a silent error each time — it won't crash but it's noise and may mask real problems.
|
||||
|
||||
```bash
|
||||
rm /home/ray/docker/pocketbase/pb_data/migrations/1740000000001_*.js
|
||||
# Then restart so the container starts clean
|
||||
docker restart pocketbase
|
||||
```
|
||||
|
||||
**REST API fallback also applies to creating NEW collections** — not just adding fields. The same two-step pattern works for full collection creation:
|
||||
|
||||
```python
|
||||
# Step 1: POST the collection (with null rules to avoid constraint issues)
|
||||
result = api("POST", "/api/collections", {
|
||||
"name": "technicianAssignments",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "shopUserId", "type": "text", "required": True},
|
||||
{"name": "status", "type": "select",
|
||||
"values": ["pending", "in_progress", "waiting_parts", "completed"]},
|
||||
# ... more fields
|
||||
]
|
||||
})
|
||||
cid = result["id"]
|
||||
|
||||
# Step 2: PATCH the rules separately (cleaner separation of concerns)
|
||||
result = api("PATCH", f"/api/collections/{cid}", {
|
||||
"listRule": "shopUserId = @request.auth.id || technicianUserId = @request.auth.id",
|
||||
"createRule": "shopUserId = @request.auth.id",
|
||||
})
|
||||
```
|
||||
|
||||
### PocketBase v0.39.x superuser auth
|
||||
|
||||
Superuser authentication is at the collections endpoint, NOT the legacy `/api/admins/` path:
|
||||
|
||||
```bash
|
||||
# ✅ Correct (v0.39.x)
|
||||
POST /api/collections/_superusers/auth-with-password
|
||||
body: {"identity": "admin@example.com", "password": "..."}
|
||||
|
||||
# ❌ Wrong (legacy — returns 404)
|
||||
POST /api/admins/auth-with-password
|
||||
```
|
||||
|
||||
Create a superuser via CLI if none exists:
|
||||
```bash
|
||||
docker exec pocketbase /usr/local/bin/pocketbase superuser upsert admin@shop.com PASSWORD --dir /pb_data
|
||||
```
|
||||
|
||||
### Creating collections via API (v0.39.x)
|
||||
|
||||
Use `fields` not `schema` in the payload. Match existing collection patterns — most SPQ collections use `text` for `userId` (not `relation`):
|
||||
|
||||
```python
|
||||
api("POST", "/api/collections", {
|
||||
"name": "collection_name",
|
||||
"type": "base",
|
||||
"listRule": "userId = @request.auth.id",
|
||||
"viewRule": "userId = @request.auth.id",
|
||||
"createRule": "@request.auth.id != ''",
|
||||
"updateRule": "userId = @request.auth.id",
|
||||
"deleteRule": "userId = @request.auth.id",
|
||||
"fields": [
|
||||
{"name": "name", "type": "text", "required": True},
|
||||
{"name": "userId", "type": "text", "required": True},
|
||||
],
|
||||
}, token=admin_token)
|
||||
```
|
||||
|
||||
The API returns 400 with specific field-level errors if the schema is wrong — inspect `resp["data"]["fields"]` for detail.
|
||||
|
||||
### Vite dev proxy for PocketBase needs explicit `rewrite`
|
||||
|
||||
When proxying `/pb` to PocketBase in Vite dev, add an explicit `rewrite` rule to strip the prefix:
|
||||
|
||||
```typescript
|
||||
server: {
|
||||
proxy: {
|
||||
'/pb': {
|
||||
target: 'http://127.0.0.1:8091',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/pb/, ''),
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Without `rewrite`, some PocketBase versions serve the admin UI HTML for proxied paths instead of the API JSON.
|
||||
|
||||
## PocketBase API Quick Reference
|
||||
|
||||
- Auth: `POST /api/collections/users/auth-with-password` with `{identity, password}`
|
||||
- List: `GET /api/collections/{name}/records?page=1&perPage=50&filter=...&sort=-id`
|
||||
- Create: `POST /api/collections/{name}/records`
|
||||
- Update: `PATCH /api/collections/{name}/records/{id}`
|
||||
- Delete: `DELETE /api/collections/{name}/records/{id}`
|
||||
- Health: `GET /api/health`
|
||||
- Admin dashboard: `http://host:8091/_/`
|
||||
|
||||
## SDK Patterns
|
||||
|
||||
```typescript
|
||||
import PocketBase from 'pocketbase';
|
||||
export const pb = new PocketBase('/pb');
|
||||
pb.autoCancellation(false);
|
||||
|
||||
// Auth
|
||||
const auth = await pb.collection('users').authWithPassword(email, password);
|
||||
const userId = pb.authStore.model?.id;
|
||||
pb.authStore.clear(); // logout
|
||||
|
||||
// CRUD
|
||||
const result = await pb.collection('name').getList(page, perPage, { filter, sort });
|
||||
const record = await pb.collection('name').create({ field: value });
|
||||
await pb.collection('name').update(id, { field: newValue });
|
||||
await pb.collection('name').delete(id);
|
||||
```
|
||||
|
||||
## React Integration
|
||||
|
||||
```tsx
|
||||
import { pb } from '../lib/pocketbase';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
// Must be wrapped in <ToastProvider>
|
||||
```
|
||||
|
||||
### Settings merge: always use spread, never explicit field lists
|
||||
|
||||
When loading settings from a PocketBase JSON field into local state, use `{ ...DEFAULT_SETTINGS, ...data }` spread pattern. An explicit field-by-field merge silently drops any fields that exist in `DEFAULT_SETTINGS` but were not added to the merge logic — a recurring bug when new settings are added later.
|
||||
|
||||
```typescript
|
||||
// ❌ Brittle — every new field must be manually added here
|
||||
const merged: ShopSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
businessName: (d.businessName as string) || '',
|
||||
taxRate: Number(d.taxRate ?? 0),
|
||||
// ... 10+ more fields — easy to miss new ones
|
||||
};
|
||||
|
||||
// ✅ Future-proof — spread includes all fields automatically
|
||||
const merged: ShopSettings = { ...DEFAULT_SETTINGS, ...d } as ShopSettings;
|
||||
```
|
||||
|
||||
This also means DEFAULT_SETTINGS must be the single source of truth for all fields and their defaults. When adding new settings fields, add them to DEFAULT_SETTINGS first, then the spread handles the rest.
|
||||
|
||||
### Nested component input focus loss
|
||||
|
||||
When an input inside a nested function component loses focus on every keystroke, the component is being recreated on every render. React treats a nested function as a new component type, destroying and recreating the DOM.
|
||||
|
||||
**Fix:** Extract the component to a file-level `memo`'d component so its identity is stable across renders. Never define components (functions that return JSX) inside other components — always at module scope.
|
||||
|
||||
```tsx
|
||||
const ServiceRow = memo(function ServiceRow({ service, onUpdate, ... }: Props) {
|
||||
return <input value={service.price} onChange={...} />;
|
||||
});
|
||||
```
|
||||
|
||||
### JSON fields from PocketBase
|
||||
|
||||
PB `json` type fields may arrive as serialized JSON strings instead of parsed objects/arrays, depending on how the record was created. Always normalize:
|
||||
|
||||
```typescript
|
||||
// Fetch time normalization (safe)
|
||||
let services = item.services || [];
|
||||
if (typeof services === 'string' && services.trim()) {
|
||||
try { services = JSON.parse(services); } catch { services = []; }
|
||||
}
|
||||
if (!Array.isArray(services)) services = [];
|
||||
```
|
||||
|
||||
And when consuming a possibly-string field:
|
||||
```typescript
|
||||
function calcROTotals(services: ROService[]) {
|
||||
const arr = Array.isArray(services) ? services : [];
|
||||
const total = arr.reduce(...);
|
||||
}
|
||||
```
|
||||
|
||||
### Date formatting crashes (Invalid time value)
|
||||
|
||||
`new Date(undefined)` produces `Invalid Date`, and calling `.toLocaleDateString()` or `Intl.DateTimeFormat().format()` on it throws `RangeError: Invalid time value`. Always guard:
|
||||
|
||||
```typescript
|
||||
function formatDate(iso: string | undefined | null) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '—';
|
||||
return new Intl.DateTimeFormat('en-US', {...}).format(d);
|
||||
}
|
||||
```
|
||||
|
||||
### Tesseract.js in Vite
|
||||
|
||||
Dynamic CDN imports like `import('https://cdn.jsdelivr.net/...')` don't work in Vite production builds. Use a static npm import instead:
|
||||
|
||||
```typescript
|
||||
// ✅ Correct — Vite bundles it
|
||||
import Tesseract from 'tesseract.js';
|
||||
|
||||
// ❌ Wrong — fails in prod
|
||||
const Tesseract = (await import('https://cdn.jsdelivr.net/...')).default;
|
||||
```
|
||||
|
||||
### PB sign-up (user registration)
|
||||
|
||||
PocketBase user creation requires `passwordConfirm` at the top level of the create payload. Without it, the API returns 400:
|
||||
|
||||
```typescript
|
||||
await pb.collection('users').create({
|
||||
email: email.trim(),
|
||||
password,
|
||||
passwordConfirm: confirmPassword, // ← REQUIRED, not a real field
|
||||
name: name.trim() || '',
|
||||
emailVisibility: true,
|
||||
});
|
||||
```
|
||||
|
||||
Common failure causes:
|
||||
- **Email already exists** → PB returns 400 `"Failed to create record."` with `data.email.code === 'validation_not_unique'`
|
||||
- **Password < 8 chars** → PB returns `"Must be at least 8 character(s)."`
|
||||
- **Missing passwordConfirm** → 400 with validation error
|
||||
|
||||
The SDK throws `ClientResponseError` (extends Error). `.message` gives "Failed to create record."; field details are in `.response.data`.
|
||||
|
||||
### Custom service / dynamic item add: always wire onClick
|
||||
|
||||
When a button that creates a dynamic item (e.g., "Add as custom service") has no `onClick` handler, it silently does nothing. Subagents frequently leave buttons with label text but no action. Always verify that every "Add X" button in generated code has a wired click handler that creates the item and updates state:
|
||||
|
||||
```tsx
|
||||
// ❌ Silent — no action
|
||||
<button>Add as custom service</button>
|
||||
|
||||
// ✅ Wired
|
||||
<button onClick={() => {
|
||||
const newItem = { id: `custom-${Date.now()}`, name: query.trim(), price: 0 };
|
||||
addService(newItem);
|
||||
}}>Add as custom service</button>
|
||||
```
|
||||
|
||||
### Appointments page: avoid restrictive PB query params
|
||||
|
||||
The `fields` and `sort` parameters in `pb.collection().getList()` will 400-error if any referenced field doesn't exist on the collection. Prefer minimal params:
|
||||
|
||||
```typescript
|
||||
// ❌ Breaks if fields or sort column doesn't exist
|
||||
pb.collection('appointments').getList(1, 200, {
|
||||
filter: `userId = '${userId}'`,
|
||||
sort: 'date,time', // wrong syntax for PB
|
||||
fields: 'id,name,date,status,created', // any missing = 400
|
||||
});
|
||||
|
||||
// ✅ Safe
|
||||
pb.collection('appointments').getList(1, 200, {
|
||||
filter: `userId = '${userId}'`,
|
||||
sort: '-id',
|
||||
});
|
||||
```
|
||||
|
||||
## Repair Order Domain Patterns
|
||||
|
||||
### Promised Completion Time (Due Date Display)
|
||||
|
||||
Auto repair management apps show a "promised completion time" on active repair orders. The display format adapts to how close the date is:
|
||||
|
||||
```typescript
|
||||
function formatDueDate(promisedTime: string | undefined | null) {
|
||||
if (!promisedTime) return '—';
|
||||
const d = new Date(promisedTime);
|
||||
if (isNaN(d.getTime())) return '—';
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const tomorrow = new Date(today.getTime() + 86400000);
|
||||
const nextWeek = new Date(today.getTime() + 7 * 86400000);
|
||||
const dateOnly = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
const timeStr = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
|
||||
if (dateOnly.getTime() === today.getTime()) return `Today ${timeStr}`;
|
||||
if (dateOnly.getTime() === tomorrow.getTime()) return `Tomorrow ${timeStr}`;
|
||||
if (dateOnly.getTime() < nextWeek.getTime()) {
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
return `${days[d.getDay()]} ${timeStr}`;
|
||||
}
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return `${months[d.getMonth()]} ${d.getDate()} ${timeStr}`;
|
||||
}
|
||||
```
|
||||
|
||||
Outputs: `"Today 3:00 PM"`, `"Tomorrow 10:30 AM"`, `"Wed 2:00 PM"`, `"Jun 27 1:00 PM"`, `"—"`.
|
||||
|
||||
Store as ISO datetime string in the PB collection (`promisedTime` field). Use `<input type="datetime-local">` in forms.
|
||||
|
||||
### Fetching Repair Orders by Customer (Two Patterns)
|
||||
|
||||
Repair orders link to customers in two ways, each used for different purposes:
|
||||
|
||||
**1. Forward-link by `customerId` (detail view)** — The RO has a `customerId` field referencing the customer record. Use this when viewing one customer's details:
|
||||
|
||||
> **⚠ SPQ data reality:** In the current SPQ database, ALL 55 repair orders and ALL 47 quotes have an **empty `customerId` field**. The only reliable link is by `customerName`. Use pattern #2 (`customerName` filter) for detail views in SPQ. If `customerId` is ever populated in the future this pattern will work, but for now it returns zero results.
|
||||
|
||||
```typescript
|
||||
const result = await pb.collection('repairOrders').getList(1, 100, {
|
||||
filter: `customerId = '${customerId}'`,
|
||||
sort: '-createdAt',
|
||||
fields: 'id,roNumber,customerId,customerName,vehicleInfo,vin,mileage,status,workStatus,createdAt,completedTime,writeupTime,technician,notes,services',
|
||||
});
|
||||
```
|
||||
|
||||
**2. Backward-link by `customerName` (vehicle discovery)** — Used when the customer has no `customerId` on the RO (legacy data pattern). Search all ROs by customer name to discover associated vehicles:
|
||||
|
||||
```typescript
|
||||
const nameFilter = customerNames
|
||||
.map((n) => `customerName ~ '${n.replace(/'/g, "\\'")}'`)
|
||||
.join(' || ');
|
||||
|
||||
const result = await pb.collection('repairOrders').getList(1, 500, {
|
||||
filter: nameFilter,
|
||||
fields: 'vehicleInfo,vin,customerName',
|
||||
});
|
||||
```
|
||||
|
||||
The first pattern is for the Customer Detail view (that customer's ROs). The second is for the Customer List view (discovering vehicles across all customers).
|
||||
|
||||
### RO Status Values and Display
|
||||
|
||||
The DB uses these `workStatus` values (stored in `status` or `workStatus` field):
|
||||
|
||||
| DB value | Display label | Badge color |
|
||||
|---|---|---|
|
||||
| `active` / `open` | Open | Blue |
|
||||
| `in-progress` / `in_progress` | In Progress | Yellow/Amber |
|
||||
| `waiter` / `waiting_parts` | Waiting Parts | Orange |
|
||||
| `completed` | Completed | Green |
|
||||
| `delivered` | Delivered | Green |
|
||||
| `cancelled` | Cancelled | Red |
|
||||
|
||||
Status label/color helpers:
|
||||
|
||||
```typescript
|
||||
function getRoStatusLabel(ro: RepairOrderRecord): string {
|
||||
const s = ro.workStatus || ro.status || '';
|
||||
switch (s) {
|
||||
case 'active': case 'open': return 'Open';
|
||||
case 'in-progress': case 'in_progress': return 'In Progress';
|
||||
case 'waiter': case 'waiting_parts': return 'Waiting Parts';
|
||||
case 'completed': return 'Completed';
|
||||
case 'delivered': return 'Delivered';
|
||||
case 'cancelled': return 'Cancelled';
|
||||
default: return s || 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function getRoStatusColor(ro: RepairOrderRecord): string {
|
||||
const s = ro.workStatus || ro.status || '';
|
||||
switch (s) {
|
||||
case 'active': case 'open':
|
||||
return 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
|
||||
case 'in-progress': case 'in_progress':
|
||||
return 'bg-yellow-50 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400';
|
||||
case 'waiter': case 'waiting_parts':
|
||||
return 'bg-orange-50 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400';
|
||||
case 'completed': case 'delivered':
|
||||
return 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400';
|
||||
case 'cancelled':
|
||||
return 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400';
|
||||
default:
|
||||
return 'bg-gray-50 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Status Transition Buttons (Reversible)
|
||||
|
||||
Repair orders need reversible status changes — once marked "Completed", the user must be able to "Reopen" back to "Active". Always show context-appropriate buttons:
|
||||
|
||||
```
|
||||
Active → In Progress, Waiting Parts, Complete
|
||||
In Progress → Active, Waiting Parts, Complete
|
||||
Waiting Pts → Active, In Progress, Complete
|
||||
Completed → Reopen (→Active), Mark Delivered
|
||||
Delivered → Reopen (→Active)
|
||||
```
|
||||
|
||||
## Local Dev Server
|
||||
|
||||
For local development with PocketBase on a different port, use the SPA+PB proxy server in `references/spa-pb-proxy.py`. It serves the Vite build output and proxies `/pb/*` API calls to PocketBase, with CORS headers. Set `SPQ_DIST` and `PB_URL` env vars, or edit the defaults.
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
# Cross-Origin / Same-Code Debugging (SPQ-specific)
|
||||
|
||||
Diagnose why the **same PocketBase + React app code** behaves differently on two domains.
|
||||
|
||||
## When to Use
|
||||
|
||||
User reports: "Feature X works on shopproquote.graj-media.com but returns 'Invalid input' / 'Error' on grajmedia.duckdns.org." Both serve the same build.
|
||||
|
||||
## Protocol
|
||||
|
||||
### 1. Verify code is identical
|
||||
|
||||
Check nginx config at `/etc/nginx/sites-enabled/`:
|
||||
|
||||
```bash
|
||||
cat /etc/nginx/sites-enabled/shopproquote
|
||||
```
|
||||
|
||||
Look for:
|
||||
- `root` directive — same path for both `server_name` entries?
|
||||
- `server_name` — both domains listed in the same `server` block?
|
||||
|
||||
In SPQ's deployment, nginx serves all three domains (`grajmedia.duckdns.org`, `graj-media.com`, `shopproquote.graj-media.com`) from a single `root` at `/mnt/seagate8tb/Websites/ShopProQuote.backup-20260626-2058/spq-v2/dist`.
|
||||
|
||||
### 2. Check backend proxy
|
||||
|
||||
In the same nginx config, check all `location /pb/` blocks:
|
||||
|
||||
```
|
||||
proxy_pass http://127.0.0.1:8091/;
|
||||
```
|
||||
|
||||
Both domains proxy to the same PocketBase instance. No difference possible.
|
||||
|
||||
### 3. Conclusion
|
||||
|
||||
If code AND backend are identical, the difference is **client-side state**:
|
||||
|
||||
- **localStorage** — per-origin; the Zustand `spq-quote` store is persisted here
|
||||
- Service worker cache (less common)
|
||||
- Cookies
|
||||
|
||||
### 4. Find the failing data path
|
||||
|
||||
In SPQ's Quote Generator, both **Save** (`handleSave` in `QuoteSummary.tsx`) and **Share** (`handleShare` → `ensureShareToken`) do:
|
||||
|
||||
```typescript
|
||||
const parsed = quoteWriteSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
console.warn('Quote schema validation failed:', JSON.stringify(parsed.error.issues, null, 2));
|
||||
showToast(parsed.error.issues[0].message, 'error');
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
The toast message like "Invalid input" comes from Zod validation. Common fields that fail:
|
||||
|
||||
| Field | Schema constraint | Stale localStorage value |
|
||||
|---|---|---|
|
||||
| `discountType` | `z.enum(['dollar', 'percent'])` | `null`, `undefined`, or unexpected string |
|
||||
| `customerDecision` (per service) | `z.enum(['approved', 'declined', 'pending'])` | `undefined` (missing from old store shape) |
|
||||
| `customerName` | `z.string().min(1)` | `""` |
|
||||
| `vehicleInfo` | `z.string().min(1)` | `""` |
|
||||
|
||||
### 5. Confirm stale localStorage
|
||||
|
||||
Open DevTools → Application → Local Storage → `<origin>` → key `spq-quote`.
|
||||
Compare the stored `discount` and `services` arrays against the current Zod schema (`src/schemas/quote.ts`).
|
||||
|
||||
### 6. Fix
|
||||
|
||||
```js
|
||||
localStorage.removeItem('spq-quote');
|
||||
```
|
||||
|
||||
Reload and retry.
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
# SPQ Customer DB Integration Pattern
|
||||
|
||||
## Shared Helper Library (all forms)
|
||||
|
||||
**`src/lib/customerName.ts`** provides three reusable functions used by AppointmentModal, ROForm, CustomerInfoPanel, and CustomerFormModal:
|
||||
|
||||
- **`composeName(first, middle, last)`** — joins parts → single `name` string
|
||||
- **`parseName(fullName)`** — splits → `{ firstName, middleName, lastName }` (first-word/last-word heuristic)
|
||||
- **`findMatchingCustomer<T>(customers, query)`** — generic matcher: phone (exact 3+ digits) → exact full name → partial name
|
||||
|
||||
The appointment and RO forms use client-side matching on the loaded customers array (400ms debounce). The quote form (CustomerInfoPanel) uses PB queries with its own 600ms debounce.
|
||||
|
||||
## Name Split (firstName / middleName / lastName)
|
||||
|
||||
`CustomerInfo` in SPQ stores both a single `name` field (for backward compat with existing code reading `customerInfo.name`) AND separate `firstName`, `middleName`, `lastName` fields for granular display/editing.
|
||||
|
||||
### Auto-compose rule (in `src/store/quote.ts`)
|
||||
|
||||
When `setCustomerInfo()` is called, if `firstName`, `middleName`, or `lastName` changed, the store auto-composes `name` from the three parts joined by spaces. Empty parts are filtered out.
|
||||
|
||||
When `name` is passed directly (legacy callers), it auto-splits into first/last using `.split(/\s+/)` — first word → `firstName`, rest → `lastName`, `middleName` left empty.
|
||||
|
||||
```typescript
|
||||
setCustomerInfo: (info) => set((s) => {
|
||||
const merged = { ...s.customerInfo, ...info };
|
||||
// Auto-compose name from firstName + lastName
|
||||
if (info.firstName !== undefined || info.lastName !== undefined || info.middleName !== undefined) {
|
||||
const parts = [merged.firstName, merged.middleName, merged.lastName].filter(Boolean);
|
||||
merged.name = parts.join(' ') || merged.name;
|
||||
}
|
||||
// Backward compat: if caller passes `name` directly, split it
|
||||
if (info.name !== undefined && info.name !== merged.name && info.firstName === undefined) {
|
||||
const parts = info.name.trim().split(/\s+/);
|
||||
merged.firstName = parts[0] || '';
|
||||
merged.lastName = parts.slice(1).join(' ') || '';
|
||||
}
|
||||
return { customerInfo: merged };
|
||||
}),
|
||||
```
|
||||
|
||||
### Helper to convert legacy flat fields
|
||||
|
||||
Used in `QuoteGenerator.tsx` for URL params, RO data, and quote editing:
|
||||
|
||||
```typescript
|
||||
function toCustomerInfo(opts: {
|
||||
name?: string; phone?: string; vehicleInfo?: string; vin?: string;
|
||||
mileage?: string; roNumber?: string; serviceAdvisor?: string;
|
||||
}): CustomerInfo {
|
||||
const n = opts.name || '';
|
||||
const parts = n.trim().split(/\s+/);
|
||||
return {
|
||||
firstName: parts[0] || '',
|
||||
middleName: '',
|
||||
lastName: parts.slice(1).join(' ') || '',
|
||||
name: n,
|
||||
phone: opts.phone || '',
|
||||
vehicleInfo: opts.vehicleInfo || '',
|
||||
vin: opts.vin || '',
|
||||
mileage: opts.mileage || '',
|
||||
roNumber: opts.roNumber || '',
|
||||
serviceAdvisor: opts.serviceAdvisor || '',
|
||||
email: '',
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Customer Record Auto-Creation
|
||||
|
||||
### `ensureCustomerRecord` helper (in `QuoteSummary.tsx`)
|
||||
|
||||
When saving or sharing a quote, this function ensures a `customers` collection record exists:
|
||||
|
||||
1. If `customerInfo.customerId` is already set → use it (skip)
|
||||
2. Search for existing customer by phone (digits, exact match via PB filter `phone ~`)
|
||||
3. If not found by phone, search by exact name match
|
||||
4. If still not found, create a new record in `customers` collection with `userId`, `name`, `firstName`, `lastName`, `phone`, `email`
|
||||
|
||||
```typescript
|
||||
async function ensureCustomerRecord(customerInfo: {
|
||||
name: string; firstName: string; lastName: string; phone: string; email?: string;
|
||||
}): Promise<string | undefined> {
|
||||
if (!customerInfo.name.trim()) return undefined;
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (!userId) return undefined;
|
||||
|
||||
// Check by phone first
|
||||
const phoneDigits = customerInfo.phone.replace(/\D/g, '');
|
||||
if (phoneDigits.length >= 4) {
|
||||
try {
|
||||
const existing = await pb.collection('customers').getFirstListItem(
|
||||
`phone ~ "${phoneDigits}" && userId = "${userId}"`,
|
||||
{ fields: 'id', batch: 1 }
|
||||
);
|
||||
if (existing) return existing.id;
|
||||
} catch { /* proceed */ }
|
||||
}
|
||||
|
||||
// Check by name
|
||||
try {
|
||||
const existing = await pb.collection('customers').getFirstListItem(
|
||||
`name = "${customerInfo.name.replace(/"/g, '')}" && userId = "${userId}"`,
|
||||
{ fields: 'id', batch: 1 }
|
||||
);
|
||||
if (existing) return existing.id;
|
||||
} catch { /* proceed */ }
|
||||
|
||||
// Create new
|
||||
try {
|
||||
const created = await pb.collection('customers').create({
|
||||
name: customerInfo.name,
|
||||
firstName: customerInfo.firstName || customerInfo.name.split(' ')[0] || '',
|
||||
lastName: customerInfo.lastName || customerInfo.name.split(' ').slice(1).join(' ') || '',
|
||||
phone: customerInfo.phone,
|
||||
email: customerInfo.email || '',
|
||||
address: '', notes: '', userId,
|
||||
});
|
||||
return created.id;
|
||||
} catch (err) { return undefined; }
|
||||
}
|
||||
```
|
||||
|
||||
### Integration points (all forms)
|
||||
|
||||
- **`handleSave()`** in QuoteSummary.tsx — calls `ensureCustomerRecord` before saving the quote, passes `customerId` in the PB quote record
|
||||
- **`ensureShareToken()`** in QuoteSummary.tsx — same pattern for shared quotes
|
||||
- **RO conversion** in QuoteGenerator.tsx — reads `customerId` from the source quote and passes it to the RO creation payload
|
||||
|
||||
## Duplicate Detection (All Forms)
|
||||
|
||||
The `CustomerInfoPanel` fires a debounced duplicate check (600ms after last keystroke on firstName/lastName/phone). It fetches all user's customers and filters client-side by name contains or phone contains. If a match is found, an amber banner offers "Use Existing" or "Create New" buttons.
|
||||
|
||||
**Client-side filtering (safe fallback):**
|
||||
|
||||
```typescript
|
||||
const result = await pb.collection('customers').getList(1, 500, {
|
||||
fields: 'id,name,phone', batch: 500,
|
||||
});
|
||||
const matched = (result.items as any[]).filter((c: any) => {
|
||||
const cName = (c.name || '').toLowerCase();
|
||||
const cPhone = (c.phone || '').replace(/\D/g, '');
|
||||
if (nameQuery && cName.includes(nameLc)) return true;
|
||||
if (phoneDigits.length >= 4 && cPhone.includes(phoneDigits)) return true;
|
||||
return false;
|
||||
});
|
||||
```
|
||||
|
||||
This avoids PB filter operator issues (the `~` operator can return 400 on some collections — see "Filter `~` operator can return 400" pitfall in the main SKILL.md).
|
||||
|
||||
## Customer Select → Multi-Source Data Population
|
||||
|
||||
When an advisor selects a customer from the search dropdown, the form pre-fills vehicle info, VIN, mileage, email, and the last RO number. These fields are scattered across multiple PocketBase collections — no single query can populate them all.
|
||||
|
||||
### Fallback chain
|
||||
|
||||
The `selectCustomer()` function in `CustomerInfoPanel.tsx` tries each source in order, stopping as soon as it finds data:
|
||||
|
||||
```
|
||||
1. vehicles collection
|
||||
└─ customerId = '<id>' → year/make/model → vehicleInfo, vin, mileage
|
||||
|
||||
2. quotes collection (by customerId)
|
||||
└─ customerId = '<id>' && userId = '<uid>' → vehicleInfo, vin, mileage, repairOrderNumber
|
||||
|
||||
3. quotes collection (by customerName — fallback for legacy records)
|
||||
└─ customerName = '<name>' && userId = '<uid>' → same fields
|
||||
|
||||
4. repairOrders collection (by customerId)
|
||||
└─ customerId = '<id>' && userId = '<uid>' → vehicleInfo, vin, mileage, roNumber
|
||||
|
||||
5. repairOrders collection (by customerName — fallback)
|
||||
└─ customerName = '<name>' && userId = '<uid>' → same fields
|
||||
```
|
||||
|
||||
Each lookup uses `getList(1, 1, { filter, sort: '-created' })` to get the most recent record. The `=` filter operator works reliably on text fields (unlike `~`). Results are merged — each field keeps the first non-empty value found.
|
||||
|
||||
### Why vehicles alone isn't enough
|
||||
|
||||
Customers created via quote auto-save (`ensureCustomerRecord`) have NO vehicle records in the `vehicles` collection — the vehicle info only exists on their quote/RO records. So the fallback to quotes and ROs is essential for those customers.
|
||||
|
||||
### Full function pattern
|
||||
|
||||
```typescript
|
||||
const selectCustomer = useCallback(async (c: CustomerSearchResult) => {
|
||||
const userId = pb.authStore.model?.id;
|
||||
try {
|
||||
const full = await pb.collection('customers').getOne(c.id);
|
||||
|
||||
let vehicleInfo = '', vin = '', mileage = '', roNumber = '';
|
||||
|
||||
// 1. Try vehicles collection
|
||||
try {
|
||||
const vResult = await pb.collection('vehicles').getList(1, 1, {
|
||||
filter: `customerId = '${c.id.replace(/'/g, "\\'")}'`,
|
||||
sort: '-created',
|
||||
fields: 'year,make,model,vin,mileage',
|
||||
});
|
||||
if (vResult.items.length > 0) {
|
||||
const v = vResult.items[0] as any;
|
||||
vehicleInfo = [v.year, v.make, v.model].filter(Boolean).join(' ');
|
||||
vin = v.vin || '';
|
||||
mileage = v.mileage || '';
|
||||
}
|
||||
} catch { /* vehicles collection may not exist */ }
|
||||
|
||||
// 2-5. Fallback to quotes/ROs
|
||||
const escapedId = c.id.replace(/'/g, "\\'");
|
||||
const escapedName = (full.name || c.name || '').replace(/'/g, "\\'");
|
||||
const idFilter = `customerId = '${escapedId}' && userId = '${userId}'`;
|
||||
const nameFilter = `customerName = '${escapedName}' && userId = '${userId}'`;
|
||||
|
||||
for (const [collection, filterToTry] of [
|
||||
['quotes', idFilter], ['quotes', nameFilter],
|
||||
['repairOrders', idFilter], ['repairOrders', nameFilter],
|
||||
] as const) {
|
||||
if (vehicleInfo) break; // stop once we have data
|
||||
try {
|
||||
const result = await pb.collection(collection).getList(1, 1, {
|
||||
filter: filterToTry, sort: '-created',
|
||||
fields: 'vehicleInfo,vin,mileage,repairOrderNumber,roNumber',
|
||||
});
|
||||
if (result.items.length > 0) {
|
||||
const r = result.items[0] as any;
|
||||
if (!vehicleInfo) vehicleInfo = r.vehicleInfo || '';
|
||||
if (!vin) vin = r.vin || '';
|
||||
if (!mileage) mileage = r.mileage || '';
|
||||
if (!roNumber) roNumber = r.repairOrderNumber || r.roNumber || '';
|
||||
}
|
||||
} catch { /* try next source */ }
|
||||
}
|
||||
|
||||
const parts = (full.name || c.name || '').trim().split(/\s+/);
|
||||
setCustomerInfo({
|
||||
customerId: c.id,
|
||||
firstName: parts[0] || '',
|
||||
middleName: parts.length > 2 ? parts.slice(1, -1).join(' ') : '',
|
||||
lastName: parts.length > 1 ? parts[parts.length - 1] : '',
|
||||
name: full.name || c.name || '',
|
||||
phone: full.phone || c.phone || '',
|
||||
email: full.email || '',
|
||||
vehicleInfo, vin, mileage, roNumber,
|
||||
});
|
||||
} catch (e) {
|
||||
logError('Failed to load full customer record', e);
|
||||
// Fallback: set what we have from search result
|
||||
const parts = c.name.trim().split(/\s+/);
|
||||
setCustomerInfo({ customerId: c.id, firstName: parts[0] || '',
|
||||
lastName: parts.slice(1).join(' ') || '', name: c.name, phone: c.phone });
|
||||
}
|
||||
}, [setCustomerInfo]);
|
||||
```
|
||||
|
||||
### Name splitting logic
|
||||
|
||||
Given a customer name string, split into first/middle/last:
|
||||
|
||||
```typescript
|
||||
const parts = name.trim().split(/\s+/);
|
||||
const firstName = parts[0] || '';
|
||||
const middleName = parts.length > 2 ? parts.slice(1, -1).join(' ') : '';
|
||||
const lastName = parts.length > 1 ? parts[parts.length - 1] : '';
|
||||
```
|
||||
|
||||
- `"John Doe"` → `{ firstName: "John", middleName: "", lastName: "Doe" }`
|
||||
- `"John Michael Doe"` → `{ firstName: "John", middleName: "Michael", lastName: "Doe" }`
|
||||
- `"Alice"` → `{ firstName: "Alice", middleName: "", lastName: "" }`
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
# GlitchTip / Sentry DSN Path-Prefix Quirk
|
||||
|
||||
## The Problem
|
||||
|
||||
When GlitchTip (Sentry-compatible) is hosted behind an nginx path prefix like `/glitchtip/`, the Sentry SDK's DSN parser **rejects** the multi-segment path.
|
||||
|
||||
**DSN format Sentry expects:**
|
||||
```
|
||||
https://<public_key>@<host>/<project_id>
|
||||
```
|
||||
Single path segment only. `/1` in the path is the project ID (a number).
|
||||
|
||||
**What doesn't work:**
|
||||
```
|
||||
https://<key>@host/glitchtip/1
|
||||
```
|
||||
The Sentry SDK v10+ DSN parser throws `Invalid Sentry Dsn` because `glitchtip/1` is two path segments, not one.
|
||||
|
||||
## The Fix
|
||||
|
||||
### 1. Change the DSN in env file
|
||||
|
||||
```
|
||||
VITE_GLITCHTIP_DSN=https://<key>@host/1
|
||||
```
|
||||
Remove `/glitchtip` from the DSN path. Keep only the project ID.
|
||||
|
||||
### 2. Add nginx location for bare project-ID paths
|
||||
|
||||
The original `/glitchtip/` location remains for backward compatibility. Add a new regex location that matches `/<digits>/api/` and `/digits/store/` and proxies to GlitchTip:
|
||||
|
||||
```nginx
|
||||
# In the server block:
|
||||
location /glitchtip/ {
|
||||
rewrite ^/glitchtip/[0-9]+/(api/.*)$ /$1 break;
|
||||
rewrite ^/glitchtip/[0-9]+/(store/.*)$ /api/1/$1 break;
|
||||
rewrite ^/glitchtip/(.*)$ /$1 break;
|
||||
proxy_pass http://127.0.0.1:8001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# Bare project-ID path (for Sentry DSN compat)
|
||||
location ~ ^/([0-9]+)/(api|store)/(.*)$ {
|
||||
rewrite ^/([0-9]+)/api/(.*)$ /api/$1/$2 break;
|
||||
rewrite ^/([0-9]+)/store/(.*)$ /api/$1/$2 break;
|
||||
proxy_pass http://127.0.0.1:8001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Rebuild frontend
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 4. Test
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" -I "https://your-domain.com/1/api/1/envelope/"
|
||||
```
|
||||
Should return 405 (Method Not Allowed — HEAD on POST-only endpoint) or 400 (valid request reached GlitchTip), not 404.
|
||||
|
||||
## Verification
|
||||
|
||||
- Browser console: no `Invalid Sentry Dsn` error
|
||||
- Sentry SDK initializes: `window.__SENTRY__` is defined
|
||||
- Test events reach GlitchTip: check GlitchTip dashboard for new issues
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
# Quote-to-RO Approved Services Sync
|
||||
|
||||
## Overview
|
||||
|
||||
When a quote is generated from a repair order (`?fromRO=RO_ID` URL parameter) and the user approves or declines services, the approved services should be written back to the originating RO's services array. This keeps the RO's service list up-to-date with the customer's decisions.
|
||||
|
||||
## Implementation
|
||||
|
||||
The sync happens in `src/components/quoteGenerator/QuoteSummary.tsx` at two points:
|
||||
|
||||
1. **`handleSave`** (Save Quote button) — after saving the quote record, if `repairOriginId` is set
|
||||
2. **`ensureShareToken`** (Share with Customer / Send Email / Send Text) — after creating the quote record, if `repairOriginId` is set
|
||||
|
||||
### Sync Logic
|
||||
|
||||
```typescript
|
||||
if (repairOriginId) {
|
||||
const approvedQuoteSvcs = services.filter((s) => s.approved);
|
||||
if (approvedQuoteSvcs.length > 0) {
|
||||
// 1. Load current RO
|
||||
const ro = await pb.collection('repairOrders').getOne(repairOriginId);
|
||||
let roServices: any[] = [];
|
||||
const raw = (ro as any).services;
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
try { roServices = JSON.parse(raw); } catch { roServices = []; }
|
||||
} else if (Array.isArray(raw)) {
|
||||
roServices = raw;
|
||||
}
|
||||
|
||||
// 2. Merge approved quote services into RO services
|
||||
const rate = settings.defaultLaborRate || 95;
|
||||
for (const qs of approvedQuoteSvcs) {
|
||||
const price = parseFloat(String(qs.price ?? 0)) || 0;
|
||||
const roSvc = {
|
||||
id: `qs-${qs.id}`,
|
||||
name: qs.name || '',
|
||||
description: qs.explanation || '',
|
||||
laborHours: Math.round((price / rate) * 100) / 100,
|
||||
laborRate: rate,
|
||||
partsCost: 0,
|
||||
total: price,
|
||||
status: 'pending' as const,
|
||||
technician: '',
|
||||
};
|
||||
const existingIdx = roServices.findIndex((s) => s.name === qs.name);
|
||||
if (existingIdx >= 0) {
|
||||
// Update existing — preserve status and technician
|
||||
roServices[existingIdx] = { ...roServices[existingIdx], ...roSvc,
|
||||
status: roServices[existingIdx].status || 'pending' };
|
||||
} else {
|
||||
roServices.push(roSvc);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Save updated RO
|
||||
await pb.collection('repairOrders').update(repairOriginId, {
|
||||
services: JSON.stringify(roServices),
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Merge Rules
|
||||
|
||||
- **Same name = same service**: matched by `s.name === qs.name`
|
||||
- **Existing service updated**: price/total/hours/rate overwritten, but `status` and `technician` are preserved
|
||||
- **New service appended**: added as a new entry with status `'pending'`
|
||||
- **Declined/pending services ignored**: only `s.approved === true` services get written back
|
||||
|
||||
### `repairOriginId` Flow
|
||||
|
||||
1. User opens RO → clicks "Generate Quote" → URL gets `?fromRO=RO_ID`
|
||||
2. `QuoteGenerator.tsx` reads the param, sets `repairOriginId` state (line 74)
|
||||
3. Passes `repairOriginId` to `QuoteSummary` component (line 601)
|
||||
4. `QuoteSummary` includes `repairOrderId: repairOriginId` in the saved quote record (line 123/244)
|
||||
5. After save, the sync block writes approved services back to that RO
|
||||
|
||||
### EnsureShareToken Sync Gap
|
||||
|
||||
The `ensureShareToken` function has a logic gap critical for debugging:
|
||||
|
||||
```typescript
|
||||
let quoteId = editId;
|
||||
if (!quoteId) {
|
||||
// ... create new quote record ...
|
||||
// ... SYNC approved services to RO ...
|
||||
}
|
||||
// ... generate share token ...
|
||||
```
|
||||
|
||||
When `editId` is set (editing an existing quote), the `if (!quoteId)` block is **entirely skipped** — including the sync code. This means:
|
||||
|
||||
- **Save path**: `handleSave` sync runs for BOTH new and edit saves. Save-then-Share works.
|
||||
- **Share-only path**: If the user Shares an existing quote WITHOUT first clicking Save, the sync is skipped. Approved services won't transfer.
|
||||
- **Mitigation**: Extract the copy-pasted sync logic into a shared function called from both paths.
|
||||
|
||||
### Root Cause Patterns
|
||||
|
||||
Three root cause categories emerged from investigation:
|
||||
|
||||
### 1. Auth Ownership Mismatch (404, not 403)
|
||||
|
||||
PocketBase's `userId = @request.auth.id` rule returns **404** (not 403) when the authenticated user doesn't own the record. If `demo@shop.com` logs in but the RO belongs to `mani8994@gmail.com`, the sync silently fails — the inner catch block shows a toast that may go unnoticed.
|
||||
|
||||
**Diagnosis:** Check userId on both quote and RO:
|
||||
```bash
|
||||
curl -s -H "Authorization: $APP_TOKEN" \
|
||||
"http://host:8091/api/collections/quotes/records/QUOTE_ID?fields=id,userId,repairOrderId"
|
||||
curl -s -H "Authorization: $APP_TOKEN" \
|
||||
"http://host:8091/api/collections/repairOrders/records/RO_ID?fields=id,userId"
|
||||
```
|
||||
Both must match the authenticated user's ID.
|
||||
|
||||
### 2. Missing `repairOrderId` on Legacy Quotes
|
||||
|
||||
Quotes created before the `repairOrderId` field was added (migration `1739999000006`) have an empty field. The edit-load effect won't set `repairOriginId` for these quotes, so the sync code exits early.
|
||||
|
||||
**Diagnosis:** Count quotes with non-empty repairOrderId:
|
||||
```bash
|
||||
curl -s -H "Authorization: $SUPERTOKEN" \
|
||||
"http://host:8091/api/collections/quotes/records?filter=repairOrderId!=%27%27&perPage=1"
|
||||
# %27 is URL-encoded single quote. In PocketBase filter syntax: repairOrderId!='' means "not empty"
|
||||
```
|
||||
|
||||
### 3. Zustand Persist vs Edit-Load (Not a Real Race)
|
||||
|
||||
The Zustand `persist` middleware rehydrates synchronously from localStorage on first render, BEFORE the edit-load `useEffect` fires. The effect's `useQuoteStore.setState()` correctly overwrites with PocketBase data. No race condition exists in practice.
|
||||
|
||||
## Debugging: Direct API Verification
|
||||
|
||||
When the sync looks correct in code but the RO doesn't update, verify at the API level:
|
||||
|
||||
**1. Check repairOrderId on quotes:**
|
||||
```bash
|
||||
APP_TOKEN=$(curl -s -X POST http://host:8091/api/collections/users/auth-with-password \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"identity":"user@example.com","password":"pwd"}' | python3 -c "import json,sys;print(json.load(sys.stdin).get('token',''))")
|
||||
|
||||
curl -s -H "Authorization: $APP_TOKEN" \
|
||||
"http://host:8091/api/collections/quotes/records?filter=repairOrderId!=%27%27&perPage=10&fields=id,customerName,repairOrderId,services" | python3 -c "
|
||||
import json,sys; d=json.load(sys.stdin)
|
||||
for q in d.get('items',[]):
|
||||
svcs = json.loads(q.get('services','[]')) if isinstance(q.get('services'),str) else (q.get('services') or [])
|
||||
approved = len([s for s in svcs if s.get('approved')])
|
||||
print(f'{q[\"id\"][:12]} {q.get(\"customerName\",\"?\")[:20]} roId={q.get(\"repairOrderId\",\"\")[:12]} svcs={len(svcs)} approved={approved}')
|
||||
"
|
||||
```
|
||||
|
||||
**2. Check services on the linked RO:**
|
||||
```bash
|
||||
curl -s -H "Authorization: $APP_TOKEN" \
|
||||
"http://host:8091/api/collections/repairOrders/records/RO_ID?skipTotal=1" | python3 -c "
|
||||
import json,sys; d=json.load(sys.stdin)
|
||||
svcs = json.loads(d.get('services','[]')) if isinstance(d.get('services'),str) else (d.get('services') or [])
|
||||
print(f'Services in RO: {len(svcs)}')
|
||||
"
|
||||
```
|
||||
|
||||
**3. Manually test the update the sync code performs:**
|
||||
```bash
|
||||
curl -s -X PATCH "http://host:8091/api/collections/repairOrders/records/RO_ID" \
|
||||
-H "Authorization: Bearer $APP_TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"services": "[{\"name\":\"Test\",\"total\":100,\"status\":\"pending\"}]"}'
|
||||
```
|
||||
If this succeeds, the code logic is correct — the issue is in the UI execution path.
|
||||
|
||||
**4. Auth ownership check:**
|
||||
```bash
|
||||
curl -s -H "Authorization: $APP_TOKEN" \
|
||||
"http://host:8091/api/collections/quotes/records/QUOTE_ID?skipTotal=1&fields=id,userId,repairOrderId"
|
||||
curl -s -H "Authorization: $APP_TOKEN" \
|
||||
"http://host:8091/api/collections/repairOrders/records/RO_ID?skipTotal=1&fields=id,userId"
|
||||
```
|
||||
The `userId` fields must match. If they differ, the `userId = @request.auth.id` rule blocks the update with 404 (not 403).
|
||||
|
||||
### Debugging: Creating Test Data with Known Ownership
|
||||
|
||||
To isolate ownership variables, create test data owned by the same user:
|
||||
|
||||
```bash
|
||||
# Authenticate and get user ID
|
||||
RESP=$(curl -s -X POST http://host:8091/api/collections/users/auth-with-password \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"identity":"user@example.com","password":"pwd"}')
|
||||
TOKEN=$(echo "$RESP" | python3 -c "import json,sys;print(json.load(sys.stdin).get('token',''))")
|
||||
AUTH_ID=$(echo "$RESP" | python3 -c "import json,sys;print(json.load(sys.stdin).get('record',{}).get('id',''))")
|
||||
|
||||
# Create a test RO
|
||||
curl -s -X POST "http://host:8091/api/collections/repairOrders/records" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"userId\":\"$AUTH_ID\",\"customerName\":\"Test\",\"roNumber\":\"TEST-001\",\"status\":\"active\",\"services\":\"[]\"}"
|
||||
|
||||
# Create a linked quote with approved services
|
||||
curl -s -X POST "http://host:8091/api/collections/quotes/records" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"userId\":\"$AUTH_ID\",\"customerName\":\"Test\",\"repairOrderId\":\"RO_ID\",\"services\":\"[{\\\"id\\\":\\\"qs-1\\\",\\\"name\\\":\\\"Service\\\",\\\"price\\\":100,\\\"approved\\\":true}]\"}"
|
||||
```
|
||||
|
||||
This ensures the test reproduces the same-owner scenario. If the sync still fails with same-owner data, the bug is in the code logic, not the auth layer.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Settings Persistence Pattern (ShopProQuote)
|
||||
|
||||
## Architecture
|
||||
|
||||
Settings live in two places:
|
||||
1. **localStorage** (`spq-settings` key) — always available, even without login
|
||||
2. **PocketBase** `settings` collection — per-user JSON field (`data` field), only when logged in
|
||||
|
||||
## Loading order (Settings.tsx init)
|
||||
|
||||
1. `loadLocalSettings()` → reads localStorage, spreads over DEFAULT_SETTINGS
|
||||
2. If logged in, fetch PB record → spread `{ ...DEFAULT_SETTINGS, ...data }` over the stored `data` JSON field
|
||||
3. `saveLocalSettings(merged)` — overwrites localStorage with PB data so next load uses it
|
||||
|
||||
## Saving
|
||||
|
||||
- `updateSetting(key, value)` — updates local state + immediately writes to localStorage
|
||||
- `saveAllSettings()` — saves to PocketBase (if logged in) + localStorage
|
||||
|
||||
## Type: ShopSettings (src/types.ts)
|
||||
|
||||
```typescript
|
||||
export interface ShopSettings {
|
||||
businessName: string;
|
||||
businessAddress: string;
|
||||
businessPhone: string;
|
||||
serviceAdvisor: string;
|
||||
taxRate: number;
|
||||
taxLabel: string;
|
||||
shopChargeRate: number;
|
||||
shopChargeExplanation: string;
|
||||
headerMessage: string;
|
||||
footerMessage: string;
|
||||
quoteFooterNote: string;
|
||||
|
||||
// PDF & Branding
|
||||
logoUrl?: string;
|
||||
showLogoOnPdf: boolean;
|
||||
quoteExpiryDays: number;
|
||||
quoteNumberPrefix: string;
|
||||
pdfAccentColor: string;
|
||||
|
||||
// Business Ops
|
||||
businessHours?: string;
|
||||
defaultLaborRate: number;
|
||||
paymentTerms: string;
|
||||
}
|
||||
```
|
||||
|
||||
## DEFAULT_SETTINGS lives in two places
|
||||
|
||||
Both MUST stay in sync:
|
||||
- `src/pages/Settings.tsx` (the settings UI uses this)
|
||||
- `src/pages/QuoteGenerator.tsx` (the PDF generator's `loadSettings()` fallback)
|
||||
|
||||
When adding a new setting: add to types.ts → add to both DEFAULT_SETTINGS → add UI field → add to pdf.ts usage.
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Serve a Vite SPA + proxy /pb to PocketBase. Generic, reusable."""
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
import urllib.request
|
||||
import os
|
||||
|
||||
DIST = os.environ.get('SPQ_DIST', './dist')
|
||||
PB = os.environ.get('PB_URL', 'http://127.0.0.1:8091')
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=DIST, **kwargs)
|
||||
|
||||
def end_headers(self):
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
|
||||
self.send_header('Access-Control-Allow-Headers', 'Authorization, Content-Type')
|
||||
super().end_headers()
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith('/pb') or self.path.startswith('/api'):
|
||||
self.proxy()
|
||||
else:
|
||||
full = os.path.join(DIST, self.path.lstrip('/'))
|
||||
if not os.path.exists(full) or (os.path.isdir(full) and self.path != '/'):
|
||||
self.path = '/index.html'
|
||||
super().do_GET()
|
||||
|
||||
def do_POST(self):
|
||||
if self.path.startswith('/pb') or self.path.startswith('/api'):
|
||||
self.proxy()
|
||||
else:
|
||||
self.send_error(405)
|
||||
|
||||
def do_PUT(self): self.proxy()
|
||||
def do_PATCH(self): self.proxy()
|
||||
def do_DELETE(self): self.proxy()
|
||||
|
||||
def proxy(self):
|
||||
# PB SDK calls /pb/api/collections/... — path already includes /api/
|
||||
url = PB + self.path
|
||||
if self.path.startswith('/pb'):
|
||||
url = PB + self.path[3:] # strip /pb prefix
|
||||
if not url.startswith(PB + '/api'):
|
||||
url = PB + '/api' + self.path[3:]
|
||||
body = None
|
||||
if self.headers.get('Content-Length'):
|
||||
body = self.rfile.read(int(self.headers['Content-Length']))
|
||||
req = urllib.request.Request(url, data=body, method=self.command)
|
||||
for k, v in self.headers.items():
|
||||
if k.lower() not in ('host', 'connection', 'origin', 'referer'):
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req)
|
||||
self.send_response(resp.status)
|
||||
for k, v in resp.headers.items():
|
||||
if k.lower() not in ('transfer-encoding', 'connection'):
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
self.wfile.write(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
self.send_response(e.code)
|
||||
self.end_headers()
|
||||
self.wfile.write(e.read())
|
||||
|
||||
if __name__ == '__main__':
|
||||
server = HTTPServer(('0.0.0.0', 4173), Handler)
|
||||
print(f'Serving {DIST} on 0.0.0.0:4173 (PB → {PB})')
|
||||
server.serve_forever()
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
# SPQ Legacy Data Model — Migration Patterns
|
||||
|
||||
## Vehicle Data: Not a Separate Collection
|
||||
|
||||
The SPQ legacy database has **no `vehicles` collection**. Vehicle data lives on `repairOrders` and `quotes` records as flat text fields:
|
||||
|
||||
- `vehicleInfo` (text) — e.g. `"2021 Honda CR-V"`, `"2019 Honda Pilot"`
|
||||
- `vin` (text) — e.g. `"2HKRW2H99MH670367"`
|
||||
|
||||
The legacy JS code (`customers.js`) dynamically assembles each customer's vehicles by:
|
||||
|
||||
1. Searching `repairOrders`, `quotes`, and `appointments` collections for records where `customerName` matches the current customer's name
|
||||
2. Extracting `vehicleInfo` and `vin` from each matching record
|
||||
3. Deduplicating by `vehicleInfo`, preferring records that have a VIN
|
||||
|
||||
```typescript
|
||||
// Pattern to follow when migrating this functionality
|
||||
const nameFilter = customerNames
|
||||
.map((n) => `customerName ~ '${n.replace(/'/g, "\\'")}'`)
|
||||
.join(' || ');
|
||||
|
||||
// Query repairOrders and quotes
|
||||
const roResult = await pb.collection('repairOrders').getList(1, 500, {
|
||||
filter: nameFilter,
|
||||
fields: 'vehicleInfo,vin,customerName',
|
||||
});
|
||||
```
|
||||
|
||||
### Key schema facts
|
||||
|
||||
| Collection | Fields with vehicles | Notes |
|
||||
|---|---|---|
|
||||
| `repairOrders` | `vehicleInfo`, `vin`, `mileage`, `customerName` | Primary source. `customerId` is empty on all 55 records. |
|
||||
| `quotes` | `vehicleInfo`, `vin`, `mileage`, `customerName` | Secondary source. `customerId` is empty on all 47 records. |
|
||||
| `appointments` | `vehicleInfo` only (no `vin` column, no `customerId` column) | Has `customerName` field |
|
||||
| `customers` | `vehicleInfo` (text), `vin` (text) | Only one vehicle per customer; often empty |
|
||||
|
||||
There is NO `vehicles` collection anywhere in the legacy database.*
|
||||
|
||||
**Update (post-M16 migration):** A `vehicles` collection was added by migration M16 (`pb_migrations/1740000000007_create_vehicles.js`). It is user-scoped (`userId` + `customerId`) and stores vehicle records (make, model, year, vin, mileage, licensePlate, color, engine). New records created via the Customer page's vehicle editor go here. However, the legacy repair orders/quotes still have empty `customerId` and their vehicle data lives on the flat text fields. The `CustomerInfoPanel.tsx` code has a multi-tier fallback: (1) try `vehicles` collection by `customerId`, (2) fallback to `quotes` by `customerName`, (3) fallback to `repairOrders` by `customerName`.
|
||||
|
||||
\* Coverage note: the `vehicles` table exists in SQLite (created by M16) but has no records in the backup dataset.
|
||||
|
||||
**Critical: `customerId` is empty on ALL existing repair orders (55/55) and ALL quotes (47/47).** Any query using `customerId` as a filter will return zero records. Always use `customerName` for record linkage. If `customerId` is ever populated in the future, the `customerId` pattern will work, but the current data has zero records matching.
|
||||
|
||||
### Parsing vehicleInfo text
|
||||
|
||||
`vehicleInfo` values follow the pattern `"YYYY Make Model"` (e.g. `"2021 Honda CR-V"`). A regex extraction handles this:
|
||||
|
||||
```typescript
|
||||
function parseVehicleInfo(info: string) {
|
||||
const match = info.trim().match(/^(\d{4})\s+(.+)$/);
|
||||
if (match) {
|
||||
const rest = match[2].trim();
|
||||
const spaceIdx = rest.indexOf(' ');
|
||||
if (spaceIdx > 0) {
|
||||
return {
|
||||
year: match[1],
|
||||
make: rest.slice(0, spaceIdx),
|
||||
model: rest.slice(spaceIdx + 1),
|
||||
};
|
||||
}
|
||||
return { year: match[1], make: rest, model: '' };
|
||||
}
|
||||
return { year: '', make: info, model: '' };
|
||||
}
|
||||
```
|
||||
|
||||
### Display in CustomerCard
|
||||
|
||||
The card shows `vehicleCount` and the first vehicle's label via `formatVehicleLabel`:
|
||||
|
||||
```typescript
|
||||
function formatVehicleLabel(v: VehicleRecord): string {
|
||||
const parts = [v.year, v.make, v.model].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(' ') : 'Unknown vehicle';
|
||||
}
|
||||
```
|
||||
|
||||
### Combine-and-deduplicate pattern across multiple collections
|
||||
|
||||
When building a per-customer vehicle list from repairOrders + quotes, deduplicate by `vehicleInfo` within each customer:
|
||||
|
||||
```typescript
|
||||
// Build: customerName -> VehicleRecord[]
|
||||
const customerVehicleMap = new Map<string, Map<string, VehicleRecord>>();
|
||||
|
||||
for (const vs of vehicleSources) {
|
||||
if (!vs.vehicleInfo || !vs.customerName) continue;
|
||||
if (!customerVehicleMap.has(vs.customerName)) {
|
||||
customerVehicleMap.set(vs.customerName, new Map());
|
||||
}
|
||||
const customerVehicles = customerVehicleMap.get(vs.customerName)!;
|
||||
const existing = customerVehicles.get(vs.vehicleInfo);
|
||||
// Prefer the entry that has a VIN
|
||||
if (!existing || (!existing.vin && vs.vin)) {
|
||||
const parsed = parseVehicleInfo(vs.vehicleInfo);
|
||||
customerVehicles.set(vs.vehicleInfo, {
|
||||
id: '',
|
||||
customerId: '',
|
||||
make: parsed.make,
|
||||
model: parsed.model,
|
||||
year: parsed.year,
|
||||
vin: vs.vin || '',
|
||||
licensePlate: '',
|
||||
mileage: '',
|
||||
color: '',
|
||||
engine: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const enriched = customerList.map((c) => {
|
||||
const cv = customerVehicleMap.get(c.name);
|
||||
const vehicles = cv ? Array.from(cv.values()) : [];
|
||||
return { ...c, vehicles, vehicleCount: vehicles.length };
|
||||
});
|
||||
```
|
||||
|
||||
## Quotes Collection Schema
|
||||
|
||||
The `quotes` collection stores quote data with the same customer-vehicle relationship pattern:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `customerId` | text | ⚠ EMPTY ON ALL 47 EXISTING RECORDS — use `customerName` for queries |
|
||||
| `customerName` | text | Denormalized for display |
|
||||
| `vehicleInfo` | text | e.g. "2023 Honda CR-V" |
|
||||
| `vin` | text | |
|
||||
| `mileage` | text | |
|
||||
| `status` | text | Values: `converted`, `sent`, `declined`, `draft` |
|
||||
| `createdAt` | text | ISO datetime |
|
||||
| `total` | number | Quote total |
|
||||
| `serviceAdvisor` | text | Advisor name |
|
||||
| `services` | json | Array of service objects |
|
||||
| `discountValue` | number | |
|
||||
| `discountType` | text | "dollar" or "percent" |
|
||||
|
||||
**Fetch quotes by `customerName` (not `customerId`):**
|
||||
|
||||
```typescript
|
||||
// ✅ Works — customerName is the only reliable link
|
||||
const nameFilter = `customerName = '${customer.name.replace(/'/g, "\\'")}'`;
|
||||
const result = await pb.collection('quotes').getList(1, 100, {
|
||||
filter: nameFilter,
|
||||
sort: '-createdAt',
|
||||
fields: 'id,customerName,vehicleInfo,vin,mileage,status,createdAt,total,serviceAdvisor,services,notes',
|
||||
});
|
||||
|
||||
// ❌ Will return zero records — customerId is empty on all existing data
|
||||
const result = await pb.collection('quotes').getList(1, 100, {
|
||||
filter: `customerId = '${customerId}'`,
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
### Quote services JSON — display pattern
|
||||
|
||||
The `services` field stores a JSON array of service items. **PocketBase may return this field as either a pre-parsed JavaScript array OR a raw JSON string.** Always normalize:
|
||||
|
||||
```typescript
|
||||
function parseQuoteServices(raw: any): QuoteServiceItem[] {
|
||||
if (!raw) return [];
|
||||
let items = raw;
|
||||
if (typeof raw === 'string') {
|
||||
try { items = JSON.parse(raw); } catch { return []; }
|
||||
}
|
||||
if (!Array.isArray(items)) return [];
|
||||
return items.map((s: any) => ({
|
||||
name: s.name || 'Service',
|
||||
price: typeof s.price === 'number' ? s.price : 0,
|
||||
customerDecision: s.customerDecision || 'pending',
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
The expandable row shows each service name with its price and a colored decision badge:
|
||||
|
||||
```tsx
|
||||
{/* In a <tbody>, each quote row is followed by a detail row */}
|
||||
<>
|
||||
<tr key={q.id} className="cursor-pointer" onClick={() => toggleQuote(q.id)}>
|
||||
{/* summary columns: date, vehicle, total, status, advisor */}
|
||||
</tr>
|
||||
<tr key={q.id + '-detail'}>
|
||||
<td colSpan={5} className="p-0">
|
||||
{isExpanded && (
|
||||
<div className="bg-gray-50 px-6 py-4 dark:bg-gray-800/50">
|
||||
{/* Service line items with name + price + Approved/Declined badge */}
|
||||
{/* Notes if present */}
|
||||
{/* VIN if present */}
|
||||
{/* Collapse button */}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
```
|
||||
|
||||
**Adjacent `<tr>` elements in a `<tbody>` must be wrapped in a fragment (`<>...</>`) to satisfy JSX parsing.** Each `<tr>` retains its `key` prop.
|
||||
|
||||
### Quote status color mapping for UI
|
||||
|
||||
```typescript
|
||||
const qStatus = q.status === 'converted' ? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: q.status === 'sent' ? 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: q.status === 'declined' ? 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
: 'bg-gray-50 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400';
|
||||
```
|
||||
|
||||
## Repair Orders Collection Schema
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `customerId` | text | ⚠ EMPTY ON ALL 55 EXISTING RECORDS — use `customerName` |
|
||||
| `customerName` | text | |
|
||||
| `customerPhone` | text | Exists in DB but missing from TS `RepairOrder` type — add if used |
|
||||
| `customerEmail` | text | Exists in DB but missing from TS `RepairOrder` type — add if used |
|
||||
| `roNumber` | text | |
|
||||
| `vehicleInfo` | text | e.g. "2019 Honda Pilot" |
|
||||
| `vin` | text | |
|
||||
| `mileage` | text | |
|
||||
| `status` | text | Values: `active`, `in_progress`, `waiting_parts`, `waiting_pickup`, `completed`, `delivered` |
|
||||
| `workStatus` | text | Legacy values: `active`, `in-progress`, `waiter`, `completed`, `delivered` |
|
||||
| `writeupTime` | text | ISO datetime — timestamp of RO creation |
|
||||
| `estimatedTime` | text | Hours as string (e.g. `"1.5"`) — **NOT** `estimatedDuration` (minutes number) |
|
||||
| `promisedTime` | text | ISO datetime of promised completion time |
|
||||
| `completedTime` | text | |
|
||||
| `technician` | text | |
|
||||
| `notes` | text | |
|
||||
| `services` | json | JSON-stringified array of service objects |
|
||||
| `financial` | json | Financial breakdown + customerType storage |
|
||||
| `createdAt` | text | ISO datetime |
|
||||
| `updatedAt` | text | ISO datetime |
|
||||
|
||||
Fetch repair orders by customer name:
|
||||
|
||||
```typescript
|
||||
const result = await pb.collection('repairOrders').getList(1, 100, {
|
||||
filter: `customerName = '${customerName}'`,
|
||||
sort: '-createdAt',
|
||||
fields: 'id,roNumber,customerId,customerName,vehicleInfo,vin,mileage,status,workStatus,createdAt,completedTime,writeupTime,technician,notes,services',
|
||||
});
|
||||
```
|
||||
|
||||
## Other Schema Notes
|
||||
|
||||
- `createdAt` / `updatedAt` (not `created` / `updated`) on most collections
|
||||
- `userId` is a plain `text` field (not a `relation` type) on all user-owned collections
|
||||
- `financial` is a `JSON` field on `repairOrders`
|
||||
- `services` is a `text` field on `repairOrders` (JSON-stringified array)
|
||||
- One superuser admin exists (not `_superusers` collection — legacy `/api/admins/` auth returns 404)
|
||||
- User auth uses `_superusers` collection via `POST /api/collections/_superusers/auth-with-password` (PB v0.39+)
|
||||
|
||||
## Cross-Page Data Passing: URL Query Parameters
|
||||
|
||||
### When to use
|
||||
|
||||
Passing record context between pages when the source page (e.g., Repair Orders) needs to pre-populate a form on the target page (e.g., Quote Generator). The alternative is global state (Zustand store) or React Router location state, but URL params are preferred when:
|
||||
|
||||
- The data should be shareable/bookmarkable
|
||||
- The target page needs to work standalone (refresh-safe)
|
||||
- Only flat key-value pairs need to pass (no complex nested objects)
|
||||
|
||||
### Implementation pattern
|
||||
|
||||
**Sender** (source page):
|
||||
|
||||
```typescript
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleGenerateQuote = () => {
|
||||
const params = new URLSearchParams({
|
||||
name: ro.customerName || '',
|
||||
phone: ro.customerPhone || '',
|
||||
vehicleInfo: ro.vehicleInfo || '',
|
||||
vin: ro.vin || '',
|
||||
mileage: ro.mileage || '',
|
||||
roNumber: ro.roNumber || '',
|
||||
serviceAdvisor: ro.advisorName || '',
|
||||
});
|
||||
navigate(`/quote?${params.toString()}`);
|
||||
};
|
||||
```
|
||||
|
||||
**Receiver** (target page):
|
||||
|
||||
```typescript
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const editId = searchParams.get('edit');
|
||||
|
||||
// Pre-populate from RO data passed via URL params (runs once on mount)
|
||||
useEffect(() => {
|
||||
if (editId) return; // don't pre-populate when editing an existing quote
|
||||
|
||||
const name = searchParams.get('name');
|
||||
if (!name) return; // no RO data — nothing to pre-fill
|
||||
|
||||
setCustomerInfo({
|
||||
name,
|
||||
phone: searchParams.get('phone') || '',
|
||||
vehicleInfo: searchParams.get('vehicleInfo') || '',
|
||||
vin: searchParams.get('vin') || '',
|
||||
mileage: searchParams.get('mileage') || '',
|
||||
roNumber: searchParams.get('roNumber') || '',
|
||||
serviceAdvisor: searchParams.get('serviceAdvisor') || '',
|
||||
});
|
||||
}, [editId]); // depends on editId so it re-runs if editId changes
|
||||
```
|
||||
|
||||
### Pitfalls
|
||||
|
||||
- **URL length limits.** Browsers cap URLs at ~2000 characters. For large objects (e.g., full service lists with 20+ items), use router state or a store instead.
|
||||
- **URI encoding.** `URLSearchParams.toString()` handles encoding automatically. Manually building query strings with template literals will break on special characters like `&` or `=` in values.
|
||||
- **Empty string values.** An empty `phone` param still appears in the URL (`&phone=`). This is harmless — the receiver's fallback-to-empty-string handles it.
|
||||
- **The effect dependency array matters.** Using `[]` (empty) means the pre-population runs on every mount, including when loading an existing quote for editing. Guard against this by checking `editId`. Using `[editId]` ensures the effect re-runs if the user switches between "new from RO" and "edit existing" flows.
|
||||
- **Competition with edit mode.** The URL may contain both `?edit=...` (load existing quote for editing) and `?name=...&phone=...` (pre-populate from RO). The `editId` guard in the effect prevents the pre-population from overwriting data loaded from the existing quote record.
|
||||
Reference in New Issue
Block a user