initial commit
This commit is contained in:
@@ -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
|
||||
+111
@@ -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()
|
||||
+92
@@ -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
|
||||
+79
@@ -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.
|
||||
+100
@@ -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`.
|
||||
Reference in New Issue
Block a user