initial commit

This commit is contained in:
ray
2026-07-12 10:17:17 -04:00
commit dab5a4ebc6
1424 changed files with 330463 additions and 0 deletions
@@ -0,0 +1,127 @@
# DeepSeek API Integration with PocketBase React Apps
## Pattern
React frontend → nginx `/deepseek/` proxy → `https://api.deepseek.com/` with API key.
## Server Proxy (nginx)
```nginx
location /deepseek/ {
proxy_pass https://api.deepseek.com/;
proxy_ssl_server_name on;
proxy_ssl_name api.deepseek.com;
proxy_http_version 1.1;
proxy_set_header Host api.deepseek.com;
proxy_set_header Authorization "Bearer sk-...";
proxy_set_header Content-Type "application/json";
proxy_buffering off;
proxy_read_timeout 120s;
}
```
## Client Module (`src/lib/ai.ts`)
Core fetch helper — all AI calls go through this:
```typescript
async function callDeepSeek(
systemPrompt: string,
userContent: string,
maxTokens = 500,
temperature = 0,
): Promise<string | null> {
const res = await fetch('/deepseek/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userContent },
],
temperature,
thinking: { type: 'disabled' as const },
max_tokens: maxTokens,
}),
});
if (!res.ok) return null;
const data = await res.json();
return data?.choices?.[0]?.message?.content || null;
}
```
**Critical:** Set `thinking: { type: 'disabled' }` — without it, `deepseek-v4-flash` spends all token budget on reasoning content and returns empty `content`.
## Three Functions We Built
### 1. AI Write Explanation — generates professional customer-facing service descriptions
```typescript
export async function aiWriteExplanation(
params: ExplanationParams,
): Promise<ExplanationResult | null>
```
Takes `serviceName`, `recommendation`, optional `technicianNotes`, `vehicleInfo`, `mileage`, `maintenanceInterval`. Returns `{level: 'CRITICAL'|'RECOMMENDED'|'OPTIONAL'|'PREVENTIVE'|'MAINTENANCE', explanation: string}`.
Parses `LEVEL:` and `EXPLANATION:` from the AI response.
### 2. Generate Priorities — ranks services by safety criticality
```typescript
export async function getPriorityAnalysis(
services: ServiceItem[],
): Promise<PriorityResult[] | null>
```
Returns `{name, rank, priority_reason}[]` sorted by rank. AI prompt forces safety-first ordering: CRITICAL_SAFETY > SAFETY_CONCERN > RECOMMENDED > MAINTENANCE > OPTIONAL.
### 3. AI Suggest Services — recommends services from catalog based on vehicle + mileage
```typescript
export async function aiSuggestServices(
vehicle: SuggestVehicle,
selectedServices: string[],
catalog: CatalogItem[],
): Promise<SuggestResult[] | null>
```
Only returns services that exist in the provided catalog (cross-references by name). Uses mileage-based maintenance intervals.
## Response Parsing Safety
Always strip markdown code blocks before JSON parsing:
```typescript
function stripCodeBlocks(text: string): string {
return text.trim().replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim();
}
```
All functions return `null` on failure (not throwing) so callers can handle gracefully with toast notifications.
## Fallback: Local Ollama
If the DeepSeek API is unavailable, proxy to Ollama instead. Change the proxy URL and model name:
```nginx
location /deepseek/ {
proxy_pass http://127.0.0.1:11434/;
...
}
```
```typescript
// In ai.ts
model: 'qwen2.5:14b', // or llava:13b
```
## UI Integration
Add Sparkles/ListOrdered/Lightbulb buttons to the service management UI. Each button:
1. Sets loading state (disables button, shows spinner)
2. Calls the AI function
3. On success: updates the service/quote state, shows success toast
4. On failure: shows error toast, re-enables button
5. Always: re-enables button in finally block
@@ -0,0 +1,111 @@
# Customer DB Integration in SPQ-v2 (All Forms → Customers Collection)
## Overview
When a shop creates a quote, appointment, or repair order, the customer info should be saved to the `customers` PocketBase collection and linked via `customerId` across all record types. This reference documents the full pattern as applied to Quotes, Appointments, and Repair Orders.
## Architecture
```
User types first/middle/last name + phone in form
debounced client-side customer matching (400ms)
Match found? ─yes─→ Show "Use Existing Customer" banner
| ↓
no User clicks "Use"
| ↓
↓ Form populated, customerId set
Auto-create on save
(ensureCustomerRecord)
customers collection (PB)
customerId on primary record (appointment / RO / quote)
```
## Shared Helper Library
**`src/lib/customerName.ts`** provides three reusable functions:
- **`composeName(first, middle, last)`** — joins parts into a single `name` string
- **`parseName(fullName)`** — splits into `{ firstName, middleName, lastName }` (first-word/last-word heuristic)
- **`findMatchingCustomer<T>(customers, query)`** — generic matcher: phone (exact 3+ digits) → exact full name → partial name. Generic so it preserves the input type.
### Type Changes
**`CustomerFormData`** (src/components/customers/types.ts):
- Added `firstName`, `middleName`, `lastName` alongside existing `name` field
- The `name` field is auto-composed from the three parts via `composeName()`
**`customerWriteSchema`** (src/schemas/customer.ts):
- Added optional `firstName`, `middleName`, `lastName` (defaults to '')
## Form-Specific Implementations
### AppointmentModal (src/components/appointments/AppointmentModal.tsx)
- **Inputs:** 3-column grid for First / Middle / Last Name (middle optional)
- **Customer matching:** Debounced 400ms, searches loaded customer list client-side
- **Suggestion banner:** Shows "Existing customer found — Name [Use] [Continue as New]"
- **Auto-create:** `ensureCustomerRecord()` on submit — creates customer in PB, sets `customerId`
- **Linked indicator:** Green badge "Linked to customer record: Name"
- **Props:** `customers: CustomerWithVehicles[]` (loaded by parent AppointmentsPage)
### ROForm (src/components/ROForm.tsx)
- Same 3-input layout as AppointmentModal
- Same matching + suggestion banner + use/dismiss controls
- Same `ensureCustomerRecord()` on submit
- Props: `customers?: CustomerWithVehicles[]` (optional, defaults to [])
- Threaded through `ROModal``RepairOrders.tsx` (parent fetches customer list)
### Customer Form (CustomerFormModal.tsx)
- Updated to use first/middle/last name inputs instead of single "Name" field
- Composes `name` field internally on save
### Quote Form (CustomerInfoPanel in QuoteGenerator)
- Same 3-name-input layout + customer search at top
- Duplicate detection via debounce + PB filter
- Auto-create via `ensureCustomerRecord()` on save/share
## Customer Matching Logic
Implemented in `findMatchingCustomer()` (customerName.ts):
1. **Phone match (exact):** If phone has 3+ digits, find customer whose phone (digits-only) equals query
2. **Exact name match:** If first + last name entered, find customer whose `name` equals composed name
3. **Partial name:** Try `startsWith` on customer name, then `includes`
**Important:** Matching is client-side on the loaded customers array — it does NOT use PB `~` filter which can 400 on some collections.
## Auto-Create on Save
`ensureCustomerRecord()` is called before saving any record when no `customerId` exists:
1. If customerId already set → return it
2. Get userId from pb.authStore
3. Compose name from first/middle/last
4. Double-check for existing customer (in case one was added since page load)
5. If found → auto-link (set customerId, return id)
6. If not found → `pb.collection('customers').create({ name, phone, email, ... })`
7. Return new id
## CustomerId Propagation
- **Appointment → Check-In:** The check-in flow reads `appointment.customerId` and passes to RO create
- **Quote → RO Conversion:** `customerId` is passed through in QuoteGenerator's RO conversion
- **Customers page detail view:** Shows linked ROs by querying `customerId` or (legacy) `customerName`
## Key Files
| File | Role |
|------|------|
| `src/lib/customerName.ts` | Shared helpers (composeName, parseName, findMatchingCustomer) |
| `src/components/appointments/AppointmentModal.tsx` | First/middle/last inputs + matching + auto-save |
| `src/components/ROForm.tsx` | Same pattern on RO creation/edit |
| `src/components/customers/CustomerFormModal.tsx` | Customer CRUD modal (also first/middle/last) |
| `src/components/customers/types.ts` | CustomerRecord, CustomerWithVehicles, CustomerFormData |
| `src/schemas/customer.ts` | Zod schema (firstName/middleName/lastName fields) |
@@ -0,0 +1,58 @@
# Nginx Config Construction with API Keys (Secrets Filter Safety)
## Problem
When reading an existing nginx config that contains API keys, the Hermes secrets filter
truncates the key in the displayed output. If you then `tee` or `cp` that truncated
output into a new config file, the API key becomes corrupted — resulting in
`Authentication Fails, Your api key is invalid` on all proxied API calls.
## Safe Pattern
Build the nginx config inline using environment variable values instead of copying
from a truncated config file:
```python
# Safe: construct config with env var key, write directly
import os
key = os.environ.get('AUXILIARY_APPROVAL_API_KEY', '')
config = f'''
location /deepseek/ {{
proxy_pass https://api.deepseek.com/;
proxy_ssl_server_name on;
proxy_ssl_name api.deepseek.com;
proxy_http_version 1.1;
proxy_set_header Host api.deepseek.com;
proxy_set_header Authorization "Bearer {key}";
proxy_set_header Content-Type "application/json";
proxy_buffering off;
proxy_read_timeout 120s;
proxy_connect_timeout 10s;
}}
'''
with open('/tmp/nginx-spq.conf', 'w') as f:
f.write(config)
```
Then deploy: `sudo cp /tmp/nginx-spq.conf /etc/nginx/sites-enabled/shopproquote`
## Verifying the Key
After deploying, test the endpoint. A working key returns a chat completion.
An invalid/corrupted key returns `{"error":{"message":"Authentication Fails..."}}`.
```bash
curl -s -X POST https://site/deepseek/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}],"temperature":0,"max_tokens":20}'
```
## Env Var Locations
DeepSeek API keys are typically set as:
- `DEEPSEEK_API_KEY` — used by Hermes config via `${DEEPSEEK_API_KEY}`
- `AUXILIARY_APPROVAL_API_KEY` — set by Hermes secret store
- `AUXILIARY_VISION_API_KEY` — set by Hermes secret store
- `AUXILIARY_WEB_EXTRACT_API_KEY` — set by Hermes secret store
Check with: `python3 -c "import os; print(len(os.environ.get('AUXILIARY_APPROVAL_API_KEY','')))"`
@@ -0,0 +1,66 @@
# Production nginx Config for React SPA + PocketBase
## Full Pattern
```nginx
server {
listen 443 ssl;
server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain/privkey.pem;
root /path/to/spa/dist;
index index.html;
# SPA routing — all paths fall back to index.html
location / {
try_files $uri $uri/ /index.html;
}
# PocketBase SDK proxy (client uses /pb as base URL)
location /pb/ {
proxy_pass http://127.0.0.1:8091/;
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;
}
# PocketBase REST API proxy
location /api/ {
proxy_pass http://127.0.0.1:8091/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# DeepSeek AI API proxy
location /deepseek/ {
proxy_pass https://api.deepseek.com/;
proxy_ssl_server_name on;
proxy_set_header Host api.deepseek.com;
proxy_set_header Authorization "Bearer sk-...";
proxy_buffering off;
proxy_read_timeout 120s;
}
# Local LLM proxy (Ollama)
location /llm/ {
proxy_pass http://127.0.0.1:11434/;
proxy_http_version 1.1;
proxy_set_header Host "localhost";
proxy_buffering off;
proxy_read_timeout 120s;
}
}
```
## Key Details
- **SPA routing**: `try_files $uri $uri/ /index.html;` is critical — without it, refreshing on a client-side route returns 404.
- **PB proxy**: two locations (`/pb/` and `/api/`) — the PB JS SDK uses `/pb` as its base URL, but the SDK may also call `/api/` directly.
- **SSL**: Use Let's Encrypt certbot — `sudo certbot --nginx -d your-domain.com`.
- **Reload**: After editing, `sudo nginx -t && sudo systemctl reload nginx`.
- **Dist path**: Point `root` at the Vite `dist/` folder, not the source.
@@ -0,0 +1,77 @@
# Exporting PDF Pages as PNG Images
When you need to export every page of a jsPDF document as individual PNG images (e.g., for sharing in chat, embedding in emails, or image-only workflows):
## Installation
```bash
npm install pdfjs-dist
```
## Worker Setup (main.tsx or entry point)
```typescript
import * as pdfjsLib from 'pdfjs-dist';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
```
## Implementation Pattern
The function accepts EITHER a jsPDF instance OR a Blob (from a function that returns `doc.output('blob')`):
```typescript
export async function downloadPdfAsImages(
pdfInput: jsPDF | Blob,
customerName: string,
): Promise<void> {
// 1. Get raw PDF bytes
let arrayBuffer: ArrayBuffer;
if (pdfInput instanceof Blob) {
arrayBuffer = await pdfInput.arrayBuffer();
} else {
arrayBuffer = pdfInput.output('arraybuffer');
}
// 2. Load into pdfjs
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
// 3. Render each page to canvas, then trigger download
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
const page = await pdf.getPage(pageNum);
const viewport = page.getViewport({ scale: 2 }); // 2x = Retina
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d')!;
await page.render({ canvasContext: ctx, viewport }).promise;
const blob = await new Promise<Blob>((resolve) => {
canvas.toBlob((b) => resolve(b!), 'image/png');
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${customerName.replace(/\s+/g, '_')}_Page${pageNum}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
page.cleanup();
}
}
```
## Key Details
- **Scale**: `getViewport({ scale: 2 })` = 144 DPI. Use 1 for smaller files, 3-4 for print quality.
- **Blob path**: If you have a function that already builds the PDF and returns `doc.output('blob')`, pass the blob directly — you don't need access to the jsPDF instance.
- **Browser download**: Each page triggers its own download. Browsers batch them into the download bar.
- **Memory**: `URL.revokeObjectURL()` and `page.cleanup()` prevent leaks with many pages.
- **Dependency**: `pdfjs-dist` adds ~2MB to the bundle (worker + core).
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Serve a Vite-built React SPA + proxy /pb/* to a PocketBase instance.
Usage: python3 proxy-server.py [--dist DIR] [--pb URL] [--port PORT]
Defaults: dist=./dist, pb=http://127.0.0.1:8091, port=4173
"""
from http.server import HTTPServer, SimpleHTTPRequestHandler
import urllib.request, os, argparse
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=opts.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(opts.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/
# Just strip the /pb prefix. Do NOT add another /api.
url = opts.pb + self.path
if self.path.startswith('/pb'):
url = opts.pb + self.path[3:]
if not url.startswith(opts.pb + '/api'):
url = opts.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__':
p = argparse.ArgumentParser()
p.add_argument('--dist', default='./dist')
p.add_argument('--pb', default='http://127.0.0.1:8091')
p.add_argument('--port', type=int, default=4173)
opts = p.parse_args()
server = HTTPServer(('0.0.0.0', opts.port), Handler)
print(f'Serving {opts.dist} on http://0.0.0.0:{opts.port} '
f'(PB proxy → {opts.pb})')
server.serve_forever()
@@ -0,0 +1,92 @@
# Phone-Verification Gate for Public Share/Approval Pages
## Problem
You have a public share link (UUID-based token in URL) that lets customers approve or decline services. Anyone with the URL can access it — forwarding the link gives full control. You need a low-friction identity check without adding SMS gateways, email infrastructure, or database changes.
## Solution
Gate the page behind a **last-4-digits phone verification** screen. The customer's phone number is already stored on the record (`customerPhone`). Show a simple 4-digit input before revealing the decision UI.
## Pattern
### 1. Add a new page status
```ts
type PageStatus = 'loading' | 'invalid' | 'expired' | 'done' | 'error' | 'phone_verify';
const MAX_PHONE_ATTEMPTS = 3;
```
### 2. Add state variables
```ts
const [phoneInput, setPhoneInput] = useState('');
const [phoneAttempts, setPhoneAttempts] = useState(0);
```
### 3. Extract phone from record and gate
After loading the record, check if a phone exists (≥4 digits). If yes → `'phone_verify'`, else skip to `'done'`:
```ts
const rawPhone = (record.customerPhone as string) || '';
const hasPhone = rawPhone.replace(/\D/g, '').length >= 4;
setPageStatus(hasPhone ? 'phone_verify' : 'done');
```
**Important:** Do NOT call `setPageStatus('done')` unconditionally after loading — the phone-verify check must be the one that sets it.
### 4. Verification handler
```ts
function handlePhoneVerify() {
if (!record) return;
const digitsOnly = record.customerPhone.replace(/\D/g, '');
const last4 = digitsOnly.slice(-4);
const inputDigits = phoneInput.replace(/\D/g, '');
if (inputDigits === last4) {
setPhoneAttempts(0);
setPageStatus('done');
} else {
const newAttempts = phoneAttempts + 1;
setPhoneAttempts(newAttempts);
setPhoneInput('');
if (newAttempts >= MAX_PHONE_ATTEMPTS) {
setError('Too many incorrect attempts. Please contact the shop directly.');
} else {
setError(`Incorrect. ${MAX_PHONE_ATTEMPTS - newAttempts} attempt(s) remaining.`);
}
}
}
```
### 5. Verification UI (screen component)
Render this when `pageStatus === 'phone_verify'`:
- Logo/branding header (reuse same accent color)
- Customer name display ("This quote is for John")
- Instruction: "Enter last 4 digits of phone on file"
- Large centered input (24px font, letter-spacing, `inputMode="numeric"`)
- Submit button (disabled until 4 digits entered)
- Enter key support (`onKeyDown`)
- Locked state after max attempts (show shop phone number, hide input)
### 6. Skip when no phone
If the record has no phone or <4 digits, the verification screen never shows — backward compatible with existing records.
## Key Details
- **Stripping non-digits:** Always normalize both sides with `.replace(/\D/g, '')` before comparing. Phone formats vary (555-0100, +1 555 0100, 555.0100).
- **Last 4 only:** Slice with `.slice(-4)` — handles any phone length globally.
- **3 attempts max:** Prevents brute force. After lockout, show a "call the shop" message with the shop's business phone from settings.
- **No backend changes:** The phone is already on the record the token gives access to. The verification is client-side — anyone who already has the token can see the record, but can't submit decisions without the verification step.
- **Field name:** `customerPhone` matches the PocketBase collection field name used in SPQ-v2's `quoteWriteSchema`.
## Design Notes
- The input uses `tracking-[0.5em]` for wide spacing between digits (easy to read)
- `autoFocus` and `maxLength={4}` make it feel like a PIN entry
- On Enter, submit the verify, not the whole form
- The shop's business phone number (`settings.businessPhone`) is shown on the lockout screen
@@ -0,0 +1,79 @@
# React Infinite Re-Render Loops from Unstable Hook Callbacks
## Symptom
A component re-fetches data in a continuous loop — data loads, then immediately loads again. The component tree doesn't crash but the page is unusable due to constant loading spinners or flickering content.
## Root Cause
A **custom hook** stores a caller-provided callback function in a `useCallback`/`useMemo`/`useEffect` dependency array. The **caller passes an inline arrow function** like `(page, perPage) => fetchData(userId, page, perPage)` that becomes a new reference on every render. This creates:
```
render → new fetchPage → load callback changes → useEffect fires → setState → re-render → new fetchPage → ...
```
### Self-Diagnosis Checklist
- [ ] The component uses a custom hook (usePagedList, useAsync, useQuery, etc.)
- [ ] The hook receives a callback function as an argument
- [ ] The callback is defined inline: `(args) => someFunction(data, args)`
- [ ] Inside the hook, the callback appears in a `useCallback` or `useEffect` dependency array
- [ ] API calls fire continuously with no user interaction
## Fix Patterns
### Pattern A — Ref inside the hook (robust)
Modify the custom hook to store the callback in a `useRef`. This breaks the dependency chain:
```typescript
export function usePagedList<T>(
fetchPage: (page: number, perPage: number) => Promise<PagedResult<T>>,
perPage = 50,
) {
// Store in ref so the hook's internal callbacks don't depend on fetchPage
const fetchRef = useRef(fetchPage);
fetchRef.current = fetchPage;
const load = useCallback(async (p: number, append: boolean) => {
// Use fetchRef.current instead of fetchPage
const result = await fetchRef.current(p, perPage);
// ...
}, [perPage]); // fetchPage NOT in deps — stable
useEffect(() => { load(1, false); }, [load]); // fires only once
// ...
}
```
### Pattern B — Memoize at the call site (specific)
For third-party hooks you can't modify:
```typescript
const fetchAssignments = useCallback(
(page: number, perPage: number) => fetchData(userId, page, perPage),
[userId], // stable reference as long as userId doesn't change
);
const { items } = usePagedList(fetchAssignments, 50);
```
Pattern A is preferred because it fixes all callers automatically and prevents future regressions.
## Real-World Example
In the SPQ project, `TechnicianJobs.tsx` used `usePagedList` with:
```typescript
const { items } = usePagedList(
(page, perPage) => {
if (!effectiveUserId) return Promise.resolve(emptyResult);
return fetchTechnicianAssignments(effectiveUserId, page, perPage);
},
50,
);
```
Every render created a new arrow function → `load` callback changed → `useEffect` re-fired → API call → setState → re-render → loop.
Fix applied: `usePagedList` now stores `fetchPage` in a `useRef` (Pattern A). This prevents the same bug for any future caller.
@@ -0,0 +1,100 @@
# Same SPA Build, Different Behavior Per Domain
## Scenario
Same build (single `dist/` folder, same nginx server block) serves multiple domains. Feature works on domain A but fails on domain B with a Zod validation error like "Invalid input." No code or API differences exist.
## Investigation Checklist
Before touching any code:
```
Build layer
├── Same dist/ folder? → check nginx `root` directive
└── Same .env baked in? → check VITE_ prefixed vars at build time
Runtime layer
├── Same API instance? → check proxy_pass targets
├── Same user data? → query the API for same record
└── Same auth token? → check cookies/localStorage
Client layer
├── localStorage (per origin) ← MOST COMMON CULPRIT
├── sessionStorage (per tab)
├── IndexedDB (per origin)
├── Service worker cache (per origin)
└── Cookies (per origin + path)
```
## Common Root Cause: Stale Persisted Client State
App version N persisted a specific state shape to localStorage (Zustand `persist` middleware, Redux `persist`, or raw `localStorage.setItem`). Version N+1 changed the schema — added required fields, changed enum values, removed fields. The browser on domain B still has the old shape rehydrated, and it fails Zod validation.
### Diagnosis Steps
**1. Confirm code is identical across domains:**
```bash
# Check nginx config — same root for all server_names?
cat /etc/nginx/sites-enabled/your-site | grep -E "server_name|root "
```
**2. Check API — same backend?**
Same `proxy_pass` target for all domains.
**3. Trace the validation path in the failing handler:**
```ts
// Look for schema validation like this:
const parsed = quoteWriteSchema.safeParse(data);
if (!parsed.success) {
console.warn('Validation failed:', JSON.stringify(parsed.error.issues, null, 2));
showToast(parsed.error.issues[0].message, 'error');
}
```
**4. On the failing domain, open DevTools Console → trigger the action.** Look for the validation warning — it names the exact field that's failing:
```
Quote schema validation failed: [{"code":"invalid_enum_value","path":["discountType"],...}]
```
**5. Check localStorage for the persisted state key:**
DevTools → Application → Local Storage → `failing-domain.com` → find the Zustand/Redux persist key (e.g., `spq-quote`).
### Fields Most Likely to Fail with Stale State
| Field type | Schema rule | Failure symptom |
|------------|-------------|-----------------|
| `z.enum([...])` | Required enum, no default | "Invalid enum value" / "Invalid input" |
| `z.string().min(1)` | Required non-empty | "String must contain at least 1 character(s)" |
| `z.number()` | Expected number, was string | "Expected number, received string" |
| `z.array().min(1)` | Non-empty array | "At least one service is required" |
| Nested object | New field added post-release | "Required" on the new field |
### Fix (Short-term)
Clear localStorage for the failing domain:
```js
localStorage.removeItem('spq-quote') // or whatever the persist key is
```
### Fix (Long-term — code)
Make the schema forward-compatible so old persisted data doesn't fail:
- Use `.optional().default('')` or `.optional().default(someValue)` on fields added after initial release
- Add a migration in the `persist` middleware's `onRehydrateStorage` callback
- Strip nulls and fill defaults before Zod validation (like `sanitizeServices()`)
- Use `z.union([z.string(), z.undefined()]).optional().default('')` for fields that may be missing
## Anti-patterns
- **Modifying source code thinking domains have different builds** — always check nginx first
- **Adding environment-specific toggles** — the code IS identical, the difference is client-side
- **Blaming the API** — both domains hit the same backend instance
The correct first step: check nginx config → confirm same build → check localStorage on both domains → reproduce the validation failure in the console → read the Zod error path to identify which field is stale.
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Serve SPA dist + proxy /pb to PocketBase + /deepseek to api.deepseek.com.
Reproducible from the spq-v2 deployment session. Drops into any PocketBase-backed
React SPA project that uses /pb as its PocketBase base URL. Serves the Vite build
output with SPA fallback, CORS headers, and PB/deepseek API proxying.
Usage:
python3 spa-pb-proxy.py
Env vars (optional — defaults are for the ShopProQuote spq-v2 deployment):
DIST_DIR - path to the Vite dist/ directory
PB_URL - PocketBase URL (default: http://127.0.0.1:8091)
PORT - listen port (default: 4173)
DS_KEY_FILE - path to file containing DeepSeek API key (default: /tmp/deepseek_key.txt)
"""
import os
import urllib.request
from http.server import HTTPServer, SimpleHTTPRequestHandler
DIST = os.environ.get('DIST_DIR', '/path/to/dist')
PB = os.environ.get('PB_URL', 'http://127.0.0.1:8091')
PORT = int(os.environ.get('PORT', '4173'))
DS_KEY_FILE = os.environ.get('DS_KEY_FILE', '/tmp/deepseek_key.txt')
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', '/api', '/deepseek')):
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', '/api', '/deepseek')):
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):
# DeepSeek API
if self.path.startswith('/deepseek'):
url = 'https://api.deepseek.com' + self.path[len('/deepseek'):]
# PB SDK: /pb/api/... → http://pb/api/...
elif self.path.startswith('/pb'):
url = PB + self.path[3:]
else:
url = PB + self.path
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)
if self.path.startswith('/deepseek') and os.path.exists(DS_KEY_FILE):
with open(DS_KEY_FILE) as f:
req.add_header('Authorization', f'Bearer {f.read().strip()}')
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', PORT), Handler)
print(f'Serving {DIST} on http://0.0.0.0:{PORT} (PB → {PB}, DeepSeek → api.deepseek.com)')
server.serve_forever()
@@ -0,0 +1,124 @@
# SPQ PocketBase Field Mapping — Frontend ↔ DB
## Critical: Frontend Field Names ≠ PB Column Names
The `repairOrders` collection schema has different field names than what the TypeScript types use. Saving to the wrong name causes **silent data loss** — PB accepts unknown fields through the API but does NOT persist them.
### Frontend → PB Translation Table
| TypeScript (types.ts) | PB Column | PB Type | Translation |
|---|---|---|---|
| `estimatedDuration: number` (minutes) | `estimatedTime` | TEXT | `(estimatedDuration / 60).toFixed(1)` → stores hours string like `"1.5"` |
| `customerType: 'waiter' \| 'drop-off'` | N/A — no column | — | Store in `financial` JSON blob: `JSON.stringify({ ...existingFinancial, customerType })` |
| `writeupTime: string` (ISO) | `writeupTime` | TEXT | Direct match ✓ |
| `promisedTime: string` (ISO) | `promisedTime` | TEXT | Direct match ✓ |
### Normalize on Load
In `fetchOrders` normalization, convert PB fields back to frontend types:
```typescript
// PB returns estimatedTime as hours string → convert to minutes number
let estimatedDuration: number | undefined;
if (item.estimatedTime && item.estimatedTime !== '') {
const hours = parseFloat(item.estimatedTime);
if (!isNaN(hours)) estimatedDuration = Math.round(hours * 60);
}
// PB returns financial JSON blob → extract customerType
let customerType: CustomerType | undefined;
if (item.financial) {
try {
const fin = typeof item.financial === 'string' ? JSON.parse(item.financial) : item.financial;
if (fin && (fin.customerType === 'waiter' || fin.customerType === 'drop-off')) {
customerType = fin.customerType;
}
} catch {}
}
return { ...item, services, estimatedDuration, customerType };
```
### Translate on Save
In `handleSave`, transform before sending to PB:
```typescript
const { estimatedDuration, customerType, ...rest } = data;
const payload = {
...rest,
userId,
services: JSON.parse(JSON.stringify(data.services || [])),
estimatedTime: estimatedDuration != null ? (estimatedDuration / 60).toFixed(1) : '',
};
// Store customerType in financial JSON
if (customerType) {
try {
const existing = typeof rest.financial === 'string'
? JSON.parse(rest.financial) : (rest.financial || {});
payload.financial = JSON.stringify({ ...existing, customerType });
} catch {
payload.financial = JSON.stringify({ customerType });
}
}
```
### For Add-Time / Inline Saves
When updating just the estimated duration inline (like the "+ Add Time" feature), save directly to `estimatedTime`:
```typescript
await pb.collection('repairOrders').update(id, {
estimatedTime: (newDuration / 60).toFixed(1),
});
```
### For Customer-Type Toggle Inline
When toggling waiter/drop-off inline, update the `financial` JSON blob:
```typescript
const ro = orders.find((o) => o.id === id);
let financial: Record<string, any> = {};
if (ro?.financial) {
try { financial = typeof ro.financial === 'string' ? JSON.parse(ro.financial) : ro.financial; } catch {}
}
financial.customerType = newType;
await pb.collection('repairOrders').update(id, {
financial: JSON.stringify(financial),
});
```
## RO Status Values (React App)
| TS Value | PB Value | Display | Badge |
|---|---|---|---|
| `active` | `active` | Active | Blue |
| `in_progress` | `in_progress` | In Progress | Yellow |
| `waiting_parts` | `waiting_parts` | Waiting Parts | Orange |
| `waiting_pickup` | `waiting_pickup` | Waiting Pick-up | Teal |
| `completed` | `completed` | Completed | Green |
| `delivered` | `delivered` | Delivered | Gray |
## Appointment to Repair Order (Check-In)
When converting an appointment into an RO, the `handleCheckInSubmit` maps fields like this:
| Appointment field | RO field |
|---|---|
| `customerName` | `customerName` |
| `customerPhone` | `customerPhone` |
| `customerEmail` | `customerEmail` |
| `vehicleInfo` | `vehicleInfo` |
| `advisorName` | `advisorName` |
| `notes` | `notes` (mapped from "customer concerns" input) |
| — | `vin` (prompted in check-in form) |
| — | `mileage` (prompted in check-in form) |
| — | `writeupTime` = `new Date().toISOString()` (auto-stamped) |
| — | `estimatedTime` = `(estimatedDuration / 60).toFixed(1)` (from hours+minutes input) |
| — | `financial` = `{ customerType }` (from radio toggle) |
| — | `status` = `'active'` (initial) |
| — | `roNumber` = auto-generated (e.g., `RO-20260629-0842`) |
After RO creation, appointment status changes to `checked_in`.