# AI + TestSprite PRD — WhatsApp Ticketing System

> Generated from the TestSprite PRD Template.
> Goal: Generate deterministic implementation + high-quality automated tests for every feature, role, and integration point.

---

# MASTER PROMPT

You are building and testing this project using a strict specification-first workflow.

Rules:
- Do NOT invent features outside this PRD
- All acceptance criteria are mandatory
- All invariants must always hold true
- Generate edge-case tests automatically
- Generate regression tests for every discovered defect
- Security requirements are mandatory
- If requirements are ambiguous, ask questions before implementation
- Prefer deterministic behavior over assumptions
- Treat all flows as testable behaviors

Deliverables:
- Production-ready implementation
- Unit tests
- Integration tests
- E2E tests
- Security validation tests
- Edge-case tests
- Regression tests

---

# 1. PROJECT OVERVIEW

## Project Name
WhatsApp Ticketing System (`wa_support_node_v1`)

## Summary
- **What:** A self-hosted, real-time WhatsApp support platform. Every inbound WhatsApp message becomes a structured ticket. Agents reply from a polished dashboard. Also supports Web Portal (company users) and Email channels.
- **Who:** Support agents (staff), admins, and company portal users (customers).
- **Why:** Centralise multi-channel customer support (WhatsApp, email, web chat) into a single ticketing dashboard with AI-assisted auto-replies, onboarding flows, and escalation alerts.
- **Business value:** Faster resolution times, automated first-response via AI, structured intake/onboarding, audit trail for compliance, multi-tenant company isolation.

## Stack
| Layer | Technology |
|-------|-----------|
| API | Node.js 20 LTS + Fastify + Socket.IO |
| WhatsApp | @whiskeysockets/baileys |
| Frontend | React 18 + Vite + Shadcn UI + Tailwind CSS |
| Database | PostgreSQL 15 (self-hosted Supabase) |
| DB Client | postgres.js |
| Auth | Custom JWT (jsonwebtoken) + bcrypt |
| Real-time | Socket.IO |
| State | Zustand + React Query |
| Storage | Supabase Storage (ticket media) |

## Success Criteria
- Staff can view and reply to WhatsApp/email/portal tickets in under 5 seconds
- API latency below 500ms for all CRUD operations
- No unauthorised data exposure across companies or roles
- Real-time message delivery via WebSocket within 2 seconds
- Mobile responsive down to 375px width
- WhatsApp QR pairing completes within 30 seconds
- AI auto-reply triggers within 5 seconds of inbound message (when configured)
- Media uploads (images, voice notes) complete within 10 seconds
- Portal users see only their own company's tickets

---

# 2. USER ROLES

| Role | Description | Permissions |
|---|---|---|
| `admin` | System administrator. Full access. | All screens: Dashboard, Tickets, Contacts, Channels (add/remove/reconnect), Calendar (call-outs/meetups), User Management (CRUD), Companies, Knowledge Base, AI Agents, Audit Log, Settings (OpenAI). Can enable/disable onboarding, AI, alerts per channel. |
| `user` (staff) | Support agent. Day-to-day ticket work. | Dashboard, Tickets (view/reply/close/assign/prioritise), Contacts (view/edit), Channels (view only), Calendar (view/book/decline), Companies (view/edit, portal user creation), Knowledge Base (edit modules). Cannot manage users, delete channels, or access Settings. |
| `company_user` | External customer. Portal access only. | Dashboard (own company tickets only), send/receive messages on portal tickets, upload media. Cannot see other companies' data, cannot access admin screens, cannot manage channels or users. |
| Guest / Unauthenticated | Public landing chat + signup. | Access `/landing/chat`, `/signup/chat`, `/signup/verify`, `/signup/complete`. Access `/health`. No other endpoints. |

---

# 3. CORE USER FLOWS

## Flow 1: Admin Login & Dashboard

### Steps
1. Admin navigates to `/login`
2. Enters email (`admin@example.com`) and password (`Admin123!`)
3. API returns JWT token
4. Frontend stores token, redirects to Dashboard
5. Dashboard shows open ticket count KPI, **Active users** card (admin only: active staff count + per-staff open ticket counts including zeros + unassigned open footer; open counts aggregated client-side from `GET /api/v1/tickets?status=open&limit=100` — approximate when more than 100 open tickets exist), and a live ticket queue enriched with **status**, **assignee** (or Unassigned), and **last-activity** relative time (`last_message_at ?? updated_at ?? created_at`)
6. Non-admin staff (`user` role) and portal users (`company_user`) see the same KPIs and enriched live feed but **not** the Active users block (`GET /api/v1/users` is admin-guarded)

### Expected Result
Admin sees full sidebar with all navigation items, Active users workload card, and enriched live queue. Staff agents see KPIs and enriched live feed without Active users. Dashboard loads within 2 seconds.

---

## Flow 2: WhatsApp Channel Setup (QR Pairing)

### Steps
1. Admin navigates to Channels → Add Channel
2. Enters channel name and phone number
3. API creates channel record, starts Baileys session
4. QR code emitted via Socket.IO (`channel:qr`)
5. Admin scans QR with WhatsApp (Linked Devices)
6. Socket.IO emits `channel:connected`
7. Channel status changes to `connected`

### Expected Result
Channel appears in list with status `connected`. Subsequent inbound WhatsApp messages create tickets.

---

## Flow 3: Inbound WhatsApp Message → Ticket Creation

### Steps
1. Customer sends WhatsApp message to connected channel number
2. Baileys worker receives message, POSTs to API `/internal/inbound`
3. API finds or creates contact (E.164 normalisation + dedup)
4. API finds open ticket for contact+channel OR creates new ticket
5. Message appended with `direction: inbound`, `source: whatsapp`
6. Socket.IO emits `ticket:created` (if new) and `message:new`
7. If onboarding enabled and first ticket on channel: automated prompts sent
8. If AI enabled: talkback service generates and sends automated reply
9. Staff alert notifications sent (email/SMS) if configured

### Expected Result
New ticket visible in dashboard. Contact linked. Messages appear in real-time.

---

## Flow 3b: Staff Create WhatsApp Ticket (Outbound First)

### Steps
1. Staff clicks **New ticket** (topbar or Tickets page)
2. Selects a connected WhatsApp channel
3. Picks an existing contact **or** enters a new phone number
4. Enters required first message and submits
5. API `POST /api/v1/tickets` with `{ channel_id, content, contact_id | phone_number }`
6. Contact found or created; new ticket inserted (always new); assigned to staff
7. Outbound message appended and sent via Baileys
8. UI navigates to `/tickets/:id`

### Expected Result
New open ticket with outbound message. Customer receives WhatsApp text when send succeeds. Delivery failure still leaves ticket + message (error via `message:sent`).

---

## Flow 4: Staff Reply on Ticket (Text)

### Steps
1. Agent opens ticket detail page
2. Types reply in message input
3. Clicks send
4. API `POST /api/v1/tickets/:id/messages` with `{ content }`
5. For WhatsApp: message sent via Baileys worker, `wa_message_id` stored
6. For Portal: message stored with `source: portal`
7. For Email: message sent via SMTP, stored with `source: email`
8. Socket.IO emits `message:sent` and `message:new`
9. Audit log entry created

### Expected Result
Message appears in ticket thread for both staff and customer. Delivery status shown.

---

## Flow 5: Staff Reply with Media (Image/Voice Note)

### Steps
1. Agent opens ticket and selects file(s) to attach
2. Multipart form submitted to `POST /api/v1/tickets/:id/messages`
3. Files uploaded to Supabase Storage (`ticket-media` bucket)
4. Message rows created with `message_type: image|voice_note`, `media_path`, `media_mime`
5. For WhatsApp: media sent as base64 via Baileys worker
6. Signed URLs generated for dashboard playback
7. Socket.IO emits updates

### Expected Result
Media visible in ticket thread with playback controls. Max 5 files per request. Max 50MB per file.

---

## Flow 6: Company Portal User Login & Messaging

### Steps
1. Portal user navigates to `/login`
2. Logs in with email/password (created by staff under Companies)
3. Dashboard shows only tickets for their company
4. User sends message via `POST /api/v1/portal/messages`
5. If no open ticket exists, one is created on the `Web Portal` channel
6. Message stored with `source: portal`, `direction: inbound`
7. Socket.IO emits `ticket:created` / `message:new`

### Expected Result
Portal user sees only their company's tickets. Staff see portal messages in the dashboard.

---

## Flow 7: Portal User with Media Attachments

### Steps
1. Portal user selects files and types a message
2. Multipart form submitted to `POST /api/v1/portal/messages`
3. Files uploaded to Supabase Storage
4. Message rows created with media metadata
5. Signed URLs generated for dashboard display

### Expected Result
Multiple files per send supported. Email tickets do not support media (400 error).

---

## Flow 8: Public Signup (Chat-Based)

### Steps
1. User clicks "Sign up" on login page
2. Scripted chat collects name, email, company (optionally joins existing via account code), phone
3. If Turnstile configured: captcha validated on first message
4. Verification email sent via SMTP
5. User clicks link → `/signup/complete`
6. User sets password → becomes `company_user`
7. JWT returned, user redirected to dashboard

### Expected Result
New user with `role: company_user` and `company_id` set. Rate limited at 40 req/min per IP.

---

## Flow 8b: Public Landing Chat (Ticket Only — Company Match)

### Steps
1. Unauthenticated user opens `/login` (landing page)
2. Left panel shows support chat (`LandingChatPanel`); right panel keeps staff email/password form
3. Chat starts via `POST /api/v1/landing/chat` (optional Turnstile on new session)
4. User chooses **help** → bot asks **name**, **company** (or account code), **work email**
5. API matches company (must exist) + email (must be an active `company_user` or email contact for that company)
6. On match: guest contact `portal:guest:{sessionId}` linked to company; Web Portal ticket created; talkback/AI may reply; ticket id returned for reference
7. On mismatch: bot directs user to **Request access** (existing signup chat) — no ticket
8. Chat **never** collects passwords; console access stays on the right-hand form

### Expected Result
Verified guests get a ticket without logging in. Unmatched guests are sent to signup. Rate limited at 40 req/min per IP on `/api/v1/landing/*`.

---

## Flow 9: Email Channel Inbound

### Steps
1. External mail parser POSTs to `POST /api/v1/inbound/email/:token`
2. Body: `{ from, text, subject?, from_name?, message_id? }`
3. API validates token against channel's `inbound_token`
4. Contact created/found with `email:` prefix key
5. Ticket created/found, message appended with `source: email`
6. AI talkback triggered if configured
7. Staff alerts sent if configured

### Expected Result
Email appears as ticket in dashboard. Duplicate `message_id` values are idempotently skipped.

---

## Flow 10: Channel Onboarding Flow

### Steps
1. Admin enables onboarding on a WhatsApp channel
2. First-time customer sends message → new ticket created
3. System sends 4 sequential prompts: who are you, company, query, query status
4. Customer replies to each prompt
5. Answers stored in `ticket.intake` JSONB
6. Ticket intake step advances: `idle → who → company → query → status → done`
7. Company upserted and contact linked if company name provided

### Expected Result
Onboarding runs only for first ticket per contact per channel. Returning customers skip it.

---

## Flow 11: AI Auto-Reply (Talkback)

### Steps
1. Admin enables AI on channel (model, system prompt, knowledge base text)
2. Optionally: company has `ai_autoreply_enabled` with knowledge modules selected
3. Optionally: company has a default AI agent (with provider, model, persona, KB scope)
4. Inbound message arrives after onboarding completes
5. Talkback service resolves effective credentials (agent > channel > platform > env)
6. LLM generates reply using system prompt + knowledge base + conversation context
7. Reply sent to customer via same channel

### Expected Result
AI reply sent within 5 seconds. Only runs when contact is linked to a company with AI enabled and at least one module selected (for company-scoped KB). Falls back gracefully if no API key.

---

## Flow 12: Ticket Lifecycle (Close, Priority, Assignment, Escalation)

### Steps
1. Agent sets priority: `PATCH /api/v1/tickets/:id` with `{ priority: 'high' }`
2. Agent assigns to user: `PATCH` with `{ assigned_to: userId }`
3. Agent sets due date: `PATCH` with `{ due_date: '2026-06-01' }`
4. Agent marks escalation handled: `PATCH` with `{ needs_escalation: false }`
5. Agent closes ticket: `PATCH` with `{ status: 'closed' }`
6. If ticket-closing message enabled on channel: farewell sent to customer
7. Socket.IO emits `ticket:updated`

### Expected Result
Each action creates an audit log entry. Closed tickets cannot receive new messages (400 error).

---

## Flow 13: Admin User Management

### Steps
1. Admin navigates to User Management
2. Creates user: `POST /api/v1/users` with `{ email, password, role, ai_agent_id? }`
3. Updates user role: `PATCH /api/v1/users/:id` with `{ role: 'admin' }`
4. Deactivates user: `DELETE /api/v1/users/:id`
5. Creates portal user under company: `POST /api/v1/companies/:id/users`

### Expected Result
Users appear in list. Deactivated users cannot log in. Duplicate emails return 409.

---

## Flow 14: Knowledge Base & AI Agents

### Steps
1. Staff creates knowledge module: `POST /api/v1/knowledge-modules`
2. Staff enables modules for a company: `PUT /api/v1/companies/:id/knowledge-modules`
3. Admin creates AI agent: `POST /api/v1/ai-agents` with provider, model, persona, KB scope
4. Admin sets company default agent: `PUT /api/v1/companies/:id/default-ai-agent`
5. Preview models: `POST /api/v1/ai-agents/preview-models` (no key in query string)

### Expected Result
API never returns full API keys (only `has_api_key` / `api_key_hint`). Knowledge modules scoped per agent intersected with company-enabled modules.

---

## Flow 15: Staff Alerts & Escalation

### Steps
1. Admin configures channel alerts: SMS (Twilio), email (SMTP), escalation settings
2. Inbound message arrives
3. Alert service checks channel settings
4. If escalation enabled with `emailThenSms`: email sent first, then SMS
5. If `flagTicketForEscalation`: ticket's `needs_escalation` set to `true`
6. Agent handles and clears flag from ticket sidebar

### Expected Result
Alerts fire per channel settings. Escalation flag visible in ticket UI.

---

## Flow 16: Calendar — Call-out / Meetup Scheduling

### Steps
1. Customer sends inbound message on WhatsApp, portal, or email requesting a call-out or meetup at a specific date/time (e.g. “Can someone come out Tuesday at 2pm?”)
2. After message persist, `schedulingIntentService` runs (fire-and-forget LLM JSON extraction)
3. If confidence ≥ 0.7 and `starts_at` is in the future, a `calendar_requests` row is created with `status=suggested` (defaults assignee from ticket `assigned_to`; buffers 30m before/after)
4. Staff opens **Manage → Calendar** (`/calendar`) and sees the pending request in the suggestions queue
5. Staff can **Book** (edit type/time/assignee/buffers), **Decline**, or open the linked ticket
6. On book, API checks blocked windows (`starts_at − buffer_before` … `ends_at + buffer_after`) for the assignee; overlapping booked events return **409**
7. Booked events appear on the week/month calendar with lighter “Travel” buffer segments
8. Ticket detail shows a banner linking to `/calendar?request=:id` when a pending suggestion exists

### Expected Result
Suggestions are never auto-booked. Confirmed bookings block double-booking including travel lean time. Socket.IO emits `calendar:updated` on suggest/book/decline/cancel.

---

# 4. ACCEPTANCE CRITERIA (CRITICAL)

## Functional Requirements

- [ ] Users must authenticate (JWT) before accessing any protected route
- [ ] `company_user` can only see tickets belonging to their company
- [ ] API returns HTTP 403 for role-restricted endpoints (`admin` only, `staff` only)
- [ ] API returns HTTP 401 for unauthenticated requests to protected routes
- [ ] Email addresses must be unique across users (409 on duplicate)
- [ ] WhatsApp QR pairing via Socket.IO must work end-to-end
- [ ] Inbound WhatsApp messages create tickets and contacts automatically
- [ ] Staff New ticket creates a WhatsApp ticket via POST /tickets (connected channel + contact or phone + content) and navigates to ticket chat
- [ ] Staff New ticket always opens a new ticket even when an open ticket already exists for contact+channel
- [ ] Staff New ticket rejects disconnected / non-WhatsApp channels
- [ ] Staff New ticket Group mode creates WA group, stores wa_group_jid, and ticket chat syncs group messages
- [ ] Group inbound without matching ticket is ignored (no accidental 1:1 ticket)
- [ ] Direct 1:1 WhatsApp inbound does not attach to an open group ticket for the same contact; AI reply stays 1:1
- [ ] Staff picker for groups only lists users with phone (`GET /users/staff-for-groups`)
- [ ] Admin can set staff WhatsApp phone and tags support/remote on user create and user detail
- [ ] Group New ticket staff list filters by All / Support / Remote tags
- [ ] Group AI talkback replies only when channel is @mentioned (phone or LID), message is a clear support question, or inbound is a follow-up to the channel’s last question (no @tag); otherwise silent
- [ ] Group AI talkback outbound is sent to `wa_group_jid` (appears in the WhatsApp group), not the sender 1:1 chat
- [ ] Group ticket right rail shows Group card with subject, client, and members from WhatsApp metadata
- [ ] Staff can add another staff user (with phone) to a group ticket; Support/Remote filter available
- [ ] Staff can remove staff from a group ticket; client cannot be removed (API 400)
- [ ] Onboarding flow runs only for first ticket per contact per channel
- [ ] AI auto-reply respects company AI settings, module scoping, and agent configuration
- [ ] Ticket closing sends farewell message if enabled on channel
- [ ] Portal messages use the `Web Portal` synthetic channel
- [ ] Email inbound webhook validates token and deduplicates by `message_id`
- [ ] Media uploads stored in Supabase Storage with signed URLs
- [ ] Audit logs created for all sensitive actions (login, user changes, channel changes, ticket updates)
- [ ] Signup flow enforces Turnstile captcha when secret key is configured
- [ ] Landing chat enforces Turnstile captcha when secret key is configured
- [ ] Rate limit of 40 req/min on `/api/v1/signup/*`
- [ ] Rate limit of 40 req/min on `/api/v1/landing/*`
- [ ] Landing chat match (company + email) creates guest portal ticket + talkback replies
- [ ] Landing chat mismatch directs to Request access (no ticket, no password collected)
- [ ] Landing chat never returns JWT / never asks for password
- [ ] Ticket list sorted by `updated_at` descending
- [ ] Calendar suggestions created from inbound scheduling intent (WhatsApp/portal/email) with staff confirmation required
- [ ] Booked calendar events enforce travel buffers (default 30m before/after) and return 409 on assignee conflicts
- [ ] Staff Calendar page lists pending suggestions and booked events; portal users cannot access `/calendar`

## UI Requirements

- [ ] Mobile responsive down to 375px width
- [ ] Loading indicators shown during async actions (ticket load, message send, file upload)
- [ ] Error messages displayed clearly (toast or inline)
- [ ] Real-time updates via Socket.IO (new messages, ticket creation, channel status, calendar updates)
- [ ] QR code displayed for WhatsApp pairing
- [ ] Media preview (images) and playback (voice notes) in ticket thread
- [ ] Sidebar navigation reflects user role (admin sees all, staff sees subset, portal user sees minimal)
- [ ] Manage sidebar includes **Calendar** for staff
- [ ] Ticket detail shows scheduling suggestion banner with link to Calendar when pending
- [ ] Ticket detail shows intake answers in sidebar
- [ ] Ticket detail Ticket details card stacks Status / Priority / Channel / Opened; staff can change priority there
- [ ] Ticket detail Customer card shows company account (when linked) and Earlier tickets accordion (up to 2 prior tickets)
- [ ] Admin dashboard shows active staff count and per-staff open ticket counts (including zeros); open counts derived from `GET /api/v1/tickets?status=open&limit=100` (approximate when >100 open); non-admin staff and portal users do not see this block
- [ ] Live ticket queue shows status, assignee (or Unassigned), and last message relative time (`last_message_at ?? updated_at ?? created_at`)
- [ ] Tickets list and Dashboard live queue show a **Group** badge on WhatsApp group tickets (`wa_group_jid`)
- [ ] Escalation flag visible in ticket sidebar with toggle
- [ ] Ticket list includes date range filter (From/To date inputs)
- [ ] Audit log page includes actor (user) filter dropdown
- [ ] Channel settings validates required onboarding template fields before save
- [ ] Company creation shows error feedback on failure

## Performance Requirements

- [ ] Dashboard loads within 2 seconds
- [ ] API response time below 500ms for all endpoints
- [ ] WebSocket events delivered within 2 seconds of trigger
- [ ] Media upload completes within 10 seconds for files under 50MB
- [ ] Ticket list pagination works correctly (limit/offset)

---

# 5. EDGE CASES & FAILURE STATES

## Validation Edge Cases

- Empty content on message send (400)
- Missing email/password on login (400)
- Duplicate email on user creation (409)
- Invalid role on user creation (400)
- Missing `name` on channel creation (400)
- Missing `from`/`text` on inbound email (400)
- Oversized file upload (exceeds `MEDIA_MAX_BYTES`, 413 or 400)
- More than 5 files per multipart request (400)
- Media on email ticket (400 — not supported)
- Sending message on closed ticket (400)
- Invalid/expired JWT token (401)
- Company user with no `company_id` (403)
- Signup with invalid captcha token (400)
- Signup with already-verified token (400)
- AI agent creation without required fields (400)
- Duplicate company name on POST /companies (409)
- Channel PATCH with onboarding enabled and blank template field (400)
- Calendar book without `assigned_to` (400)
- Calendar book overlapping another booked event including travel buffers (409)
- Calendar decline/edit on non-suggested status (400)
- Calendar cancel on non-booked status (400)
- Group participants DELETE when `user_id` phone matches ticket client (400 — client cannot leave via API)
- Group GET/POST/DELETE on ticket without `wa_group_jid` (400)
- Group add staff without WhatsApp phone (400)

## Network / Infrastructure

- WhatsApp worker unavailable (503 on reconnect, warn on session restore)
- Database unavailable (API fails to start, graceful error)
- SMTP not configured (email send returns 503)
- Supabase Storage unavailable (media upload fails gracefully)
- Socket.IO connection dropped and reconnected
- API restart during active WhatsApp session (session restore on startup)

## Concurrency

- Two agents replying to same ticket simultaneously (sequence numbers prevent ordering issues)
- Double-click on message send (idempotent message dedup by `wa_message_id`)
- Duplicate inbound email (`message_id` dedup check)
- Channel disconnect during message send (audit log + error status)
- Simultaneous ticket close and message send

## Recovery Expectations

- WhatsApp sessions auto-restore on API restart (`restoreAllSessions`)
- Failed WhatsApp send logged in audit, error status emitted via Socket.IO
- Inbound email dedup prevents duplicate tickets
- Graceful shutdown: SIGTERM/SIGINT → close Fastify, end DB pool
- Channel reconnect flow: clear worker session → set `connecting` → start new session with `clearAuth: true` (wipes Baileys multi-file auth so a new QR is emitted; stale creds must not silent-login)

---

# 6. INVARIANTS (MUST NEVER BREAK)

- `company_user` may only access tickets where the contact's company matches their `company_id`
- Staff (`admin`/`user`) can access all tickets
- Audit log entries are immutable (insert-only, no UPDATE or DELETE)
- API keys (`ai_agents.api_key`, `platform_settings.openai_api_key`) are never returned in full to the frontend
- JWT secret is never exposed to the frontend
- Passwords are stored as bcrypt hashes, never in plain text
- The `Web Portal` channel (`portal:system`) is never connected to Baileys
- Email channels never connect to Baileys (reconnect returns 400)
- `message.sequence` is monotonically increasing per ticket
- Channel `inbound_token` (email) is never exposed in list APIs
- Ticket `updated_at` is bumped on every new message
- Media signed URLs expire after configured TTL (default 3600s)
- Onboarding prompts run only for the first ticket per contact per channel
- Deleted/deactivated users cannot authenticate
- CORS is restricted to configured origins in production

---

# 7. API CONTRACTS

## Authentication

### POST /api/v1/auth/login
**Request:** `{ "email": "string", "password": "string" }`
**Response:** `{ "token": "jwt-string", "user": { ... } }`
**Errors:** 400 (missing fields), 401 (invalid credentials)

### POST /api/v1/auth/logout
**Headers:** `Authorization: Bearer <token>`
**Response:** `{ "ok": true }`

### GET /api/v1/auth/me
**Headers:** `Authorization: Bearer <token>`
**Response:** `User` object
**Errors:** 401 (no token), 404 (user not found)

## Tickets

### GET /api/v1/tickets
**Query:** `status`, `priority`, `channel_id`, `assigned_to`, `from`, `to`, `limit`, `offset`
**Response:** `Ticket[]` (with joined contact, channel, `assigned_user` `{ id, email, display_name }`, `last_message_at` ISO timestamp or null, portal_participant)

### GET /api/v1/tickets/:id
**Response:** `{ ...Ticket, messages: Message[] }` — Ticket includes `assigned_user.display_name`, `last_message_at`; messages enriched with `media_signed_url`
**Errors:** 403 (access denied), 404 (not found)

### POST /api/v1/tickets
**Auth:** staff (`admin` | `user`)
**Request:** `{ "channel_id": "uuid", "content": "string", "contact_id"?: "uuid", "phone_number"?: "string", "mode"?: "direct"|"group", "staff_user_ids"?: "uuid[]", "group_subject"?: "string" }` — exactly one of `contact_id` or `phone_number`
**Behaviour:** Creates a **new** ticket on a **connected WhatsApp** channel, assigns to the actor, sends first outbound WhatsApp message. `mode: "group"` creates a WhatsApp group (client + 1–3 staff with phones), stores `wa_group_jid`. On WA send failure still returns 201 with ticket/message and emits `message:sent` error.
**Response:** `{ ticket: Ticket, message: Message }` (201)
**Errors:** 400 (validation / channel not WhatsApp or not connected), 404 (channel/contact), 502 (group create failed)

### GET /api/v1/users/staff-for-groups
**Auth:** staff
**Response:** `{ id, email, display_name, phone, staff_tags }[]` — active staff with a WhatsApp phone set

### GET /api/v1/tickets/:id/group
**Auth:** staff
**Response:** `{ ticket_id, wa_group_jid, subject, channel_id, channel_name, client, members[], participant_count }` — members include `kind` (`client`|`staff`|`unknown`), `can_remove`, optional `user_id` / `staff_tags`
**Errors:** 400 (not a group ticket), 403, 404

### POST /api/v1/tickets/:id/group/participants
**Auth:** staff
**Request:** `{ "user_id": "uuid" }` — staff with WhatsApp phone; adds to WA group and `wa_group_participant_user_ids`
**Response:** same as GET group view
**Errors:** 400, 403, 404, 500 (worker failure)

### DELETE /api/v1/tickets/:id/group/participants
**Auth:** staff
**Request:** `{ "user_id": "uuid" }` — removes staff from WA group; **cannot** remove client contact phone
**Response:** same as GET group view
**Errors:** 400 (client remove / missing phone), 403, 404, 500

### PATCH /api/v1/tickets/:id
**Request:** `{ status?, priority?, due_date?, assigned_to?, needs_escalation? }`
**Response:** Updated `Ticket`
**Errors:** 403, 404

### POST /api/v1/tickets/:id/messages
**Request (JSON):** `{ "content": "string" }`
**Request (Multipart):** Fields `content`/`caption` + up to 5 file parts
**Response (JSON):** `Message` or `{ messages: Message[] }`
**Errors:** 400 (empty content, closed ticket, media on email), 403, 404

## Channels

### GET /api/v1/channels
**Response:** `Channel[]`

### POST /api/v1/channels/init
**Request:** `{ "name": "string", "phone_number": "string" }`
**Response:** `Channel` (status `connecting`)
**Admin only**

### POST /api/v1/channels/email
**Request:** `{ "name": "string" }`
**Response:** `Channel` with `inbound_token`
**Admin only**

### POST /api/v1/channels/:id/reconnect
**Admin only.** Errors: 400 (portal/email channel), 404, 503 (worker down)

### PATCH /api/v1/channels/:id
**Request:** `{ name?, settings? }`
**Admin only**
**Errors:** 400 (onboarding enabled with empty template field), 404

### DELETE /api/v1/channels/:id
**Admin only.** Returns 204.

## Users

### GET /api/v1/users — Admin only
### POST /api/v1/users — Admin only. `{ email, password, role, ai_agent_id? }`
### PATCH /api/v1/users/:id — Admin only. `{ role?, is_active?, ai_agent_id? }`
### DELETE /api/v1/users/:id — Admin only (deactivate). Returns 204.

## Companies

### GET /api/v1/companies — Staff only
### POST /api/v1/companies — Staff only. `{ name }`
**Errors:** 400 (missing name), 409 (duplicate normalized name)
### GET /api/v1/companies/:id — Staff only. Returns aggregates + tickets + default AI agent.
### PATCH /api/v1/companies/:id — Staff only. `{ name?, ai_autoreply_enabled? }`
### POST /api/v1/companies/:id/users — Staff only. `{ email, password, display_name? }`
### GET /api/v1/companies/:id/knowledge-modules — Staff only
### PUT /api/v1/companies/:id/knowledge-modules — Staff only. `{ module_ids: string[] }`
### PUT /api/v1/companies/:id/default-ai-agent — Staff only. `{ ai_agent_id: string|null }`

## Knowledge Modules

### GET/POST /api/v1/knowledge-modules — Staff only
### GET/PATCH/DELETE /api/v1/knowledge-modules/:id — Staff only

## AI Agents

### GET /api/v1/ai-agents — Staff only
### POST /api/v1/ai-agents — Admin only. `{ display_name, provider, api_key?, base_url?, model_id, persona_system?, guardrails?, knowledge_module_ids? }`
### GET /api/v1/ai-agents/:id — Staff only
### PATCH /api/v1/ai-agents/:id — Admin only
### DELETE /api/v1/ai-agents/:id — Admin only. Returns 204.
### POST /api/v1/ai-agents/preview-models — Staff only. `{ provider, api_key?, base_url? }`

## Calendar

### GET /api/v1/calendar/staff — Staff only. Active `admin`/`user` rows for assignee picker.
### GET /api/v1/calendar/requests — Staff only. Query: `status` (comma-separated), `assigned_to`, `from`, `to`, `ticket_id`, `limit`.
### GET /api/v1/calendar/events — Staff only. Query: `from`, `to` (required), `assigned_to?`. Booked events overlapping range.
### GET /api/v1/calendar/availability — Staff only. Query: `assigned_to`, `starts_at`, `ends_at`, `buffer_before_minutes?`, `buffer_after_minutes?`, `exclude_id?`. Returns `{ available, conflicts[] }`.
### GET /api/v1/calendar/requests/:id — Staff only.
### PATCH /api/v1/calendar/requests/:id — Staff only. Edit suggested draft: `{ event_type?, summary?, starts_at?, ends_at?, buffer_before_minutes?, buffer_after_minutes?, assigned_to? }`.
### POST /api/v1/calendar/requests/:id/book — Staff only. Optional same body as PATCH. **409** with `conflicts` on buffer overlap.
### POST /api/v1/calendar/requests/:id/decline — Staff only. Suggested → declined.
### POST /api/v1/calendar/requests/:id/cancel — Staff only. Booked → cancelled.

**Socket.IO:** `calendar:updated` `{ id, status, ticketId? }` on suggest/book/decline/cancel.

## Portal

### POST /api/v1/portal/messages — `company_user` only. JSON or multipart.

## Inbound Email

### POST /api/v1/inbound/email/:token — Public (token auth). `{ from, text, subject?, from_name?, message_id? }`

## Signup

### POST /api/v1/signup/chat — Public. `{ sessionId?, message?, captchaToken? }`. Rate limited.
### GET /api/v1/signup/verify?token= — Public.
### POST /api/v1/signup/complete — Public. `{ token, password }`

## Landing

### POST /api/v1/landing/chat — Public. `{ sessionId?, message?, captchaToken? }`. Rate limited (40/min/IP). Returns `{ sessionId, messages, ticketId? }`. No passwords / no JWT. Optional Turnstile on new session.

## Settings

### GET /api/v1/settings/platform — Admin only. Returns `{ has_openai_key, openai_key_source, openai_base_url }`
### PATCH /api/v1/settings/platform — Admin only. `{ openai_api_key?, openai_base_url? }`
### GET /api/v1/settings/openai/models — Admin only

## Audit Logs

### GET /api/v1/audit-logs — Admin only

## Contacts

### GET /api/v1/contacts — Staff only
### GET /api/v1/contacts/:id — Staff only

## Internal (WhatsApp Worker → API)

### POST /internal/inbound — Worker auth. Inbound message from Baileys.

## Health

### GET /health — Public. `{ "status": "ok" }`

### Error Response Format

| Code | Meaning |
|---|---|
| 400 | Validation Error / Bad Request |
| 401 | Unauthorized (missing or invalid JWT) |
| 403 | Forbidden (insufficient role / company mismatch) |
| 404 | Not Found |
| 409 | Conflict (duplicate email, etc.) |
| 503 | Service Unavailable (SMTP/worker down) |
| 500 | Internal Server Error |

---

# 8. DATABASE RULES

## Tables (migrations: `001_users` → `023_calendar_requests`)

| Table | Purpose |
|---|---|
| `users` | Staff and portal users. Columns: id, email, password_hash, role, company_id, display_name, phone, ai_agent_id, is_active |
| `contacts` | WhatsApp/email/portal contacts. phone_number VARCHAR(64) for `portal:uuid` and `email:addr` keys |
| `channels` | WhatsApp, email, and portal channels. Columns: kind, status, phone_number, inbound_token, settings JSONB |
| `tickets` | Support tickets. Columns: contact_id, channel_id, assigned_to, status, priority, due_date, needs_escalation, intake JSONB |
| `messages` | Ticket messages. Columns: direction, content, source, message_type, media_path, media_mime, media_size_bytes, media_duration_sec, media_caption, wa_message_id, sequence |
| `audit_logs` | Immutable audit trail. Columns: actor_id, actor_type, action, entity_type, entity_id, metadata JSONB |
| `platform_settings` | Singleton row for OpenAI key/base URL |
| `companies` | Company records. Columns: name, normalized_name, account_code, ai_autoreply_enabled |
| `signup_sessions` | In-progress signup chat sessions |
| `landing_sessions` | Public landing chat sessions (mode + ticket/contact state). Migration `022_landing_sessions.sql` |
| `knowledge_modules` | Markdown knowledge articles for AI |
| `company_knowledge_modules` | Junction: which modules are enabled per company |
| `ai_agents` | LLM agent configurations (provider, model, persona, API key) |
| `company_ai_agents` | Junction: company → default agent |
| `ai_agent_knowledge_modules` | Junction: which modules an agent is scoped to |
| `calendar_requests` | Call-out/meetup suggestions and bookings. Columns: ticket_id, message_id, channel_id, contact_id, event_type, summary, starts_at, ends_at, buffer_before_minutes, buffer_after_minutes, assigned_to, status (`suggested`/`booked`/`declined`/`cancelled`), extraction JSONB, confirmed_by, confirmed_at. Migration `023_calendar_requests.sql` |

## Constraints
- Foreign keys enforced on all relationships
- `contacts.phone_number` unique
- `users.email` unique
- `companies.normalized_name` unique
- `companies.account_code` unique
- `channels.inbound_token` unique (email channels)
- `message.sequence` auto-incremented per ticket
- Soft delete via `users.is_active = false` (no hard deletes on users)
- At most one `calendar_requests` row with `status=suggested` per ticket (partial unique index)
- `calendar_requests.ends_at > starts_at`

## Data Retention
- Audit logs: permanent, insert-only
- Messages: permanent (no deletion)
- Tickets: permanent (closed, not deleted)
- WhatsApp auth sessions: stored in Docker volume `wa_auth`
- Media files: stored in Supabase Storage `ticket-media` bucket

---

# 9. SECURITY REQUIREMENTS

## Authentication
- JWT with configurable expiry (`JWT_EXPIRY`, default 8h)
- bcrypt password hashing
- Session timeout via JWT expiry
- Password policy: minimum enforced by signup flow
- Deactivated users (`is_active = false`) cannot authenticate

## Authorization
- Role-based access control: `admin`, `user`, `company_user`
- `admin` guard on: user management, channel add/delete, settings, AI agents
- `staff` guard (admin + user) on: tickets, contacts, companies, knowledge modules, channels (read)
- `company_user` guard: portal messages only, own company tickets only
- Ticket access checked via `userCanAccessTicket` (company isolation)

## Input Validation
- All request bodies validated for required fields
- E.164 phone number normalisation on contacts
- Email format validation on contacts and users
- File MIME type validation on media uploads
- File size limit (`MEDIA_MAX_BYTES`, default 50MB)
- File count limit (5 per request)
- JSON body validation on all API endpoints
- Turnstile captcha on signup and landing chat (when configured)

## Captcha
- Cloudflare Turnstile on signup and landing chat (first message)
- `TURNSTILE_SECRET_KEY` (API) + `TURNSTILE_SITE_KEY` (frontend)
- Bypassed when secret key is not set

## Rate Limiting
- Signup routes: 40 req/min per IP
- Other endpoints: should be rate limited in production

## CORS
- Configurable via `CORS_ORIGIN` environment variable
- Default in Docker: only the stack's UI origin
- Unset: allow-all (dev only)

## Logging
- All authentication attempts logged (audit log)
- All user creation/modification logged
- All channel operations logged
- All ticket status changes logged
- All message send attempts logged (success + failure)
- All settings changes logged
- All AI agent operations logged
- Failed operations include error details in metadata

---

# 10. NON-FUNCTIONAL REQUIREMENTS

## Scalability
- Single-node deployment via Docker Compose
- PostgreSQL connection pooling via Supavisor
- WhatsApp sessions: one Baileys worker per channel
- Expected scale: 1–50 concurrent agents, 100–1000 tickets/day

## Availability
- Target: 99.5% uptime
- Auto-restart via Docker `restart: unless-stopped`
- Graceful shutdown on SIGTERM/SIGINT
- WhatsApp session auto-restore on API restart

## Monitoring
- Health check endpoint: `GET /health`
- Structured logging via Pino (pretty in dev, JSON in prod)
- Supabase Analytics (Logflare) for Postgres query logs
- Vector log collector → Logflare pipeline

## Accessibility
- Semantic HTML in React components
- ARIA labels on interactive elements
- Keyboard navigation support
- Sufficient colour contrast (WCAG AA)
- Screen reader compatible ticket thread

## Browser Support
- Chrome 100+ (primary)
- Firefox 100+
- Safari 15+
- Edge 100+
- Mobile Safari (iOS 15+)
- Chrome Mobile (Android 10+)

---

# 11. TESTING REQUIREMENTS

## Required Test Coverage

### Unit Tests
- **authService:** login, createUser, createCompanyPortalUser, deactivateUser, bcrypt hashing, JWT generation/verification
- **ticketService:** list (with filters), getById, createTicket, closeTicket, setPriority, setDueDate, assignTo, setNeedsEscalation, buildInitialIntake
- **messagingService:** appendMessage (all sources), getMessages, isDuplicate, sequence ordering
- **contactService:** findOrCreate, E.164 normalisation, dedup
- **channelService:** create, list, getById, update, remove, createEmailChannel, setStatus, getByInboundToken
- **companyService:** create, list, getById, updateName, updateAiAutoreply, getDetailAggregates
- **knowledgeModuleService:** CRUD, listForCompany, setCompanyModules
- **aiAgentService:** CRUD, setCompanyDefaultAgent, getCompanyDefaultAgentId, API key masking
- **portalService:** postInboundFromCompanyUser, postInboundFromCompanyUserWithFiles, company isolation
- **signupService:** processChat (all steps), verifyToken, completeSignup
- **landingChatService:** detectLandingIntent, matchCompanyAndDetails, processChat (welcome / identity / support — no password)
- **talkbackService:** handleInbound, credential resolution chain (agent > channel > platform > env)
- **calendarService:** listRequests, listBookedEvents, checkAvailability (buffer overlap), book/decline/cancel, createSuggested
- **schedulingIntentService:** detectAndSuggest guards (onboarding skip, confidence, future starts_at)
- **ticketMediaService:** uploadTicketObject, normalizeMime, enrichMessageWithMediaUrl, requireMediaKindFromMime
- **emailService:** sendSmtpMail
- **channelAlertService:** notifyInboundAlerts (SMS, email, escalation ordering)
- **captchaService:** verifyTurnstileForNewSession
- **Shared types:** mergeChannelSettings, mergeTicketIntake, isPortalChannelPhone, isWebChatSystemChannel, emailContactKey, isEmailContactKey, calendarBlockedWindow

### Integration Tests
- Auth flow: login → get token → access protected route → logout
- Ticket lifecycle: create → reply → set priority → assign → close
- Portal flow: company user login → send message → receive reply
- Signup flow: chat → verify → complete → login
- Landing chat: start session → name/company/email match → ticket/talkback; mismatch → Request access
- Email inbound: POST webhook → ticket created → AI talkback → staff reply
- Channel management: create WhatsApp → reconnect → delete
- Group ticket members: GET group view → POST add staff → DELETE remove staff → reject client remove
- Media upload: multipart POST → storage upload → signed URL retrieval
- Knowledge modules: create module → enable for company → verify in AI context
- AI agents: create agent → set company default → verify talkback uses agent
- Alerts: configure channel alerts → inbound message → verify SMS/email sent
- Calendar: inbound scheduling phrase → suggested request → book with buffers → conflict 409 → decline/cancel

### API Tests
- Every endpoint in Section 7 tested with valid requests
- Every endpoint tested with invalid input (400)
- Every protected endpoint tested without auth (401)
- Every role-restricted endpoint tested with insufficient role (403)
- Every `:id` endpoint tested with non-existent ID (404)
- Duplicate email creation (409)
- Calendar book conflict returns 409 with `conflicts` array
- CORS headers present on all responses
- Rate limiting on signup and landing chat (429 after 40 requests/minute)

### E2E Tests (Playwright)
- **Admin flow:** Login → Dashboard → Channels → Add Channel → Tickets → Reply → Close
- **Agent flow:** Login → Dashboard → Open Ticket → Reply (text) → Reply (media) → Set Priority → Assign → Close
- **Portal user flow:** Login → Dashboard → Send Message → Receive Reply → Upload File
- **Signup flow:** Navigate to login → Sign Up → Complete chat → Verify email → Set password → Dashboard
- **Landing chat:** Open login → help → name/company/email match → ticket; mismatch → Request access; sign-in form still works for staff
- **Channel management:** Add WhatsApp channel → QR display → Add email channel → View webhook URL → Delete channel
- **Company management:** Create company → Create portal user → View company detail → Enable AI autoreply
- **Knowledge base:** Create module → Edit module → Enable for company → Delete module
- **AI agents:** Create agent → Edit agent → Set company default → Delete agent
- **Settings:** OpenAI key configuration → Model listing
- **Contacts:** View contact list → View contact detail
- **Audit log:** View audit log entries (admin only)
- **Responsive:** All above flows at 375px viewport width

### Security Tests
- JWT expired token returns 401
- JWT tampered/invalid token returns 401
- Admin-only endpoint with `user` role returns 403
- Admin-only endpoint with `company_user` role returns 403
- `company_user` accessing another company's ticket returns 403
- `company_user` accessing admin screens returns 403
- Portal message from non-company_user returns 403
- SQL injection attempts on all string inputs
- XSS attempts on message content (sanitised on display)
- CSRF: verify CORS restrictions in production mode
- API key not exposed in GET responses for AI agents and settings
- Signed media URLs expire after TTL
- Inbound email with invalid token returns 404
- Brute force login protection (optional Turnstile)
- File upload with disallowed MIME type rejected
- File upload exceeding size limit rejected

### Performance Tests
- Dashboard load time under 2 seconds (50 open tickets)
- Ticket list load time under 1 second (1000 tickets, paginated)
- Message send round-trip under 500ms
- Media upload under 10 seconds (50MB file)
- 20 concurrent WebSocket connections stable
- 100 API requests/second sustained for 60 seconds

### Regression Tests
- Every production bug becomes a permanent regression test
- Every failed edge case becomes a reusable test case
- Channel disconnect during message send (error logged, UI shows failure)
- Duplicate inbound email (idempotent, no duplicate ticket)
- Portal user without company_id (403)
- Ticket sort order after message update (`updated_at` bump)

---

# 12. OUT OF SCOPE

- Group WhatsApp chat support (1-to-1 only)
- Video calls or voice calls via WhatsApp
- POP3/IMAP email polling (inbound is webhook-only)
- Multi-language/i18n UI (English only)
- Two-factor authentication (MFA)
- Billing/payment processing
- Customer satisfaction surveys
- Chatbot builder UI (AI config is per-channel/agent, not visual flow)
- Mobile native apps (web responsive only)
- Data export (CSV/PDF)
- Scheduled messages
- Canned responses / message templates UI

---

# 13. KNOWN RISKS

| Risk | Impact | Mitigation |
|---|---|---|
| WhatsApp ban/block on Baileys | Complete channel outage | Monitor connection status, support multiple channels, reconnect flow |
| Supabase Storage downtime | Media uploads fail | Graceful error handling, text messages still work |
| OpenAI API rate limits | AI auto-replies delayed or fail | Talkback catches errors, falls back to no-reply, logs failure |
| SMTP misconfiguration | Email channels and signup broken | Validation on send, clear error messages, audit logging |
| PostgreSQL disk full | All writes fail | Monitor disk usage, Supabase analytics alerts |
| JWT secret leak | Complete auth compromise | Rotate secret, re-deploy, all sessions invalidated |
| Baileys library breaking change | WhatsApp integration breaks | Pin dependency version, test on update |
| Docker volume loss | All data + WhatsApp sessions lost | Regular backups of `pgdata` and `wa_auth` volumes |

---

# 14. OPEN QUESTIONS

| Question | Owner | Status |
|---|---|---|
| Should rate limiting be added to all API endpoints? | Dev team | Open |
| Should file type allowlist be configurable per channel? | Product | Open |
| Should portal users be able to close their own tickets? | Product | Open |
| Should AI agent API keys be encrypted at rest in the database? | Security | Open |
| Should WebSocket connections require JWT authentication? | Security | Open |

---

# 15. IMPLEMENTATION NOTES

- **Framework:** Fastify (API), React 18 + Vite (frontend), Baileys (WhatsApp)
- **Infrastructure:** Docker Compose (all-in-one), self-hosted Supabase stack
- **Deployment:** `./deploy.sh` or `deploy.bat` — git pull, docker compose down, build, up, migrate
- **Migrations:** `scripts/apply-sql-migrations.mjs` tracks applied files in `public._wa_sql_migrations`
- **CI/CD:** Bitbucket pipeline (SonarQube scan + TestSprite)
- **Coding standards:** TypeScript strict mode, ESLint, conventional commits
- **Monorepo:** npm workspaces (`apps/api`, `apps/frontend`, `services/whatsapp`, `packages/shared`)
- **Shared package:** `@wa-ticketing/shared` compiled to `packages/shared/dist` before API/frontend builds
- **Environment:** `.env` at repo root, Docker Compose derives most URLs from `PUBLIC_SITE_HOST` + port vars
- **Default credentials:** `admin@example.com` / `Admin123!` (seed command)
- **Ports:** Frontend 3000, API 3001, Kong 8001, Studio 8002, Postgres 5432, Worker 3002 (internal)

---

# 16. FINAL AI EXECUTION INSTRUCTIONS

Before implementation:
1. Review all acceptance criteria in Section 4
2. Review all invariants in Section 6
3. Generate edge-case tests for every flow in Section 3
4. Generate security tests for every endpoint in Section 7
5. Validate all API contracts against actual route implementations
6. Validate authorization rules against role matrix in Section 2

During implementation:
- Fail fast on ambiguous requirements
- Do not silently ignore validation errors
- Log meaningful diagnostics via Pino
- Ensure Socket.IO events match the contracts in shared types
- Test media upload with real files (not mocks) in integration tests

Before completion:
- Run all automated tests (unit + integration + E2E)
- Validate every edge case in Section 5
- Validate full regression suite
- Validate all security requirements in Section 9
- Validate mobile responsiveness at 375px
- Verify all Socket.IO events fire correctly
- Verify audit log completeness for all sensitive actions
- Verify media signed URLs expire correctly
- Verify company isolation for portal users
