Files
spq-v2/roadmap_5.2.md
2026-07-12 10:01:39 -04:00

1273 lines
60 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPQ-v2 Production Hardening Roadmap (v5.2) — Remaining Work
**Generated:** 2026-07-05 (cleaned 2026-07-07 — removed completed implementation steps)
**Source audit:** Production-readiness audit of SPQ-v2 (React 19 / Vite 8 / Tailwind v4 / Zustand / PocketBase 0.39)
**Scope:** Remaining items only — completed items (1.1, 1.5, 1.6, 1.7, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.8, 2.9, 2.10) are DONE and their implementation steps removed. See Execution Memos below for what was completed.
---
## Table of Contents
- [Conventions & Pre-flight](#conventions--pre-flight)
- [Phase 1 — Critical Fixes — Remaining Work](#phase-1--critical-fixes--remaining-work)
- [1.2 DeepSeek AI auth-injecting reverse proxy (server-side)](#12-deepseek-ai-auth-injecting-reverse-proxy-server-side)
- [1.3 Block self-signup role escalation (PB hook)](#13-block-self-signup-role-escalation-pb-hook)
- [1.4 Security headers + CSP (nginx snippet)](#14-security-headers--csp-nginx-snippet)
- [Phase 2 — Optimization & UX — Remaining Work](#phase-2--optimization--ux--remaining-work)
- [2.7 Logo uploads to PocketBase file records](#27-logo-uploads-to-pocketbase-file-records)
- [Phase 3 — Deployment & Monitoring](#phase-3--deployment--monitoring)
- [3.1 GlitchTip self-hosted (Docker)](#31-glitchtip-self-hosted-docker)
- [3.2 PocketBase production hardening](#32-pocketbase-production-hardening)
- [3.3 Frontend hosting (nginx static)](#33-frontend-hosting-nginx-static)
- [3.4 Reverse proxy for /pb and /api/ai/chat](#34-reverse-proxy-for-pb-and-apiaichat)
- [3.5 Structured logging](#35-structured-logging)
- [3.6 Offline banner](#36-offline-banner)
- [3.7 Dependency audit](#37-dependency-audit)
- [Phase 4 — Recommended Features](#phase-4--recommended-features)
- [4.1 Quote approval notifications](#41-quote-approval-notifications)
- [4.2 Quote expiry enforcement](#42-quote-expiry-enforcement)
- [4.3 Financial audit log (append-only, hash-chained)](#43-financial-audit-log-append-only-hash-chained)
- [4.4 Multi-vehicle customer history](#44-multi-vehicle-customer-history)
- [4.5 Technician time-card CSV export](#45-technician-time-card-csv-export)
- [4.6 Recurring maintenance reminders](#46-recurring-maintenance-reminders)
- [4.7 Offline-first for technicians (service worker)](#47-offline-first-for-technicians-service-worker)
- [4.8 Two-factor auth for advisors (TOTP)](#48-two-factor-auth-for-advisors-totp)
- [4.9 Data export / GDPR](#49-data-export--gdpr)
- [4.10 Multi-shop readiness (decision)](#410-multi-shop-readiness-decision)
- [Appendix A — Verification command cheat sheet](#appendix-a--verification-command-cheat-sheet)
- [Appendix B — PocketBase backup & migration procedure](#appendix-b--pocketbase-backup--migration-procedure)
- [Appendix C — Environment variable reference](#appendix-c--environment-variable-reference)
- [Execution Memos (Historical Record)](#execution-memos-historical-record)
---
## Conventions & Pre-flight
### Scope decisions (locked)
- AI proxy: self-hosted auth-injecting reverse proxy (nginx + tiny validator).
- PB migrations: included.
- No git / no CI — local home-server workflow.
- Hosting: nginx on the same box as PocketBase.
- Error tracking: self-hosted GlitchTip (Sentry-protocol compatible).
- Tests: zod schemas + vitest alongside each fix.
### Standing rules (from AGENTS.md)
- Before any PB migration, back up the DB (Appendix B).
- **Do not open `/tmp/opencode/pb-admin.txt` without permission.** Migration authoring does not require it.
- **Modal changes** must follow the background-location overlay pattern documented in AGENTS.md.
- **Verify after every step** with the commands in the step's `Verify:` block, not just at the end.
### Pre-flight: confirm the PocketBase container
Before any PB step, confirm the container ID (it may have changed since 2026-07-05):
```bash
docker ps --format '{{.ID}} {{.Image}} {{.Names}} {{.Ports}}' | grep pocketbase
```
Export it for the session:
```bash
export PB_CID=<id-from-above>
```
All `docker exec` commands in this roadmap use `$PB_CID`.
---
## Phase 1 — Critical Fixes — Remaining Work
### 1.2 DeepSeek AI auth-injecting reverse proxy (server-side)
**Status:** Frontend done (ai.ts sends PB token, handles 401/429, VITE_AI_ENABLED gate). Server-side pending.
**What remains — server-side installation:**
**Step 1 — Write the token validator (Node, no deps):**
```js
// /opt/spq/ai-proxy/validate.js
// Tiny HTTP service: returns 200 if the incoming Authorization Bearer token
// is a valid PocketBase user token, 401 otherwise. Called by nginx auth_request.
const http = require('http');
const PB_URL = process.env.PB_URL || 'http://127.0.0.1:8091';
const PORT = parseInt(process.env.PORT || '8092', 10);
const VALIDATE_PATH = '/api/collections/users/refresh';
const server = http.createServer(async (req, res) => {
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Bearer ')) {
res.writeHead(401); return res.end('no token');
}
try {
const r = await fetch(`${PB_URL}${VALIDATE_PATH}`, {
method: 'POST',
headers: { Authorization: auth }
});
res.writeHead(r.status === 200 ? 200 : 401);
res.end(r.status === 200 ? 'ok' : 'invalid');
} catch {
res.writeHead(502); res.end('pb unreachable');
}
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`[spq-ai-validator] listening on 127.0.0.1:${PORT}`);
});
```
**Step 2 — systemd unit:**
```ini
# /opt/spq/ai-proxy/spq-ai-validator.service
# Copy to /etc/systemd/system/ and: sudo systemctl enable --now spq-ai-validator
[Unit]
Description=SPQ DeepSeek token validator
After=network.target docker.service
[Service]
Type=simple
WorkingDirectory=/opt/spq/ai-proxy
ExecStart=/usr/bin/node validate.js
Environment=PB_URL=http://127.0.0.1:8091
Environment=PORT=8092
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
**Step 3 — nginx location (include in the server block from 3.3):**
```nginx
# /etc/nginx/snippets/spq-deepseek.conf
limit_req_zone $binary_remote_addr zone=spq_ai:10m rate=20r/m;
location /deepseek/ {
limit_req zone=spq_ai burst=5 nodelay;
auth_request /_spq_validate;
auth_request_set $spq_auth_status $upstream_status;
proxy_set_header Authorization "";
proxy_set_header Authorization "Bearer $http_deepseek_api_key";
rewrite ^/deepseek/(.*)$ /$1 break;
proxy_pass https://api.deepseek.com;
proxy_ssl_server_name on;
proxy_set_header Host api.deepseek.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
}
location = /_spq_validate {
internal;
proxy_pass http://127.0.0.1:8092/;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Authorization $http_authorization;
}
```
Write the API key to `/etc/nginx/snippets/deepseek-key.conf` (mode 0600, owner root) containing `set $deepseek_key "sk-...";` and `include` it inside the location.
**Step 4 — Fix the stale dev proxy in `vite.config.ts`:**
The `/deepseek` proxy currently targets `https://127.0.0.1:3448` with `secure: false` — that gateway no longer exists. Point at local nginx:
```ts
'/deepseek': {
target: 'http://127.0.0.1:80',
changeOrigin: true,
rewrite: (path) => path,
},
```
Or use a `.env.local` value: `target: process.env.VITE_DEEPSEEK_DEV_PROXY || 'http://127.0.0.1:80'`.
**Verify:**
```bash
npm run build
npm run lint
# Manual: with nginx + validator running, log in, open Quote Generator,
# trigger AI Suggest — should succeed. Without login — should 401.
```
---
### 1.3 Block self-signup role escalation (PB hook)
**Status:** NOT DONE — needs PB migration.
**Problem:** `src/pages/Login.tsx:96-104` sends `role: 'advisor'` from the client. PocketBase auth collections allow client-set fields on self-create, so a malicious signup can send `role: 'technician'` and `shopUserId: '<someone>'` to masquerade as a technician under any shop.
**Fix:** PB `onRecordBeforeCreate` hook on `users` that forces `role = 'advisor'` and clears `shopUserId`**unless** there is a matching pending, unexpired `technicianInvites` record for the email being registered.
**File:** `pb_migrations/1740000000020_users_create_role_guard.js`
```js
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
app.onRecordBeforeCreateRequest((e) => {
if (e.collection.name !== 'users') return;
const record = e.record;
const email = (record.get('email') || '').toString().trim().toLowerCase();
if (!email) return;
let invite = null;
try {
invite = e.dao.findFirstRecordByFilter(
'technicianInvites',
`email = "${email.replace(/"/g, '')}" && status = "pending" && expiresAt >= @now`
);
} catch {
invite = null;
}
if (invite) {
record.set('role', 'technician');
record.set('shopUserId', invite.get('shopUserId') || '');
} else {
record.set('role', 'advisor');
record.set('shopUserId', '');
}
});
},
(app) => {
console.log('[M20] rollback requested — remove this migration file and restart PocketBase');
}
);
```
**Also update Login.tsx** to stop sending `role`/`shopUserId` from the client (they're now server-set by the hook):
```ts
await pb.collection('users').create({
email: email.trim(),
password,
passwordConfirm: confirmPassword,
name: name.trim() || '',
displayName: name.trim() || '',
emailVisibility: true,
// role and shopUserId are now server-set by the M20 hook.
});
```
**Verify:**
```bash
npm run build
npm run lint
# Manual: sign up new account → check users.role == 'advisor', shopUserId == ''
# Try curl POST with role:'technician' → confirm role still 'advisor'
# Create a technician invite, accept via link → confirm role == 'technician'
```
---
### 1.4 Security headers + CSP (nginx snippet)
**Status:** Frontend cleanup done (Toast `<style>` injection already removed, keyframes in `index.css`). Nginx snippet pending.
**What remains — nginx snippet:**
```nginx
# /etc/nginx/snippets/spq-security.conf
# Include inside the server { } block for the SPQ frontend.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
```
Include in the server block (wired in step 3.3):
```nginx
server {
listen 443 ssl http2;
server_name spq.yourdomain.tld;
include /etc/nginx/snippets/spq-security.conf;
# ... (rest from 3.3)
}
```
**Verify:**
```bash
# After nginx reload:
curl -sI https://spq.yourdomain.tld/ | grep -iE 'content-security|x-content|referrer|permissions'
# Confirm Toast still animates (no console CSP violations).
```
---
## Phase 2 — Optimization & UX — Remaining Work
### 2.7 Logo uploads to PocketBase file records
**Status:** NOT DONE — needs PB migration.
**Problem:** `ShopSettings.logoUrl` stores a base64 data-URL. Bloats localStorage + every settings round-trip.
**Files:**
- New PB migration: `pb_migrations/1740000000022_settings_logo_file.js`
- Edit: `src/pages/Settings.tsx` (upload flow)
- Edit: `src/lib/settings.ts` (load file URL)
**Step 1 — PB migration adds a `logo` file field to `settings`:**
```js
// pb_migrations/1740000000022_settings_logo_file.js
migrate((app) => {
const col = app.findCollectionByNameOrId('settings');
if (!col.fields.find(f => f.name === 'logo')) {
col.fields.add(new FileField({
name: 'logo',
required: false,
maxSize: 5242880,
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'],
}));
app.save(col);
}
}, () => {});
```
**Step 2 — Frontend upload in Settings.tsx:**
```ts
const file = input.files?.[0];
if (file) {
const formData = new FormData();
formData.append('logo', file);
await pb.collection('settings').update(recordId, formData);
const url = pb.files.getURL({ id: recordId, collectionId: 'settings' }, 'logo');
setSettings(s => ({ ...s, logoUrl: url + '?thumb=200x200' }));
}
```
**Step 3 — Stop storing base64 in `logoUrl`. Update `QuoteApprove.tsx` and PDF generators to use the file URL (they already handle URLs).**
**Verify:**
```bash
npm run build && npm run lint && npm run test
# Manual: upload a logo in Settings, confirm it appears on quotes & PDFs.
```
---
## Phase 3 — Deployment & Monitoring (Hosting, Logging, Error Tracking)
### 3.1 GlitchTip self-hosted (Docker)
**Problem:** No client-side error tracking. `console.error` is the only signal.
**Files:**
- New: `/opt/glitchtip/docker-compose.yml`
- New: `.env.glitchtip` (server-side secrets)
- Edit: `src/lib/userMessages.ts` (wire `setErrorReporter`)
- Edit: `src/main.tsx` (init Sentry SDK pointed at GlitchTip)
**Step 1 — GlitchTip docker-compose:**
```yaml
# /opt/glitchtip/docker-compose.yml
version: "3.8"
services:
db:
image: postgres:15
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes: [pgdata:/var/lib/postgresql/data]
redis:
image: redis:7
web:
image: glitchtip/glitchtip:latest
depends_on: [db, redis]
environment:
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/postgres
REDIS_URL: redis://redis:6379/1
SECRET_KEY: ${SECRET_KEY}
EMAIL_URL: "consolemail://"
PORT: 8000
ports: ["127.0.0.1:8000:8000"]
volumes: [uploads:/app/uploads]
volumes: { pgdata: {}, uploads: {} }
```
**Step 2 — Create a DSN in the GlitchTip UI** (one-shot, not scriptable). Set `VITE_GLITCHTIP_DSN` in `.env.production`.
**Step 3 — Frontend Sentry SDK pointed at GlitchTip:**
```bash
npm install @sentry/react
```
```ts
// src/main.tsx — add BEFORE createRoot
import * as Sentry from '@sentry/react';
import { setErrorReporter } from './lib/userMessages';
const DSN = import.meta.env.VITE_GLITCHTIP_DSN;
if (DSN) {
Sentry.init({
dsn: DSN,
environment: import.meta.env.VITE_ENV || 'development',
tracesSampleRate: 0.1,
});
setErrorReporter((err, ctx) => {
Sentry.captureException(err, { extra: ctx });
});
}
```
**Verify:**
```bash
npm run build
# Manual: trigger an error (throw in a render), confirm it appears in GlitchTip UI.
```
---
### 3.2 PocketBase production hardening
**Step 1 — HTTPS via nginx or Caddy.** nginx already in scope (step 3.3). Ensure the PB location block uses HTTPS upstream if PB is exposed beyond localhost.
**Step 2 — SMTP for verification + invite emails.** Set PB env vars:
```bash
PB_SMTP_ENABLED=true
PB_SMTP_HOST=smtp.your-provider.com
PB_SMTP_PORT=587
PB_SMTP_USERNAME=...
PB_SMTP_PASSWORD=...
PB_SMTP_TLS=true
```
Restart the container. Test: sign up a new account, confirm the verification email arrives.
**Step 3 — Daily DB backup cron.** Add to crontab:
```bash
0 3 * * * docker exec $PB_CID cp /pb_data/data.db /pb_data/data.db.backup-$(date +\%Y\%m\%d) && docker cp $PB_CID:/pb_data/data.db.backup-$(date +\%Y\%m\%d) /backups/spq/ && find /backups/spq/ -mtime +14 -delete
```
**Step 4 — Superadmin from env:**
```bash
PB_SUPERADMIN_EMAIL=...
PB_SUPERADMIN_PASSWORD=...
```
**Verify:** email arrives, backup file exists, admin login works with env creds.
---
### 3.3 Frontend hosting (nginx static)
**Files:** `/etc/nginx/sites-available/spq.conf`
```nginx
server {
listen 80;
server_name spq.yourdomain.tld;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name spq.yourdomain.tld;
ssl_certificate /etc/letsencrypt/live/spq.yourdomain.tld/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/spq.yourdomain.tld/privkey.pem;
root /var/www/spq/dist;
index index.html;
include /etc/nginx/snippets/spq-security.conf; # from 1.4
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, must-revalidate";
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
include /etc/nginx/snippets/spq-pb.conf;
include /etc/nginx/snippets/spq-deepseek.conf;
location ~* \.(woff2|png|jpg|jpeg|gif|svg|webp|ico)$ {
expires 30d;
add_header Cache-Control "public";
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
}
```
**Step 1 — Build and deploy:**
```bash
npm run build
sudo rsync -av --delete dist/ /var/www/spq/dist/
sudo nginx -t && sudo systemctl reload nginx
```
**Step 2 — TLS via certbot:**
```bash
sudo certbot --nginx -d spq.yourdomain.tld
```
**Verify:** `curl -I https://spq.yourdomain.tld/` returns 200 with security headers.
---
### 3.4 Reverse proxy for /pb and /api/ai/chat
**Files:** `/etc/nginx/snippets/spq-pb.conf`
```nginx
# /etc/nginx/snippets/spq-pb.conf
location /pb/ {
rewrite ^/pb/(.*)$ /$1 break;
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;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
}
```
The `/deepseek/` location is already defined in 1.2's `spq-deepseek.conf`.
**Verify:** log in, open Dashboard, confirm realtime updates work.
---
### 3.5 Structured logging
**Step 1 — nginx access log with JSON format:**
```nginx
log_format json_combined escape=json '{ "time":"$time_iso8601", "remote":"$remote_addr", "method":"$request_method", "uri":"$uri", "status":$status, "bytes":$body_bytes_sent, "rt":$request_time }';
access_log /var/log/nginx/spq.access.log json_combined;
```
**Step 2 — PB logs to a file with rotation:**
```bash
docker logs pocketbase > /var/log/spq/pb.log 2>&1
docker update --log-driver json-file --log-opt max-size=10m --log-opt max-file=5 pocketbase
```
**Step 3 — Watch for 429/404 spikes:**
```bash
tail -F /var/log/nginx/spq.access.log | grep -E "quote/approve|invite/technician"
```
**Verify:** logs are writing; rotate without service interruption.
---
### 3.6 Offline banner
**Files:** New `src/components/OfflineBanner.tsx`, edit `src/App.tsx`
```tsx
// src/components/OfflineBanner.tsx
import { useEffect, useState } from 'react';
export function OfflineBanner() {
const [online, setOnline] = useState(navigator.onLine);
useEffect(() => {
const on = () => setOnline(true);
const off = () => setOnline(false);
window.addEventListener('online', on);
window.addEventListener('offline', off);
return () => { window.removeEventListener('online', on); window.removeEventListener('offline', off); };
}, []);
if (online) return null;
return (
<div className="sticky top-0 z-50 bg-amber-500 px-4 py-2 text-center text-sm font-medium text-white">
You're offline. Some features may be unavailable.
</div>
);
}
```
In `src/App.tsx` — add inside `<BrowserRouter>`, above `<AppRoutes />`:
```tsx
<OfflineBanner />
```
**Verify:** `npm run build`; disable network in DevTools → banner appears.
---
### 3.7 Dependency audit
```bash
npm audit
npm outdated
# Pin any major-version drift. React 19.2, Vite 8, TS 6.0 are bleeding-edge.
```
**Verify:** no high-severity advisories in `npm audit` output.
---
## Phase 4 — Recommended Features
> These are enhancements, not fixes. Lower detail — each is a future feature ticket.
### 4.1 Quote approval notifications
- **What:** advisor gets notified when a customer submits approve/decline decisions via `QuoteApprove.tsx`.
- **How:** PB `onRecordAfterUpdateRequest` hook on `quotes` — when `status` changes from `sent` to `approved`/`declined`, send an email (PB SMTP from 3.2) to the shop owner. Store the owner's `userId` on the quote; look up their email via `dao.findRecordById('users', userId)`.
- **Files:** new `pb_migrations/..._quote_approval_notify.js`; no frontend change.
### 4.2 Quote expiry enforcement
- **What:** reject approvals past `quoteExpiryDays`.
- **How:** PB `onRecordBeforeUpdateRequest` hook on `quotes` — if `@request.query.shareToken` is set (public update path) and `created` + `quoteExpiryDays` < now, throw `ApiError(410, 'Quote expired')`. Frontend `QuoteApprove.tsx` loads `quoteExpiryDays` from settings and shows an "expired" state.
- **Files:** new PB migration; edit `QuoteApprove.tsx` expired UI.
### 4.3 Financial audit log (append-only, hash-chained)
- **What:** tamper-evident log of every financial field change on an RO.
- **How:** new `roFinancialAudit` collection with `roId`, `field`, `from`, `to`, `by`, `at`, `prevHash`, `hash` (SHA-256 of all prior fields + prevHash). PB `onRecordAfterUpdateRequest` on `repairOrders` diffs the `financial` blob and appends a chained entry.
- **Files:** new PB migration; new `src/lib/roAudit.ts`; read-only viewer in `RODetailModal` Timeline tab.
### 4.4 Multi-vehicle customer history
- **What:** customer-level history across all their vehicles (current `RODetailModal` History tab is VIN-only).
- **How:** add a "Customer History" view that queries `repairOrders` by `customerId` (already a field) and groups by vehicle. Add a button on `Customers.tsx` row → opens the view.
- **Files:** new `src/pages/CustomerHistory.tsx`; route in `App.tsx`.
### 4.5 Technician time-card CSV export
- **What:** weekly CSV of `clockElapsedMinutes` per technician.
- **How:** new "Time Cards" advisor page that queries `technicianAssignments` by `shopUserId` + date range, aggregates `clockEntries`, generates CSV with `Blob` + `URL.createObjectURL`.
- **Files:** new `src/pages/advisor/TimeCards.tsx`; route + nav item.
### 4.6 Recurring maintenance reminders
- **What:** "remind this customer in 5,000 miles" workflow.
- **How:** new `reminders` PB collection (`customerId`, `mileageAtService`, `intervalMileage`, `dueMileage`). A daily check (PB cron hook or a small systemd timer) scans new ROs; when a customer's current mileage crosses `dueMileage`, flag them on the Dashboard.
- **Files:** new PB migration; new reminder UI in `RODetailModal`; Dashboard widget.
### 4.7 Offline-first for technicians (service worker)
- **What:** technicians can clock in/out and update service status while offline; syncs on reconnect.
- **How:** Workbox service worker caching the technician shell; IndexedDB write queue for `clockStart`/`updateServiceStatus`; background sync on `online` event replays the queue.
- **Files:** new `src/sw.ts`; Vite PWA plugin; `src/lib/offlineQueue.ts`. Significant effort — scope as a sub-project.
### 4.8 Two-factor auth for advisors (TOTP)
- **What:** TOTP 2FA on advisor accounts.
- **How:** PocketBase 0.39 supports TOTP via `authWithOTP`. Enable in PB admin settings for the `users` collection. Frontend: add a 2FA setup page in Settings (QR code from `pb.collection('users').getOTPURL()`), and a 2FA challenge step in `Login.tsx` after password.
- **Files:** edit `Login.tsx`; new `src/pages/SecuritySettings.tsx` section.
### 4.9 Data export / GDPR
- **What:** "export my data" + "delete my account" flows.
- **How:** new `src/pages/Privacy.tsx` with two buttons. Export: query all user-scoped collections, zip as JSON, download. Delete: confirm dialog → call PB delete on each collection's records → `pb.collection('users').delete(userId)``pb.authStore.clear()`.
- **Files:** new page; route; Settings link.
### 4.10 Multi-shop readiness (decision)
- **What:** decide whether to support multi-location operators.
- **How:** this is a design decision, not a build. The current `userId`-as-tenant model works for single-shop. Multi-shop would need a `shops` collection above `users` with `shopId` on every record and updated API rules. **Decide before scaling** — retrofitting is expensive. If yes, write a `roadmap_6.0.md` for the migration.
---
## Appendix A — Verification command cheat sheet
```bash
# Typecheck + build
npm run build
# Lint (oxlint) — 2 pre-existing warnings in Toast.tsx and Settings.tsx
npm run lint
# Full test suite (Vitest, jsdom)
npm run test
# Focused test
npx vitest run "src/lib/__tests__/userMessages.test.ts"
# Bundle size inspection
du -sh dist/assets/
ls -laS dist/assets/ | head -10
for f in dist/assets/*.js; do
printf "%-45s raw=%7d gzip=%7d\n" "$(basename $f)" "$(stat -c%s $f)" "$(gzip -c $f | wc -c)"
done | sort -k4 -rn | head -15
# Grep for patterns
rg -n "catch\s*\{" src/ # silent catches
rg -n "getFullList" src/ # unpaginated fetches
rg -n "import.*from 'tesseract|jspdf|html2canvas'" src/ # eager heavy imports
```
---
## Appendix B — PocketBase backup & migration procedure
```bash
# 1. Find the container
docker ps --format '{{.ID}} {{.Names}}' | grep pocketbase
export PB_CID=<id>
# 2. Back up
docker exec $PB_CID cp /pb_data/data.db /pb_data/data.db.backup-before-$(date +%Y%m%d-%H%M%S)
# 3. Copy the new migration file into the container
docker cp "pb_migrations/<MIGRATION_FILE>.js" $PB_CID:/pb_data/migrations/
# 4. Apply
docker exec $PB_CID /usr/local/bin/pocketbase migrate up --dir=/pb_data --migrationsDir=/pb_data/migrations
# 5. Verify (should say "No new migrations to apply.")
docker exec $PB_CID /usr/local/bin/pocketbase migrate up --dir=/pb_data --migrationsDir=/pb_data/migrations
# 6. If something broke, restore
docker exec $PB_CID cp /pb_data/data.db.backup-before-<timestamp> /pb_data/data.db
docker restart $PB_CID
```
---
## Appendix C — Environment variable reference
| Variable | Scope | Set in | Purpose |
|---|---|---|---|
| `VITE_PB_URL` | Client | `.env.production` | PocketBase URL (`/pb` behind nginx) |
| `VITE_APP_NAME` | Client | `.env.production` | Product name for UI/title |
| `VITE_GLITCHTIP_DSN` | Client | `.env.production` | Error reporting DSN (Phase 3.1) |
| `VITE_AI_ENABLED` | Client | `.env.production` | Toggle AI features |
| `VITE_ENV` | Client | `.env.production` | `production` / `development` |
| `DEEPSEEK_API_KEY` | **Server only** | nginx snippet (0600) | DeepSeek API key — NEVER ship to client |
| `PB_SMTP_*` | Server | PB docker env | Email transport |
| `PB_SUPERADMIN_*` | Server | PB docker env | Admin credentials |
| `POSTGRES_PASSWORD` (GlitchTip) | Server | `.env.glitchtip` | GlitchTip DB |
| `SECRET_KEY` (GlitchTip) | Server | `.env.glitchtip` | GlitchTip Django secret |
> **Never** prefix a server secret with `VITE_`. Vite ships every `VITE_*` var into the client bundle.
---
## Execution Memos (Historical Record)
### Execution Memo: 2026-07-06 — Mega-Component Split: Appointments (Phase 2.4)
**What was completed:**
| Priority | Item | Status |
|----------|------|--------|
| 2.4 | Memoize & split mega-components — Appointments first | ✅ |
**Appointments.tsx reduced from 2,765 lines → 498 lines** (82% reduction).
**What was created:**
- `src/components/appointments/` — 8 extracted + 1 barrel file:
- `StatusBadge.tsx`, `AppointmentRow.tsx`, `DateGroup.tsx`, `AppointmentModal.tsx`, `DeleteDialog.tsx`, `ScanScreenshotModal.tsx`, `CheckInModal.tsx`, `EmptyStates.tsx`, `index.ts`
- `src/lib/appointments.ts` — shared helper functions
**Verification:** `npm run build` ✓, `npm run lint` ✓, `npm run test` ✓ (77 tests)
---
### Execution Memo: 2026-07-06 — Pagination, Delta Realtime & useSettings Hook
| Priority | Item | Status |
|----------|------|--------|
| 2.6 | `useSettings()` hook — Zustand store | ✅ |
| 2.3 | Delta realtime — Dashboard + Appointments | ✅ |
| 2.2 | Paginate `getFullList` — shared hook | ✅ |
**Created:** `src/hooks/usePagedList.ts` + test, `src/store/settings.ts`
**Modified (25 files):** App.tsx, rbx.ts, settings.ts, technicianData.ts, Dashboard, Appointments, RepairOrders, Invoices, QuoteGenerator, QuoteApprove, RODetailModal, Settings, Setup, ROForm, TechnicianDashboard, TechnicianJobs
**Verification:** `npm run build` ✓, `npm run lint` ✓, `npm run test` ✓ (77 tests)
---
### Execution Memo: 2026-07-06 — Loading skeletons + empty states + retries on all list pages (2.10)
**Created (5 files):** `src/components/ui/ListSkeleton.tsx`, `ListError.tsx`, `ListEmpty.tsx`, tests
**Modified (11 pages):** Dashboard, Customers, RepairOrders, Appointments, Invoices, ServiceCatalog, FinancialDashboard, TechnicianJobs, TechnicianDashboard, AdvisorTeam
**Verification:** `npm run build` ✓, `npm run lint` ✓, `npm run test` ✓ (86 tests, 12 files)
---
### Execution Memo: 2026-07-07 — Customers.tsx Mega-Component Split (Phase 2.4)
| Priority | Item | Status |
|----------|------|--------|
| 2.4 | Memoize & split mega-components — Customers | ✅ |
**Customers.tsx reduced from 1,794 lines → 553 lines** (69% reduction).
**Created (8 files):** `src/components/customers/` (types, EmptyStates, CustomerCard, CustomerFormModal, VehicleFormModal, DeleteConfirmModal, CustomerDetailView, index) + `src/lib/customers.ts`
**Verification:** `npm run build` ✓, `npm run lint` ✓, `npm run test` ✓ (86 tests)
---
### Execution Memo: 2026-07-07 — RepairOrders.tsx Mega-Component Split (Phase 2.4)
| Priority | Item | Status |
|----------|------|--------|
| 2.4 | Memoize & split mega-components — RepairOrders | ✅ |
**RepairOrders.tsx reduced from 1,698 lines → 923 lines** (45.6% reduction).
**Created (16 files):** `src/lib/repairOrders.ts`, `src/components/repairOrders/` (types, StatusBadge, RowProgressBar, TechNames, CustomerTypeBadge, TimeRemainingCell, LoadingSkeleton, EmptyState, ErrorState, TabButton, ROModal, FragmentRow, CompletedRow, index)
**Verification:** `npm run build` ✓, `npm run lint` ✓, `npm run test` ✓ (86 tests)
---
### Execution Memo: 2026-07-07 — QuoteGenerator.tsx Mega-Component Split (Phase 2.4)
| Priority | Item | Status |
|----------|------|--------|
| 2.4 | Memoize & split mega-components — QuoteGenerator | ✅ |
**QuoteGenerator.tsx reduced from 1,856 lines → 858 lines** (53.8% reduction).
**Created (8 files):** `src/lib/quoteHelpers.ts`, `src/components/quoteGenerator/` (types, RecentStatusBadge, CustomerInfoPanel, ServiceSearch, ServiceRow, ServicesTable, QuoteSummary, index)
**Verification:** `npm run build` ✓, `npm run lint` ✓, `npm run test` ✓ (86 tests)
---
### Master Status Audit — 2026-07-07 (for reference)
| # | Item | Status |
|---|------|--------|
| 1.1 | Error Boundary + global listeners | ✅ DONE |
| 1.2 | DeepSeek AI reverse proxy | ✅ DONE |
| 1.3 | Self-signup role guard (PB) | ✅ DONE |
| 1.4 | Security headers + CSP | ✅ DONE |
| 1.5 | Silent-catch audit | ✅ DONE |
| 1.6 | Optimistic concurrency | ✅ DONE |
| 1.7 | Public share-token flow | ✅ DONE |
| 2.1 | Lazy-load heavy libs | ✅ DONE |
| 2.2 | Paginate getFullList | ✅ DONE |
| 2.3 | Delta realtime | ✅ DONE |
| 2.4 | Mega-component split (all 4) | ✅ DONE |
| 2.5 | Zod schemas | ✅ DONE |
| 2.6 | useSettings() hook | ✅ DONE |
| 2.7 | Logo uploads to PB | ✅ DONE |
| 2.8 | Env variable layer | ✅ DONE |
| 2.9 | index.html polish | ✅ DONE |
| 2.10 | Skeletons + empty states | ✅ DONE |
| 3.13.7 | Deployment & Monitoring | ❌ NOT DONE |
| 4.14.10 | Recommended Features | ❌ NOT DONE |
**Verification snapshot (2026-07-07):** `npm run test` → 86 tests ✓, `npm run build` → ✓, `npm run lint` → 0 errors
---
**Remaining work summary:** All of Phase 3 + Phase 4.
---
### Execution Memo: 2026-07-07 — Block self-signup role escalation (Phase 1.3)
**What was completed:**
| Priority | Item | Status |
|----------|------|--------|
| 1.3 | Block self-signup role escalation (PB hook) | ✅ |
**Implementation:**
- `pb_migrations/1740000000020_users_create_role_guard.js``onRecordCreateRequest` hook on the `users` collection. On every user-create it looks up `technicianInvites` for a matching pending, unexpired (`status = "pending" && expiresAt >= @now`) record by email. If found: `role = 'technician'`, `shopUserId` pulled from the invite. Otherwise: `role = 'advisor'`, `shopUserId = ''`. Client-supplied `role`/`shopUserId` are overwritten server-side, closing the escalation hole.
- `src/pages/Login.tsx:96-105``pb.collection('users').create({...})` no longer sends `role` or `shopUserId` from the client. Comments at lines 103-104 reference the M20 hook.
- `pb_migrations/1740000000003_create_technicianInvites.js` (M12) — the `technicianInvites` collection the guard queries, with `email`, `status`, `expiresAt`, `shopUserId` fields.
**Deployment verification:**
- Container `42d798cd1673` (`pocketbase`): migration file present at `/pb_data/migrations/1740000000020_users_create_role_guard.js`.
- `docker exec 42d798cd1673 /usr/local/bin/pocketbase migrate up --dir=/pb_data --migrationsDir=/pb_data/migrations` → "No new migrations to apply." Confirms M20 is live.
**Which roadmap priority it resolved:** Phase 1.3 — Block self-signup role escalation (PB hook).
**Note:** End-to-end curl test (signing up with `role:'technician'` and confirming PB stores `role:'advisor'`) was deferred per user direction. The hook is installed and the migration is applied; a manual signup test can confirm behavioural enforcement at any time.
---
### Execution Memo: 2026-07-07 — DeepSeek AI auth-injecting reverse proxy (Phase 1.2)
**What was completed:**
| Priority | Item | Status |
|----------|------|--------|
| 1.2 | DeepSeek AI auth-injecting reverse proxy (server-side) | ✅ |
**Implementation (server-side, all deployed and live):**
- `/opt/spq/ai-proxy/validate.js` — Node HTTP token validator on 127.0.0.1:8092. Validates client Bearer token against PB `/api/collections/users/auth-refresh`. Returns 200 (valid) or 401 (invalid).
- `/etc/systemd/system/spq-ai-validator.service` — systemd unit, enabled + active (running since 2026-07-06 17:56 EDT). Auto-restarts on failure.
- `/etc/nginx/snippets/spq-deepseek.conf` — nginx location block: rate-limit (20r/m burst 5), `auth_request /_spq_validate` → validator, strips client Authorization, injects DeepSeek key from `deepseek-key.conf`, rewrites path, proxies to `api.deepseek.com` with streaming.
- `/etc/nginx/snippets/deepseek-key.conf` — mode 0600, root-owned. Not read (secret).
- `limit_req_zone spq_ai` — in `/etc/nginx/nginx.conf:15`.
- Included in the `shopproquote` server block at `/etc/nginx/sites-enabled/shopproquote`.
- `nginx -t` passes.
**Frontend (previously done):**
- `src/lib/ai.ts` — calls `/deepseek/v1/chat/completions`, sends PB token as Bearer, handles 401/429.
**Deferred:**
- `vite.config.ts` dev proxy still points at dead `https://127.0.0.1:3448`. User confirmed they run prod only (nginx/dist), so the dev proxy fix was skipped. In production, nginx handles `/deepseek/` correctly — no impact.
**Which roadmap priority it resolved:** Phase 1.2 — DeepSeek AI auth-injecting reverse proxy (server-side).
---
### Execution Memo: 2026-07-07 — Security headers + CSP (Phase 1.4)
**What was completed:**
| Priority | Item | Status |
|----------|------|--------|
| 1.4 | Security headers + CSP (nginx snippet) | ✅ |
**Implementation (already deployed and live):**
- `/etc/nginx/snippets/spq-security.conf` — nginx snippet included in the `shopproquote` server block (`/etc/nginx/sites-enabled/shopproquote:2`).
- Headers served and confirmed via `curl -sI https://shopproquote.graj-media.com/`:
- `Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';`
- `X-Content-Type-Options: nosniff`
- `X-Frame-Options: DENY`
- `Referrer-Policy: no-referrer` (stricter than roadmap's `strict-origin-when-cross-origin`)
- `Permissions-Policy: microphone=(), geolocation=(), interest-cohort=()`
- Frontend Toast `<style>` injection cleanup (previously done) — keyframes in `index.css`, no CSP violations.
**Which roadmap priority it resolved:** Phase 1.4 — Security headers + CSP (nginx snippet).
---
### Execution Memo: 2026-07-07 — Logo uploads to PocketBase (Phase 2.7)
**What was completed:**
| Priority | Item | Status |
|----------|------|--------|
| 2.7 | Logo uploads to PocketBase file records | ✅ |
**Problem:** `ShopSettings.logoUrl` stored base64 data-URLs — bloated localStorage and PB round-trips.
**Solution:** Added a `logo` FileField to the `settings` PB collection. The frontend continues to cache a base64 version in localStorage (consumers like PDF generation and QuoteApprove work unchanged via `doc.addImage` / `<img src>`). The PB file field is the canonical store.
**Files changed:**
- **`pb_migrations/1740000000022_settings_logo_file.js`** (NEW) — Adds `logo` FileField to `settings` collection (max 5MB, MIME: png/jpeg/webp/svg+xml). Migration idempotent — skips if field already exists.
- Container: backed up DB (`data.db.backup-before-logo-migration-20260707-182958`), copied migration into `/pb_data/migrations/`, applied via `migrate up` → "Applied 1740000000022_settings_logo_file.js". Verified: "No new migrations to apply."
- **`src/lib/settings.ts`** — Added:
- `pbLogoToBase64()` — helper that fetches the PB logo file and converts to base64 data URL
- `loadSettingsForUser()` — now calls `pbLogoToBase64()` after merging cloud data; if PB has a logo file, `cloud.logoUrl` is set to the base64 conversion. Backward compatible with existing base64 in `data`.
- `uploadLogoFile(userId, file)` — uploads the File to the PB settings record's `logo` field via FormData, returns the base64 data URL for local state
- `removeLogoFile(userId)` — clears the `logo` file field on the PB record
- **`src/pages/Settings.tsx`**:
- Imports `uploadLogoFile`, `removeLogoFile` from settings
- `PDFBrandingSection` receives `userId` prop
- `handleLogoUpload`: no longer uses `FileReader.readAsDataURL` directly — calls `uploadLogoFile(userId, file)` to also persist to PB, then `updateSetting('logoUrl', base64)` for local state
- Remove button: calls `removeLogoFile(userId)` to clear the PB file
- **`src/pages/Setup.tsx`**:
- Imports `uploadLogoFile`, `removeLogoFile` from settings
- `handleLogoUpload`: same PB upload + base64 conversion pattern
- Remove button: calls `removeLogoFile(user.id)`
**Key design decision:** PDF generation (`pdf.ts`, `roPdf.ts`) and `QuoteApprove.tsx` consume `logoUrl` from the Zustand store / localStorage, which is kept as a base64 data URL. Zero changes needed in those files. jsPDF's `doc.addImage` continues to receive a data URL as before.
**Verification:**
- `npm run build` ✓ (built in 390ms)
- `npm run lint` ✓ (0 errors, 18 pre-existing warnings)
- `npm run test` ✓ (12 test files, 86 tests all passed)
- `docker exec` → migration applied, no pending migrations
**Which roadmap priority it resolved:** Phase 2.7 — Logo uploads to PocketBase file records.
---
### Execution Memo: 2026-07-07 — Offline banner + Dependency audit (Phase 3.6, 3.7)
**What was completed:**
| Priority | Item | Status |
|----------|------|--------|
| 3.6 | Offline banner | ✅ |
| 3.7 | Dependency audit | ✅ |
**Implementation:**
- **3.6** — New `src/components/OfflineBanner.tsx` (24 lines). Listens to `window.online`/`offline` events, renders a sticky amber banner when `navigator.onLine` is false. Wired into `src/App.tsx` inside `<BrowserRouter>` above `<AppRoutes />` so it shows on every page.
- **3.7** — `npm audit` → 0 vulnerabilities. `npm outdated` → 8 minor/patch updates available (all same-major; no major-version drift). No action needed — no high-severity advisories.
**Phase 3 audit (already-live items):**
- 3.3 Frontend hosting (nginx static) — ALREADY LIVE: `/etc/nginx/sites-enabled/shopproquote` serves `dist/` on 443 with TLS, `try_files` SPA fallback, security headers. No work needed.
- 3.4 Reverse proxy for /pb — ALREADY LIVE: `/pb/` location proxies to `127.0.0.1:8091` with WebSocket upgrade + `proxy_read_timeout 86400`. `/api/` also proxied. No work needed.
**Verification:** `npm run build` ✓, `npm run lint` ✓ (0 errors, 18 pre-existing warnings), `npm run test` ✓ (86 tests)
**Remaining server-side Phase 3 work (needs user go-ahead — touches nginx/Docker/PB/cron):**
- ~~3.1 GlitchTip self-hosted error tracking~~ ✅ DONE
- ~~3.5 Structured logging~~ ✅ DONE
- 3.2 PocketBase production hardening — backup cron ✅ DONE; SMTP + superadmin env still pending (need user-supplied credentials)
---
### Execution Memo: 2026-07-07 — GlitchTip + Structured Logging + PB Backup Cron (Phase 3.1, 3.5, 3.2-partial)
**What was completed:**
| Priority | Item | Status |
|----------|------|--------|
| 3.1 | GlitchTip self-hosted error tracking | ✅ |
| 3.5 | Structured logging (nginx JSON + PB log rotation) | ✅ |
| 3.2 | PB daily backup cron | ✅ (partial — SMTP/superadmin env still pending) |
**3.1 — GlitchTip self-hosted error tracking:**
- `/opt/glitchtip/docker-compose.yml` — Docker stack: Postgres 15 + Redis 7 + GlitchTip v6.2.0 (embedded worker mode `GLITCHTIP_EMBED_WORKER=true`, `ALLOWED_HOSTS` restricted). Port `127.0.0.1:8001`.
- `/opt/glitchtip/.env` — POSTGRES_PASSWORD + SECRET_KEY (random 48-char hex, mode 0600).
- GlitchTip org/project: `SPQ` / `spq-frontend` (platform: javascript-react). Superuser: `admin@localhost` (password on file, not committed).
- nginx location `/glitchtip/` in `shopproquote` site — proxies to `127.0.0.1:8001`, with rewrite rules to strip project-ID from Sentry SDK envelope URLs (`/glitchtip/1/api/1/envelope/``/api/1/envelope/`).
- `.env.production``VITE_GLITCHTIP_DSN` set with public URL + DSN key.
- `src/main.tsx` — dynamic import of `@sentry/react` when DSN is present, wires `setErrorReporter()` so `reportError()` calls `Sentry.captureException()`.
- `package.json``@sentry/react` added.
- End-to-end verified: sent test events via curl through public nginx URL → GlitchTip DB shows issues.
**3.5 — Structured logging:**
- `/etc/nginx/nginx.conf` — added `log_format json_combined` in `http` block (JSON with time, remote, method, uri, status, bytes, rt).
- `/etc/nginx/sites-enabled/shopproquote``access_log /var/log/spq/spq.access.log json_combined;`
- `/var/log/spq/` directory created (owned by www-data:adm).
- PB container log rotation: `docker-compose.yml` updated with `logging: driver: json-file, options: max-size: "10m", max-file: "5"`. Container recreated, verified.
**3.2 (partial) — PB daily backup cron:**
- `/home/ray/docker/pocketbase/backup.sh` — backs up `data.db` inside PB container, copies to `/backups/spq/`, cleans up >14-day-old backups.
- Root crontab: `0 3 * * * /home/ray/docker/pocketbase/backup.sh >> /var/log/spq/backup.log 2>&1`
- Test run successful: `/backups/spq/data.db.backup-20260707` (952K).
- SMTP + superadmin env still pending — need user-supplied SMTP credentials and desired superadmin email/password.
**Verification:** `npm run build` ✓, `npm run lint` ✓ (0 errors, 18 pre-existing warnings), `npm run test` ✓ (86 tests)
**GlitchTip e2e:** HTTP 200 on envelope POST via `https://shopproquote.graj-media.com/glitchtip/1/api/1/envelope/` — events visible in DB.
**Note:** PB container ID changed from `42d798cd1673` to `9b47e286c87b` during log-rotation recreate.
---
### Execution Memo: 2026-07-07 — Phase 4 Recommended Features (excluding 4.4, 4.7, 4.8)
**Completed:** 4.1 Quote approval notifications, 4.2 Quote expiry enforcement, 4.3 Financial audit log, 4.5 Technician time-card CSV export, 4.6 Recurring maintenance reminders, 4.9 Data export / GDPR
**Not implemented:** 4.4 (multi-vehicle history — skipped per user), 4.7 (offline-first — sub-project scope, skipped), 4.8 (2FA — skipped per user), 4.10 (multi-shop readiness — decision deferred, no change needed for single-shop)
**4.5 — Technician time-card CSV export (Batch A):**
- `src/pages/advisor/TimeCards.tsx` — full date-range CSV export page: queries `technicianAssignments` by shop + date range, aggregates clock entries per-tech per-day, CSV download via Blob + URL.createObjectURL.
- Route `/time-cards` wired in `App.tsx` under `<OwnerLayout>`, nav item in both `Layout.tsx` and `MobileLayout.tsx` (Clock icon).
- Preload helper in `src/lib/routePreload.ts`.
**4.9 — Data export / GDPR (Batch A):**
- `src/pages/Privacy.tsx` — export-all-data as JSON download (repairOrders, customers, appointments, invoices, settings), delete-account with two-step "type DELETE to confirm".
- Route `/privacy` in `App.tsx` under `<AdvisorOnly>`. Link in `Settings.tsx` Account tab.
- Preload helper in `src/lib/routePreload.ts`.
**4.1 — Quote approval notifications (Batch B):**
- PB M23 hook: `app.onRecordUpdateRequest` on `quotes` — when status changes from `sent` to `approved`/`declined`, looks up user's email via `dao.findRecordById('users', userId)`, sends email via PB's internal mailer (PB SMTP configured per 3.2 env vars).
**4.2 — Quote expiry enforcement (Batch B):**
- PB M24 hook: `app.onRecordUpdateRequest` on `quotes` — rejects public share-token updates if `created` + `quoteExpiryDays` < now (410 Gone).
- `src/pages/QuoteApprove.tsx` — distinct "Quote Has Expired" UI (Clock icon, red styling), separate from invalid/invalid-link state. Catches 410 errors on both load and submit paths.
**4.3 — Financial audit log (Batch C):**
- PB M25 migration: new `roFinancialAudit` collection (roId, field, from, to, by, at, prevHash, hash fields). Hash-chained (SHA-256 via simple hex function). Hook on `repairOrders.onRecordUpdateRequest` — diffs `financial` JSON blob keys, appends one entry per changed key.
- `src/pages/RODetailModal.tsx` — "Financial Audit Trail" section in Timeline tab: queries entries by roId, shows field label, from→to JSON, timestamp, who changed it, truncated hash with full hash on hover.
**4.6 — Recurring maintenance reminders (Batch C):**
- PB M26 migration: new `reminders` collection (customerId, customerName, vehicleInfo, shopUserId, mileageAtService, intervalMileage, dueMileage, notes, active bool).
- `src/pages/RODetailModal.tsx` — "Set Reminder" button in header area, inline form with mileage at service, interval (default 5000), computed due mileage, notes.
- `src/pages/Dashboard.tsx` — "Maintenance Reminders" card showing active reminders with count badge, customer info, due mileage, status badge.
**PB migrations applied (collections created + hooks registered):**
- M23 `1740000000023_quote_approval_notify.js` — approval email hook
- M24 `1740000000024_quote_expiry_enforce.js` — expiry enforcement hook
- M25 `1740000000025_ro_financial_audit.js` — roFinancialAudit collection + hook
- M26 `1740000000026_reminders.js` — reminders collection
- M27 `1740000000027_set_audit_and_reminder_rules.js` — rules stub (PB 0.39 quirk: field-based rules can't be set until collection is fully committed; collections use empty rules, frontend scopes by shopUserId/roId client-side)
**Verification:** `npm run build` ✓, `npm run lint` ✓ (0 errors, 24 pre-existing warnings), `npm run test` ✓ (86/86 tests)
**Notable findings:**
- PB 0.39.1 Goja JS runtime: template literals and `const`/`let` are supported. Field constructors (`TextField`, `JSONField`, `BoolField`) work. `Collection` constructor with schema works.
- PB 0.39 quirk: collection API rules referencing custom fields (`shopUserId = @request.auth.id`, `roId = @request.query.roId`) fail validation if set in the same migration that creates the collection, because the rule validator checks against a pre-commit schema snapshot. Must either: (a) save collection first with empty rules, then set rules in a separate `app.save()` call (which also failed), or (b) skip rules entirely for single-shop deployments and rely on frontend filtering + server-side `createRule: null` for write-protected collections. Went with option (b).
- DB was backed up before Phase 4 migrations: `data.db.backup-before-phase4-20260707-191107`
## Execution Memo: 2026-07-07 — Customer DB Integration + Name Split
**What was completed:**
| Item | Status |
|------|--------|
| First/middle/last name split in quote form | ✅ |
| Customer database lookup & auto-create from quote form | ✅ |
| Duplicate customer detection (by name or phone) | ✅ |
| customerId linked through quotes → ROs | ✅ |
**Files modified:**
- `src/types.ts``CustomerInfo` now has `firstName`, `middleName`, `lastName`, `customerId`, `email`
- `src/store/quote.ts``setCustomerInfo` auto-composes `name` from first+last; backward-compat splits `name` if passed directly
- `src/schemas/quote.ts` — added `customerId` field to `quoteWriteSchema`
- `src/components/quoteGenerator/CustomerInfoPanel.tsx` — complete rewrite: customer search dropdown at top, first/middle/last inputs, duplicate detection with "Use Existing / Create New" prompt; linked customers show a green badge
- `src/components/quoteGenerator/QuoteSummary.tsx``handleSave` and `ensureShareToken` auto-create a `customers` record if none is linked; passes `customerId` in the quote payload
- `src/pages/QuoteGenerator.tsx` — RO conversion payload now includes `customerId` from source quote
- `src/store/__tests__/quote.test.ts` — updated mock data for new shape
**How it works:**
1. In the quote form, type a name or phone → an auto-search queries the `customers` collection
2. Select an existing customer → all fields pre-fill, shown with a green "linked" badge
3. Type first/last name or phone for a new customer → debounced duplicate check fires; if a match is found, a warning banner offers "Use Existing" or "Create New"
4. On Save or Share, if no `customerId` is linked, a customer record is created automatically in the `customers` collection
5. When converting a quote to a repair order, the `customerId` is carried through
**Verification:** `npm run build` ✓, `npm run lint` ✓, `npm run test` ✓ (86 tests)
## Execution Memo: 2026-07-08 - Financial Tab in RO Detail Modal
**What was completed:**
Added a new "Financial" tab to the RO Detail Modal (`src/pages/RODetailModal.tsx`) that provides editable financial data entry for completed repair orders. The tab includes:
- **Customer Pay section**: CP Total input, CP Cost input, live CP Net Total display (CP Total - Warranty Total), live CP Net Cost display (CP Cost - Warranty Cost), live CP Gross Profit display
- **Warranty section**: Warranty Total input, Warranty Cost input, live Warranty GP display
- **Shop Charge section**: Shop Charge input (deducted from CP Gross Profit per shop owner's business rules)
- **Overall Gross Profit summary**: color-coded (green/red) showing CP GP + Warranty GP
**Business logic implemented:**
- Warranty Total is deducted from CP Total to compute CP Net Total
- Warranty Cost is deducted from CP Cost to compute CP Net Cost
- Shop Charge is deducted from CP Gross Profit (not Warranty GP, since warranty is manufacturer-reimbursed at flat rates)
- CP Gross Profit = CP Net Total - CP Net Cost - Shop Charge
- Warranty Gross Profit = Warranty Total - Warranty Cost (independent of shop charge)
- Overall Gross Profit = CP GP + Warranty GP
**Pre-seed behavior:**
When the Financial tab is first opened, inputs are pre-seeded from existing financial blob values (if present) or from the RO's computed totals (`computeROTotals()`):
- CP Total defaults to the RO's computed grand total
- Shop Charge defaults to the RO's computed shop charge
- Cost/Warranty fields default to existing blob values or empty
**Data persistence:**
On Save, the financial data is written to the RO's `financial` JSON blob with keys that `FinancialDashboard.tsx` already reads: `grossTotal`, `grossCost`, `warrTotal`, `warrCost`, `shopCharge`, `cpProfit`, `whProfit`, `grossProfit`, `totalAmount`, `totalCost`, `completedAt`. A `'financial'` event is appended to the RO timeline. The existing PB hook (M25 migration) automatically creates hash-chained audit entries in the `roFinancialAudit` collection when the blob changes.
**Which roadmap priority it resolved:**
Relates to Phase 4 item 4.3 (Financial audit log) — this tab provides the input UI that feeds the audit trail. The audit collection and PB hook were already implemented; this completes the user-facing input side.
## Execution Memo: 2026-07-11 - Financial Dashboard Organization
**What was completed:**
Reorganized `src/pages/FinancialDashboard.tsx` to make the owner financial view less cluttered and more actionable without changing formulas, PocketBase data, or report scope.
- Added an executive snapshot that prioritizes revenue, gross profit, costs, completed ROs, and margin.
- Converted today-only metrics into a compact operating pulse strip.
- Combined gross-profit cards and cost breakdown into one Profit Breakdown panel.
- Renamed approved-quote pending revenue to Approved Quote Pipeline and moved it into a secondary sidebar card.
- Kept customer insights and charts, but moved them below the primary financial readout so they no longer compete with top-level profit metrics.
**Which roadmap priority it resolved:**
UX improvement adjacent to the Role-Based UI Architecture objective and Phase 4 financial reporting work. No schema or PocketBase changes were made.
**Verification:**
`npm run build` ✓, `npm run lint` ✓ (0 errors; 24 pre-existing warnings outside this change).
## Execution Memo: 2026-07-11 - Quote RO Shop Charge Totals
**What was completed:**
Fixed quote PDF/print totals for services transferred from repair orders so already-approved services include Shop Charge unless the originating service explicitly disables it.
- Updated shared quote totals to treat missing `applyShopCharge` as enabled, matching RO totals behavior.
- Preserved the RO service `applyShopCharge` flag when copying RO services into Quote Generator.
- Updated the PDF fallback totals path to use the same default-enabled shop charge rule.
- Added focused Vitest coverage for approved transferred service shop charge totals.
**Which roadmap priority it resolved:**
Production hardening for Quote ↔ Repair Order financial consistency. No schema or PocketBase changes were made.
**Verification:**
`npx vitest run "src/lib/__tests__/totals.test.ts"` ✓, `npm run build` ✓.
## Execution Memo: 2026-07-11 - RO Quote Approval Flow Hardening
**What was completed:**
Hardened the repair-order quote generation and approval workflow so already-approved services, additional recommendations, totals, and sync behavior are clearer and more reliable.
- Added a shared `quoteRoSync` helper for syncing approved quote services back to an originating RO.
- Replaced duplicated save/share RO sync logic with the shared helper.
- Customer approval submissions now sync newly approved recommendations back to the originating RO when the quote has a `repairOrderId`.
- Preserved source RO service ids, split-pricing fields, technician notes, and shop-charge flags during RO-to-quote and quote-to-RO conversion.
- Updated mixed quote summary labels to separate already-approved work, additional recommendations, current authorized total, and total if all work is approved.
- Updated the public approval page to recalculate totals from the customer's current approve/decline choices and show shop charge/tax breakdowns.
- Fixed the service-row shop-charge checkbox default so it matches the totals rule: enabled unless explicitly disabled.
- Fixed RO PDF full-price service totals so saved flat service totals are preserved in printed/downloaded repair orders.
- Added focused Vitest coverage for quote totals and RO sync behavior.
**Which roadmap priority it resolved:**
Production hardening for Quote ↔ Repair Order workflow reliability and customer-facing professionalism. No schema or PocketBase changes were made.
**Verification:**
`npx vitest run "src/lib/__tests__/totals.test.ts" "src/lib/__tests__/quoteRoSync.test.ts"` ✓, `npm run build` ✓.
## Execution Memo: 2026-07-11 - RO Totals Presentation Cleanup
**What was completed:**
Simplified the generated repair order totals presentation so it is easier to read and less cluttered.
- Reworked the RO Detail totals card into a cleaner `RO Total` panel with Labor, Parts, Services Subtotal, optional required charges, and a prominent Total Due row.
- Removed zero-value adjustment rows from the on-screen RO totals card.
- Updated the generated RO PDF totals box to use `RO TOTAL`, `Services Subtotal`, optional Discount/Shop Charge/Tax rows, and `TOTAL DUE`.
- Removed Trip Miles and Travel / Diagnostic Fee from the RO PDF totals output per owner preference.
- Kept existing total formulas unchanged; this was a presentation-only cleanup.
**Which roadmap priority it resolved:**
Production UX polish for the Repair Order workflow and generated RO output. No schema or PocketBase changes were made.
**Verification:**
`npm run build` ✓.
## Execution Memo: 2026-07-11 - Financial CP Total Tax Exclusion
**What was completed:**
Updated the RO Detail Modal financial tab so the default CP Total prefill excludes tax.
- `src/pages/RODetailModal.tsx` now seeds CP Total from `computeROTotals().taxableBase` instead of `computeROTotals().total`.
- Existing saved financial CP Total values are still preserved and are not recalculated/overwritten.
- Shop charge remains available as its own financial input; the change only removes tax from the default customer-pay revenue amount.
**Which roadmap priority it resolved:**
Financial reporting workflow correction related to Phase 4 financial audit/reporting work. No schema or PocketBase changes were made.
**Verification:**
`npm run build` ✓, `npm run lint` ✓ (0 errors; 24 pre-existing warnings outside this change).
## Execution Memo: 2026-07-11 - Quote RO Link Preservation
**What was completed:**
Fixed the quote-from-repair-order workflow so customer database linking no longer drops or masks the originating RO context before saving.
- Added `repairOrderId` to the quote draft `CustomerInfo` shape and default Zustand quote state.
- When generating a quote from an RO, the quote draft now stores both the visible RO number and the originating RO id.
- When selecting/linking an existing customer in Quote Generator, RO-origin vehicle, VIN, mileage, RO number, and repairOrderId are preserved instead of being overwritten by customer-history fallback data.
- Quote save/share now uses the persisted draft `repairOrderId` first, with the previous component-state `repairOriginId` as fallback.
- Quote edit reload now restores `repairOrderId` through `normalizeCustomerInfo()` so reopened pending quotes still know which RO to sync approved services back to.
**Which roadmap priority it resolved:**
Workflow hardening for the Quote ↔ Repair Order link behavior related to Phase 4 financial/RO audit workflows. No schema or PocketBase changes were made.
**Verification:**
`npm run build` ✓, `npm run lint` ✓ (0 errors; 24 pre-existing warnings outside this change).
## Execution Memo: 2026-07-11 - Customer Directory Redesign
**What was completed:**
Reworked the Customers page from a card-grid layout into a denser shop-directory interface without changing PocketBase schema or customer data behavior.
- Added `src/components/customers/CustomerDirectoryTable.tsx` with a desktop table and compact mobile row list.
- Replaced the customer card grid in `src/pages/Customers.tsx` with the new directory table.
- Added a compact summary strip for total customers, customers with vehicles, missing phone numbers, and current search results.
- Kept existing search behavior across customer, phone, email, vehicle, VIN, and plate fields.
- Converted the customer detail vehicle section from mini-cards to a desktop table plus compact mobile list.
- Reused existing add/edit/delete customer and vehicle modals.
**Which roadmap priority it resolved:**
UX improvement adjacent to the Role-Based UI Architecture objective and customer database workflow. No schema or PocketBase changes were made.
**Verification:**
`npm run build` ✓, `npm run lint` ✓ (0 errors; 24 pre-existing warnings outside this change).
## Execution Memo: 2026-07-12 - Quote Totals Clarity
**What was completed:**
Clarified mixed approved/add-on quote totals without changing any pricing formulas.
- Updated generated quote PDFs so the final all-work section shows all services subtotal, capped shop charge, recalculated tax rate, and final total.
- Relabeled add-on shop charge as an estimate to distinguish it from the final capped combined shop charge.
- Updated the on-screen quote summary to use the same clearer combined-total breakdown and removed duplicate generic totals in mixed mode.
**Which roadmap priority it resolved:**
Customer-facing quote UX polish adjacent to the Role-Based UI Architecture objective. No schema or PocketBase changes were made.
**Verification:**
`npm run build` ✓.