Files
2026-07-12 10:17:17 -04:00

8.9 KiB

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

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:

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:

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:

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:

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:

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:

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:

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:

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