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