# Ticket SLA & Escalation — Implementation Plan

**Spec:** docs/specs/2026-05-31-ticket-sla-escalation.md  ·  **Slug:** ticket-sla-escalation  ·  **Wave:** 7
**Depends on:** crm-support-center, foundation-auth-rbac, system-communications-notifications

## Goal
Add per-priority SLA targets and breach escalation on top of the existing `tickets` table (owned by `crm-support-center`). Tenants on the Business+ tier get configurable first-response and resolution time targets per priority, a `due_at` deadline stamped on every ticket, a 15-minute cron that flags breached tickets and emits in-app + email escalation notifications, first-response tracking on staff replies, and SLA status surfaced on ticket detail/list. The SLA settings **page** is built downstream by `sla-config-ui` (spec 145); this spec owns the SLA **data, API, cron, and ticket-surface badges**.

## Architecture
- **New table `sla_policies`** — one row per (tenant, priority); holds `first_response_hours`, `resolution_hours`, `escalation_email`, and per-policy breach-notification prefs `notify_email` / `notify_in_app`. UNIQUE(tenant_id, priority).
- **`tickets` ALTERs** — adds `due_at`, `first_response_at`, `sla_breached` to the upstream `tickets` table (defined in `crm-support-center`; we ALTER, never recreate). `due_at = created_at + resolution_hours` computed on create and on priority change.
- **Tenant flag as a `tenant_settings` column** — typed module config lives on the `tenant_settings` table (base owned by `foundation-auth-rbac`, one row per tenant seeded at signup). This spec owns one boolean column: `sla_enabled` (gates all SLA computation, Business+), added idempotently via `ALTER TABLE tenant_settings ADD COLUMN IF NOT EXISTS`. Read/written via a domain helper on `tenantQuery` (NOT the AI-config `getTenantSettings`/`upsertTenantSettings`, which operate on `ai_tenant_settings`). Breach-notification routing is **per-policy** (`sla_policies.notify_email` / `notify_in_app`), not a global tenant flag. The downstream `sla-config-ui` (spec 145) reads `sla_enabled` and edits the per-policy notify columns.
- **Cron `ticket-sla-check`** (`*/15 * * * *`) — single `UPDATE ... RETURNING` over indexed `due_at`; for each newly-breached ticket looks up the matching policy and, if `notify_in_app` is true, emits in-app notifications to assignee + OWNER/ADMIN via `createNotification`; if `notify_email` is true and `escalation_email` is set, sends the escalation email via `deliverNotification`/`sendEmail`.
- **First-response hook** — in the existing `POST /api/tickets/:id/reply` handler (owned by `crm-support-center`), when a staff member posts the first message, stamp `first_response_at`; if past the first-response deadline, emit an in-app `ticket_first_response_breached` notification (no re-escalation).
- **API** — `GET /api/settings/sla` (OWNER/ADMIN, Business+) returns all policies + `sla_enabled`; `PATCH /api/settings/sla/:policyId` (OWNER, Business+) edits one policy's targets + notify prefs; the existing `GET /api/tickets` list endpoint is extended with `?sla_breached=true`. `sla-config-ui` (spec 145) consumes this route for its settings page and adds the per-ticket `GET /api/tickets/:id/sla` status endpoint.

Upstream consumed: `tickets`, `tenants`, `tenant_settings`, `users`, `tenant_memberships`, `roles` tables; `createNotification`, `deliverNotification`, `sendEmail`, `requireTier`, `requirePermission`, `authMiddleware`, `tenantQuery`, `systemQuery`, `createDb`, `buildPaginated`, `TicketStatus`, `TenantTier` exports.

## Tech Stack
- **DB:** Neon Postgres via Cloudflare Hyperdrive; Drizzle ORM. Migration + Drizzle schema in `packages/db`.
- **API:** Hono routes in `apps/zync-api`; Zod validation; `requireTier('business')` + `requirePermission` middleware.
- **Cron:** Cloudflare Cron Trigger declared in `apps/zync-api/wrangler.toml`, dispatched from the Worker `scheduled` handler; also exposed as `CRON_SECRET`-guarded `POST /api/cron/ticket-sla-check` per the cron convention.
- **UI:** `apps/zync-app` (Vite+React) — SLA badge component + ticket-list SLA column + "breached only" filter chip, using `@zync/ui` primitives (`Badge`). i18n keys in `packages/ui/src/locales/{en,he}.json`.
- **Bindings:** `HYPERDRIVE`/`DB` (Postgres), `QUEUE` (none new), `RATE_LIMITER_WEBHOOK` (n/a here). Secret `CRON_SECRET`, `RESEND_API_KEY` (email via `sendEmail`).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema | 1, 2 | `packages/db/src/schema/sla.ts`, `packages/db/migrations/*`, schema index | No (1 blocks all) |
| B — data layer | 3, 4 | `packages/db/src/queries/sla.ts` | After A; 3 then 4 |
| C — API | 5, 6 | `apps/zync-api/src/routes/settings-sla.ts`, ticket list/reply route edits | After B |
| D — cron | 7 | `apps/zync-api/src/cron/ticket-sla-check.ts`, `wrangler.toml`, scheduled handler | After B (parallel with C) |
| E — UI | 8, 9, 10 | `apps/zync-app/src/.../SlaBadge.tsx`, ticket detail, ticket list, locales | After C |
| F — wiring | 11 | Business+ upgrade seed hook | After B |

## Tasks

### Task 1: `sla_policies` table + `tickets` SLA columns (migration + Drizzle schema)
**Blocks:** 2, 3, 4, 5, 6, 7, 11  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/migrations/00XX_ticket_sla.sql`
- Create: `packages/db/src/schema/sla.ts`
- Modify: `packages/db/src/schema/index.ts` (export `slaPolicies`)
- Modify: `packages/db/src/schema/tickets.ts` (add the three columns to the existing Drizzle `tickets` table definition)
- Modify: `packages/db/src/schema/tenant-settings.ts` (add `sla_enabled` to the existing `tenant_settings` Drizzle table owned by `foundation-auth-rbac`)
**Steps:**
- [ ] Write the SQL migration creating `sla_policies` (with `escalation_email`, `notify_email`, and `notify_in_app` included from the start — not later ALTERs), the three `tickets` ALTERs, and the one `tenant_settings` boolean ALTER (`sla_enabled`).
- [ ] Add an index on `tickets(tenant_id, due_at)` to keep the cron `UPDATE` range-scan O(breached) not O(all).
- [ ] Add a partial/normal index supporting the breach predicate; add `UNIQUE (tenant_id, priority)` on `sla_policies`.
- [ ] Mirror the table in Drizzle (`packages/db/src/schema/sla.ts`) and add the three columns to the existing `tickets` Drizzle table.
**Schema / Interfaces:**
```sql
-- ALTER the upstream tickets table (defined in crm-support-center) — never recreate it.
ALTER TABLE tickets ADD COLUMN due_at TIMESTAMPTZ;
ALTER TABLE tickets ADD COLUMN first_response_at TIMESTAMPTZ;
ALTER TABLE tickets ADD COLUMN sla_breached BOOLEAN NOT NULL DEFAULT false;

CREATE INDEX idx_tickets_sla_due ON tickets (tenant_id, due_at)
  WHERE sla_breached = false;

-- Tenant SLA master gate on the foundation-owned tenant_settings table (idempotent; base not re-created).
ALTER TABLE tenant_settings ADD COLUMN IF NOT EXISTS sla_enabled BOOLEAN NOT NULL DEFAULT false;

CREATE TABLE sla_policies (
  id                    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id             UUID NOT NULL REFERENCES tenants(id),
  priority              TEXT NOT NULL CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
  first_response_hours  INTEGER NOT NULL,
  resolution_hours      INTEGER NOT NULL,
  escalation_email      TEXT,
  notify_email          BOOLEAN NOT NULL DEFAULT true,
  notify_in_app         BOOLEAN NOT NULL DEFAULT true,
  created_at            TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at            TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, priority)
);
CREATE INDEX idx_sla_policies_tenant ON sla_policies (tenant_id);
```
```ts
// packages/db/src/schema/sla.ts (Drizzle, pgTable)
export const slaPolicies = pgTable('sla_policies', {
  id: uuid('id').primaryKey().defaultRandom(),
  tenantId: uuid('tenant_id').notNull().references(() => tenants.id),
  priority: text('priority').notNull(), // CHECK low|medium|high|urgent
  firstResponseHours: integer('first_response_hours').notNull(),
  resolutionHours: integer('resolution_hours').notNull(),
  escalationEmail: text('escalation_email'),
  notifyEmail: boolean('notify_email').notNull().default(true),
  notifyInApp: boolean('notify_in_app').notNull().default(true),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({ tenantPriorityUq: unique().on(t.tenantId, t.priority) }));
// tickets table (existing) gains:
//   dueAt: timestamp('due_at', { withTimezone: true })
//   firstResponseAt: timestamp('first_response_at', { withTimezone: true })
//   slaBreached: boolean('sla_breached').notNull().default(false)
```
**Acceptance:**
- [ ] `pnpm drizzle-kit generate` produces no drift; migration applies cleanly to a Neon branch.
- [ ] `sla_policies` rejects a duplicate (tenant_id, priority) and a priority outside the enum.
- [ ] `tickets` has `due_at`, `first_response_at`, `sla_breached`; `idx_tickets_sla_due` exists.
- [ ] `tenant_settings` has the `sla_enabled` BOOLEAN column (idempotent ALTER; base table not re-created); `sla_policies` carries `notify_email` / `notify_in_app` (BOOLEAN NOT NULL DEFAULT true).

### Task 2: Default SLA targets constant + tenant settings keys
**Blocks:** 3, 11  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/queries/sla.ts` (constant block; queries added in Task 3)
- Modify: `packages/types/src/index.ts` (export `SLA_DEFAULTS`, `SlaPolicy`, `SlaPriority`, `SlaBadgeState`)
**Steps:**
- [ ] Define `SLA_DEFAULTS` from the spec's default table (urgent 1/4, high 4/24, medium 8/72, low 24/168 hours).
- [ ] This spec owns one `tenant_settings` column: `sla_enabled BOOLEAN NOT NULL DEFAULT false` (Business+ gate; SLA computed only when true), added in Task 1 via `ALTER TABLE tenant_settings ADD COLUMN IF NOT EXISTS`. Per-policy breach-notification prefs live on `sla_policies` (`notify_email` / `notify_in_app`), not `tenant_settings`. Do NOT re-create the `tenant_settings` base table (owned by foundation-auth-rbac); do NOT add `ticket_auto_close_days` (owned by crm-support-center).
- [ ] Export `SlaPriority = 'low' | 'medium' | 'high' | 'urgent'` and `SlaBadgeState`.
**Schema / Interfaces:**
```ts
export type SlaPriority = 'low' | 'medium' | 'high' | 'urgent';

export const SLA_DEFAULTS: Record<SlaPriority, { firstResponseHours: number; resolutionHours: number }> = {
  urgent: { firstResponseHours: 1,  resolutionHours: 4 },
  high:   { firstResponseHours: 4,  resolutionHours: 24 },
  medium: { firstResponseHours: 8,  resolutionHours: 72 },
  low:    { firstResponseHours: 24, resolutionHours: 168 },
};

export interface SlaPolicy {
  id: string; tenantId: string; priority: SlaPriority;
  firstResponseHours: number; resolutionHours: number;
  escalationEmail: string | null; notifyEmail: boolean; notifyInApp: boolean;
  createdAt: string; updatedAt: string;
}

// tenant_settings column owned by this spec (read/written via a tenantQuery domain helper):
//   sla_enabled BOOLEAN — master gate, Business+ only
// (per-policy breach-notification prefs notify_email / notify_in_app live on sla_policies)
export type SlaBadgeState =
  | { kind: 'on_track'; dueAt: string }
  | { kind: 'at_risk'; dueAt: string; minutesRemaining: number }   // within 25% of deadline
  | { kind: 'breached'; dueAt: string; minutesOverdue: number }
  | { kind: 'resolved_within_sla' }
  | { kind: 'resolved_breached' }
  | { kind: 'none' };                                              // sla_enabled = false / no policy
```
**Acceptance:**
- [ ] `SLA_DEFAULTS` matches the spec's four rows exactly.
- [ ] Types exported from `@zync/types`.

### Task 3: SLA query layer — policies, due_at compute, badge state, seeding
**Blocks:** 5, 6, 7, 8, 11  ·  **Blocked by:** 1, 2
**Files:**
- Modify: `packages/db/src/queries/sla.ts`
- Modify: `packages/db/src/queries/index.ts` (export the new functions)
**Steps:**
- [ ] `getSlaPolicies(db, tenantId)` — all rows for tenant ordered urgent→low. Uses `tenantQuery`.
- [ ] `getSlaPolicy(db, tenantId, priority)` — single row or null.
- [ ] `updateSlaPolicy(db, tenantId, policyId, patch)` — `tenantQuery`-scoped UPDATE of one policy row by `id` (returns null / 404 if not in tenant); sets any of `first_response_hours`, `resolution_hours`, `escalation_email`, `notify_email`, `notify_in_app` provided; `updated_at = now()`. Returns the updated row.
- [ ] `seedSlaPolicies(db, tenantId)` — insert the four `SLA_DEFAULTS` rows for a tenant if none exist (idempotent `ON CONFLICT (tenant_id, priority) DO NOTHING`).
- [ ] `computeDueAt(createdAt, resolutionHours)` — pure helper returning `createdAt + resolutionHours*3600s`.
- [ ] `applyTicketSla(tx, tenantId, ticketId, priority, createdAt)` — looks up policy, computes `due_at`, updates the ticket row; called on ticket create and on priority change.
- [ ] `computeSlaBadgeState(ticket, policy, now)` — returns `SlaBadgeState`: resolved/closed → `resolved_within_sla` or `resolved_breached` (by `resolved_at` vs `due_at`); else `breached` if `now > due_at`; `at_risk` if remaining ≤ 25% of total window; else `on_track`. `none` when policy missing or `sla_enabled` false.
- [ ] `findFirstResponseDeadline(createdAt, firstResponseHours)` — helper for the reply hook.
- [ ] `getSlaEnabled(db, tenantId)` — read `sla_enabled` from `tenant_settings` via `tenantQuery` (domain helper, NOT the AI-config `getTenantSettings`); defaults `false` if the row is unset. `setSlaEnabled(db, tenantId, enabled)` — `UPDATE tenant_settings SET sla_enabled = :enabled, updated_at = now() WHERE tenant_id = :tenantId` via `tenantQuery` (never `upsertTenantSettings`).
**Schema / Interfaces:**
```ts
export function computeDueAt(createdAt: Date, resolutionHours: number): Date;
export async function getSlaEnabled(db: Db, tenantId: string): Promise<boolean>;
export async function setSlaEnabled(db: Db, tenantId: string, enabled: boolean): Promise<void>;
export async function getSlaPolicies(db: Db, tenantId: string): Promise<SlaPolicy[]>;
export async function getSlaPolicy(db: Db, tenantId: string, priority: SlaPriority): Promise<SlaPolicy | null>;
export async function updateSlaPolicy(db: Db, tenantId: string, policyId: string,
  patch: { firstResponseHours?: number; resolutionHours?: number; escalationEmail?: string | null; notifyEmail?: boolean; notifyInApp?: boolean }): Promise<SlaPolicy | null>;
export async function seedSlaPolicies(db: Db, tenantId: string): Promise<void>;
export async function applyTicketSla(tx: Db, tenantId: string, ticketId: string, priority: SlaPriority, createdAt: Date): Promise<void>;
export function computeSlaBadgeState(
  ticket: { dueAt: string | null; slaBreached: boolean; status: string; resolvedAt: string | null; firstResponseAt: string | null },
  policy: SlaPolicy | null, now: Date, slaEnabled: boolean): SlaBadgeState;
```
**Acceptance:**
- [ ] `computeDueAt(t, 4)` returns `t + 4h`.
- [ ] `seedSlaPolicies` is idempotent (second call inserts nothing).
- [ ] `computeSlaBadgeState` returns `at_risk` at exactly 25% remaining, `breached` after `due_at`, `none` when `slaEnabled` is false.

### Task 4: `applyTicketSla` integration into ticket create + priority change
**Blocks:** 7, 8  ·  **Blocked by:** 3
**Files:**
- Modify: `apps/zync-api/src/routes/tickets.ts` (the existing `POST /api/tickets` and `PATCH /api/tickets/:id` handlers from crm-support-center)
**Steps:**
- [ ] In `POST /api/tickets`: after insert, if `await getSlaEnabled(tx, tenantId)`, call `applyTicketSla(tx, tenantId, ticket.id, priority, ticket.createdAt)` inside the same transaction.
- [ ] In `PATCH /api/tickets/:id`: when `priority` changes and `sla_enabled`, recompute `due_at` via `applyTicketSla` and reset `sla_breached = false` (re-evaluate against new policy). Keep this inside `require-audit-in-transaction`.
- [ ] No-op cleanly when `sla_enabled` is false (leave `due_at` NULL).
**Acceptance:**
- [ ] Creating a HIGH ticket on an SLA-enabled tenant stamps `due_at = created_at + 24h`.
- [ ] Changing priority urgent→low recomputes `due_at` and clears a prior `sla_breached`.
- [ ] On an SLA-disabled tenant, `due_at` stays NULL.

### Task 5: `GET /api/settings/sla` + `PATCH /api/settings/sla/:policyId` (policy API)
**Blocks:** 8  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/settings-sla.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount router)
**Steps:**
- [ ] `GET /api/settings/sla` — `authMiddleware` + `requireTier('business')` + `requirePermission('settings:modules:read')` (OWNER/ADMIN). Return `{ policies: SlaPolicy[], slaEnabled }` (policies ordered urgent→low, each incl `notifyEmail` / `notifyInApp`). If no policies exist, return `SLA_DEFAULTS` projected (un-persisted) so the config UI shows defaults. This is the sole declaration of the SLA settings route; `sla-config-ui` (spec 145) consumes it (does not redeclare it).
- [ ] `PATCH /api/settings/sla/:policyId` — `requireTier('business')` + OWNER only (`requirePermission('settings:modules:write')`). Zod-validate the per-policy patch, call `updateSlaPolicy(db, tenantId, policyId, patch)`; 404 if the policy is not in the tenant. Return the updated policy.
- [ ] Validate `first_response_hours` / `resolution_hours` are non-negative integers (0 = no target); `escalation_email` optional valid email or null; `notify_email` / `notify_in_app` optional booleans.
- [ ] `sla_enabled` is not toggled here (auto-set on Business+ upgrade by Task 11; gated by `requireTier`/cron); `GET` returns it for the UI's read-only "Active" indicator.
- [ ] Use `require-zod-validation-in-routes` and `no-raw-drizzle-from-routes` (go through query layer).
**Schema / Interfaces:**
```ts
// Zod (per-policy patch)
const patchSlaPolicySchema = z.object({
  firstResponseHours: z.number().int().min(0).max(8760).optional(),
  resolutionHours:    z.number().int().min(0).max(8760).optional(),
  escalationEmail:    z.string().email().nullish(),
  notifyEmail:        z.boolean().optional(),
  notifyInApp:        z.boolean().optional(),
}).strict()
 .refine(b => b.resolutionHours == null || b.firstResponseHours == null || b.resolutionHours >= b.firstResponseHours,
  { message: 'resolution_hours must be >= first_response_hours' });
// Routes (exported names): GET /api/settings/sla, PATCH /api/settings/sla/:policyId
```
**Acceptance:**
- [ ] `GET` on a Freelancer-tier tenant returns 402/403 (tier gate).
- [ ] `PATCH /api/settings/sla/:policyId` by ADMIN (non-OWNER) is rejected; by OWNER succeeds and persists the policy's targets + notify prefs; a cross-tenant `policyId` → 404.
- [ ] Invalid email or negative hours → 400 with Zod error.

### Task 6: Extend `GET /api/tickets` with `?sla_breached=true` + SLA fields in serializer
**Blocks:** 9  ·  **Blocked by:** 3
**Files:**
- Modify: `apps/zync-api/src/routes/tickets.ts` (existing list handler)
- Modify: `apps/zync-api/src/serializers/ticket.ts` (or inline serializer)
**Steps:**
- [ ] Add optional `sla_breached` query param (Zod `z.coerce.boolean().optional()`); when `true`, add `WHERE sla_breached = true` to the list query.
- [ ] Include `due_at`, `first_response_at`, `sla_breached`, and a computed `slaBadge` (`SlaBadgeState`) in each serialized ticket. Compute `slaBadge` using `computeSlaBadgeState` + the tenant's policy map + `sla_enabled`.
- [ ] Preserve existing pagination via `buildPaginated`.
**Acceptance:**
- [ ] `GET /api/tickets?sla_breached=true` returns only breached tickets.
- [ ] Each ticket payload carries `slaBadge` reflecting its current state.

### Task 7: Cron `ticket-sla-check` — breach detection + escalation notifications
**Blocks:** —  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/cron/ticket-sla-check.ts`
- Modify: `apps/zync-api/src/index.ts` (`scheduled` handler dispatch + `POST /api/cron/ticket-sla-check` route guarded by `CRON_SECRET`)
- Modify: `apps/zync-api/wrangler.toml` (`[triggers] crons = ["*/15 * * * *", ...]`)
**Steps:**
- [ ] Register the `*/15 * * * *` cron trigger; route it in the Worker `scheduled` handler to `runTicketSlaCheck(env)`.
- [ ] Also expose `POST /api/cron/ticket-sla-check` validating the `CRON_SECRET` header with `timingSafeEqual` (no string `===` on the secret — `no-string-equality-for-tokens`).
- [ ] Run the breach `UPDATE ... RETURNING` (only tenants with policies; only non-resolved/closed, not-yet-breached, past-due tickets).
- [ ] For each returned ticket: look up the matching `sla_policies` row (by `tenant_id` + `priority`). If its `notify_in_app` is true, resolve assignee + the tenant's OWNER/ADMIN user ids (via `tenant_memberships` + `roles`) and emit in-app `ticket_sla_breached` notifications through `createNotification` using `title_key`/`body_key`/`params` (never pre-rendered text).
- [ ] If the policy's `notify_email` is true and its `escalation_email` is set, send the escalation email via `deliverNotification`/`sendEmail` (locale-resolved per the notifications spec: tenant default locale; RTL `dir="rtl"` for `he`). Skip silently if `notify_email` is false or `escalation_email` is null.
- [ ] Use `systemQuery` (cron has no tenant session) but scope every statement by `tenant_id`.
- [ ] The notification `type` string `ticket_sla_breached` is emitted only — the `NotificationType` union is owned downstream by `notification-preferences`; do not redefine it here.
**Schema / Interfaces:**
```sql
-- Breach detection (cron), per spec:
UPDATE tickets SET sla_breached = true
WHERE status NOT IN ('resolved', 'closed')
  AND sla_breached = false
  AND due_at < now()
  AND tenant_id IN (SELECT DISTINCT tenant_id FROM sla_policies)
RETURNING id, tenant_id, assignee_id, priority, customer_id;
```
```ts
export async function runTicketSlaCheck(env: Env): Promise<{ breached: number }>;
// In-app notification payload per breached ticket:
//   createNotification(db, { tenantId, userId, type: 'ticket_sla_breached',
//     titleKey: 'notification.ticket_sla_breached.title',
//     bodyKey:  'notification.ticket_sla_breached.body',
//     params: { ticketTitle, priority }, entityType: 'ticket', entityId: ticketId })
```
**Acceptance:**
- [ ] Cron flips `sla_breached` true exactly once per ticket (re-run does not re-notify — guarded by `sla_breached = false` predicate).
- [ ] Assignee + OWNER/ADMIN receive an in-app notification per newly-breached ticket.
- [ ] With the policy's `notify_email = true` and a set `escalation_email`, an escalation email is sent; with `notify_email` false or email null, none is sent. In-app notifications are emitted only when the policy's `notify_in_app` is true.
- [ ] `POST /api/cron/ticket-sla-check` with a wrong secret returns 401 and does nothing (timing-safe comparison).

### Task 8: First-response tracking in the reply handler
**Blocks:** —  ·  **Blocked by:** 3, 4
**Files:**
- Modify: `apps/zync-api/src/routes/tickets.ts` (existing `POST /api/tickets/:id/reply` from crm-support-center)
**Steps:**
- [ ] After persisting a staff reply (`author_type = 'staff'`), if `ticket.first_response_at` is null and `sla_enabled`, set `first_response_at = now()` in the same transaction.
- [ ] Compute the first-response deadline (`created_at + first_response_hours*3600s`); if `now() > deadline`, emit an in-app `ticket_first_response_breached` notification to assignee + OWNER/ADMIN via `createNotification` (in-app only — NOT a re-escalation, no email).
- [ ] Do nothing for customer/system replies, or when `first_response_at` is already set.
**Acceptance:**
- [ ] First staff reply stamps `first_response_at`; subsequent replies do not change it.
- [ ] A late first response logs `ticket_first_response_breached` in-app only (no email).
- [ ] Customer reply never stamps `first_response_at`.

### Task 9: SLA badge component + ticket-detail SLA panel
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-app/src/features/support/SlaBadge.tsx`
- Modify: `apps/zync-app/src/features/support/TicketDetail.tsx`
- Modify: `packages/ui/src/locales/en.json`, `packages/ui/src/locales/he.json`
**Steps:**
- [ ] `SlaBadge` renders from `SlaBadgeState` using `@zync/ui` `Badge`: `on_track`/`at_risk` (green/warning, "⚡ Nh remaining"), `breached` (red, "⏰ Overdue by Nh"), `resolved_within_sla` ("✅ Resolved within SLA"), `resolved_breached`, `none` (render nothing).
- [ ] In `TicketDetail`, show the SLA line: "Due by {dueAt} [badge]" and a "First response: ✅ Responded (within SLA) / late / pending" row from `first_response_at` vs deadline.
- [ ] a11y: badge conveys state via text + `aria-label` (not color alone); respect `prefers-reduced-motion` (no pulsing animation on the ⚡ icon when reduced).
- [ ] RTL: badge text/icon order must mirror under `dir="rtl"`; use logical properties. Hebrew strings in `he.json`.
- [ ] All copy via i18n keys — no hardcoded strings, no hardcoded colors (use design tokens / `Badge` variants).
**Acceptance:**
- [ ] Badge shows correct variant for each `SlaBadgeState`.
- [ ] `aria-label` present; no color-only signalling (axe passes).
- [ ] Renders correctly under `dir="rtl"` with Hebrew labels; no animation under reduced-motion.

### Task 10: Ticket-list SLA column + "breached only" filter chip
**Blocks:** —  ·  **Blocked by:** 6, 9
**Files:**
- Modify: `apps/zync-app/src/features/support/TicketList.tsx`
- Modify: `apps/zync-app/src/features/support/TicketFilters.tsx`
**Steps:**
- [ ] Add an "SLA status" column rendering `SlaBadge` per row (compact variant).
- [ ] Add a "SLA breached only" filter chip that sets `?sla_breached=true` in the URL-synced filter state and refetches.
- [ ] Hide the column + chip entirely when the tenant's `sla_enabled` is false.
- [ ] Keep column sortable/consistent with existing list a11y (`scope="col"`, etc.).
**Acceptance:**
- [ ] SLA column appears only when `sla_enabled`.
- [ ] Toggling "breached only" updates the URL and shows only breached tickets.

### Task 11: Seed default SLA policies on Business+ upgrade
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Modify: the tenant tier-change handler (where `syncTierToTenant` is invoked — `apps/zync-api` subscription/tier flow)
**Steps:**
- [ ] When a tenant transitions to Business or above, call `seedSlaPolicies(db, tenantId)` and default `sla_enabled = true` via `setSlaEnabled(db, tenantId, true)` if not already set (writes the `tenant_settings` column).
- [ ] On downgrade below Business, leave existing `sla_policies` rows intact but `sla_enabled` is effectively gated by `requireTier` at the API/cron layer (cron only processes tenants whose policies exist AND `sla_enabled`).
- [ ] Idempotent: re-upgrading does not duplicate policies (relies on `seedSlaPolicies` `ON CONFLICT DO NOTHING`).
**Acceptance:**
- [ ] Upgrading a tenant to Business seeds four `sla_policies` rows with `SLA_DEFAULTS` and sets `sla_enabled = true`.
- [ ] Re-running the upgrade path inserts no duplicates.

## Cross-Cutting Compliance
- **Security:** `CRON_SECRET` compared with `timingSafeEqual` (never `===`); all SLA APIs behind `authMiddleware` + `requireTier('business')` + `requirePermission`; PUT restricted to OWNER. Escalation emails use the existing `sendEmail` pipeline (no new secret).
- **a11y:** SLA badges convey state via text + `aria-label`, never color alone; reduced-motion respected.
- **i18n/RTL:** in-app notifications use `title_key`/`body_key`/`params` (rendered in viewer locale); escalation emails locale-resolved with `dir="rtl"` for Hebrew; all UI copy via i18n keys with Hebrew translations.
- **Performance:** `due_at` indexed (`idx_tickets_sla_due`); cron uses one `UPDATE ... RETURNING` (O(breached)), not per-ticket timers.
