# Milestone 1: WhatsApp ticket media (images + voice notes + documents)

**Audit (gaps, checklist, build order):** [`whatsapp_media_audit.md`](./whatsapp_media_audit.md)

## Behaviour

- **Inbound:** Customers can send images, voice notes, and **documents** (PDF, Office, etc.) on WhatsApp. The Baileys worker downloads media and posts it to `POST /internal/messages` with base64 payload. The API stores files in **Supabase Storage** (private bucket `ticket-media` by default) and persists metadata on `messages` (`message_type`: `image` | `voice_note` | `document`).
- **Outbound (WhatsApp):** Staff can send **multiple** images, voice files, or **documents** in one multipart request (optional caption from the reply box), upload voice files, or **record** a voice note in the browser. MIME is inferred per part; delivery uses the worker per file (`sendMediaMessage` / text).
- **Outbound (web portal):** Company users `POST /api/v1/portal/messages` with multipart (`ticket_id`, optional `content`, repeated `file` parts). Staff on **portal** tickets use `POST /api/v1/tickets/:id/messages` multipart the same way—**no** WhatsApp worker; `source: portal` on message rows. One **talkback** run per user action after all rows are saved.
- **UI:** Ticket thread shows inline images and `<audio controls>` for voice notes. **Documents:** PDF and plain text open in a preview modal (browser iframe); Word/Excel/zip remain download-only. URLs are **short-lived signed URLs** (default TTL 3600s); refreshing the ticket reloads new URLs.

## Configuration

| Variable | Purpose |
|--------|---------|
| `SUPABASE_URL` | Internal Storage API base for the API container (Docker Compose: `http://kong:8000`) |
| `SUPABASE_PUBLIC_URL` | Browser-facing Kong URL used in signed media links (defaults to `http://PUBLIC_SITE_HOST:KONG_HTTP_HOST_PORT`; set `https://…` in production) |
| `SERVICE_ROLE_KEY` | Same JWT used by the Supabase stack |
| `MEDIA_BUCKET` | Bucket name (default `ticket-media`) |
| `MEDIA_SIGNED_URL_TTL_SEC` | Signed URL lifetime in seconds |
| `MEDIA_MAX_BYTES` | Max upload size (default `52428800`, matches storage `FILE_SIZE_LIMIT` in `docker-compose.yml`) |

Apply DB migrations **`018_message_media.sql`** and **`020_message_document_type.sql`** (included in `docker-compose` db init mounts). The API creates the bucket on first use if it does not exist.

**Existing databases:** run `020_message_document_type.sql` once as the DB owner (same role that applied `018`), e.g.:

```bash
docker compose exec db psql -U supabase_admin -d postgres -c "ALTER TYPE message_content_type ADD VALUE IF NOT EXISTS 'document';"
```

(Fresh `docker compose` init mounts include `020` automatically.)

## Limits

- Enforced against **`MEDIA_MAX_BYTES`** (default **52428800** bytes, aligned with `storage` service `FILE_SIZE_LIMIT`).
- **At most 10 files** per multipart request (`MAX_MEDIA_FILES_PER_REQUEST` in **`ticketMediaService`**).
- Allowed MIME types are restricted in **`ticketMediaService`** (`IMAGE_MIMES`, `VOICE_MIMES`, `DOCUMENT_MIMES`; `inferMediaKindFromMime` / `validateInboundMime` / `validateStaffMime`).

## AI (non-text inbound)

Talkback/onboarding receive **text placeholders** only (e.g. `[Image]`, `[Document: report.pdf]`), not file content or signed URLs. See **`docs/whatsapp_media_audit.md`** § AI / talkback path.

## Ticket UI (delivery & channel)

- Failed WhatsApp outbound: red ring + message on bubble (`message:sent`, missing `wa_message_id`) — `ticketMessageDelivery.ts`.
- Disconnected WhatsApp channel: banner + disabled composer until `channel.status === 'connected'`.

## Email channel

Email tickets remain **text-only** on `POST /tickets/:id/messages` (multipart is rejected).
