# CRM: Support Center

**Date:** 2026-05-30  
**Status:** Draft  
**Depends on:** `foundation-auth-rbac`, `customers-module`, `system-communications-notifications`, `tasks-detail-communication`  
**Referenced by:** `time-management`, `tenant-portals`

---

## Overview

Support ticket system. Customers open tickets via email, Telegram bot, or portal. Staff work tickets from a board or list view. Timer starts on ticket open. Replies flow back to the originating channel.

---

## Data Model

```sql
tickets (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  customer_id UUID,                -- nullable (anonymous or no customer match)
  contact_id UUID,                 -- nullable (specific contact)
  title TEXT NOT NULL,
  description TEXT NOT NULL,
  status TEXT DEFAULT 'open',      -- 'open' | 'in_progress' | 'pending_customer' | 'resolved' | 'closed'
  priority TEXT DEFAULT 'medium',  -- 'low' | 'medium' | 'high' | 'urgent'
  category TEXT,                   -- free text or from category list
  assignee_id UUID,                -- references users
  source TEXT NOT NULL,            -- 'email' | 'telegram' | 'whatsapp' | 'portal' | 'manual'
  external_id TEXT,                -- message ID in source system (Telegram msg ID, email Message-ID)
  external_thread_id TEXT,         -- for grouping replies in source (email thread, Telegram chat ID)
  resolved_at TIMESTAMPTZ,
  closed_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
)

ticket_messages (
  id UUID PRIMARY KEY,
  ticket_id UUID NOT NULL,
  tenant_id UUID NOT NULL,
  author_type TEXT NOT NULL,       -- 'staff' | 'customer' | 'system'
  author_id UUID,                  -- staff user id; NULL for customer/system
  author_name TEXT,                -- customer name (from contact or inbound message)
  content TEXT NOT NULL,           -- HTML (sanitized allowlist, same as task messages)
  source TEXT NOT NULL,            -- 'web' | 'email' | 'telegram' | 'whatsapp'
  created_at TIMESTAMPTZ DEFAULT now(),
  deleted_at TIMESTAMPTZ
)

ticket_message_attachments (
  id UUID PRIMARY KEY,
  message_id UUID NOT NULL,
  tenant_id UUID NOT NULL,
  filename TEXT NOT NULL,
  r2_key TEXT NOT NULL,
  url TEXT NOT NULL,
  size_bytes INTEGER NOT NULL,
  mime_type TEXT NOT NULL,
  created_at TIMESTAMPTZ
)

ticket_categories (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  name TEXT NOT NULL,
  color TEXT,
  UNIQUE (tenant_id, name)
)
```

---

## Support Center Screen (`/crm/support`)

> Routes live under `/crm/*` because Support Center is part of the CRM module (see nav-model `moduleId` `crm`); reconciled 2026-06-11.

### Board view (default)

Kanban-style columns by status: `Open`, `In Progress`, `Pending Customer`, `Resolved`.

Each ticket card: title, customer name + company chip, priority badge, assignee avatar, age (time since created), source icon (email/telegram/etc).

Drag card between columns → `PATCH /api/tickets/:id` with new status.

### List view

Table: Title, Customer, Priority, Category, Assignee, Status, Created, Last reply.  
Sortable columns. Click row → ticket detail.

### Filters (URL-synced)

Priority, category, assignee, status, source, date range, customer. Active filter chips. "Reset filters" button.

### "New Ticket" button

Opens sheet form: customer (select/create), title, description (rich text), priority, category, assignee.

---

## Ticket Detail (`/crm/support/:id`)

### Layout

```
┌──────────────────────────────────────────────────┐
│ [← Back]   Ticket #123  [Status ▾]  [Priority ▾] │
│                                                  │
│ Title (read-only)                                │
│                                                  │
│ ┌─────────────────────┐ ┌─────────────────────┐  │
│ │ Correspondence      │ │ Ticket info         │  │
│ │ (messages stream)   │ │ Customer: ...       │  │
│ │                     │ │ Category: ...       │  │
│ │                     │ │ Assignee: ...       │  │
│ │                     │ │ Source: email       │  │
│ │                     │ │ Created: 2h ago     │  │
│ │                     │ │                     │  │
│ │ [Reply box]         │ │ [Start timer]       │  │
│ └─────────────────────┘ └─────────────────────┘  │
└──────────────────────────────────────────────────┘
```

### Timer on ticket open

"Start timer" button in sidebar. Clicking opens a small popover: project selector (required, pre-filtered to customer's projects) + description (pre-filled "Support: {ticket title}"). Staff confirms → `POST /api/time/start`. Time entry `source = 'auto'`.

### Correspondence stream

Same pattern as task messages: interleaved staff replies + system events (status changes, assignments) sorted by `created_at`. Staff replies show sender name; customer replies show contact name + source icon.

### Reply

Staff reply → `POST /api/tickets/:id/reply`:
1. Saves `ticket_messages` record (`author_type = 'staff'`)
2. Routes reply back to originating channel:
   - `source = 'email'` → send reply email to `ticket.external_thread_id` (reply-to thread)
   - `source = 'telegram'` → `bot.sendMessage(chatId, text)` via Telegram adapter
   - `source = 'whatsapp'` → WhatsApp Business API (Enterprise only)
   - `source = 'portal'` → in-app notification to portal user

---

## Inbound Channels

### Email

Inbound emails routed via Cloudflare Email Routing → Worker → Queue.

Worker receives raw email, extracts:
- `Message-ID` → `external_id`
- `In-Reply-To` / `References` → `external_thread_id` (for threading)
- `From` → match against `customer_contacts.email` → link `customer_id`
- `Subject` → ticket title (or "Re: {existing title}" for replies)
- `Body` (text/html sanitized) → `ticket_messages` record

**New email** (no matching thread): creates new ticket (`source = 'email'`).  
**Reply to existing thread**: appends message to existing ticket (`external_thread_id` match).

### Telegram Bot

Each tenant registers their own Telegram bot (via @BotFather). Bot token stored encrypted in `adapter_credentials` table under `adapter_id = 'telegram'` (same AES-256-GCM pattern as invoice adapters, keyed by `INTEGRATION_ENCRYPTION_KEY`).

Bot webhook registered at: `POST /api/webhooks/telegram/{tenantId}`

Webhook verification: Telegram `secret_token` header (set via `setWebhook` `secret_token` param). Token stored alongside bot token in `adapter_credentials`. Webhook handler validates `X-Telegram-Bot-Api-Secret-Token` header before processing.

> Path keyed by `tenantId`, NOT the bot token — bot tokens in URL paths are logged in CF access logs.

- Private message to bot → new ticket (`source = 'telegram'`)
  - `from.username` / `from.first_name` → author name
  - `chat.id` → `external_thread_id` (used to route replies back)
  - `message.text` → ticket title + initial message
- Subsequent messages in same chat → append to existing open ticket for that `external_thread_id`

Reply from staff (via board) → decrypt bot token from `adapter_credentials` → `bot.sendMessage(chat_id, text)` via Telegram Bot API.

### WhatsApp (Enterprise)

Same pattern as Telegram. WhatsApp Business API webhook at `POST /api/webhooks/whatsapp`. Requires `tier = 'ENTERPRISE'` — stub returns 402 on lower tiers.

### Customer Portal

> Portal tickets path is `/portal/:slug/tickets` per the tenant-portals implementation; reconciled 2026-06-11.

Portal users submit tickets via the create form on `/portal/:tenantSlug/tickets`. `source = 'portal'`. Reply notifications delivered in-app to portal user + email.

---

## Ticket Status Lifecycle

```
open → in_progress → pending_customer → resolved → closed
              ↑_______________|
```

- `pending_customer`: staff replied, waiting for customer response. Auto-reopen to `in_progress` when customer replies.
- `resolved`: staff marks resolved. Customer can reopen within 7 days (configurable).
- `closed`: no further replies accepted. Manually by staff or auto-close after 7 days of resolved.

System messages generated on each transition (logged to `ticket_messages` with `author_type = 'system'`).

**Auto-close cron** (`ticket-close-stale`, daily): transitions `resolved` tickets whose `resolved_at` is older than the configurable window (`tenant_settings.ticket_auto_close_days`, default 7) to `closed`, writing the standard system transition message. The customer-reopen window (above) shares the same setting — a ticket is reopenable until it auto-closes. `CRON_SECRET`-guarded `/api/cron/*` endpoint, per the cron convention.

---

## Customer-Facing View

Portal users see tickets at `/portal/:tenantSlug/tickets`:
- List of own tickets
- Ticket detail with correspondence
- Reply form (plain text, no rich text for portal simplicity)
- Cannot see other customers' tickets (enforced by `tenantQuery(db, tenantId)` + `customer_id` filter)

Staff view and customer view are the same route, different by session type (`portal_role` vs staff role).

---

## Permissions

| Action | Required permission |
|--------|-------------------|
| View all tickets | `tickets:read` |
| Create ticket (staff) | `tickets:write` |
| Reply to ticket | `tickets:write` |
| Assign ticket | `tickets:write` |
| Change status | `tickets:write` |
| Delete ticket | `tickets:delete` |
| Manage categories | `tickets:write` |

---

## API Endpoints

```
GET    /api/tickets                        → list (filterable, paginated)
POST   /api/tickets                        → create (staff)
GET    /api/tickets/:id                    → detail + messages
PATCH  /api/tickets/:id                    → update (status, priority, assignee, category)
DELETE /api/tickets/:id                    → soft delete
POST   /api/tickets/:id/reply              → staff reply (routes to originating channel)
GET    /api/tickets/:id/messages           → message stream
DELETE /api/tickets/:id/messages/:mid      → soft delete own message
GET    /api/tickets/categories             → list categories
POST   /api/tickets/categories             → create category
DELETE /api/tickets/categories/:id         → delete category (unassigns from tickets)

POST   /api/webhooks/telegram/:tenantId    → Telegram inbound (verified via X-Telegram-Bot-Api-Secret-Token header)
POST   /api/webhooks/whatsapp              → WhatsApp inbound (Enterprise)
```

---

## Webhooks

| Event | Payload |
|-------|---------|
| `ticket.created` | `{ ticketId, customerId, source, priority, assigneeId }` |
| `ticket.replied` | `{ ticketId, messageId, authorType, source }` |
| `ticket.resolved` | `{ ticketId, resolvedAt, resolvedBy }` |

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Ticket ↔ task separation | Separate `tickets` table | Different lifecycle, customer-facing, different metadata — tasks are internal work items |
| Thread routing | `external_thread_id` per ticket | Email `In-Reply-To` and Telegram `chat_id` both map to this field |
| Reply routing | Source-aware in reply handler | One `POST /api/tickets/:id/reply` routes to correct channel — no adapter switch in UI |
| HTML content | Same sanitization as task messages | Reuse allowlist; consistent rendering |
| WhatsApp gating | `tier = 'ENTERPRISE'` check | Business API has per-message cost + approval overhead |
| Portal vs staff view | Same route, session type controls data scope | Reduces duplicate components; permission layer enforces visibility |
| Telegram per-tenant bot | Encrypted `adapter_credentials` per tenant | White-label multi-tenant needs per-tenant bots; global secret would be shared across all tenants |
| Telegram webhook path keyed by tenantId | `POST /api/webhooks/telegram/{tenantId}` not `/:botToken` | Bot tokens in URL paths land in CF access logs; tenantId is safe to expose |
