# LESSONS.md - RateRight Growth Engine

> Hard-won knowledge from 30 years construction + 4 months coding. Read before every session.

Last Updated: Jan 31, 2026 (BUG #27 - Voice Assistant manual stop fix)

---

## 🤖 VPS Claude Code Setup

### Claude Code CLI as Root = Blocked
- **`--dangerously-skip-permissions` won't run as root** - Security feature in Claude Code
- **Solution:** Create non-root user `ccuser` for CC to run as
- **Setup:** `useradd -m ccuser`, copy claude binary + credentials, run via `su - ccuser -c "claude ..."`

### Clawdbot Config Format is Strict
- **Don't invent keys** - `"use_for"`, `"routing"` are not recognized
- **Model format:** `"models": {"anthropic/model-name": {"alias": "name"}}`
- **Always backup before changing:** `cp clawdbot.json clawdbot.json.bak`
- **Restart required:** `systemctl restart clawdbot` after config changes

### Model IDs Change
- **`claude-3-5-sonnet-20241022` → 404 error** - Model was deprecated
- **Use latest:** `claude-sonnet-4-20250514` or check Anthropic docs
- **Clawdbot needs `anthropic/` prefix:** `"anthropic/claude-sonnet-4-20250514"`

### CC + Clawdbot Architecture
```
Clawdbot (API, 24/7)     Claude Code (Max plan, on-demand)
     │                           │
     │ Operations                │ Code work
     │ - Briefs                  │ - Git commits
     │ - Monitoring              │ - Bug fixes
     │ - Notion sync             │ - Deploys
     │                           │
     └─── trigger-cc.sh ─────────┘
         (delegates code tasks)
```

### VPS Non-Root User for CC
- **User:** `ccuser` at `/home/ccuser`
- **Claude binary:** `/home/ccuser/.local/bin/claude`
- **Credentials:** `/home/ccuser/.claude/`
- **Repo:** `/home/ccuser/rateright-growth`
- **Config:** `/home/ccuser/config.env` (Notion keys, etc.)

### Frontend Supabase Auth - Build-Time Env Vars
- **VITE_SUPABASE_URL** and **VITE_SUPABASE_ANON_KEY** must be in `admin/.env` at BUILD time
- These get baked into the JS bundle - not read at runtime
- **Railway deployment:** Add VITE_* variables to Railway dashboard env vars (see docs/RAILWAY-ENV-SETUP.md)
- **Railway runs `npm run build` automatically** - Nixpacks detects build script and runs it with Railway env vars
- **Not just backend vars:** Railway needs BOTH `SUPABASE_URL` (backend runtime) AND `VITE_SUPABASE_URL` (frontend build)
- **Verify fix:** `grep "supabase.co" public/assets/index*.js` should find the URL
- **Don't mark Done until tested** - wait for Railway deploy, then test login

### Railway Nixpacks Auto-Rebuilds Frontend (P0 Jan 31)
- **Railway's Nixpacks builder auto-runs `npm run build`** if build script exists in package.json
- Our build script: `cd admin && npm install && npm run build && cp -r dist/* ../public/`
- **Problem:** Railway doesn't have VITE_* env vars, so rebuilt frontend has NO Supabase credentials
- **Symptom:** Auth works locally, breaks in production (different JS file hashes in public/)
- **Fix:** Set explicit `buildCommand` in `railway.json` to skip frontend rebuild:
  ```json
  "build": {
    "builder": "NIXPACKS",
    "buildCommand": "npm install"
  }
  ```
- **Pattern:** Pre-build frontend locally with correct `.env`, commit to `public/`, let Railway just install deps

### Verify Frontend Build Has Credentials (Jan 31)
- **Don't assume old builds are valid** - Just because `public/` has files doesn't mean they have credentials
- **Check BOTH URL and anon key:**
  ```bash
  # Check URL
  grep "memscjotxrzqnhrvnnkc.supabase.co" public/assets/index-*.js
  # Check anon key (first part of JWT)
  grep "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" public/assets/index-*.js
  ```
- **If either is missing:** Rebuild frontend: `cd admin && npm run build`, then copy `dist/*` to `public/`
- **Stale builds accumulate** - Multiple `index-*.js` files in `public/assets/` means old builds weren't cleaned up
- **Clean before copy:** `rm -rf public/assets public/index.html` before copying new build
- **File hash changes = new build** - If `index-BmHVaqEO.js` becomes `index-M3mvPwS8.js`, credentials may differ

### VPS Memory Management
- **4GB RAM + 2GB swap** is minimum for CC + MCP servers
- If CC freezes: check `free -h` - likely out of memory
- Added swap: `fallocate -l 2G /swapfile && mkswap && swapon` (persistent via /etc/fstab)
- Consider upgrading to 8GB RAM when budget allows

---

## 🔧 /fix Workflow

When user says `/fix`:
1. **Read** `.claude/context.md` + `docs/LESSONS.md`
2. **Read Slack** #growth-alerts for bug reports
3. **Investigate** - trace the bug, find root cause
4. **Plan** - identify the fix (0.1% solution)
5. **Fix** - implement the fix
6. **Test** - verify it works
7. **Deploy** - commit, push, update docs (LESSONS.md, SYSTEM-INTEL.md)

**No questions. No explanations. Just fix.**

---

## 🚨 CRITICAL (Read First)

### Self-Improvement Protocol
- **AUTOMATIC: "Is there a lesson here?"** - After EVERY fix, discovery, or workaround, immediately ask yourself: "Is there a lesson here?" Don't wait for the user to prompt you. If you had to figure something out, future sessions need to know it.
- **Documenting isn't enough** - When you discover a gotcha, ask: "What config/code/data needs to change so this CAN'T happen again?" Don't just add to LESSONS.md - update the actual system. Example: Wrong Notion IDs → don't just document the right ones → add them to CLAUDE.md where they'll be used.
- **Three-layer capture** - Every lesson should be: (1) Fixed in the system, (2) Documented in LESSONS.md, (3) Logged to Notion for pattern analysis. If you only do one, the lesson is incomplete.
- **Agents must share knowledge** - If CC learns something, Clawdbot needs to see it too (and vice versa). The VPS syncs the git repo every 30 min. Both read from `docs/LESSONS.md` + Notion. Don't create separate knowledge silos.

### Context Management
- **Small batches only** - 4-5 fixes max per prompt. 8+ fixes = context loss risk
- **Plan files save you** - Always save state to plan file before context compacts
- **Push after each step** - Commit after each completed step, not at the end
- **Never trust "I'll remember"** - Write it down or lose it

### Tailwind Compilation
- **NEVER use dynamic classes** - `bg-${color}-500` won't compile in production
- **Use static classes with conditionals** - `score > 70 ? 'bg-green-500' : 'bg-gray-500'`
- **Test production build** - `npm run build` catches what dev mode misses

### Import Paths
- **Service files use `../config/database`** - Always `const { supabase } = require('../config/database')` in `src/services/` files
- **Never `require('./supabase')`** - This file doesn't exist; causes server startup crash
- **Route files also use `../config/database`** - Same pattern in `src/routes/`
- **Test startup after adding new files** - `node --check src/index.js` catches missing modules

### Database Safety
- **Always `IF NOT EXISTS`** - Every ALTER TABLE, every CREATE, every time
- **Update PENDING_MIGRATIONS.md immediately** - Mark ⬜ pending, user marks ✅ after running
- **Error handling on every DB op** - Supabase fails silently otherwise
- **Never hard delete leads** - Soft delete with `deleted_at` column
- **Check errors on insert/update** - `const { error } = await supabase.insert(...)` then handle it

### Phone Lookup Queries MUST Filter Deleted Leads (Jan 30)
- **Bug:** When a lead is soft-deleted and re-created with the same phone, `maybeSingle()` throws PGRST116 (multiple rows)
- **Silent failure:** Code was ignoring PGRST116 error, resulting in `lead = null` even though active lead exists
- **Fix:** Always add `.is('deleted_at', null)` BEFORE `.or(phone.eq.format1,...)` in phone lookups
- **Affected files:** `webhooks.js` (inbound SMS), `voice.js` (inbound calls)
- **Broader pattern:** Any query using `maybeSingle()` on a table with soft deletes MUST filter `deleted_at`
- **Call list gotcha:** Also need `.not('phone', 'is', null)` to filter leads without phone numbers

### React Patterns (from Jan 19 audit)
- **Array keys from content, not index** - Use `key={item.id}` or `key={item}` for strings, not `key={idx}`
- **Optional chaining everywhere** - `businessIntel?.hiring_needs?.looking_for` not `businessIntel.hiring_needs.looking_for`
- **Promise.allSettled for batch ops** - One failure shouldn't kill the whole batch
- **Error state in components** - Users need to know when things fail, not just see empty UI

### TDZ (Temporal Dead Zone) in useCallback - Jan 26
- **NEVER reference later-defined callbacks in dependency arrays** - Causes TDZ crash at runtime
- **Bad:** `const selectAll = useCallback(() => {...}, [filterBySearch])` when `filterBySearch` defined 100 lines later
- **Fix:** Inline the logic instead of referencing the function, or move the dependency earlier in file
- **Why:** Dependency array is evaluated at definition time, not execution time - variable must exist

### API Rate Limiting
- **Expensive AI endpoints need `expensiveAiLimiter`** - GPT-4o calls cost money, protect them
- **Check all POST endpoints for rate limits** - Easy to forget on new endpoints

### The 200 Hour Rule
- **Document everything as you go** - You WILL lose context
- **If it's not in a .md file, it doesn't exist** - Memory is unreliable
- **Evidence archive before fixes** - Screenshot/save state before changing anything

### Investigation Before Planning
- **Always verify user reports** - Screenshots can be outdated; the actual code may be more complete
- **Audit first, plan second** - Read all related files before assuming something is broken
- **Check what API returns** - Data often exists server-side but isn't displayed client-side
- **Count what's working vs missing** - Frame the gap, not the whole feature (e.g., "5/17 issues missing" not "17 issues")

### Debugging "Not Working" Features
When user says "X isn't showing/working":
1. **Trace the data flow** - Where is data created? Stored? Fetched? Displayed?
2. **Check field names at each step** - camelCase vs snake_case mismatches are common
3. **Verify API select includes all needed fields** - Frontend can't show what API doesn't return
4. **Look for multiple data sources** - Same concept may be stored in different places (e.g., buyingSignal in communication.metadata AND buying_signals in lead.metadata)
5. **Add fallback detection** - If data might come from multiple sources, check all of them

---

## Database / Supabase

### Patterns That Work
- Perplexity caching: 30-day cache in `company_intel` table
- Soft deletes: `deleted_at` column, filter with `WHERE deleted_at IS NULL`
- Realtime subscriptions: `RealtimeContext.jsx` pattern
- Phone numbers: E.164 Australian format (+614...)
- `supabaseAdmin` client for server-side auth validation

### Gotchas
- **Notion MCP uses different IDs than URLs** - The database ID in Notion URLs (e.g., `7129fca3-14a4-48a7-8de6-7e14a880823d`) is NOT what the MCP needs. Use `API-post-search` to find the `data_source_id` (e.g., `6ede5973-7f70-4814-bdaa-711934c18194`). The URL ID is `database_id`, but MCP queries need `data_source_id`. Search by name first, then use the ID from results.
- Race conditions on callback creation - check exists before insert
- RLS policies can silently block operations - test with actual user tokens
- Realtime only fires on tables with replication enabled
- Large result sets need pagination - don't fetch all leads at once
- **Storage buckets can't be created via SQL** - Database tables (voicemail_recordings, voicemail_drops) can be migrated but Storage buckets must be created via Supabase Storage API or Dashboard. Check for missing buckets when features fail with "Storage bucket not configured"
- **Realtime debugging:** Check browser console for `[Realtime] Communications channel: SUBSCRIBED`. If not subscribed, WebSocket connection failed.
- **Realtime race condition:** Subscription connects asynchronously after page load. Messages arriving in the first few seconds may be missed. Hard refresh reconnects.
- **Use `gen_random_uuid()` not `uuid_generate_v4()`** - The uuid-ossp extension may not be enabled; gen_random_uuid() is built-in to PostgreSQL 13+
- **`new Date(null)` = epoch (1970)** - Always guard date calculations with year checks (>= 2020). Null dates show as "491295 hrs overdue" (56 years!)
- **Callback `scheduled_at` can be null** - SMS auto-callbacks don't always set a date. Guard with `if (date.getFullYear() >= 2020)`
- **"Save" buttons failing silently** - Often means columns don't exist in DB. Check schema matches what code is trying to save. Example: Intel Edit modal failed because `user_notes`, `manually_updated` columns were never created.
- **Division by zero in UI calculations** - Always check denominator > 0 before calculating percentages. Example: `script.times_replied / script.times_used` crashes when `times_used = 0`
- **API select must include all needed fields** - If frontend needs `lead.metadata`, the backend query MUST include `metadata` in the select. Example: `sms.js:1084` was missing `metadata` so `conv.lead.metadata` was always undefined. Fix: `.select('...fields, metadata')`
- **Type field conventions in communications table** - Use `type: 'sms_outbound'` NOT `type: 'sms'` for outbound messages. The conversations endpoint filters by `type IN ('sms_inbound', 'sms_outbound')`. Using `type: 'sms'` means messages won't appear in the inbox!
- **Metadata field naming inconsistency** - Communication uses `metadata.buyingSignal` (camelCase) with `level` field. Lead uses `metadata.buying_signals` (snake_case array) with `type` field. Always check both: `latestSignal?.type === 'high' || latestSignal?.level === 'high'`
- **UI forms that "don't save"** - Trace the save handler: does it actually call an API to persist data? Example: `PostCallSummarySheet` generated AI summaries but never called `callsApi.log()` - user feedback was lost. Always verify `handleSave/handleComplete` functions include the actual API call.
- **Silent database insert failures** - Always capture the result of `.insert()` calls: `const { error } = await supabase.from('table').insert({...})`. Without error capture, inserts can fail silently (SMS sent via Twilio but not logged to DB). Example: playbook.js sent messages but never showed in chat history because communications insert wasn't verified.
- **Backend/Frontend category mismatch** - If backend sends category values that frontend doesn't render, items disappear. Example: Backend sent `acknowledgment` category for SMS triage, but frontend `categoryOrder` only had `['buying_signal', 'callback_request', 'question', 'positive', 'other']` - conversations with `acknowledgment` never appeared. Fix: Map unknown categories to a catch-all like 'other'.
- **leads table has no `name` or `trade` columns** - Use `first_name, last_name` instead of `name`. Trade comes from `metadata.trade`. Query `.select('id, name, trade')` will fail. For search, use `.or('first_name.ilike.%query%,last_name.ilike.%query%')`. **IMPORTANT:** When fixing a column name error, grep the ENTIRE codebase - the same wrong column is often used in multiple files (qualityAudit.js, sequenceProcessor.js, sequences.js, activation.js, activationAI.js all had this bug).
- **Phone normalization double-prefix bug** - If a phone number is already E.164 format (`+61451415169`), don't re-add country code. Bug: `+61${normalizedPhone.slice(1)}` turns `+61451415169` into `+6161451415169`. Fix: Use normalized phone directly, it's already correct format.
- **RLS blocks backend reads AND writes** - Tables with RLS enabled block ALL backend operations (not just writes). The anon key can't read sequences, enrollments, dossiers, etc. **ALWAYS use `supabaseAdmin || supabase` pattern for any table with RLS.** This bit us multiple times: sequences API returned empty, dossier SELECT returned null then INSERT failed. If an API returns empty/null when data exists, check RLS first.
- **Race condition on upsert patterns** - SELECT then INSERT can fail if another process creates the row between queries. Example: Dossier GET checks if exists, gets null (RLS), tries INSERT, fails with duplicate key (call log already created it). Fix: Catch error code `23505` (duplicate key) and re-SELECT.
- **Auto-enrollment needs backend trigger** - Don't rely on frontend UI for critical automations. If user closes app before selecting sequence, automation fails. Put auto-enrollment in backend call log endpoint: `if (outcome === 'no_answer') { enrollInSequence() }`.
- **Check table name conflicts before migration** - Before creating new tables, grep existing schema files. Example: `daily_metrics` already existed with different columns (calls/sms/leads), so intelligence system metrics table was renamed to `intelligence_daily_metrics`. Migration will fail if table exists with different schema - the `IF NOT EXISTS` clause doesn't save you when columns differ.
- **Frontend build fails on missing npm packages** - If a hook imports a library (e.g., `uuid` in `useIntelligenceTracking.js`), it MUST be in `admin/package.json`. Build works locally if you ran `npm install` but Railway builds from scratch. Error: `Rollup failed to resolve import "uuid"`. Fix: `cd admin && npm install uuid --save`.
- **Internal job triggers bypass auth** - Jobs API requires Supabase JWT which is hard to get externally. Added `/api/jobs/internal/:jobName` route with `X-Internal-Key` header auth for cron/admin use. Example: `curl -X POST -H "X-Internal-Key: KEY" https://api/jobs/internal/excellenceReport`.
- **Scheduled jobs can infinite loop** - If a setTimeout-based job scheduler calculates 0ms delay, it runs immediately and reschedules itself, causing infinite spam. Example: Prompt evolution job was spamming Slack every second. Fix: (1) Add rate limiting to Slack service (max 1 msg/sec), (2) Disable problematic job until root cause fixed. Always add safeguards to scheduled job functions.
- **Rep performance needs handled_by column** - The `communications` table needs a top-level `handled_by UUID` column (not just in metadata) for efficient querying by rep. Call logging must set this when recording calls. Migration: `ALTER TABLE communications ADD COLUMN IF NOT EXISTS handled_by UUID` + backfill from `metadata->>'handled_by'` or `metadata->>'user_id'`.
- **Phone normalization must apply when sending by leadId** - The `/api/sms/send` endpoint normalized phone numbers when passed directly (`phone` param), but NOT when looking up from lead record (`leadId` param). Bug: Leads with `phone: '0412345678'` would fail because `+610412345678` (12 digits) isn't valid E.164. Fix: Apply `normalizeAustralianPhone(lead.phone)` to the phone from lead record, not just direct phone input. Always normalize at point of use, not just point of entry.
- **Check ALL job files for RLS** - Job files (`src/jobs/`) run without user context. If they use `supabase` instead of `supabaseAdmin`, RLS silently returns empty results — no error, just 0 records. Bug: `sequenceProcessor.js` and `scheduledSms.js` used anon key, RLS blocked reads, jobs appeared to work but processed nothing. Fix: Grep all job files for `require('../config/database')` and ensure they use `supabaseAdmin || supabase` pattern. This is the THIRD time RLS has blocked backend operations — always check job files after adding RLS policies.
- **Express route shadowing with `:id` params** - Routes with dynamic params like `/:id` will catch ALL requests if placed before specific routes. Bug: `/api/leads/assignments` was returning "Invalid lead ID format" because `/:id` route (line 891) came BEFORE `/assignments`. The word "assignments" was being treated as an ID. Fix: Move specific routes (like `/search`, `/assignments`) BEFORE dynamic `/:id` routes. Route order in Express matters! Rule: Static paths first, dynamic params last.
- **Supabase silently returns empty on non-existent columns** - If you SELECT or filter on a column that doesn't exist, Supabase returns empty results with NO error. Bug: Team stats showed 0 calls because queries used `created_by` column which doesn't exist on `communications` table - actual columns are `handled_by` and `user_id`. Fix: Always verify column names against actual schema. Use `\d table_name` in psql or check Supabase Table Editor. Test queries manually before shipping. This is insidious because everything "works" (no crashes) but data is wrong.
- **Voice Assistant SILENCE_TIMEOUT too aggressive** - VoiceAssistant.jsx has a 5-second silence timeout that stops the mic if no speech is detected. Web Speech API `onresult` only fires when speech is RECOGNIZED, not when audio is received. If user hesitates or speaks unclearly, timeout triggers before they finish. Bug: Mic "cuts out after 5 seconds". Fix: Increase `SILENCE_TIMEOUT` from 5000 to 15000ms (15 seconds). Also check button label matches behavior (was "Hold and Speak" but actually tap-to-toggle).
- **Hardcoded API key fallbacks are security risks** - Pattern `const KEY = process.env.KEY || 'default-key'` exposes the default in source code. If code is on public GitHub and env var isn't set in prod, anyone can use the default key. Bug: `src/routes/jobs.js` had `INTERNAL_JOB_KEY || 'growth-engine-internal-2024'` allowing anyone to trigger internal jobs. Fix: Remove fallback, fail fast if env var not set, generate secure random key for production.
- **Migrations can exist but never be applied** - A migration SQL file in `supabase/` doesn't mean the table exists. If migration isn't tracked in PENDING_MIGRATIONS.md, it may have been forgotten. Bug: `dev_queue` table migration existed but was never run - bug/feature reports silently failed. Fix: Always add migrations to PENDING_MIGRATIONS.md immediately when creating them, even if you plan to run them right away.
- **Mobile bottom padding with large sticky headers** - Standard `pb-24` (96px) may not be enough when page has large sticky header taking up viewport space. Combined with 80px BottomNav and iOS safe-area, content can be cut off. Bug: Leads page couldn't scroll to bottom - had 220px sticky header + BottomNav. Fix: Use `pb-32` (128px) for pages with substantial sticky headers. Test on actual mobile devices.
- **iOS Safari PWA blocks Web Speech API** - `webkitSpeechRecognition in window` returns true on iOS Safari PWA, but `recognition.start()` fails silently or throws. Bug: Voice Assistant worked in Safari browser but not when installed as PWA. Fix: Wrap `recognition.start()` in try-catch and fall back to Whisper transcription (MediaRecorder + backend API). Also handle onerror events with 'not-allowed' or 'service-not-allowed' by falling back to Whisper. Pattern: Always have a fallback when using browser APIs that might be restricted in PWA/WebView contexts.
- **Android Chrome Web Speech API ends after 3-5 seconds** - Even with `recognition.continuous = true`, Android Chrome fires `onend` after detecting a brief pause in speech. Bug: Voice Assistant cut out after 3-5 seconds on Android. Fix: Track `commandProcessedRef` and `manualStopRef` refs. In `onend`, if neither is true, call `recognition.start()` to restart. Pattern: Don't trust `continuous` mode on mobile browsers - always implement auto-restart logic in `onend` handler.
- **Web Speech API - manual stop discards interim transcripts** - When user manually stops recognition before getting a final result (`isFinal=true`), the interim transcript is lost. Bug: Saying "Mark hot" then tapping stop button = nothing happened. Fix: Store current transcript in a ref (`currentTranscriptRef`). In `onend`, if `manualStopRef=true` AND transcript exists but not processed, call `processCommand()`. Pattern: Always save transcript state and handle manual stop as a valid "finalize now" signal.

### Migration Protocol (CLI - Preferred)
1. Write SQL in `supabase/[feature]-migration.sql`
2. Run `npx supabase migration new [name]`
3. Copy content to new migration file
4. Run `npx supabase db push --linked`
5. Mark ✅ in `supabase/PENDING_MIGRATIONS.md`
6. Verify with `npx supabase migration list --linked`

**IMPORTANT:** CC has direct DB access - always use CLI, don't ask user to run SQL manually!

### CLI Commands That Work
```bash
npx supabase migration list --linked      # Check applied migrations
npx supabase db push --linked             # Apply pending migrations
npx supabase migration new [name]         # Create new migration file
npx supabase inspect db table-sizes --linked  # Check table sizes
npx supabase migration repair --status reverted <migration_id> --linked  # Reset failed migration
```

**Does NOT exist:** `npx supabase db execute --linked` - use `db push` instead

### Migration Workflow (Quick Reference)
```bash
# 1. Create migration file
npx supabase migration new lead_dossier

# 2. Write SQL to the created file in supabase/migrations/

# 3. Push to remote database
npx supabase db push --linked

# 4. Verify
npx supabase migration list --linked
```

**Gotcha:** `db push` may fail with 502 on first try - just retry, usually works second time

### Migration Recovery
If a migration fails partway through:
1. Check what columns/tables were created before failure
2. Run `npx supabase migration repair --status reverted <migration_id> --linked`
3. Fix the migration SQL
4. Re-run `npx supabase db push --linked`

### Extending Existing Tables
Before using CREATE TABLE, check if table exists:
- **Wrong:** `CREATE TABLE scripts (...)` when scripts table already exists
- **Right:** `ALTER TABLE scripts ADD COLUMN IF NOT EXISTS new_col TYPE`
- Always read existing schema before writing migrations

### Migration Protocol (Manual - Fallback)
1. Write SQL in `supabase/[feature]-migration.sql`
2. Add entry to `supabase/PENDING_MIGRATIONS.md` with ⬜
3. Show SQL to user
4. User runs in Supabase SQL Editor
5. User marks ✅ in PENDING_MIGRATIONS.md
6. Then test the feature

---

## Frontend / React

### Patterns That Work
- `ErrorBoundary` wrapper on App.jsx - catches crashes gracefully
- Time-aware UI components (morning/work/evening modes)
- React Query for caching - implemented in performance session
- Code splitting with lazy imports for faster page loads
- 44px minimum tap targets for mobile
- `useClickOutside` hook for dropdown dismissal
- **Status badges on mobile should be icon-only** - Full text badges take too much space. Use `hidden md:inline` for text, show only icon on mobile. Position `bottom-20 right-3` to stay above nav.

### Gotchas
- **Tailwind v4 Spacing: Use `gap-*` not `space-y-*` for Flex** - Tailwind CSS v4 changed how `space-y-*` works (uses `:not(:last-child)` instead of `* + *`, and `:where()` with 0 specificity). In flex contexts, `space-y-*` can behave unexpectedly. **Pattern:** Always use `flex flex-col gap-*` instead of `space-y-*` for vertical spacing. Example: Desktop sidebar nav items had no visible gap despite `space-y-4`. Multiple fix attempts failed. Root cause: Tailwind v4's changed implementation. Fix: `<nav className="flex flex-col gap-4">` not `<nav className="space-y-4">`.
- **Flex Gap: Remove Wrapper Divs** - `gap-*` on a flex container doesn't create visible spacing when child elements are wrapped in extra `<div>` elements with no sizing. Empty wrapper divs collapse or behave unexpectedly as flex children. **Fix:** Remove unnecessary wrapper divs so the actual content elements are direct flex children. Radix primitives like `Tooltip.Root` with `asChild` on Trigger render NO DOM element - they're just React context providers. So `<Tooltip><NavLink>` makes NavLink the actual flex child, and `gap-4` applies correctly between NavLinks. **Rule:** When using flex + gap, ensure elements you want spaced are DIRECT flex children.
- Stale callback bugs - use refs (`callEndedRef`) to prevent double-calling
- Mock Supabase client when auth not configured (prevents white screen crash)
- Build process uses pre-built `public/` folder - copy `dist/*` after build
- Env vars must be `VITE_` prefixed for frontend access
- Hardcoded URLs break deploys - always use env vars with fallbacks
- **Production build can be stale** - Code changes don't appear until you rebuild (`npm run build`) and copy `dist/*` to `public/`. Always rebuild after frontend changes!
- **Cleanup effects can't access state** - useEffect cleanup runs with stale closure. Use refs to track values needed on unmount (transcriptRef, durationRef, etc.)
- **Component unmount races** - If component A sets state that unmounts component B, B's state/handlers are lost. Use refs + cleanup effects to save critical data before unmount (e.g., LiveCopilot saving transcript when TwilioCall ends the call)
- **Mic conflicts** - Only one component should access microphone at a time. LiveCopilot handles transcription, don't duplicate in CallContext
- **API response format inconsistency** - Endpoints return different formats:
  - `GET /api/leads` returns `{ leads: [...] }`
  - `GET /api/leads/:id` returns lead object directly (not wrapped)
  - `/api/leads/search` returns `{ data: [...] }`
  - Always check what the actual endpoint returns, don't assume `response.lead` or `response.data` exists
- **useEffect race conditions with state from previous renders** - When navigating between pages with the same component (e.g., `/call-prep/123` to `/call-prep/456`), old state may persist:
  - State updates are async, refs are sync - Effect can see new ref but old state
  - Example: Auto-dial fired immediately because `timeRemaining === 0` from previous lead
  - **Fix:** Use a "gate" ref to track if the condition was ever valid (e.g., `timerWasRunningRef` that only becomes true after `timeRemaining > 0`)
- **Context missing functions** - If destructuring `const { makeCall } = useCall()` returns undefined, the function isn't exported from the provider. Check CallContext.Provider value object includes the function. Fixed Jan 2026: Added `makeCall` to CallContext (was only exporting `startCall`).
- **Touch scroll triggering click on mobile** - When using `useLongPress` hook for tap/long-press distinction, scroll gestures can trigger the `onCancel` callback (intended for tap). Bug: User scrolls list, lifts finger, card click fires. Fix: Track `cancelledByScroll` ref when touch moves >10px, and skip `onCancel` call if scroll was detected. **IMPORTANT:** This fix is in `useLongPress.js` but components with CUSTOM touch handling (like SwipeableLeadCard in Leads.jsx) need the same pattern manually added. If a component has its own `onTouchStart/onTouchMove/onTouchEnd` handlers, it needs vertical scroll detection too!
- **Position-based scroll detection failsafe** - `touchmove` events may not fire when scroll container consumes them during momentum scrolling. Failsafe: Compare element's `getBoundingClientRect().top` on touchstart vs touchend. If position changed >10px, it was a scroll not a tap. This catches scrolls that touchmove missed.
- **URL ↔ State bidirectional sync for browser back** - `setSearchParams()` replaces the URL without adding a history entry, so browser back goes to the page before, not the previous state. Fix: (1) Use `navigate()` instead of `setSearchParams()` to add history entries, (2) Add useEffect that syncs URL params → state so browser back/forward updates the state. Pattern: `searchParams` is source of truth, state follows.
- **Feature parity across similar components** - When same feature exists in two places (e.g., CallOutcomeSheet and PostCallSummarySheet), ensure both have the same capabilities. Bug: CallOutcomeSheet had sequence enrollment toggle for no_answer, but PostCallSummarySheet (used from LeadProfile) didn't. Users reported "sequence option missing" depending on which page they called from.
- **Never use `localStorage.getItem('auth_token')` for API auth** - Supabase stores tokens internally, not in `localStorage.auth_token`. Always use `supabase.auth.getSession()` to get the access token:
  ```javascript
  const { data: { session } } = await supabase.auth.getSession();
  const token = session?.access_token;
  headers: { Authorization: `Bearer ${token}` }
  ```
  Bug: FeatureRequestButton used localStorage which was never set → 401 errors. See `api/client.js` for the correct pattern.

### React Router Navigation
- **NEVER use `<a href>` for internal links** - causes full page refresh, loses React state
- **Always use `<NavLink>` or `<Link>`** - proper SPA client-side navigation
- `NavLink` accepts function for className: `className={({ isActive }) => isActive ? 'active' : ''}`
- Use `end` prop on NavLink for exact matching: `<NavLink to="/" end>`

### Realtime Notification System
The high-intent SMS notification flow has multiple components:
1. **Webhook** (`webhooks.js`) - Receives SMS, classifies intent, stores `communication.metadata.buyingSignal`
2. **Webhook also updates** - `lead.metadata.buying_signals` array (separate storage!)
3. **RealtimeContext** - Listens for Supabase INSERT on communications, checks `record.metadata.buyingSignal`
4. **HighIntentAlert** - Popup triggered by RealtimeContext `highIntentSms` state
5. **HighIntentInboxCard** - Dashboard card fetches via API, checks `conv.lead.metadata.buying_signals`

**Key insight:** Same data stored in TWO places with DIFFERENT formats:
- `communication.metadata.buyingSignal` = `{ level: 'high', score: 20, matchedPattern: '...' }`
- `lead.metadata.buying_signals` = `[{ type: 'high', score: 20, detected_at: '...' }]`

**Debugging checklist:**
- Check RealtimeContext console logs: `[Realtime] 🔥 HIGH INTENT SMS received!`
- Verify API returns metadata: Add `metadata` to `.select()` if missing
- Handle both field formats: `signal?.type === 'high' || signal?.level === 'high'`
- Use `useLocation()` hook to get current pathname for conditional rendering

### Modal/Popup Mobile Issues
- **Absolute overlays block clicks** - Decorative overlays (like pulsing backgrounds) with `absolute inset-0` intercept all clicks. Always add `pointer-events-none` to pass clicks through.
- **Modals hidden behind bottom nav** - Fixed modals need `pb-24 md:pb-0` to clear the 80px bottom nav on mobile. Also add `max-h-[calc(100vh-120px)] overflow-y-auto` for tall content.
- **Button disabled with wrong condition** - If button checks `!lead?.phone` but you also have `sms.phone`, the button stays disabled while lead loads. Check all available data sources: `disabled={loading && !sms.phone}` not `disabled={loading || !lead?.phone}`.
- **Browser notifications need permission** - Use Notifications API: check `Notification.permission`, request if needed, then `new Notification(title, {body, icon})`. Add `requireInteraction: true` to keep notification visible.

### Mobile Bottom Padding
- **BottomNav is 80px** - Any content at screen bottom needs clearance
- Use `pb-24` (96px) minimum for mobile bottom padding
- Use `pb-28` (112px) for extra buffer when floating buttons present
- **Responsive padding:** `pb-24 md:pb-6` - mobile clears nav, desktop minimal
- Always test with keyboard open on mobile

### Viewport Height on Mobile
- **Use `100dvh` not `100vh`** - dvh accounts for mobile browser chrome (address bar)
- `100vh` on mobile includes hidden browser chrome = content pushed off screen
- Example: `h-[calc(100dvh-80px)]` for full height minus nav
- 95%+ browser support for dvh as of 2024

### Floating Buttons / Z-Index Conflicts
- **Always audit floating elements** when adding new ones:
  - BottomNav: `fixed bottom-0 z-30` (80px)
  - BugReportButton: `fixed bottom-20 left-4 z-40`
  - VoiceAssistant: `fixed bottom-24 right-4 z-40`
- Floating buttons can block input fields, Send buttons, form controls
- **Hide floating buttons on pages with their own similar features** (e.g., hide VoiceAssistant on Messages which has its own voice input)
- Use `useLocation()` to conditionally render: `{!isInboxPage && <VoiceAssistant />}`

### Performance Wins (Jan 17-18)
- Bundle size: 1.2MB → 396KB (67% reduction) via code splitting
- API calls: 8 → 4 per page load (50% reduction) via batching
- React Query caching with smart invalidation
- Lazy load heavy components (charts, modals)
- **Compression middleware** - 70% smaller transfers (180KB → 47KB gzipped)
- **Static asset caching** - `/assets` cached 1 year (immutable, content-hashed)

### Timezone Handling (AEST)
- **Server runs in UTC (Railway)** but users are in Australia (AEST = UTC+10)
- **"Today" calculations must use AEST** - `setHours(0,0,0,0)` uses server timezone
- Use `getTodayStartAEST()` and `getWeekStartAEST()` helpers in `src/routes/dashboard.js`
- **Bug pattern:** Dashboard shows 0 calls even though calls were made early morning AEST (before 10am = still "yesterday" in UTC)
- **The fix:** Calculate AEST midnight by: (1) shift to AEST, (2) set to midnight, (3) shift back to UTC

```javascript
function getTodayStartAEST() {
  const AEST_OFFSET_MS = 10 * 60 * 60 * 1000;
  const now = new Date();
  const aestTime = new Date(now.getTime() + AEST_OFFSET_MS);
  const midnightAEST = new Date(Date.UTC(
    aestTime.getUTCFullYear(),
    aestTime.getUTCMonth(),
    aestTime.getUTCDate()
  ));
  return new Date(midnightAEST.getTime() - AEST_OFFSET_MS);
}
```

---

## AI Features

### Patterns That Work
- Voice command fast-path: 8 regex patterns checked before GPT fallback
- Context-aware commands via `leadId` injection
- Wisdom system: 14 context triggers with curated fallbacks
- 2-hour cache with context-change invalidation
- Fallback responses (28 curated) when AI unavailable

### Rate Limits (Cost Control)
- Expensive AI ops: 5/min (transcribe, Perplexity research)
- SMS sending: 100/hour, batch SMS: 10/hour
- Webhooks: 500/min for Twilio callbacks
- Write ops: 60/min, deletes: 10/min
- **Don't apply rate limiters at route level** - `app.use('/api/sms', smsLimiter, routes)` blocks ALL endpoints including GETs. Apply limiters only to POST endpoints that actually send SMS: `router.post('/send', smsLimiter, handler)`. Otherwise loading conversations hits the SMS rate limit!

### API Timeout Protection
- **Always add timeouts to external API calls** - Hanging requests tie up server resources and degrade UX. Use `fetchWithTimeout` utility:
  ```javascript
  const { fetchWithTimeout, isTimeoutError } = require('../utils/fetchWithTimeout');
  const response = await fetchWithTimeout(url, options, timeoutMs);
  ```
- **Timeout values by service type:**
  - OpenAI/AI calls: 60s (complex processing)
  - Perplexity research: 45s (web search)
  - Platform API sync: 30s (database queries)
  - Slack webhooks: 10s (should be fast)
  - Health checks: 5s (quick validation)
- **Handle timeout errors gracefully:**
  ```javascript
  if (isTimeoutError(error)) {
    return { success: false, error: 'Request timed out' };
  }
  ```
- **OpenAI client has built-in timeout:** `new OpenAI({ timeout: 60000, maxRetries: 2 })`

### Gotchas
- **Never expose Deepgram API key to frontend** - Use backend WebSocket proxy at `/api/transcribe/ws`. Browser subprotocol auth (`['token', apiKey]`) fails with error 1006. Server-side proxy uses `Authorization: Token xxx` header which works.
- OpenAI can timeout on long transcripts - chunk if needed
- Perplexity has daily limits - cache aggressively (30 days)
- AI suggestions need thumbs up/down feedback loop to improve
- **Cache AI-generated content** - Intel briefs and script recommendations were hitting GPT on EVERY page load. Add 2-hour cache: check `ai_last_analyzed_at` before generating. Support `?refresh=true` to bypass.

### Models Used
| Task | Model | Why |
|------|-------|-----|
| Complex reasoning | gpt-4o | Intel briefs, post-call analysis |
| Simple tasks | gpt-4o-mini | Message drafting, commands |
| Transcription | whisper-1 | Audio-to-text |
| Live streaming | Deepgram Nova-2 | Real-time (fallback: Web Speech API) |

---

## Twilio / Voice

### Patterns That Work
- In-app calling via Twilio Voice SDK (not native dialer)
- Sydney edge server for Australian dial tones
- Global `CallProvider` context for call state
- Auto-transcription starts when call connects

### Gotchas
- Webhook URL must be HTTPS and publicly accessible
- Phone numbers must include country code (+61)
- Call status webhooks can arrive out of order - handle gracefully
- Voicemail detection isn't perfect - give user manual override
- **Inbound calls need outcome logging too** - IncomingCallHandler originally just reset state when calls ended, never showing CallOutcomeSheet. Inbound calls weren't being logged to DB. Fix: Show CallOutcomeSheet after inbound call ends, same as outbound.
- **Track call duration through context** - CallOutcomeSheet had hardcoded `durationSeconds: 0`. Duration must be captured when call ends (`endCall(duration)`) and stored in CallContext state, then read by CallOutcomeSheet. Flow: TwilioCall → endCall(duration) → CallContext.callDuration → CallOutcomeSheet uses it.
- **Inbound calls weren't recording** - Recording was only configured on outbound `<Dial>`. Inbound handler was missing `record: 'record-from-answer-dual'` and `recordingStatusCallback`. Fix: Add both to inbound dial options + cache leadId for linking.
- **Deepgram can't access Twilio recordings directly** - Twilio recordings require Basic Auth (Account SID + Token). Sending URL to Deepgram returns 401 Unauthorized. Fix: Fetch audio from Twilio with auth first, then send binary data to Deepgram with `Content-Type: audio/mpeg`.
- **Manual transcription endpoint** - For retrying failed transcriptions: `POST /api/voice/transcribe-recording` with `{ recordingSid: "RE..." }`. Useful for backfilling recordings that failed before the fix.

---

## Deployment / Railway

### Deploy Process
```bash
cd admin && npm run build    # Build frontend
# Copy admin/dist/* to public/
git add . && git commit -m "Deploy"
git push                      # Railway auto-deploys
```

### Environment Variables
- Document ALL env vars in `.env.example`
- Never commit real keys
- Railway env vars set in dashboard, not in code
- Frontend needs `VITE_` prefix

### Gotchas
- Hardcoded URLs without env var fallbacks break deploys
- Check Railway logs immediately after deploy
- Health endpoint `/health` for monitoring
- Cold starts can cause first request timeout

---

## CC Workflow (3-Instance Setup)

### Roles
| Instance | Role | Reads | Writes |
|----------|------|-------|--------|
| CC1 | Investigate & Plan | Everything | `docs/*-plan.md` |
| CC2 | Execute & Build | Plans | Code, `BUILD-GUIDE.md` |
| CC3 | Test & Deploy | All | `PENDING_MIGRATIONS.md`, live testing |

### Handoff Protocol
1. CC1 writes plan to `docs/[feature]-plan.md`
2. User approves plan
3. CC2 executes, updates `BUILD-GUIDE.md` after each step
4. CC3 runs migrations, tests live, confirms deployment
5. Update `SYSTEM-INTEL.md` with new feature status

### Context Recovery
If CC loses context or chat restarts:
1. Read `CLAUDE.md`
2. Read `docs/SYSTEM-INTEL.md`
3. Read `BUILD-GUIDE.md`
4. Read relevant `docs/*-plan.md`
5. Check `supabase/PENDING_MIGRATIONS.md`
6. Continue from last checkbox

---

## Build Protocol (Every Feature)

### Phase 1: Investigate
- Read existing code
- Report what exists vs missing
- **DON'T write code yet**

### Phase 2: Plan
- Design 0.1% solution
- Save to `docs/[feature]-plan.md`
- **WAIT for approval**

### Phase 3: Execute
- One step at a time
- Show SQL if needed
- Push after each step
- Update plan with ✅ progress

### Phase 4: Test
- End-to-end testing
- Report ✅ / ⚠️ / ❌

### Phase 5: Document
- Update `SYSTEM-INTEL.md` (feature status)
- Update `BUILD-GUIDE.md` (changelog)
- Check off priority queue

### 0.1% Quality Check
Before marking complete, ask:
- Would the best CRM in the world do it this way?
- Is there any friction?
- Could AI make it smarter?
- Is it one click when it could be zero?

---

## Debugging / MDP Protocol

### Before Fixing Anything
1. Screenshot/document current state
2. Save evidence to archive folder
3. Write hypothesis in plan file
4. Get approval before changing code

### Investigation Steps
1. Check browser console for errors
2. Check network tab for failed requests
3. Check Railway logs for backend errors
4. Check Supabase logs for DB errors
5. Reproduce consistently before fixing

### Safe Rollback
- Git commit before each change
- Know how to `git revert`
- Keep previous working version accessible
- Test rollback procedure before you need it

### Common Issues & Fixes
| Symptom | Likely Cause | Fix |
|---------|--------------|-----|
| White screen | Supabase auth crash | Add mock client fallback |
| Missing data | Realtime not connected | Check subscription setup |
| Slow loads | Too many API calls | Batch requests, add caching |
| Build fails | Dynamic Tailwind class | Use static classes |
| Deploy works locally but not prod | Missing env var | Check Railway dashboard |

---

## Planning Best Practices (Jan 20, 2026)

### Plan Document Structure
Every feature plan should include:
1. **Problem** - What pain point are we solving?
2. **Solution** - High-level approach
3. **Database Schema** - Tables, columns, indexes
4. **API Endpoints** - Routes with methods and params
5. **UI Components** - What to build in frontend
6. **Implementation Steps** - Phased, with time estimates
7. **Success Metrics** - How do we know it worked?

### Estimation Guidelines
| Complexity | Effort | Example |
|------------|--------|---------|
| Simple | 2-4 hrs | Add column, new endpoint, simple component |
| Medium | 4-8 hrs | New feature with DB + API + UI |
| Complex | 8-16 hrs | Major feature with multiple integrations |
| Epic | 16-40 hrs | Email sequences, AI scoring, etc. |

### Planning Patterns That Work
- **Start with database schema** - Everything else follows from data model
- **Include seed data** - Makes testing faster
- **List all integration points** - Where does this touch existing code?
- **Estimate ongoing costs** - API calls, storage, external services
- **Define "done" clearly** - What does success look like?

### Plan Document Naming
- `[feature]-plan.md` for implementation plans
- `[feature]-investigation-report.md` for research
- Plans go in `docs/` folder
- Link from SYSTEM-INTEL.md and INDEX.md

### When to Create a Plan
- New feature > 4 hours of work
- Changes to multiple files/systems
- Anything with database schema changes
- Integration with external services
- User asked for something that needs clarification

---

## Code Patterns to Reuse

### Backend
- `src/services/wisdom.js` - Context triggers pattern
- `src/services/callListRanker.js` - Weighted scoring algorithm
- `src/services/ai.js` - Voice command fast-path with GPT fallback
- `src/utils/intent.js` - SMS intent classification + buying signals

### Frontend
- `StrategyModal.jsx` - AI-powered modal pattern
- `RealtimeContext.jsx` - Supabase realtime subscriptions
- `CallContext.jsx` - Global state for in-app calling
- `PowerDialerContext.jsx` - Timer, queue, session stats for auto-advance calling
- `useClickOutside.js` - Dropdown dismissal hook
- `useLongPress.js` - Touch gesture hook for context menus
- `ErrorBoundary.jsx` - Crash recovery wrapper
- `SmartMessageContent.jsx` - Entity detection pattern (phone, email, date linking)
- `InboxFilterTabs.jsx` - Smart filter pattern with counts
- `EnhancedConversationCard.jsx` - Full-featured conversation card with triage actions
- `AITriageSection.jsx` - Collapsible category sections with counts

### Messages Page Architecture (Reference)
The Messages page (`admin/src/pages/Messages.jsx`, 1313 lines) uses 8 modular inbox components:
```
admin/src/components/inbox/
├── AITriageSection.jsx      # Collapsible triage category sections
├── AIExplainModal.jsx       # AI explanation for messages
├── EnhancedConversationCard.jsx  # Main conversation card (name, preview, actions)
├── InboxFilterTabs.jsx      # Filter tabs with counts (Hot, Waiting, etc.)
├── LeadCardPreview.jsx      # Lead info header in conversation view
├── MessageContextMenu.jsx   # Long-press context menu
├── MiniLeadPopup.jsx        # Long-press lead popup
└── SmartMessageContent.jsx  # Clickable phone/email/date detection
```
- **Key pattern:** Keep main page file under control by extracting to subcomponents
- **Filter logic:** Lives in `InboxFilterTabs.jsx` with `filterConversations()` and `calculateFilterCounts()`
- **Triage config:** Lives in components, not centralized (each component defines its own triage styles)

### Database
- Perplexity 30-day cache pattern
- Soft delete with `deleted_at`
- `IF NOT EXISTS` everything
- Indexes on frequently filtered columns
- **JSONB metadata for flexibility** - Store ephemeral flags like `handled: true` in metadata column rather than adding schema columns. No migration needed, can be updated with `metadata || '{"key": value}'::jsonb`

---

### Context Hooks Must Return Safe Defaults
- **Always check if context is null/undefined** before returning from a `useContext` hook
- Components may render outside their expected provider (e.g., TwilioCall in CallProvider uses Tooltip, but TooltipProvider is nested inside CallProvider)
- **Pattern:** Return safe defaults when context is undefined:
  ```jsx
  export const useMyContext = () => {
    const context = useContext(MyContext);
    if (!context) {
      return { enabled: true, setEnabled: () => {} }; // Safe defaults
    }
    return context;
  };
  ```
- **Bug example:** TwilioCall used Tooltip → Tooltip used useTooltips() → useTooltips returned undefined → destructuring `enabled` crashed with "Cannot destructure property 'enabled' of undefined"

### User ID from Auth Middleware
- **Use `req.user?.id` not `req.headers['x-user-id']`** - Frontend doesn't send x-user-id header
- Auth middleware (`src/middleware/auth.js`) attaches `req.user = { id, email, displayName }`
- Routes with `requireAuth` always have `req.user` available
- **Bug pattern:** XP/challenges tracked under 'default-user' because routes checked wrong source
- **The fix:** Replace `req.headers['x-user-id'] || 'default-user'` with `req.user?.id || 'default-user'`

---

## What NOT to Do

- ❌ Big prompts with 8+ fixes (context loss)
- ❌ Dynamic Tailwind classes (won't compile)
- ❌ Hardcoded URLs (breaks deploys)
- ❌ Missing error handling on DB ops (silent failures)
- ❌ Exposing API keys to frontend (security risk)
- ❌ Hard deleting data (use soft delete)
- ❌ Using `req.headers['x-user-id']` instead of `req.user?.id` (feature tracks 'default-user')
- ❌ Trusting memory over documentation (you'll forget)
- ❌ Skipping the plan phase (leads to rework)
- ❌ Deploying without testing build (dev ≠ prod)
- ❌ Forgetting to update PENDING_MIGRATIONS.md (broken features)

---

## 0.1% Quality Audit System

### Daily Automated Testing
- **Runs daily at 6am AEST** - Comprehensive system health check
- **28 tests** across 5 categories: Critical, Core, AI, Quality, Performance
- **Weighted scoring** - Critical systems (3x), Core/AI (2x), Quality/Performance (1x)
- **Slack report** - Full breakdown sent to Slack channel automatically
- **Alerts on failure** - Score below 50 triggers critical alert

### Manual Trigger
```bash
# Via API (requires auth)
POST /api/jobs/qualityAudit

# Via Railway console
node -e "require('./src/jobs/dailyQualityAudit').runAuditNow()"
```

### Test Categories
| Category | Weight | Tests |
|----------|--------|-------|
| Critical | 3x | Database, Auth, Core tables |
| Core | 2x | Call list, Search, SMS, Sequences |
| AI | 2x | Copilot, Intel, Patterns, Learning |
| Quality | 1x | Outcome logging, Transcription, Dossiers |
| Performance | 1x | Query speed, Join queries, Battleground |

### Key Files
- `src/services/qualityAudit.js` - All test definitions
- `src/jobs/dailyQualityAudit.js` - Scheduler + Slack reporter
- `src/jobs/index.js` - Cron registration

### Adding New Tests
```javascript
// In qualityAudit.js, add to appropriate category:
tests.push(await runTest('Test Name', async () => {
  const { data, error } = await supabase.from('table').select('*').limit(1);
  if (error) throw error;
  return { detail: `Found ${data.length} items` };
}));
```

---

## Quick Reference

### Key Files
| File | Purpose |
|------|---------|
| `CLAUDE.md` | Project overview for AI context |
| `docs/SYSTEM-INTEL.md` | Feature status + priorities |
| `BUILD-GUIDE.md` | What's been built |
| `docs/CODEBASE_MAP.md` | Architecture reference |
| `supabase/PENDING_MIGRATIONS.md` | Migration tracking |
| `docs/LESSONS.md` | This file - hard-won knowledge |

### Key URLs
| Service | URL |
|---------|-----|
| Production API | https://rateright-growth-production.up.railway.app |
| Supabase | https://memscjotxrzqnhrvnnkc.supabase.co |
| GitHub | https://github.com/mcloughlinmichaelr-debug/rateright-growth |

### Team
| Name | Role | Uses |
|------|------|------|
| Tony McCabe | Sales Director | Call List, Call Logging |
| Angelica | GM Remote Ops | Messages, Lead Profiles |
| Michael | Founder | Dashboard, Battleground |
| Markus | Developer | Code review |
