{
  "slug": "crm-support-center",
  "spec_file": "docs/specs/2026-05-30-crm-support-center.md",
  "findings": [
    {
      "id": "crm-support-center-001",
      "severity": "P0",
      "type": "broken",
      "summary": "Inbound createSupportTicket seam is a throwing stub — email/Telegram/WhatsApp never create tickets",
      "spec_ref": "Inbound Channels; Plan Task 9 — createSupportTicket wired from comms.inbound queue",
      "code_ref": "apps/zync-api/src/queues/comms-inbound.ts:47",
      "evidence": "createSupportTicketStub throws Error('createSupportTicket not wired'); grep createSupportTicket across apps/ finds only stub at comms-inbound.ts:47 and DI call at :85. No apps/zync-api/src/services/create-support-ticket.ts.",
      "repro": "Enable tenant settings autoCreateTickets → POST Telegram webhook → queue consumer retries forever; no ticket row inserted."
    },
    {
      "id": "crm-support-center-002",
      "severity": "P0",
      "type": "broken",
      "summary": "Staff reply persists message only — no source-aware outbound routing to email/Telegram/WhatsApp/portal",
      "spec_ref": "POST /api/tickets/:id/reply — routes reply back to originating channel; Plan Task 8",
      "code_ref": "apps/zync-api/src/routes/support/index.ts:217",
      "evidence": "Reply handler ends with NOTE comment about future queue wiring; no sendEmail, Telegram sendMessage, createNotification, or routeReplyToChannel call. grep routeReplyToChannel returns zero matches in repo.",
      "repro": "POST /api/tickets/:id/reply on source=email ticket → message saved; customer receives no email."
    },
    {
      "id": "crm-support-center-003",
      "severity": "P0",
      "type": "security",
      "summary": "createTicket accepts foreign customer_id/contact_id/assignee_id/category_id without tenant ownership checks",
      "spec_ref": "Architecture — tenant_id row isolation; POST /api/tickets",
      "code_ref": "packages/db/src/queries/support.ts:284",
      "evidence": "createTicket inserts customerId/contactId/categoryId/assigneeId from input with only tickets.tenantId=session tenant; no SELECT verifying referenced rows belong to tenantId. FKs are id-only (customers.id, ticket_categories.id, users.id).",
      "repro": "Session tenant B → POST /api/tickets with customer_id UUID from tenant A → 201 ticket row with mismatched tenant_id/customer_id."
    },
    {
      "id": "crm-support-center-004",
      "severity": "P0",
      "type": "security",
      "summary": "updateTicket patch assignee_id/category_id lacks tenant ownership validation",
      "spec_ref": "PATCH /api/tickets/:id — assignee/category updates tenant-scoped",
      "code_ref": "packages/db/src/queries/support.ts:319",
      "evidence": "updateTicket sets assigneeId/categoryId directly from patch; no membership or category tenant lookup before UPDATE.",
      "repro": "PATCH /api/tickets/:id { assignee_id: <user uuid from another tenant> } → 200 with cross-tenant assignee reference."
    },
    {
      "id": "crm-support-center-005",
      "severity": "P0",
      "type": "security",
      "summary": "DELETE message endpoint soft-deletes any message without author ownership check",
      "spec_ref": "DELETE /api/tickets/:id/messages/:mid — soft delete own message; system messages not deletable",
      "code_ref": "apps/zync-api/src/routes/support/index.ts:226",
      "evidence": "Handler calls softDeleteTicketMessage(db, tid, ticketId, mid) with no session.sub vs author_id check and no author_type guard.",
      "repro": "Staff user A DELETE /api/tickets/:id/messages/:mid for message authored by user B → 200 { ok: true }."
    },
    {
      "id": "crm-support-center-006",
      "severity": "P0",
      "type": "security",
      "summary": "Ticket message create/reply stores raw content with no server-side HTML allowlist sanitization",
      "spec_ref": "ticket_messages content HTML sanitized allowlist; Reply — sanitize content; Plan Task 5/8",
      "code_ref": "apps/zync-api/src/routes/support/index.ts:205",
      "evidence": "grep sanitize in apps/zync-api/src/routes/support/ returns zero matches. task-messages route uses sanitizeCommentHtml at task-messages.ts:149; ticket reply passes parsed.data.content directly to createTicketMessage.",
      "repro": "POST /api/tickets/:id/reply { content: '<script>alert(1)</script>' } → stored verbatim in ticket_messages.content."
    },
    {
      "id": "crm-support-center-007",
      "severity": "P0",
      "type": "broken",
      "summary": "Support list row navigation targets /support/:id but routes live under /crm/support/:id",
      "spec_ref": "Support Center Screen / Ticket Detail routes",
      "code_ref": "apps/zync-app/src/pages/support/SupportPage.tsx:236",
      "evidence": "onClick navigates `/support/${ticket.id}`; module routes at apps/zync-app/src/modules/crm.tsx:19-33 mount only crm/support and crm/support/:id. grep path support in main.tsx finds no top-level /support route.",
      "repro": "Open /crm/support → click ticket row → browser goes to /support/:id → 404/blank outside CRM module."
    },
    {
      "id": "crm-support-center-008",
      "severity": "P1",
      "type": "missing",
      "summary": "tickets:delete permission not seeded — DELETE /api/tickets/:id always 403 for every role",
      "spec_ref": "Permissions — Delete ticket tickets:delete; Plan Task 2",
      "code_ref": "packages/db/src/seed/permission-keys.ts:26",
      "evidence": "PERMISSION_KEYS lists tickets:read, tickets:write, tickets:assign, tickets:resolve — no tickets:delete. Route requires tickets:delete at support/index.ts:160.",
      "repro": "OWNER session DELETE /api/tickets/:id → 403 Forbidden."
    },
    {
      "id": "crm-support-center-009",
      "severity": "P1",
      "type": "missing",
      "summary": "Kanban board view with drag-between-columns not implemented",
      "spec_ref": "Support Center — Board view default; Plan Task 12",
      "code_ref": "apps/zync-app/src/pages/support/SupportPage.tsx:108",
      "evidence": "SupportPage renders flat TicketRow list inside Card; grep @dnd-kit in apps/zync-app/src/pages/support returns zero matches. tasks KanbanBoard exists at features/tasks/views/KanbanBoard.tsx only.",
      "repro": "Visit /crm/support — no status columns, no drag PATCH on status change."
    },
    {
      "id": "crm-support-center-010",
      "severity": "P1",
      "type": "missing",
      "summary": "Status lifecycle helpers and system transition messages absent",
      "spec_ref": "Ticket Status Lifecycle — system messages on each transition; Plan Task 5 applyStatusTransition",
      "code_ref": "packages/db/src/queries/support.ts:322",
      "evidence": "grep applyStatusTransition|appendSystemMessage|ticket-lifecycle across repo returns zero implementation files. updateTicket sets status/resolvedAt/closedAt only; no ticket_messages author_type=system insert.",
      "repro": "PATCH /api/tickets/:id { status: 'resolved' } → no system message in GET /api/tickets/:id/messages."
    },
    {
      "id": "crm-support-center-011",
      "severity": "P1",
      "type": "missing",
      "summary": "autoReopenOnCustomerReply not implemented for pending_customer tickets",
      "spec_ref": "Ticket Status Lifecycle — auto-reopen to in_progress when customer replies",
      "code_ref": "packages/db/src/queries/support.ts:252",
      "evidence": "grep autoReopenOnCustomerReply returns zero matches. findTicketByThread exists but has zero callers outside support.ts.",
      "repro": "Customer reply on pending_customer ticket (once inbound wired) would stay pending_customer — no reopen logic."
    },
    {
      "id": "crm-support-center-012",
      "severity": "P1",
      "type": "missing",
      "summary": "Outbound webhook events ticket.created/replied/resolved never emitted from ticket routes",
      "spec_ref": "Webhooks — ticket.created, ticket.replied, ticket.resolved; Plan Task 17",
      "code_ref": "apps/zync-api/src/routes/support/index.ts:117",
      "evidence": "grep 'ticket.created'|'ticket.replied'|'ticket.resolved' in apps/zync-api/src/routes/support returns zero. No ticket-events.ts service file. Marketing/invoices routes enqueue webhook.deliver; support routes do not.",
      "repro": "POST /api/tickets or PATCH status→resolved → no webhook.deliver queue message."
    },
    {
      "id": "crm-support-center-013",
      "severity": "P1",
      "type": "missing",
      "summary": "TenantRealtime DO broadcast not wired on ticket create/update/reply",
      "spec_ref": "Plan Task 17 — DO_REALTIME broadcast ticket.*; Board/detail live updates",
      "code_ref": "apps/zync-api/src/realtime/broadcast.ts:20",
      "evidence": "broadcast.ts exists but grep ticket in apps/zync-api/src/realtime returns zero. Support routes never import broadcast helper. TicketDetailPage/SupportPage do not use useRealtimeEvent.",
      "repro": "Two browsers on /crm/support/:id — reply in one; other shows stale messages until manual refresh."
    },
    {
      "id": "crm-support-center-014",
      "severity": "P1",
      "type": "missing",
      "summary": "Portal ticket detail and customer reply routes/UI missing",
      "spec_ref": "Customer-Facing View — list, detail with correspondence, plain-text reply",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:177",
      "evidence": "Portal router exposes GET/POST /tickets only; no GET /tickets/:id or POST reply. apps/zync-app portal/tickets.tsx lists tickets with no row navigation or detail page. grep PortalTicketDetail returns zero files.",
      "repro": "Portal customer creates ticket → cannot open detail or reply from portal UI."
    },
    {
      "id": "crm-support-center-015",
      "severity": "P1",
      "type": "missing",
      "summary": "Email inbound ticket creation path not implemented",
      "spec_ref": "Inbound Channels — Email routed via Cloudflare Email Routing → Queue → ticket",
      "code_ref": "apps/zync-api/src/queues/comms-inbound.ts:47",
      "evidence": "grep email webhook inbound ticket in apps/zync-api finds intake/email-expense.ts (expenses source=email) only. comms-inbound queue handles telegram/slack/whatsapp types; createSupportTicket stub covers all autoCreateTickets paths including email once wired.",
      "repro": "Inbound support email → no ticket row; expense email handler unrelated."
    },
    {
      "id": "crm-support-center-016",
      "severity": "P1",
      "type": "missing",
      "summary": "tenants.ticket_auto_close_days column absent — cron uses hardcoded 7 days globally",
      "spec_ref": "Auto-close cron — tenant_settings.ticket_auto_close_days default 7; Plan Task 1",
      "code_ref": "apps/zync-api/src/routes/cron/ticket-close-stale.ts:33",
      "evidence": "grep ticket_auto_close in packages/db/src/schema/tenants.ts returns zero. closeStaleResolvedTickets(db, 7) called with literal 7; no per-tenant read.",
      "repro": "Tenant with 14-day reopen window still auto-closes at 7 days."
    },
    {
      "id": "crm-support-center-017",
      "severity": "P1",
      "type": "missing",
      "summary": "Start timer popover on ticket detail not integrated",
      "spec_ref": "Ticket Detail — Start timer → POST /api/time/start source=auto; Plan Task 14",
      "code_ref": "apps/zync-app/src/pages/support/TicketDetailPage.tsx:132",
      "evidence": "TicketDetailPage sidebar has status/priority/source/created only; grep StartTimer in pages/support returns zero. StartTimerPopover exists at components/timer/StartTimerPopover.tsx but unused in support pages.",
      "repro": "Open /crm/support/:id — no Start timer control."
    },
    {
      "id": "crm-support-center-018",
      "severity": "P1",
      "type": "missing",
      "summary": "Nav link /crm has no index route — support center unreachable from sidebar default target",
      "spec_ref": "Support Center Screen /support",
      "code_ref": "apps/zync-app/src/shell/nav-model.ts:126",
      "evidence": "nav-model to='/crm'; crm module routes only path support and support/:id (crm.tsx:19-33) with no index redirect to support.",
      "repro": "Click Support Center in sidebar → /crm renders empty module shell."
    },
    {
      "id": "crm-support-center-019",
      "severity": "P1",
      "type": "missing",
      "summary": "TicketStatus union not exported from @zync/db as locked name",
      "spec_ref": "Plan Task 1 — export TicketStatus from @zync/db",
      "code_ref": "packages/db/src/queries/index.ts:490",
      "evidence": "queries/index exports ticket helpers but grep 'export type TicketStatus' in packages/db returns zero. packages/types/src/enums.ts:28 still TicketStatus = string placeholder.",
      "repro": "import { TicketStatus } from '@zync/db' — type not exported."
    },
    {
      "id": "crm-support-center-020",
      "severity": "P2",
      "type": "divergent",
      "summary": "Staff-created tickets use source=web default instead of spec manual",
      "spec_ref": "POST /api/tickets — create staff source=manual; Data Model source CHECK includes manual",
      "code_ref": "packages/db/src/validation/support.ts:34",
      "evidence": "createTicketSchema source default 'web'; schema CHECK at support.ts:112 allows web|email|telegram|whatsapp|portal — no manual value.",
      "repro": "POST /api/tickets from NewTicketDialog → ticket.source='web'."
    },
    {
      "id": "crm-support-center-021",
      "severity": "P2",
      "type": "divergent",
      "summary": "Category stored as category_id FK instead of spec free-text category column",
      "spec_ref": "Data Model tickets.category TEXT; ticket_categories table",
      "code_ref": "packages/db/src/schema/support.ts:75",
      "evidence": "tickets table has categoryId uuid FK to ticket_categories; spec DDL shows category TEXT on tickets plus separate ticket_categories for list management.",
      "repro": "Schema differs from spec DDL; delete category relies on FK set null not name-based unassign."
    },
    {
      "id": "crm-support-center-022",
      "severity": "P2",
      "type": "divergent",
      "summary": "List view is simple card rows — not DataTable with required sortable columns",
      "spec_ref": "List view — Table Title/Customer/Priority/Category/Assignee/Status/Created/Last reply sortable",
      "code_ref": "apps/zync-app/src/pages/support/SupportPage.tsx:52",
      "evidence": "TicketRow shows title, priority badge, status label, source, created date only — no DataTable, no customer/assignee/category/last-reply columns, no sort params to API.",
      "repro": "Open /crm/support — flat list, cannot sort by assignee or last reply."
    },
    {
      "id": "crm-support-center-023",
      "severity": "P2",
      "type": "divergent",
      "summary": "URL-synced filters incomplete — missing assignee, category, source, date range, customer",
      "spec_ref": "Filters URL-synced — priority, category, assignee, status, source, date range, customer",
      "code_ref": "apps/zync-app/src/pages/support/SupportPage.tsx:119",
      "evidence": "SupportPage syncs status, priority, q only. ticketFiltersSchema supports assignee_id/category_id/customer_id/source but UI never sets them.",
      "repro": "No URL params for assignee_id or dateFrom/dateTo; listTickets has no date range filter in TicketFilters interface."
    },
    {
      "id": "crm-support-center-024",
      "severity": "P2",
      "type": "divergent",
      "summary": "Staff UI mounted at /crm/support not spec path /support",
      "spec_ref": "Support Center Screen (/support); Ticket Detail (/support/:id)",
      "code_ref": "apps/zync-app/src/modules/crm.tsx:19",
      "evidence": "moduleRoutes path crm/* with nested support routes; spec documents /support and /support/:id.",
      "repro": "Spec URL /support → actual route /crm/support."
    },
    {
      "id": "crm-support-center-025",
      "severity": "P2",
      "type": "divergent",
      "summary": "Portal paths use /portal/:tenantId/tickets not spec /portal/:tenantSlug/support",
      "spec_ref": "Customer-Facing View /portal/:tenantSlug/support",
      "code_ref": "apps/zync-app/src/main.tsx:80",
      "evidence": "Route portal/:tenantId/tickets; spec uses tenantSlug segment and /support path with /support/new form route.",
      "repro": "Spec URL /portal/acme/support → app route /portal/<uuid>/tickets."
    },
    {
      "id": "crm-support-center-026",
      "severity": "P2",
      "type": "divergent",
      "summary": "List API uses cursor pagination not spec buildPaginated offset pagination",
      "spec_ref": "GET /api/tickets — filterable, paginated; Plan Task 3 listTickets buildPaginated",
      "code_ref": "packages/db/src/queries/support.ts:171",
      "evidence": "listTickets returns { rows, nextCursor } with encodeTicketCursor; no page/limit offset PaginatedResponse shape.",
      "repro": "GET /api/tickets returns nextCursor not total/page metadata."
    },
    {
      "id": "crm-support-center-027",
      "severity": "P2",
      "type": "divergent",
      "summary": "Auto-close cron closes resolved tickets without system transition messages",
      "spec_ref": "Auto-close cron — writing standard system transition message; Plan Task 11",
      "code_ref": "packages/db/src/queries/support.ts:523",
      "evidence": "closeStaleResolvedTickets bulk UPDATE status=closed only; no createTicketMessage author_type=system calls.",
      "repro": "Run POST /api/cron/ticket-close-stale → tickets closed with no audit message in correspondence."
    },
    {
      "id": "crm-support-center-028",
      "severity": "P2",
      "type": "divergent",
      "summary": "Staff reply auto-sets pending_customer contradicting spec/plan explicit status control",
      "spec_ref": "Plan Task 8 — staff reply keeps pending_customer only when staff sets via PATCH",
      "code_ref": "apps/zync-api/src/routes/support/index.ts:213",
      "evidence": "Reply handler updateTicket status pending_customer when ticket open or in_progress; spec lifecycle sets pending_customer when waiting on customer after staff reply but plan says staff explicit PATCH controls it.",
      "repro": "Reply on in_progress ticket → status forced pending_customer without separate PATCH."
    },
    {
      "id": "crm-support-center-029",
      "severity": "P2",
      "type": "divergent",
      "summary": "GET ticket detail omits joined customer/contact/assignee display fields",
      "spec_ref": "getTicketWithMessages — joined customer/contact/assignee; Ticket info sidebar",
      "code_ref": "packages/db/src/queries/support.ts:236",
      "evidence": "getTicket selects tickets.* only; no join to customers/users. TicketDetailPage shows source/created but no customer name, assignee, or category labels.",
      "repro": "GET /api/tickets/:id returns ticket.customer_id UUID only; UI never shows customer name chip."
    },
    {
      "id": "crm-support-center-030",
      "severity": "P2",
      "type": "divergent",
      "summary": "New ticket form missing customer select/create and assignee fields",
      "spec_ref": "New Ticket sheet — customer, assignee; Plan Task 15",
      "code_ref": "apps/zync-app/src/pages/support/NewTicketDialog.tsx:47",
      "evidence": "createTicket.mutate sends title/description/priority/category_id only; Dialog not Sheet; no customer_id or assignee_id inputs.",
      "repro": "Open New Ticket dialog — cannot link customer or assign staff."
    },
    {
      "id": "crm-support-center-031",
      "severity": "P2",
      "type": "divergent",
      "summary": "MEMBER system role lacks tickets:read/write contrary to plan default grants",
      "spec_ref": "Plan Task 2 — staff/member read+write; Permissions table",
      "code_ref": "packages/db/src/seed/permission-keys.ts:71",
      "evidence": "SYSTEM_ROLE_PERMISSIONS.MEMBER = tasks/projects/time/kb only; no tickets:* keys. VIEWER gets all :read including tickets:read.",
      "repro": "MEMBER role user → 403 on GET /api/tickets despite plan granting tickets:read."
    },
    {
      "id": "crm-support-center-032",
      "severity": "P2",
      "type": "divergent",
      "summary": "Support ticket routes skip requireModuleEnabled('crm') guard used by other modules",
      "spec_ref": "Plan Task 6 — requireModuleEnabled('support') module guard",
      "code_ref": "apps/zync-api/src/routes/support/router.ts:16",
      "evidence": "supportRoutes.use only authMiddleware; projects/time/tasks routes call requireModuleEnabled. grep requireModuleEnabled in routes/support returns zero.",
      "repro": "Disable crm module for tenant → /api/tickets still accessible with permission."
    },
    {
      "id": "crm-support-center-033",
      "severity": "P2",
      "type": "divergent",
      "summary": "WhatsApp webhook tier gate returns 403 not spec 402 for non-Enterprise",
      "spec_ref": "WhatsApp Enterprise — stub returns 402 on lower tiers",
      "code_ref": "apps/zync-api/src/routes/webhooks/whatsapp.ts:84",
      "evidence": "meetsMinimumTier check returns c.json(..., 403) with message Enterprise tier required.",
      "repro": "Non-Enterprise tenant WhatsApp POST → HTTP 403 not 402."
    },
    {
      "id": "crm-support-center-034",
      "severity": "P2",
      "type": "divergent",
      "summary": "createPortalTicket inserts ticket row only — no initial ticket_messages correspondence row",
      "spec_ref": "Portal new ticket — description in correspondence stream",
      "code_ref": "packages/db/src/queries/customer-portal.ts:261",
      "evidence": "createPortalTicket insert into tickets only; description stored on ticket row, no createTicketMessage for customer first message.",
      "repro": "POST /api/portal/tickets → GET messages empty though description provided."
    },
    {
      "id": "crm-support-center-035",
      "severity": "P2",
      "type": "divergent",
      "summary": "ticket.resolved missing from public webhook event catalog",
      "spec_ref": "Webhooks — ticket.resolved payload",
      "code_ref": "packages/public-api/src/routes/webhooks.ts:39",
      "evidence": "WEBHOOK_EVENT_CATALOG includes ticket.created and ticket.replied only; ticket.resolved absent.",
      "repro": "Subscribe webhook to ticket.resolved via public API → 422 validation_error."
    },
    {
      "id": "crm-support-center-036",
      "severity": "P3",
      "type": "divergent",
      "summary": "Ticket cards omit spec customer chip, assignee avatar, age, and source icons",
      "spec_ref": "Board card — customer name + company chip, assignee avatar, age, source icon",
      "code_ref": "apps/zync-app/src/pages/support/SupportPage.tsx:52",
      "evidence": "TicketRow renders priority, title, status text, optional source text, created date — no Avatar, Badge customer, relative age, or channel icons.",
      "repro": ""
    },
    {
      "id": "crm-support-center-037",
      "severity": "P3",
      "type": "divergent",
      "summary": "Ticket detail layout places correspondence on right vs spec left column",
      "spec_ref": "Ticket Detail layout wireframe — Correspondence left, Ticket info right",
      "code_ref": "apps/zync-app/src/pages/support/TicketDetailPage.tsx:197",
      "evidence": "Two-column Stack: left Card is metadata sidebar (w-56); right Stack is messages + reply — inverted from spec diagram.",
      "repro": ""
    },
    {
      "id": "crm-support-center-038",
      "severity": "P3",
      "type": "divergent",
      "summary": "empty-state-catalog links to nonexistent /support/new route",
      "spec_ref": "Portal /portal/:tenantSlug/support/new; staff new ticket flow",
      "code_ref": "apps/zync-app/src/lib/empty-state-catalog.ts:91",
      "evidence": "action href: '/support/new' — no matching route in main.tsx or crm module.",
      "repro": ""
    }
  ],
  "summary": {
    "P0": 7,
    "P1": 12,
    "P2": 16,
    "P3": 3,
    "total": 38
  }
}
