# WhatsApp Ticketing System

A self-hosted, real-time WhatsApp support platform. Every inbound WhatsApp message becomes a structured ticket. Agents reply from a polished dashboard. Runs with a single `docker-compose up --build`.

---

## Quick Start

### 1. Copy and configure environment variables

```bash
cp .env.example .env
```

Generate secrets (on Windows use Git Bash, or see comments in `utils/generate-keys.sh`):
```bash
sh utils/generate-keys.sh
```

Edit `.env` and set all values. For `ANON_KEY` and `SERVICE_ROLE_KEY`, follow the [Supabase self-hosting guide](https://supabase.com/docs/guides/self-hosting/docker#generate-and-configure-api-keys).

### 2. Start everything

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

### 3. Seed the admin user

```bash
docker compose exec api npm run seed
# Default: admin@example.com / Admin123! (same as docker-compose API env defaults)
# Set ADMIN_EMAIL and ADMIN_PASSWORD in .env to override
```

### 4. Access

Set **`PUBLIC_SITE_HOST`** in `.env` to the hostname or IP the **browser** uses (`localhost`, LAN IP, or domain). **`docker-compose.yml`** then aligns public URLs with **`PUBLIC_SITE_HOST`** and the **`_*_HOST_PORT`** variables (Kong, API, UI) so the stack only talks to itself.

| Service | URL pattern |
|---------|------------|
| App | `http://<PUBLIC_SITE_HOST>:<FRONTEND_PORT>` (default port **3000**) |
| API | `http://<PUBLIC_SITE_HOST>:<API_HOST_PORT>` (default **3001**) |
| Supabase (Kong) | `http://<PUBLIC_SITE_HOST>:<KONG_HTTP_HOST_PORT>` (default **8001**) |
| Supabase Studio | `http://<PUBLIC_SITE_HOST>:<STUDIO_HOST_PORT>` (default **8002**) |

You usually **do not** need to set **`VITE_*`**, **`SUPABASE_PUBLIC_URL`**, **`SITE_URL`**, or **`FRONTEND_URL`** by hand—compose derives them. Override those only for **https**, a reverse proxy, or unusual paths.

**React API URL (Docker):** The **frontend** container writes **`/runtime-config.js`** at start from **`PUBLIC_SITE_HOST`**, **`API_HOST_PORT`**, and optional **`PUBLIC_HTTP_SCHEME`** — the SPA does **not** rely on a rebuild to change API port or LAN IP. After editing those, run: **`docker compose up -d --force-recreate frontend`**. For **`npm run dev`**, use **`VITE_API_URL` / `VITE_WS_URL`** in `.env` as before.

### Multiple Docker stacks on one machine

Each stack should use a **unique** **`COMPOSE_PROJECT_NAME`** (e.g. `wa-ticketing-b`) so networks and named volumes stay isolated, and a **free port block** in `.env`:

| Variable | Default | Service |
|----------|---------|---------|
| `PUBLIC_SITE_HOST` | `localhost` | Hostname/IP used in browser URLs |
| `FRONTEND_PORT` | 3000 | React UI |
| `API_HOST_PORT` | 3001 | Ticketing API (Node) |
| `STUDIO_HOST_PORT` | 8002 | Supabase Studio |
| `KONG_HTTP_HOST_PORT` | 8001 | Kong (Supabase API gateway) |
| `KONG_HTTPS_HOST_PORT` | 8444 | Kong HTTPS |
| `POSTGRES_HOST_PORT` | 5432 | Postgres |
| `POOLER_HOST_PORT` | 6543 | Supavisor pooler |
| `ANALYTICS_HOST_PORT` | 14000 | Logflare / analytics |

Changing ports updates the **derived** Kong and API URLs automatically. For **hybrid dev** (Vite on `:5173` + API in Docker), set **`CORS_ORIGIN`** to a comma-separated list including your Vite origin (compose’s default CORS allows only the stack’s UI origin).

**Scope & user guide (HTML, with screenshots):** [docs/scope_wa_ticketing_system.html](docs/scope_wa_ticketing_system.html)

**User simulation (API):** Optional Python harness with staff / portal / email / landing personas, hybrid company-user bootstrap, and cycle error reports. See [user_automation/README.md](user_automation/README.md).

The **WhatsApp worker** is not exposed on the host by default (the API calls it as `http://whatsapp:3002` inside Docker). That avoids clashes when something else already uses port 3002. To hit the worker from your machine, add a `ports` mapping in `docker-compose.override.yml`.

### WhatsApp media (images & voice notes)

Inbound/outbound **images** and **voice notes** are stored in **Supabase Storage** (private bucket, signed URLs for the dashboard). **Web portal** chats support the same attachments (company users and staff on portal tickets), including **multiple files** per send; **email** tickets stay text-only. Compose sets **`SUPABASE_URL=http://kong:8000`** (internal) and **`SUPABASE_PUBLIC_URL`** from **`PUBLIC_SITE_HOST` + `KONG_HTTP_HOST_PORT`** so browsers load media via Kong on a reachable host (not `kong:8000`). Override **`SUPABASE_PUBLIC_URL`** for HTTPS/reverse proxies. Also requires **`SERVICE_ROLE_KEY`** and optional **`MEDIA_*`** vars (see `.env.example`). Details: [docs/whatsapp_media_milestone1.md](docs/whatsapp_media_milestone1.md).

### Troubleshooting: `password authentication failed` for `supabase_auth_admin` (or other `supabase_*` roles)

Postgres **role passwords are set when the data volume is first initialized**. Changing **`POSTGRES_PASSWORD`** in `.env` afterwards updates compose env vars (connection strings) but **does not** automatically change passwords stored inside the existing database—so GoTrue/REST/analytics may fail with SASL / `28P01`.

**Fix (keep data):** from the repo root, with the `db` container running:

```bash
npm run sync:db-passwords
```

The script connects as **`supabase_admin`** (the superuser in the Supabase Postgres image; `postgres` is not superuser and cannot `ALTER` reserved roles).

Then recreate dependent services:

```bash
docker compose up -d --force-recreate auth rest realtime storage meta analytics supavisor
```

**Alternative (empty dev DB acceptable):** remove the project volumes and bring the stack up again so init scripts re-run with the current `POSTGRES_PASSWORD` (this **destroys local DB data**):

```bash
docker compose down -v
docker compose up -d --build
```

See also: [Supabase — changing database password (self-hosted)](https://supabase.com/docs/guides/self-hosting/docker#changing-database-password).

### Troubleshooting: Logflare / `analytics` unhealthy (`database "_supabase" does not exist`)

That means Postgres never ran the **init SQL** that creates `_supabase` and related roles. Common causes:

1. **Stale `pgdata` volume** from a broken first boot — reset (destroys DB): `docker compose down -v` then `docker compose up -d --build`.
2. **Old `./volumes/` tree** where Docker created **directories** instead of files for missing bind mounts (especially on Windows). This repo now ships init scripts under **`docker/supabase/`** (tracked in git). Remove any mistaken host folders named `kong.yml` under `./volumes/api/` if you still have them.

### Troubleshooting: Kong `Is a directory` / GoTrue `parsing "8h"`

- **Kong:** the declarative file must be a **file**. Compose mounts **`docker/supabase/kong.yml`** and runs **`kong-entrypoint.sh`** to inject secrets (same idea as upstream Supabase).
- **GoTrue:** **`GOTRUE_JWT_EXP`** must be **seconds** (integer). Set **`GOTRUE_JWT_EXP=28800`** in `.env` for 8 hours, or leave unset for the compose default. The Node API may still use **`JWT_EXPIRY=8h`** for its own JWTs.
- **Kong:** if startup fails with **`plugin 'request-termination' not enabled`** (or **`post-function`**), ensure **`docker-compose.yml`** lists them on **`KONG_PLUGINS`** (this repo includes both for `docker/supabase/kong.yml`).
- **Frontend:** **`exec /docker-entrypoint.sh: no such file or directory`** on Windows is usually **CRLF** in `apps/frontend/docker-entrypoint.sh`. The frontend **Dockerfile** strips `\r` at build time; rebuild with **`docker compose build frontend`**.

---

## Architecture

```
React UI (3000)
    │  REST + WebSocket
Node API / Socket.IO (3001)
    │
    ├── authService      JWT + bcrypt
    ├── ticketService    Ticket lifecycle
    ├── messagingService Message persistence + ordering
    ├── contactService   E.164 normalisation + dedup
    ├── channelService   Baileys session management
    └── auditService     Immutable audit trail
    │
PostgreSQL 15 (Supabase self-hosted)
    │
Baileys WhatsApp Worker (per channel)
```

---

## 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 |

---

## Directory Structure

```
wa_support_node_v1/
├── apps/
│   ├── frontend/          React + Vite + Shadcn UI
│   └── api/               Node.js + Fastify + Socket.IO
├── services/
│   └── whatsapp/          Baileys worker
├── packages/
│   └── shared/            Shared TypeScript types
├── supabase/
│   └── migrations/        001_users → 026_landing_sessions_api_privileges
├── volumes/               Supabase runtime (git-ignored)
├── utils/
│   └── generate-keys.sh
├── docker-compose.yml
├── user_automation/       Python API simulation (optional load testing)
└── .env.example
```

---

## Public signup (login page chat)

From **Sign in**, users can open **Request access** and complete a short scripted chat. The API emails a verification link; the user sets a password on `/signup/complete` and becomes a `company_user`.

- **Apply migration** `014_signup_sessions_company_codes.sql` (adds `signup_sessions`, `users.phone`, `companies.account_code`).
- **SMTP** must be configured on the API (`SMTP_HOST`, `SMTP_FROM_EMAIL`, `SMTP_FROM_NAME`, …) or the flow cannot send mail.
- **`FRONTEND_PORT`** — host port for the UI (default `3000`); set in the monorepo root `.env` for Docker and for local Vite (the dev server reads it via `loadEnv` from the repo root).
- **`FRONTEND_URL`** — optional override for signup email links; compose defaults to `http://PUBLIC_SITE_HOST:FRONTEND_PORT`.
- **`TURNSTILE_SECRET_KEY`** (optional, API) — if set, the first signup chat POST must include a valid Turnstile token. Set the matching public key as **`TURNSTILE_SITE_KEY`** for Docker (frontend entrypoint injects it into `runtime-config.js`) or **`VITE_TURNSTILE_SITE_KEY`** under `apps/frontend/` for `npm run dev`. If you only set the secret, signup will fail until the site key is configured.

Rate limit: **40 requests/minute** per IP on `/api/v1/signup/*`.

---

## Public landing chat (ticket only — no passwords)

The login page left panel is a live assistant (`POST /api/v1/landing/chat`). It **never** collects passwords. Guests open a support ticket after matching **company + work email** to an existing subscription (`company_user` or email contact). Staff use the sign-in form on the right for console access; new clients use **Request access** (existing signup chat).

- **Apply migrations** `022_landing_sessions.sql` and `026_landing_sessions_api_privileges.sql`. The API may bootstrap a missing table, but owner-only index and privilege changes remain migration-owned.
- Flow: help → name → company (must exist) → email (must match that company) → ticket on **Web Portal** + talkback/AI.
- Mismatch → bot points to **Request access** (no ticket).
- Guest contact key: `portal:guest:{sessionId}`.
- **Turnstile** uses the same keys as signup (first landing chat POST when `TURNSTILE_SECRET_KEY` is set).
- **AI replies** still need company `ai_autoreply_enabled`, knowledge modules, and LLM credentials.

Rate limit: **40 requests/minute** per IP on `/api/v1/landing/*`.

---

## Adding a WhatsApp Channel

1. Log in as admin
2. Navigate to **Channels** → **Add Channel**
3. Enter a name and phone number, then choose a **Provider**: **Baileys / QR** or **Africa's Talking**
4. **Baileys / QR:** scan the QR code in WhatsApp (Linked Devices → Link a Device). **Reconnect** on a disconnected channel clears stored Baileys auth (`wa_auth` volume) so a new QR is always generated — stale creds otherwise try a silent re-login and never emit QR. The channel status turns **connected**.
5. **Africa's Talking:** no QR; see below.

### Africa's Talking WhatsApp provider

- **One deployment account.** Set `AFRICASTALKING_USERNAME` and `AFRICASTALKING_API_KEY` in `.env` (passed to the API container by both compose files). All Africa's Talking channels share them; no per-channel secrets are stored. **Both must be non-empty before an Africa's Talking channel can be created** (the API refuses creation otherwise) and for the channel to send. Leave both empty if you only use Baileys.
- **Webhook setup.** After creating an Africa's Talking channel you land on its detail page, which shows the callback URL `<API base>/api/v1/inbound/whatsapp/africas-talking/<token>` with a **Copy URL** button. In the Africa's Talking dashboard, paste it as the WhatsApp incoming-message callback. The token is a per-channel UUID scoped to this provider; **only admins** receive it (channel create/detail). Treat the URL as a secret.
- **Capability badges.** Channel cards and detail show the provider (`Baileys` / `Africa's Talking`) and capability badges (Direct text, Media, Groups, QR pairing, Reconnect, Templates) taken from the API.
- **Supported:** direct text; image, voice and document media. Templates are indicated as a capability, but there is no template admin UI.
- **Outbound media needs a publicly reachable `SUPABASE_PUBLIC_URL`.** Unlike Baileys, Africa's Talking does not accept bytes — it is given a signed Supabase Storage URL and fetches the file from *their* servers. That URL is built from `SUPABASE_PUBLIC_URL`, so it must be **HTTPS and resolvable from the public internet**. A default compose value such as `http://localhost:8000` or an internal hostname like `http://kong:8000` works for the dashboard but makes every outbound media send fail, because the provider cannot reach it. Set `SUPABASE_PUBLIC_URL` to your public HTTPS origin before enabling media on an Africa's Talking channel. Text sending is unaffected.
- **Provider status follows the last send.** Africa's Talking is stateless HTTP — no session, no connection event — so credential health is only observable when a message is sent. An authentication rejection (HTTP 401/403) marks the channel **disconnected**, and the next successful send marks it **connected** again. Other failures (timeout, network, provider rejection) are transient and leave the status alone. Baileys channel status is unchanged: the worker's session events still own it.
- **A disconnected Africa's Talking channel stays sendable — the send is the fix.** There is no Reconnect for this provider, so if staff sends were blocked at `disconnected` the channel could never recover. Instead: correct `AFRICASTALKING_USERNAME` / `AFRICASTALKING_API_KEY` in the deployment configuration, then send any ticket message on that channel. A successful send restores **connected**; if the credentials are still wrong the send fails with the provider's error and the channel stays disconnected, ready to retry. The ticket composer stays enabled with a warning banner saying exactly this, and the Channels card points at the deployment config rather than a dead-end "review configuration". **Baileys is unchanged** — a disconnected Baileys channel still blocks sends until its session is reconnected, because a send cannot revive a dead linked-device session.
- **Not supported (Baileys only):** WhatsApp groups, QR pairing and session reconnect. On an Africa's Talking channel these show as **No groups / No QR / No reconnect**, the Reconnect button is replaced by deployment-configuration guidance, the New ticket modal disables the WhatsApp group option, and the API rejects group ticket creation.
- **Callback payloads.** Both `application/json` and `application/x-www-form-urlencoded` are accepted — Africa's Talking does not guarantee JSON, and a 415 would look to them like a broken endpoint. The form parser is scoped to this route only; the rest of the API still refuses form bodies.
- **Non-message callbacks.** Delivery receipts, status updates and read receipts arrive on the same URL. They are answered **200 `{ok:true, ignored:true, reason:"unsupported_event"}`** and create no ticket or message. Only genuinely unreadable payloads get a 400, so healthy delivery traffic never looks like a failing endpoint.
- **Webhook protections.** **600 requests/min per channel token** (not per IP — the provider calls from a shared pool of egress addresses, so an IP budget would let one busy channel throttle every other tenant's inbound; the limiter key is a digest of the token, never the token itself), 1 MiB body limit, inbound media capped at 16 MiB (clamped — a caller cannot raise it), and media URLs must be HTTPS, non-redirecting, and pass SSRF checks. Rejected destinations include private, loopback and link-local addresses, IPv4-mapped and IPv4-compatible IPv6, IPv4-translated (`::ffff:0:0/96`), NAT64 (`64:ff9b::/32`), 6to4 (`2002::/16`) and Teredo (`2001:0::/32`).
- **Media fetch backpressure.** At most four concurrent media downloads, with a queue only four deep and a 5-second wait deadline. A callback that cannot get a slot in time is shed with **HTTP 503** and `Retry-After: 5` so the provider redelivers. The queue is deliberately shallow: holding a callback longer than the provider's own request timeout invites a redelivery that can clear the duplicate fast path before the first copy finishes storing. Storage itself stays correct — `messages.wa_message_id` is UNIQUE — but both copies download the media for nothing and the losing insert returns a transient 500 before the retry is acknowledged as a duplicate. Shedding early avoids that detour.
- **Residual risk.** DNS rebinding between the validating lookup and the connection is not prevented — that needs connect-time IP pinning.
- **Migration `028_channel_whatsapp_provider.sql`** adds `channels.provider` (`baileys` | `africas_talking`); existing WhatsApp channels become `baileys`. Apply with `npm run migrate:sql` / `./deploy.sh`.
- **Rollback (maintenance procedure).** Do not just delete Africa's Talking channels: `tickets.channel_id` references `channels` without cascade (the delete fails), and `calendar_requests.channel_id` is `ON DELETE CASCADE` (the delete would silently remove those rows). This procedure has **not been executed or rehearsed**; review every step against your data and substitute reviewed UUIDs — never delete ticket history casually.
  1. Announce downtime and stop the app (`docker compose stop api whatsapp frontend`); leave `db` running. Remove the callback URL in the Africa's Talking dashboard.
  2. **Back up the database** (e.g. `docker compose exec -T db pg_dump -U supabase_admin -d postgres -Fc > backup-pre-rollback.dump`) and confirm it is restorable.
  3. Choose (or create, before stopping the app) a retained Baileys channel and note its `id`. List the Africa's Talking channels and what depends on them:

     ```sql
     SELECT c.id, c.phone_number, c.name,
            (SELECT count(*) FROM tickets t WHERE t.channel_id = c.id) AS tickets,
            (SELECT count(*) FROM calendar_requests r WHERE r.channel_id = c.id) AS calendar_requests
     FROM channels c WHERE c.kind = 'whatsapp' AND c.provider = 'africas_talking';
     ```
  4. Preserve history by moving dependants to the retained channel (or, if operator policy says to archive/delete that data instead, do that explicitly and record it). Replace `<RETAINED_BAILEYS_CHANNEL_UUID>` and `<AT_CHANNEL_UUID>` with the reviewed values, one AT channel at a time:

     ```sql
     BEGIN;
     UPDATE tickets           SET channel_id = '<RETAINED_BAILEYS_CHANNEL_UUID>' WHERE channel_id = '<AT_CHANNEL_UUID>';
     UPDATE calendar_requests SET channel_id = '<RETAINED_BAILEYS_CHANNEL_UUID>' WHERE channel_id = '<AT_CHANNEL_UUID>';
     DELETE FROM channels WHERE id = '<AT_CHANNEL_UUID>' AND provider = 'africas_talking';
     COMMIT;
     ```
  5. Confirm none remain (`SELECT count(*) FROM channels WHERE provider = 'africas_talking';` must be `0`), then remove the schema change in this order (constraint, column, type), still with the app stopped, as `supabase_admin`:

     ```sql
     BEGIN;
     ALTER TABLE channels DROP CONSTRAINT IF EXISTS channels_whatsapp_provider_required;
     ALTER TABLE channels DROP COLUMN IF EXISTS provider;
     DROP TYPE IF EXISTS whatsapp_provider;
     DELETE FROM public._wa_sql_migrations WHERE filename = '028_channel_whatsapp_provider.sql';
     COMMIT;
     ```
  6. Deploy the previous application code (which does not know `provider`), then start the app. Rolling code back without the schema, or the schema without the code, leaves them mismatched — do both in the same window. `channels.inbound_token` is shared with email channels and is not touched.
- **Verification status.** Unit tests cover the provider and webhook logic. A real Africa's Talking callback and live credentials have **not yet been exercised**.

---

## Channel settings, onboarding, and AI

### Database migrations

**Automated:** `./deploy.sh` or `deploy.bat` (repo root). On Linux: `chmod +x deploy.sh`, then run **`./deploy.sh` as the user that owns the clone** (not `sudo ./deploy.sh`: root uses the wrong SSH keys → *Permission denied publickey*; the script uses `sudo` only for Docker). Default pull is `git pull --no-rebase` (`GIT_PULL_CMD` override, e.g. `git pull --ff-only`). They run `git pull`, **`docker compose down`** (stop this project’s stack so ports/names are free), then `docker compose build` / `up`, and `node scripts/apply-sql-migrations.mjs`, which applies `supabase/migrations/*.sql` in order and records filenames in `public._wa_sql_migrations` (skips already applied; tolerates duplicate errors if initdb already created objects). Override compose command with `DOCKER_COMPOSE` (deploy.sh uses `DOCKER`). Use `SKIP_MIGRATIONS=1` to skip migrations, `SKIP_STACK_STOP=1` to skip the `down` step. Manual run: `npm run migrate:sql` after `docker compose up -d`.

On the bundled Supabase Postgres image, app tables are often owned by **`supabase_admin`**, not `postgres`. The migration script defaults to **`supabase_admin`**. If you apply SQL by hand and `psql -U postgres` fails with “must be owner”, use:

```bash
docker compose exec -T db psql -U supabase_admin -d postgres < supabase/migrations/013_email_channels_alerts.sql
```

### Email channels

- **Add email** under Channels (admin). Inbound uses **HTTP POST** JSON to a secret URL — no POP/IMAP worker. Configure **`SMTP_*`** on the API to send agent replies and automated messages to the customer’s address.
- Webhook: `POST {API}/api/v1/inbound/email/{inbound_token}` with body `{ "from", "text", "subject?", "from_name?", "message_id?" }`.
- **Staff alerts** (all channel types): per channel, enable **SMS** (optional **Twilio** `TWILIO_*` on the API) and/or **staff email** (SMTP). **Escalation** can send email first then SMS and **flag the ticket** for follow-up (tick off in the ticket sidebar).

**Web chat** (`Web chat` / `portal:system`) appears in **Channels** as always on. It does not use WhatsApp. Onboarding prompts, AI, and ticket-closing text are **inherited from your oldest WhatsApp channel** (by creation date). Edit that WhatsApp channel to change web chat behaviour. Company portal messages run the same automated onboarding and AI flow as WhatsApp.

1. Open **Settings → Manage channels** (or go to `/channels/{id}`).
2. **Onboarding** (admin only): enable the flow to send up to four WhatsApp prompts for a contact’s **first ticket on that channel** only: who they are, company, query, and query status. Answers are stored on the ticket (visible in the ticket sidebar).
**Onboarding checklist:** turn on **Enable onboarding flow** on the channel detail page and **Save**. Link WhatsApp so messages flow. Preset prompts run only for a contact’s **first ticket ever on that channel** (returning customers with past tickets here won’t see the flow again). If replies still don’t arrive, check API audit for `message.automated_failed` and rebuild the **whatsapp** image after worker changes.

3. **AI** (admin only): enable automated replies using OpenAI after onboarding completes (or immediately if onboarding is off). Configure model (dropdown lists models returned by OpenAI when a key is configured), system prompt, and a plain-text knowledge base per channel.

4. **Company knowledge base & guardrails** (admin): under **Admin → Knowledge base**, maintain markdown **modules** (grouped labels optional). On each **company** profile, enable **AI autoreply** and tick the modules that organisation may use. Automated AI runs only when the ticket contact is **linked to a company** (WhatsApp intake company step or portal user), **`ai_autoreply_enabled`** is on, and **at least one module** is selected. The model receives the channel knowledge text plus the enabled modules and is instructed to stay within those topics (no RAG — keep total text within the API’s KB size limit).

   - **API:** `GET/POST/PATCH/DELETE /api/v1/knowledge-modules`, `GET/PUT /api/v1/companies/:id/knowledge-modules`, `PATCH /api/v1/companies/:id` with `{ "ai_autoreply_enabled": true }`.
   - **Migration:** `015_knowledge_modules.sql` (`knowledge_modules`, `company_knowledge_modules`, `companies.ai_autoreply_enabled`, seeded starter rows).
5. **Per-company AI agents** (admin catalog + company default): under **Admin → AI agents**, create rows with provider (`openai`, `anthropic`, `openai_compatible`), API key, optional base URL (for compatible endpoints), model id, **persona** (system text), optional **additional instructions**, and **knowledge base scope** (checkboxes over catalog modules). Only modules that are both **enabled for the company** and **selected on the agent** are injected into automated replies; if the agent selects **no** modules, all company-enabled modules apply (backward compatible). Use **Load models** (preview) to pick a model without putting secrets in the query string. On each **company** profile, choose a **default AI agent**; automated talkback uses that agent’s credentials and model. The effective system prompt order is: agent persona + optional extra instructions + existing KB guardrail + channel knowledge + scoped organisation modules. If there is **no** default agent or the agent has **no** API key, talkback keeps the previous behaviour: channel AI settings plus global OpenAI from **Settings → OpenAI** or `OPENAI_API_KEY` / `OPENAI_BASE_URL`. List/detail APIs never return full keys (only `has_api_key` / a short hint). Keys are stored in `ai_agents.api_key` (same sensitivity as `platform_settings.openai_api_key`). Staff users may optionally link a persona via `ai_agent_id` (UI on admin user create); company default still drives automation.

   - **API:** `GET/POST/PATCH/DELETE /api/v1/ai-agents`, `POST /api/v1/ai-agents/preview-models`, `PUT /api/v1/companies/:id/default-ai-agent`.
   - **Migrations:** `016_ai_agents.sql` (`ai_agents`, `company_ai_agents`, optional `users.ai_agent_id`); `017_ai_agent_knowledge_modules.sql` (`ai_agent_knowledge_modules`).
6. **OpenAI credentials** (admin): under **Settings → OpenAI**, save an API key to `platform_settings` in the database (optional base URL). The API prefers this key over the environment variable. Alternatively, set on the **API** host only:

   - `OPENAI_API_KEY` — used when no database key is stored.
   - `OPENAI_BASE_URL` — optional; default `https://api.openai.com/v1` (use for Azure OpenAI or compatible proxies). Can also be set in Settings.

7. **Ticket integrations** (admin): under **Settings → Ticket integrations**, pick a provider (Odoo, Trello, Atlassian/Jira, or GitHub) and save credentials. On each **channel**, choose **Ticket sync** to auto-create a remote ticket on the first message of a new conversation. Staff can also use **Sync this to …** on a ticket for a manual preview.

   - **API:** `GET/PUT /api/v1/integrations/:provider`, `GET /api/v1/integrations/enabled`, `GET …/tickets/:id/preview/:provider`, `POST …/tickets/:id/sync/:provider`, `GET …/tickets/:id/links`
   - **Migration:** `029_ticket_integrations.sql` (`ticket_integrations`, `ticket_integration_links`) — apply with `npm run migrate:sql`

Apply new DB columns on an existing database (if `postgres` is not the table owner, use `supabase_admin` or the owner user):

```bash
docker compose exec -T db psql -U supabase_admin -d postgres -c "ALTER TABLE public.channels ADD COLUMN IF NOT EXISTS settings JSONB NOT NULL DEFAULT '{}'; ALTER TABLE public.tickets ADD COLUMN IF NOT EXISTS intake JSONB NOT NULL DEFAULT '{}';"
```

Fresh Postgres volumes run app SQL from `docker-compose.yml` through `008_platform_settings.sql` (OpenAI row in `platform_settings`).

**Existing volumes** created before `008` was added: apply once with  
`docker compose exec -T db psql -U postgres -d postgres < supabase/migrations/008_platform_settings.sql`  
(or paste the file in `psql`). The migration is idempotent (`IF NOT EXISTS` / `ON CONFLICT DO NOTHING`).

### Shared package build

`@wa-ticketing/shared` compiles to `packages/shared/dist` (Node cannot load `.ts` at runtime). Root `npm run build` builds shared first, then all workspaces. After `npm ci` / clone, run `npm run build` (or `npm run build --workspace=@wa-ticketing/shared`) before starting the API or running a production frontend build locally.

### E2E smoke tests (Playwright)

With the app on **http://localhost:3000** and API on **http://localhost:3001**:

```bash
npx playwright install
npm run test:e2e
```

See [docs/testsprite_run.md](docs/testsprite_run.md) for TestSprite cloud status and troubleshooting. Product spec: [PRD_v2_WhatsApp_Ticketing_System.md](PRD_v2_WhatsApp_Ticketing_System.md); TestSprite inputs: `testsprite_tests/standard_prd.json`, `testsprite_tests/testsprite_frontend_test_plan.json`.

---

## Roles

| Role | Access |
|------|--------|
| `admin` | All screens including User Management, Audit Log, channel add/remove, Companies, **Calendar**, **Staff** |
| `user` | View/reply to tickets, manage contacts, view channels, Companies, **Calendar** (book/decline call-outs), **Staff** directory |

| `company_user` | Dashboard + Tickets for their company only; messages via **Web Portal** (no WhatsApp). Created under **Companies → portal users**. |

---

## Admin dashboard — staff workload

When logged in as **`admin`**, the dashboard includes an **Active users** card:

- **Active staff count** — active users with role `admin` or `user` (`is_active = true`), fetched from `GET /api/v1/users` (admin-only endpoint).
- **Per-staff open tickets** — each active staff member listed with their open ticket count (including **0**), plus an **Unassigned** footer line.
- Open counts are derived client-side from `GET /api/v1/tickets?status=open&limit=100` (same cap as the open-tickets KPI; counts are approximate if more than 100 open tickets exist).

**Non-admin staff** (`user` role) and **portal users** do not see the Active users block because `/users` is admin-guarded.

The **live ticket queue** (all roles that see the dashboard feed) shows status, assignee (`display_name` → email → “Unassigned”), and last-activity time from `last_message_at ?? updated_at ?? created_at` (latest message `created_at`, then ticket `updated_at`, then `created_at`).

---

## Calendar (call-outs & meetups)

Staff **Manage → Calendar** (`/calendar`) shows:

1. **Pending requests** — LLM-detected scheduling intent (`status=suggested`). Staff **Book** or **Decline**.
2. **Compact month → day** — click a day to open it; click a time to **create a booking** (client WhatsApp + channel + assignee).
3. **In / Out buffers** — travel (call-out) or prep (meetup) around the appointment. Overlaps return **409**.
4. **WhatsApp confirm** — on a booking, pick a connected channel, send a YES/NO message, then mark client confirmed/declined while waiting.

- **API:** `GET/POST /api/v1/calendar/requests`, `/events`, `/availability`, `POST …/book|decline|cancel|confirm-message|client-confirm`
- **Migration:** `023_calendar_requests.sql` (apply via `npm run migrate:sql`)
- Ticket detail shows a banner when a pending suggestion exists for that ticket.

## Staff directory

Staff **Manage → Staff** (`/staff`) lists active admin and agent users (card or list view). Open a person for a **full-screen** workspace:

1. **WhatsApp chat (main)** — conversation on their profile phone via a connected channel. History is kept for that number; when they reply on WhatsApp it lands on the same open direct ticket.
2. **Tickets & history** — assigned / group tickets (side rail).
3. **Calendar & activity** — bookings and audit actions (side rail).

- **API:** `GET /api/v1/users/staff`, `GET /api/v1/users/staff/:id`, `POST /api/v1/users/staff/:id/chat` (roles `admin` and `user`). Phone and tags are still edited under **Admin → Users**.

---

## Companies and Web Portal

- **Companies** (staff): sidebar → Companies. Create or edit companies; view contact numbers and channel numbers from tickets; create **portal users** (email/password) for a company.
- **WhatsApp intake**: when onboarding captures a company name, a `companies` row is upserted and the contact is linked (`contacts.company_id`).
- **Portal channel**: migration `009` seeds a synthetic channel `Web Portal` (`phone_number` `portal:system`). It never connects to Baileys; tickets from company portal logins use this channel and `portal:{userId}` contacts.
- **Portal contacts**: migration `010` widens `contacts.phone_number` to `VARCHAR(64)` so values like `portal:{uuid}` fit (the column was `VARCHAR(20)` and blocked portal messaging). Apply on existing DBs the same way as `009` (PowerShell: pipe `010_contact_phone_portal_length.sql` into `psql -U supabase_admin`).
- **Portal display names**: migration `011` adds nullable `users.display_name` for portal users. Tickets show **company name** as the title, **name · email** under it and on inbound portal message footers. Optional **Display name** when creating a portal user under Companies.
- **Tickets**: staff reply on portal tickets from the ticket UI (messages stay in-app, not sent to WhatsApp). Company users can have **multiple open tickets**; **Start ticket** always creates a new one (`force_new`). Replies on a ticket thread use `ticket_id`. Inbound without a ticket id appends to the **most recent** open ticket for that contact+channel. Staff ticket chat right rail: **Ticket details** stacks status/priority/channel/opened (priority editable there); **Customer** shows company account + AI autoreply and an **Earlier tickets** accordion (up to 2 prior tickets via `GET /contacts/:id`). On **group** tickets, a **Group** card lists WhatsApp members and lets staff add/remove staff (not the client). Staff **New ticket** (topbar + Tickets) creates a WhatsApp ticket via `POST /api/v1/tickets` (connected channel + contact or phone + first message; always a new ticket). Toggle **Direct** vs **WhatsApp group** (client + 1–3 staff phones; subject default `Silicon Support — {client}`).
- **Ordering**: ticket lists sort by `updated_at` (bumped when messages arrive).

Apply `009` on existing databases. In this stack, `public.users` / `tickets` / `messages` are owned by **`supabase_admin`**, so use that role (not `postgres`) for `ALTER`:

```bash
docker compose exec -T db psql -U supabase_admin -d postgres -f - < supabase/migrations/009_companies_and_portal.sql
```

On Windows PowerShell:

```powershell
Get-Content supabase/migrations/009_companies_and_portal.sql -Raw | docker compose exec -T db psql -U supabase_admin -d postgres
```

---

## Development (without Docker)

```bash
# Install all dependencies
npm install

# Start API (requires DATABASE_URL in .env)
npm run dev:api

# Start frontend dev server
npm run dev:frontend

# Start WhatsApp worker
npm run dev:whatsapp
```

---

## Useful Commands

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

# Restart a single service
docker compose restart api

# Stop (data preserved)
docker compose down

# Wipe all data (destructive)
docker compose down -v
```

---

## Deploy script (`./deploy.sh`) — git pull blocked

If `git pull` / `./deploy.sh` stops with **local changes would be overwritten** or **untracked files would be overwritten**:

1. **Tracked edits (e.g. `deploy.sh`)** — save or shelve them, then pull:
   - `git stash push -m "deploy" deploy.sh` — or commit your change with `git add` / `git commit`.
2. **Untracked files in the way of incoming files** — Git will not merge until they are moved aside or removed (backup first if unsure):
   - `mkdir -p ~/wa_support-wip-backup && mv <paths> ~/wa_support-wip-backup/`
   - Or, if those files are disposable copies of what `origin` already has: remove them, then `git pull` again.
3. **`.git/objects` permission errors** — fix once after accidental `sudo git`:
   - `sudo chown -R "$(id -un):$(id -gn)" .git`

`.playwright-mcp/`, `.env_old`, and similar paths are listed in `.gitignore` so they do not clutter status; existing untracked files must still be moved or deleted once if they block a merge.

---

## System Requirements

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