# PRD v2 — WhatsApp Ticketing System

**Version:** 2.2  
**Status:** Open Questions Resolved — Ready for Build  
**Last Updated:** April 2026  
**Stack:** Node.js · React · Supabase (self-hosted) · Docker

---

## Table of Contents

1. [Product Overview](#1-product-overview)
2. [Tech Stack](#2-tech-stack)
3. [Architecture](#3-architecture)
4. [Core Modules](#4-core-modules)
5. [Functional Logic](#5-functional-logic)
6. [Database Design](#6-database-design)
7. [API Design](#7-api-design)
8. [WebSocket Events](#8-websocket-events)
9. [UI & Frontend](#9-ui--frontend)
10. [Real-Time Layer](#10-real-time-layer)
11. [Channel Management (QR Flow)](#11-channel-management-qr-flow)
12. [Testing Strategy](#12-testing-strategy)
13. [Infrastructure & Deployment](#13-infrastructure--deployment)
14. [Risk Register](#14-risk-register)
15. [Open Questions](#15-open-questions)
16. [Milestone Plan](#16-milestone-plan)

---

## 1. Product Overview

### Vision

A **self-hosted, real-time WhatsApp support platform** — think Intercom or Zendesk, but built natively for WhatsApp. Every conversation becomes a structured ticket; every reply is sent directly from the agent dashboard.

### Goals

- Automatically create and manage support tickets from inbound WhatsApp messages
- Support multiple WhatsApp numbers (channels) from a single dashboard
- Deliver sub-second message updates via WebSockets
- Run entirely on-premise with a single `docker-compose up --build` command
- Provide a modern, polished UI built with React and Shadcn
- Support role-based access control: **Admin** (full control) and **User** (agent-level access)
- Retain all messages and system events indefinitely for logging and compliance purposes
- Maintain a full audit log of agent and system actions

### Non-Goals (v2)

- Native mobile app
- SMS / email channel support
- AI-assisted reply suggestions *(deferred to v3)*
- Multi-tenant SaaS hosting

### Success Metrics

| Metric | Target |
|--------|--------|
| Time from inbound message to ticket creation | < 2 seconds |
| WebSocket message delivery latency | < 500 ms |
| Session persistence across restarts | 100% |
| Docker cold-start to ready | < 60 seconds |

---

## 2. Tech Stack

### Frontend
| Layer | Technology | Notes |
|-------|-----------|-------|
| Framework | React (Vite) | |
| UI Components | **Shadcn UI** | Installed via `npx shadcn@latest init`; components live in `src/components/ui/` and are owned code — not a dependency |
| UI Source | [https://ui.shadcn.com](https://ui.shadcn.com) | Add individual components: `npx shadcn@latest add button card table dialog` etc. |
| Styling | Tailwind CSS v4 | Bundled with Shadcn setup |
| Icons | Lucide React | Shadcn default icon set |
| Real-time Client | Socket.IO client | |
| State Management | Zustand + React Query | Zustand for UI state; React Query for server data |
| Routing | React Router v6 | |

### Backend
| Layer | Technology | Notes |
|-------|-----------|-------|
| Runtime | Node.js 20 LTS | |
| API Framework | Fastify | Faster than Express; schema validation built-in |
| WhatsApp Client | Baileys | Latest stable — pin version in `package.json` |
| WebSocket Server | Socket.IO | Runs alongside Fastify on same process |
| Auth | JWT (jsonwebtoken) + bcrypt | Custom — not Supabase Auth |

### Data Layer
| Layer | Technology | Notes |
|-------|-----------|-------|
| Primary Database | PostgreSQL 15 | Via self-hosted Supabase Docker stack |
| Connection Pooler | Supavisor | Bundled with Supabase; port `5432` (session) / `6543` (transaction) |
| Admin UI | Supabase Studio | Available at `http://localhost:8000` after startup |
| Migrations | SQL files in `supabase/migrations/` | Applied via `supabase db push` or on container init |
| ORM | Postgres.js or Kysely | Lightweight; avoids heavy ORM overhead |

### Infrastructure
| Layer | Technology | Notes |
|-------|-----------|-------|
| Containerisation | Docker + Docker Compose v2 | All services in one `docker-compose.yml` |
| Supabase Stack | Official Supabase Docker Compose | Cloned from `github.com/supabase/supabase` — see Section 13 |
| Session Store | JSONB in `channels` table | Baileys session persisted to DB |
| Reverse Proxy | Kong (Supabase built-in) | Supabase uses Kong on port `8000` as its API gateway |

---

## 3. Architecture

### System Diagram

```
┌─────────────────────────────────────────────────────────────┐
│                        React UI                             │
│              (REST + WebSocket client)                      │
└───────────────────────┬─────────────────────────────────────┘
                        │  HTTP / WS
┌───────────────────────▼─────────────────────────────────────┐
│                   Node API Gateway                          │
│   ┌─────────────┐  ┌──────────────┐  ┌───────────────────┐ │
│   │  REST API   │  │  WebSocket   │  │  Background Jobs  │ │
│   │  (Express)  │  │  (Socket.IO) │  │  (Bull / Redis)   │ │
│   └──────┬──────┘  └──────┬───────┘  └─────────┬─────────┘ │
└──────────┼────────────────┼───────────────────┼────────────┘
           │                │                   │
┌──────────▼────────────────▼───────────────────▼────────────┐
│                      Core Services                          │
│   ┌───────────┐  ┌───────────┐  ┌───────────┐             │
│   │  Ticket   │  │ Messaging │  │  Channel  │             │
│   │  Service  │  │  Service  │  │  Service  │             │
│   └─────┬─────┘  └─────┬─────┘  └─────┬─────┘             │
└─────────┼──────────────┼──────────────┼────────────────────┘
          │              │              │
┌─────────▼──────────────▼──────────────▼────────────────────┐
│                Supabase (PostgreSQL)                        │
└─────────────────────────────────────────────────────────────┘
          │
┌─────────▼──────────────────────────────────────────────────┐
│            Baileys WhatsApp Worker                         │
│        (runs per channel / per number)                     │
└─────────────────────────────────────────────────────────────┘
```

### Docker Compose Services

| Service | Description |
|---------|-------------|
| `frontend` | React / Vite app |
| `api` | Node.js API + WebSocket server |
| `whatsapp` | Baileys worker (one instance per channel) |
| `supabase-db` | PostgreSQL |
| `supabase-studio` | Supabase admin UI |
| `nginx` | Reverse proxy *(optional)* |

---

## 4. Core Modules

### 4.1 Ticket Service

**Responsibilities:**
- Create tickets on first inbound message from a number
- Append subsequent messages to the open ticket
- Manage ticket lifecycle: `open` → `closed`
- Apply priority and due-date metadata

**Key Interfaces:**
```
createTicket(contactId, channelId) → Ticket
appendMessage(ticketId, message) → void
closeTicket(ticketId) → Ticket
setTicketPriority(ticketId, priority) → Ticket
```

---

### 4.2 Messaging Service

**Responsibilities:**
- Persist all inbound and outbound messages
- Route outbound replies through the correct Baileys session
- Guarantee message ordering via sequence numbers

---

### 4.3 Channel Service

**Responsibilities:**
- Manage WhatsApp channel registrations
- Orchestrate QR authentication (via Baileys + WebSocket)
- Persist and restore Baileys sessions on restart
- Emit connection-status events

---

### 4.4 Contact Service

**Responsibilities:**
- Normalise and deduplicate phone numbers
- Aggregate ticket history per contact
- Provide a unified contact view across channels

---

### 4.5 Auth Service

**Responsibilities:**
- Issue and validate JWT tokens on login
- Enforce role-based access control on all API routes
- Manage user accounts (Admin can create/deactivate users)

**Roles:**

| Role | Capabilities |
|------|-------------|
| `admin` | All actions: manage users, channels, tickets, view audit logs |
| `user` | View and respond to tickets, close tickets, manage contacts |

**Key Interfaces:**
```
login(email, password) → { token, user }
validateToken(token) → User
createUser(email, password, role) → User   [admin only]
deactivateUser(userId) → void              [admin only]
```

---

### 4.6 Audit Log Service

**Responsibilities:**
- Record every significant action performed by agents or the system
- Store actor (userId or `system`), action type, target entity, and timestamp
- Provide a queryable audit log API for Admin review

**Audited Actions:**
- Ticket created, closed, priority changed, due date set
- Message sent (outbound)
- Channel added, removed, connected, disconnected
- User created, deactivated, role changed
- Login (success and failure)

---

## 5. Functional Logic

### Inbound Message Flow

```
Inbound WhatsApp Message
        │
        ▼
Does an open ticket exist?
   (matching phone + channel)
        │
   ┌────┴────┐
  YES       NO
   │         │
   ▼         ▼
Append    Create new
message    ticket
   │         │
   └────┬────┘
        ▼
  Emit ticket:update
  via WebSocket
```

### Ticket Lifecycle

Multiple open tickets **per contact per channel** are supported. Each `closed` ticket is a distinct record; a new inbound message after close always opens a fresh ticket.

| State | Trigger | Behaviour |
|-------|---------|-----------|
| `open` | New inbound message, no existing open ticket | Create ticket, link message |
| `open` | New inbound message, existing open ticket | Append message to most recent open ticket |
| `closed` | Agent closes manually | Mark closed, set `closed_at` |
| `open` (new) | Inbound message after all tickets closed | Create a fresh ticket |

### Outbound Reply Flow

1. Agent types reply in Chat UI
2. UI sends `POST /tickets/:id/messages`
3. API routes to Messaging Service
4. Messaging Service calls Baileys for the correct channel
5. Baileys sends message via WhatsApp
6. Success/failure emitted via `message:sent` WebSocket event

---

## 6. Database Design

### Entity Relationship Overview

```
users
contacts ──< tickets ──< messages
channels ──< tickets
tickets   ──< audit_logs
users     ──< audit_logs
```

### Schema

#### `users`
| Column | Type | Notes |
|--------|------|-------|
| id | uuid | PK |
| email | varchar(255) | Unique |
| password_hash | varchar(255) | bcrypt |
| display_name | varchar(255) | Nullable; optional label for portal (`company_user`) accounts |
| role | enum | `admin`, `user`, `company_user` |
| company_id | uuid | Nullable; set for `company_user` (web portal access scoped to one company) |
| is_active | boolean | Default `true` |
| created_at | timestamptz | |
| last_login_at | timestamptz | Nullable |

#### `contacts`
| Column | Type | Notes |
|--------|------|-------|
| id | uuid | PK |
| phone_number | varchar(64) | E.164 for WhatsApp; synthetic `portal:{userUuid}` for web portal identities |
| display_name | varchar(255) | Nullable |
| company_id | uuid | Nullable, FK → `companies` — staff can link WhatsApp contacts; portal contacts are tied via portal users |
| created_at | timestamptz | |

#### `companies`
| Column | Type | Notes |
|--------|------|-------|
| id | uuid | PK |
| name | varchar | Display name |
| normalized_name | varchar | Unique, for deduplication / lookup |
| created_at | timestamptz | |

#### `channels`
| Column | Type | Notes |
|--------|------|-------|
| id | uuid | PK |
| name | varchar(255) | Friendly label |
| phone_number | varchar(20) | |
| status | enum | `connecting`, `connected`, `disconnected` |
| session_data | jsonb | Baileys session blob — **handle with care** |
| created_at | timestamptz | |
| last_connected_at | timestamptz | |

#### `tickets`
| Column | Type | Notes |
|--------|------|-------|
| id | uuid | PK |
| contact_id | uuid | FK → contacts |
| channel_id | uuid | FK → channels |
| assigned_to | uuid | FK → users, Nullable |
| status | enum | `open`, `closed` |
| priority | enum | `low`, `medium`, `high` |
| due_date | timestamptz | Nullable |
| created_at | timestamptz | |
| updated_at | timestamptz | Bumped on message activity; used for ticket list ordering |
| closed_at | timestamptz | Nullable |
| intake | jsonb | WhatsApp onboarding answers (and defaults) |

> **Index:** `(contact_id, channel_id, status)` — used for the "open ticket exists?" lookup on every inbound message.

#### `messages`
| Column | Type | Notes |
|--------|------|-------|
| id | uuid | PK |
| ticket_id | uuid | FK → tickets |
| direction | enum | `inbound`, `outbound` |
| content | text | |
| wa_message_id | varchar(255) | WhatsApp message ID for deduplication |
| sequence | integer | Monotonic per ticket for ordering |
| created_at | timestamptz | |

> **Unique constraint:** `(wa_message_id)` — prevents duplicate processing.  
> **Retention policy:** Messages are kept indefinitely — they serve as the primary log of all customer interactions.

#### `audit_logs`
| Column | Type | Notes |
|--------|------|-------|
| id | uuid | PK |
| actor_id | uuid | FK → users, Nullable (`null` = system) |
| actor_type | enum | `user`, `system` |
| action | varchar(100) | e.g. `ticket.closed`, `channel.connected`, `user.login` |
| entity_type | varchar(50) | e.g. `ticket`, `channel`, `user` |
| entity_id | uuid | The affected record's ID |
| metadata | jsonb | Additional context (old value, new value, IP, etc.) |
| created_at | timestamptz | |

> **Retention policy:** Audit logs are kept indefinitely and are read-only (no updates or deletes permitted).  
> **Index:** `(actor_id, created_at)` and `(entity_type, entity_id)` for efficient filtering.

---

## 7. API Design

### Base URL
```
/api/v1
```

### Endpoints

#### Tickets
| Method | Path | Description |
|--------|------|-------------|
| GET | `/tickets` | List tickets (filterable by status, priority, channel) |
| GET | `/tickets/:id` | Get ticket detail with messages |
| PATCH | `/tickets/:id` | Update status, priority, due date, or assigned agent |
| POST | `/tickets/:id/messages` | Send outbound reply |

#### Contacts *(staff: admin, user)*
| Method | Path | Description |
|--------|------|-------------|
| GET | `/contacts` | List all contacts |
| GET | `/contacts/:id` | Contact detail: profile, optional joined `company`, and all related tickets (open + closed), ordered by activity |
| PATCH | `/contacts/:id` | Set `company_id` to link/unlink a **WhatsApp** contact to a company (`company_id: null` to clear). **Not** allowed for synthetic `portal:*` contacts (managed via portal users). |

#### Companies *(staff)*
| Method | Path | Description |
|--------|------|-------------|
| GET | `/companies` | List companies |
| POST | `/companies` | Create company |
| GET | `/companies/:id` | Company detail: name, **linked contacts** (id + phone + display name), channel numbers from tickets, portal users, and **all tickets** for contacts in this company (open + closed, `limit` 200) |
| PATCH | `/companies/:id` | Rename company |
| POST | `/companies/:id/users` | Create portal user (`email`, `password`, optional `display_name`) |

#### Portal *(authenticated `company_user`)*
| Method | Path | Description |
|--------|------|-------------|
| POST | `/portal/messages` | Start or continue a web-portal ticket; messages stay on that ticket thread |

#### Channels
| Method | Path | Description |
|--------|------|-------------|
| GET | `/channels` | List channels |
| POST | `/channels/init` | Initialise a new channel (triggers QR flow) |
| DELETE | `/channels/:id` | Remove channel and terminate session |

#### Auth
| Method | Path | Role | Description |
|--------|------|------|-------------|
| POST | `/auth/login` | Public | Authenticate and receive JWT |
| POST | `/auth/logout` | Any | Invalidate token |
| GET | `/auth/me` | Any | Get current user profile |

#### Users *(Admin only)*
| Method | Path | Description |
|--------|------|-------------|
| GET | `/users` | List all users |
| POST | `/users` | Create a new user |
| PATCH | `/users/:id` | Update role or active status |
| DELETE | `/users/:id` | Deactivate user |

#### Audit Logs *(Admin only)*
| Method | Path | Description |
|--------|------|-------------|
| GET | `/audit-logs` | Query audit log (filterable by actor, action, entity, date range) |

---

## 8. WebSocket Events

### Server → Client

| Event | Payload | Description |
|-------|---------|-------------|
| `qr:update` | `{ channelId, qrDataUrl }` | New QR code to display |
| `channel:connected` | `{ channelId }` | Channel authenticated |
| `channel:disconnected` | `{ channelId, reason }` | Session lost |
| `message:new` | `{ ticketId, message }` | Inbound message received |
| `ticket:created` | `{ ticket }` | New ticket opened |
| `ticket:updated` | `{ ticket }` | Ticket metadata changed |
| `message:sent` | `{ messageId, status }` | Outbound send confirmation |

### Client → Server

| Event | Payload | Description |
|-------|---------|-------------|
| `tickets:subscribe` | `{ ticketIds[] }` | Subscribe to specific ticket updates |
| `tickets:unsubscribe` | `{ ticketIds[] }` | Unsubscribe |

---

## 9. UI & Frontend

### Shadcn UI Setup

Shadcn UI is **not a component library** — it is a CLI that copies component source code directly into your project. Components live in `src/components/ui/` and are fully editable.

```bash
# Initialise Shadcn in the frontend app
npx shadcn@latest init

# Add components as needed (examples)
npx shadcn@latest add button card table dialog badge input textarea
npx shadcn@latest add sidebar navigation-menu dropdown-menu avatar
npx shadcn@latest add sheet tooltip popover separator skeleton
```

Reference: [https://ui.shadcn.com](https://ui.shadcn.com)  
MCP integration: Use the Shadcn MCP server (`https://mcp.shadcn.com`) to browse and scaffold components directly from your editor.

**`components.json`** (project config generated by `shadcn init`):
```json
{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "default",
  "rsc": false,
  "tsx": true,
  "tailwind": {
    "config": "tailwind.config.ts",
    "css": "src/index.css",
    "baseColor": "zinc",
    "cssVariables": true
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils"
  }
}
```

---

### Screen Inventory

#### Login
- Email + password form
- JWT stored in `httpOnly` cookie or `localStorage`
- Redirect to Dashboard on success

#### Dashboard
- KPI cards: Open tickets, Closed today, High priority, Due today
- Activity feed (recent messages, real-time)
- Channel status bar

#### Ticket List
- Filter by: status, priority, channel, date range, assigned agent
- Sort by: created date, due date, priority
- Real-time row updates without full page refresh
- Batch actions: close, assign priority

#### Ticket Detail (Chat UI)
- WhatsApp-style message bubbles (inbound left, outbound right)
- Real-time message streaming
- Reply input with send button
- Sidebar: contact info, ticket metadata, close/priority/due-date controls
- Assign ticket to agent *(Admin only)*

#### Contacts View
- Searchable grid of contact cards (`/contacts`); click a card → **`/contacts/:id` contact detail**
- **Contact detail:** phone / display name, **company link** (dropdown of companies + Save; portal synthetic contacts show read-only explanation), list of **all tickets** for that contact with link to **`/tickets/:id`** (full thread and replies stay on the ticket)

#### Companies View *(staff)*
- **`/companies`** — list and create companies
- **`/companies/:id`** — two-column layout on large screens: **left** — edit name, **linked contacts** (links to contact detail), channel numbers from ticket activity, portal user management; **right** — scrollable **Tickets** panel (open and closed) with links to ticket detail. All messaging remains inside each ticket’s chat.

#### Channels View
- Channel cards with connection status indicator
- "Add Channel" → triggers QR modal *(Admin only)*
- QR modal: auto-refresh, countdown timer, connection confirmation

#### User Management *(Admin only)*
- List all users with role and status
- Create user (email, password, role)
- Deactivate / reactivate user
- Change user role

#### Audit Log *(Admin only)*
- Paginated, filterable log table
- Filters: date range, actor, action type, entity type
- Read-only — no edit or delete

#### Settings
- Theme toggle: Dark / Light (persisted in `localStorage`)
- Change own password

---

### Shadcn Component Map

| Screen | Shadcn Components |
|--------|------------------|
| Login | `Card`, `Input`, `Button`, `Label` |
| Dashboard | `Card`, `Badge`, `Skeleton`, `Separator` |
| Ticket List | `Table`, `Badge`, `Button`, `DropdownMenu`, `Input`, `Select` |
| Ticket Detail | `Sheet`, `Avatar`, `Badge`, `Textarea`, `Button`, `Tooltip`, `Separator` |
| Contacts View | `Card`, `Input`, `Badge`, `Button` |
| Contact Detail | `Card`, `Select`, `Badge`, `Button`, `Skeleton` |
| Companies View | `Card`, `Input`, `Badge`, `Button` |
| Channels View | `Card`, `Badge`, `Dialog`, `Button`, `Tooltip` |
| QR Modal | `Dialog`, `Skeleton`, `Badge` |
| User Management | `Table`, `Dialog`, `Input`, `Select`, `Button`, `Badge` |
| Audit Log | `Table`, `Input`, `Select`, `Badge`, `Popover` |
| Sidebar Nav | `Sidebar`, `NavigationMenu`, `Avatar`, `DropdownMenu` |

---

## 10. Real-Time Layer

### Approach

Socket.IO WebSockets are preferred over Supabase Realtime for:
- Lower latency on chat interactions
- Full control over event schema
- Easier debugging and replay
- No Supabase subscription cost on self-hosted tiers

### Connection Management

- Clients reconnect automatically on disconnect (Socket.IO handles this)
- Server tracks socket rooms by `ticketId` for targeted event emission
- On API restart, active sockets reconnect and re-subscribe

---

## 11. Channel Management (QR Flow)

### Sequence

```
Agent                   React UI              Node API         Baileys
  │                        │                     │                │
  │── click "Add Channel" ─►                     │                │
  │                        │─── POST /channels/init ──►           │
  │                        │                     │── startSession()─►
  │                        │                     │                │
  │                        │◄─── WS: qr:update ──│◄── QR emit ───│
  │◄─── QR modal shown ────│                     │                │
  │                        │                     │                │
  │─── scan QR ────────────────────────────────────────────────── │
  │                        │                     │◄── connected ──│
  │                        │◄─── WS: channel:connected ──────────-│
  │◄─── modal closes ──────│                     │                │
  │                        │                     │── persist session
```

### Session Persistence

Baileys sessions **must** be persisted to survive restarts. Strategy:

- Store session data in `channels.session_data` (JSONB column)
- On startup, reload all `connected` channels and restore sessions
- If session is invalid on reload, emit `channel:disconnected` and prompt re-auth

---

## 12. Testing Strategy

### Testing Pyramid

```
         ▲
        / \       E2E (TestSprite MCP)
       /   \      Critical user journeys
      /─────\
     /       \    Integration Tests
    /         \   Service boundaries, DB, WS
   /───────────\
  /             \ Unit Tests
 /               \ Business logic, helpers
└─────────────────┘
```

### Unit Tests

- Ticket creation logic (open ticket lookup, new ticket creation)
- Message deduplication (wa_message_id uniqueness)
- Phone number normalisation

### Integration Tests

- Ticket creation via inbound message
- Channel QR flow (mock Baileys)
- WebSocket event emission on ticket update

### E2E Tests (TestSprite MCP)

**Critical Path (must pass before every release):**

1. Add new channel
2. QR code appears in modal within 3 seconds
3. Scan QR (mocked phone)
4. Channel status becomes `connected`
5. Send message from phone
6. Ticket is created in dashboard
7. Agent sends reply from UI
8. Message delivered to phone

**Regression Suite:**

- No duplicate tickets for same phone + channel
- Message ordering is preserved (sequence numbers)
- Channel reconnects after Docker restart without re-scanning QR
- Closed ticket → new message → new ticket created

---

## 13. Infrastructure & Deployment

### Overview

The entire stack runs from a **single `docker-compose.yml`** with one command:

```bash
docker-compose up --build
```

The Supabase self-hosted stack is embedded directly — no separate clone required. This follows the official Supabase Docker self-hosting guide at `https://supabase.com/docs/guides/self-hosting/docker`.

---

### System Requirements

| Resource | Minimum | Recommended |
|----------|---------|-------------|
| RAM | 4 GB | 8 GB+ |
| CPU | 2 cores | 4 cores+ |
| Disk | 50 GB SSD | 80 GB+ SSD |

---

### Port Map

| Service | Port | Description |
|---------|------|-------------|
| Frontend (React) | `3000` | App UI |
| API (Node.js) | `3001` | REST + WebSocket |
| Supabase Kong Gateway | `8000` | Supabase Studio + API gateway |
| Supabase Studio | via `8000` | Admin dashboard |
| PostgreSQL (Supavisor session) | `5432` | Direct DB access |
| PostgreSQL (Supavisor transaction pool) | `6543` | Pooled DB access |

---

### All-in-One `docker-compose.yml`

```yaml
# ============================================================
# WhatsApp Ticketing System — All-in-One Docker Compose
# Supabase self-hosted stack embedded (supabase.com/docs/guides/self-hosting/docker)
# ============================================================

name: wa-ticketing

services:

  # ----------------------------------------------------------
  # APPLICATION SERVICES
  # ----------------------------------------------------------

  frontend:
    build:
      context: ./apps/frontend
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      VITE_API_URL: http://localhost:3001
      VITE_WS_URL: ws://localhost:3001
    depends_on:
      - api
    restart: unless-stopped

  api:
    build:
      context: ./apps/api
      dockerfile: Dockerfile
    ports:
      - "3001:3001"
    environment:
      NODE_ENV: production
      PORT: 3001
      DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/postgres
      JWT_SECRET: ${JWT_SECRET}
      JWT_EXPIRY: 8h
      SUPABASE_URL: http://kong:8000
      SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  whatsapp:
    build:
      context: ./services/whatsapp
      dockerfile: Dockerfile
    environment:
      NODE_ENV: production
      DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/postgres
      API_INTERNAL_URL: http://api:3001
    depends_on:
      db:
        condition: service_healthy
      api:
        condition: service_started
    restart: unless-stopped

  # ----------------------------------------------------------
  # SUPABASE SELF-HOSTED STACK
  # Ref: https://supabase.com/docs/guides/self-hosting/docker
  # ----------------------------------------------------------

  studio:
    image: supabase/studio:latest
    restart: unless-stopped
    ports:
      - "8000:3000"
    environment:
      STUDIO_PG_META_URL: http://meta:8080
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION}
      DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT}
      SUPABASE_URL: http://kong:8000
      SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
      SUPABASE_ANON_KEY: ${ANON_KEY}
      SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
      AUTH_JWT_SECRET: ${JWT_SECRET}
      LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
      NEXT_PUBLIC_ENABLE_LOGS: "true"
      NEXT_ANALYTICS_BACKEND_PROVIDER: postgres
    depends_on:
      analytics:
        condition: service_healthy

  kong:
    image: kong:2.8.1
    restart: unless-stopped
    ports:
      - "8000:8000/tcp"
      - "8443:8443/tcp"
    volumes:
      - ./volumes/api/kong.yml:/home/kong/temp.yml:ro
    environment:
      KONG_DATABASE: "off"
      KONG_DECLARATIVE_CONFIG_STRING: ${KONG_DECLARATIVE_CONFIG}
      KONG_DNS_ORDER: LAST,A,CNAME
      KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
      KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
      KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
      SUPABASE_ANON_KEY: ${ANON_KEY}
      SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
      DASHBOARD_USERNAME: ${DASHBOARD_USERNAME}
      DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}

  auth:
    image: supabase/gotrue:v2.164.0
    restart: unless-stopped
    environment:
      GOTRUE_API_HOST: "0.0.0.0"
      GOTRUE_API_PORT: 9999
      API_EXTERNAL_URL: ${API_EXTERNAL_URL}
      GOTRUE_DB_DRIVER: postgres
      GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@db:5432/postgres
      GOTRUE_SITE_URL: ${SITE_URL}
      GOTRUE_JWT_ADMIN_ROLES: service_role
      GOTRUE_JWT_AUD: authenticated
      GOTRUE_JWT_DEFAULT_EXP: 3600
      GOTRUE_JWT_SECRET: ${JWT_SECRET}
      GOTRUE_EXTERNAL_EMAIL_ENABLED: "true"
      GOTRUE_MAILER_AUTOCONFIRM: "false"
    depends_on:
      db:
        condition: service_healthy

  rest:
    image: postgrest/postgrest:v12.2.0
    restart: unless-stopped
    environment:
      PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@db:5432/postgres
      PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
      PGRST_DB_ANON_ROLE: anon
      PGRST_JWT_SECRET: ${JWT_SECRET}
      PGRST_DB_USE_LEGACY_GUCS: "false"
      PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
      PGRST_APP_SETTINGS_JWT_EXP: 3600
    depends_on:
      db:
        condition: service_healthy

  realtime:
    image: supabase/realtime:v2.33.72
    restart: unless-stopped
    environment:
      PORT: 4000
      DB_HOST: db
      DB_PORT: 5432
      DB_USER: supabase_admin
      DB_PASSWORD: ${POSTGRES_PASSWORD}
      DB_NAME: postgres
      DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime"
      DB_ENC_KEY: supabaserealtime
      API_JWT_SECRET: ${JWT_SECRET}
      SECRET_KEY_BASE: ${SECRET_KEY_BASE}
      ERL_AFLAGS: -proto_dist inet_tcp
      ENABLE_TAILSCALE: "false"
      DNS_NODES: "''"
    depends_on:
      db:
        condition: service_healthy

  storage:
    image: supabase/storage-api:v1.11.13
    restart: unless-stopped
    volumes:
      - ./volumes/storage:/var/lib/storage:z
    environment:
      ANON_KEY: ${ANON_KEY}
      SERVICE_KEY: ${SERVICE_ROLE_KEY}
      POSTGREST_URL: http://rest:3000
      PGRST_JWT_SECRET: ${JWT_SECRET}
      DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@db:5432/postgres
      FILE_SIZE_LIMIT: 52428800
      STORAGE_BACKEND: file
      FILE_STORAGE_BACKEND_PATH: /var/lib/storage
      TENANT_ID: stub
      REGION: stub
      GLOBAL_S3_BUCKET: stub
      ENABLE_IMAGE_TRANSFORMATION: "true"
      IMGPROXY_URL: http://imgproxy:5001
    depends_on:
      db:
        condition: service_healthy
      rest:
        condition: service_started

  imgproxy:
    image: darthsim/imgproxy:v3.8.0
    restart: unless-stopped
    volumes:
      - ./volumes/storage:/var/lib/storage:z
    environment:
      IMGPROXY_BIND: ":5001"
      IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
      IMGPROXY_USE_ETAG: "true"
      IMGPROXY_ENABLE_WEBP_DETECTION: "true"

  meta:
    image: supabase/postgres-meta:v0.84.2
    restart: unless-stopped
    environment:
      PG_META_PORT: 8080
      PG_META_DB_HOST: db
      PG_META_DB_PORT: 5432
      PG_META_DB_NAME: postgres
      PG_META_DB_USER: supabase_admin
      PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
      PG_META_CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
    depends_on:
      db:
        condition: service_healthy

  functions:
    image: supabase/edge-runtime:v1.67.4
    restart: unless-stopped
    volumes:
      - ./volumes/functions:/home/deno/functions:Z
    environment:
      JWT_SECRET: ${JWT_SECRET}
      SUPABASE_URL: http://kong:8000
      SUPABASE_ANON_KEY: ${ANON_KEY}
      SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
      SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/postgres
    depends_on:
      analytics:
        condition: service_healthy

  analytics:
    image: supabase/logflare:1.4.0
    restart: unless-stopped
    ports:
      - "4000:4000"
    environment:
      LOGFLARE_NODE_HOST: 127.0.0.1
      DB_USERNAME: supabase_admin
      DB_DATABASE: _analytics
      DB_HOSTNAME: db
      DB_PORT: 5432
      DB_PASSWORD: ${POSTGRES_PASSWORD}
      DB_SCHEMA: _analytics
      LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
      LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN}
      LOGFLARE_SUPABASE_MODE: "true"
      LOGFLARE_MIN_CLUSTER_SIZE: 1
      POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@db:5432/postgres
      POSTGRES_BACKEND_SCHEMA: _analytics
      LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: [ "CMD", "curl", "http://localhost:4000/health" ]
      timeout: 5s
      interval: 5s
      retries: 10

  db:
    image: supabase/postgres:15.8.1.060
    restart: unless-stopped
    ports:
      - "5432:5432"
    volumes:
      - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z
      - ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z
      - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z
      - ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z
      - ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z
      - ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z
      # App migrations — applied after Supabase init
      - ./supabase/migrations:/docker-entrypoint-initdb.d/migrations/app:Z
      - pgdata:/var/lib/postgresql/data:Z
    environment:
      POSTGRES_HOST: /var/run/postgresql
      PGPORT: 5432
      POSTGRES_PORT: 5432
      PGPASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      PGDATABASE: postgres
      POSTGRES_DB: postgres
      JWT_SECRET: ${JWT_SECRET}
      JWT_EXP: 3600
    healthcheck:
      test: pg_isready -U postgres -h localhost
      interval: 5s
      timeout: 5s
      retries: 10

  vector:
    image: timberio/vector:0.28.1-alpine
    restart: unless-stopped
    volumes:
      - ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
    depends_on:
      analytics:
        condition: service_healthy

  supavisor:
    image: supabase/supavisor:2.4.5
    restart: unless-stopped
    ports:
      - "5432:5432"   # Session mode
      - "6543:6543"   # Transaction mode
    environment:
      PORT: 4000
      POSTGRES_PORT: 5432
      POSTGRES_DB: postgres
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      DATABASE_URL: ecto://supabase_admin:${POSTGRES_PASSWORD}@db:5432/postgres
      CLUSTER_POSTGRES: "true"
      SECRET_KEY_BASE: ${SECRET_KEY_BASE}
      VAULT_ENC_KEY: ${VAULT_ENC_KEY}
      API_JWT_SECRET: ${JWT_SECRET}
      METRICS_JWT_SECRET: ${JWT_SECRET}
      REGION: local
      ERL_AFLAGS: -proto_dist inet_tcp
    depends_on:
      db:
        condition: service_healthy

# ----------------------------------------------------------
# VOLUMES
# ----------------------------------------------------------

volumes:
  pgdata:
    driver: local
```

---

### `.env` File

> ⚠️ **Never commit this file.** Copy from `.env.example` and set all values before first run.

```env
# ============================================================
# APPLICATION
# ============================================================
JWT_SECRET=your-app-jwt-secret-min-32-chars
JWT_EXPIRY=8h

# ============================================================
# SUPABASE
# Generate keys: https://supabase.com/docs/guides/self-hosting/docker#generate-and-configure-api-keys
# Or run: sh ./utils/generate-keys.sh
# ============================================================
POSTGRES_PASSWORD=your-strong-db-password
ANON_KEY=your-anon-key
SERVICE_ROLE_KEY=your-service-role-key

# Must be at least 64 chars: openssl rand -base64 48
SECRET_KEY_BASE=your-secret-key-base

# Exactly 32 chars: openssl rand -hex 16
VAULT_ENC_KEY=your-vault-enc-key

# At least 32 chars: openssl rand -base64 24
PG_META_CRYPTO_KEY=your-pg-meta-crypto-key
LOGFLARE_PUBLIC_ACCESS_TOKEN=your-logflare-public-token
LOGFLARE_PRIVATE_ACCESS_TOKEN=your-logflare-private-token

# Supabase Studio credentials
DASHBOARD_USERNAME=admin
DASHBOARD_PASSWORD=your-dashboard-password

# URLs (use localhost for local dev)
SUPABASE_PUBLIC_URL=http://localhost:8000
API_EXTERNAL_URL=http://localhost:8000
SITE_URL=http://localhost:3000

# Studio defaults
STUDIO_DEFAULT_ORGANIZATION=WA Ticketing
STUDIO_DEFAULT_PROJECT=wa-ticketing

# PostgREST
PGRST_DB_SCHEMAS=public,storage,graphql_public

# ============================================================
# FRONTEND
# ============================================================
VITE_API_URL=http://localhost:3001
VITE_WS_URL=ws://localhost:3001
```

---

### First-Run Setup

```bash
# 1. Clone the repo
git clone https://github.com/your-org/wa-ticketing && cd wa-ticketing

# 2. Copy and fill in environment variables
cp .env.example .env
# Edit .env — set all passwords and generate keys

# 3. Generate Supabase JWT keys (optional helper)
sh ./utils/generate-keys.sh

# 4. Start everything
docker compose up --build

# 5. Access services
# App:             http://localhost:3000
# API:             http://localhost:3001
# Supabase Studio: http://localhost:8000  (login with DASHBOARD_USERNAME/PASSWORD)
```

---

### Monorepo Structure

```
/
├── apps/
│   ├── frontend/                  # React + Vite + Shadcn UI
│   │   ├── src/
│   │   │   ├── components/
│   │   │   │   └── ui/            # Shadcn components (owned code)
│   │   │   ├── pages/
│   │   │   ├── hooks/
│   │   │   └── lib/
│   │   ├── components.json        # Shadcn config
│   │   └── Dockerfile
│   └── api/                       # Node.js + Fastify + Socket.IO
│       ├── src/
│       │   ├── routes/
│       │   ├── services/
│       │   ├── middleware/
│       │   └── ws/
│       └── Dockerfile
├── services/
│   └── whatsapp/                  # Baileys worker
│       ├── src/
│       └── Dockerfile
├── packages/
│   └── shared/                    # Shared TypeScript types
├── supabase/
│   └── migrations/                # SQL migration files
│       ├── 001_users.sql
│       ├── 002_contacts.sql
│       ├── 003_channels.sql
│       ├── 004_tickets.sql
│       ├── 005_messages.sql
│       └── 006_audit_logs.sql
├── volumes/                       # Supabase runtime volumes (git-ignored)
│   ├── api/
│   │   └── kong.yml
│   ├── db/
│   ├── functions/
│   ├── logs/
│   └── storage/
├── utils/
│   └── generate-keys.sh           # Supabase key generator
├── docker-compose.yml
├── .env.example
└── .env                           # Never commit
```

---

### Stopping & Restarting

```bash
# Stop all services (data preserved)
docker compose down

# Stop and wipe all data (destructive)
docker compose down -v

# Restart a single service
docker compose restart api

# View logs
docker compose logs -f api
docker compose logs -f whatsapp
docker compose logs -f db
```

---

## 14. Risk Register

| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Baileys session lost on restart | High | High | Persist to DB; reload on startup; prompt re-auth if invalid |
| WhatsApp rate limiting | Medium | Medium | Queue outbound messages; respect per-minute limits |
| Duplicate message processing | Medium | High | Unique constraint on `wa_message_id`; idempotent handlers |
| WebSocket connection drops | Medium | Medium | Auto-reconnect on client; socket rooms re-joined on reconnect |
| Baileys API breaking changes | Low | High | Pin Baileys version; monitor upstream changelog |
| Concurrent ticket creation race condition | Low | High | DB-level unique index on `(contact_id, channel_id, status=open)` |

---

## 15. Resolved Decisions

All open questions have been answered. These decisions are now incorporated throughout the PRD.

| # | Question | Decision |
|---|---------|----------|
| 1 | Will agents need user accounts / login? | **Yes** — two roles: `admin` (full control) and `user` (agent access). JWT-based auth required. |
| 2 | Should multiple open tickets per contact be supported? | **Yes** — multiple open tickets per contact per channel are permitted. Most recent open ticket receives new messages. |
| 3 | Is Redis mandatory from day one? | **No** — Redis is removed from v2. Synchronous processing is sufficient. Can be introduced later if throughput demands it. |
| 4 | What is the message retention policy? | **Keep forever** — messages serve as the primary interaction log and are never deleted. |
| 5 | Does the system need audit logs for compliance? | **Yes** — full audit log of all agent and system actions, stored indefinitely, read-only, Admin-only access. |

---

## 16. Milestone Plan

| Milestone | Deliverable | Scope |
|-----------|-------------|-------|
| M1 — Foundation | Docker Compose, DB schema (incl. `users`, `audit_logs`), API skeleton | Infra + Data |
| M2 — Auth | JWT login/logout, RBAC middleware, user management API | Backend |
| M3 — WhatsApp Core | Baileys integration, QR flow, session persistence | Backend |
| M4 — Ticketing Logic | Inbound message → ticket, multi-ticket support, message deduplication | Backend |
| M5 — Audit Logging | Audit log service wired to all key actions, query API | Backend |
| M6 — WebSocket Layer | Socket.IO server, all events wired | Backend |
| M7 — Frontend MVP | Login, Dashboard, Ticket List, Chat UI, Channels View | Frontend |
| M8 — Admin UI | User Management screen, Audit Log screen | Frontend |
| M9 — Integration | End-to-end message flow working, auth enforced | Full stack |
| M10 — Testing | TestSprite E2E suite, auth & audit regression tests | QA |
| M11 — Polish | Theme system, error handling, logging, graceful shutdown | Full stack |

---

*End of PRD v2*
