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