Files
hermes-config/skills/software-development/pocketbase-development/references/quote-to-ro-approved-services-sync.md
T
2026-07-12 10:17:17 -04:00

203 lines
8.9 KiB
Markdown

# 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.