# CRM: Support Center — Implementation Plan

**Spec:** docs/specs/2026-05-30-crm-support-center.md  ·  **Slug:** crm-support-center  ·  **Wave:** 6
**Depends on:** customers-module, foundation-auth-rbac, system-communications-notifications, tasks-detail-communication

## Goal
Deliver a multi-channel support ticket system: customers open tickets via inbound email (Cloudflare Email Routing → comms queue), Telegram bot, WhatsApp (Enterprise), or the customer portal; staff triage them on a kanban board / list view and reply from a detail screen where replies route back to the originating channel. Tickets carry a full correspondence stream (staff replies + system lifecycle events), a sidebar timer that starts a `source='auto'` time entry against the customer's project, a status lifecycle with auto-reopen and a daily auto-close cron, and outbound webhook events.

## Architecture
- **New DB tables (this spec owns):** `tickets`, `ticket_messages`, `ticket_message_attachments`, `ticket_categories`. The per-tenant auto-close window is owned here as `tenant_settings.ticket_auto_close_days` (default 7) on the shared `tenant_settings` row — reconciled 2026-06-11 to match convention.
- **Consumes upstream (locked names):** `tenantQuery`/`systemQuery` (tenant-scoped Drizzle), `customers`/`customer_contacts`/`getCustomerWithStats` (customer linking), `users` (assignee/author), `adapter_credentials` + `loadAdapterCredential`/`decryptCredential` (Telegram bot token + secret), `CommsAdapter`/`OutboundMessage`/`InboundMessage` + `routeInboundMessage` (inbound dispatch entry; it calls the `createSupportTicket` exported here), `comms.inbound queue` (email/bot inbound already enqueued by comms), `sendEmail`, `createNotification` + `deliverNotification`/`DeliverableNotification` (portal reply notifications), `webhook.deliver` + `WebhookEvent` (outbound events), `requireTier`/`TenantTier` (WhatsApp Enterprise gate), `requirePermission` + `seedPermissions` (new `tickets:*` perms), `authMiddleware`/`Session`, `requireModuleEnabled`, `buildPaginated`/`PaginatedResponse`/`PaginationParams`, `rateLimit`/`RATE_LIMITER_WEBHOOK`, `DO_REALTIME`/`TenantRealtimeDO` (broadcast ticket updates), `POST /api/time/start` (timer), the task-message HTML allowlist sanitizer + client DOMPurify pattern, UI primitives (`DataTable`, `Sheet`, `Dialog`, `Popover`, `Badge`, `Avatar`, `Button`, `Select`, `Textarea`, `Tabs`, `EmptyState`, `Toaster`/`toast`), `useDirection` (RTL).
- **Data flow (inbound):** Email Routing Worker / Telegram webhook / WhatsApp webhook → comms parses to `InboundMessage` → `comms.inbound queue` → `routeInboundMessage` → `createSupportTicket(msg)` (this spec): match contact by `from`, find open ticket by `external_thread_id` (append) or create new ticket + first `ticket_messages` row → emit `ticket.created`/`ticket.replied` webhook + DO broadcast + (if assigned) notification.
- **Data flow (outbound reply):** `POST /api/tickets/:id/reply` → insert `ticket_messages` (`author_type='staff'`) → source-aware route: email via `sendEmail` (reply into thread), telegram via decrypted bot token `sendMessage`, whatsapp via Business API (Enterprise) , portal via `createNotification`+`deliverNotification` → emit `ticket.replied` + DO broadcast.
- **Status lifecycle:** transitions write `author_type='system'` messages; customer reply on a `pending_customer` ticket auto-reopens to `in_progress`; daily `ticket-close-stale` cron closes `resolved` tickets older than `tenant_settings.ticket_auto_close_days`.

## Tech Stack
- **API:** `apps/zync-api` (Hono on Cloudflare Workers) — ticket routes, reply routing, inbound `createSupportTicket`, cron handler, webhook gate for WhatsApp Enterprise.
- **DB:** `packages/db` (Drizzle + Neon Postgres via Hyperdrive) — schema + query modules.
- **App UI:** `apps/zync-app` (Vite + React, TanStack Query + Zustand) — `/crm/support` board+list, `/crm/support/:id` detail, `/portal/:tenantSlug/tickets` portal views.
- **Bindings:** `HYPERDRIVE` (Neon), `STORAGE` (R2 attachments), `DO_REALTIME` (TenantRealtimeDO), `RATELIMIT_KV`/`RATE_LIMITER_WEBHOOK`, `QUEUE` (`comms.inbound`), secrets `INTEGRATION_ENCRYPTION_KEY`, `CRON_SECRET`.
- **Libraries:** existing HTML allowlist sanitizer (task-messages), DOMPurify (client), `@dnd-kit` for kanban drag (per design-system), zod for validation.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 6a | 1, 2 | `packages/db/src/schema`, `packages/auth` (perm seed) | Task 1 then 2 |
| 6b | 3, 4, 5 | `packages/db/src/queries/tickets.ts`, `.../ticket-categories.ts`, `.../ticket-messages.ts` | Parallel after Task 1 |
| 6c | 6, 7, 8, 9 | `apps/zync-api/routes/tickets.ts`, `.../ticket-categories.ts`, reply handler, inbound `createSupportTicket` | After 6b |
| 6d | 10, 11 | WhatsApp Enterprise webhook gate, `ticket-close-stale` cron | After 6c |
| 6e | 12, 13, 14, 15 | `apps/zync-app` board, list+filters, detail+timer, New Ticket sheet | Parallel after 6c |
| 6f | 16 | `apps/zync-app` portal support views | After 6c |
| 6g | 17 | webhook event emission + DO broadcast wiring | After 6c |

## Tasks

### Task 1: Ticket schema (Drizzle tables + auto-close column)
**Blocks:** 2, 3, 4, 5  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/tickets.ts`
- Modify: `packages/db/src/schema/index.ts` (export new tables)
- Modify: `packages/db/src/schema/tenants.ts` (`tenant_settings` table — add `ticket_auto_close_days` column)
- Create: `packages/db/migrations/<ts>_crm_support_center.sql`
**Steps:**
- [ ] Define the four tables in Drizzle (pg-core) matching the canonical DDL below; all PKs `uuid().defaultRandom()`, all FKs UUID→UUID, enums as `text().$type<...>()` with table-level CHECK, `createdAt: timestamp(..., { withTimezone: true }).notNull().defaultNow()`.
- [ ] Add column `ticket_auto_close_days INTEGER NOT NULL DEFAULT 7` to `tenant_settings`.
- [ ] Add indexes: `tickets(tenant_id, status)`, `tickets(tenant_id, external_thread_id)`, `tickets(tenant_id, assignee_id)`, `ticket_messages(ticket_id, created_at)`, `ticket_message_attachments(message_id)`, unique `ticket_categories(tenant_id, name)`.
- [ ] Export a `TicketStatus` union type (`'open' | 'in_progress' | 'pending_customer' | 'resolved' | 'closed'`) — this is the locked exported name `TicketStatus`.
- [ ] Write the SQL migration; verify it applies on a Neon branch.
**Schema / Interfaces:**
```sql
CREATE TABLE tickets (
  id                 UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id          UUID NOT NULL REFERENCES tenants(id),
  customer_id        UUID REFERENCES customers(id),
  contact_id         UUID REFERENCES customer_contacts(id),
  title              TEXT NOT NULL,
  description        TEXT NOT NULL,
  status             TEXT NOT NULL DEFAULT 'open'
                       CHECK (status IN ('open','in_progress','pending_customer','resolved','closed')),
  priority           TEXT NOT NULL DEFAULT 'medium'
                       CHECK (priority IN ('low','medium','high','urgent')),
  category           TEXT,
  assignee_id        UUID REFERENCES users(id),
  source             TEXT NOT NULL
                       CHECK (source IN ('email','telegram','whatsapp','portal','manual')),
  external_id        TEXT,
  external_thread_id TEXT,
  resolved_at        TIMESTAMPTZ,
  closed_at          TIMESTAMPTZ,
  created_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at         TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE ticket_messages (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  ticket_id   UUID NOT NULL REFERENCES tickets(id),
  tenant_id   UUID NOT NULL REFERENCES tenants(id),
  author_type TEXT NOT NULL CHECK (author_type IN ('staff','customer','system')),
  author_id   UUID REFERENCES users(id),
  author_name TEXT,
  content     TEXT NOT NULL,
  source      TEXT NOT NULL CHECK (source IN ('web','email','telegram','whatsapp')),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  deleted_at  TIMESTAMPTZ
);

CREATE TABLE ticket_message_attachments (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  message_id UUID NOT NULL REFERENCES ticket_messages(id),
  tenant_id  UUID NOT NULL REFERENCES tenants(id),
  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 NOT NULL DEFAULT now()
);

CREATE TABLE ticket_categories (
  id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  name      TEXT NOT NULL,
  color     TEXT,
  UNIQUE (tenant_id, name)
);

ALTER TABLE tenant_settings ADD COLUMN ticket_auto_close_days INTEGER NOT NULL DEFAULT 7;
```
```ts
export type TicketStatus = 'open' | 'in_progress' | 'pending_customer' | 'resolved' | 'closed';
export type TicketPriority = 'low' | 'medium' | 'high' | 'urgent';
export type TicketSource = 'email' | 'telegram' | 'whatsapp' | 'portal' | 'manual';
```
**Acceptance:**
- [ ] Migration applies cleanly on a Neon branch; all four tables + the `tenant_settings` column exist.
- [ ] Every FK is UUID→UUID; every enum is an inline CHECK matching the spec verbatim.
- [ ] `TicketStatus` exported from `@zync/db`.

### Task 2: Seed `tickets:*` permissions
**Blocks:** 6, 7, 8  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/auth/src/permissions.ts` (the source consumed by `seedPermissions`)
**Steps:**
- [ ] Register three new permission keys: `tickets:read`, `tickets:write`, `tickets:delete`.
- [ ] Wire them into the default role grants used by `seedSystemRoles` (admin: all three; staff/member: read+write; viewer: read).
- [ ] Ensure `seedPermissions` inserts them idempotently (no duplicate on re-seed).
**Schema / Interfaces:**
```ts
// added to the permission catalog
'tickets:read'   // View all tickets
'tickets:write'  // Create/reply/assign/status/manage categories
'tickets:delete' // Delete ticket
```
**Acceptance:**
- [ ] Running `seedPermissions` twice yields exactly three `tickets:*` rows.
- [ ] `requirePermission('tickets:read')` resolves for granted roles.

### Task 3: Ticket query module (tenant-scoped repo)
**Blocks:** 6, 8, 9, 11, 17  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/queries/tickets.ts`
**Steps:**
- [ ] Implement all queries via `tenantQuery(db, tenantId)` — never raw Drizzle from routes (lint rule `no-raw-drizzle-from-routes`).
- [ ] `listTickets(tenantId, filters, pagination)` — filter by priority, category, assignee, status, source, customer, date range; sortable; returns `buildPaginated(...)`.
- [ ] `getTicketWithMessages(tenantId, ticketId)` — ticket + ordered non-deleted messages + attachments + joined customer/contact/assignee display fields.
- [ ] `createTicket(tenantId, input)` and `updateTicket(tenantId, ticketId, patch)` — patch updates status/priority/assignee/category, sets `resolved_at` on `resolved`, `closed_at` on `closed`, bumps `updated_at`.
- [ ] `softDeleteTicket(tenantId, ticketId)` — sets `closed_at` and a deleted marker (DELETE endpoint is soft per spec); excluded from default lists.
- [ ] `findOpenTicketByThread(tenantId, externalThreadId)` — for inbound append matching (status NOT IN ('closed')).
- [ ] `appendSystemMessage(tenantId, ticketId, text)` and `appendTicketMessage(tenantId, ticketId, msg)` helpers.
**Schema / Interfaces:**
```ts
interface TicketFilters {
  priority?: TicketPriority; category?: string; assigneeId?: string;
  status?: TicketStatus; source?: TicketSource; customerId?: string;
  dateFrom?: string; dateTo?: string; sort?: string;
}
function listTickets(tenantId: string, f: TicketFilters, p: PaginationParams): Promise<PaginatedResponse<TicketRow>>;
function getTicketWithMessages(tenantId: string, ticketId: string): Promise<TicketDetail>;
function createTicket(tenantId: string, input: CreateTicketInput): Promise<TicketRow>;
function updateTicket(tenantId: string, ticketId: string, patch: UpdateTicketInput): Promise<TicketRow>;
function softDeleteTicket(tenantId: string, ticketId: string): Promise<void>;
function findOpenTicketByThread(tenantId: string, externalThreadId: string): Promise<TicketRow | null>;
function appendSystemMessage(tenantId: string, ticketId: string, text: string): Promise<void>;
function appendTicketMessage(tenantId: string, ticketId: string, msg: AppendMessageInput): Promise<{ id: string }>;
```
**Acceptance:**
- [ ] All functions go through `tenantQuery`; cross-tenant rows never returned.
- [ ] `listTickets` honors every filter + pagination via `buildPaginated`.

### Task 4: Ticket categories query module
**Blocks:** 7  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/queries/ticket-categories.ts`
**Steps:**
- [ ] `listCategories(tenantId)`, `createCategory(tenantId, { name, color })` (unique per tenant), `deleteCategory(tenantId, id)`.
- [ ] On delete, unassign from tickets: `UPDATE tickets SET category = NULL WHERE tenant_id = $1 AND category = <deleted name>`.
**Schema / Interfaces:**
```ts
function listCategories(tenantId: string): Promise<{ id: string; name: string; color: string | null }[]>;
function createCategory(tenantId: string, input: { name: string; color?: string }): Promise<{ id: string }>;
function deleteCategory(tenantId: string, id: string): Promise<void>; // also nulls category on affected tickets
```
**Acceptance:**
- [ ] Duplicate `(tenant_id, name)` rejected by unique constraint.
- [ ] Deleting a category nulls `tickets.category` for that name within the tenant.

### Task 5: Reply-routing service + status lifecycle helpers
**Blocks:** 8, 9, 11, 17  ·  **Blocked by:** 1, 3
**Files:**
- Create: `packages/db/src/queries/ticket-routing.ts`
- Create: `packages/db/src/queries/ticket-lifecycle.ts`
**Steps:**
- [ ] `routeReplyToChannel(tenantId, ticket, html, text)` — source-aware:
  - `email` → `sendEmail({ to: contact.email, subject: 'Re: '+title, html, ... , locale })` threaded into `external_thread_id`.
  - `telegram` → `loadAdapterCredential(tenantId, 'telegram')` → `decryptCredential(...)` → Telegram Bot API `sendMessage(chat_id=ticket.external_thread_id, text, parse_mode='HTML')`.
  - `whatsapp` → WhatsApp Business API send (Enterprise; throws `PaymentProviderNotConfiguredError`-style 402 path if tier lower — gated upstream at route).
  - `portal` → `createNotification(...)` (entity_type `'ticket'`, entity_id ticketId) + `deliverNotification(portalUserId, DeliverableNotification)`.
- [ ] `applyStatusTransition(tenantId, ticketId, fromStatus, toStatus, actorId)` — validates lifecycle, sets `resolved_at`/`closed_at`, writes an `author_type='system'` message `"Status changed from {from} to {to} by {actor}"`.
- [ ] `autoReopenOnCustomerReply(tenantId, ticket)` — if current status `pending_customer`, transition to `in_progress` and write system message.
- [ ] Sanitize all stored HTML message bodies through the task-message allowlist sanitizer before insert.
**Schema / Interfaces:**
```ts
function routeReplyToChannel(tenantId: string, ticket: TicketRow, html: string, text: string): Promise<void>;
function applyStatusTransition(tenantId: string, ticketId: string, from: TicketStatus, to: TicketStatus, actorId: string): Promise<void>;
function autoReopenOnCustomerReply(tenantId: string, ticket: TicketRow): Promise<void>;
```
**Acceptance:**
- [ ] Each `source` routes to exactly its channel; no UI-side adapter switch.
- [ ] Telegram token never appears in a URL path or log; loaded from `adapter_credentials` and decrypted in-memory.
- [ ] Every status transition appends a non-deletable system message.

### Task 6: `/api/tickets` CRUD routes
**Blocks:** 12, 13, 14, 15, 16  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/tickets.ts`
- Modify: `apps/zync-api/src/app.ts` (mount router under module guard)
**Steps:**
- [ ] Mount behind `authMiddleware` + `requireModuleEnabled('support')` (or the support ModuleId) + per-route `requirePermission`.
- [ ] `GET /api/tickets` (`tickets:read`) — zod-validate query filters; call `listTickets`.
- [ ] `POST /api/tickets` (`tickets:write`) — zod body (customer, title, description, priority, category, assignee); `source='manual'`; sanitize description HTML; emit `ticket.created`.
- [ ] `GET /api/tickets/:id` (`tickets:read`) — `getTicketWithMessages`.
- [ ] `PATCH /api/tickets/:id` (`tickets:write`) — status/priority/assignee/category; on status change call `applyStatusTransition`; broadcast via DO.
- [ ] `DELETE /api/tickets/:id` (`tickets:delete`) — `softDeleteTicket`.
- [ ] `GET /api/tickets/:id/messages` (`tickets:read`) — message stream.
- [ ] `DELETE /api/tickets/:id/messages/:mid` (`tickets:write`) — soft delete own message (author check; system messages not deletable).
- [ ] All bodies validated with zod (`require-zod-validation-in-routes`).
**Schema / Interfaces:**
```
GET    /api/tickets                   tickets:read
POST   /api/tickets                   tickets:write
GET    /api/tickets/:id               tickets:read
PATCH  /api/tickets/:id               tickets:write
DELETE /api/tickets/:id               tickets:delete
GET    /api/tickets/:id/messages      tickets:read
DELETE /api/tickets/:id/messages/:mid tickets:write
```
```ts
const createTicketSchema = z.object({
  customerId: z.string().uuid().nullable().optional(),
  contactId: z.string().uuid().nullable().optional(),
  title: z.string().min(1),
  description: z.string().min(1),
  priority: z.enum(['low','medium','high','urgent']).default('medium'),
  category: z.string().optional(),
  assigneeId: z.string().uuid().nullable().optional(),
});
const updateTicketSchema = z.object({
  status: z.enum(['open','in_progress','pending_customer','resolved','closed']).optional(),
  priority: z.enum(['low','medium','high','urgent']).optional(),
  assigneeId: z.string().uuid().nullable().optional(),
  category: z.string().nullable().optional(),
});
```
**Acceptance:**
- [ ] Each route enforces the spec's permission; missing permission returns 403.
- [ ] Cross-tenant access impossible (tenantQuery-scoped).
- [ ] Soft delete keeps the row; default list excludes it.

### Task 7: Ticket categories routes
**Blocks:** 13, 15  ·  **Blocked by:** 2, 4
**Files:**
- Create: `apps/zync-api/src/routes/ticket-categories.ts`
- Modify: `apps/zync-api/src/app.ts`
**Steps:**
- [ ] `GET /api/tickets/categories` (`tickets:read`) → `listCategories`.
- [ ] `POST /api/tickets/categories` (`tickets:write`) → zod `{ name, color? }` → `createCategory`.
- [ ] `DELETE /api/tickets/categories/:id` (`tickets:write`) → `deleteCategory` (unassigns).
**Schema / Interfaces:**
```
GET    /api/tickets/categories      tickets:read
POST   /api/tickets/categories      tickets:write
DELETE /api/tickets/categories/:id  tickets:write
```
**Acceptance:**
- [ ] Categories CRUD scoped to tenant; duplicate name returns 409.

### Task 8: Staff reply route (source-aware routing)
**Blocks:** 14, 16, 17  ·  **Blocked by:** 2, 3, 5
**Files:**
- Modify: `apps/zync-api/src/routes/tickets.ts`
**Steps:**
- [ ] `POST /api/tickets/:id/reply` (`tickets:write`) — zod body `{ content: string }` (rich HTML for staff).
- [ ] Sanitize `content` with the task-message HTML allowlist.
- [ ] Insert `ticket_messages` `author_type='staff'`, `author_id=session.userId`, `source='web'`.
- [ ] Call `routeReplyToChannel(tenantId, ticket, html, plainText)` to deliver to the originating channel.
- [ ] If ticket was `pending_customer`, staff reply keeps it `pending_customer` (waiting on customer is set by staff explicitly via PATCH); emit `ticket.replied`; DO broadcast.
**Schema / Interfaces:**
```
POST   /api/tickets/:id/reply   tickets:write   body { content }
```
**Acceptance:**
- [ ] Reply persists, routes to the correct channel, emits `ticket.replied`.
- [ ] Stored HTML passes allowlist; raw script/style stripped server-side.

### Task 9: Inbound ticket creation (`createSupportTicket`) — email, telegram, portal
**Blocks:** 16, 17  ·  **Blocked by:** 3, 5
**Files:**
- Create: `apps/zync-api/src/services/create-support-ticket.ts`
- Modify: `packages/db/src/queries/inbound.ts` (wire `routeInboundMessage` → `createSupportTicket`)
**Steps:**
- [ ] Export `createSupportTicket(msg: InboundMessage, tenantConfig)` invoked by the existing `routeInboundMessage` consumer of `comms.inbound queue` (do NOT re-register the inbound webhook routes — they are comms-owned: `POST /api/webhooks/telegram/:tenantId`, `POST /api/webhooks/whatsapp/:tenantId`).
- [ ] Resolve `external_thread_id` from `msg.chatId` (telegram) or `In-Reply-To`/`References` (email, in `msg.metadata`).
- [ ] Match `msg.from` against `customer_contacts.email` (email) / username (telegram) → set `customer_id`/`contact_id` when found.
- [ ] If `findOpenTicketByThread` hits → append `ticket_messages` (`author_type='customer'`, source from channel), call `autoReopenOnCustomerReply`, emit `ticket.replied`.
- [ ] Else create new ticket (`source` = channel, title from subject/first line, sanitized body as first message), emit `ticket.created`.
- [ ] DO broadcast on both paths; notify assignee if set.
**Schema / Interfaces:**
```ts
function createSupportTicket(msg: InboundMessage, tenantConfig: TenantCommsConfig): Promise<{ ticketId: string; created: boolean }>;
```
**Acceptance:**
- [ ] New inbound email/telegram with no thread match creates a ticket with correct `source`.
- [ ] Reply on existing thread appends and (if `pending_customer`) auto-reopens to `in_progress`.
- [ ] No duplicate registration of the comms-owned webhook paths.

### Task 10: WhatsApp inbound Enterprise gate (stub)
**Blocks:** —  ·  **Blocked by:** 9
**Files:**
- Modify: `apps/zync-api/src/services/create-support-ticket.ts` (whatsapp branch)
**Steps:**
- [ ] In the whatsapp inbound branch, check `requireTier('ENTERPRISE')` against the tenant tier; on lower tiers return/throw a 402 stub (no ticket created), per spec.
- [ ] When Enterprise, process identically to telegram (thread by sender wa-id, source `'whatsapp'`).
- [ ] Webhook path is the comms-owned `POST /api/webhooks/whatsapp/:tenantId` (locked-sheet form); do not create a non-tenant-scoped variant.
**Acceptance:**
- [ ] Non-Enterprise tenant inbound WhatsApp yields HTTP 402, no ticket.
- [ ] Enterprise tenant inbound WhatsApp creates/append a `source='whatsapp'` ticket.

### Task 11: `ticket-close-stale` daily cron
**Blocks:** —  ·  **Blocked by:** 3, 5
**Files:**
- Create: `apps/zync-api/src/routes/cron/ticket-close-stale.ts`
- Modify: `apps/zync-api/wrangler.toml` (cron trigger, daily)
- Modify: `apps/zync-api/src/app.ts` (mount `/api/cron/ticket-close-stale`)
**Steps:**
- [ ] Guard endpoint with `CRON_SECRET` (timing-safe compare via `timingSafeEqual`) per the `/api/cron/*` convention.
- [ ] For each tenant, select `resolved` tickets where `resolved_at < now() - (tenant_settings.ticket_auto_close_days || ' days')::interval`.
- [ ] Transition each to `closed` via `applyStatusTransition` (writes the standard system message), set `closed_at`.
- [ ] The customer-reopen window shares this setting: a `resolved` ticket is reopenable (customer reply → `autoReopenOnCustomerReply` path) until it auto-closes.
**Schema / Interfaces:**
```
POST /api/cron/ticket-close-stale   (CRON_SECRET-guarded, daily schedule)
```
**Acceptance:**
- [ ] Wrong/absent `CRON_SECRET` → 401; correct secret runs the sweep.
- [ ] A `resolved` ticket older than the tenant window becomes `closed` with a system message; reopen no longer accepted once closed.

### Task 12: Support board view (`/crm/support`, kanban)
**Blocks:** —  ·  **Blocked by:** 6, 7
**Files:**
- Create: `apps/zync-app/src/pages/support/SupportBoardPage.tsx`
- Create: `apps/zync-app/src/features/support/TicketCard.tsx`
- Create: `apps/zync-app/src/features/support/useTickets.ts` (TanStack Query hooks)
**Steps:**
- [ ] Kanban columns by status: Open, In Progress, Pending Customer, Resolved (closed not shown on board).
- [ ] Each card: title, customer name + company chip (`Badge`), priority badge, assignee `Avatar`, age, source icon.
- [ ] Drag card between columns → optimistic store update → `PATCH /api/tickets/:id { status }` → revert + `toast` on error.
- [ ] Board container `role="list"`; columns `role="group"` with `aria-label`; cards keyboard-movable (a11y); respect `prefers-reduced-motion` for drag animations.
- [ ] RTL: column order and drag math respect `useDirection`.
- [ ] Subscribe to DO `ticket.*` broadcasts to live-update cards.
**Acceptance:**
- [ ] Drag persists status and survives reload; failed PATCH reverts with toast.
- [ ] Board is keyboard-operable and announces columns to screen readers.

### Task 13: Support list view + URL-synced filters
**Blocks:** —  ·  **Blocked by:** 6, 7
**Files:**
- Create: `apps/zync-app/src/pages/support/SupportListPage.tsx`
- Create: `apps/zync-app/src/features/support/TicketFilters.tsx`
**Steps:**
- [ ] `DataTable` columns: Title, Customer, Priority, Category, Assignee, Status, Created, Last reply; sortable; row click → `/crm/support/:id`.
- [ ] Filters (priority, category, assignee, status, source, date range, customer) synced to URL query params; active filter chips; "Reset filters" button.
- [ ] Pagination via `DataTablePagination` / `PaginatedResponse`.
- [ ] `EmptyState` when no tickets match.
**Acceptance:**
- [ ] Filter state round-trips through the URL (shareable/back-button safe).
- [ ] Sorting and pagination call the API with correct params.

### Task 14: Ticket detail (`/crm/support/:id`) — correspondence + reply + timer
**Blocks:** —  ·  **Blocked by:** 6, 8
**Files:**
- Create: `apps/zync-app/src/pages/support/TicketDetailPage.tsx`
- Create: `apps/zync-app/src/features/support/CorrespondenceStream.tsx`
- Create: `apps/zync-app/src/features/support/TicketReplyBox.tsx`
- Create: `apps/zync-app/src/features/support/StartTimerPopover.tsx`
**Steps:**
- [ ] Header: back link, `Ticket #id`, Status `Select`, Priority `Select` (PATCH on change).
- [ ] Two-column: left correspondence stream (staff replies + system events interleaved, sorted by `created_at`; staff shows sender name; customer shows contact name + source icon), reply box below. Right sidebar: customer, category, assignee, source, created age, Start timer.
- [ ] Reply box: rich text editor; submit → `POST /api/tickets/:id/reply`; optimistic append with pending state.
- [ ] Render stored message HTML through DOMPurify before `dangerouslySetInnerHTML` (defense-in-depth, same as task messages / kb-article-editor).
- [ ] Start timer `Popover`: required project selector pre-filtered to the ticket customer's projects + description pre-filled `Support: {ticket title}`; confirm → `POST /api/time/start` (entry `source='auto'`).
- [ ] Subscribe to DO `ticket.*` events for live message/status updates.
- [ ] A11y: stream is a labeled log region; reply editor `role="textbox"` `aria-multiline="true"`; RTL via `useDirection`.
**Acceptance:**
- [ ] Reply routes to the originating channel and appears optimistically then confirmed.
- [ ] Timer popover requires a project and starts a `source='auto'` time entry with the pre-filled description.
- [ ] No unsanitized HTML reaches the DOM.

### Task 15: New Ticket sheet (staff create)
**Blocks:** —  ·  **Blocked by:** 6, 7
**Files:**
- Create: `apps/zync-app/src/features/support/NewTicketSheet.tsx`
**Steps:**
- [ ] `Sheet` form: customer (select existing or create-inline), title, description (rich text), priority, category (from `GET /api/tickets/categories`), assignee.
- [ ] Submit → `POST /api/tickets`; on success navigate to detail or refresh board/list; `toast` on error.
- [ ] zod client validation mirrors `createTicketSchema`.
**Acceptance:**
- [ ] Creating a ticket from the sheet adds it to the Open column / list immediately.

### Task 16: Customer portal support views
**Blocks:** —  ·  **Blocked by:** 6, 8, 9
**Files:**
- Create: `apps/zync-app/src/pages/portal/PortalSupportListPage.tsx`
- Create: `apps/zync-app/src/pages/portal/PortalTicketDetailPage.tsx`
- Create: `apps/zync-app/src/pages/portal/PortalNewTicketPage.tsx`
**Steps:**
- [ ] Routes `/portal/:tenantSlug/tickets`, `/portal/:tenantSlug/tickets/:id` (create via dialog on list page).
- [ ] Same data routes as staff but scoped by session type: portal session filters to own `customer_id` (enforced server-side by `tenantQuery` + `customer_id` filter; portal users cannot see other customers' tickets).
- [ ] List own tickets; detail with correspondence; **plain-text** reply form (no rich editor) → `POST /api/tickets/:id/reply` as customer; new-ticket form sets `source='portal'`.
- [ ] Reply notifications delivered in-app + email to the portal user.
**Acceptance:**
- [ ] A portal user sees only their own tickets; attempting another customer's ticket returns 403/404.
- [ ] Portal reply is plain text and notifies staff; new portal ticket has `source='portal'`.

### Task 17: Outbound webhook events + DO broadcast wiring
**Blocks:** —  ·  **Blocked by:** 6, 8, 9
**Files:**
- Modify: `apps/zync-api/src/routes/tickets.ts`
- Modify: `apps/zync-api/src/services/create-support-ticket.ts`
- Create: `apps/zync-api/src/services/ticket-events.ts`
**Steps:**
- [ ] Emit via `webhook.deliver` (`WebhookEvent`):
  - `ticket.created` `{ ticketId, customerId, source, priority, assigneeId }`
  - `ticket.replied` `{ ticketId, messageId, authorType, source }`
  - `ticket.resolved` `{ ticketId, resolvedAt, resolvedBy }` (on status→resolved in PATCH).
- [ ] Broadcast ticket changes to `TenantRealtimeDO` via `DO_REALTIME` stub `fetch('/broadcast', ...)` so boards/detail update live (worker pushes; DO never queries Neon).
**Schema / Interfaces:**
```ts
// payloads
type TicketCreated  = { ticketId: string; customerId: string | null; source: TicketSource; priority: TicketPriority; assigneeId: string | null };
type TicketReplied  = { ticketId: string; messageId: string; authorType: 'staff' | 'customer' | 'system'; source: string };
type TicketResolved = { ticketId: string; resolvedAt: string; resolvedBy: string };
```
**Acceptance:**
- [ ] Each lifecycle action fires exactly the spec'd webhook event with the spec'd payload shape.
- [ ] Connected board clients receive DO broadcasts and update without reload.
