diff --git a/CLAUDE.md b/CLAUDE.md
index 7b32ed1f8..3ae10d480 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,6 +9,7 @@ bun run setup # One-time setup (deps, Docker, migrations, seed)
bun run dev # Dev server at localhost:3000 (login: demo@example.com / password)
bun run build && bun run db:generate && bun run db:migrate
bun run test && bun run test:e2e && bun run lint && bun run typecheck
+bun apps/web/scripts/backfill-ticket-contacts.ts [--dry-run] # One-shot: link existing portal users to contacts and backfill tickets.requesterContactId
```
## Rules
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6280dc0dd..e3111fe1c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -87,6 +87,22 @@ const post = await db.query.posts.findFirst({
- Single workspace, `DATABASE_URL` singleton
+### Authorization
+
+Quackback has two independent authorization systems serving different product domains:
+
+| System | Import | Domain |
+| ---------- | ---------------------------- | --------------------------------------------------------------- |
+| **Policy** | `@/lib/server/policy` | Feedback portal (boards, posts, comments, chat) |
+| **Authz** | `@/lib/server/domains/authz` | Ticketing & workspace admin (tickets, teams, inboxes, SLA, CRM) |
+
+**Which one should I use?**
+
+- Adding a board/post/comment/chat feature → use `policy`
+- Adding a ticket/inbox/team/contact/SLA feature → use `authz`
+
+The two systems are independent and will be unified in a future iteration.
+
## Development Guidelines
### Code Style
diff --git a/apps/web/e2e/tests/admin/tickets.spec.ts b/apps/web/e2e/tests/admin/tickets.spec.ts
new file mode 100644
index 000000000..06b7794d6
--- /dev/null
+++ b/apps/web/e2e/tests/admin/tickets.spec.ts
@@ -0,0 +1,164 @@
+import { test, expect } from '@playwright/test'
+
+const uniqueId = Date.now()
+
+test.describe('Admin Tickets', () => {
+ test.describe.configure({ mode: 'serial' })
+
+ let ticketSubject: string
+
+ test('can navigate to tickets section', async ({ page }) => {
+ await page.goto('/admin')
+ await page.waitForLoadState('networkidle')
+
+ // Sidebar should have a Tickets link
+ const ticketsLink = page.getByRole('link', { name: /tickets/i })
+ await expect(ticketsLink).toBeVisible({ timeout: 10000 })
+
+ await ticketsLink.click()
+ await page.waitForURL('**/admin/tickets**')
+ await expect(page).toHaveURL(/\/admin\/tickets/)
+ })
+
+ test('can create a new ticket', async ({ page }) => {
+ await page.goto('/admin/tickets/new')
+ await page.waitForLoadState('networkidle')
+
+ ticketSubject = `E2E Ticket ${uniqueId}`
+
+ // Fill the subject field
+ const subjectInput = page.locator('#subject')
+ await expect(subjectInput).toBeVisible({ timeout: 10000 })
+ await subjectInput.fill(ticketSubject)
+
+ // Fill the description (TipTap rich text editor)
+ const editor = page.locator('.tiptap').first()
+ await editor.click()
+ await page.keyboard.type('This is an automated E2E test ticket description.')
+
+ // Submit the form
+ await page.getByRole('button', { name: /create/i }).click()
+
+ // Should navigate to the ticket detail page
+ await page.waitForURL('**/admin/tickets/ticket_**', { timeout: 15000 })
+ await expect(page).toHaveURL(/\/admin\/tickets\/ticket_/)
+ })
+
+ test('can view ticket detail', async ({ page }) => {
+ // Navigate to tickets list
+ await page.goto('/admin/tickets')
+ await page.waitForLoadState('networkidle')
+
+ // Find and click our ticket in the queue
+ const ticketRow = page.getByText(ticketSubject)
+ await expect(ticketRow).toBeVisible({ timeout: 10000 })
+ await ticketRow.click()
+
+ // Should navigate to detail page
+ await page.waitForURL('**/admin/tickets/ticket_**')
+
+ // Verify subject is visible in the properties panel
+ await expect(page.getByText(ticketSubject)).toBeVisible()
+
+ // Verify the properties panel renders key sections
+ await expect(page.getByText('Status')).toBeVisible()
+ await expect(page.getByText('Priority')).toBeVisible()
+ })
+
+ test('can add a public reply', async ({ page }) => {
+ // Navigate to tickets list then open our ticket
+ await page.goto('/admin/tickets')
+ await page.waitForLoadState('networkidle')
+ await page.getByText(ticketSubject).click()
+ await page.waitForURL('**/admin/tickets/ticket_**')
+
+ // Find the thread composer (placeholder "Reply to customer…")
+ const composer = page.locator('.tiptap').last()
+ await expect(composer).toBeVisible({ timeout: 10000 })
+ await composer.click()
+ await page.keyboard.type('This is a public reply from E2E test.')
+
+ // Click Post button
+ await page.getByRole('button', { name: /post/i }).click()
+
+ // The reply should appear in the thread timeline
+ await expect(page.getByText('This is a public reply from E2E test.')).toBeVisible({
+ timeout: 10000,
+ })
+ })
+
+ test('can change ticket status', async ({ page }) => {
+ await page.goto('/admin/tickets')
+ await page.waitForLoadState('networkidle')
+ await page.getByText(ticketSubject).click()
+ await page.waitForURL('**/admin/tickets/ticket_**')
+
+ // Find Status section and its picker button
+ const statusSection = page.locator('text=Status').locator('..')
+ const statusButton = statusSection.locator('button').first()
+ await expect(statusButton).toBeVisible({ timeout: 10000 })
+ await statusButton.click()
+
+ // Select "Solved" from the dropdown
+ const solvedOption = page.getByRole('option', { name: /solved/i }).or(page.getByText('Solved'))
+ await solvedOption.first().click()
+
+ // Wait for the mutation to complete
+ await page.waitForLoadState('networkidle')
+
+ // Verify the status changed (status badge should show "Solved")
+ await expect(page.getByText('Solved').first()).toBeVisible({ timeout: 10000 })
+ })
+
+ test('can change ticket priority', async ({ page }) => {
+ await page.goto('/admin/tickets')
+ await page.waitForLoadState('networkidle')
+ await page.getByText(ticketSubject).click()
+ await page.waitForURL('**/admin/tickets/ticket_**')
+
+ // Find Priority section and its picker
+ const prioritySection = page.locator('text=Priority').locator('..')
+ const priorityButton = prioritySection.locator('button').first()
+ await expect(priorityButton).toBeVisible({ timeout: 10000 })
+ await priorityButton.click()
+
+ // Select "High" from the dropdown
+ const highOption = page.getByRole('option', { name: /high/i }).or(page.getByText('high'))
+ await highOption.first().click()
+
+ await page.waitForLoadState('networkidle')
+
+ // Verify priority changed
+ await expect(page.getByText(/high/i).first()).toBeVisible({ timeout: 10000 })
+ })
+
+ test('can edit ticket subject', async ({ page }) => {
+ await page.goto('/admin/tickets')
+ await page.waitForLoadState('networkidle')
+ await page.getByText(ticketSubject).click()
+ await page.waitForURL('**/admin/tickets/ticket_**')
+
+ // Find the Subject section in properties panel and click it to start editing
+ const subjectSection = page.locator('text=Subject').locator('..')
+ await subjectSection.click()
+
+ // The input should appear
+ const subjectInput = subjectSection.locator('input')
+ await expect(subjectInput).toBeVisible({ timeout: 5000 })
+
+ // Clear and type new subject
+ const newSubject = `Updated E2E Ticket ${uniqueId}`
+ await subjectInput.clear()
+ await subjectInput.fill(newSubject)
+ await subjectInput.press('Enter')
+
+ // Wait for save
+ await page.waitForLoadState('networkidle')
+
+ // Verify subject updated (it should appear in the header)
+ await expect(page.getByText(newSubject).first()).toBeVisible({ timeout: 10000 })
+
+ // Update for subsequent tests
+ ticketSubject = newSubject
+ })
+})
diff --git a/apps/web/package.json b/apps/web/package.json
index 86bc9fb31..5326ddd94 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -61,27 +61,28 @@
"@tanstack/react-start": "^1.168.25",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.26",
- "@tiptap/core": "^3.22.3",
- "@tiptap/extension-bubble-menu": "^3.22.3",
- "@tiptap/extension-code-block-lowlight": "^3.22.3",
- "@tiptap/extension-emoji": "3.22.3",
- "@tiptap/extension-image": "^3.22.3",
- "@tiptap/extension-link": "^3.22.3",
- "@tiptap/extension-mention": "3.22.3",
- "@tiptap/extension-placeholder": "^3.22.3",
- "@tiptap/extension-table": "^3.22.3",
- "@tiptap/extension-table-cell": "^3.22.3",
- "@tiptap/extension-table-header": "^3.22.3",
- "@tiptap/extension-table-row": "^3.22.3",
- "@tiptap/extension-task-item": "^3.22.3",
- "@tiptap/extension-task-list": "^3.22.3",
- "@tiptap/extension-underline": "^3.22.3",
- "@tiptap/extension-youtube": "^3.22.3",
- "@tiptap/markdown": "^3.22.3",
- "@tiptap/pm": "^3.22.3",
- "@tiptap/react": "^3.22.3",
- "@tiptap/starter-kit": "^3.22.3",
- "@tiptap/suggestion": "^3.22.3",
+ "@tanstack/start": "^1.120.20",
+ "@tiptap/core": "3.23.4",
+ "@tiptap/extension-bubble-menu": "3.23.4",
+ "@tiptap/extension-code-block-lowlight": "3.23.4",
+ "@tiptap/extension-emoji": "3.23.4",
+ "@tiptap/extension-image": "3.23.4",
+ "@tiptap/extension-link": "3.23.4",
+ "@tiptap/extension-mention": "3.23.4",
+ "@tiptap/extension-placeholder": "3.23.4",
+ "@tiptap/extension-table": "3.23.4",
+ "@tiptap/extension-table-cell": "3.23.4",
+ "@tiptap/extension-table-header": "3.23.4",
+ "@tiptap/extension-table-row": "3.23.4",
+ "@tiptap/extension-task-item": "3.23.4",
+ "@tiptap/extension-task-list": "3.23.4",
+ "@tiptap/extension-underline": "3.23.4",
+ "@tiptap/extension-youtube": "3.23.4",
+ "@tiptap/markdown": "3.23.4",
+ "@tiptap/pm": "3.23.4",
+ "@tiptap/react": "3.23.4",
+ "@tiptap/starter-kit": "3.23.4",
+ "@tiptap/suggestion": "3.23.4",
"bcryptjs": "^3.0.3",
"better-auth": "^1.6.16",
"bullmq": "^5.74.1",
diff --git a/apps/web/scripts/backfill-github-ticket-comments.ts b/apps/web/scripts/backfill-github-ticket-comments.ts
new file mode 100644
index 000000000..35fe872c2
--- /dev/null
+++ b/apps/web/scripts/backfill-github-ticket-comments.ts
@@ -0,0 +1,580 @@
+#!/usr/bin/env bun
+/**
+ * Backfill two-way GitHub issue comment sync for linked tickets.
+ *
+ * Usage:
+ * bun apps/web/scripts/backfill-github-ticket-comments.ts --dry-run
+ * bun apps/web/scripts/backfill-github-ticket-comments.ts --integration-id=integration_...
+ * bun apps/web/scripts/backfill-github-ticket-comments.ts --ticket-id=ticket_...
+ * bun apps/web/scripts/backfill-github-ticket-comments.ts --direction=github-to-quackback
+ * bun apps/web/scripts/backfill-github-ticket-comments.ts --direction=quackback-to-github
+ * bun apps/web/scripts/backfill-github-ticket-comments.ts --since=2026-06-01T00:00:00Z
+ *
+ * Environment:
+ * DATABASE_URL — Required. PostgreSQL connection string.
+ */
+
+try {
+ const { config } = await import('dotenv')
+ config({ path: '.env', quiet: true })
+} catch {
+ // dotenv not available; rely on environment variables.
+}
+
+import {
+ db,
+ eq,
+ and,
+ integrations,
+ ticketExternalLinks,
+ ticketThreadExternalLinks,
+ ticketThreads,
+} from '@/lib/server/db'
+import type { IntegrationId, PrincipalId, TicketId, TicketThreadId } from '@quackback/ids'
+import { decryptSecrets } from '@/lib/server/integrations/encryption'
+import type { GitHubIntegrationConfig } from '@/lib/server/integrations/github/types'
+import {
+ buildInboundTicketThreadBody,
+ buildOutboundGitHubCommentBody,
+ createGitHubIssueComment,
+ deleteGitHubIssueComment,
+ findThreadLinkByExternalComment,
+ findThreadLinkByThread,
+ getThreadAuthorName,
+ listGitHubIssueComments,
+ loadThreadForSync,
+ markThreadLinkDeleted,
+ parseQuackbackThreadMarker,
+ upsertThreadExternalLink,
+ type GitHubIssueComment,
+} from '@/lib/server/integrations/github/ticket-comments'
+import {
+ addThread,
+ editThread,
+ listPublicThreadsForTicket,
+} from '@/lib/server/domains/tickets/ticket.threads'
+
+type Direction = 'both' | 'github-to-quackback' | 'quackback-to-github'
+
+interface Flags {
+ dryRun: boolean
+ integrationId: string | null
+ ticketId: string | null
+ direction: Direction
+ since: string | null
+}
+
+interface Counters {
+ created: number
+ updated: number
+ deleted: number
+ linked: number
+ skipped: number
+}
+
+interface GitHubIntegrationRow {
+ id: IntegrationId
+ principalId: PrincipalId | null
+ config: GitHubIntegrationConfig
+ accessToken: string
+}
+
+interface TicketIssueLink {
+ ticketId: TicketId
+ externalId: string
+ externalUrl: string | null
+}
+
+function printUsage(): void {
+ console.log(`Backfill GitHub ticket comment sync.
+
+Usage:
+ bun apps/web/scripts/backfill-github-ticket-comments.ts [flags]
+
+Flags:
+ --dry-run Preview without writing.
+ --integration-id=ID Limit to one GitHub integration.
+ --ticket-id=ID Limit to one linked ticket.
+ --direction=DIR both | github-to-quackback | quackback-to-github (default both).
+ --since=ISO_TIMESTAMP Only fetch GitHub comments updated since this timestamp.
+ --help Show this message.
+
+Notes:
+ Deletes are only reconciled when --since is omitted, because GitHub's list endpoint
+ cannot report deleted comments inside a since window.
+`)
+}
+
+function parseFlags(argv: string[]): Flags {
+ const direction = readArg(argv, '--direction') ?? 'both'
+ if (!['both', 'github-to-quackback', 'quackback-to-github'].includes(direction)) {
+ throw new Error('--direction must be both, github-to-quackback, or quackback-to-github')
+ }
+ const since = readArg(argv, '--since')
+ if (since && Number.isNaN(Date.parse(since))) {
+ throw new Error('--since must be an ISO timestamp')
+ }
+ return {
+ dryRun: argv.includes('--dry-run'),
+ integrationId: readArg(argv, '--integration-id'),
+ ticketId: readArg(argv, '--ticket-id'),
+ direction: direction as Direction,
+ since: since ? new Date(since).toISOString() : null,
+ }
+}
+
+function readArg(argv: string[], name: string): string | null {
+ const prefix = `${name}=`
+ const value = argv.find((arg) => arg.startsWith(prefix))
+ return value ? value.slice(prefix.length) : null
+}
+
+async function loadIntegrations(flags: Flags): Promise {
+ const conditions = [eq(integrations.integrationType, 'github'), eq(integrations.status, 'active')]
+ if (flags.integrationId) {
+ conditions.push(eq(integrations.id, flags.integrationId as IntegrationId))
+ }
+
+ const rows = await db.query.integrations.findMany({
+ where: and(...conditions),
+ })
+
+ return rows.flatMap((row) => {
+ const config = (row.config ?? {}) as GitHubIntegrationConfig
+ const ownerRepo = config.channelId
+ if (!ownerRepo) {
+ console.warn(`[github-comments-backfill] skip integration ${row.id}: missing channelId`)
+ return []
+ }
+ if (!row.secrets) {
+ console.warn(`[github-comments-backfill] skip integration ${row.id}: missing secrets`)
+ return []
+ }
+ const secrets = decryptSecrets<{ accessToken?: string }>(row.secrets)
+ if (!secrets.accessToken) {
+ console.warn(`[github-comments-backfill] skip integration ${row.id}: missing access token`)
+ return []
+ }
+ return [
+ {
+ id: row.id as IntegrationId,
+ principalId: (row.principalId as PrincipalId | null) ?? null,
+ config,
+ accessToken: secrets.accessToken,
+ },
+ ]
+ })
+}
+
+async function loadIssueLinks(
+ integration: GitHubIntegrationRow,
+ flags: Flags
+): Promise {
+ const conditions = [
+ eq(ticketExternalLinks.integrationId, integration.id),
+ eq(ticketExternalLinks.status, 'active'),
+ ]
+ if (flags.ticketId) {
+ conditions.push(eq(ticketExternalLinks.ticketId, flags.ticketId as TicketId))
+ }
+ return db
+ .select({
+ ticketId: ticketExternalLinks.ticketId,
+ externalId: ticketExternalLinks.externalId,
+ externalUrl: ticketExternalLinks.externalUrl,
+ })
+ .from(ticketExternalLinks)
+ .where(and(...conditions))
+}
+
+async function backfillGitHubToQuackback(
+ integration: GitHubIntegrationRow,
+ issueLink: TicketIssueLink,
+ flags: Flags,
+ counters: Counters
+): Promise {
+ const comments = await listGitHubIssueComments({
+ ownerRepo: integration.config.channelId,
+ issueNumber: issueLink.externalId,
+ accessToken: integration.accessToken,
+ since: flags.since ?? undefined,
+ })
+ const remoteIds = new Set(comments.map((comment) => String(comment.id)))
+
+ for (const comment of comments) {
+ const externalCommentId = String(comment.id)
+ const marker = parseQuackbackThreadMarker(comment.body)
+ if (marker) {
+ if (marker.integrationId === integration.id) {
+ await maybeLinkMarker(integration, issueLink, comment, marker, flags, counters)
+ } else {
+ counters.skipped++
+ }
+ continue
+ }
+
+ if (!commentBody(comment)) {
+ counters.skipped++
+ continue
+ }
+
+ const link = await findThreadLinkByExternalComment({
+ integrationId: integration.id,
+ externalCommentId,
+ })
+ const nextBody = buildInboundTicketThreadBody(comment)
+
+ if (link?.status === 'active') {
+ const thread = await loadThreadForSync(link.threadId)
+ if (!thread || thread.deletedAt) {
+ await createInboundThread(integration, issueLink, comment, flags, counters)
+ continue
+ }
+ if (thread.bodyText !== nextBody) {
+ if (flags.dryRun) {
+ console.log(
+ `[dry-run] update ticket thread ${thread.id} from GitHub comment ${externalCommentId}`
+ )
+ } else {
+ await editThread({
+ threadId: thread.id,
+ actorPrincipalId: (thread.principalId as PrincipalId | null) ?? integration.principalId,
+ bodyText: nextBody,
+ syncSourceIntegrationId: integration.id,
+ })
+ await upsertThreadExternalLink({
+ ticketId: issueLink.ticketId,
+ threadId: thread.id,
+ integrationId: integration.id,
+ externalIssueId: issueLink.externalId,
+ externalCommentId,
+ externalUrl: comment.html_url ?? null,
+ syncDirection: 'inbound',
+ })
+ }
+ counters.updated++
+ } else {
+ counters.skipped++
+ }
+ continue
+ }
+
+ await createInboundThread(integration, issueLink, comment, flags, counters)
+ }
+
+ if (!flags.since) {
+ await deleteMissingInboundThreads(integration, issueLink, remoteIds, flags, counters)
+ }
+}
+
+async function backfillQuackbackToGitHub(
+ integration: GitHubIntegrationRow,
+ issueLink: TicketIssueLink,
+ flags: Flags,
+ counters: Counters
+): Promise {
+ const comments = await listGitHubIssueComments({
+ ownerRepo: integration.config.channelId,
+ issueNumber: issueLink.externalId,
+ accessToken: integration.accessToken,
+ })
+ const markerByThreadId = new Map()
+ for (const comment of comments) {
+ const marker = parseQuackbackThreadMarker(comment.body)
+ if (marker?.integrationId === integration.id && marker.ticketId === issueLink.ticketId) {
+ markerByThreadId.set(marker.threadId, comment)
+ }
+ }
+
+ const publicThreads = await listPublicThreadsForTicket(issueLink.ticketId)
+ const sinceMs = flags.since ? Date.parse(flags.since) : null
+
+ for (const thread of publicThreads) {
+ if (
+ sinceMs &&
+ thread.createdAt.getTime() < sinceMs &&
+ (thread.editedAt?.getTime() ?? 0) < sinceMs
+ ) {
+ counters.skipped++
+ continue
+ }
+
+ const link = await findThreadLinkByThread({
+ integrationId: integration.id,
+ threadId: thread.id,
+ })
+ if (link?.status === 'active') {
+ counters.skipped++
+ continue
+ }
+ if (link?.status === 'deleted') {
+ counters.skipped++
+ continue
+ }
+
+ const markerComment = markerByThreadId.get(thread.id)
+ if (markerComment) {
+ if (flags.dryRun) {
+ console.log(
+ `[dry-run] link existing GitHub comment ${markerComment.id} to ticket thread ${thread.id}`
+ )
+ } else {
+ await upsertThreadExternalLink({
+ ticketId: issueLink.ticketId,
+ threadId: thread.id,
+ integrationId: integration.id,
+ externalIssueId: issueLink.externalId,
+ externalCommentId: String(markerComment.id),
+ externalUrl: markerComment.html_url ?? null,
+ syncDirection: 'outbound',
+ })
+ }
+ counters.linked++
+ continue
+ }
+
+ const authorName = await getThreadAuthorName(thread.principalId)
+ const body = buildOutboundGitHubCommentBody({
+ ticketId: issueLink.ticketId,
+ threadId: thread.id,
+ integrationId: integration.id,
+ bodyText: thread.bodyText,
+ authorName,
+ isFromRequester: false,
+ })
+
+ if (flags.dryRun) {
+ console.log(
+ `[dry-run] create GitHub comment for ticket ${issueLink.ticketId} thread ${thread.id}`
+ )
+ } else {
+ const comment = await createGitHubIssueComment({
+ ownerRepo: integration.config.channelId,
+ issueNumber: issueLink.externalId,
+ accessToken: integration.accessToken,
+ body,
+ })
+ await upsertThreadExternalLink({
+ ticketId: issueLink.ticketId,
+ threadId: thread.id,
+ integrationId: integration.id,
+ externalIssueId: issueLink.externalId,
+ externalCommentId: comment.id,
+ externalUrl: comment.htmlUrl,
+ syncDirection: 'outbound',
+ })
+ }
+ counters.created++
+ }
+
+ if (!flags.since) {
+ await deleteRemoteCommentsForDeletedThreads(integration, issueLink, flags, counters)
+ }
+}
+
+async function maybeLinkMarker(
+ integration: GitHubIntegrationRow,
+ issueLink: TicketIssueLink,
+ comment: GitHubIssueComment,
+ marker: { ticketId: string; threadId: string; integrationId: string },
+ flags: Flags,
+ counters: Counters
+): Promise {
+ if (flags.dryRun) {
+ console.log(`[dry-run] link marker GitHub comment ${comment.id} to thread ${marker.threadId}`)
+ } else {
+ await upsertThreadExternalLink({
+ ticketId: marker.ticketId,
+ threadId: marker.threadId,
+ integrationId: integration.id,
+ externalIssueId: issueLink.externalId,
+ externalCommentId: String(comment.id),
+ externalUrl: comment.html_url ?? null,
+ syncDirection: 'outbound',
+ })
+ }
+ counters.linked++
+}
+
+async function createInboundThread(
+ integration: GitHubIntegrationRow,
+ issueLink: TicketIssueLink,
+ comment: GitHubIssueComment,
+ flags: Flags,
+ counters: Counters
+): Promise {
+ const externalCommentId = String(comment.id)
+ if (flags.dryRun) {
+ console.log(
+ `[dry-run] create public ticket thread on ${issueLink.ticketId} from GitHub comment ${externalCommentId}`
+ )
+ } else {
+ const thread = await addThread({
+ ticketId: issueLink.ticketId,
+ principalId: integration.principalId,
+ audience: 'public',
+ bodyText: buildInboundTicketThreadBody(comment),
+ syncSourceIntegrationId: integration.id,
+ })
+ await upsertThreadExternalLink({
+ ticketId: issueLink.ticketId,
+ threadId: thread.id,
+ integrationId: integration.id,
+ externalIssueId: issueLink.externalId,
+ externalCommentId,
+ externalUrl: comment.html_url ?? null,
+ syncDirection: 'inbound',
+ })
+ }
+ counters.created++
+}
+
+async function deleteMissingInboundThreads(
+ integration: GitHubIntegrationRow,
+ issueLink: TicketIssueLink,
+ remoteIds: Set,
+ flags: Flags,
+ counters: Counters
+): Promise {
+ const links = await db
+ .select({
+ threadId: ticketThreadExternalLinks.threadId,
+ externalCommentId: ticketThreadExternalLinks.externalCommentId,
+ syncDirection: ticketThreadExternalLinks.syncDirection,
+ })
+ .from(ticketThreadExternalLinks)
+ .where(
+ and(
+ eq(ticketThreadExternalLinks.integrationId, integration.id),
+ eq(ticketThreadExternalLinks.externalIssueId, issueLink.externalId),
+ eq(ticketThreadExternalLinks.status, 'active')
+ )
+ )
+
+ for (const link of links) {
+ if (link.syncDirection === 'outbound') continue
+ if (remoteIds.has(link.externalCommentId)) continue
+ if (flags.dryRun) {
+ console.log(
+ `[dry-run] delete local thread ${link.threadId} because GitHub comment ${link.externalCommentId} is gone`
+ )
+ } else {
+ await softDeleteLinkedThread(integration, link.threadId, link.externalCommentId)
+ }
+ counters.deleted++
+ }
+}
+
+async function deleteRemoteCommentsForDeletedThreads(
+ integration: GitHubIntegrationRow,
+ issueLink: TicketIssueLink,
+ flags: Flags,
+ counters: Counters
+): Promise {
+ const links = await db
+ .select({
+ threadId: ticketThreadExternalLinks.threadId,
+ externalCommentId: ticketThreadExternalLinks.externalCommentId,
+ })
+ .from(ticketThreadExternalLinks)
+ .where(
+ and(
+ eq(ticketThreadExternalLinks.integrationId, integration.id),
+ eq(ticketThreadExternalLinks.externalIssueId, issueLink.externalId),
+ eq(ticketThreadExternalLinks.status, 'active')
+ )
+ )
+
+ for (const link of links) {
+ const thread = await loadThreadForSync(link.threadId)
+ if (!thread?.deletedAt) continue
+ if (flags.dryRun) {
+ console.log(
+ `[dry-run] delete GitHub comment ${link.externalCommentId} for deleted thread ${link.threadId}`
+ )
+ } else {
+ await deleteGitHubIssueComment({
+ ownerRepo: integration.config.channelId,
+ commentId: link.externalCommentId,
+ accessToken: integration.accessToken,
+ })
+ await markThreadLinkDeleted({
+ integrationId: integration.id,
+ externalCommentId: link.externalCommentId,
+ })
+ }
+ counters.deleted++
+ }
+}
+
+async function softDeleteLinkedThread(
+ integration: GitHubIntegrationRow,
+ threadId: TicketThreadId,
+ externalCommentId: string
+): Promise {
+ const { softDeleteThread } = await import('@/lib/server/domains/tickets/ticket.threads')
+ const thread = await db.query.ticketThreads.findFirst({
+ where: eq(ticketThreads.id, threadId),
+ columns: { principalId: true, deletedAt: true },
+ })
+ if (!thread || thread.deletedAt) {
+ await markThreadLinkDeleted({ integrationId: integration.id, externalCommentId })
+ return
+ }
+ await softDeleteThread(
+ threadId,
+ (thread.principalId as PrincipalId | null) ?? integration.principalId,
+ integration.id
+ )
+ await markThreadLinkDeleted({ integrationId: integration.id, externalCommentId })
+}
+
+function commentBody(comment: GitHubIssueComment): string {
+ return (comment.body ?? '').trim()
+}
+
+async function main(): Promise {
+ const argv = process.argv.slice(2)
+ if (argv.includes('--help')) {
+ printUsage()
+ return
+ }
+ if (!process.env.DATABASE_URL) {
+ throw new Error('DATABASE_URL is required')
+ }
+
+ const flags = parseFlags(argv)
+ const counters: Counters = { created: 0, updated: 0, deleted: 0, linked: 0, skipped: 0 }
+ console.log('[github-comments-backfill] start', flags)
+
+ const integrationsToProcess = await loadIntegrations(flags)
+ if (integrationsToProcess.length === 0) {
+ console.log('[github-comments-backfill] no matching active GitHub integrations')
+ return
+ }
+
+ for (const integration of integrationsToProcess) {
+ const links = await loadIssueLinks(integration, flags)
+ console.log('[github-comments-backfill] integration', {
+ integrationId: integration.id,
+ repo: integration.config.channelId,
+ linkedTickets: links.length,
+ })
+
+ for (const issueLink of links) {
+ if (flags.direction === 'both' || flags.direction === 'github-to-quackback') {
+ await backfillGitHubToQuackback(integration, issueLink, flags, counters)
+ }
+ if (flags.direction === 'both' || flags.direction === 'quackback-to-github') {
+ await backfillQuackbackToGitHub(integration, issueLink, flags, counters)
+ }
+ }
+ }
+
+ console.log('[github-comments-backfill] summary', counters)
+}
+
+main().catch((error) => {
+ console.error('[github-comments-backfill] failed', error)
+ process.exit(1)
+})
diff --git a/apps/web/scripts/backfill-ticket-contacts.ts b/apps/web/scripts/backfill-ticket-contacts.ts
new file mode 100644
index 000000000..d457edb97
--- /dev/null
+++ b/apps/web/scripts/backfill-ticket-contacts.ts
@@ -0,0 +1,319 @@
+#!/usr/bin/env bun
+/**
+ * Backfill historical data for the portal-tickets identity model.
+ *
+ * Closes the gap left by Phases A + B for rows that already existed before
+ * those landed:
+ *
+ * Phase 1 — link existing portal users to CRM contacts via
+ * `contact_user_links`. Mirrors the better-auth
+ * `databaseHooks.user.create.after` behavior, applied
+ * retroactively. Gated on `emailVerified = true` so we only
+ * link identities the user has demonstrably proven they own.
+ *
+ * Phase 2 — populate `tickets.requesterContactId` for tickets that were
+ * filed with a `requesterPrincipalId` but no contact (the
+ * pre-Phase-B portal-creation path). Resolves principal → user
+ * → email → contact and writes the contact id back.
+ *
+ * Both phases are idempotent — re-running is a no-op for rows already in
+ * the desired state. Use `--dry-run` for a preview that reports candidate
+ * counts without writing.
+ *
+ * Usage:
+ * bun apps/web/scripts/backfill-ticket-contacts.ts # Run both phases
+ * bun apps/web/scripts/backfill-ticket-contacts.ts --dry-run # Preview
+ * bun apps/web/scripts/backfill-ticket-contacts.ts --users-only
+ * bun apps/web/scripts/backfill-ticket-contacts.ts --tickets-only
+ * bun apps/web/scripts/backfill-ticket-contacts.ts --batch-size=200
+ * bun apps/web/scripts/backfill-ticket-contacts.ts --limit=1000
+ * bun apps/web/scripts/backfill-ticket-contacts.ts --help
+ *
+ * Environment:
+ * DATABASE_URL — Required. PostgreSQL connection string.
+ */
+
+// Load .env if available — same pattern as sibling backfill scripts.
+try {
+ const { config } = await import('dotenv')
+ config({ path: '.env', quiet: true })
+} catch {
+ // dotenv not available, rely on environment variables
+}
+
+import { db, eq, and, isNull, isNotNull, gt, asc, tickets, principal, user } from '@/lib/server/db'
+import type { ContactId, PrincipalId, TicketId, UserId } from '@quackback/ids'
+import { linkContactForUser } from '@/lib/server/auth/link-contact'
+import {
+ findOrCreateByEmail,
+ linkContactToUser,
+} from '@/lib/server/domains/organizations/contact.service'
+
+// ---------------------------------------------------------------------------
+// CLI parsing
+// ---------------------------------------------------------------------------
+
+interface Flags {
+ dryRun: boolean
+ usersOnly: boolean
+ ticketsOnly: boolean
+ batchSize: number
+ limit: number | null
+}
+
+function printUsage(): void {
+ console.log(`Backfill ticket-contact identity links.
+
+Usage:
+ bun apps/web/scripts/backfill-ticket-contacts.ts [flags]
+
+Flags:
+ --dry-run Preview without writing.
+ --users-only Skip the ticket backfill phase.
+ --tickets-only Skip the user-link phase.
+ --batch-size=N Rows per batch (default 100, max 500).
+ --limit=N Cap total processed rows per phase.
+ --help Show this message.
+
+Environment:
+ DATABASE_URL Required. PostgreSQL connection string.
+`)
+}
+
+function parseFlags(argv: string[]): Flags {
+ const dryRun = argv.includes('--dry-run')
+ const usersOnly = argv.includes('--users-only')
+ const ticketsOnly = argv.includes('--tickets-only')
+ if (usersOnly && ticketsOnly) {
+ console.error(
+ '[backfill-ticket-contacts] --users-only and --tickets-only are mutually exclusive'
+ )
+ process.exit(2)
+ }
+ const batchSizeArg = argv.find((a) => a.startsWith('--batch-size='))
+ let batchSize = batchSizeArg ? parseInt(batchSizeArg.split('=')[1] ?? '', 10) : 100
+ if (!Number.isFinite(batchSize) || batchSize <= 0) batchSize = 100
+ if (batchSize > 500) batchSize = 500
+ const limitArg = argv.find((a) => a.startsWith('--limit='))
+ const limit = limitArg ? parseInt(limitArg.split('=')[1] ?? '', 10) : NaN
+ return {
+ dryRun,
+ usersOnly,
+ ticketsOnly,
+ batchSize,
+ limit: Number.isFinite(limit) && limit > 0 ? limit : null,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Phase 1 — link existing verified users to contacts
+// ---------------------------------------------------------------------------
+
+interface UserPhaseStats {
+ candidates: number
+ processed: number
+ errors: number
+}
+
+async function phase1LinkUsers(flags: Flags): Promise {
+ const stats: UserPhaseStats = { candidates: 0, processed: 0, errors: 0 }
+
+ let cursor: string | null = null
+ while (true) {
+ if (flags.limit != null && stats.candidates >= flags.limit) break
+ const remaining = flags.limit != null ? flags.limit - stats.candidates : flags.batchSize
+ const take = Math.min(flags.batchSize, remaining)
+
+ const rows = await db
+ .select({ id: user.id, email: user.email, isAnonymous: user.isAnonymous })
+ .from(user)
+ .where(
+ and(
+ isNotNull(user.email),
+ eq(user.emailVerified, true),
+ eq(user.isAnonymous, false),
+ cursor ? gt(user.id, cursor) : undefined
+ )
+ )
+ .orderBy(asc(user.id))
+ .limit(take)
+ if (rows.length === 0) break
+
+ for (const row of rows) {
+ stats.candidates += 1
+ cursor = row.id
+ if (!row.email) continue // belt-and-suspenders; filtered above
+ if (flags.dryRun) continue
+ try {
+ await linkContactForUser({
+ userId: row.id as UserId,
+ email: row.email,
+ emailVerified: true,
+ anonymous: row.isAnonymous,
+ })
+ stats.processed += 1
+ } catch (err) {
+ stats.errors += 1
+ console.error('[backfill-ticket-contacts] phase1 user error', {
+ userId: row.id,
+ error: err instanceof Error ? err.message : err,
+ })
+ }
+ }
+
+ if (rows.length < take) break
+ }
+
+ return stats
+}
+
+// ---------------------------------------------------------------------------
+// Phase 2 — backfill tickets.requesterContactId
+// ---------------------------------------------------------------------------
+
+interface TicketPhaseStats {
+ candidates: number
+ processed: number
+ missingEmail: number
+ errors: number
+}
+
+async function phase2BackfillTickets(flags: Flags): Promise {
+ const stats: TicketPhaseStats = { candidates: 0, processed: 0, missingEmail: 0, errors: 0 }
+
+ let cursor: string | null = null
+ while (true) {
+ if (flags.limit != null && stats.candidates >= flags.limit) break
+ const remaining = flags.limit != null ? flags.limit - stats.candidates : flags.batchSize
+ const take = Math.min(flags.batchSize, remaining)
+
+ const rows = await db
+ .select({
+ id: tickets.id,
+ requesterPrincipalId: tickets.requesterPrincipalId,
+ })
+ .from(tickets)
+ .where(
+ and(
+ isNotNull(tickets.requesterPrincipalId),
+ isNull(tickets.requesterContactId),
+ isNull(tickets.deletedAt),
+ cursor ? gt(tickets.id, cursor) : undefined
+ )
+ )
+ .orderBy(asc(tickets.id))
+ .limit(take)
+ if (rows.length === 0) break
+
+ for (const row of rows) {
+ stats.candidates += 1
+ cursor = row.id
+ try {
+ const contactId = await resolveContactForTicket(row.requesterPrincipalId as PrincipalId)
+ if (!contactId) {
+ stats.missingEmail += 1
+ continue
+ }
+ if (flags.dryRun) {
+ console.log(
+ `[backfill-ticket-contacts] [dry-run] would set ticket ${row.id}.requesterContactId = ${contactId}`
+ )
+ continue
+ }
+ await db
+ .update(tickets)
+ .set({ requesterContactId: contactId })
+ .where(eq(tickets.id, row.id as TicketId))
+ stats.processed += 1
+ } catch (err) {
+ stats.errors += 1
+ console.error('[backfill-ticket-contacts] phase2 ticket error', {
+ ticketId: row.id,
+ error: err instanceof Error ? err.message : err,
+ })
+ }
+ }
+
+ if (rows.length < take) break
+ }
+
+ return stats
+}
+
+/**
+ * Resolve the contact id for a ticket's requester principal.
+ *
+ * Mirrors `resolveRequesterContactId` in `ticket.service.ts` but exposed at
+ * script level so we can decide policy (skip vs. error) per row. Returns
+ * `null` when the principal isn't a user, has no email, or any link
+ * step fails — those rows are reported as `missingEmail` and skipped.
+ */
+async function resolveContactForTicket(principalId: PrincipalId): Promise {
+ const principalRow = await db.query.principal.findFirst({
+ where: eq(principal.id, principalId),
+ columns: { userId: true, type: true },
+ })
+ if (!principalRow || !principalRow.userId || principalRow.type !== 'user') return null
+ const userRow = await db.query.user.findFirst({
+ where: eq(user.id, principalRow.userId as UserId),
+ columns: { email: true },
+ })
+ if (!userRow?.email) return null
+ const contact = await findOrCreateByEmail({ email: userRow.email })
+ // Best-effort link — keeps the user/contact pair joined for future queries.
+ await linkContactToUser({
+ contactId: contact.id,
+ userId: principalRow.userId as UserId,
+ linkedByPrincipalId: null,
+ })
+ return contact.id
+}
+
+// ---------------------------------------------------------------------------
+// Entrypoint
+// ---------------------------------------------------------------------------
+
+async function main(): Promise {
+ const argv = process.argv.slice(2)
+ if (argv.includes('--help') || argv.includes('-h')) {
+ printUsage()
+ return
+ }
+ if (!process.env.DATABASE_URL) {
+ console.error('[backfill-ticket-contacts] DATABASE_URL is required')
+ process.exit(1)
+ }
+ const flags = parseFlags(argv)
+
+ console.log('[backfill-ticket-contacts] start', {
+ dryRun: flags.dryRun,
+ usersOnly: flags.usersOnly,
+ ticketsOnly: flags.ticketsOnly,
+ batchSize: flags.batchSize,
+ limit: flags.limit,
+ })
+
+ let userStats: UserPhaseStats | null = null
+ let ticketStats: TicketPhaseStats | null = null
+
+ if (!flags.ticketsOnly) {
+ console.log('[backfill-ticket-contacts] phase 1 — link users to contacts')
+ userStats = await phase1LinkUsers(flags)
+ console.log('[backfill-ticket-contacts] phase 1 done', userStats)
+ }
+ if (!flags.usersOnly) {
+ console.log('[backfill-ticket-contacts] phase 2 — backfill ticket.requesterContactId')
+ ticketStats = await phase2BackfillTickets(flags)
+ console.log('[backfill-ticket-contacts] phase 2 done', ticketStats)
+ }
+
+ console.log('[backfill-ticket-contacts] summary', {
+ users: userStats,
+ tickets: ticketStats,
+ })
+
+ const errors = (userStats?.errors ?? 0) + (ticketStats?.errors ?? 0)
+ process.exit(errors > 0 ? 1 : 0)
+}
+
+await main()
diff --git a/apps/web/src/components/admin/contacts/__tests__/contact-create-dialog.test.tsx b/apps/web/src/components/admin/contacts/__tests__/contact-create-dialog.test.tsx
new file mode 100644
index 000000000..96a279c04
--- /dev/null
+++ b/apps/web/src/components/admin/contacts/__tests__/contact-create-dialog.test.tsx
@@ -0,0 +1,202 @@
+// @vitest-environment happy-dom
+import type { ChangeEvent, ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { ContactCreateDialog } from '../contact-create-dialog'
+
+type MutationOptions = {
+ mutationFn: () => Promise
+ onSuccess?: (result: T) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ createContactFn: vi.fn(),
+ invalidateQueries: vi.fn(),
+ navigate: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useMutation: (options: MutationOptions) => ({
+ isPending: false,
+ mutate: async () => {
+ try {
+ const result = await options.mutationFn()
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ useRouter: () => ({
+ navigate: mocks.navigate,
+ }),
+}))
+
+vi.mock('@/lib/server/functions/contacts', () => ({
+ createContactFn: mocks.createContactFn,
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({
+ id,
+ type = 'text',
+ value,
+ onChange,
+ }: {
+ id?: string
+ type?: string
+ value?: string
+ onChange?: (event: ChangeEvent) => void
+ maxLength?: number
+ className?: string
+ }) => ,
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/dialog', () => ({
+ Dialog: ({
+ children,
+ }: {
+ children: ReactNode
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ }) => {children}
,
+ DialogContent: ({ children }: { children: ReactNode; className?: string }) => (
+
+ ),
+ DialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ DialogFooter: ({ children }: { children: ReactNode }) => ,
+ DialogHeader: ({ children }: { children: ReactNode }) => ,
+ DialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+ DialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}>,
+}))
+
+vi.mock('@/components/admin/shared/org-picker', () => ({
+ OrgPicker: ({
+ value,
+ onValueChange,
+ }: {
+ value: string | null
+ onValueChange: (value: string | null) => void
+ allowClear?: boolean
+ }) => (
+ ) =>
+ onValueChange(event.currentTarget.value || null)
+ }
+ >
+ No organization
+ Default org
+ Beta
+
+ ),
+}))
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.createContactFn.mockResolvedValue({ id: 'contact_created' })
+})
+
+describe('ContactCreateDialog', () => {
+ it('requires either a name or an email before creating a contact', () => {
+ render(Open} />)
+
+ fireEvent.submit(screen.getByRole('button', { name: 'Create' }).closest('form')!)
+
+ expect(mocks.toastError).toHaveBeenCalledWith('Name or email is required')
+ expect(mocks.createContactFn).not.toHaveBeenCalled()
+ })
+
+ it('submits trimmed fields, invalidates the list, resets and navigates to the contact', async () => {
+ render(
+ Open}
+ defaultOrganizationId={'org_default' as never}
+ />
+ )
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Ada Lovelace ' } })
+ fireEvent.change(screen.getByLabelText('Email'), { target: { value: ' ada@example.com ' } })
+ fireEvent.change(screen.getByLabelText('Phone'), { target: { value: ' ' } })
+ fireEvent.change(screen.getByLabelText('Title'), { target: { value: ' Engineer ' } })
+ fireEvent.change(screen.getByLabelText('Organization'), { target: { value: 'org_beta' } })
+ fireEvent.change(screen.getByLabelText('External ID'), { target: { value: ' crm-42 ' } })
+ fireEvent.submit(screen.getByRole('button', { name: 'Create' }).closest('form')!)
+
+ await waitFor(() => {
+ expect(mocks.createContactFn).toHaveBeenCalledWith({
+ data: {
+ name: 'Ada Lovelace',
+ email: 'ada@example.com',
+ phone: null,
+ title: 'Engineer',
+ externalId: 'crm-42',
+ organizationId: 'org_beta',
+ },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['contacts'] })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Contact created')
+ expect(mocks.navigate).toHaveBeenCalledWith({
+ to: '/admin/contacts/people/$contactId',
+ params: { contactId: 'contact_created' },
+ })
+ expect(screen.getByLabelText('Name')).toHaveValue('')
+ expect(screen.getByLabelText('Organization')).toHaveValue('org_default')
+ })
+
+ it('reports create failures from the server function', async () => {
+ mocks.createContactFn.mockRejectedValueOnce(new Error('Duplicate contact'))
+ render(Open} />)
+
+ fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'ada@example.com' } })
+ fireEvent.submit(screen.getByRole('button', { name: 'Create' }).closest('form')!)
+
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Duplicate contact')
+ })
+ expect(mocks.navigate).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/web/src/components/admin/contacts/__tests__/contact-linked-users-tab.test.tsx b/apps/web/src/components/admin/contacts/__tests__/contact-linked-users-tab.test.tsx
new file mode 100644
index 000000000..c25764fac
--- /dev/null
+++ b/apps/web/src/components/admin/contacts/__tests__/contact-linked-users-tab.test.tsx
@@ -0,0 +1,320 @@
+// @vitest-environment happy-dom
+import type { ChangeEvent, ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { ContactLinkedUsersTab } from '../contact-linked-users-tab'
+
+type QueryOptions = {
+ queryKey: readonly unknown[]
+ queryFn: () => T
+ staleTime?: number
+}
+
+type MutationOptions = {
+ mutationFn: (vars: TVars) => Promise
+ onSuccess?: (result: TResult) => void
+ onError?: (error: Error) => void
+}
+
+type Link = {
+ id: string
+ userId: string
+ linkedAt: string
+}
+
+type Principal = {
+ id: string
+ userId: string | null
+ displayName: string | null
+ email: string | null
+}
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ linkContactToUserFn: vi.fn(),
+ unlinkContactFromUserFn: vi.fn(),
+ searchPrincipalsFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+ permissionAllowed: true,
+ links: [] as Link[],
+ principals: [] as Principal[],
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useSuspenseQuery: () => ({
+ data: mocks.links,
+ }),
+ useQuery: (options: QueryOptions) => ({
+ data: options.queryFn(),
+ }),
+ useMutation: (options: MutationOptions) => ({
+ isPending: false,
+ mutate: async (vars: TVars) => {
+ try {
+ const result = await options.mutationFn(vars)
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@/components/admin/shared/permission-gate', () => ({
+ PermissionGate: ({
+ children,
+ fallback = null,
+ }: {
+ children: ReactNode
+ fallback?: ReactNode
+ permission: string
+ }) => (mocks.permissionAllowed ? <>{children}> : <>{fallback}>),
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ 'aria-label': ariaLabel,
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ size?: string
+ className?: string
+ 'aria-label'?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/avatar', () => ({
+ Avatar: ({ children }: { children: ReactNode; className?: string }) => {children} ,
+}))
+
+vi.mock('@/components/ui/table', () => ({
+ Table: ({ children }: { children: ReactNode }) => ,
+ TableBody: ({ children }: { children: ReactNode }) => {children} ,
+ TableCell: ({
+ children,
+ colSpan,
+ }: {
+ children?: ReactNode
+ colSpan?: number
+ className?: string
+ }) => {children} ,
+ TableHead: ({ children }: { children?: ReactNode; className?: string }) => {children} ,
+ TableHeader: ({ children }: { children: ReactNode }) => {children} ,
+ TableRow: ({ children }: { children: ReactNode }) => {children} ,
+}))
+
+vi.mock('@/components/ui/alert-dialog', () => ({
+ AlertDialog: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogAction: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
+
+ {children}
+
+ ),
+ AlertDialogCancel: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ AlertDialogContent: ({ children }: { children: ReactNode }) => ,
+ AlertDialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogFooter: ({ children }: { children: ReactNode }) => ,
+ AlertDialogHeader: ({ children }: { children: ReactNode }) => ,
+ AlertDialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+ AlertDialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}>,
+}))
+
+vi.mock('@/components/admin/shared/principal-picker', () => ({
+ PrincipalPicker: ({
+ value,
+ onValueChange,
+ excludeIds,
+ placeholder,
+ }: {
+ value: string | null
+ onValueChange: (value: string | null) => void
+ roleFilter?: string[]
+ excludeIds: string[]
+ placeholder?: string
+ }) => (
+ ) =>
+ onValueChange(event.currentTarget.value || null)
+ }
+ >
+ {placeholder ?? 'Pick user'}
+ New user
+ Principal without user
+
+ ),
+}))
+
+vi.mock('@heroicons/react/24/outline', () => ({
+ TrashIcon: () => trash ,
+}))
+
+vi.mock('@/lib/server/functions/contacts', () => ({
+ linkContactToUserFn: mocks.linkContactToUserFn,
+ unlinkContactFromUserFn: mocks.unlinkContactFromUserFn,
+}))
+
+vi.mock('@/lib/server/functions/principals', () => ({
+ searchPrincipalsFn: mocks.searchPrincipalsFn,
+}))
+
+vi.mock('@/lib/client/queries/contacts', () => ({
+ contactQueries: {
+ links: (contactId: string) => ({ queryKey: ['contacts', contactId, 'links'] }),
+ },
+}))
+
+vi.mock('@/lib/server/domains/authz', () => ({
+ PERMISSIONS: {
+ ORG_MANAGE: 'org.manage',
+ },
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.permissionAllowed = true
+ mocks.links = [
+ { id: 'link_1', userId: 'user_existing', linkedAt: '2026-06-18T12:00:00.000Z' },
+ { id: 'link_2', userId: 'user_unknown', linkedAt: '2026-06-18T13:00:00.000Z' },
+ ]
+ mocks.principals = [
+ {
+ id: 'principal_existing',
+ userId: 'user_existing',
+ displayName: 'Existing User',
+ email: 'existing@example.com',
+ },
+ {
+ id: 'principal_new',
+ userId: 'user_new',
+ displayName: 'New User',
+ email: 'new@example.com',
+ },
+ {
+ id: 'principal_without_user',
+ userId: null,
+ displayName: 'No User',
+ email: null,
+ },
+ ]
+ mocks.searchPrincipalsFn.mockImplementation(() => mocks.principals)
+ mocks.linkContactToUserFn.mockResolvedValue({ id: 'link_new' })
+ mocks.unlinkContactFromUserFn.mockResolvedValue(undefined)
+})
+
+describe('ContactLinkedUsersTab', () => {
+ it('renders linked users with principal enrichment and picker exclusions', () => {
+ render( )
+
+ expect(mocks.searchPrincipalsFn).toHaveBeenCalledWith({
+ data: { roleFilter: ['user'], limit: 50 },
+ })
+ expect(screen.getByText('Existing User')).toBeInTheDocument()
+ expect(screen.getByText('existing@example.com')).toBeInTheDocument()
+ expect(screen.getByText('user_unknown')).toBeInTheDocument()
+ expect(screen.getByLabelText('Add user')).toHaveAttribute(
+ 'data-exclude-ids',
+ 'principal_existing'
+ )
+ })
+
+ it('links a selected user and rejects principals that do not resolve to a user', async () => {
+ render( )
+
+ expect(screen.getByRole('button', { name: 'Link' })).toBeDisabled()
+ fireEvent.change(screen.getByLabelText('Add user'), {
+ target: { value: 'principal_without_user' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Link' }))
+ expect(mocks.toastError).toHaveBeenCalledWith('Selected principal has no associated user')
+ expect(mocks.linkContactToUserFn).not.toHaveBeenCalled()
+
+ fireEvent.change(screen.getByLabelText('Add user'), { target: { value: 'principal_new' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Link' }))
+
+ await waitFor(() => {
+ expect(mocks.linkContactToUserFn).toHaveBeenCalledWith({
+ data: {
+ contactId: 'contact_1',
+ userId: 'user_new',
+ },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['contacts', 'contact_1', 'links'],
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('User linked')
+ })
+
+ it('unlinks existing users and reports link/unlink failures', async () => {
+ mocks.linkContactToUserFn.mockRejectedValueOnce(new Error('Link denied'))
+ mocks.unlinkContactFromUserFn.mockRejectedValueOnce(new Error('Unlink denied'))
+
+ render( )
+
+ fireEvent.change(screen.getByLabelText('Add user'), { target: { value: 'principal_new' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Link' }))
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Link denied')
+ })
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Unlink' })[0])
+ await waitFor(() => {
+ expect(mocks.unlinkContactFromUserFn).toHaveBeenCalledWith({
+ data: {
+ contactId: 'contact_1',
+ userId: 'user_existing',
+ },
+ })
+ })
+ expect(mocks.toastError).toHaveBeenCalledWith('Unlink denied')
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Unlink' })[0])
+ await waitFor(() => {
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('User unlinked')
+ })
+ })
+
+ it('renders empty and permission-denied states without mutation controls', () => {
+ mocks.links = []
+ render( )
+
+ expect(screen.getByText('No linked users yet.')).toBeInTheDocument()
+
+ cleanup()
+ mocks.links = [{ id: 'link_1', userId: 'user_existing', linkedAt: '2026-06-18T12:00:00.000Z' }]
+ mocks.permissionAllowed = false
+ render( )
+
+ expect(screen.queryByLabelText('Add user')).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Unlink user' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Unlink' })).not.toBeInTheDocument()
+ expect(screen.getByText('Existing User')).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/admin/contacts/__tests__/contact-list-and-tabs.test.tsx b/apps/web/src/components/admin/contacts/__tests__/contact-list-and-tabs.test.tsx
new file mode 100644
index 000000000..120e3c249
--- /dev/null
+++ b/apps/web/src/components/admin/contacts/__tests__/contact-list-and-tabs.test.tsx
@@ -0,0 +1,355 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { ContactList } from '../contact-list'
+import { ContactTicketsTab } from '../contact-tickets-tab'
+import { OrganizationList } from '../organization-list'
+import { OrganizationContactsTab } from '../organization-contacts-tab'
+import { OrganizationTicketsTab } from '../organization-tickets-tab'
+
+type ContactRow = {
+ id: string
+ name: string | null
+ email: string | null
+ phone?: string | null
+ title: string | null
+ organizationId: string | null
+ archivedAt: string | null
+}
+
+type TicketRow = {
+ id: string
+ subject: string
+ priority: string
+ channel: string
+ lastActivityAt: string | null
+}
+
+type OrganizationRow = {
+ id: string
+ name: string
+ domain: string | null
+ website: string | null
+ externalId: string | null
+ archivedAt: string | null
+}
+
+const mocks = vi.hoisted(() => ({
+ searchContacts: [] as ContactRow[],
+ orgContacts: [] as ContactRow[],
+ organizations: [] as Array<{ id: string; name: string }>,
+ organizationList: [] as OrganizationRow[],
+ organizationListParams: undefined as unknown,
+ ticketRows: [] as TicketRow[],
+ listTicketsFn: vi.fn(),
+ permissionAllowed: true,
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useSuspenseQuery: (options: { queryKey?: unknown[]; queryFn?: () => { rows: TicketRow[] } }) => {
+ if (options.queryFn) return { data: options.queryFn() }
+ if (options.queryKey?.[1] === 'byOrg') return { data: mocks.orgContacts }
+ if (options.queryKey?.[0] === 'organizations') return { data: mocks.organizationList }
+ return { data: mocks.searchContacts }
+ },
+ useQuery: () => ({
+ data: mocks.organizations,
+ }),
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ Link: ({
+ children,
+ params,
+ to,
+ }: {
+ children: ReactNode
+ params?: Record
+ to: string
+ }) => {
+ const href = Object.entries(params ?? {}).reduce(
+ (path, [key, value]) => path.replace(`$${key}`, value),
+ to
+ )
+ return {children}
+ },
+}))
+
+vi.mock('@/lib/client/queries/contacts', () => ({
+ contactQueries: {
+ search: (params: unknown) => ({ queryKey: ['contacts', 'search', params] }),
+ byOrg: (organizationId: string, params: unknown) => ({
+ queryKey: ['contacts', 'byOrg', organizationId, params],
+ }),
+ },
+}))
+
+vi.mock('@/lib/client/queries/organizations', () => ({
+ organizationQueries: {
+ list: (params: unknown) => {
+ mocks.organizationListParams = params
+ return { queryKey: ['organizations', 'list', params] }
+ },
+ },
+}))
+
+vi.mock('@/lib/server/functions/tickets', () => ({
+ listTicketsFn: mocks.listTicketsFn,
+}))
+
+vi.mock('@/components/admin/shared/permission-gate', () => ({
+ PermissionGate: ({ children }: { children: ReactNode; permission: string }) =>
+ mocks.permissionAllowed ? <>{children}> : null,
+}))
+
+vi.mock('@/components/admin/contacts/contact-create-dialog', () => ({
+ ContactCreateDialog: ({
+ defaultOrganizationId,
+ trigger,
+ }: {
+ defaultOrganizationId: string
+ trigger: ReactNode
+ }) => {trigger}
,
+}))
+
+vi.mock('@/lib/server/domains/authz', () => ({
+ PERMISSIONS: {
+ ORG_MANAGE: 'org.manage',
+ },
+}))
+
+function contact(overrides: Partial = {}): ContactRow {
+ return {
+ id: 'contact_1',
+ name: 'Ada Lovelace',
+ email: 'ada@example.com',
+ phone: '+1 555 1000',
+ title: 'Engineer',
+ organizationId: 'org_acme',
+ archivedAt: null,
+ ...overrides,
+ }
+}
+
+function organization(overrides: Partial = {}): OrganizationRow {
+ return {
+ id: 'org_acme',
+ name: 'Acme',
+ domain: 'acme.com',
+ website: 'https://acme.com',
+ externalId: 'crm-acme',
+ archivedAt: null,
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.searchContacts = []
+ mocks.orgContacts = []
+ mocks.organizations = []
+ mocks.organizationList = []
+ mocks.organizationListParams = undefined
+ mocks.ticketRows = []
+ mocks.permissionAllowed = true
+ mocks.listTicketsFn.mockImplementation(() => ({ rows: mocks.ticketRows }))
+})
+
+describe('ContactList', () => {
+ it('renders search rows with organization names, fallbacks and status badges', () => {
+ mocks.searchContacts = [
+ contact(),
+ contact({
+ id: 'contact_email',
+ name: null,
+ email: 'fallback@example.com',
+ organizationId: 'org_unknown',
+ title: null,
+ archivedAt: '2026-06-20T10:00:00.000Z',
+ }),
+ contact({
+ id: 'contact_id',
+ name: null,
+ email: null,
+ organizationId: null,
+ }),
+ ]
+ mocks.organizations = [{ id: 'org_acme', name: 'Acme' }]
+
+ render( )
+
+ expect(screen.getByRole('link', { name: 'Ada Lovelace' })).toHaveAttribute(
+ 'href',
+ '/admin/contacts/people/contact_1'
+ )
+ expect(screen.getByText('Acme')).toBeInTheDocument()
+ expect(screen.getByText('org_unknown')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'fallback@example.com' })).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'contact_id' })).toBeInTheDocument()
+ expect(screen.getAllByText('Active')).toHaveLength(2)
+ expect(screen.getByText('Archived')).toBeInTheDocument()
+ })
+
+ it('renders an empty contact-list row', () => {
+ render( )
+
+ expect(screen.getByText('No contacts.')).toBeInTheDocument()
+ })
+})
+
+describe('ContactTicketsTab', () => {
+ it('requests tickets for the contact and renders ticket rows', () => {
+ mocks.ticketRows = [
+ {
+ id: 'ticket_1',
+ subject: 'Cannot sign in',
+ priority: 'urgent',
+ channel: 'portal',
+ lastActivityAt: '2026-06-20T10:00:00.000Z',
+ },
+ {
+ id: 'ticket_2',
+ subject: 'Billing question',
+ priority: 'normal',
+ channel: 'email',
+ lastActivityAt: null,
+ },
+ ]
+
+ render( )
+
+ expect(mocks.listTicketsFn).toHaveBeenCalledWith({
+ data: { scope: 'all', requesterContactId: 'contact_1', limit: 100 },
+ })
+ expect(screen.getByRole('link', { name: 'Cannot sign in' })).toHaveAttribute(
+ 'href',
+ '/admin/tickets/ticket_1'
+ )
+ expect(screen.getByText('urgent')).toBeInTheDocument()
+ expect(screen.getByText('portal')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'Billing question' })).toBeInTheDocument()
+ expect(screen.getByText('email')).toBeInTheDocument()
+ })
+
+ it('renders an empty ticket row', () => {
+ render( )
+
+ expect(screen.getByText('No tickets for this contact.')).toBeInTheDocument()
+ })
+})
+
+describe('OrganizationContactsTab', () => {
+ it('renders organization contacts and passes the organization id to the create dialog', () => {
+ mocks.orgContacts = [
+ contact(),
+ contact({
+ id: 'contact_archived',
+ name: null,
+ email: null,
+ phone: null,
+ title: null,
+ organizationId: 'org_acme',
+ archivedAt: '2026-06-20T10:00:00.000Z',
+ }),
+ ]
+
+ const { container } = render( )
+
+ expect(screen.getByRole('button', { name: /Add contact/ })).toBeInTheDocument()
+ expect(container.querySelector('[data-default-organization-id="org_acme"]')).not.toBeNull()
+ expect(screen.getByRole('link', { name: 'Ada Lovelace' })).toHaveAttribute(
+ 'href',
+ '/admin/contacts/people/contact_1'
+ )
+ expect(screen.getByRole('link', { name: 'contact_archived' })).toBeInTheDocument()
+ expect(screen.getByText('+1 555 1000')).toBeInTheDocument()
+ expect(screen.getByText('Engineer')).toBeInTheDocument()
+ expect(screen.getByText('Archived')).toBeInTheDocument()
+ })
+
+ it('hides the add-contact action when the actor lacks org management permission', () => {
+ mocks.permissionAllowed = false
+
+ render( )
+
+ expect(screen.queryByRole('button', { name: /Add contact/ })).not.toBeInTheDocument()
+ expect(screen.getByText('No contacts in this organization yet.')).toBeInTheDocument()
+ })
+})
+
+describe('OrganizationList', () => {
+ it('trims search, filters archived rows and renders organization fallbacks', () => {
+ mocks.organizationList = [
+ organization(),
+ organization({
+ id: 'org_archived',
+ name: 'Archived org',
+ domain: null,
+ website: null,
+ externalId: null,
+ archivedAt: '2026-06-20T10:00:00.000Z',
+ }),
+ ]
+
+ const { rerender } = render( )
+
+ expect(mocks.organizationListParams).toEqual({
+ includeArchived: true,
+ search: 'acme',
+ })
+ expect(screen.getByRole('link', { name: 'Acme' })).toHaveAttribute(
+ 'href',
+ '/admin/contacts/organizations/org_acme'
+ )
+ expect(screen.getByText('acme.com')).toBeInTheDocument()
+ expect(screen.getByText('https://acme.com')).toBeInTheDocument()
+ expect(screen.getByText('crm-acme')).toBeInTheDocument()
+ expect(screen.getByText('Active')).toBeInTheDocument()
+ expect(screen.queryByRole('link', { name: 'Archived org' })).not.toBeInTheDocument()
+
+ rerender( )
+
+ expect(mocks.organizationListParams).toEqual({ includeArchived: true })
+ expect(screen.getByRole('link', { name: 'Archived org' })).toBeInTheDocument()
+ expect(screen.getByText('Archived')).toBeInTheDocument()
+ })
+
+ it('renders an empty organization-list row', () => {
+ render( )
+
+ expect(screen.getByText('No organizations.')).toBeInTheDocument()
+ })
+})
+
+describe('OrganizationTicketsTab', () => {
+ it('requests tickets for the organization and renders ticket rows', () => {
+ mocks.ticketRows = [
+ {
+ id: 'ticket_1',
+ subject: 'SLA escalation',
+ priority: 'high',
+ channel: 'email',
+ lastActivityAt: '2026-06-20T10:00:00.000Z',
+ },
+ ]
+
+ render( )
+
+ expect(mocks.listTicketsFn).toHaveBeenCalledWith({
+ data: { scope: 'all', organizationId: 'org_acme', limit: 100 },
+ })
+ expect(screen.getByRole('link', { name: 'SLA escalation' })).toHaveAttribute(
+ 'href',
+ '/admin/tickets/ticket_1'
+ )
+ expect(screen.getByText('high')).toBeInTheDocument()
+ expect(screen.getByText('email')).toBeInTheDocument()
+ })
+
+ it('renders an empty organization-ticket row', () => {
+ render( )
+
+ expect(screen.getByText('No tickets for this organization.')).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/admin/contacts/__tests__/contact-overview-tab.test.tsx b/apps/web/src/components/admin/contacts/__tests__/contact-overview-tab.test.tsx
new file mode 100644
index 000000000..9bec0da9e
--- /dev/null
+++ b/apps/web/src/components/admin/contacts/__tests__/contact-overview-tab.test.tsx
@@ -0,0 +1,295 @@
+// @vitest-environment happy-dom
+import type { ChangeEvent, ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { ContactOverviewTab } from '../contact-overview-tab'
+
+type ContactProp = Parameters[0]['contact']
+
+type MutationOptions = {
+ mutationFn: () => Promise
+ onSuccess?: (result: T) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ updateContactFn: vi.fn(),
+ archiveContactFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+ permissionAllowed: true,
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useMutation: (options: MutationOptions) => ({
+ isPending: false,
+ mutate: async () => {
+ try {
+ const result = await options.mutationFn()
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@/components/admin/shared/permission-gate', () => ({
+ PermissionGate: ({
+ children,
+ fallback = null,
+ }: {
+ children: ReactNode
+ fallback?: ReactNode
+ permission: string
+ }) => (mocks.permissionAllowed ? <>{children}> : <>{fallback}>),
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ size?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({
+ id,
+ type = 'text',
+ value,
+ onChange,
+ }: {
+ id?: string
+ type?: string
+ value?: string
+ onChange?: (event: ChangeEvent) => void
+ maxLength?: number
+ className?: string
+ }) => ,
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/alert-dialog', () => ({
+ AlertDialog: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogAction: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
+
+ {children}
+
+ ),
+ AlertDialogCancel: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ AlertDialogContent: ({ children }: { children: ReactNode }) => ,
+ AlertDialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogFooter: ({ children }: { children: ReactNode }) => ,
+ AlertDialogHeader: ({ children }: { children: ReactNode }) => ,
+ AlertDialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+ AlertDialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}>,
+}))
+
+vi.mock('@/components/admin/shared/org-picker', () => ({
+ OrgPicker: ({
+ value,
+ onValueChange,
+ }: {
+ value: string | null
+ onValueChange: (value: string | null) => void
+ allowClear?: boolean
+ }) => (
+ ) =>
+ onValueChange(event.currentTarget.value || null)
+ }
+ >
+ No organization
+ Acme
+ Beta
+
+ ),
+}))
+
+vi.mock('@/lib/server/functions/contacts', () => ({
+ updateContactFn: mocks.updateContactFn,
+ archiveContactFn: mocks.archiveContactFn,
+}))
+
+vi.mock('@/lib/client/queries/contacts', () => ({
+ contactQueries: {
+ detail: (contactId: string) => ({ queryKey: ['contacts', 'detail', contactId] }),
+ },
+}))
+
+vi.mock('@/lib/server/domains/authz', () => ({
+ PERMISSIONS: {
+ ORG_MANAGE: 'org.manage',
+ },
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+function contact(overrides: Partial = {}): ContactProp {
+ return {
+ id: 'contact_1',
+ name: 'Ada Lovelace',
+ email: 'ada@example.com',
+ phone: '+1 555 1000',
+ title: 'Engineer',
+ externalId: 'crm-123',
+ organizationId: 'org_acme',
+ archivedAt: null,
+ ...overrides,
+ } as ContactProp
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.permissionAllowed = true
+ mocks.updateContactFn.mockResolvedValue({ id: 'contact_1' })
+ mocks.archiveContactFn.mockResolvedValue({ id: 'contact_1' })
+})
+
+describe('ContactOverviewTab', () => {
+ it('saves trimmed contact fields, invalidates detail/list queries and shows success', async () => {
+ render( )
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Grace Hopper ' } })
+ fireEvent.change(screen.getByLabelText('Email'), { target: { value: ' grace@example.com ' } })
+ fireEvent.change(screen.getByLabelText('Phone'), { target: { value: ' ' } })
+ fireEvent.change(screen.getByLabelText('Title'), { target: { value: ' Admiral ' } })
+ fireEvent.change(screen.getByLabelText('Organization'), { target: { value: 'org_beta' } })
+ fireEvent.change(screen.getByLabelText('External ID'), { target: { value: ' crm-456 ' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+
+ await waitFor(() => {
+ expect(mocks.updateContactFn).toHaveBeenCalledWith({
+ data: {
+ contactId: 'contact_1',
+ name: 'Grace Hopper',
+ email: 'grace@example.com',
+ phone: null,
+ title: 'Admiral',
+ externalId: 'crm-456',
+ organizationId: 'org_beta',
+ },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['contacts', 'detail', 'contact_1'],
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['contacts'] })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Contact updated')
+ })
+
+ it('requires at least a name or email before saving and reports save failures', async () => {
+ render( )
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' ' } })
+ fireEvent.change(screen.getByLabelText('Email'), { target: { value: ' ' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+
+ expect(mocks.toastError).toHaveBeenCalledWith('Name or email is required')
+ expect(mocks.updateContactFn).not.toHaveBeenCalled()
+
+ fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'ada@example.com' } })
+ mocks.updateContactFn.mockRejectedValueOnce(new Error('Duplicate contact'))
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Duplicate contact')
+ })
+ })
+
+ it('resets local fields when the contact prop changes', () => {
+ const { rerender } = render( )
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Unsaved edit' } })
+ expect(screen.getByLabelText('Name')).toHaveValue('Unsaved edit')
+
+ rerender(
+
+ )
+
+ expect(screen.getByLabelText('Name')).toHaveValue('')
+ expect(screen.getByLabelText('Email')).toHaveValue('new@example.com')
+ expect(screen.getByLabelText('Phone')).toHaveValue('')
+ expect(screen.getByLabelText('Title')).toHaveValue('')
+ expect(screen.getByLabelText('External ID')).toHaveValue('')
+ expect(screen.getByLabelText('Organization')).toHaveValue('')
+ })
+
+ it('archives active contacts and reports archive failures', async () => {
+ render( )
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Archive' })[1])
+
+ await waitFor(() => {
+ expect(mocks.archiveContactFn).toHaveBeenCalledWith({
+ data: { contactId: 'contact_1' },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Contact archived')
+
+ mocks.archiveContactFn.mockRejectedValueOnce(new Error('Archive denied'))
+ fireEvent.click(screen.getAllByRole('button', { name: 'Archive' })[1])
+
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Archive denied')
+ })
+ })
+
+ it('renders archived and denied-management states without mutation controls', () => {
+ const { rerender } = render(
+
+ )
+
+ expect(screen.getByText('Archived')).toBeInTheDocument()
+ expect(
+ screen.getByText('This contact is archived. Restoring is not currently supported.')
+ ).toBeInTheDocument()
+
+ mocks.permissionAllowed = false
+ rerender( )
+
+ expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Archive' })).not.toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/admin/contacts/__tests__/organization-create-dialog.test.tsx b/apps/web/src/components/admin/contacts/__tests__/organization-create-dialog.test.tsx
new file mode 100644
index 000000000..2debdd7fc
--- /dev/null
+++ b/apps/web/src/components/admin/contacts/__tests__/organization-create-dialog.test.tsx
@@ -0,0 +1,201 @@
+// @vitest-environment happy-dom
+import type { ChangeEvent, ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { OrganizationCreateDialog } from '../organization-create-dialog'
+
+type MutationOptions = {
+ mutationFn: () => Promise
+ onSuccess?: (result: T) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ createOrganizationFn: vi.fn(),
+ invalidateQueries: vi.fn(),
+ navigate: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useMutation: (options: MutationOptions) => ({
+ isPending: false,
+ mutate: async () => {
+ try {
+ const result = await options.mutationFn()
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ useRouter: () => ({
+ navigate: mocks.navigate,
+ }),
+}))
+
+vi.mock('@/lib/server/functions/organizations', () => ({
+ createOrganizationFn: mocks.createOrganizationFn,
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({
+ id,
+ value,
+ onChange,
+ placeholder,
+ }: {
+ id?: string
+ value?: string
+ onChange?: (event: ChangeEvent) => void
+ required?: boolean
+ maxLength?: number
+ placeholder?: string
+ className?: string
+ }) => ,
+}))
+
+vi.mock('@/components/ui/textarea', () => ({
+ Textarea: ({
+ id,
+ value,
+ onChange,
+ rows,
+ }: {
+ id?: string
+ value?: string
+ onChange?: (event: ChangeEvent) => void
+ rows?: number
+ maxLength?: number
+ }) => ,
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/dialog', () => ({
+ Dialog: ({
+ children,
+ }: {
+ children: ReactNode
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ }) => {children}
,
+ DialogContent: ({ children }: { children: ReactNode; className?: string }) => (
+
+ ),
+ DialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ DialogFooter: ({ children }: { children: ReactNode }) => ,
+ DialogHeader: ({ children }: { children: ReactNode }) => ,
+ DialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+ DialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}>,
+}))
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.createOrganizationFn.mockResolvedValue({ id: 'org_created' })
+})
+
+describe('OrganizationCreateDialog', () => {
+ it('requires a name before creating an organization', () => {
+ render(Open} />)
+
+ fireEvent.submit(screen.getByRole('button', { name: 'Create' }).closest('form')!)
+
+ expect(mocks.toastError).toHaveBeenCalledWith('Name is required')
+ expect(mocks.createOrganizationFn).not.toHaveBeenCalled()
+ })
+
+ it('submits trimmed fields, invalidates organizations and navigates on success', async () => {
+ render(Open} />)
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Acme ' } })
+ fireEvent.change(screen.getByLabelText('Domain'), { target: { value: ' acme.com ' } })
+ fireEvent.change(screen.getByLabelText('Website'), { target: { value: ' ' } })
+ fireEvent.change(screen.getByLabelText('External ID'), { target: { value: ' crm-acme ' } })
+ fireEvent.change(screen.getByLabelText('Notes'), { target: { value: ' Enterprise ' } })
+ fireEvent.submit(screen.getByRole('button', { name: 'Create' }).closest('form')!)
+
+ await waitFor(() => {
+ expect(mocks.createOrganizationFn).toHaveBeenCalledWith({
+ data: {
+ name: 'Acme',
+ domain: 'acme.com',
+ website: null,
+ externalId: 'crm-acme',
+ notes: 'Enterprise',
+ },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['organizations'] })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Organization created')
+ expect(mocks.navigate).toHaveBeenCalledWith({
+ to: '/admin/contacts/organizations/$organizationId',
+ params: { organizationId: 'org_created' },
+ })
+ expect(screen.getByLabelText('Name')).toHaveValue('')
+ expect(screen.getByLabelText('Domain')).toHaveValue('')
+ })
+
+ it('turns empty optional fields into null and reports create failures', async () => {
+ mocks.createOrganizationFn.mockRejectedValueOnce(new Error('Duplicate organization'))
+ render(Open} />)
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Acme' } })
+ fireEvent.change(screen.getByLabelText('Domain'), { target: { value: ' ' } })
+ fireEvent.change(screen.getByLabelText('Website'), { target: { value: ' ' } })
+ fireEvent.change(screen.getByLabelText('External ID'), { target: { value: ' ' } })
+ fireEvent.change(screen.getByLabelText('Notes'), { target: { value: ' ' } })
+ fireEvent.submit(screen.getByRole('button', { name: 'Create' }).closest('form')!)
+
+ await waitFor(() => {
+ expect(mocks.createOrganizationFn).toHaveBeenCalledWith({
+ data: {
+ name: 'Acme',
+ domain: null,
+ website: null,
+ externalId: null,
+ notes: null,
+ },
+ })
+ })
+ expect(mocks.toastError).toHaveBeenCalledWith('Duplicate organization')
+ expect(mocks.navigate).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/web/src/components/admin/contacts/__tests__/organization-overview-tab.test.tsx b/apps/web/src/components/admin/contacts/__tests__/organization-overview-tab.test.tsx
new file mode 100644
index 000000000..eb441c668
--- /dev/null
+++ b/apps/web/src/components/admin/contacts/__tests__/organization-overview-tab.test.tsx
@@ -0,0 +1,297 @@
+// @vitest-environment happy-dom
+import type { ChangeEvent, ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { OrganizationOverviewTab } from '../organization-overview-tab'
+
+type OrganizationProp = Parameters[0]['organization']
+
+type MutationOptions = {
+ mutationFn: () => Promise
+ onSuccess?: (result: T) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ updateOrganizationFn: vi.fn(),
+ archiveOrganizationFn: vi.fn(),
+ unarchiveOrganizationFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+ permissionAllowed: true,
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useMutation: (options: MutationOptions) => ({
+ isPending: false,
+ mutate: async () => {
+ try {
+ const result = await options.mutationFn()
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@/components/admin/shared/permission-gate', () => ({
+ PermissionGate: ({
+ children,
+ fallback = null,
+ }: {
+ children: ReactNode
+ fallback?: ReactNode
+ permission: string
+ }) => (mocks.permissionAllowed ? <>{children}> : <>{fallback}>),
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ size?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({
+ id,
+ value,
+ onChange,
+ }: {
+ id?: string
+ value?: string
+ onChange?: (event: ChangeEvent) => void
+ required?: boolean
+ maxLength?: number
+ className?: string
+ }) => ,
+}))
+
+vi.mock('@/components/ui/textarea', () => ({
+ Textarea: ({
+ id,
+ value,
+ onChange,
+ rows,
+ }: {
+ id?: string
+ value?: string
+ onChange?: (event: ChangeEvent) => void
+ rows?: number
+ maxLength?: number
+ }) => ,
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/alert-dialog', () => ({
+ AlertDialog: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogAction: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
+
+ {children}
+
+ ),
+ AlertDialogCancel: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ AlertDialogContent: ({ children }: { children: ReactNode }) => ,
+ AlertDialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogFooter: ({ children }: { children: ReactNode }) => ,
+ AlertDialogHeader: ({ children }: { children: ReactNode }) => ,
+ AlertDialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+ AlertDialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}>,
+}))
+
+vi.mock('@/lib/server/functions/organizations', () => ({
+ updateOrganizationFn: mocks.updateOrganizationFn,
+ archiveOrganizationFn: mocks.archiveOrganizationFn,
+ unarchiveOrganizationFn: mocks.unarchiveOrganizationFn,
+}))
+
+vi.mock('@/lib/client/queries/organizations', () => ({
+ organizationQueries: {
+ detail: (organizationId: string) => ({
+ queryKey: ['organizations', 'detail', organizationId],
+ }),
+ },
+}))
+
+vi.mock('@/lib/server/domains/authz', () => ({
+ PERMISSIONS: {
+ ORG_MANAGE: 'org.manage',
+ },
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+function organization(overrides: Partial = {}): OrganizationProp {
+ return {
+ id: 'org_acme',
+ name: 'Acme',
+ domain: 'acme.example',
+ website: 'https://acme.example',
+ externalId: 'crm-acme',
+ notes: 'Important account',
+ archivedAt: null,
+ ...overrides,
+ } as OrganizationProp
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.permissionAllowed = true
+ mocks.updateOrganizationFn.mockResolvedValue({ id: 'org_acme' })
+ mocks.archiveOrganizationFn.mockResolvedValue({ id: 'org_acme' })
+ mocks.unarchiveOrganizationFn.mockResolvedValue({ id: 'org_acme' })
+})
+
+describe('OrganizationOverviewTab', () => {
+ it('saves trimmed organization fields and invalidates detail/list queries', async () => {
+ render( )
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Beta Corp ' } })
+ fireEvent.change(screen.getByLabelText('Domain'), { target: { value: ' ' } })
+ fireEvent.change(screen.getByLabelText('Website'), {
+ target: { value: ' https://beta.example ' },
+ })
+ fireEvent.change(screen.getByLabelText('External ID'), { target: { value: ' crm-beta ' } })
+ fireEvent.change(screen.getByLabelText('Notes'), { target: { value: ' Renewal soon ' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+
+ await waitFor(() => {
+ expect(mocks.updateOrganizationFn).toHaveBeenCalledWith({
+ data: {
+ organizationId: 'org_acme',
+ name: 'Beta Corp',
+ domain: null,
+ website: 'https://beta.example',
+ externalId: 'crm-beta',
+ notes: 'Renewal soon',
+ },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['organizations', 'detail', 'org_acme'],
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['organizations'] })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Organization updated')
+ })
+
+ it('validates name and reports save failures', async () => {
+ render( )
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' ' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+
+ expect(mocks.toastError).toHaveBeenCalledWith('Name is required')
+ expect(mocks.updateOrganizationFn).not.toHaveBeenCalled()
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Acme' } })
+ mocks.updateOrganizationFn.mockRejectedValueOnce(new Error('Duplicate organization'))
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Duplicate organization')
+ })
+ })
+
+ it('resets local fields when the organization prop changes', () => {
+ const { rerender } = render( )
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Unsaved' } })
+ expect(screen.getByLabelText('Name')).toHaveValue('Unsaved')
+
+ rerender(
+
+ )
+
+ expect(screen.getByLabelText('Name')).toHaveValue('Beta')
+ expect(screen.getByLabelText('Domain')).toHaveValue('')
+ expect(screen.getByLabelText('Website')).toHaveValue('')
+ expect(screen.getByLabelText('External ID')).toHaveValue('')
+ expect(screen.getByLabelText('Notes')).toHaveValue('')
+ })
+
+ it('archives and unarchives organizations and reports archive failures', async () => {
+ const { rerender } = render( )
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Archive' })[1])
+ await waitFor(() => {
+ expect(mocks.archiveOrganizationFn).toHaveBeenCalledWith({
+ data: { organizationId: 'org_acme' },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Organization archived')
+
+ mocks.archiveOrganizationFn.mockRejectedValueOnce(new Error('Archive denied'))
+ fireEvent.click(screen.getAllByRole('button', { name: 'Archive' })[1])
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Archive denied')
+ })
+
+ rerender(
+
+ )
+ fireEvent.click(screen.getByRole('button', { name: 'Unarchive' }))
+ await waitFor(() => {
+ expect(mocks.unarchiveOrganizationFn).toHaveBeenCalledWith({
+ data: { organizationId: 'org_acme' },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Organization unarchived')
+
+ mocks.unarchiveOrganizationFn.mockRejectedValueOnce(new Error('Unarchive denied'))
+ fireEvent.click(screen.getByRole('button', { name: 'Unarchive' }))
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Unarchive denied')
+ })
+ })
+
+ it('hides mutation controls when organization management permission is denied', () => {
+ mocks.permissionAllowed = false
+
+ render( )
+
+ expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Archive' })).not.toBeInTheDocument()
+ expect(screen.getByLabelText('Name')).toHaveValue('Acme')
+ })
+})
diff --git a/apps/web/src/components/admin/contacts/contact-create-dialog.tsx b/apps/web/src/components/admin/contacts/contact-create-dialog.tsx
new file mode 100644
index 000000000..fb9d6172f
--- /dev/null
+++ b/apps/web/src/components/admin/contacts/contact-create-dialog.tsx
@@ -0,0 +1,177 @@
+/**
+ * Create-contact dialog. Optional `defaultOrganizationId` prop pre-selects the
+ * org picker (used from the org-detail Contacts tab).
+ */
+import { useState, useEffect } from 'react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { useRouter } from '@tanstack/react-router'
+import { toast } from 'sonner'
+import type { ContactId, OrganizationId } from '@quackback/ids'
+import { createContactFn } from '@/lib/server/functions/contacts'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog'
+import { OrgPicker } from '@/components/admin/shared/org-picker'
+
+interface Props {
+ trigger: React.ReactNode
+ defaultOrganizationId?: OrganizationId
+}
+
+export function ContactCreateDialog({ trigger, defaultOrganizationId }: Props) {
+ const [open, setOpen] = useState(false)
+ const router = useRouter()
+ const qc = useQueryClient()
+
+ const [name, setName] = useState('')
+ const [email, setEmail] = useState('')
+ const [phone, setPhone] = useState('')
+ const [title, setTitle] = useState('')
+ const [externalId, setExternalId] = useState('')
+ const [organizationId, setOrganizationId] = useState(
+ defaultOrganizationId ?? null
+ )
+
+ useEffect(() => {
+ if (open) setOrganizationId(defaultOrganizationId ?? null)
+ }, [open, defaultOrganizationId])
+
+ const reset = () => {
+ setName('')
+ setEmail('')
+ setPhone('')
+ setTitle('')
+ setExternalId('')
+ setOrganizationId(defaultOrganizationId ?? null)
+ }
+
+ const mutation = useMutation({
+ mutationFn: () =>
+ createContactFn({
+ data: {
+ name: name.trim() || null,
+ email: email.trim() || null,
+ phone: phone.trim() || null,
+ title: title.trim() || null,
+ externalId: externalId.trim() || null,
+ organizationId: organizationId ?? null,
+ },
+ }),
+ onSuccess: (contact) => {
+ qc.invalidateQueries({ queryKey: ['contacts'] })
+ toast.success('Contact created')
+ setOpen(false)
+ reset()
+ router.navigate({
+ to: '/admin/contacts/people/$contactId',
+ params: { contactId: contact.id as ContactId },
+ })
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ return (
+
+ {trigger}
+
+
+ New contact
+
+ People you support — typically a customer or end-user representative.
+
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/contacts/contact-linked-users-tab.tsx b/apps/web/src/components/admin/contacts/contact-linked-users-tab.tsx
new file mode 100644
index 000000000..8fa5317d8
--- /dev/null
+++ b/apps/web/src/components/admin/contacts/contact-linked-users-tab.tsx
@@ -0,0 +1,235 @@
+/**
+ * Linked users tab — manage portal-user links for a contact.
+ *
+ * Approach:
+ * - List existing links (`listLinksForContactFn`) → show userId + linkedAt;
+ * enrich displayName/email via `searchPrincipalsFn({roleFilter:['user']})`
+ * client-side Map keyed by userId.
+ * - Add: `` returns PrincipalId; we
+ * resolve PrincipalId → UserId via the same map and call `linkContactToUserFn`.
+ * - Remove: AlertDialog → `unlinkContactFromUserFn`.
+ */
+import { useState, useMemo } from 'react'
+import { useSuspenseQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import type { ContactId, PrincipalId, UserId } from '@quackback/ids'
+import { linkContactToUserFn, unlinkContactFromUserFn } from '@/lib/server/functions/contacts'
+import { searchPrincipalsFn } from '@/lib/server/functions/principals'
+import { contactQueries } from '@/lib/client/queries/contacts'
+import { Button } from '@/components/ui/button'
+import { Avatar } from '@/components/ui/avatar'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog'
+import { TrashIcon } from '@heroicons/react/24/outline'
+import { PrincipalPicker } from '@/components/admin/shared/principal-picker'
+import { PermissionGate } from '@/components/admin/shared/permission-gate'
+import { PERMISSIONS } from '@/lib/server/domains/authz'
+
+export function ContactLinkedUsersTab({ contactId }: { contactId: ContactId }) {
+ const qc = useQueryClient()
+ const { data: links } = useSuspenseQuery(contactQueries.links(contactId))
+
+ // Resolve userId → display info via a single principals query.
+ const principalsQuery = useQuery({
+ queryKey: ['principals', 'allUsers'],
+ queryFn: () => searchPrincipalsFn({ data: { roleFilter: ['user'], limit: 50 } }),
+ staleTime: 60_000,
+ })
+ const userMap = useMemo(() => {
+ const m = new Map<
+ string,
+ { principalId: PrincipalId; displayName: string | null; email: string | null }
+ >()
+ for (const p of principalsQuery.data ?? []) {
+ if (p.userId)
+ m.set(p.userId, {
+ principalId: p.id,
+ displayName: p.displayName,
+ email: p.email,
+ })
+ }
+ return m
+ }, [principalsQuery.data])
+
+ const linkedUserIds = useMemo(() => links.map((l) => l.userId as UserId), [links])
+ const excludePrincipalIds = useMemo(() => {
+ const ids: PrincipalId[] = []
+ for (const u of linkedUserIds) {
+ const info = userMap.get(u)
+ if (info) ids.push(info.principalId)
+ }
+ return ids
+ }, [linkedUserIds, userMap])
+
+ const [addPrincipalId, setAddPrincipalId] = useState(null)
+
+ const invalidate = () =>
+ qc.invalidateQueries({ queryKey: contactQueries.links(contactId).queryKey })
+
+ const linkMutation = useMutation({
+ mutationFn: (userId: UserId) => linkContactToUserFn({ data: { contactId, userId } }),
+ onSuccess: () => {
+ setAddPrincipalId(null)
+ invalidate()
+ toast.success('User linked')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const unlinkMutation = useMutation({
+ mutationFn: (userId: UserId) => unlinkContactFromUserFn({ data: { contactId, userId } }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('User unlinked')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const handleAdd = () => {
+ if (!addPrincipalId) return
+ // Find userId for the picked principalId.
+ let resolvedUserId: UserId | null = null
+ for (const [uid, info] of userMap.entries()) {
+ if (info.principalId === addPrincipalId) {
+ resolvedUserId = uid as UserId
+ break
+ }
+ }
+ if (!resolvedUserId) {
+ toast.error('Selected principal has no associated user')
+ return
+ }
+ linkMutation.mutate(resolvedUserId)
+ }
+
+ return (
+
+
+
Linked users
+
+ Portal users associated with this contact. Linking ties their authenticated identity to
+ ticket history.
+
+
+
+
+
+
+
+
+
+
+
+ User
+ Linked at
+
+
+
+
+ {links.length === 0 ? (
+
+
+ No linked users yet.
+
+
+ ) : (
+ links.map((l) => {
+ const info = userMap.get(l.userId)
+ const label = info?.displayName ?? info?.email ?? l.userId
+ return (
+
+
+
+
+ {label.slice(0, 2).toUpperCase()}
+
+
+ {label}
+ {info?.email && info?.displayName && (
+ {info.email}
+ )}
+
+
+
+
+ {new Date(l.linkedAt).toLocaleString()}
+
+
+
+
+
+
+
+
+
+
+
+ Unlink user?
+
+ The contact will no longer be associated with this portal user.
+
+
+
+ Cancel
+ unlinkMutation.mutate(l.userId as UserId)}
+ >
+ Unlink
+
+
+
+
+
+
+
+ )
+ })
+ )}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/contacts/contact-list.tsx b/apps/web/src/components/admin/contacts/contact-list.tsx
new file mode 100644
index 000000000..d884a2f5f
--- /dev/null
+++ b/apps/web/src/components/admin/contacts/contact-list.tsx
@@ -0,0 +1,89 @@
+/**
+ * Cross-org contact table. Resolves organization names via list-cache lookup.
+ */
+import { useMemo } from 'react'
+import { Link } from '@tanstack/react-router'
+import { useSuspenseQuery, useQuery } from '@tanstack/react-query'
+import type { ContactId } from '@quackback/ids'
+import { contactQueries } from '@/lib/client/queries/contacts'
+import { organizationQueries } from '@/lib/client/queries/organizations'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Badge } from '@/components/ui/badge'
+
+interface Props {
+ search: string
+ showArchived: boolean
+}
+
+export function ContactList({ search, showArchived }: Props) {
+ const { data: contacts } = useSuspenseQuery(
+ contactQueries.search({ query: search, includeArchived: showArchived })
+ )
+
+ // Best-effort org name lookup. Stays optional so we don't block the list.
+ const orgsQuery = useQuery(organizationQueries.list({ includeArchived: true }))
+ const orgMap = useMemo(() => {
+ const m = new Map()
+ for (const o of orgsQuery.data ?? []) m.set(o.id, o.name)
+ return m
+ }, [orgsQuery.data])
+
+ return (
+
+
+
+
+ Name
+ Email
+ Organization
+ Title
+ Status
+
+
+
+ {contacts.length === 0 ? (
+
+
+ No contacts.
+
+
+ ) : (
+ contacts.map((c) => (
+
+
+
+ {c.name ?? c.email ?? c.id}
+
+
+ {c.email ?? '—'}
+
+ {c.organizationId ? (orgMap.get(c.organizationId) ?? c.organizationId) : '—'}
+
+ {c.title ?? '—'}
+
+ {c.archivedAt ? (
+
+ Archived
+
+ ) : (
+ Active
+ )}
+
+
+ ))
+ )}
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/contacts/contact-overview-tab.tsx b/apps/web/src/components/admin/contacts/contact-overview-tab.tsx
new file mode 100644
index 000000000..dd3c63d77
--- /dev/null
+++ b/apps/web/src/components/admin/contacts/contact-overview-tab.tsx
@@ -0,0 +1,202 @@
+/**
+ * Editable contact form. Bottom Archive section. No unarchive (backend does
+ * not expose unarchiveContactFn).
+ */
+import { useState, useEffect } from 'react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import type { Contact } from '@/lib/shared/db-types'
+import type { OrganizationId } from '@quackback/ids'
+import { updateContactFn, archiveContactFn } from '@/lib/server/functions/contacts'
+import { contactQueries } from '@/lib/client/queries/contacts'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog'
+import { OrgPicker } from '@/components/admin/shared/org-picker'
+import { PermissionGate } from '@/components/admin/shared/permission-gate'
+import { PERMISSIONS } from '@/lib/server/domains/authz'
+
+export function ContactOverviewTab({ contact }: { contact: Contact }) {
+ const qc = useQueryClient()
+ const [name, setName] = useState(contact.name ?? '')
+ const [email, setEmail] = useState(contact.email ?? '')
+ const [phone, setPhone] = useState(contact.phone ?? '')
+ const [title, setTitle] = useState(contact.title ?? '')
+ const [externalId, setExternalId] = useState(contact.externalId ?? '')
+ const [organizationId, setOrganizationId] = useState(
+ (contact.organizationId as OrganizationId | null) ?? null
+ )
+
+ useEffect(() => {
+ setName(contact.name ?? '')
+ setEmail(contact.email ?? '')
+ setPhone(contact.phone ?? '')
+ setTitle(contact.title ?? '')
+ setExternalId(contact.externalId ?? '')
+ setOrganizationId((contact.organizationId as OrganizationId | null) ?? null)
+ }, [contact])
+
+ const invalidate = () => {
+ qc.invalidateQueries({ queryKey: contactQueries.detail(contact.id).queryKey })
+ qc.invalidateQueries({ queryKey: ['contacts'] })
+ }
+
+ const saveMutation = useMutation({
+ mutationFn: () =>
+ updateContactFn({
+ data: {
+ contactId: contact.id,
+ name: name.trim() || null,
+ email: email.trim() || null,
+ phone: phone.trim() || null,
+ title: title.trim() || null,
+ externalId: externalId.trim() || null,
+ organizationId: organizationId ?? null,
+ },
+ }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Contact updated')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const archiveMutation = useMutation({
+ mutationFn: () => archiveContactFn({ data: { contactId: contact.id } }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Contact archived')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ return (
+
+
+
+
+ {contact.archivedAt ? (
+
+
Archived
+
+ This contact is archived. Restoring is not currently supported.
+
+
+ ) : (
+
+
Archive
+
+ Archived contacts are hidden from pickers; existing tickets keep their reference.
+
+
+
+
+ Archive
+
+
+
+
+ Archive contact?
+
+ This action cannot be undone from the UI.
+
+
+
+ Cancel
+ archiveMutation.mutate()}>
+ Archive
+
+
+
+
+
+ )}
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/contacts/contact-tickets-tab.tsx b/apps/web/src/components/admin/contacts/contact-tickets-tab.tsx
new file mode 100644
index 000000000..498269e59
--- /dev/null
+++ b/apps/web/src/components/admin/contacts/contact-tickets-tab.tsx
@@ -0,0 +1,71 @@
+/**
+ * Tickets where this contact is the requester. Uses extended `listTicketsFn`
+ * with `requesterContactId` filter (Phase G0a backend gap).
+ */
+import { Link } from '@tanstack/react-router'
+import { useSuspenseQuery } from '@tanstack/react-query'
+import type { ContactId, TicketId } from '@quackback/ids'
+import { listTicketsFn } from '@/lib/server/functions/tickets'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Badge } from '@/components/ui/badge'
+
+export function ContactTicketsTab({ contactId }: { contactId: ContactId }) {
+ const { data } = useSuspenseQuery({
+ queryKey: ['tickets', 'byContact', contactId],
+ queryFn: () =>
+ listTicketsFn({
+ data: { scope: 'all', requesterContactId: contactId, limit: 100 },
+ }),
+ staleTime: 30_000,
+ })
+
+ return (
+
+
+
+
+ Subject
+ Priority
+ Channel
+ Last activity
+
+
+
+ {data.rows.length === 0 ? (
+
+
+ No tickets for this contact.
+
+
+ ) : (
+ data.rows.map((t) => (
+
+
+
+ {t.subject}
+
+
+
+
+ {t.priority}
+
+
+ {t.channel}
+
+ {t.lastActivityAt ? new Date(t.lastActivityAt).toLocaleString() : '—'}
+
+
+ ))
+ )}
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/contacts/organization-contacts-tab.tsx b/apps/web/src/components/admin/contacts/organization-contacts-tab.tsx
new file mode 100644
index 000000000..cbe34c998
--- /dev/null
+++ b/apps/web/src/components/admin/contacts/organization-contacts-tab.tsx
@@ -0,0 +1,93 @@
+/**
+ * Contacts belonging to an organization + Add-contact button.
+ */
+import { Link } from '@tanstack/react-router'
+import { useSuspenseQuery } from '@tanstack/react-query'
+import type { OrganizationId, ContactId } from '@quackback/ids'
+import { contactQueries } from '@/lib/client/queries/contacts'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { PlusIcon } from '@heroicons/react/24/solid'
+import { ContactCreateDialog } from '@/components/admin/contacts/contact-create-dialog'
+import { PermissionGate } from '@/components/admin/shared/permission-gate'
+import { PERMISSIONS } from '@/lib/server/domains/authz'
+
+export function OrganizationContactsTab({ organizationId }: { organizationId: OrganizationId }) {
+ const { data: contacts } = useSuspenseQuery(
+ contactQueries.byOrg(organizationId, { includeArchived: false })
+ )
+
+ return (
+
+
+
+
+
+ Add contact
+
+ }
+ />
+
+
+
+
+
+
+
+ Name
+ Email
+ Phone
+ Title
+ Status
+
+
+
+ {contacts.length === 0 ? (
+
+
+ No contacts in this organization yet.
+
+
+ ) : (
+ contacts.map((c) => (
+
+
+
+ {c.name ?? c.email ?? c.id}
+
+
+ {c.email ?? '—'}
+ {c.phone ?? '—'}
+ {c.title ?? '—'}
+
+ {c.archivedAt ? (
+
+ Archived
+
+ ) : (
+ Active
+ )}
+
+
+ ))
+ )}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/contacts/organization-create-dialog.tsx b/apps/web/src/components/admin/contacts/organization-create-dialog.tsx
new file mode 100644
index 000000000..eb841739c
--- /dev/null
+++ b/apps/web/src/components/admin/contacts/organization-create-dialog.tsx
@@ -0,0 +1,159 @@
+/**
+ * Create-organization dialog. Navigates to detail on success.
+ */
+import { useState } from 'react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { useRouter } from '@tanstack/react-router'
+import { toast } from 'sonner'
+import type { OrganizationId } from '@quackback/ids'
+import { createOrganizationFn } from '@/lib/server/functions/organizations'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog'
+
+export function OrganizationCreateDialog({ trigger }: { trigger: React.ReactNode }) {
+ const [open, setOpen] = useState(false)
+ const router = useRouter()
+ const qc = useQueryClient()
+
+ const [name, setName] = useState('')
+ const [domain, setDomain] = useState('')
+ const [website, setWebsite] = useState('')
+ const [externalId, setExternalId] = useState('')
+ const [notes, setNotes] = useState('')
+
+ const reset = () => {
+ setName('')
+ setDomain('')
+ setWebsite('')
+ setExternalId('')
+ setNotes('')
+ }
+
+ const mutation = useMutation({
+ mutationFn: () =>
+ createOrganizationFn({
+ data: {
+ name: name.trim(),
+ domain: domain.trim() || null,
+ website: website.trim() || null,
+ externalId: externalId.trim() || null,
+ notes: notes.trim() || null,
+ },
+ }),
+ onSuccess: (org) => {
+ qc.invalidateQueries({ queryKey: ['organizations'] })
+ toast.success('Organization created')
+ setOpen(false)
+ reset()
+ router.navigate({
+ to: '/admin/contacts/organizations/$organizationId',
+ params: { organizationId: org.id as OrganizationId },
+ })
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ return (
+
+ {trigger}
+
+
+ New organization
+
+ Companies, departments, or any account-level grouping.
+
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/contacts/organization-list.tsx b/apps/web/src/components/admin/contacts/organization-list.tsx
new file mode 100644
index 000000000..6566d8e87
--- /dev/null
+++ b/apps/web/src/components/admin/contacts/organization-list.tsx
@@ -0,0 +1,91 @@
+/**
+ * Table of organizations with name (Link), domain, website, externalId, status.
+ */
+import { useMemo } from 'react'
+import { Link } from '@tanstack/react-router'
+import { useSuspenseQuery } from '@tanstack/react-query'
+import type { OrganizationId } from '@quackback/ids'
+import { organizationQueries } from '@/lib/client/queries/organizations'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Badge } from '@/components/ui/badge'
+
+interface Props {
+ search: string
+ showArchived: boolean
+}
+
+export function OrganizationList({ search, showArchived }: Props) {
+ const trimmedSearch = search.trim()
+ const { data: orgs } = useSuspenseQuery(
+ organizationQueries.list({
+ includeArchived: true,
+ ...(trimmedSearch ? { search: trimmedSearch } : {}),
+ })
+ )
+
+ const rows = useMemo(
+ () => (showArchived ? orgs : orgs.filter((o) => o.archivedAt == null)),
+ [orgs, showArchived]
+ )
+
+ return (
+
+
+
+
+ Name
+ Domain
+ Website
+ External ID
+ Status
+
+
+
+ {rows.length === 0 ? (
+
+
+ No organizations.
+
+
+ ) : (
+ rows.map((org) => (
+
+
+
+ {org.name}
+
+
+ {org.domain ?? '—'}
+
+ {org.website ?? '—'}
+
+
+ {org.externalId ?? '—'}
+
+
+ {org.archivedAt ? (
+
+ Archived
+
+ ) : (
+ Active
+ )}
+
+
+ ))
+ )}
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/contacts/organization-overview-tab.tsx b/apps/web/src/components/admin/contacts/organization-overview-tab.tsx
new file mode 100644
index 000000000..267362dbc
--- /dev/null
+++ b/apps/web/src/components/admin/contacts/organization-overview-tab.tsx
@@ -0,0 +1,205 @@
+/**
+ * Editable overview form for an organization. Bottom Archive/Unarchive section.
+ */
+import { useState, useEffect } from 'react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import type { Organization } from '@/lib/shared/db-types'
+import {
+ updateOrganizationFn,
+ archiveOrganizationFn,
+ unarchiveOrganizationFn,
+} from '@/lib/server/functions/organizations'
+import { organizationQueries } from '@/lib/client/queries/organizations'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog'
+import { PermissionGate } from '@/components/admin/shared/permission-gate'
+import { PERMISSIONS } from '@/lib/server/domains/authz'
+
+export function OrganizationOverviewTab({ organization }: { organization: Organization }) {
+ const qc = useQueryClient()
+ const [name, setName] = useState(organization.name)
+ const [domain, setDomain] = useState(organization.domain ?? '')
+ const [website, setWebsite] = useState(organization.website ?? '')
+ const [externalId, setExternalId] = useState(organization.externalId ?? '')
+ const [notes, setNotes] = useState(organization.notes ?? '')
+
+ useEffect(() => {
+ setName(organization.name)
+ setDomain(organization.domain ?? '')
+ setWebsite(organization.website ?? '')
+ setExternalId(organization.externalId ?? '')
+ setNotes(organization.notes ?? '')
+ }, [organization])
+
+ const invalidate = () => {
+ qc.invalidateQueries({ queryKey: organizationQueries.detail(organization.id).queryKey })
+ qc.invalidateQueries({ queryKey: ['organizations'] })
+ }
+
+ const saveMutation = useMutation({
+ mutationFn: () =>
+ updateOrganizationFn({
+ data: {
+ organizationId: organization.id,
+ name: name.trim(),
+ domain: domain.trim() || null,
+ website: website.trim() || null,
+ externalId: externalId.trim() || null,
+ notes: notes.trim() || null,
+ },
+ }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Organization updated')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const archiveMutation = useMutation({
+ mutationFn: () => archiveOrganizationFn({ data: { organizationId: organization.id } }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Organization archived')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const unarchiveMutation = useMutation({
+ mutationFn: () => unarchiveOrganizationFn({ data: { organizationId: organization.id } }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Organization unarchived')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ return (
+
+
{
+ e.preventDefault()
+ if (!name.trim()) {
+ toast.error('Name is required')
+ return
+ }
+ saveMutation.mutate()
+ }}
+ className="space-y-3"
+ >
+
+ Name
+ setName(e.target.value)}
+ required
+ maxLength={200}
+ />
+
+
+
+ External ID
+ setExternalId(e.target.value)}
+ maxLength={255}
+ className="font-mono text-xs"
+ />
+
+
+ Notes
+ setNotes(e.target.value)}
+ rows={4}
+ maxLength={5000}
+ />
+
+
+
+
+
+ Save changes
+
+
+
+
+
+
+
+
Archive
+
+ Archived organizations are hidden from pickers; existing tickets keep their reference.
+
+ {organization.archivedAt ? (
+
unarchiveMutation.mutate()}
+ disabled={unarchiveMutation.isPending}
+ >
+ Unarchive
+
+ ) : (
+
+
+
+ Archive
+
+
+
+
+ Archive {organization.name}?
+
+ The organization will be hidden from pickers. You can unarchive it later.
+
+
+
+ Cancel
+ archiveMutation.mutate()}>
+ Archive
+
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/contacts/organization-tickets-tab.tsx b/apps/web/src/components/admin/contacts/organization-tickets-tab.tsx
new file mode 100644
index 000000000..2bcf06ba9
--- /dev/null
+++ b/apps/web/src/components/admin/contacts/organization-tickets-tab.tsx
@@ -0,0 +1,71 @@
+/**
+ * Tickets filed by/under an organization. Uses extended `listTicketsFn`
+ * with `organizationId` filter (Phase G0a backend gap).
+ */
+import { Link } from '@tanstack/react-router'
+import { useSuspenseQuery } from '@tanstack/react-query'
+import type { OrganizationId, TicketId } from '@quackback/ids'
+import { listTicketsFn } from '@/lib/server/functions/tickets'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Badge } from '@/components/ui/badge'
+
+export function OrganizationTicketsTab({ organizationId }: { organizationId: OrganizationId }) {
+ const { data } = useSuspenseQuery({
+ queryKey: ['tickets', 'byOrg', organizationId],
+ queryFn: () =>
+ listTicketsFn({
+ data: { scope: 'all', organizationId, limit: 100 },
+ }),
+ staleTime: 30_000,
+ })
+
+ return (
+
+
+
+
+ Subject
+ Priority
+ Channel
+ Last activity
+
+
+
+ {data.rows.length === 0 ? (
+
+
+ No tickets for this organization.
+
+
+ ) : (
+ data.rows.map((t) => (
+
+
+
+ {t.subject}
+
+
+
+
+ {t.priority}
+
+
+ {t.channel}
+
+ {t.lastActivityAt ? new Date(t.lastActivityAt).toLocaleString() : '—'}
+
+
+ ))
+ )}
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/customers/__tests__/customer-people-table.test.tsx b/apps/web/src/components/admin/customers/__tests__/customer-people-table.test.tsx
new file mode 100644
index 000000000..96ea95e7a
--- /dev/null
+++ b/apps/web/src/components/admin/customers/__tests__/customer-people-table.test.tsx
@@ -0,0 +1,267 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { CustomerPeopleTable } from '../customer-people-table'
+
+type PermissionData = {
+ workspacePermissions: string[]
+ teamPermissions: Array<{ permissions: string[] }>
+}
+
+type PersonRow = {
+ id: string
+ name: string | null
+ email: string | null
+ avatarUrl: string | null
+ contactId: string | null
+ principalIds: string[]
+ organizationName: string | null
+ title: string | null
+ hasPortalUser: boolean
+ emailVerified: boolean
+ segments: Array<{ id: string; name: string }>
+ postCount: number
+ commentCount: number
+ voteCount: number
+ ticketCount: number
+ archivedAt: string | null
+ kind: 'linked' | 'contact' | 'user'
+}
+
+const mocks = vi.hoisted(() => ({
+ customerPeopleArgs: [] as Array>,
+ permissionData: null as PermissionData | null,
+ items: [] as PersonRow[],
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useSuspenseQuery: () => ({
+ data: { items: mocks.items },
+ }),
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ Link: ({
+ children,
+ to,
+ params,
+ search,
+ }: {
+ children: ReactNode
+ to: string
+ params?: Record
+ search?: Record
+ className?: string
+ }) => {
+ let href = to
+ if (params) {
+ for (const [key, value] of Object.entries(params)) {
+ href = href.replace(`$${key}`, value)
+ }
+ }
+ const query = search ? `?${new URLSearchParams(search).toString()}` : ''
+ return {children}
+ },
+}))
+
+vi.mock('@/components/ui/table', () => ({
+ Table: ({ children }: { children: ReactNode }) => ,
+ TableBody: ({ children }: { children: ReactNode }) => {children} ,
+ TableCell: ({
+ children,
+ colSpan,
+ }: {
+ children?: ReactNode
+ colSpan?: number
+ className?: string
+ }) => {children} ,
+ TableHead: ({ children }: { children?: ReactNode; className?: string }) => {children} ,
+ TableHeader: ({ children }: { children: ReactNode }) => {children} ,
+ TableRow: ({ children }: { children: ReactNode; className?: string }) => {children} ,
+}))
+
+vi.mock('@/components/ui/badge', () => ({
+ Badge: ({ children }: { children: ReactNode; variant?: string; className?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/avatar', () => ({
+ Avatar: ({ src, name }: { src?: string | null; name?: string | null; className?: string }) => (
+ {src ? `avatar:${src}` : `avatar:${name ?? 'empty'}`}
+ ),
+}))
+
+vi.mock('@/lib/client/queries/admin', () => ({
+ adminQueries: {
+ customerPeople: (args: Record) => {
+ mocks.customerPeopleArgs.push(args)
+ return { queryKey: ['admin', 'customer-people', args] }
+ },
+ },
+}))
+
+vi.mock('@/lib/client/hooks/use-authz-queries', () => ({
+ useMyPermissions: () => ({
+ data: mocks.permissionData,
+ }),
+}))
+
+vi.mock('@/lib/server/domains/authz', () => ({
+ PERMISSIONS: {
+ ORG_VIEW: 'org.view',
+ TICKET_VIEW_ALL: 'ticket.view_all',
+ },
+}))
+
+function person(overrides: Partial): PersonRow {
+ return {
+ id: 'person_1',
+ name: 'Ada Lovelace',
+ email: 'ada@example.com',
+ avatarUrl: null,
+ contactId: 'contact_1',
+ principalIds: ['principal_1'],
+ organizationName: 'Acme',
+ title: 'Engineer',
+ hasPortalUser: true,
+ emailVerified: true,
+ segments: [],
+ postCount: 0,
+ commentCount: 0,
+ voteCount: 0,
+ ticketCount: 0,
+ archivedAt: null,
+ kind: 'linked',
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.customerPeopleArgs = []
+ mocks.permissionData = {
+ workspacePermissions: ['org.view'],
+ teamPermissions: [{ permissions: ['ticket.view_all'] }],
+ }
+ mocks.items = [
+ person({
+ id: 'person_contact',
+ name: 'Ada Lovelace',
+ contactId: 'contact_ada',
+ segments: [
+ { id: 'seg_1', name: 'VIP' },
+ { id: 'seg_2', name: 'Beta' },
+ { id: 'seg_3', name: 'Enterprise' },
+ { id: 'seg_4', name: 'EU' },
+ ],
+ postCount: 1_200,
+ commentCount: 5,
+ voteCount: 0,
+ ticketCount: 1_500,
+ kind: 'linked',
+ }),
+ person({
+ id: 'person_user',
+ name: null,
+ email: null,
+ avatarUrl: 'https://example.com/avatar.png',
+ contactId: null,
+ principalIds: ['principal_user'],
+ organizationName: null,
+ title: null,
+ hasPortalUser: false,
+ emailVerified: false,
+ segments: [],
+ postCount: 0,
+ commentCount: 2,
+ voteCount: 3,
+ ticketCount: 0,
+ archivedAt: '2026-06-01T00:00:00.000Z',
+ kind: 'user',
+ }),
+ person({
+ id: 'person_unverified',
+ name: 'Unverified User',
+ email: 'unverified@example.com',
+ contactId: 'contact_unverified',
+ hasPortalUser: true,
+ emailVerified: false,
+ organizationName: null,
+ title: null,
+ archivedAt: null,
+ kind: 'contact',
+ }),
+ ]
+})
+
+describe('CustomerPeopleTable', () => {
+ it('passes trimmed search and archived filters into the admin query', () => {
+ render( )
+
+ expect(mocks.customerPeopleArgs[0]).toEqual({
+ includeArchived: true,
+ limit: 100,
+ search: 'ada',
+ })
+ })
+
+ it('renders CRM and ticket columns when permissions are present', () => {
+ render( )
+
+ expect(screen.getByText('Organization')).toBeInTheDocument()
+ expect(screen.getByText('Tickets')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'Ada Lovelace' })).toHaveAttribute(
+ 'href',
+ '/admin/contacts/people/contact_ada'
+ )
+ expect(screen.getByRole('link', { name: 'person_user' })).toHaveAttribute(
+ 'href',
+ '/admin/users?selected=principal_user'
+ )
+ expect(screen.getByText('avatar:https://example.com/avatar.png')).toBeInTheDocument()
+ expect(screen.getByText('No email')).toBeInTheDocument()
+ expect(screen.getByText('Acme')).toBeInTheDocument()
+ expect(screen.getByText('Engineer')).toBeInTheDocument()
+ expect(screen.getAllByText('Portal user')).toHaveLength(2)
+ expect(screen.getByText('Verified')).toBeInTheDocument()
+ expect(screen.getByText('Unverified')).toBeInTheDocument()
+ expect(screen.getByText('Contact only')).toBeInTheDocument()
+ expect(screen.getByText('VIP')).toBeInTheDocument()
+ expect(screen.getByText('+1')).toBeInTheDocument()
+ expect(screen.getByText('1.2k')).toBeInTheDocument()
+ expect(screen.getByText('1.5k')).toBeInTheDocument()
+ expect(screen.getByText('Linked')).toBeInTheDocument()
+ expect(screen.getByText('Archived')).toBeInTheDocument()
+ expect(screen.getByText('Active')).toBeInTheDocument()
+ })
+
+ it('hides CRM and ticket columns when permissions are missing', () => {
+ mocks.permissionData = {
+ workspacePermissions: [],
+ teamPermissions: [],
+ }
+
+ render( )
+
+ expect(mocks.customerPeopleArgs[0]).toEqual({
+ includeArchived: false,
+ limit: 100,
+ })
+ expect(screen.queryByText('Organization')).not.toBeInTheDocument()
+ expect(screen.queryByText('Tickets')).not.toBeInTheDocument()
+ expect(screen.queryByText('Acme')).not.toBeInTheDocument()
+ expect(screen.queryByText('1.5k')).not.toBeInTheDocument()
+ })
+
+ it('renders the empty state with the correct column count when permissions are unknown', () => {
+ mocks.permissionData = null
+ mocks.items = []
+
+ render( )
+
+ expect(screen.getByText('No people.')).toBeInTheDocument()
+ expect(screen.getByText('No people.').closest('td')).toHaveAttribute('colspan', '5')
+ })
+})
diff --git a/apps/web/src/components/admin/customers/customer-people-table.tsx b/apps/web/src/components/admin/customers/customer-people-table.tsx
new file mode 100644
index 000000000..823601f7b
--- /dev/null
+++ b/apps/web/src/components/admin/customers/customer-people-table.tsx
@@ -0,0 +1,179 @@
+import { Link } from '@tanstack/react-router'
+import { useSuspenseQuery } from '@tanstack/react-query'
+import type { ContactId, PrincipalId } from '@quackback/ids'
+import { adminQueries } from '@/lib/client/queries/admin'
+import { useMyPermissions } from '@/lib/client/hooks/use-authz-queries'
+import { PERMISSIONS, type PermissionKey } from '@/lib/server/domains/authz'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Badge } from '@/components/ui/badge'
+import { Avatar } from '@/components/ui/avatar'
+
+interface Props {
+ search: string
+ showArchived: boolean
+}
+
+function formatCount(value: number): string {
+ return value > 999 ? `${Math.round(value / 100) / 10}k` : String(value)
+}
+
+function permissionSetHas(
+ permissionData: ReturnType['data'],
+ permission: PermissionKey
+): boolean {
+ if (!permissionData) return false
+ return (
+ permissionData.workspacePermissions.includes(permission) ||
+ permissionData.teamPermissions.some((team) => team.permissions.includes(permission))
+ )
+}
+
+export function CustomerPeopleTable({ search, showArchived }: Props) {
+ const trimmedSearch = search.trim()
+ const { data: myPerms } = useMyPermissions()
+ const showCrmColumns = permissionSetHas(myPerms, PERMISSIONS.ORG_VIEW)
+ const showTicketColumn = permissionSetHas(myPerms, PERMISSIONS.TICKET_VIEW_ALL)
+ const columnCount = 5 + (showCrmColumns ? 1 : 0) + (showTicketColumn ? 1 : 0)
+ const { data } = useSuspenseQuery(
+ adminQueries.customerPeople({
+ includeArchived: showArchived,
+ limit: 100,
+ ...(trimmedSearch ? { search: trimmedSearch } : {}),
+ })
+ )
+
+ return (
+
+
+
+
+ Person
+ {showCrmColumns ? Organization : null}
+ Portal
+ Segments
+ Activity
+ {showTicketColumn ? Tickets : null}
+ Status
+
+
+
+ {data.items.length === 0 ? (
+
+
+ No people.
+
+
+ ) : (
+ data.items.map((person) => {
+ const label = person.name ?? person.email ?? person.id
+ const personLink = person.contactId ? (
+
+ {label}
+
+ ) : (
+
+ {label}
+
+ )
+
+ return (
+
+
+
+
+
+ {personLink}
+
+ {person.email ?? 'No email'}
+
+
+
+
+ {showCrmColumns ? (
+
+ {person.organizationName ?? '—'}
+ {person.title ? (
+ {person.title}
+ ) : null}
+
+ ) : null}
+
+ {person.hasPortalUser ? (
+
+ Portal user
+ {person.emailVerified ? (
+ Verified
+ ) : (
+
+ Unverified
+
+ )}
+
+ ) : (
+
+ Contact only
+
+ )}
+
+
+
+ {person.segments.length === 0 ? (
+ —
+ ) : (
+ person.segments.slice(0, 3).map((segment) => (
+
+ {segment.name}
+
+ ))
+ )}
+ {person.segments.length > 3 ? (
+ +{person.segments.length - 3}
+ ) : null}
+
+
+
+ {formatCount(person.postCount + person.commentCount + person.voteCount)}
+
+ {showTicketColumn ? (
+
+ {formatCount(person.ticketCount)}
+
+ ) : null}
+
+ {person.archivedAt ? (
+
+ Archived
+
+ ) : person.kind === 'linked' ? (
+ Linked
+ ) : (
+ Active
+ )}
+
+
+ )
+ })
+ )}
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/notifications/__tests__/my-ticket-subscriptions-panel.test.tsx b/apps/web/src/components/admin/notifications/__tests__/my-ticket-subscriptions-panel.test.tsx
new file mode 100644
index 000000000..2327cee92
--- /dev/null
+++ b/apps/web/src/components/admin/notifications/__tests__/my-ticket-subscriptions-panel.test.tsx
@@ -0,0 +1,291 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { MyTicketSubscriptionsPanel } from '../my-ticket-subscriptions-panel'
+
+type Subscription = {
+ id: string
+ ticketId: string
+ mutedUntil: string | null
+ source: string
+ ticket: {
+ subject: string | null
+ priority: string
+ updatedAt: string
+ }
+}
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ fetchNextPage: vi.fn(),
+ unsubscribeFromTicketFn: vi.fn(),
+ listMyTicketSubscriptionsFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+ isLoading: false,
+ hasNextPage: false,
+ isFetchingNextPage: false,
+ pages: [] as Array<{ subscriptions: Subscription[]; nextCursor: unknown }>,
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useInfiniteQuery: (options: { queryFn: (args: { pageParam: unknown }) => unknown }) => {
+ options.queryFn({ pageParam: null })
+ return {
+ data: { pages: mocks.pages },
+ isLoading: mocks.isLoading,
+ hasNextPage: mocks.hasNextPage,
+ isFetchingNextPage: mocks.isFetchingNextPage,
+ fetchNextPage: mocks.fetchNextPage,
+ }
+ },
+ useMutation: (options: { mutationFn: (ticketId: string) => Promise }) => ({
+ mutateAsync: (ticketId: string) => options.mutationFn(ticketId),
+ }),
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ Link: ({
+ children,
+ to,
+ params,
+ }: {
+ children: ReactNode
+ to: string
+ params: { ticketId: string }
+ className?: string
+ }) => {children} ,
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ size?: string
+ variant?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/checkbox', () => ({
+ Checkbox: ({
+ checked,
+ onCheckedChange,
+ 'aria-label': ariaLabel,
+ }: {
+ checked: boolean | 'indeterminate'
+ onCheckedChange: (checked: boolean) => void
+ 'aria-label'?: string
+ }) => (
+ onCheckedChange(checked !== true)}
+ />
+ ),
+}))
+
+vi.mock('@/components/ui/badge', () => ({
+ Badge: ({ children }: { children: ReactNode; variant?: string; className?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/shared/empty-state', () => ({
+ EmptyState: ({
+ title,
+ description,
+ }: {
+ icon: unknown
+ title: string
+ description: string
+ className?: string
+ }) => (
+
+
{title}
+
{description}
+
+ ),
+}))
+
+vi.mock('@/components/shared/spinner', () => ({
+ Spinner: ({ size }: { size?: string }) => Spinner:{size}
,
+}))
+
+vi.mock('@/components/ui/time-ago', () => ({
+ TimeAgo: ({ date }: { date: string }) => {date} ,
+}))
+
+vi.mock('@/components/admin/tickets/ticket-priority-chip', () => ({
+ TicketPriorityChip: ({ priority }: { priority: string }) => priority:{priority} ,
+}))
+
+vi.mock('@heroicons/react/24/outline', () => ({
+ BellIcon: () => bell ,
+ BellSlashIcon: () => bell-slash ,
+}))
+
+vi.mock('@/lib/server/functions/notifications', () => ({
+ listMyTicketSubscriptionsFn: mocks.listMyTicketSubscriptionsFn,
+ unsubscribeFromTicketFn: mocks.unsubscribeFromTicketFn,
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+function subscription(overrides: Partial): Subscription {
+ return {
+ id: 'subscription_1',
+ ticketId: 'ticket_1',
+ mutedUntil: null,
+ source: 'manual',
+ ticket: {
+ subject: 'Printer is down',
+ priority: 'normal',
+ updatedAt: '2026-06-18T12:00:00.000Z',
+ },
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.isLoading = false
+ mocks.hasNextPage = false
+ mocks.isFetchingNextPage = false
+ mocks.pages = [
+ {
+ subscriptions: [
+ subscription({ id: 'sub_1', ticketId: 'ticket_1', source: 'manual' }),
+ subscription({
+ id: 'sub_2',
+ ticketId: 'ticket_fail',
+ source: 'auto_team_member',
+ mutedUntil: '2099-01-01T00:00:00.000Z',
+ ticket: {
+ subject: null,
+ priority: 'urgent',
+ updatedAt: '2026-06-18T13:00:00.000Z',
+ },
+ }),
+ subscription({
+ id: 'sub_3',
+ ticketId: 'ticket_custom',
+ source: 'custom_source',
+ ticket: {
+ subject: 'Custom source ticket',
+ priority: 'low',
+ updatedAt: '2026-06-18T14:00:00.000Z',
+ },
+ }),
+ ],
+ nextCursor: null,
+ },
+ ]
+ mocks.listMyTicketSubscriptionsFn.mockResolvedValue({ subscriptions: [], nextCursor: null })
+ mocks.unsubscribeFromTicketFn.mockResolvedValue(undefined)
+})
+
+describe('MyTicketSubscriptionsPanel', () => {
+ it('renders loading and empty states', () => {
+ mocks.isLoading = true
+ render( )
+
+ expect(screen.getByText('Spinner:xl')).toBeInTheDocument()
+
+ mocks.isLoading = false
+ mocks.pages = [{ subscriptions: [], nextCursor: null }]
+ const { rerender } = render( )
+ rerender( )
+
+ expect(screen.getByText('No ticket subscriptions yet')).toBeInTheDocument()
+ expect(
+ screen.getByText('Subscribe to a ticket from its detail page to get notified about updates.')
+ ).toBeInTheDocument()
+ })
+
+ it('renders subscription labels, muted state and pagination controls', () => {
+ mocks.hasNextPage = true
+ render( )
+
+ expect(mocks.listMyTicketSubscriptionsFn).toHaveBeenCalledWith({
+ data: { limit: 50, cursor: undefined },
+ })
+ expect(screen.getByText('3 subscriptions')).toBeInTheDocument()
+ expect(screen.getByText('Printer is down')).toBeInTheDocument()
+ expect(screen.getByText('(no subject)')).toBeInTheDocument()
+ expect(screen.getByText('Manual')).toBeInTheDocument()
+ expect(screen.getByText('Auto · team')).toBeInTheDocument()
+ expect(screen.getByText('custom_source')).toBeInTheDocument()
+ expect(screen.getByText('priority:urgent')).toBeInTheDocument()
+ expect(screen.getByText('muted until')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Load more' }))
+ expect(mocks.fetchNextPage).toHaveBeenCalledTimes(1)
+
+ mocks.isFetchingNextPage = true
+ const { rerender } = render( )
+ rerender( )
+ expect(screen.getByRole('button', { name: 'Loading…' })).toBeDisabled()
+ })
+
+ it('selects individual and all subscriptions and bulk unsubscribes successfully', async () => {
+ render( )
+
+ expect(screen.getByRole('button', { name: 'Unsubscribe selected' })).toBeDisabled()
+ fireEvent.click(screen.getByLabelText('Select ticket Printer is down'))
+ expect(screen.getByText('1 selected')).toBeInTheDocument()
+ expect(screen.getByLabelText('Select all')).toHaveAttribute('data-state', 'indeterminate')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Unsubscribe selected' }))
+ await waitFor(() => {
+ expect(mocks.unsubscribeFromTicketFn).toHaveBeenCalledWith({
+ data: { ticketId: 'ticket_1' },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['tickets', 'my-subscriptions'],
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Unsubscribed from 1 ticket')
+
+ fireEvent.click(screen.getByLabelText('Select all'))
+ expect(screen.getByText('3 selected')).toBeInTheDocument()
+ fireEvent.click(screen.getByLabelText('Select all'))
+ expect(screen.getByText('3 subscriptions')).toBeInTheDocument()
+ })
+
+ it('reports partial bulk unsubscribe failures', async () => {
+ mocks.unsubscribeFromTicketFn.mockImplementation(({ data }: { data: { ticketId: string } }) =>
+ data.ticketId === 'ticket_fail' ? Promise.reject(new Error('Denied')) : Promise.resolve()
+ )
+
+ render( )
+
+ fireEvent.click(screen.getByLabelText('Select all'))
+ fireEvent.click(screen.getByRole('button', { name: 'Unsubscribe selected' }))
+
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Unsubscribed from 2; 1 failed')
+ })
+ })
+})
diff --git a/apps/web/src/components/admin/notifications/my-ticket-subscriptions-panel.tsx b/apps/web/src/components/admin/notifications/my-ticket-subscriptions-panel.tsx
new file mode 100644
index 000000000..95cfbcd30
--- /dev/null
+++ b/apps/web/src/components/admin/notifications/my-ticket-subscriptions-panel.tsx
@@ -0,0 +1,189 @@
+/**
+ * Lists all tickets the current principal is subscribed to. Allows
+ * bulk unsubscribe via sequential mutations (page-size N is small).
+ */
+import { useState } from 'react'
+import { Link } from '@tanstack/react-router'
+import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { BellIcon, BellSlashIcon } from '@heroicons/react/24/outline'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Badge } from '@/components/ui/badge'
+import { EmptyState } from '@/components/shared/empty-state'
+import { Spinner } from '@/components/shared/spinner'
+import { TimeAgo } from '@/components/ui/time-ago'
+import {
+ TicketPriorityChip,
+ type TicketPriority,
+} from '@/components/admin/tickets/ticket-priority-chip'
+import {
+ listMyTicketSubscriptionsFn,
+ unsubscribeFromTicketFn,
+} from '@/lib/server/functions/notifications'
+
+const SOURCE_LABEL: Record = {
+ manual: 'Manual',
+ auto_assigned: 'Auto · assignee',
+ auto_participant: 'Auto · participant',
+ auto_team_member: 'Auto · team',
+}
+
+export function MyTicketSubscriptionsPanel() {
+ const qc = useQueryClient()
+ const [selected, setSelected] = useState>(new Set())
+ const [unsubscribing, setUnsubscribing] = useState(false)
+
+ const query = useInfiniteQuery({
+ queryKey: ['tickets', 'my-subscriptions'] as const,
+ initialPageParam: null as { createdAt: string; id: string } | null,
+ queryFn: ({ pageParam }) =>
+ listMyTicketSubscriptionsFn({
+ data: { limit: 50, cursor: pageParam ?? undefined },
+ }),
+ getNextPageParam: (last) => last.nextCursor,
+ })
+
+ const unsubscribeOne = useMutation({
+ mutationFn: (ticketId: string) => unsubscribeFromTicketFn({ data: { ticketId } }),
+ })
+
+ const subscriptions = query.data?.pages.flatMap((p) => p.subscriptions) ?? []
+
+ function toggle(ticketId: string, on: boolean) {
+ setSelected((prev) => {
+ const next = new Set(prev)
+ if (on) next.add(ticketId)
+ else next.delete(ticketId)
+ return next
+ })
+ }
+
+ function toggleAll(on: boolean) {
+ setSelected(on ? new Set(subscriptions.map((s) => s.ticketId)) : new Set())
+ }
+
+ async function bulkUnsubscribe() {
+ if (selected.size === 0) return
+ setUnsubscribing(true)
+ let ok = 0
+ let failed = 0
+ for (const ticketId of selected) {
+ try {
+ await unsubscribeOne.mutateAsync(ticketId)
+ ok += 1
+ } catch {
+ failed += 1
+ }
+ }
+ setSelected(new Set())
+ setUnsubscribing(false)
+ qc.invalidateQueries({ queryKey: ['tickets', 'my-subscriptions'] })
+ if (failed === 0) {
+ toast.success(`Unsubscribed from ${ok} ticket${ok === 1 ? '' : 's'}`)
+ } else {
+ toast.error(`Unsubscribed from ${ok}; ${failed} failed`)
+ }
+ }
+
+ if (query.isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (subscriptions.length === 0) {
+ return (
+
+ )
+ }
+
+ const allSelected = selected.size === subscriptions.length
+ const someSelected = selected.size > 0 && !allSelected
+
+ return (
+
+
+
toggleAll(v === true)}
+ aria-label="Select all"
+ />
+
+ {selected.size > 0
+ ? `${selected.size} selected`
+ : `${subscriptions.length} subscription${subscriptions.length === 1 ? '' : 's'}`}
+
+
+
+
+ Unsubscribe selected
+
+
+
+
+ {subscriptions.map((s) => {
+ const isMuted = s.mutedUntil && new Date(s.mutedUntil).getTime() > Date.now()
+ return (
+
+ toggle(s.ticketId, v === true)}
+ aria-label={`Select ticket ${s.ticket.subject ?? s.ticketId}`}
+ />
+
+
+ {s.ticket.subject ?? '(no subject)'}
+
+
+
+
+ {SOURCE_LABEL[s.source] ?? s.source}
+
+ {isMuted && (
+
+
+ muted until
+
+ )}
+ ·
+
+ updated
+
+
+
+
+ )
+ })}
+
+ {query.hasNextPage && (
+
+ query.fetchNextPage()}
+ disabled={query.isFetchingNextPage}
+ >
+ {query.isFetchingNextPage ? 'Loading…' : 'Load more'}
+
+
+ )}
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/__tests__/membership-tabs.test.tsx b/apps/web/src/components/admin/settings/__tests__/membership-tabs.test.tsx
new file mode 100644
index 000000000..86febb24d
--- /dev/null
+++ b/apps/web/src/components/admin/settings/__tests__/membership-tabs.test.tsx
@@ -0,0 +1,449 @@
+// @vitest-environment happy-dom
+import type { ChangeEvent, ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { InboxMembersTab } from '../inboxes/inbox-members-tab'
+import { TeamMembersTab } from '../teams/team-members-tab'
+
+type QueryOptions = {
+ queryKey: readonly unknown[]
+ queryFn: () => T
+ enabled?: boolean
+ staleTime?: number
+}
+
+type MutationOptions = {
+ mutationFn: (vars: TVars) => Promise
+ onSuccess?: (result: TResult) => void
+ onError?: (error: Error) => void
+}
+
+type InboxMembership = {
+ id: string
+ principalId: string
+ role: 'owner' | 'agent' | 'viewer'
+}
+
+type TeamMembership = {
+ id: string
+ principalId: string
+ role: 'lead' | 'member'
+}
+
+type Principal = {
+ id: string
+ displayName: string | null
+ avatarUrl: string | null
+}
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ addInboxMembershipFn: vi.fn(),
+ updateInboxMembershipRoleFn: vi.fn(),
+ removeInboxMembershipFn: vi.fn(),
+ addTeamMemberFn: vi.fn(),
+ removeTeamMemberFn: vi.fn(),
+ getPrincipalsByIdsFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+ permissionAllowed: true,
+ inboxMemberships: [] as InboxMembership[],
+ teamMemberships: [] as TeamMembership[],
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useSuspenseQuery: (options: { queryKey: readonly unknown[] }) => {
+ const [scope] = options.queryKey
+ return {
+ data: scope === 'inboxes' ? mocks.inboxMemberships : mocks.teamMemberships,
+ }
+ },
+ useQuery: (options: QueryOptions) => ({
+ data: options.enabled === false ? undefined : options.queryFn(),
+ }),
+ useMutation: (options: MutationOptions) => ({
+ isPending: false,
+ mutate: async (vars: TVars) => {
+ try {
+ const result = await options.mutationFn(vars)
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@/components/admin/shared/permission-gate', () => ({
+ PermissionGate: ({
+ children,
+ fallback = null,
+ }: {
+ children: ReactNode
+ fallback?: ReactNode
+ permission: string
+ }) => (mocks.permissionAllowed ? <>{children}> : <>{fallback}>),
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ 'aria-label': ariaLabel,
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ size?: string
+ className?: string
+ 'aria-label'?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/avatar', () => ({
+ Avatar: ({ children }: { children: ReactNode; className?: string }) => {children} ,
+}))
+
+vi.mock('@/components/ui/table', () => ({
+ Table: ({ children }: { children: ReactNode }) => ,
+ TableBody: ({ children }: { children: ReactNode }) => {children} ,
+ TableCell: ({
+ children,
+ colSpan,
+ }: {
+ children?: ReactNode
+ colSpan?: number
+ className?: string
+ }) => {children} ,
+ TableHead: ({ children }: { children?: ReactNode; className?: string }) => {children} ,
+ TableHeader: ({ children }: { children: ReactNode }) => {children} ,
+ TableRow: ({ children }: { children: ReactNode }) => {children} ,
+}))
+
+vi.mock('@/components/ui/select', () => ({
+ Select: ({
+ value,
+ onValueChange,
+ children,
+ }: {
+ value: string
+ onValueChange: (value: string) => void
+ children: ReactNode
+ }) => (
+ ) => onValueChange(event.currentTarget.value)}
+ >
+ {children}
+
+ ),
+ SelectContent: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectItem: ({ children, value }: { children: ReactNode; value: string }) => (
+ {children}
+ ),
+ SelectTrigger: ({ children }: { children: ReactNode; className?: string }) => <>{children}>,
+ SelectValue: () => null,
+}))
+
+vi.mock('@/components/ui/alert-dialog', () => ({
+ AlertDialog: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogAction: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
+
+ {children}
+
+ ),
+ AlertDialogCancel: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ AlertDialogContent: ({ children }: { children: ReactNode }) => ,
+ AlertDialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogFooter: ({ children }: { children: ReactNode }) => ,
+ AlertDialogHeader: ({ children }: { children: ReactNode }) => ,
+ AlertDialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+ AlertDialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}>,
+}))
+
+vi.mock('@/components/admin/shared/principal-picker', () => ({
+ PrincipalPicker: ({
+ value,
+ onValueChange,
+ excludeIds,
+ placeholder,
+ }: {
+ value: string | null
+ onValueChange: (value: string | null) => void
+ excludeIds: string[]
+ placeholder?: string
+ }) => (
+ ) =>
+ onValueChange(event.currentTarget.value || null)
+ }
+ >
+ {placeholder ?? 'Pick principal'}
+ New principal
+ Extra principal
+
+ ),
+}))
+
+vi.mock('@heroicons/react/24/outline', () => ({
+ TrashIcon: () => trash ,
+}))
+
+vi.mock('@/lib/server/functions/inboxes', () => ({
+ addInboxMembershipFn: mocks.addInboxMembershipFn,
+ updateInboxMembershipRoleFn: mocks.updateInboxMembershipRoleFn,
+ removeInboxMembershipFn: mocks.removeInboxMembershipFn,
+}))
+
+vi.mock('@/lib/server/functions/teams', () => ({
+ addTeamMemberFn: mocks.addTeamMemberFn,
+ removeTeamMemberFn: mocks.removeTeamMemberFn,
+}))
+
+vi.mock('@/lib/server/functions/principals', () => ({
+ getPrincipalsByIdsFn: mocks.getPrincipalsByIdsFn,
+}))
+
+vi.mock('@/lib/client/queries/inboxes', () => ({
+ inboxQueries: {
+ memberships: (inboxId: string) => ({
+ queryKey: ['inboxes', inboxId, 'memberships'],
+ }),
+ },
+}))
+
+vi.mock('@/lib/client/queries/teams', () => ({
+ teamQueries: {
+ members: (teamId: string) => ({
+ queryKey: ['teams', teamId, 'members'],
+ }),
+ },
+}))
+
+vi.mock('@/lib/server/domains/authz', () => ({
+ PERMISSIONS: {
+ INBOX_MANAGE: 'inbox.manage',
+ ADMIN_MANAGE_USERS: 'admin.manage_users',
+ },
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.permissionAllowed = true
+ mocks.inboxMemberships = [
+ { id: 'inbox_member_owner', principalId: 'principal_owner', role: 'owner' },
+ { id: 'inbox_member_viewer', principalId: 'principal_viewer', role: 'viewer' },
+ ]
+ mocks.teamMemberships = [{ id: 'team_member_lead', principalId: 'principal_lead', role: 'lead' }]
+ mocks.getPrincipalsByIdsFn.mockImplementation(
+ ({ data }: { data: { ids: string[] } }): Principal[] =>
+ data.ids.map((id) => ({
+ id,
+ displayName:
+ id === 'principal_owner' ? 'Owner Person' : id === 'principal_lead' ? 'Team Lead' : null,
+ avatarUrl: id === 'principal_owner' ? 'https://example.com/avatar.png' : null,
+ }))
+ )
+ mocks.addInboxMembershipFn.mockResolvedValue({ id: 'inbox_member_new' })
+ mocks.updateInboxMembershipRoleFn.mockResolvedValue({ id: 'inbox_member_owner' })
+ mocks.removeInboxMembershipFn.mockResolvedValue(undefined)
+ mocks.addTeamMemberFn.mockResolvedValue({ id: 'team_member_new' })
+ mocks.removeTeamMemberFn.mockResolvedValue(undefined)
+})
+
+describe('membership tabs', () => {
+ it('renders the inbox member table with enriched principals and empty-state fallback', () => {
+ render( )
+
+ expect(mocks.getPrincipalsByIdsFn).toHaveBeenCalledWith({
+ data: { ids: ['principal_owner', 'principal_viewer'] },
+ })
+ expect(screen.getByText('Owner Person')).toBeInTheDocument()
+ expect(screen.getByText('principal_viewer')).toBeInTheDocument()
+ expect(screen.getByAltText('')).toHaveAttribute('src', 'https://example.com/avatar.png')
+ expect(screen.getByLabelText('Add member')).toHaveAttribute(
+ 'data-exclude-ids',
+ 'principal_owner,principal_viewer'
+ )
+
+ cleanup()
+ mocks.inboxMemberships = []
+ render( )
+
+ expect(screen.getByText('No members yet.')).toBeInTheDocument()
+ })
+
+ it('adds, updates and removes inbox members with cache invalidation and toast feedback', async () => {
+ render( )
+
+ expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled()
+ fireEvent.change(screen.getByLabelText('Add member'), {
+ target: { value: 'principal_new' },
+ })
+ fireEvent.change(screen.getByLabelText('select-agent'), { target: { value: 'owner' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Add' }))
+
+ await waitFor(() => {
+ expect(mocks.addInboxMembershipFn).toHaveBeenCalledWith({
+ data: {
+ inboxId: 'inbox_support',
+ principalId: 'principal_new',
+ role: 'owner',
+ },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['inboxes', 'inbox_support', 'memberships'],
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Member added')
+
+ fireEvent.change(screen.getByLabelText('select-owner'), { target: { value: 'viewer' } })
+ await waitFor(() => {
+ expect(mocks.updateInboxMembershipRoleFn).toHaveBeenCalledWith({
+ data: {
+ membershipId: 'inbox_member_owner',
+ role: 'viewer',
+ },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Role updated')
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Remove' })[0])
+ await waitFor(() => {
+ expect(mocks.removeInboxMembershipFn).toHaveBeenCalledWith({
+ data: { membershipId: 'inbox_member_owner' },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Member removed')
+ })
+
+ it('shows inbox role fallbacks and surfaces mutation failures when access is denied or rejected', async () => {
+ mocks.permissionAllowed = false
+ render( )
+
+ expect(screen.queryByLabelText('Add member')).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Remove member' })).not.toBeInTheDocument()
+ expect(screen.getByText('owner')).toBeInTheDocument()
+ expect(screen.getByText('viewer')).toBeInTheDocument()
+
+ cleanup()
+ mocks.permissionAllowed = true
+ mocks.addInboxMembershipFn.mockRejectedValueOnce(new Error('Already a member'))
+ render( )
+
+ fireEvent.change(screen.getByLabelText('Add member'), {
+ target: { value: 'principal_extra' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Add' }))
+
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Already a member')
+ })
+ })
+
+ it('adds, updates and removes team members through the team member tab', async () => {
+ render( )
+
+ expect(mocks.getPrincipalsByIdsFn).toHaveBeenCalledWith({
+ data: { ids: ['principal_lead'] },
+ })
+ expect(screen.getByText('Team Lead')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled()
+
+ fireEvent.change(screen.getByLabelText('Add member'), {
+ target: { value: 'principal_new' },
+ })
+ fireEvent.change(screen.getByLabelText('select-member'), { target: { value: 'lead' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Add' }))
+
+ await waitFor(() => {
+ expect(mocks.addTeamMemberFn).toHaveBeenCalledWith({
+ data: {
+ teamId: 'team_support',
+ principalId: 'principal_new',
+ role: 'lead',
+ },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['teams', 'team_support', 'members'],
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Member added')
+
+ fireEvent.change(screen.getByLabelText('select-lead'), { target: { value: 'member' } })
+ await waitFor(() => {
+ expect(mocks.addTeamMemberFn).toHaveBeenCalledWith({
+ data: {
+ teamId: 'team_support',
+ principalId: 'principal_lead',
+ role: 'member',
+ },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Role updated')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
+ await waitFor(() => {
+ expect(mocks.removeTeamMemberFn).toHaveBeenCalledWith({
+ data: {
+ teamId: 'team_support',
+ principalId: 'principal_lead',
+ },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Member removed')
+ })
+
+ it('renders team empty and no-permission states and reports mutation failures', async () => {
+ mocks.teamMemberships = []
+ render( )
+
+ expect(screen.getByText('No members yet.')).toBeInTheDocument()
+ expect(mocks.getPrincipalsByIdsFn).not.toHaveBeenCalled()
+
+ cleanup()
+ mocks.teamMemberships = [
+ { id: 'team_member_lead', principalId: 'principal_lead', role: 'lead' },
+ ]
+ mocks.permissionAllowed = false
+ render( )
+
+ expect(screen.queryByLabelText('Add member')).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Remove member' })).not.toBeInTheDocument()
+ expect(screen.getByText('lead')).toBeInTheDocument()
+
+ cleanup()
+ mocks.permissionAllowed = true
+ mocks.removeTeamMemberFn.mockRejectedValueOnce(new Error('Cannot remove last lead'))
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Cannot remove last lead')
+ })
+ })
+})
diff --git a/apps/web/src/components/admin/settings/audit/__tests__/audit-csv-and-diff.test.tsx b/apps/web/src/components/admin/settings/audit/__tests__/audit-csv-and-diff.test.tsx
new file mode 100644
index 000000000..3845ab9d1
--- /dev/null
+++ b/apps/web/src/components/admin/settings/audit/__tests__/audit-csv-and-diff.test.tsx
@@ -0,0 +1,121 @@
+// @vitest-environment happy-dom
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import type { UnifiedAuditEventRow } from '@/lib/server/domains/audit/audit.unified'
+import { downloadAuditCsv, rowsToCsv } from '../audit-csv'
+import { AuditDiffViewer } from '../audit-diff-viewer'
+
+function row(overrides: Partial = {}): UnifiedAuditEventRow {
+ return {
+ id: 'audit_1',
+ occurredAt: new Date('2026-06-20T10:00:00.000Z'),
+ origin: 'api',
+ action: 'ticket.updated',
+ outcome: 'success',
+ source: 'rest',
+ principalId: 'principal_1',
+ actorUserId: 'user_1',
+ actorEmail: 'agent@example.com',
+ actorDisplayName: 'Agent, One',
+ actorRole: 'admin',
+ actorType: 'user',
+ authMethod: 'api_key',
+ ipAddress: '203.0.113.10',
+ userAgent: 'cli "quoted"',
+ targetType: 'ticket',
+ targetId: 'ticket_1',
+ requestId: 'req_1',
+ diff: { before: { priority: 'normal' }, after: { priority: 'urgent' } },
+ metadata: { nested: true },
+ ...overrides,
+ } as UnifiedAuditEventRow
+}
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ vi.useRealTimers()
+})
+
+describe('audit CSV helpers', () => {
+ it('serializes audit rows with ISO dates, empty nullable fields and escaped CSV values', () => {
+ const csv = rowsToCsv([
+ row(),
+ row({
+ id: 'audit_2',
+ occurredAt: '2026-06-21T11:00:00.000Z' as unknown as Date,
+ actorEmail: null,
+ actorDisplayName: 'Plain Agent',
+ userAgent: 'line\nbreak',
+ diff: null,
+ metadata: ['array', 'value'],
+ }),
+ ])
+
+ const lines = csv.split('\n')
+ expect(lines[0]).toBe(
+ 'occurred_at,origin,action,outcome,source,actor_principal_id,actor_user_id,actor_email,actor_display_name,actor_role,actor_type,auth_method,ip_address,user_agent,target_type,target_id,request_id,diff,metadata'
+ )
+ expect(lines[1]).toContain('2026-06-20T10:00:00.000Z')
+ expect(lines[1]).toContain('"Agent, One"')
+ expect(lines[1]).toContain('"cli ""quoted"""')
+ expect(lines[1]).toContain('"{""before"":{""priority"":""normal""}')
+ expect(lines[2]).toContain('2026-06-21T11:00:00.000Z')
+ expect(lines[2]).toContain(',,Plain Agent')
+ expect(csv).toContain('"line\nbreak"')
+ expect(csv).toContain('[""array"",""value""]')
+ })
+
+ it('downloads the generated CSV through a temporary object URL', () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-06-20T12:00:00.000Z'))
+ const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:audit')
+ const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined)
+ const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
+
+ downloadAuditCsv([row()])
+
+ expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
+ expect(click).toHaveBeenCalled()
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:audit')
+ expect(document.querySelector('a[download="audit-log-2026-06-20.csv"]')).toBeNull()
+ })
+})
+
+describe('AuditDiffViewer', () => {
+ it('renders before, after, context and request metadata sections', () => {
+ render(
+
+ )
+
+ expect(screen.getByText('Before')).toBeInTheDocument()
+ expect(screen.getByText(/"priority": "normal"/)).toBeInTheDocument()
+ expect(screen.getByText('After')).toBeInTheDocument()
+ expect(screen.getByText(/"priority": "urgent"/)).toBeInTheDocument()
+ expect(screen.getByText('Context')).toBeInTheDocument()
+ expect(screen.getByText('203.0.113.10')).toBeInTheDocument()
+ expect(screen.getByText('quackback-cli/1.0')).toBeInTheDocument()
+ })
+
+ it('renders raw object diffs and a placeholder for empty or primitive diffs without metadata', () => {
+ const { rerender } = render(
+
+ )
+
+ expect(screen.getByText('Diff')).toBeInTheDocument()
+ expect(screen.getByText(/"custom"/)).toBeInTheDocument()
+
+ rerender( )
+ expect(screen.getByText('No change details recorded.')).toBeInTheDocument()
+
+ rerender( )
+ expect(screen.getByText('No change details recorded.')).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/admin/settings/audit/__tests__/audit-table-filter.test.tsx b/apps/web/src/components/admin/settings/audit/__tests__/audit-table-filter.test.tsx
new file mode 100644
index 000000000..d17c53095
--- /dev/null
+++ b/apps/web/src/components/admin/settings/audit/__tests__/audit-table-filter.test.tsx
@@ -0,0 +1,409 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { AuditEventTable } from '../audit-event-table'
+import { AuditFilterBar } from '../audit-filter-bar'
+import type { AuditFilters } from '@/lib/client/queries/audit'
+
+type ComponentProps = {
+ children?: ReactNode
+ className?: string
+ onClick?: () => void
+ disabled?: boolean
+}
+
+const mocks = vi.hoisted(() => ({
+ auditPages: [] as Array<{ items: unknown[] }>,
+ hasNextPage: true,
+ isFetchingNextPage: false,
+ fetchNextPage: vi.fn(),
+ principals: [] as Array<{
+ id: string
+ displayName: string | null
+ email: string | null
+ role: string
+ }>,
+ actions: ['ticket.created', 'ticket.updated'],
+ downloadAuditCsv: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useSuspenseInfiniteQuery: () => ({
+ data: { pages: mocks.auditPages },
+ hasNextPage: mocks.hasNextPage,
+ isFetchingNextPage: mocks.isFetchingNextPage,
+ fetchNextPage: mocks.fetchNextPage,
+ }),
+ useQuery: (options: { queryKey?: readonly unknown[] }) => {
+ if (options.queryKey?.[0] === 'principals') return { data: mocks.principals }
+ return { data: mocks.actions }
+ },
+}))
+
+vi.mock('@/lib/client/queries/audit', async () => {
+ const actual = await vi.importActual(
+ '@/lib/client/queries/audit'
+ )
+ return {
+ ...actual,
+ auditQueries: {
+ list: (filters: unknown) => ({ queryKey: ['audit', 'list', filters] }),
+ actions: () => ({ queryKey: ['audit', 'actions'] }),
+ },
+ defaultAuditFilters: () => ({ limit: 50 }),
+ rangeToFromIso: (range: string) => (range === 'all' ? undefined : `from-${range}`),
+ }
+})
+
+vi.mock('@/lib/client/hooks/use-debounced-value', () => ({
+ useDebouncedValue: (value: unknown) => value,
+}))
+
+vi.mock('@/lib/server/functions/principals', () => ({
+ getPrincipalsByIdsFn: vi.fn(),
+}))
+
+vi.mock('../audit-csv', () => ({
+ downloadAuditCsv: mocks.downloadAuditCsv,
+}))
+
+vi.mock('../audit-diff-viewer', () => ({
+ AuditDiffViewer: ({
+ diff,
+ ipAddress,
+ userAgent,
+ }: {
+ diff?: unknown
+ ipAddress?: string | null
+ userAgent?: string | null
+ }) => (
+
+ Diff {JSON.stringify(diff)} {ipAddress} {userAgent}
+
+ ),
+}))
+
+vi.mock('@/components/ui/table', () => ({
+ Table: ({ children }: ComponentProps) => ,
+ TableHeader: ({ children }: ComponentProps) => {children} ,
+ TableBody: ({ children }: ComponentProps) => {children} ,
+ TableRow: ({ children, className }: ComponentProps) => {children} ,
+ TableHead: ({ children, className }: ComponentProps) => {children} ,
+ TableCell: ({
+ children,
+ className,
+ colSpan,
+ title,
+ }: ComponentProps & { colSpan?: number; title?: string }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/avatar', () => ({
+ Avatar: ({ children, className }: ComponentProps) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/badge', () => ({
+ Badge: ({ children, variant, className }: ComponentProps & { variant?: string }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({ children, onClick, disabled }: ComponentProps) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({
+ value,
+ onChange,
+ onBlur,
+ onKeyDown,
+ placeholder,
+ type,
+ className,
+ list,
+ }: {
+ value?: string
+ onChange?: (event: React.ChangeEvent) => void
+ onBlur?: () => void
+ onKeyDown?: (event: React.KeyboardEvent) => void
+ placeholder?: string
+ type?: string
+ className?: string
+ list?: string
+ }) => (
+
+ ),
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children, htmlFor, className }: ComponentProps & { htmlFor?: string }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/switch', () => ({
+ Switch: ({
+ checked,
+ onCheckedChange,
+ id,
+ }: {
+ checked?: boolean
+ onCheckedChange?: (checked: boolean) => void
+ id?: string
+ }) => (
+ onCheckedChange?.(!checked)}
+ />
+ ),
+}))
+
+vi.mock('@/components/ui/select', async () => {
+ const React = await import('react')
+ const SelectContext = React.createContext<{ onValueChange?: (value: string) => void }>({})
+
+ return {
+ Select: ({
+ value,
+ onValueChange,
+ children,
+ }: {
+ value?: string
+ onValueChange?: (value: string) => void
+ children?: ReactNode
+ }) => (
+
+ {children}
+
+ ),
+ SelectContent: ({ children }: ComponentProps) => {children}
,
+ SelectTrigger: ({ children }: ComponentProps) => {children}
,
+ SelectValue: () => ,
+ SelectItem: ({ value, children }: ComponentProps & { value: string }) => {
+ const context = React.useContext(SelectContext)
+ return (
+ context.onValueChange?.(value)}>
+ {children}
+
+ )
+ },
+ }
+})
+
+vi.mock('@/components/admin/shared/principal-picker', () => ({
+ PrincipalPicker: ({ onValueChange }: { onValueChange: (value: string | null) => void }) => (
+ onValueChange('principal_2')}>
+ Pick actor
+
+ ),
+}))
+
+function auditRow(overrides: Record) {
+ return {
+ id: 'evt-1',
+ origin: 'workspace',
+ principalId: 'principal_1',
+ actorDisplayName: null,
+ actorEmail: null,
+ actorUserId: null,
+ actorType: 'user',
+ actorRole: 'admin',
+ authMethod: 'password',
+ action: 'ticket.created',
+ targetType: 'ticket',
+ targetId: 'ticket-1',
+ outcome: 'success',
+ source: 'web',
+ occurredAt: '2026-06-19T10:15:00.000Z',
+ diff: { after: { subject: 'Hi' } },
+ ipAddress: '127.0.0.1',
+ userAgent: 'Vitest',
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.auditPages = [
+ {
+ items: [
+ auditRow({ id: 'evt-1' }),
+ auditRow({
+ id: 'evt-2',
+ origin: 'security',
+ principalId: null,
+ actorDisplayName: 'Deleted User',
+ actorEmail: 'deleted@example.com',
+ actorRole: null,
+ authMethod: null,
+ outcome: 'failure',
+ source: null,
+ targetType: null,
+ targetId: null,
+ action: 'auth.login.failed',
+ }),
+ auditRow({
+ id: 'evt-3',
+ principalId: null,
+ actorDisplayName: null,
+ actorEmail: null,
+ actorUserId: null,
+ actorType: null,
+ outcome: null,
+ }),
+ ],
+ },
+ ]
+ mocks.principals = [
+ {
+ id: 'principal_1',
+ displayName: 'Ada Admin',
+ email: 'ada@example.com',
+ role: 'owner',
+ },
+ ]
+ mocks.hasNextPage = true
+ mocks.isFetchingNextPage = false
+})
+
+describe('AuditEventTable', () => {
+ it('renders rows, resolves actors, expands details, exports csv, and paginates', () => {
+ render( )
+
+ expect(screen.getByText('3 events shown')).toBeTruthy()
+ expect(screen.getAllByText('Ada Admin').length).toBeGreaterThan(0)
+ expect(screen.getByText('ada@example.com · admin · user · password')).toBeTruthy()
+ expect(screen.getAllByText('Deleted User').length).toBeGreaterThan(0)
+ expect(screen.getAllByText('System').length).toBeGreaterThan(0)
+ expect(screen.getAllByText('success').length).toBeGreaterThan(0)
+ expect(screen.getAllByText('failure').length).toBeGreaterThan(0)
+
+ fireEvent.click(screen.getByRole('button', { name: 'Export CSV' }))
+ expect(mocks.downloadAuditCsv).toHaveBeenCalledWith(mocks.auditPages[0].items)
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Expand row' })[0])
+ expect(screen.getAllByText(/Diff/).length).toBeGreaterThan(0)
+
+ fireEvent.click(screen.getByRole('button', { name: 'Load more' }))
+ expect(mocks.fetchNextPage).toHaveBeenCalledTimes(1)
+ })
+
+ it('renders empty and terminal states', () => {
+ mocks.auditPages = [{ items: [] }]
+ mocks.hasNextPage = false
+
+ render( )
+
+ expect(screen.getByText('0 events shown')).toBeTruthy()
+ expect(
+ screen.getAllByText('No audit events match the current filters.').length
+ ).toBeGreaterThan(0)
+ expect(screen.getByRole('button', { name: 'Export CSV' })).toBeDisabled()
+ expect(screen.getByText('End of matching events.')).toBeTruthy()
+ })
+})
+
+describe('AuditFilterBar', () => {
+ it('propagates filter changes from selects, inputs, actor picker, prefix mode, and clear', () => {
+ const onChange = vi.fn()
+ const value = {
+ limit: 50,
+ origin: 'workspace',
+ principalId: 'principal_1',
+ action: 'ticket.created',
+ targetType: 'ticket',
+ targetId: 'ticket-1',
+ source: 'web',
+ fromIso: '2026-06-19T10:15:00.000Z',
+ toIso: '2026-06-20T10:15:00.000Z',
+ } as AuditFilters
+
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Security' }))
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ origin: 'security' }))
+
+ fireEvent.click(screen.getByRole('button', { name: 'Pick actor' }))
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ principalId: 'principal_2' }))
+
+ fireEvent.change(screen.getByPlaceholderText('e.g. ticket.created'), {
+ target: { value: 'ticket.' },
+ })
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining({ action: 'ticket.', actionPrefix: undefined })
+ )
+
+ fireEvent.click(screen.getByRole('switch'))
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining({ action: undefined, actionPrefix: 'ticket.created' })
+ )
+
+ fireEvent.change(screen.getByPlaceholderText('name@example.com'), {
+ target: { value: 'auditor@example.com' },
+ })
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining({ actorEmail: 'auditor@example.com' })
+ )
+
+ fireEvent.change(screen.getByPlaceholderText('e.g. ticket'), {
+ target: { value: 'organization' },
+ })
+ fireEvent.blur(screen.getByPlaceholderText('e.g. ticket'))
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ targetType: 'organization' }))
+
+ fireEvent.change(screen.getByPlaceholderText('ticket_...'), {
+ target: { value: 'ticket-2' },
+ })
+ fireEvent.keyDown(screen.getByPlaceholderText('ticket_...'), { key: 'Enter' })
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ targetId: 'ticket-2' }))
+
+ fireEvent.click(screen.getByRole('button', { name: 'api' }))
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ source: 'api' }))
+
+ fireEvent.click(screen.getByRole('button', { name: 'All time' }))
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining({ fromIso: undefined, toIso: undefined })
+ )
+
+ fireEvent.change(screen.getByDisplayValue(/2026-06-19T\d\d:15/), {
+ target: { value: '2026-06-21T12:30' },
+ })
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ fromIso: expect.any(String) }))
+
+ fireEvent.click(screen.getByRole('button', { name: 'Clear filters' }))
+ expect(onChange).toHaveBeenCalledWith({ limit: 50 })
+ })
+
+ it('disables clear when no filters are active', () => {
+ render( )
+
+ expect(screen.getByRole('button', { name: 'Clear filters' })).toBeDisabled()
+ })
+})
diff --git a/apps/web/src/components/admin/settings/audit/audit-csv.ts b/apps/web/src/components/admin/settings/audit/audit-csv.ts
new file mode 100644
index 000000000..6e7447f21
--- /dev/null
+++ b/apps/web/src/components/admin/settings/audit/audit-csv.ts
@@ -0,0 +1,79 @@
+import type { UnifiedAuditEventRow } from '@/lib/server/domains/audit/audit.unified'
+
+function toIso(value: Date | string): string {
+ return value instanceof Date ? value.toISOString() : new Date(value).toISOString()
+}
+
+function escapeCsv(value: unknown): string {
+ if (value === null || value === undefined) return ''
+ const text = typeof value === 'string' ? value : JSON.stringify(value)
+ return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
+}
+
+export function rowsToCsv(rows: UnifiedAuditEventRow[]): string {
+ const headers = [
+ 'occurred_at',
+ 'origin',
+ 'action',
+ 'outcome',
+ 'source',
+ 'actor_principal_id',
+ 'actor_user_id',
+ 'actor_email',
+ 'actor_display_name',
+ 'actor_role',
+ 'actor_type',
+ 'auth_method',
+ 'ip_address',
+ 'user_agent',
+ 'target_type',
+ 'target_id',
+ 'request_id',
+ 'diff',
+ 'metadata',
+ ]
+
+ const lines = [
+ headers.join(','),
+ ...rows.map((row) =>
+ [
+ toIso(row.occurredAt),
+ row.origin,
+ row.action,
+ row.outcome,
+ row.source,
+ row.principalId,
+ row.actorUserId,
+ row.actorEmail,
+ row.actorDisplayName,
+ row.actorRole,
+ row.actorType,
+ row.authMethod,
+ row.ipAddress,
+ row.userAgent,
+ row.targetType,
+ row.targetId,
+ row.requestId,
+ row.diff,
+ row.metadata,
+ ]
+ .map(escapeCsv)
+ .join(',')
+ ),
+ ]
+
+ return lines.join('\n')
+}
+
+export function downloadAuditCsv(rows: UnifiedAuditEventRow[]): void {
+ const csv = rowsToCsv(rows)
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+}
diff --git a/apps/web/src/components/admin/settings/audit/audit-diff-viewer.tsx b/apps/web/src/components/admin/settings/audit/audit-diff-viewer.tsx
new file mode 100644
index 000000000..cc76a2443
--- /dev/null
+++ b/apps/web/src/components/admin/settings/audit/audit-diff-viewer.tsx
@@ -0,0 +1,72 @@
+/**
+ * Renders the structured `AuditDiff` JSON in three optional panels
+ * (Before / After / Context) plus a small IP / UA metadata grid.
+ */
+interface Props {
+ diff: unknown
+ ipAddress: string | null
+ userAgent: string | null
+}
+
+function isObject(v: unknown): v is Record {
+ return typeof v === 'object' && v !== null && !Array.isArray(v)
+}
+
+function Section({ label, value }: { label: string; value: unknown }) {
+ return (
+
+
+ {label}
+
+
+ {JSON.stringify(value, null, 2)}
+
+
+ )
+}
+
+export function AuditDiffViewer({ diff, ipAddress, userAgent }: Props) {
+ const obj = isObject(diff) ? diff : null
+ const before = obj && 'before' in obj ? obj.before : undefined
+ const after = obj && 'after' in obj ? obj.after : undefined
+ const context = obj && 'context' in obj ? obj.context : undefined
+ const hasAnyDiffSection = before !== undefined || after !== undefined || context !== undefined
+ const hasMeta = Boolean(ipAddress || userAgent)
+
+ if (!hasAnyDiffSection && !hasMeta) {
+ // Render the raw diff if it's a non-empty primitive/array, otherwise
+ // a small placeholder.
+ if (obj && Object.keys(obj).length > 0) {
+ return (
+
+
+
+ )
+ }
+ return No change details recorded.
+ }
+
+ return (
+
+ {before !== undefined &&
}
+ {after !== undefined &&
}
+ {context !== undefined &&
}
+ {hasMeta && (
+
+ {ipAddress && (
+
+ IP: {' '}
+ {ipAddress}
+
+ )}
+ {userAgent && (
+
+ UA: {' '}
+ {userAgent}
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/audit/audit-event-table.tsx b/apps/web/src/components/admin/settings/audit/audit-event-table.tsx
new file mode 100644
index 000000000..4072bfce5
--- /dev/null
+++ b/apps/web/src/components/admin/settings/audit/audit-event-table.tsx
@@ -0,0 +1,365 @@
+/**
+ * Unified audit event table — cursor-paged via `useSuspenseInfiniteQuery`.
+ * Workspace actors are resolved in a single batched lookup; security rows use
+ * denormalized actor fields so deleted users still render coherently.
+ */
+import { Fragment, useMemo, useState } from 'react'
+import { useQuery, useSuspenseInfiniteQuery } from '@tanstack/react-query'
+import type { PrincipalId } from '@quackback/ids'
+import { auditQueries, type AuditFilters } from '@/lib/client/queries/audit'
+import { getPrincipalsByIdsFn } from '@/lib/server/functions/principals'
+import type { UnifiedAuditEventRow } from '@/lib/server/domains/audit/audit.unified'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Avatar } from '@/components/ui/avatar'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { ArrowDownTrayIcon, ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline'
+import { AuditDiffViewer } from './audit-diff-viewer'
+import { downloadAuditCsv } from './audit-csv'
+
+interface Props {
+ filters: AuditFilters
+}
+
+function toDate(value: Date | string): Date {
+ return value instanceof Date ? value : new Date(value)
+}
+
+function formatTimestamp(value: Date | string): { date: string; time: string; full: string } {
+ const d = toDate(value)
+ return {
+ date: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
+ time: d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }),
+ full: d.toLocaleString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ }),
+ }
+}
+
+function OriginBadge({ origin }: { origin: UnifiedAuditEventRow['origin'] }) {
+ return (
+
+ {origin}
+
+ )
+}
+
+function OutcomeBadge({ outcome }: { outcome: UnifiedAuditEventRow['outcome'] }) {
+ if (!outcome) return —
+ return (
+
+ {outcome}
+
+ )
+}
+
+function TargetCell({ row }: { row: UnifiedAuditEventRow }) {
+ if (!row.targetType) return —
+ return (
+
+ {row.targetType}
+ {row.targetId ? (
+
+ {row.targetId}
+
+ ) : null}
+
+ )
+}
+
+export function AuditEventTable({ filters }: Props) {
+ const query = useSuspenseInfiniteQuery(auditQueries.list(filters))
+ const items = useMemo(() => query.data.pages.flatMap((p) => p.items), [query.data])
+
+ const principalIds = useMemo(() => {
+ const set = new Set()
+ for (const row of items) {
+ if (row.origin === 'workspace' && row.principalId) set.add(row.principalId)
+ }
+ return Array.from(set)
+ }, [items])
+
+ const principalsQuery = useQuery({
+ queryKey: ['principals', 'byIds', principalIds] as const,
+ queryFn: () => getPrincipalsByIdsFn({ data: { ids: principalIds as PrincipalId[] } }),
+ enabled: principalIds.length > 0,
+ staleTime: 60_000,
+ })
+
+ const principalMap = useMemo(() => {
+ const m = new Map()
+ for (const principal of principalsQuery.data ?? []) {
+ m.set(principal.id, {
+ displayName: principal.displayName,
+ email: principal.email,
+ role: principal.role,
+ })
+ }
+ return m
+ }, [principalsQuery.data])
+
+ const rowKey = (row: Pick) => `${row.origin}:${row.id}`
+
+ const [expanded, setExpanded] = useState>(new Set())
+ const toggle = (id: string) => {
+ setExpanded((prev) => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+
+ const actorLabel = (row: UnifiedAuditEventRow) => {
+ const resolved = row.principalId ? principalMap.get(row.principalId) : null
+ return (
+ resolved?.displayName ??
+ resolved?.email ??
+ row.actorDisplayName ??
+ row.actorEmail ??
+ row.principalId ??
+ row.actorUserId ??
+ (row.actorType ? `(${row.actorType})` : null)
+ )
+ }
+
+ const actorSubtitle = (row: UnifiedAuditEventRow) => {
+ const resolved = row.principalId ? principalMap.get(row.principalId) : null
+ return [resolved?.email, row.actorRole ?? resolved?.role, row.actorType, row.authMethod]
+ .filter(Boolean)
+ .join(' · ')
+ }
+
+ return (
+
+
+
{items.length} events shown
+
downloadAuditCsv(items)}
+ >
+
+ Export CSV
+
+
+
+
+
+
+
+
+ When
+ Origin
+ Actor
+ Action
+ Target
+ Outcome
+ Source
+
+
+
+ {items.length === 0 ? (
+
+
+ No audit events match the current filters.
+
+
+ ) : (
+ items.map((row) => {
+ const key = rowKey(row)
+ const isOpen = expanded.has(key)
+ const actor = actorLabel(row)
+ const subtitle = actorSubtitle(row)
+ const stamp = formatTimestamp(row.occurredAt)
+ return (
+
+
+
+ toggle(key)}
+ className="text-muted-foreground hover:text-foreground"
+ >
+ {isOpen ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {stamp.date}
+ {stamp.time}
+
+
+
+
+
+
+ {actor ? (
+
+
+ {actor.slice(0, 2).toUpperCase()}
+
+
+ {actor}
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
+
+
+ ) : (
+
+ System
+
+ )}
+
+
+
+ {row.action}
+
+
+
+
+
+
+
+
+
+
+ {row.source ?? '—'}
+
+
+
+ {isOpen && (
+
+
+
+
+
+
+ )}
+
+ )
+ })
+ )}
+
+
+
+
+
+ {items.length === 0 ? (
+
+ No audit events match the current filters.
+
+ ) : (
+ items.map((row) => {
+ const key = rowKey(row)
+ const isOpen = expanded.has(key)
+ const stamp = formatTimestamp(row.occurredAt)
+ const actor = actorLabel(row)
+ return (
+
+
+
+
+
+
+ {stamp.date} {stamp.time}
+
+
+
+ {row.action}
+
+
+
+
+
+
+ {actor ? (
+
+ Actor
+ {actor}
+
+ ) : null}
+ {row.targetType ? (
+
+
Target
+
+
+ {row.targetType}
+
+ {row.targetId ? (
+
+ {row.targetId}
+
+ ) : null}
+
+
+ ) : null}
+ {row.source ? (
+
+ Source
+ {row.source}
+
+ ) : null}
+
+
+
toggle(key)} className="h-8 px-2">
+ {isOpen ? 'Hide details' : 'Show details'}
+
+ {isOpen ? (
+
+ ) : null}
+
+ )
+ })
+ )}
+
+
+
+
+ {query.hasNextPage ? 'More events are available.' : 'End of matching events.'}
+
+ {query.hasNextPage ? (
+ query.fetchNextPage()}
+ >
+ {query.isFetchingNextPage ? 'Loading...' : 'Load more'}
+
+ ) : null}
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/audit/audit-filter-bar.tsx b/apps/web/src/components/admin/settings/audit/audit-filter-bar.tsx
new file mode 100644
index 000000000..9f3d42c04
--- /dev/null
+++ b/apps/web/src/components/admin/settings/audit/audit-filter-bar.tsx
@@ -0,0 +1,340 @@
+/**
+ * Filter bar for the unified audit log. All controls drive a single
+ * `AuditFilters` object; changes propagate up immediately and the underlying
+ * infinite query resets its cursor when filters change.
+ */
+import { useState, useEffect } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import type { PrincipalId } from '@quackback/ids'
+import {
+ auditQueries,
+ defaultAuditFilters,
+ rangeToFromIso,
+ type AuditFilters,
+ type AuditOriginFilter,
+ type AuditSourceFilter,
+ type AuditTimeRange,
+} from '@/lib/client/queries/audit'
+import { useDebouncedValue } from '@/lib/client/hooks/use-debounced-value'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { PrincipalPicker } from '@/components/admin/shared/principal-picker'
+
+interface Props {
+ value: AuditFilters
+ onChange: (next: AuditFilters) => void
+}
+
+const SOURCES: AuditSourceFilter[] = ['web', 'api', 'integration', 'system', 'mcp']
+const ALL = '__all__'
+const ORIGINS: Array<{ label: string; value: AuditOriginFilter | typeof ALL }> = [
+ { label: 'All', value: ALL },
+ { label: 'Workspace', value: 'workspace' },
+ { label: 'Security', value: 'security' },
+]
+
+const TIME_RANGES: Array<{ label: string; value: AuditTimeRange }> = [
+ { label: 'Last 7 days', value: '7d' },
+ { label: 'Last 30 days', value: '30d' },
+ { label: 'Last 90 days', value: '90d' },
+ { label: 'All time', value: 'all' },
+ { label: 'Custom', value: 'custom' },
+]
+
+function isoToLocal(iso: string | undefined): string {
+ if (!iso) return ''
+ const d = new Date(iso)
+ if (isNaN(d.getTime())) return ''
+ // Build YYYY-MM-DDTHH:mm in local time.
+ const pad = (n: number) => String(n).padStart(2, '0')
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
+}
+
+function localToIso(local: string): string | undefined {
+ if (!local) return undefined
+ const d = new Date(local)
+ if (isNaN(d.getTime())) return undefined
+ return d.toISOString()
+}
+
+export function AuditFilterBar({ value, onChange }: Props) {
+ const actionsQuery = useQuery(auditQueries.actions())
+ const actions = actionsQuery.data ?? []
+
+ // Derive the "current action" string and the prefix-mode flag from the
+ // incoming filter values so the controls round-trip cleanly.
+ const currentAction = value.action ?? value.actionPrefix ?? ''
+ const [prefixMode, setPrefixMode] = useState(Boolean(value.actionPrefix))
+
+ const [timeRange, setTimeRange] = useState('30d')
+ const [actorEmail, setActorEmail] = useState(value.actorEmail ?? '')
+ const debouncedActorEmail = useDebouncedValue(actorEmail, 300)
+
+ // Local text buffers so typing doesn't refetch every server query.
+ const [targetType, setTargetType] = useState(value.targetType ?? '')
+ const [targetId, setTargetId] = useState(value.targetId ?? '')
+
+ useEffect(() => {
+ setTargetType(value.targetType ?? '')
+ }, [value.targetType])
+
+ useEffect(() => {
+ setTargetId(value.targetId ?? '')
+ }, [value.targetId])
+
+ useEffect(() => {
+ setActorEmail(value.actorEmail ?? '')
+ }, [value.actorEmail])
+
+ useEffect(() => {
+ const trimmed = debouncedActorEmail.trim()
+ const next = trimmed || undefined
+ if ((value.actorEmail ?? undefined) !== next) {
+ onChange({ ...value, actorEmail: next })
+ }
+ }, [debouncedActorEmail, onChange, value])
+
+ const setOrigin = (next: string) =>
+ onChange({
+ ...value,
+ origin: next === ALL ? undefined : (next as AuditOriginFilter),
+ })
+
+ const setActor = (next: PrincipalId | null) => onChange({ ...value, principalId: next })
+
+ const setAction = (next: string) => {
+ const trimmed = next.trim()
+ if (!trimmed) {
+ onChange({ ...value, action: undefined, actionPrefix: undefined })
+ return
+ }
+ if (prefixMode) onChange({ ...value, action: undefined, actionPrefix: trimmed })
+ else onChange({ ...value, action: trimmed, actionPrefix: undefined })
+ }
+
+ const togglePrefix = (next: boolean) => {
+ setPrefixMode(next)
+ if (!currentAction) return
+ if (next) onChange({ ...value, action: undefined, actionPrefix: currentAction })
+ else onChange({ ...value, action: currentAction, actionPrefix: undefined })
+ }
+
+ const setSource = (next: string) =>
+ onChange({
+ ...value,
+ source: next === ALL ? undefined : (next as AuditSourceFilter),
+ })
+
+ const setRange = (next: AuditTimeRange) => {
+ setTimeRange(next)
+ if (next === 'custom') return
+ onChange({
+ ...value,
+ fromIso: rangeToFromIso(next),
+ toIso: undefined,
+ })
+ }
+
+ const setFrom = (local: string) => {
+ setTimeRange('custom')
+ onChange({ ...value, fromIso: localToIso(local) })
+ }
+
+ const setTo = (local: string) => {
+ setTimeRange('custom')
+ onChange({ ...value, toIso: localToIso(local) })
+ }
+
+ const commitTargetType = () => onChange({ ...value, targetType: targetType.trim() || undefined })
+ const commitTargetId = () => onChange({ ...value, targetId: targetId.trim() || undefined })
+
+ const clearAll = () => {
+ setPrefixMode(false)
+ setTargetType('')
+ setTargetId('')
+ setActorEmail('')
+ setTimeRange('30d')
+ onChange(defaultAuditFilters())
+ }
+
+ const hasFilters =
+ Boolean(value.origin) ||
+ Boolean(value.principalId) ||
+ Boolean(value.actorEmail) ||
+ Boolean(value.action) ||
+ Boolean(value.actionPrefix) ||
+ Boolean(value.targetType) ||
+ Boolean(value.targetId) ||
+ Boolean(value.source) ||
+ Boolean(value.fromIso) ||
+ Boolean(value.toIso)
+
+ return (
+
+
+
+ Origin
+
+
+
+
+
+ {ORIGINS.map((origin) => (
+
+ {origin.label}
+
+ ))}
+
+
+
+
+
+
+
+
Action
+
+ setAction(e.target.value)}
+ placeholder={prefixMode ? 'e.g. ticket.' : 'e.g. ticket.created'}
+ className="h-9"
+ />
+
+ {actions.map((a) => (
+
+ ))}
+
+
+
+
+
+ Match as prefix
+
+
+
+
+
+ Actor email
+ setActorEmail(e.target.value)}
+ placeholder="name@example.com"
+ className="h-9"
+ />
+
+
+
+ Target type
+ setTargetType(e.target.value)}
+ onBlur={commitTargetType}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ commitTargetType()
+ }
+ }}
+ placeholder="e.g. ticket"
+ className="h-9"
+ />
+
+
+
+ Target ID
+ setTargetId(e.target.value)}
+ onBlur={commitTargetId}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ commitTargetId()
+ }
+ }}
+ placeholder="ticket_..."
+ className="h-9"
+ />
+
+
+
+ Source
+
+
+
+
+
+ Any
+ {SOURCES.map((s) => (
+
+ {s}
+
+ ))}
+
+
+
+
+
+ Range
+ setRange(next as AuditTimeRange)}>
+
+
+
+
+ {TIME_RANGES.map((range) => (
+
+ {range.label}
+
+ ))}
+
+
+
+
+
+ From
+ setFrom(e.target.value)}
+ className="h-9"
+ />
+
+
+
+ To
+ setTo(e.target.value)}
+ className="h-9"
+ />
+
+
+
+
+
+ Clear filters
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/inboxes/__tests__/inbox-channel-dialog.test.tsx b/apps/web/src/components/admin/settings/inboxes/__tests__/inbox-channel-dialog.test.tsx
new file mode 100644
index 000000000..30bc40724
--- /dev/null
+++ b/apps/web/src/components/admin/settings/inboxes/__tests__/inbox-channel-dialog.test.tsx
@@ -0,0 +1,341 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { InboxChannelDialog } from '../inbox-channel-dialog'
+
+type MutationOptions = {
+ mutationFn: () => Promise
+ onSuccess?: (result: unknown) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ addInboxChannelFn: vi.fn(),
+ updateInboxChannelFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useMutation: (options: MutationOptions) => ({
+ isPending: false,
+ mutate: async () => {
+ try {
+ const result = await options.mutationFn()
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@/components/ui/dialog', () => {
+ let openDialog: (open: boolean) => void = () => undefined
+ let currentOpen = false
+
+ return {
+ Dialog: ({
+ open,
+ onOpenChange,
+ children,
+ }: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ children: ReactNode
+ }) => {
+ currentOpen = open
+ openDialog = onOpenChange
+ return {children}
+ },
+ DialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => (
+ openDialog(true)}>
+ {children}
+
+ ),
+ DialogContent: ({ children }: { children: ReactNode; className?: string }) =>
+ currentOpen ? : null,
+ DialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ DialogFooter: ({ children }: { children: ReactNode }) => ,
+ DialogHeader: ({ children }: { children: ReactNode }) => ,
+ DialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+ }
+})
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({
+ id,
+ type = 'text',
+ value,
+ onChange,
+ placeholder,
+ }: {
+ id?: string
+ type?: string
+ value?: string
+ onChange?: (event: React.ChangeEvent) => void
+ placeholder?: string
+ required?: boolean
+ maxLength?: number
+ autoComplete?: string
+ }) => ,
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/switch', () => ({
+ Switch: ({
+ id,
+ checked,
+ onCheckedChange,
+ }: {
+ id?: string
+ checked: boolean
+ onCheckedChange: (checked: boolean) => void
+ }) => (
+ onCheckedChange(!checked)} />
+ ),
+}))
+
+vi.mock('@/components/ui/select', () => ({
+ Select: ({
+ value,
+ onValueChange,
+ disabled,
+ children,
+ }: {
+ value: string
+ onValueChange: (value: string) => void
+ disabled?: boolean
+ children: ReactNode
+ }) => (
+ onValueChange(event.currentTarget.value)}
+ >
+ {children}
+
+ ),
+ SelectContent: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectItem: ({ children, value }: { children: ReactNode; value: string }) => (
+ {children}
+ ),
+ SelectTrigger: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectValue: () => null,
+}))
+
+vi.mock('@/lib/server/functions/inboxes', () => ({
+ addInboxChannelFn: mocks.addInboxChannelFn,
+ updateInboxChannelFn: mocks.updateInboxChannelFn,
+}))
+
+vi.mock('@/lib/client/queries/inboxes', () => ({
+ inboxQueries: {
+ channels: (inboxId: string) => ({ queryKey: ['inboxes', inboxId, 'channels'] }),
+ },
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+function renderDialog(channel?: React.ComponentProps['channel']) {
+ return render(
+ Open channel dialog}
+ />
+ )
+}
+
+function openDialog() {
+ fireEvent.click(screen.getByRole('button', { name: 'Open channel dialog' }))
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.addInboxChannelFn.mockResolvedValue({ id: 'inbox_channel_new' })
+ mocks.updateInboxChannelFn.mockResolvedValue({ id: 'inbox_channel_1' })
+})
+
+describe('InboxChannelDialog', () => {
+ it('creates an email channel with trimmed config and invalidates channel queries', async () => {
+ renderDialog()
+ openDialog()
+
+ expect(screen.getByRole('heading', { name: 'Add channel' })).toBeInTheDocument()
+ expect(screen.getByText('No additional configuration required.')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Add channel' }))
+ expect(mocks.toastError).toHaveBeenCalledWith('Label is required')
+
+ fireEvent.change(screen.getByLabelText('Kind'), { target: { value: 'email' } })
+ fireEvent.change(screen.getByLabelText('Label'), {
+ target: { value: ' Email support ' },
+ })
+ fireEvent.change(screen.getByLabelText('External ID'), {
+ target: { value: ' provider-123 ' },
+ })
+ fireEvent.change(screen.getByLabelText('Mailbox'), {
+ target: { value: ' support@example.com ' },
+ })
+ fireEvent.change(screen.getByLabelText('Forwarding address'), {
+ target: { value: ' forward@example.com ' },
+ })
+ fireEvent.click(screen.getByLabelText('Enabled'))
+ fireEvent.click(screen.getByRole('button', { name: 'Add channel' }))
+
+ await waitFor(() => {
+ expect(mocks.addInboxChannelFn).toHaveBeenCalledWith({
+ data: {
+ inboxId: 'inbox_1',
+ kind: 'email',
+ label: 'Email support',
+ externalId: 'provider-123',
+ enabled: false,
+ config: {
+ mailbox: 'support@example.com',
+ forwardingAddress: 'forward@example.com',
+ },
+ },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['inboxes', 'inbox_1', 'channels'],
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Channel added')
+ await waitFor(() => {
+ expect(screen.queryByRole('heading', { name: 'Add channel' })).not.toBeInTheDocument()
+ })
+ })
+
+ it('creates a webhook channel with signing config and a write-only secret', async () => {
+ renderDialog()
+ openDialog()
+
+ fireEvent.change(screen.getByLabelText('Kind'), { target: { value: 'webhook' } })
+ fireEvent.change(screen.getByLabelText('Label'), { target: { value: 'Webhook' } })
+ fireEvent.change(screen.getByLabelText('External ID'), { target: { value: ' ' } })
+ fireEvent.change(screen.getByLabelText('Signing header'), {
+ target: { value: ' X-Hook-Signature ' },
+ })
+ fireEvent.change(screen.getByLabelText('Secret'), { target: { value: ' new-secret ' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Add channel' }))
+
+ await waitFor(() => {
+ expect(mocks.addInboxChannelFn).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ kind: 'webhook',
+ label: 'Webhook',
+ externalId: null,
+ enabled: true,
+ config: {
+ signingHeader: 'X-Hook-Signature',
+ secret: 'new-secret',
+ },
+ }),
+ })
+ })
+ })
+
+ it('updates an existing webhook channel without changing its kind', async () => {
+ renderDialog({
+ id: 'inbox_channel_1',
+ kind: 'webhook',
+ label: 'Current webhook',
+ externalId: null,
+ enabled: false,
+ config: { signingHeader: 'X-Old-Signature', retained: 'value' },
+ } as never)
+ openDialog()
+
+ expect(screen.getByRole('heading', { name: 'Edit channel' })).toBeInTheDocument()
+ expect(screen.getByLabelText('Kind')).toBeDisabled()
+ expect(screen.getByText('Channel kind cannot change after creation.')).toBeInTheDocument()
+ expect(screen.getByLabelText('Enabled')).not.toBeChecked()
+
+ fireEvent.change(screen.getByLabelText('Label'), { target: { value: 'Updated webhook' } })
+ fireEvent.change(screen.getByLabelText('Signing header'), {
+ target: { value: ' X-New-Signature ' },
+ })
+ fireEvent.change(screen.getByLabelText(/Secret/), { target: { value: ' rotated ' } })
+ fireEvent.click(screen.getByLabelText('Enabled'))
+ fireEvent.click(screen.getByRole('button', { name: 'Save channel' }))
+
+ await waitFor(() => {
+ expect(mocks.updateInboxChannelFn).toHaveBeenCalledWith({
+ data: {
+ channelId: 'inbox_channel_1',
+ label: 'Updated webhook',
+ externalId: null,
+ enabled: true,
+ config: {
+ signingHeader: 'X-New-Signature',
+ retained: 'value',
+ secret: 'rotated',
+ },
+ },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Channel updated')
+ })
+
+ it('cancels and reports update errors', async () => {
+ mocks.updateInboxChannelFn.mockRejectedValueOnce(new Error('Cannot update channel'))
+ renderDialog({
+ id: 'inbox_channel_1',
+ kind: 'api',
+ label: 'API',
+ externalId: 'api-1',
+ enabled: true,
+ config: null,
+ } as never)
+ openDialog()
+
+ expect(screen.getByText('No additional configuration required.')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+ expect(screen.queryByRole('heading', { name: 'Edit channel' })).not.toBeInTheDocument()
+
+ openDialog()
+ fireEvent.change(screen.getByLabelText('External ID'), { target: { value: ' ' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save channel' }))
+
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Cannot update channel')
+ })
+ })
+})
diff --git a/apps/web/src/components/admin/settings/inboxes/__tests__/inbox-channels-tab.test.tsx b/apps/web/src/components/admin/settings/inboxes/__tests__/inbox-channels-tab.test.tsx
new file mode 100644
index 000000000..102bf738b
--- /dev/null
+++ b/apps/web/src/components/admin/settings/inboxes/__tests__/inbox-channels-tab.test.tsx
@@ -0,0 +1,299 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { InboxChannelsTab } from '../inbox-channels-tab'
+
+type Channel = {
+ id: string
+ kind: string
+ label: string
+ externalId: string | null
+ enabled: boolean
+ archivedAt: string | null
+}
+
+type MutationOptions = {
+ mutationFn: (vars: TVars) => Promise
+ onSuccess?: (result: TResult) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ updateInboxChannelFn: vi.fn(),
+ archiveInboxChannelFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+ permissionAllowed: true,
+ channels: [] as Channel[],
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useSuspenseQuery: () => ({
+ data: mocks.channels,
+ }),
+ useMutation: (options: MutationOptions) => ({
+ isPending: false,
+ mutate: async (vars: TVars) => {
+ try {
+ const result = await options.mutationFn(vars)
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@/components/admin/shared/permission-gate', () => ({
+ PermissionGate: ({
+ children,
+ fallback = null,
+ }: {
+ children: ReactNode
+ fallback?: ReactNode
+ permission: string
+ }) => (mocks.permissionAllowed ? <>{children}> : <>{fallback}>),
+}))
+
+vi.mock('../inbox-channel-dialog', () => ({
+ InboxChannelDialog: ({
+ trigger,
+ channel,
+ }: {
+ inboxId: string
+ trigger: ReactNode
+ channel?: Channel
+ }) => (
+
+ {trigger}
+ {channel ? `Edit dialog for ${channel.label}` : 'Add channel dialog'}
+
+ ),
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ 'aria-label': ariaLabel,
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ size?: string
+ className?: string
+ 'aria-label'?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/switch', () => ({
+ Switch: ({
+ checked,
+ disabled,
+ onCheckedChange,
+ 'aria-label': ariaLabel,
+ }: {
+ checked: boolean
+ disabled?: boolean
+ onCheckedChange: (checked: boolean) => void
+ 'aria-label'?: string
+ }) => (
+ onCheckedChange(!checked)}
+ />
+ ),
+}))
+
+vi.mock('@/components/ui/badge', () => ({
+ Badge: ({ children }: { children: ReactNode; variant?: string; className?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/table', () => ({
+ Table: ({ children }: { children: ReactNode }) => ,
+ TableBody: ({ children }: { children: ReactNode }) => {children} ,
+ TableCell: ({
+ children,
+ colSpan,
+ }: {
+ children?: ReactNode
+ colSpan?: number
+ className?: string
+ }) => {children} ,
+ TableHead: ({ children }: { children?: ReactNode; className?: string }) => {children} ,
+ TableHeader: ({ children }: { children: ReactNode }) => {children} ,
+ TableRow: ({ children }: { children: ReactNode }) => {children} ,
+}))
+
+vi.mock('@/components/ui/alert-dialog', () => ({
+ AlertDialog: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogAction: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
+
+ {children}
+
+ ),
+ AlertDialogCancel: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ AlertDialogContent: ({ children }: { children: ReactNode }) => ,
+ AlertDialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogFooter: ({ children }: { children: ReactNode }) => ,
+ AlertDialogHeader: ({ children }: { children: ReactNode }) => ,
+ AlertDialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+ AlertDialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}>,
+}))
+
+vi.mock('@heroicons/react/24/outline', () => ({
+ PencilSquareIcon: () => pencil ,
+ PlusIcon: () => plus ,
+ TrashIcon: () => trash ,
+}))
+
+vi.mock('@/lib/client/queries/inboxes', () => ({
+ inboxQueries: {
+ channels: (inboxId: string) => ({ queryKey: ['inboxes', inboxId, 'channels'] }),
+ },
+}))
+
+vi.mock('@/lib/server/functions/inboxes', () => ({
+ updateInboxChannelFn: mocks.updateInboxChannelFn,
+ archiveInboxChannelFn: mocks.archiveInboxChannelFn,
+}))
+
+vi.mock('@/lib/server/domains/authz', () => ({
+ PERMISSIONS: {
+ INBOX_CHANNEL_MANAGE: 'inbox_channel.manage',
+ },
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.permissionAllowed = true
+ mocks.channels = [
+ {
+ id: 'channel_portal',
+ kind: 'portal',
+ label: 'Portal',
+ externalId: null,
+ enabled: true,
+ archivedAt: null,
+ },
+ {
+ id: 'channel_email',
+ kind: 'email',
+ label: 'Support email',
+ externalId: 'mailbox_1',
+ enabled: false,
+ archivedAt: '2026-06-01T00:00:00.000Z',
+ },
+ {
+ id: 'channel_custom',
+ kind: 'custom',
+ label: 'Custom channel',
+ externalId: 'custom_1',
+ enabled: true,
+ archivedAt: null,
+ },
+ ]
+ mocks.updateInboxChannelFn.mockResolvedValue({ id: 'channel_portal' })
+ mocks.archiveInboxChannelFn.mockResolvedValue(undefined)
+})
+
+describe('InboxChannelsTab', () => {
+ it('renders empty state and add-channel affordance', () => {
+ mocks.channels = []
+ render( )
+
+ expect(screen.getByText('No channels yet.')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Add channel' })).toBeInTheDocument()
+ expect(screen.getByText('Add channel dialog')).toBeInTheDocument()
+ })
+
+ it('renders channel rows with status, external id fallbacks and edit affordances', () => {
+ render( )
+
+ expect(screen.getByText('portal')).toBeInTheDocument()
+ expect(screen.getByText('email')).toBeInTheDocument()
+ expect(screen.getByText('custom')).toBeInTheDocument()
+ expect(screen.getByText('Portal')).toBeInTheDocument()
+ expect(screen.getByText('Support email')).toBeInTheDocument()
+ expect(screen.getByText('mailbox_1')).toBeInTheDocument()
+ expect(screen.getByText('—')).toBeInTheDocument()
+ expect(screen.getAllByText('Active')).toHaveLength(2)
+ expect(screen.getByText('Archived')).toBeInTheDocument()
+ expect(screen.getByText('Edit dialog for Portal')).toBeInTheDocument()
+ })
+
+ it('toggles and archives active channels with cache invalidation', async () => {
+ render( )
+
+ fireEvent.click(screen.getAllByLabelText('Toggle channel enabled')[0])
+ await waitFor(() => {
+ expect(mocks.updateInboxChannelFn).toHaveBeenCalledWith({
+ data: { channelId: 'channel_portal', enabled: false },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['inboxes', 'inbox_1', 'channels'],
+ })
+
+ expect(screen.getAllByLabelText('Toggle channel enabled')[1]).toBeDisabled()
+ fireEvent.click(screen.getAllByRole('button', { name: 'Archive' })[0])
+ await waitFor(() => {
+ expect(mocks.archiveInboxChannelFn).toHaveBeenCalledWith({
+ data: { channelId: 'channel_portal' },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Channel archived')
+ })
+
+ it('reports mutation errors and renders permission-denied fallback states', async () => {
+ mocks.updateInboxChannelFn.mockRejectedValueOnce(new Error('Toggle denied'))
+ mocks.archiveInboxChannelFn.mockRejectedValueOnce(new Error('Archive denied'))
+ render( )
+
+ fireEvent.click(screen.getAllByLabelText('Toggle channel enabled')[0])
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Toggle denied')
+ })
+ fireEvent.click(screen.getAllByRole('button', { name: 'Archive' })[0])
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Archive denied')
+ })
+
+ cleanup()
+ mocks.permissionAllowed = false
+ render( )
+
+ expect(screen.queryByRole('button', { name: 'Add channel' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Edit channel' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Archive channel' })).not.toBeInTheDocument()
+ expect(screen.getAllByText('on')).toHaveLength(2)
+ expect(screen.getByText('off')).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/admin/settings/inboxes/__tests__/inbox-create-dialog.test.tsx b/apps/web/src/components/admin/settings/inboxes/__tests__/inbox-create-dialog.test.tsx
new file mode 100644
index 000000000..2f2e62488
--- /dev/null
+++ b/apps/web/src/components/admin/settings/inboxes/__tests__/inbox-create-dialog.test.tsx
@@ -0,0 +1,352 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { InboxCreateDialog } from '../inbox-create-dialog'
+
+type MutationOptions = {
+ mutationFn: () => Promise
+ onSuccess?: (result: T) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ createInboxFn: vi.fn(),
+ invalidateQueries: vi.fn(),
+ navigate: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useMutation: (options: MutationOptions) => ({
+ isPending: false,
+ mutate: async () => {
+ try {
+ const result = await options.mutationFn()
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ useRouter: () => ({
+ navigate: mocks.navigate,
+ }),
+}))
+
+vi.mock('@/components/ui/dialog', () => {
+ let openDialog: (open: boolean) => void = () => undefined
+ let currentOpen = false
+
+ return {
+ Dialog: ({
+ open,
+ onOpenChange,
+ children,
+ }: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ children: ReactNode
+ }) => {
+ currentOpen = open
+ openDialog = onOpenChange
+ return {children}
+ },
+ DialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => (
+ openDialog(true)}>
+ {children}
+
+ ),
+ DialogContent: ({ children }: { children: ReactNode; className?: string }) =>
+ currentOpen ? : null,
+ DialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ DialogFooter: ({ children }: { children: ReactNode }) => ,
+ DialogHeader: ({ children }: { children: ReactNode }) => ,
+ DialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+ }
+})
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({
+ id,
+ value,
+ onChange,
+ placeholder,
+ }: {
+ id?: string
+ value?: string
+ onChange?: (event: React.ChangeEvent) => void
+ placeholder?: string
+ required?: boolean
+ maxLength?: number
+ }) => ,
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/textarea', () => ({
+ Textarea: ({
+ id,
+ value,
+ onChange,
+ rows,
+ }: {
+ id?: string
+ value?: string
+ onChange?: (event: React.ChangeEvent) => void
+ rows?: number
+ }) => ,
+}))
+
+vi.mock('@/components/ui/select', () => ({
+ Select: ({
+ value,
+ onValueChange,
+ children,
+ }: {
+ value: string
+ onValueChange: (value: string) => void
+ children: ReactNode
+ }) => (
+ onValueChange(event.currentTarget.value)}
+ >
+ {children}
+
+ ),
+ SelectContent: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectItem: ({ children, value }: { children: ReactNode; value: string }) => (
+ {children}
+ ),
+ SelectTrigger: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectValue: () => null,
+}))
+
+vi.mock('@/components/admin/shared/team-picker', () => ({
+ TeamPicker: ({
+ value,
+ onValueChange,
+ placeholder,
+ }: {
+ value: string | null
+ onValueChange: (value: string | null) => void
+ allowClear?: boolean
+ placeholder?: string
+ }) => (
+ onValueChange(event.currentTarget.value || null)}
+ >
+ {placeholder ?? 'No team'}
+ Support
+ Success
+
+ ),
+}))
+
+vi.mock('@/components/admin/shared/status-picker', () => ({
+ StatusPicker: ({
+ value,
+ onValueChange,
+ placeholder,
+ }: {
+ value: string | null
+ onValueChange: (value: string | null) => void
+ placeholder?: string
+ }) => (
+ onValueChange(event.currentTarget.value || null)}
+ >
+ {placeholder ?? 'Workspace default'}
+ Open
+ Triage
+
+ ),
+}))
+
+vi.mock('@/lib/server/functions/inboxes', () => ({
+ createInboxFn: mocks.createInboxFn,
+}))
+
+vi.mock('@/lib/client/queries/inboxes', () => ({
+ inboxQueries: {
+ list: () => ({ queryKey: ['inboxes', 'list'] }),
+ },
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+function renderDialog() {
+ return render(Open inbox dialog} />)
+}
+
+function openDialog() {
+ fireEvent.click(screen.getByRole('button', { name: 'Open inbox dialog' }))
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.createInboxFn.mockResolvedValue({ id: 'inbox_created' })
+})
+
+describe('InboxCreateDialog', () => {
+ it('opens from the trigger and validates required slug and name fields', () => {
+ renderDialog()
+ expect(screen.queryByRole('heading', { name: 'New inbox' })).not.toBeInTheDocument()
+
+ openDialog()
+
+ expect(screen.getByRole('heading', { name: 'New inbox' })).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Create inbox' }))
+
+ expect(mocks.toastError).toHaveBeenCalledWith('Slug and name are required')
+ expect(mocks.createInboxFn).not.toHaveBeenCalled()
+ })
+
+ it('creates an inbox with trimmed defaults, invalidates inbox lists and navigates to detail', async () => {
+ renderDialog()
+ openDialog()
+
+ fireEvent.change(screen.getByLabelText('Slug'), { target: { value: 'SUPPORT' } })
+ expect(screen.getByLabelText('Slug')).toHaveValue('support')
+ fireEvent.change(screen.getByLabelText('Name'), {
+ target: { value: ' Customer Support ' },
+ })
+ fireEvent.change(screen.getByLabelText('Description'), {
+ target: { value: ' All customer requests ' },
+ })
+ fireEvent.change(screen.getByLabelText('Primary team'), {
+ target: { value: 'team_support' },
+ })
+ fireEvent.change(screen.getByLabelText('select-team'), { target: { value: 'shared' } })
+ fireEvent.change(screen.getByLabelText('select-normal'), { target: { value: 'urgent' } })
+ fireEvent.change(screen.getByLabelText('Default status'), {
+ target: { value: 'status_triage' },
+ })
+ fireEvent.change(screen.getByLabelText('Color'), { target: { value: ' #22c55e ' } })
+ fireEvent.change(screen.getByLabelText('Icon'), { target: { value: ' InboxIcon ' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Create inbox' }))
+
+ await waitFor(() => {
+ expect(mocks.createInboxFn).toHaveBeenCalledWith({
+ data: {
+ slug: 'support',
+ name: 'Customer Support',
+ description: 'All customer requests',
+ primaryTeamId: 'team_support',
+ defaultStatusId: 'status_triage',
+ defaultVisibilityScope: 'shared',
+ defaultPriority: 'urgent',
+ color: '#22c55e',
+ icon: 'InboxIcon',
+ },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['inboxes', 'list'],
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['inboxes'] })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Inbox created')
+ expect(mocks.navigate).toHaveBeenCalledWith({
+ to: '/admin/settings/inboxes/$inboxId',
+ params: { inboxId: 'inbox_created' },
+ })
+ await waitFor(() => {
+ expect(screen.queryByRole('heading', { name: 'New inbox' })).not.toBeInTheDocument()
+ })
+
+ openDialog()
+ expect(screen.getByLabelText('Slug')).toHaveValue('')
+ expect(screen.getByLabelText('Name')).toHaveValue('')
+ expect(screen.getByLabelText('Description')).toHaveValue('')
+ expect(screen.getByLabelText('Primary team')).toHaveValue('')
+ expect(screen.getByLabelText('Default status')).toHaveValue('')
+ expect(screen.getByLabelText('select-team')).toHaveValue('team')
+ expect(screen.getByLabelText('select-normal')).toHaveValue('normal')
+ })
+
+ it('submits nullable optional fields and keeps the dialog open when creation fails', async () => {
+ mocks.createInboxFn.mockRejectedValueOnce(new Error('Slug already exists'))
+
+ renderDialog()
+ openDialog()
+
+ fireEvent.change(screen.getByLabelText('Slug'), { target: { value: 'OPS' } })
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Operations ' } })
+ fireEvent.change(screen.getByLabelText('Description'), { target: { value: ' ' } })
+ fireEvent.change(screen.getByLabelText('Color'), { target: { value: ' ' } })
+ fireEvent.change(screen.getByLabelText('Icon'), { target: { value: ' ' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Create inbox' }))
+
+ await waitFor(() => {
+ expect(mocks.createInboxFn).toHaveBeenCalledWith({
+ data: {
+ slug: 'ops',
+ name: 'Operations',
+ description: null,
+ primaryTeamId: null,
+ defaultStatusId: null,
+ defaultVisibilityScope: 'team',
+ defaultPriority: 'normal',
+ color: null,
+ icon: null,
+ },
+ })
+ })
+ expect(mocks.toastError).toHaveBeenCalledWith('Slug already exists')
+ expect(mocks.invalidateQueries).not.toHaveBeenCalled()
+ expect(mocks.navigate).not.toHaveBeenCalled()
+ expect(screen.getByRole('heading', { name: 'New inbox' })).toBeInTheDocument()
+ })
+
+ it('closes the dialog without submitting when cancelled', () => {
+ renderDialog()
+ openDialog()
+
+ fireEvent.change(screen.getByLabelText('Slug'), { target: { value: 'support' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+
+ expect(mocks.createInboxFn).not.toHaveBeenCalled()
+ expect(screen.queryByRole('heading', { name: 'New inbox' })).not.toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/admin/settings/inboxes/__tests__/inbox-list.test.tsx b/apps/web/src/components/admin/settings/inboxes/__tests__/inbox-list.test.tsx
new file mode 100644
index 000000000..378bce4af
--- /dev/null
+++ b/apps/web/src/components/admin/settings/inboxes/__tests__/inbox-list.test.tsx
@@ -0,0 +1,135 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { InboxList } from '../inbox-list'
+
+type InboxRow = {
+ id: string
+ name: string
+ slug: string
+ color: string | null
+ defaultPriority: string
+ defaultVisibilityScope: string
+ archivedAt: string | null
+}
+
+const mocks = vi.hoisted(() => ({
+ inboxes: [] as InboxRow[],
+ listParams: undefined as unknown,
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useSuspenseQuery: () => ({
+ data: mocks.inboxes,
+ }),
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ Link: ({
+ children,
+ params,
+ to,
+ }: {
+ children: ReactNode
+ params?: Record
+ to: string
+ className?: string
+ }) => {
+ const href = Object.entries(params ?? {}).reduce(
+ (path, [key, value]) => path.replace(`$${key}`, value),
+ to
+ )
+ return {children}
+ },
+}))
+
+vi.mock('@/components/ui/switch', () => ({
+ Switch: ({
+ id,
+ checked,
+ onCheckedChange,
+ }: {
+ id?: string
+ checked: boolean
+ onCheckedChange: (checked: boolean) => void
+ }) => (
+ onCheckedChange(event.currentTarget.checked)}
+ />
+ ),
+}))
+
+vi.mock('@/lib/client/queries/inboxes', () => ({
+ inboxQueries: {
+ list: (params: unknown) => {
+ mocks.listParams = params
+ return { queryKey: ['inboxes', 'list', params] }
+ },
+ },
+}))
+
+function inbox(overrides: Partial = {}): InboxRow {
+ return {
+ id: 'inbox_support',
+ name: 'Support',
+ slug: 'support',
+ color: '#22c55e',
+ defaultPriority: 'normal',
+ defaultVisibilityScope: 'team',
+ archivedAt: null,
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ mocks.inboxes = []
+ mocks.listParams = undefined
+})
+
+describe('InboxList', () => {
+ it('renders active inboxes by default and reveals archived rows through the toggle', () => {
+ mocks.inboxes = [
+ inbox(),
+ inbox({
+ id: 'inbox_archived',
+ name: 'Archived',
+ slug: 'archived',
+ color: null,
+ defaultPriority: 'urgent',
+ defaultVisibilityScope: 'private',
+ archivedAt: '2026-06-20T10:00:00.000Z',
+ }),
+ ]
+
+ render( )
+
+ expect(mocks.listParams).toEqual({ includeArchived: true })
+ expect(screen.getByRole('link', { name: /Support/ })).toHaveAttribute(
+ 'href',
+ '/admin/settings/inboxes/inbox_support'
+ )
+ expect(screen.getByText('support')).toBeInTheDocument()
+ expect(screen.getByText('normal · team')).toBeInTheDocument()
+ expect(screen.getByText('Active')).toBeInTheDocument()
+ expect(screen.queryByRole('link', { name: /Archived/ })).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByLabelText('Show archived'))
+
+ expect(screen.getByRole('link', { name: /Archived/ })).toHaveAttribute(
+ 'href',
+ '/admin/settings/inboxes/inbox_archived'
+ )
+ expect(screen.getByText('urgent · private')).toBeInTheDocument()
+ expect(screen.getAllByText('Archived')).toHaveLength(2)
+ })
+
+ it('renders an empty state when there are no visible inboxes', () => {
+ render( )
+
+ expect(screen.getByText('No inboxes yet.')).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/admin/settings/inboxes/__tests__/inbox-overview-tab.test.tsx b/apps/web/src/components/admin/settings/inboxes/__tests__/inbox-overview-tab.test.tsx
new file mode 100644
index 000000000..05cd05a96
--- /dev/null
+++ b/apps/web/src/components/admin/settings/inboxes/__tests__/inbox-overview-tab.test.tsx
@@ -0,0 +1,344 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { InboxOverviewTab } from '../inbox-overview-tab'
+
+type MutationOptions = {
+ mutationFn: () => Promise
+ onSuccess?: (result: unknown) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ updateInboxFn: vi.fn(),
+ archiveInboxFn: vi.fn(),
+ unarchiveInboxFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useMutation: (options: MutationOptions) => ({
+ isPending: false,
+ mutate: async () => {
+ try {
+ const result = await options.mutationFn()
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ size?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({
+ id,
+ value,
+ onChange,
+ placeholder,
+ disabled,
+ readOnly,
+ }: {
+ id?: string
+ value?: string
+ onChange?: (event: React.ChangeEvent) => void
+ placeholder?: string
+ disabled?: boolean
+ readOnly?: boolean
+ required?: boolean
+ maxLength?: number
+ className?: string
+ }) => (
+
+ ),
+}))
+
+vi.mock('@/components/ui/textarea', () => ({
+ Textarea: ({
+ id,
+ value,
+ onChange,
+ }: {
+ id?: string
+ value?: string
+ onChange?: (event: React.ChangeEvent) => void
+ rows?: number
+ }) => ,
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/select', () => ({
+ Select: ({
+ value,
+ onValueChange,
+ children,
+ }: {
+ value: string
+ onValueChange: (value: string) => void
+ children: ReactNode
+ }) => (
+ onValueChange(event.currentTarget.value)}>
+ {children}
+
+ ),
+ SelectContent: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectItem: ({ children, value }: { children: ReactNode; value: string }) => (
+ {children}
+ ),
+ SelectTrigger: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectValue: () => null,
+}))
+
+vi.mock('@/components/ui/alert-dialog', () => ({
+ AlertDialog: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}>,
+ AlertDialogContent: ({ children }: { children: ReactNode }) => ,
+ AlertDialogHeader: ({ children }: { children: ReactNode }) => ,
+ AlertDialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+ AlertDialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogFooter: ({ children }: { children: ReactNode }) => ,
+ AlertDialogCancel: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ AlertDialogAction: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/admin/shared/team-picker', () => ({
+ TeamPicker: ({ onValueChange }: { onValueChange: (id: string | null) => void }) => (
+ <>
+ onValueChange('team_primary')}>
+ Pick primary team
+
+ onValueChange(null)}>
+ Clear primary team
+
+ >
+ ),
+}))
+
+vi.mock('@/components/admin/shared/status-picker', () => ({
+ StatusPicker: ({ onValueChange }: { onValueChange: (id: string | null) => void }) => (
+ <>
+ onValueChange('ticket_status_default')}>
+ Pick default status
+
+ onValueChange(null)}>
+ Clear default status
+
+ >
+ ),
+}))
+
+vi.mock('@/lib/server/functions/inboxes', () => ({
+ updateInboxFn: mocks.updateInboxFn,
+ archiveInboxFn: mocks.archiveInboxFn,
+ unarchiveInboxFn: mocks.unarchiveInboxFn,
+}))
+
+vi.mock('@/lib/client/queries/inboxes', () => ({
+ inboxQueries: {
+ detail: (inboxId: string) => ({ queryKey: ['inboxes', inboxId, 'detail'] }),
+ list: () => ({ queryKey: ['inboxes', 'list'] }),
+ },
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+function inbox(overrides: Record = {}) {
+ return {
+ id: 'inbox_1',
+ slug: 'support',
+ name: 'Support inbox',
+ description: 'Customer questions',
+ primaryTeamId: null,
+ defaultStatusId: null,
+ defaultVisibilityScope: 'team',
+ defaultPriority: 'normal',
+ color: null,
+ icon: null,
+ archivedAt: null,
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.updateInboxFn.mockResolvedValue({ id: 'inbox_1' })
+ mocks.archiveInboxFn.mockResolvedValue({ id: 'inbox_1' })
+ mocks.unarchiveInboxFn.mockResolvedValue({ id: 'inbox_1' })
+})
+
+describe('InboxOverviewTab', () => {
+ it('validates and saves normalized inbox overview settings', async () => {
+ render( )
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' ' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+ expect(mocks.toastError).toHaveBeenCalledWith('Name is required')
+
+ fireEvent.change(screen.getByLabelText('Name'), {
+ target: { value: ' Priority support ' },
+ })
+ fireEvent.change(screen.getByLabelText('Description'), {
+ target: { value: ' Handles urgent cases ' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Pick primary team' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Pick default status' }))
+ fireEvent.change(screen.getAllByRole('combobox')[0], { target: { value: 'shared' } })
+ fireEvent.change(screen.getAllByRole('combobox')[1], { target: { value: 'urgent' } })
+ fireEvent.change(screen.getByLabelText('Color'), { target: { value: ' #22c55e ' } })
+ fireEvent.change(screen.getByLabelText('Icon'), { target: { value: ' InboxIcon ' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+
+ await waitFor(() => {
+ expect(mocks.updateInboxFn).toHaveBeenCalledWith({
+ data: {
+ inboxId: 'inbox_1',
+ name: 'Priority support',
+ description: 'Handles urgent cases',
+ primaryTeamId: 'team_primary',
+ defaultStatusId: 'ticket_status_default',
+ defaultVisibilityScope: 'shared',
+ defaultPriority: 'urgent',
+ color: '#22c55e',
+ icon: 'InboxIcon',
+ },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['inboxes', 'inbox_1', 'detail'],
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['inboxes', 'list'] })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['inboxes'] })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Inbox updated')
+ })
+
+ it('saves nullable fields and reports update errors', async () => {
+ mocks.updateInboxFn.mockRejectedValueOnce(new Error('Update failed'))
+ render(
+
+ )
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Support' } })
+ fireEvent.change(screen.getByLabelText('Description'), { target: { value: ' ' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Clear primary team' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Clear default status' }))
+ fireEvent.change(screen.getByLabelText('Color'), { target: { value: ' ' } })
+ fireEvent.change(screen.getByLabelText('Icon'), { target: { value: ' ' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+
+ await waitFor(() => {
+ expect(mocks.updateInboxFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ description: null,
+ primaryTeamId: null,
+ defaultStatusId: null,
+ color: null,
+ icon: null,
+ }),
+ })
+ )
+ })
+ expect(mocks.toastError).toHaveBeenCalledWith('Update failed')
+ })
+
+ it('archives active inboxes and unarchives archived inboxes', async () => {
+ const { rerender } = render( )
+
+ expect(screen.getByText(/Hide this inbox from queues/)).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Archive' }))
+
+ await waitFor(() => {
+ expect(mocks.archiveInboxFn).toHaveBeenCalledWith({ data: { inboxId: 'inbox_1' } })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Inbox archived')
+
+ rerender(
+
+ )
+ fireEvent.click(screen.getByRole('button', { name: 'Unarchive' }))
+
+ await waitFor(() => {
+ expect(mocks.unarchiveInboxFn).toHaveBeenCalledWith({ data: { inboxId: 'inbox_1' } })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Inbox unarchived')
+ })
+
+ it('resets local form state when the inbox prop changes and reports archive errors', async () => {
+ mocks.archiveInboxFn.mockRejectedValueOnce(new Error('Archive failed'))
+ const { rerender } = render( )
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Unsaved name' } })
+ rerender(
+
+ )
+
+ expect(screen.getByLabelText('Name')).toHaveValue('Sales inbox')
+ fireEvent.click(screen.getByRole('button', { name: 'Archive' }))
+
+ await waitFor(() => {
+ expect(mocks.archiveInboxFn).toHaveBeenCalledWith({ data: { inboxId: 'inbox_2' } })
+ })
+ expect(mocks.toastError).toHaveBeenCalledWith('Archive failed')
+ })
+})
diff --git a/apps/web/src/components/admin/settings/inboxes/inbox-channel-dialog.tsx b/apps/web/src/components/admin/settings/inboxes/inbox-channel-dialog.tsx
new file mode 100644
index 000000000..2cd25227c
--- /dev/null
+++ b/apps/web/src/components/admin/settings/inboxes/inbox-channel-dialog.tsx
@@ -0,0 +1,278 @@
+/**
+ * Add/edit dialog for inbox channels. The channel kind is locked once created
+ * because the backend `updateInboxChannelFn` does not accept it. Per-kind
+ * config fields are intentionally minimal in v1; the backend stores them as
+ * an opaque jsonb record.
+ */
+import { useState, useEffect } from 'react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import type { InboxId, InboxChannelId } from '@quackback/ids'
+import type { InboxChannel } from '@/lib/shared/db-types'
+import { addInboxChannelFn, updateInboxChannelFn } from '@/lib/server/functions/inboxes'
+import { inboxQueries } from '@/lib/client/queries/inboxes'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+
+const CHANNEL_KINDS = ['portal', 'email', 'api', 'widget', 'webhook'] as const
+type ChannelKind = (typeof CHANNEL_KINDS)[number]
+
+export interface InboxChannelDialogProps {
+ inboxId: InboxId
+ channel?: InboxChannel
+ trigger: React.ReactNode
+}
+
+export function InboxChannelDialog({ inboxId, channel, trigger }: InboxChannelDialogProps) {
+ const isEdit = channel != null
+ const [open, setOpen] = useState(false)
+ const qc = useQueryClient()
+
+ const initialConfig = (channel?.config ?? {}) as Record
+
+ const [kind, setKind] = useState((channel?.kind as ChannelKind) ?? 'portal')
+ const [label, setLabel] = useState(channel?.label ?? '')
+ const [externalId, setExternalId] = useState(channel?.externalId ?? '')
+ const [enabled, setEnabled] = useState(channel?.enabled ?? true)
+
+ // Per-kind config inputs.
+ const [mailbox, setMailbox] = useState(String(initialConfig.mailbox ?? ''))
+ const [forwardingAddress, setForwardingAddress] = useState(
+ String(initialConfig.forwardingAddress ?? '')
+ )
+ const [secret, setSecret] = useState('')
+ const [signingHeader, setSigningHeader] = useState(String(initialConfig.signingHeader ?? ''))
+
+ useEffect(() => {
+ if (!open) return
+ setKind((channel?.kind as ChannelKind) ?? 'portal')
+ setLabel(channel?.label ?? '')
+ setExternalId(channel?.externalId ?? '')
+ setEnabled(channel?.enabled ?? true)
+ const cfg = (channel?.config ?? {}) as Record
+ setMailbox(String(cfg.mailbox ?? ''))
+ setForwardingAddress(String(cfg.forwardingAddress ?? ''))
+ setSecret('')
+ setSigningHeader(String(cfg.signingHeader ?? ''))
+ }, [channel, open])
+
+ const buildConfig = (): Record => {
+ if (kind === 'email') {
+ return {
+ ...(mailbox.trim() ? { mailbox: mailbox.trim() } : {}),
+ ...(forwardingAddress.trim() ? { forwardingAddress: forwardingAddress.trim() } : {}),
+ }
+ }
+ if (kind === 'webhook') {
+ const merged: Record = {
+ ...(channel?.config ?? {}),
+ ...(signingHeader.trim() ? { signingHeader: signingHeader.trim() } : {}),
+ }
+ // Only persist secret if user typed one (write-only field).
+ if (secret.trim()) merged.secret = secret.trim()
+ return merged
+ }
+ return {}
+ }
+
+ const invalidate = () =>
+ qc.invalidateQueries({ queryKey: inboxQueries.channels(inboxId).queryKey })
+
+ const createMutation = useMutation({
+ mutationFn: () =>
+ addInboxChannelFn({
+ data: {
+ inboxId,
+ kind,
+ label: label.trim(),
+ externalId: externalId.trim() || null,
+ enabled,
+ config: buildConfig(),
+ },
+ }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Channel added')
+ setOpen(false)
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const updateMutation = useMutation({
+ mutationFn: () =>
+ updateInboxChannelFn({
+ data: {
+ channelId: channel!.id as InboxChannelId,
+ label: label.trim(),
+ externalId: externalId.trim() || null,
+ enabled,
+ config: buildConfig(),
+ },
+ }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Channel updated')
+ setOpen(false)
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const submitting = createMutation.isPending || updateMutation.isPending
+
+ return (
+
+ {trigger}
+
+
+ {isEdit ? 'Edit channel' : 'Add channel'}
+ Channels feed tickets into this inbox.
+
+
+ {
+ e.preventDefault()
+ if (!label.trim()) {
+ toast.error('Label is required')
+ return
+ }
+ if (isEdit) updateMutation.mutate()
+ else createMutation.mutate()
+ }}
+ className="space-y-3"
+ >
+
+
Kind
+
setKind(v as ChannelKind)} disabled={isEdit}>
+
+
+
+
+ {CHANNEL_KINDS.map((k) => (
+
+ {k}
+
+ ))}
+
+
+ {isEdit && (
+
+ Channel kind cannot change after creation.
+
+ )}
+
+
+
+ Label
+ setLabel(e.target.value)}
+ required
+ maxLength={200}
+ />
+
+
+
+ External ID
+ setExternalId(e.target.value)}
+ maxLength={200}
+ placeholder="provider-side identifier"
+ />
+
+
+ {kind === 'email' && (
+ <>
+
+ Mailbox
+ setMailbox(e.target.value)}
+ placeholder="support@example.com"
+ />
+
+
+ Forwarding address
+ setForwardingAddress(e.target.value)}
+ placeholder="optional"
+ />
+
+ >
+ )}
+
+ {kind === 'webhook' && (
+ <>
+
+ Signing header
+ setSigningHeader(e.target.value)}
+ placeholder="X-Webhook-Signature"
+ />
+
+
+
+ Secret {isEdit && '(leave blank to keep current)'}
+
+ setSecret(e.target.value)}
+ autoComplete="new-password"
+ />
+
+ >
+ )}
+
+ {(kind === 'portal' || kind === 'widget' || kind === 'api') && (
+
+ No additional configuration required.
+
+ )}
+
+
+
+
+ Enabled
+
+
+
+
+ setOpen(false)}>
+ Cancel
+
+
+ {submitting ? 'Saving…' : isEdit ? 'Save channel' : 'Add channel'}
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/inboxes/inbox-channels-tab.tsx b/apps/web/src/components/admin/settings/inboxes/inbox-channels-tab.tsx
new file mode 100644
index 000000000..8498800b2
--- /dev/null
+++ b/apps/web/src/components/admin/settings/inboxes/inbox-channels-tab.tsx
@@ -0,0 +1,216 @@
+/**
+ * Channels tab for an inbox detail page. Lists existing channels with inline
+ * enable toggles + edit + archive buttons. Add/edit/archive are gated by the
+ * `INBOX_CHANNEL_MANAGE` permission.
+ */
+import { useSuspenseQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import type { InboxId, InboxChannelId } from '@quackback/ids'
+import { updateInboxChannelFn, archiveInboxChannelFn } from '@/lib/server/functions/inboxes'
+import { inboxQueries } from '@/lib/client/queries/inboxes'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Switch } from '@/components/ui/switch'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog'
+import { TrashIcon, PencilSquareIcon, PlusIcon } from '@heroicons/react/24/outline'
+import { InboxChannelDialog } from './inbox-channel-dialog'
+import { PermissionGate } from '@/components/admin/shared/permission-gate'
+import { PERMISSIONS } from '@/lib/server/domains/authz'
+
+const KIND_COLORS: Record = {
+ portal: 'bg-blue-100 text-blue-900 dark:bg-blue-950/40 dark:text-blue-200',
+ email: 'bg-amber-100 text-amber-900 dark:bg-amber-950/40 dark:text-amber-200',
+ api: 'bg-violet-100 text-violet-900 dark:bg-violet-950/40 dark:text-violet-200',
+ widget: 'bg-emerald-100 text-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200',
+ webhook: 'bg-rose-100 text-rose-900 dark:bg-rose-950/40 dark:text-rose-200',
+}
+
+export function InboxChannelsTab({ inboxId }: { inboxId: InboxId }) {
+ const qc = useQueryClient()
+ const { data: channels } = useSuspenseQuery(inboxQueries.channels(inboxId))
+
+ const invalidate = () =>
+ qc.invalidateQueries({ queryKey: inboxQueries.channels(inboxId).queryKey })
+
+ const toggleMutation = useMutation({
+ mutationFn: (vars: { channelId: InboxChannelId; enabled: boolean }) =>
+ updateInboxChannelFn({ data: { channelId: vars.channelId, enabled: vars.enabled } }),
+ onSuccess: () => invalidate(),
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const archiveMutation = useMutation({
+ mutationFn: (channelId: InboxChannelId) => archiveInboxChannelFn({ data: { channelId } }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Channel archived')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ return (
+
+
+
+
Channels
+
+ Sources that can create tickets in this inbox.
+
+
+
+
+
+ Add channel
+
+ }
+ />
+
+
+
+
+
+
+
+ Kind
+ Label
+ External ID
+ Enabled
+ Status
+
+
+
+
+ {channels.length === 0 ? (
+
+
+ No channels yet.
+
+
+ ) : (
+ channels.map((ch) => (
+
+
+
+ {ch.kind}
+
+
+ {ch.label}
+
+ {ch.externalId ?? '—'}
+
+
+
+ {ch.enabled ? 'on' : 'off'}
+
+ }
+ >
+
+ toggleMutation.mutate({
+ channelId: ch.id as InboxChannelId,
+ enabled: v,
+ })
+ }
+ aria-label="Toggle channel enabled"
+ />
+
+
+
+ {ch.archivedAt ? (
+
+ Archived
+
+ ) : (
+ Active
+ )}
+
+
+
+
+
+
+
+ }
+ />
+ {ch.archivedAt == null && (
+
+
+
+
+
+
+
+
+ Archive channel?
+
+ Tickets that arrived via this channel are kept; new tickets will
+ no longer flow through it.
+
+
+
+ Cancel
+ archiveMutation.mutate(ch.id as InboxChannelId)}
+ >
+ Archive
+
+
+
+
+ )}
+
+
+
+
+ ))
+ )}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/inboxes/inbox-create-dialog.tsx b/apps/web/src/components/admin/settings/inboxes/inbox-create-dialog.tsx
new file mode 100644
index 000000000..b596668ad
--- /dev/null
+++ b/apps/web/src/components/admin/settings/inboxes/inbox-create-dialog.tsx
@@ -0,0 +1,245 @@
+/**
+ * Create-inbox dialog. Captures slug + display name + a few defaults; submit
+ * calls `createInboxFn`, then navigates to the new inbox detail page.
+ */
+import { useState } from 'react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { useRouter } from '@tanstack/react-router'
+import { toast } from 'sonner'
+import type { TeamId, TicketStatusId, InboxId } from '@quackback/ids'
+import { createInboxFn } from '@/lib/server/functions/inboxes'
+import { inboxQueries } from '@/lib/client/queries/inboxes'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { TeamPicker } from '@/components/admin/shared/team-picker'
+import { StatusPicker } from '@/components/admin/shared/status-picker'
+
+const PRIORITIES = ['low', 'normal', 'high', 'urgent'] as const
+const VISIBILITY = ['team', 'org', 'shared', 'private'] as const
+
+export function InboxCreateDialog({ trigger }: { trigger: React.ReactNode }) {
+ const [open, setOpen] = useState(false)
+ const router = useRouter()
+ const qc = useQueryClient()
+
+ const [slug, setSlug] = useState('')
+ const [name, setName] = useState('')
+ const [description, setDescription] = useState('')
+ const [primaryTeamId, setPrimaryTeamId] = useState(null)
+ const [defaultStatusId, setDefaultStatusId] = useState(null)
+ const [defaultVisibilityScope, setDefaultVisibilityScope] =
+ useState<(typeof VISIBILITY)[number]>('team')
+ const [defaultPriority, setDefaultPriority] = useState<(typeof PRIORITIES)[number]>('normal')
+ const [color, setColor] = useState('')
+ const [icon, setIcon] = useState('')
+
+ const reset = () => {
+ setSlug('')
+ setName('')
+ setDescription('')
+ setPrimaryTeamId(null)
+ setDefaultStatusId(null)
+ setDefaultVisibilityScope('team')
+ setDefaultPriority('normal')
+ setColor('')
+ setIcon('')
+ }
+
+ const mutation = useMutation({
+ mutationFn: () =>
+ createInboxFn({
+ data: {
+ slug: slug.trim(),
+ name: name.trim(),
+ description: description.trim() || null,
+ primaryTeamId: primaryTeamId ?? null,
+ defaultStatusId: defaultStatusId ?? null,
+ defaultVisibilityScope,
+ defaultPriority,
+ color: color.trim() || null,
+ icon: icon.trim() || null,
+ },
+ }),
+ onSuccess: (inbox) => {
+ qc.invalidateQueries({ queryKey: inboxQueries.list().queryKey })
+ qc.invalidateQueries({ queryKey: ['inboxes'] })
+ toast.success('Inbox created')
+ setOpen(false)
+ reset()
+ router.navigate({
+ to: '/admin/settings/inboxes/$inboxId',
+ params: { inboxId: inbox.id as InboxId },
+ })
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ return (
+
+ {trigger}
+
+
+ New inbox
+
+ An inbox is a named queue with its own channels, members and routing defaults.
+
+
+
+ {
+ e.preventDefault()
+ if (!slug.trim() || !name.trim()) {
+ toast.error('Slug and name are required')
+ return
+ }
+ mutation.mutate()
+ }}
+ className="space-y-3"
+ >
+
+
+
+ Description
+ setDescription(e.target.value)}
+ rows={2}
+ />
+
+
+
+ Primary team
+
+
+
+
+
+ Default visibility
+ setDefaultVisibilityScope(v as (typeof VISIBILITY)[number])}
+ >
+
+
+
+
+ {VISIBILITY.map((v) => (
+
+ {v}
+
+ ))}
+
+
+
+
+ Default priority
+ setDefaultPriority(v as (typeof PRIORITIES)[number])}
+ >
+
+
+
+
+ {PRIORITIES.map((p) => (
+
+ {p}
+
+ ))}
+
+
+
+
+
+
+ Default status
+
+
+
+
+
+
+ setOpen(false)}>
+ Cancel
+
+
+ {mutation.isPending ? 'Creating…' : 'Create inbox'}
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/inboxes/inbox-list.tsx b/apps/web/src/components/admin/settings/inboxes/inbox-list.tsx
new file mode 100644
index 000000000..c75b77700
--- /dev/null
+++ b/apps/web/src/components/admin/settings/inboxes/inbox-list.tsx
@@ -0,0 +1,97 @@
+/**
+ * ` ` — table of all inboxes with name (color dot), slug, primary
+ * team, defaults summary, and active/archived chip. Row click navigates to
+ * the detail page. The "Show archived" toggle reveals soft-deleted rows.
+ */
+import { useState, useMemo } from 'react'
+import { Link } from '@tanstack/react-router'
+import { useSuspenseQuery } from '@tanstack/react-query'
+import type { InboxId } from '@quackback/ids'
+import { inboxQueries } from '@/lib/client/queries/inboxes'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Badge } from '@/components/ui/badge'
+import { Switch } from '@/components/ui/switch'
+import { Label } from '@/components/ui/label'
+
+export function InboxList() {
+ const [showArchived, setShowArchived] = useState(false)
+ const { data: inboxes } = useSuspenseQuery(inboxQueries.list({ includeArchived: true }))
+
+ const rows = useMemo(
+ () => (showArchived ? inboxes : inboxes.filter((i) => i.archivedAt == null)),
+ [inboxes, showArchived]
+ )
+
+ return (
+
+
+
+
+ Show archived
+
+
+
+
+
+
+
+ Name
+ Slug
+ Defaults
+ Status
+
+
+
+ {rows.length === 0 ? (
+
+
+ No inboxes yet.
+
+
+ ) : (
+ rows.map((inbox) => (
+
+
+
+
+ {inbox.name}
+
+
+
+ {inbox.slug}
+
+
+ {inbox.defaultPriority} · {inbox.defaultVisibilityScope}
+
+
+ {inbox.archivedAt ? (
+
+ Archived
+
+ ) : (
+ Active
+ )}
+
+
+ ))
+ )}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/inboxes/inbox-members-tab.tsx b/apps/web/src/components/admin/settings/inboxes/inbox-members-tab.tsx
new file mode 100644
index 000000000..5800224c8
--- /dev/null
+++ b/apps/web/src/components/admin/settings/inboxes/inbox-members-tab.tsx
@@ -0,0 +1,267 @@
+/**
+ * Members tab for an inbox detail page. Lists current memberships with role
+ * inline-edit + remove. Adds members via `` filtered to
+ * exclude already-present principals.
+ */
+import { useState, useMemo } from 'react'
+import { useSuspenseQuery, useMutation, useQueryClient, useQuery } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import type { InboxId, PrincipalId, InboxMembershipId } from '@quackback/ids'
+import {
+ addInboxMembershipFn,
+ updateInboxMembershipRoleFn,
+ removeInboxMembershipFn,
+} from '@/lib/server/functions/inboxes'
+import { getPrincipalsByIdsFn } from '@/lib/server/functions/principals'
+import { inboxQueries } from '@/lib/client/queries/inboxes'
+import { Button } from '@/components/ui/button'
+import { Avatar } from '@/components/ui/avatar'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog'
+import { TrashIcon } from '@heroicons/react/24/outline'
+import { PrincipalPicker } from '@/components/admin/shared/principal-picker'
+import { PermissionGate } from '@/components/admin/shared/permission-gate'
+import { PERMISSIONS } from '@/lib/server/domains/authz'
+
+const ROLES = ['owner', 'agent', 'viewer'] as const
+type MembershipRole = (typeof ROLES)[number]
+
+export function InboxMembersTab({ inboxId }: { inboxId: InboxId }) {
+ const qc = useQueryClient()
+ const { data: memberships } = useSuspenseQuery(inboxQueries.memberships(inboxId))
+
+ const principalIds = useMemo(
+ () => memberships.map((m) => m.principalId as PrincipalId),
+ [memberships]
+ )
+
+ const principalsQuery = useQuery({
+ queryKey: ['principals', 'byIds', principalIds],
+ queryFn: () => getPrincipalsByIdsFn({ data: { ids: principalIds } }),
+ enabled: principalIds.length > 0,
+ staleTime: 60_000,
+ })
+ const principalMap = useMemo(() => {
+ const m = new Map()
+ for (const p of principalsQuery.data ?? []) {
+ m.set(p.id, { displayName: p.displayName, avatarUrl: p.avatarUrl })
+ }
+ return m
+ }, [principalsQuery.data])
+
+ const [addPrincipalId, setAddPrincipalId] = useState(null)
+ const [addRole, setAddRole] = useState('agent')
+
+ const invalidate = () =>
+ qc.invalidateQueries({ queryKey: inboxQueries.memberships(inboxId).queryKey })
+
+ const addMutation = useMutation({
+ mutationFn: () =>
+ addInboxMembershipFn({
+ data: { inboxId, principalId: addPrincipalId!, role: addRole },
+ }),
+ onSuccess: () => {
+ setAddPrincipalId(null)
+ setAddRole('agent')
+ invalidate()
+ toast.success('Member added')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const updateRoleMutation = useMutation({
+ mutationFn: (vars: { membershipId: InboxMembershipId; role: MembershipRole }) =>
+ updateInboxMembershipRoleFn({
+ data: { membershipId: vars.membershipId, role: vars.role },
+ }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Role updated')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const removeMutation = useMutation({
+ mutationFn: (membershipId: InboxMembershipId) =>
+ removeInboxMembershipFn({ data: { membershipId } }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Member removed')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ return (
+
+
+
Members
+
+ Principals who have explicit access to this inbox.
+
+
+
+
+
+
+
+
+ Role
+ setAddRole(v as MembershipRole)}>
+
+
+
+
+ {ROLES.map((r) => (
+
+ {r}
+
+ ))}
+
+
+
+
addMutation.mutate()}
+ >
+ Add
+
+
+
+
+
+
+
+
+
+ Member
+ Role
+
+
+
+
+ {memberships.length === 0 ? (
+
+
+ No members yet.
+
+
+ ) : (
+ memberships.map((m) => {
+ const info = principalMap.get(m.principalId)
+ const label = info?.displayName ?? m.principalId
+ return (
+
+
+
+
+ {info?.avatarUrl ? (
+
+ ) : (
+ label.slice(0, 2).toUpperCase()
+ )}
+
+
{label}
+
+
+
+ {m.role}}
+ >
+
+ updateRoleMutation.mutate({
+ membershipId: m.id as InboxMembershipId,
+ role: v as MembershipRole,
+ })
+ }
+ >
+
+
+
+
+ {ROLES.map((r) => (
+
+ {r}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Remove member?
+
+ They will lose direct access to this inbox.
+
+
+
+ Cancel
+ removeMutation.mutate(m.id as InboxMembershipId)}
+ >
+ Remove
+
+
+
+
+
+
+
+ )
+ })
+ )}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/inboxes/inbox-overview-tab.tsx b/apps/web/src/components/admin/settings/inboxes/inbox-overview-tab.tsx
new file mode 100644
index 000000000..5505b003d
--- /dev/null
+++ b/apps/web/src/components/admin/settings/inboxes/inbox-overview-tab.tsx
@@ -0,0 +1,297 @@
+/**
+ * Overview tab for an inbox detail page. Editable form for name/description/
+ * defaults plus an Archive / Unarchive button. Slug is read-only because
+ * `updateInboxFn` does not accept it.
+ */
+import { useState, useEffect } from 'react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import type { Inbox } from '@/lib/shared/db-types'
+import type { TeamId, TicketStatusId } from '@quackback/ids'
+import { updateInboxFn, archiveInboxFn, unarchiveInboxFn } from '@/lib/server/functions/inboxes'
+import { inboxQueries } from '@/lib/client/queries/inboxes'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog'
+import { TeamPicker } from '@/components/admin/shared/team-picker'
+import { StatusPicker } from '@/components/admin/shared/status-picker'
+
+const PRIORITIES = ['low', 'normal', 'high', 'urgent'] as const
+const VISIBILITY = ['team', 'org', 'shared', 'private'] as const
+
+export interface InboxOverviewTabProps {
+ inbox: Inbox
+}
+
+export function InboxOverviewTab({ inbox }: InboxOverviewTabProps) {
+ const qc = useQueryClient()
+ const [name, setName] = useState(inbox.name)
+ const [description, setDescription] = useState(inbox.description ?? '')
+ const [primaryTeamId, setPrimaryTeamId] = useState(
+ inbox.primaryTeamId as TeamId | null
+ )
+ const [defaultStatusId, setDefaultStatusId] = useState(
+ inbox.defaultStatusId as TicketStatusId | null
+ )
+ const [defaultVisibilityScope, setDefaultVisibilityScope] = useState<(typeof VISIBILITY)[number]>(
+ inbox.defaultVisibilityScope as (typeof VISIBILITY)[number]
+ )
+ const [defaultPriority, setDefaultPriority] = useState<(typeof PRIORITIES)[number]>(
+ inbox.defaultPriority as (typeof PRIORITIES)[number]
+ )
+ const [color, setColor] = useState(inbox.color ?? '')
+ const [icon, setIcon] = useState(inbox.icon ?? '')
+
+ // Reset local state when the inbox prop changes (after a refetch).
+ useEffect(() => {
+ setName(inbox.name)
+ setDescription(inbox.description ?? '')
+ setPrimaryTeamId(inbox.primaryTeamId as TeamId | null)
+ setDefaultStatusId(inbox.defaultStatusId as TicketStatusId | null)
+ setDefaultVisibilityScope(inbox.defaultVisibilityScope as (typeof VISIBILITY)[number])
+ setDefaultPriority(inbox.defaultPriority as (typeof PRIORITIES)[number])
+ setColor(inbox.color ?? '')
+ setIcon(inbox.icon ?? '')
+ }, [inbox])
+
+ const invalidate = () => {
+ qc.invalidateQueries({ queryKey: inboxQueries.detail(inbox.id).queryKey })
+ qc.invalidateQueries({ queryKey: inboxQueries.list().queryKey })
+ qc.invalidateQueries({ queryKey: ['inboxes'] })
+ }
+
+ const saveMutation = useMutation({
+ mutationFn: () =>
+ updateInboxFn({
+ data: {
+ inboxId: inbox.id,
+ name: name.trim(),
+ description: description.trim() || null,
+ primaryTeamId: primaryTeamId ?? null,
+ defaultStatusId: defaultStatusId ?? null,
+ defaultVisibilityScope,
+ defaultPriority,
+ color: color.trim() || null,
+ icon: icon.trim() || null,
+ },
+ }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Inbox updated')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const archiveMutation = useMutation({
+ mutationFn: () => archiveInboxFn({ data: { inboxId: inbox.id } }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Inbox archived')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const unarchiveMutation = useMutation({
+ mutationFn: () => unarchiveInboxFn({ data: { inboxId: inbox.id } }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Inbox unarchived')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ return (
+
+
{
+ e.preventDefault()
+ if (!name.trim()) {
+ toast.error('Name is required')
+ return
+ }
+ saveMutation.mutate()
+ }}
+ className="space-y-3"
+ >
+
+
+
+ Description
+ setDescription(e.target.value)}
+ rows={2}
+ />
+
+
+
+ Primary team
+
+
+
+
+
+ Default visibility
+ setDefaultVisibilityScope(v as (typeof VISIBILITY)[number])}
+ >
+
+
+
+
+ {VISIBILITY.map((v) => (
+
+ {v}
+
+ ))}
+
+
+
+
+ Default priority
+ setDefaultPriority(v as (typeof PRIORITIES)[number])}
+ >
+
+
+
+
+ {PRIORITIES.map((p) => (
+
+ {p}
+
+ ))}
+
+
+
+
+
+
+ Default status
+
+
+
+
+
+
+
+ {saveMutation.isPending ? 'Saving…' : 'Save changes'}
+
+
+
+
+
+
Archive
+ {inbox.archivedAt ? (
+
+
+ Archived. Tickets in this inbox remain accessible but new routing rules will skip it.
+
+
unarchiveMutation.mutate()}
+ disabled={unarchiveMutation.isPending}
+ >
+ Unarchive
+
+
+ ) : (
+
+
+ Hide this inbox from queues and routing. Existing tickets are preserved.
+
+
+
+
+ Archive…
+
+
+
+
+ Archive this inbox?
+
+ Members will no longer see it in their saved views. You can unarchive it later.
+
+
+
+ Cancel
+ archiveMutation.mutate()}>
+ Archive
+
+
+
+
+
+ )}
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/roles/__tests__/role-dialogs.test.tsx b/apps/web/src/components/admin/settings/roles/__tests__/role-dialogs.test.tsx
new file mode 100644
index 000000000..aa21f7b98
--- /dev/null
+++ b/apps/web/src/components/admin/settings/roles/__tests__/role-dialogs.test.tsx
@@ -0,0 +1,516 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import type { RoleId } from '@quackback/ids'
+import { DeleteRoleDialog } from '../delete-role-dialog'
+import { RoleCreateDialog } from '../role-create-dialog'
+import { RoleDetailPanel } from '../role-detail-panel'
+import { RoleEditDialog } from '../role-edit-dialog'
+import { RoleList } from '../role-list'
+import { RolePermissionMatrix } from '../role-permission-matrix'
+import { RolesSettings } from '../roles-settings'
+
+const mocks = vi.hoisted(() => ({
+ createRoleFn: vi.fn(),
+ deleteRoleFn: vi.fn(),
+ getRoleFn: vi.fn(),
+ setRolePermissionsFn: vi.fn(),
+ updateRoleFn: vi.fn(),
+ invalidateQueries: vi.fn(),
+ routerInvalidate: vi.fn(),
+ roleDetail: {
+ permissionKeys: ['tickets.read'],
+ },
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useSuspenseQuery: () => ({
+ data: mocks.roleDetail,
+ }),
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ useRouter: () => ({
+ invalidate: mocks.routerInvalidate,
+ }),
+}))
+
+vi.mock('@/lib/server/functions/roles', () => ({
+ createRoleFn: mocks.createRoleFn,
+ deleteRoleFn: mocks.deleteRoleFn,
+ getRoleFn: mocks.getRoleFn,
+ setRolePermissionsFn: mocks.setRolePermissionsFn,
+ updateRoleFn: mocks.updateRoleFn,
+}))
+
+vi.mock('@/components/admin/settings/api-keys/scope-picker', () => ({
+ ScopePicker: ({
+ value,
+ onChange,
+ disabled,
+ }: {
+ value: string[]
+ onChange: (value: string[]) => void
+ disabled?: boolean
+ }) => (
+
+ Scopes: {value.join(', ') || 'none'}
+ Picker {disabled ? 'disabled' : 'enabled'}
+
+ onChange([...value, value.includes('tickets.read') ? 'tickets.write' : 'tickets.read'])
+ }
+ >
+ {value.includes('tickets.read') ? 'Add ticket write' : 'Add ticket read'}
+
+
+ ),
+}))
+
+vi.mock('@/components/ui/dialog', () => ({
+ Dialog: ({
+ children,
+ open,
+ }: {
+ children: ReactNode
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ }) => (open ? {children}
: null),
+ DialogContent: ({ children }: { children: ReactNode; className?: string }) => (
+
+ ),
+ DialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ DialogFooter: ({ children }: { children: ReactNode }) => ,
+ DialogHeader: ({ children }: { children: ReactNode }) => ,
+ DialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ 'aria-label': ariaLabel,
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ size?: string
+ 'aria-label'?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({
+ id,
+ value,
+ onChange,
+ disabled,
+ placeholder,
+ }: {
+ id?: string
+ value?: string
+ onChange?: (event: { target: { value: string } }) => void
+ disabled?: boolean
+ placeholder?: string
+ maxLength?: number
+ autoFocus?: boolean
+ }) => (
+ onChange?.({ target: { value: event.currentTarget.value } })}
+ />
+ ),
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string; className?: string }) => (
+ {children}
+ ),
+}))
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.createRoleFn.mockResolvedValue({ id: 'role_1' })
+ mocks.deleteRoleFn.mockResolvedValue({ ok: true })
+ mocks.setRolePermissionsFn.mockResolvedValue({ ok: true })
+ mocks.updateRoleFn.mockResolvedValue({ ok: true })
+ mocks.roleDetail = {
+ permissionKeys: ['tickets.read'],
+ }
+})
+
+describe('RoleCreateDialog', () => {
+ it('creates a trimmed role, invalidates role data, and closes', async () => {
+ const onOpenChange = vi.fn()
+ const onCreated = vi.fn()
+ render( )
+
+ expect(screen.getByRole('heading', { name: 'Create role' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Create role' })).toBeDisabled()
+
+ fireEvent.change(screen.getByLabelText('Key'), { target: { value: ' custom-role ' } })
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Custom Role ' } })
+ fireEvent.change(screen.getByLabelText('Description'), {
+ target: { value: ' Can triage tickets ' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Add ticket read' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Create role' }))
+
+ await waitFor(() => {
+ expect(mocks.createRoleFn).toHaveBeenCalledWith({
+ data: {
+ key: 'custom-role',
+ name: 'Custom Role',
+ description: 'Can triage tickets',
+ permissionKeys: ['tickets.read'],
+ },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'roles'] })
+ expect(mocks.routerInvalidate).toHaveBeenCalled()
+ expect(onCreated).toHaveBeenCalledWith('role_1')
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+
+ it('renders create failures and resets state when cancelled', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ mocks.createRoleFn.mockRejectedValueOnce(new Error('Role key already exists'))
+ const onOpenChange = vi.fn()
+ render( )
+
+ fireEvent.change(screen.getByLabelText('Key'), { target: { value: 'agent' } })
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Agent' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Create role' }))
+
+ await waitFor(() => {
+ expect(screen.getByText('Role key already exists')).toBeInTheDocument()
+ })
+ expect(console.error).toHaveBeenCalledWith('Failed to create role:', expect.any(Error))
+
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ expect(screen.getByLabelText('Key')).toHaveValue('')
+ expect(screen.getByLabelText('Name')).toHaveValue('')
+ })
+})
+
+describe('DeleteRoleDialog', () => {
+ it('blocks deletion while assignments still reference the role', () => {
+ render(
+
+ )
+
+ expect(screen.getByText(/This role has 2 active assignments/)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Delete role' })).toBeDisabled()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Delete role' }))
+ expect(mocks.deleteRoleFn).not.toHaveBeenCalled()
+ })
+
+ it('deletes unassigned roles and closes the dialog', async () => {
+ const onOpenChange = vi.fn()
+ render(
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Delete role' }))
+
+ await waitFor(() => {
+ expect(mocks.deleteRoleFn).toHaveBeenCalledWith({ data: { id: 'role_1' } })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'roles'] })
+ expect(mocks.routerInvalidate).toHaveBeenCalled()
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+
+ it('renders delete failures and supports cancelling', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ mocks.deleteRoleFn.mockRejectedValueOnce('denied')
+ const onOpenChange = vi.fn()
+ render(
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Delete role' }))
+
+ await waitFor(() => {
+ expect(screen.getByText('Failed to delete role')).toBeInTheDocument()
+ })
+ expect(console.error).toHaveBeenCalledWith('Failed to delete role:', 'denied')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+})
+
+describe('RoleEditDialog', () => {
+ it('updates a role with trimmed values and invalidates role data', async () => {
+ const onOpenChange = vi.fn()
+ render(
+
+ )
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Support Agent ' } })
+ fireEvent.change(screen.getByLabelText('Description'), { target: { value: ' ' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
+
+ await waitFor(() => {
+ expect(mocks.updateRoleFn).toHaveBeenCalledWith({
+ data: {
+ id: 'role_1',
+ name: 'Support Agent',
+ description: null,
+ },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'roles'] })
+ expect(mocks.routerInvalidate).toHaveBeenCalled()
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+
+ it('renders validation and update failures', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ mocks.updateRoleFn.mockRejectedValueOnce(new Error('Name is already taken'))
+ render(
+
+ )
+
+ const nameInput = screen.getByLabelText('Name')
+ fireEvent.change(nameInput, { target: { value: ' ' } })
+ fireEvent.submit(nameInput.closest('form') as HTMLFormElement)
+ expect(screen.getByText('Name is required')).toBeInTheDocument()
+
+ fireEvent.change(nameInput, { target: { value: 'Agent 2' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
+
+ await waitFor(() => {
+ expect(screen.getByText('Name is already taken')).toBeInTheDocument()
+ })
+ expect(console.error).toHaveBeenCalledWith('Failed to update role:', expect.any(Error))
+ })
+})
+
+describe('RolePermissionMatrix', () => {
+ it('saves changed permission keys and can reset the draft', async () => {
+ render( )
+
+ expect(screen.getByRole('button', { name: 'Save permissions' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Add ticket write' }))
+ expect(screen.getByRole('button', { name: 'Save permissions' })).toBeEnabled()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Save permissions' }))
+
+ await waitFor(() => {
+ expect(mocks.setRolePermissionsFn).toHaveBeenCalledWith({
+ data: {
+ roleId: 'role_1',
+ permissionKeys: ['tickets.read', 'tickets.write'],
+ },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'roles'] })
+ expect(mocks.routerInvalidate).toHaveBeenCalled()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Reset' }))
+ expect(screen.getByText('Scopes: tickets.read')).toBeInTheDocument()
+ })
+
+ it('renders permission save failures and hides controls for system roles', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ mocks.setRolePermissionsFn.mockRejectedValueOnce('denied')
+ const { rerender } = render(
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Add ticket write' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Save permissions' }))
+
+ await waitFor(() => {
+ expect(screen.getByText('Failed to save permissions')).toBeInTheDocument()
+ })
+ expect(console.error).toHaveBeenCalledWith('Failed to save permissions:', 'denied')
+
+ rerender( )
+ expect(screen.getByText('Picker disabled')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Save permissions' })).not.toBeInTheDocument()
+ })
+})
+
+describe('RoleList', () => {
+ it('renders role metadata, active state, and selection callbacks', () => {
+ const onSelect = vi.fn()
+ render(
+
+ )
+
+ expect(screen.getByText('Owner')).toBeInTheDocument()
+ expect(screen.getByText('owner')).toBeInTheDocument()
+ expect(screen.getByText('7 perms')).toBeInTheDocument()
+ expect(screen.getByText('1 assigned')).toBeInTheDocument()
+ expect(screen.getByText('Support')).toBeInTheDocument()
+ expect(screen.getByText('3 perms')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: /Support/ }))
+ expect(onSelect).toHaveBeenCalledWith('role_custom')
+ })
+})
+
+describe('RoleDetailPanel', () => {
+ it('renders editable role metadata and opens edit/delete dialogs', () => {
+ render(
+
+ )
+
+ expect(screen.getByRole('heading', { name: 'Support' })).toBeInTheDocument()
+ expect(screen.getByText('support')).toBeInTheDocument()
+ expect(screen.getByText('1 assignment')).toBeInTheDocument()
+ expect(screen.getByText('Can triage tickets')).toBeInTheDocument()
+ expect(screen.getByText('Scopes: tickets.read')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Edit Support' }))
+ expect(screen.getByRole('heading', { name: 'Edit role' })).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Delete Support' }))
+ expect(screen.getByRole('heading', { name: 'Delete role' })).toBeInTheDocument()
+ })
+
+ it('renders system roles as locked and disables destructive controls', () => {
+ render(
+
+ )
+
+ expect(screen.getByText('System')).toBeInTheDocument()
+ expect(screen.getByText('2 assignments')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Edit Owner' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: 'Delete Owner' })).toBeDisabled()
+ expect(screen.getByText('Picker disabled')).toBeInTheDocument()
+ expect(screen.queryByRole('heading', { name: 'Edit role' })).not.toBeInTheDocument()
+ })
+})
+
+describe('RolesSettings', () => {
+ it('selects roles and opens the create role dialog', () => {
+ render(
+
+ )
+
+ expect(screen.getAllByText('Owner')).toHaveLength(2)
+ expect(screen.getByText('1 assignment')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: /Support/ }))
+ expect(screen.getByRole('heading', { name: 'Support' })).toBeInTheDocument()
+ expect(screen.getByText('Support role')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: /Create role/ }))
+ expect(screen.getByRole('heading', { name: 'Create role' })).toBeInTheDocument()
+ })
+
+ it('renders an empty selection state when no roles exist', () => {
+ render( )
+
+ expect(screen.getByText('No role selected.')).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/admin/settings/roles/delete-role-dialog.tsx b/apps/web/src/components/admin/settings/roles/delete-role-dialog.tsx
new file mode 100644
index 000000000..a08a46093
--- /dev/null
+++ b/apps/web/src/components/admin/settings/roles/delete-role-dialog.tsx
@@ -0,0 +1,81 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import { useQueryClient } from '@tanstack/react-query'
+import { useRouter } from '@tanstack/react-router'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { deleteRoleFn } from '@/lib/server/functions/roles'
+import type { RoleId } from '@quackback/ids'
+
+interface Props {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ role: { id: RoleId; name: string; assignmentCount: number }
+}
+
+export function DeleteRoleDialog({ open, onOpenChange, role }: Props) {
+ const router = useRouter()
+ const queryClient = useQueryClient()
+ const [isPending, startTransition] = useTransition()
+ const [error, setError] = useState(null)
+ const [submitting, setSubmitting] = useState(false)
+
+ const blocked = role.assignmentCount > 0
+
+ const handleConfirm = async () => {
+ if (blocked) return
+ setError(null)
+ setSubmitting(true)
+ try {
+ await deleteRoleFn({ data: { id: role.id } })
+ startTransition(() => {
+ queryClient.invalidateQueries({ queryKey: ['admin', 'roles'] })
+ router.invalidate()
+ })
+ onOpenChange(false)
+ } catch (err) {
+ console.error('Failed to delete role:', err)
+ setError(err instanceof Error ? err.message : 'Failed to delete role')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ const busy = submitting || isPending
+
+ return (
+
+
+
+ Delete role
+
+ Permanently delete {role.name} . This cannot be undone.
+
+
+ {blocked && (
+
+ This role has {role.assignmentCount} active assignment
+ {role.assignmentCount === 1 ? '' : 's'}. Revoke them before deleting.
+
+ )}
+ {error && {error}
}
+
+ onOpenChange(false)} disabled={busy}>
+ Cancel
+
+
+ {busy ? 'Deleting…' : 'Delete role'}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/roles/role-create-dialog.tsx b/apps/web/src/components/admin/settings/roles/role-create-dialog.tsx
new file mode 100644
index 000000000..5504143e8
--- /dev/null
+++ b/apps/web/src/components/admin/settings/roles/role-create-dialog.tsx
@@ -0,0 +1,158 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import { useQueryClient } from '@tanstack/react-query'
+import { useRouter } from '@tanstack/react-router'
+import type { RoleId } from '@quackback/ids'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { ScopePicker } from '@/components/admin/settings/api-keys/scope-picker'
+import { createRoleFn } from '@/lib/server/functions/roles'
+import type { PermissionKey } from '@/lib/server/domains/authz'
+
+interface Props {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onCreated?: (id: RoleId) => void
+}
+
+export function RoleCreateDialog({ open, onOpenChange, onCreated }: Props) {
+ const router = useRouter()
+ const queryClient = useQueryClient()
+ const [isPending, startTransition] = useTransition()
+
+ const [key, setKey] = useState('')
+ const [name, setName] = useState('')
+ const [description, setDescription] = useState('')
+ const [permissionKeys, setPermissionKeys] = useState([])
+ const [error, setError] = useState(null)
+ const [submitting, setSubmitting] = useState(false)
+
+ const handleOpenChange = (next: boolean) => {
+ if (!next) {
+ setKey('')
+ setName('')
+ setDescription('')
+ setPermissionKeys([])
+ setError(null)
+ }
+ onOpenChange(next)
+ }
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setError(null)
+ if (!key.trim() || !name.trim()) {
+ setError('Key and name are required')
+ return
+ }
+ setSubmitting(true)
+ try {
+ const { id } = await createRoleFn({
+ data: {
+ key: key.trim(),
+ name: name.trim(),
+ description: description.trim() || null,
+ permissionKeys: permissionKeys as PermissionKey[],
+ },
+ })
+ startTransition(() => {
+ queryClient.invalidateQueries({ queryKey: ['admin', 'roles'] })
+ router.invalidate()
+ })
+ onCreated?.(id)
+ handleOpenChange(false)
+ } catch (err) {
+ console.error('Failed to create role:', err)
+ setError(err instanceof Error ? err.message : 'Failed to create role')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ const busy = submitting || isPending
+
+ return (
+
+
+
+ Create role
+
+ Define a new role bundle. Pick the permissions agents holding this role should have.
+
+
+
+
+
+
+
Key
+
setKey(e.target.value)}
+ disabled={busy}
+ maxLength={64}
+ autoFocus
+ />
+
lowercase, digits, _ or - only
+
+
+ Name
+ setName(e.target.value)}
+ disabled={busy}
+ maxLength={128}
+ />
+
+
+
+
+ Description
+ setDescription(e.target.value)}
+ disabled={busy}
+ maxLength={2000}
+ />
+
+
+
+ Permissions
+
+
+
+ {error && {error}
}
+
+
+ handleOpenChange(false)}
+ disabled={busy}
+ >
+ Cancel
+
+
+ {busy ? 'Creating…' : 'Create role'}
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/roles/role-detail-panel.tsx b/apps/web/src/components/admin/settings/roles/role-detail-panel.tsx
new file mode 100644
index 000000000..f588662ed
--- /dev/null
+++ b/apps/web/src/components/admin/settings/roles/role-detail-panel.tsx
@@ -0,0 +1,80 @@
+'use client'
+
+import { useState, Suspense } from 'react'
+import { LockClosedIcon, PencilIcon, TrashIcon } from '@heroicons/react/24/outline'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { RolePermissionMatrix } from './role-permission-matrix'
+import { RoleEditDialog } from './role-edit-dialog'
+import { DeleteRoleDialog } from './delete-role-dialog'
+import type { RoleListItem } from '@/lib/server/domains/authz/role.service'
+
+interface Props {
+ role: RoleListItem
+}
+
+export function RoleDetailPanel({ role }: Props) {
+ const [editOpen, setEditOpen] = useState(false)
+ const [deleteOpen, setDeleteOpen] = useState(false)
+
+ return (
+
+
+
+
+ {role.isSystem && }
+
{role.name}
+ {role.isSystem && (
+
+ System
+
+ )}
+
+
+ {role.key}
+ ·
+
+ {role.assignmentCount} assignment{role.assignmentCount === 1 ? '' : 's'}
+
+
+ {role.description && (
+
{role.description}
+ )}
+
+
+
setEditOpen(true)}
+ disabled={role.isSystem}
+ aria-label={`Edit ${role.name}`}
+ >
+
+
+
setDeleteOpen(true)}
+ disabled={role.isSystem}
+ aria-label={`Delete ${role.name}`}
+ >
+
+
+
+
+
+
Loading permissions… }
+ >
+
+
+
+ {!role.isSystem && (
+ <>
+
+
+ >
+ )}
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/roles/role-edit-dialog.tsx b/apps/web/src/components/admin/settings/roles/role-edit-dialog.tsx
new file mode 100644
index 000000000..c78bda17b
--- /dev/null
+++ b/apps/web/src/components/admin/settings/roles/role-edit-dialog.tsx
@@ -0,0 +1,111 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import { useQueryClient } from '@tanstack/react-query'
+import { useRouter } from '@tanstack/react-router'
+import type { RoleId } from '@quackback/ids'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { updateRoleFn } from '@/lib/server/functions/roles'
+
+interface Props {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ role: { id: RoleId; name: string; description: string | null }
+}
+
+export function RoleEditDialog({ open, onOpenChange, role }: Props) {
+ const router = useRouter()
+ const queryClient = useQueryClient()
+ const [isPending, startTransition] = useTransition()
+ const [name, setName] = useState(role.name)
+ const [description, setDescription] = useState(role.description ?? '')
+ const [error, setError] = useState(null)
+ const [submitting, setSubmitting] = useState(false)
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setError(null)
+ if (!name.trim()) {
+ setError('Name is required')
+ return
+ }
+ setSubmitting(true)
+ try {
+ await updateRoleFn({
+ data: {
+ id: role.id,
+ name: name.trim(),
+ description: description.trim() || null,
+ },
+ })
+ startTransition(() => {
+ queryClient.invalidateQueries({ queryKey: ['admin', 'roles'] })
+ router.invalidate()
+ })
+ onOpenChange(false)
+ } catch (err) {
+ console.error('Failed to update role:', err)
+ setError(err instanceof Error ? err.message : 'Failed to update role')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ const busy = submitting || isPending
+
+ return (
+
+
+
+ Edit role
+
+
+
+ Name
+ setName(e.target.value)}
+ disabled={busy}
+ autoFocus
+ maxLength={128}
+ />
+
+
+ Description
+ setDescription(e.target.value)}
+ disabled={busy}
+ maxLength={2000}
+ />
+
+ {error && {error}
}
+
+ onOpenChange(false)}
+ disabled={busy}
+ >
+ Cancel
+
+
+ {busy ? 'Saving…' : 'Save'}
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/roles/role-list.tsx b/apps/web/src/components/admin/settings/roles/role-list.tsx
new file mode 100644
index 000000000..8debee9de
--- /dev/null
+++ b/apps/web/src/components/admin/settings/roles/role-list.tsx
@@ -0,0 +1,50 @@
+'use client'
+
+import { LockClosedIcon } from '@heroicons/react/24/solid'
+import { cn } from '@/lib/shared/utils'
+import type { RoleListItem } from '@/lib/server/domains/authz/role.service'
+import type { RoleId } from '@quackback/ids'
+
+interface Props {
+ roles: RoleListItem[]
+ selectedId: RoleId | null
+ onSelect: (id: RoleId) => void
+}
+
+export function RoleList({ roles, selectedId, onSelect }: Props) {
+ return (
+
+
+ {roles.map((r) => {
+ const active = r.id === selectedId
+ return (
+
+ onSelect(r.id)}
+ className={cn(
+ 'w-full text-left px-3 py-2 transition-colors hover:bg-muted/50',
+ active && 'bg-muted'
+ )}
+ >
+
+ {r.isSystem && (
+
+ )}
+ {r.name}
+
+
+ {r.key}
+ ·
+ {r.permissionCount} perms
+ ·
+ {r.assignmentCount} assigned
+
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/roles/role-permission-matrix.tsx b/apps/web/src/components/admin/settings/roles/role-permission-matrix.tsx
new file mode 100644
index 000000000..c9dd20100
--- /dev/null
+++ b/apps/web/src/components/admin/settings/roles/role-permission-matrix.tsx
@@ -0,0 +1,88 @@
+'use client'
+
+import { useState, useTransition, useMemo } from 'react'
+import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
+import { useRouter } from '@tanstack/react-router'
+import { Button } from '@/components/ui/button'
+import { ScopePicker } from '@/components/admin/settings/api-keys/scope-picker'
+import { getRoleFn, setRolePermissionsFn } from '@/lib/server/functions/roles'
+import type { RoleId } from '@quackback/ids'
+import type { PermissionKey } from '@/lib/server/domains/authz'
+
+interface Props {
+ roleId: RoleId
+ isSystem: boolean
+}
+
+const roleQuery = (id: RoleId) => ({
+ queryKey: ['admin', 'roles', 'detail', id] as const,
+ queryFn: () => getRoleFn({ data: { id } }),
+})
+
+/**
+ * Read+edit grid for one role's permissions.
+ * Parent should remount with `key={roleId}` when selected role changes.
+ */
+export function RolePermissionMatrix({ roleId, isSystem }: Props) {
+ const router = useRouter()
+ const queryClient = useQueryClient()
+ const { data } = useSuspenseQuery(roleQuery(roleId))
+ const [isPending, startTransition] = useTransition()
+ const [submitting, setSubmitting] = useState(false)
+ const [error, setError] = useState(null)
+ const [draft, setDraft] = useState(data.permissionKeys)
+
+ const initialSet = useMemo(() => new Set(data.permissionKeys), [data.permissionKeys])
+ const draftSet = useMemo(() => new Set(draft), [draft])
+ const dirty =
+ draft.length !== data.permissionKeys.length ||
+ draft.some((k) => !initialSet.has(k as PermissionKey)) ||
+ data.permissionKeys.some((k) => !draftSet.has(k))
+
+ const handleSave = async () => {
+ setError(null)
+ setSubmitting(true)
+ try {
+ await setRolePermissionsFn({
+ data: { roleId, permissionKeys: draft as PermissionKey[] },
+ })
+ startTransition(() => {
+ queryClient.invalidateQueries({ queryKey: ['admin', 'roles'] })
+ router.invalidate()
+ })
+ } catch (err) {
+ console.error('Failed to save permissions:', err)
+ setError(err instanceof Error ? err.message : 'Failed to save permissions')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ const busy = submitting || isPending
+
+ return (
+
+
undefined : setDraft}
+ disabled={isSystem || busy}
+ />
+ {error && {error}
}
+ {!isSystem && (
+
+ setDraft(data.permissionKeys)}
+ disabled={!dirty || busy}
+ >
+ Reset
+
+
+ {busy ? 'Saving…' : 'Save permissions'}
+
+
+ )}
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/roles/roles-settings.tsx b/apps/web/src/components/admin/settings/roles/roles-settings.tsx
new file mode 100644
index 000000000..dedec0c84
--- /dev/null
+++ b/apps/web/src/components/admin/settings/roles/roles-settings.tsx
@@ -0,0 +1,59 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { PlusIcon } from '@heroicons/react/24/outline'
+import { Button } from '@/components/ui/button'
+import { RoleList } from './role-list'
+import { RoleDetailPanel } from './role-detail-panel'
+import { RoleCreateDialog } from './role-create-dialog'
+import type { RoleListItem } from '@/lib/server/domains/authz/role.service'
+import type { RoleId } from '@quackback/ids'
+
+interface Props {
+ roles: RoleListItem[]
+}
+
+export function RolesSettings({ roles }: Props) {
+ const [selectedId, setSelectedId] = useState(roles[0]?.id ?? null)
+ const [createOpen, setCreateOpen] = useState(false)
+
+ // Re-anchor selection if the currently-selected role disappears (delete).
+ useEffect(() => {
+ if (!selectedId) {
+ setSelectedId(roles[0]?.id ?? null)
+ return
+ }
+ const stillExists = roles.some((r) => r.id === selectedId)
+ if (!stillExists) setSelectedId(roles[0]?.id ?? null)
+ }, [roles, selectedId])
+
+ const selected = roles.find((r) => r.id === selectedId) ?? null
+
+ return (
+
+
+
setCreateOpen(true)}>
+
+ Create role
+
+
+
+
+
+ {selected ? (
+
+ ) : (
+
+ No role selected.
+
+ )}
+
+
+
setSelectedId(id)}
+ />
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/routing/__tests__/routing-actions-builder.test.tsx b/apps/web/src/components/admin/settings/routing/__tests__/routing-actions-builder.test.tsx
new file mode 100644
index 000000000..1898fd6ce
--- /dev/null
+++ b/apps/web/src/components/admin/settings/routing/__tests__/routing-actions-builder.test.tsx
@@ -0,0 +1,209 @@
+// @vitest-environment happy-dom
+import type { ChangeEvent, ReactNode } from 'react'
+import { describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { type BuilderAction, RoutingActionsBuilder } from '../routing-actions-builder'
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ onClick,
+ type = 'button',
+ 'aria-label': ariaLabel,
+ }: {
+ children: ReactNode
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ size?: string
+ className?: string
+ 'aria-label'?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/select', () => ({
+ Select: ({
+ children,
+ value,
+ onValueChange,
+ }: {
+ children: ReactNode
+ value: string
+ onValueChange: (value: string) => void
+ }) => {
+ const ariaLabel = ['low', 'normal', 'high', 'urgent'].includes(value)
+ ? 'Priority'
+ : ['team', 'org', 'shared', 'private'].includes(value)
+ ? 'Visibility'
+ : 'Action type'
+ return (
+ ) =>
+ onValueChange(event.currentTarget.value)
+ }
+ >
+ {children}
+
+ )
+ },
+ SelectContent: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectItem: ({ children, value }: { children: ReactNode; value: string }) => (
+ {children}
+ ),
+ SelectTrigger: () => null,
+ SelectValue: () => null,
+}))
+
+vi.mock('@/components/admin/shared/inbox-picker', () => ({
+ InboxPicker: ({
+ value,
+ onValueChange,
+ }: {
+ value: string | null
+ onValueChange: (value: string | null) => void
+ allowClear?: boolean
+ placeholder?: string
+ }) => (
+ ) =>
+ onValueChange(event.currentTarget.value || null)
+ }
+ >
+ No inbox
+ Support inbox
+
+ ),
+}))
+
+vi.mock('@/components/admin/shared/team-picker', () => ({
+ TeamPicker: ({
+ value,
+ onValueChange,
+ }: {
+ value: string | null
+ onValueChange: (value: string | null) => void
+ allowClear?: boolean
+ placeholder?: string
+ }) => (
+ ) =>
+ onValueChange(event.currentTarget.value || null)
+ }
+ >
+ No team
+ Support team
+
+ ),
+}))
+
+vi.mock('@/components/admin/shared/principal-picker', () => ({
+ PrincipalPicker: ({
+ value,
+ onValueChange,
+ }: {
+ value: string | null
+ onValueChange: (value: string | null) => void
+ placeholder?: string
+ }) => (
+ ) =>
+ onValueChange(event.currentTarget.value || null)
+ }
+ >
+ No principal
+ Ada
+
+ ),
+}))
+
+describe('RoutingActionsBuilder', () => {
+ it('adds actions, removes actions, and clears value when the action type changes', () => {
+ const onChange = vi.fn()
+
+ render(
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: /Add action/ }))
+ expect(onChange).toHaveBeenCalledWith([
+ { type: 'assignToInbox', value: 'inbox_1' },
+ { type: 'assignToInbox', value: '' },
+ ])
+
+ fireEvent.change(screen.getByLabelText('Action type'), { target: { value: 'assignToTeam' } })
+ expect(onChange).toHaveBeenCalledWith([{ type: 'assignToTeam', value: '' }])
+
+ fireEvent.click(screen.getByRole('button', { name: 'Remove action' }))
+ expect(onChange).toHaveBeenCalledWith([])
+ })
+
+ it('renders every value input branch and updates action values', () => {
+ const onChange = vi.fn()
+ const actions: BuilderAction[] = [
+ { type: 'assignToInbox', value: '' },
+ { type: 'assignToTeam', value: '' },
+ { type: 'assignToPrincipal', value: '' },
+ { type: 'addParticipant', value: '' },
+ { type: 'setPriority', value: 'normal' },
+ { type: 'setVisibility', value: 'team' },
+ ]
+
+ render( )
+
+ fireEvent.change(screen.getByLabelText('Inbox'), { target: { value: 'inbox_1' } })
+ expect(onChange).toHaveBeenCalledWith([
+ { type: 'assignToInbox', value: 'inbox_1' },
+ ...actions.slice(1),
+ ])
+
+ fireEvent.change(screen.getByLabelText('Team'), { target: { value: 'team_1' } })
+ expect(onChange).toHaveBeenCalledWith([
+ actions[0],
+ { type: 'assignToTeam', value: 'team_1' },
+ ...actions.slice(2),
+ ])
+
+ const principalInputs = screen.getAllByLabelText('Principal')
+ fireEvent.change(principalInputs[0], { target: { value: 'principal_1' } })
+ expect(onChange).toHaveBeenCalledWith([
+ ...actions.slice(0, 2),
+ { type: 'assignToPrincipal', value: 'principal_1' },
+ ...actions.slice(3),
+ ])
+
+ fireEvent.change(principalInputs[1], { target: { value: 'principal_1' } })
+ expect(onChange).toHaveBeenCalledWith([
+ ...actions.slice(0, 3),
+ { type: 'addParticipant', value: 'principal_1' },
+ ...actions.slice(4),
+ ])
+
+ fireEvent.change(screen.getByLabelText('Priority'), { target: { value: 'urgent' } })
+ expect(onChange).toHaveBeenCalledWith([
+ ...actions.slice(0, 4),
+ { type: 'setPriority', value: 'urgent' },
+ actions[5],
+ ])
+
+ fireEvent.change(screen.getByLabelText('Visibility'), { target: { value: 'private' } })
+ expect(onChange).toHaveBeenCalledWith([
+ ...actions.slice(0, 5),
+ { type: 'setVisibility', value: 'private' },
+ ])
+ })
+})
diff --git a/apps/web/src/components/admin/settings/routing/__tests__/routing-conditions-builder.test.tsx b/apps/web/src/components/admin/settings/routing/__tests__/routing-conditions-builder.test.tsx
new file mode 100644
index 000000000..965916865
--- /dev/null
+++ b/apps/web/src/components/admin/settings/routing/__tests__/routing-conditions-builder.test.tsx
@@ -0,0 +1,192 @@
+// @vitest-environment happy-dom
+import { useState, type ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { RoutingConditionsBuilder, type BuilderRuleSet } from '../routing-conditions-builder'
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ onClick,
+ type = 'button',
+ 'aria-label': ariaLabel,
+ }: {
+ children: ReactNode
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ 'aria-label'?: string
+ variant?: string
+ size?: string
+ className?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({
+ value,
+ onChange,
+ placeholder,
+ }: {
+ value?: string
+ onChange?: (event: React.ChangeEvent) => void
+ placeholder?: string
+ className?: string
+ }) => ,
+}))
+
+vi.mock('@/components/ui/select', () => ({
+ Select: ({
+ value,
+ onValueChange,
+ children,
+ }: {
+ value: string
+ onValueChange: (value: string) => void
+ children: ReactNode
+ }) => (
+ onValueChange(event.currentTarget.value)}>
+ {children}
+
+ ),
+ SelectContent: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectItem: ({ children, value }: { children: ReactNode; value: string }) => (
+ {children}
+ ),
+ SelectTrigger: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectValue: () => null,
+}))
+
+vi.mock('@heroicons/react/24/outline', () => ({
+ TrashIcon: () => ,
+ PlusIcon: () => ,
+}))
+
+const onChange = vi.fn()
+
+function Harness({ initial }: { initial: BuilderRuleSet }) {
+ const [value, setValue] = useState(initial)
+ return (
+ <>
+ {
+ onChange(next)
+ setValue(next)
+ }}
+ />
+ {JSON.stringify(value)}
+ >
+ )
+}
+
+function currentValue(): BuilderRuleSet {
+ return JSON.parse(screen.getByTestId('value').textContent ?? '{}') as BuilderRuleSet
+}
+
+beforeEach(() => {
+ onChange.mockClear()
+})
+
+describe('RoutingConditionsBuilder', () => {
+ it('adds, removes, and changes match mode for conditions', () => {
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Any' }))
+ expect(currentValue().match).toBe('any')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Add condition' }))
+ expect(currentValue().conditions).toEqual([{ field: 'subject', op: 'contains', value: '' }])
+
+ fireEvent.click(screen.getByRole('button', { name: 'Remove condition' }))
+ expect(currentValue().conditions).toEqual([])
+ })
+
+ it('edits scalar values and normalizes when switching to and from in-operator arrays', () => {
+ render(
+
+ )
+
+ fireEvent.change(screen.getByPlaceholderText(/value/), {
+ target: { value: 'invoice' },
+ })
+ expect(currentValue().conditions[0]).toEqual({
+ field: 'subject',
+ op: 'contains',
+ value: 'invoice',
+ })
+
+ fireEvent.change(screen.getAllByRole('combobox')[1], { target: { value: 'in' } })
+ expect(currentValue().conditions[0].value).toEqual(['invoice'])
+
+ fireEvent.change(screen.getByPlaceholderText(/value1/), {
+ target: { value: 'alpha, beta, , gamma ' },
+ })
+ expect(currentValue().conditions[0].value).toEqual(['alpha', 'beta', 'gamma'])
+
+ fireEvent.change(screen.getAllByRole('combobox')[1], { target: { value: 'matches' } })
+ expect(currentValue().conditions[0]).toEqual({
+ field: 'subject',
+ op: 'matches',
+ value: 'alpha',
+ })
+ expect(screen.getByPlaceholderText(/regex/)).toBeInTheDocument()
+ })
+
+ it('resets values when changing fields and supports enum scalar selects', () => {
+ render(
+
+ )
+
+ fireEvent.change(screen.getAllByRole('combobox')[0], { target: { value: 'priority' } })
+ expect(currentValue().conditions[0]).toEqual({
+ field: 'priority',
+ op: 'eq',
+ value: '',
+ })
+
+ fireEvent.change(screen.getAllByRole('combobox')[2], { target: { value: 'urgent' } })
+ expect(currentValue().conditions[0].value).toBe('urgent')
+ })
+
+ it('supports enum in-operator toggles for ticket channels and inbox channel kinds', () => {
+ const view = render(
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'email' }))
+ expect(currentValue().conditions[0].value).toEqual([])
+ fireEvent.click(screen.getByRole('button', { name: 'widget' }))
+ expect(currentValue().conditions[0].value).toEqual(['widget'])
+
+ view.unmount()
+ render(
+
+ )
+ fireEvent.click(screen.getByRole('button', { name: 'webhook' }))
+ expect(currentValue().conditions[0].value).toEqual(['webhook'])
+ })
+})
diff --git a/apps/web/src/components/admin/settings/routing/__tests__/routing-rule-editor-sheet.test.tsx b/apps/web/src/components/admin/settings/routing/__tests__/routing-rule-editor-sheet.test.tsx
new file mode 100644
index 000000000..4751c2784
--- /dev/null
+++ b/apps/web/src/components/admin/settings/routing/__tests__/routing-rule-editor-sheet.test.tsx
@@ -0,0 +1,386 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { RoutingRuleEditorSheet } from '../routing-rule-editor-sheet'
+
+type MutationOptions = {
+ mutationFn: () => Promise
+ onSuccess?: (result: unknown) => void
+ onError?: (error: Error) => void
+}
+
+type RuleSet = {
+ match: 'all' | 'any'
+ conditions: Array<{ field: string; op: string; value: string | string[] }>
+}
+
+type Action = { type: string; value: string }
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ createRoutingRuleFn: vi.fn(),
+ updateRoutingRuleFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useMutation: (options: MutationOptions) => ({
+ isPending: false,
+ mutate: async () => {
+ try {
+ const result = await options.mutationFn()
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@/components/ui/sheet', () => ({
+ Sheet: ({ open, children }: { open: boolean; children: ReactNode }) =>
+ open ? {children}
: null,
+ SheetContent: ({ children }: { children: ReactNode; side?: string; className?: string }) => (
+
+ ),
+ SheetDescription: ({ children }: { children: ReactNode }) => {children}
,
+ SheetFooter: ({ children }: { children: ReactNode }) => ,
+ SheetHeader: ({ children }: { children: ReactNode }) => ,
+ SheetTitle: ({ children }: { children: ReactNode }) => {children} ,
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ size?: string
+ className?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({
+ id,
+ type = 'text',
+ value,
+ onChange,
+ placeholder,
+ }: {
+ id?: string
+ type?: string
+ value?: string | number
+ onChange?: (event: React.ChangeEvent) => void
+ placeholder?: string
+ min?: number
+ max?: number
+ }) => ,
+}))
+
+vi.mock('@/components/ui/textarea', () => ({
+ Textarea: ({
+ id,
+ value,
+ onChange,
+ placeholder,
+ }: {
+ id?: string
+ value?: string
+ onChange?: (event: React.ChangeEvent) => void
+ placeholder?: string
+ rows?: number
+ }) => ,
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string; className?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/switch', () => ({
+ Switch: ({
+ id,
+ checked,
+ onCheckedChange,
+ }: {
+ id?: string
+ checked: boolean
+ onCheckedChange: (checked: boolean) => void
+ }) => (
+ onCheckedChange(!checked)} />
+ ),
+}))
+
+vi.mock('@/components/ui/select', () => ({
+ Select: ({
+ value,
+ onValueChange,
+ children,
+ }: {
+ value: string
+ onValueChange: (value: string) => void
+ children: ReactNode
+ }) => (
+ onValueChange(event.currentTarget.value)}>
+ {children}
+
+ ),
+ SelectContent: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectItem: ({ children, value }: { children: ReactNode; value: string }) => (
+ {children}
+ ),
+ SelectTrigger: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectValue: () => null,
+}))
+
+vi.mock('@/components/admin/shared/inbox-picker', () => ({
+ InboxPicker: ({ onValueChange }: { onValueChange: (id: string | null) => void }) => (
+ onValueChange('inbox_scope')}>
+ Pick scoped inbox
+
+ ),
+}))
+
+vi.mock('../routing-conditions-builder', () => ({
+ RoutingConditionsBuilder: ({
+ value,
+ onChange,
+ }: {
+ value: RuleSet
+ onChange: (next: RuleSet) => void
+ }) => (
+
+
conditions:{value.conditions.length}
+
onChange({ match: 'all', conditions: [] })}>
+ Empty conditions
+
+
+ onChange({
+ match: 'all',
+ conditions: [{ field: 'subject', op: 'contains', value: '' }],
+ })
+ }
+ >
+ Missing condition value
+
+
+ onChange({
+ match: 'any',
+ conditions: [{ field: 'priority', op: 'eq', value: 'urgent' }],
+ })
+ }
+ >
+ Valid condition
+
+
+ ),
+}))
+
+vi.mock('../routing-actions-builder', () => ({
+ RoutingActionsBuilder: ({
+ value,
+ onChange,
+ }: {
+ value: Action[]
+ onChange: (next: Action[]) => void
+ }) => (
+
+
actions:{value.length}
+
onChange([])}>
+ Empty actions
+
+
onChange([{ type: 'assignToInbox', value: '' }])}>
+ Missing action value
+
+
onChange([{ type: 'assignToInbox', value: 'inbox_action' }])}
+ >
+ Valid action
+
+
+ ),
+}))
+
+vi.mock('@/lib/server/functions/routing', () => ({
+ createRoutingRuleFn: mocks.createRoutingRuleFn,
+ updateRoutingRuleFn: mocks.updateRoutingRuleFn,
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+function renderSheet(props: Partial> = {}) {
+ const onOpenChange = vi.fn()
+ const view = render( )
+ return { ...view, onOpenChange }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.createRoutingRuleFn.mockResolvedValue({ id: 'routing_rule_new' })
+ mocks.updateRoutingRuleFn.mockResolvedValue({ id: 'routing_rule_1' })
+})
+
+describe('RoutingRuleEditorSheet', () => {
+ it('does not render content while closed', () => {
+ render( )
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('validates and creates a scoped routing rule', async () => {
+ const { onOpenChange } = renderSheet()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Create rule' }))
+ expect(mocks.toastError).toHaveBeenCalledWith('Name is required')
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' VIP routing ' } })
+ fireEvent.change(screen.getByLabelText('Description'), {
+ target: { value: ' Move urgent customers ' },
+ })
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'inbox' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Create rule' }))
+ expect(mocks.toastError).toHaveBeenCalledWith('Pick an inbox or switch to workspace scope')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Pick scoped inbox' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Empty conditions' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Create rule' }))
+ expect(mocks.toastError).toHaveBeenCalledWith('At least one condition is required')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Missing condition value' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Create rule' }))
+ expect(mocks.toastError).toHaveBeenCalledWith('Condition on "subject" is missing a value')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Valid condition' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Empty actions' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Create rule' }))
+ expect(mocks.toastError).toHaveBeenCalledWith('At least one action is required')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Missing action value' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Create rule' }))
+ expect(mocks.toastError).toHaveBeenCalledWith('Action "assignToInbox" is missing a value')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Valid action' }))
+ fireEvent.change(screen.getByLabelText('Priority'), { target: { value: '' } })
+ fireEvent.click(screen.getByLabelText('Enabled'))
+ fireEvent.click(screen.getByRole('button', { name: 'Create rule' }))
+
+ await waitFor(() => {
+ expect(mocks.createRoutingRuleFn).toHaveBeenCalledWith({
+ data: {
+ name: 'VIP routing',
+ description: 'Move urgent customers',
+ priority: 0,
+ enabled: false,
+ conditions: {
+ match: 'any',
+ conditions: [{ field: 'priority', op: 'eq', value: 'urgent' }],
+ },
+ actions: [{ type: 'assignToInbox', value: 'inbox_action' }],
+ inboxIdScope: 'inbox_scope',
+ },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Routing rule created')
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['routing-rules'] })
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+
+ it('prefills and updates existing routing rules', async () => {
+ const { onOpenChange } = renderSheet({
+ rule: {
+ id: 'routing_rule_1',
+ name: 'Existing rule',
+ description: null,
+ inboxIdScope: null,
+ priority: 25,
+ enabled: true,
+ conditions: {
+ match: 'all',
+ conditions: [{ field: 'channel', op: 'eq', value: 'email' }],
+ },
+ actions: [{ type: 'assignToInbox', value: 'inbox_email' }],
+ } as never,
+ })
+
+ expect(screen.getByRole('heading', { name: 'Edit routing rule' })).toBeInTheDocument()
+ expect(screen.getByLabelText('Name')).toHaveValue('Existing rule')
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Updated rule' } })
+ fireEvent.change(screen.getByLabelText('Description'), { target: { value: ' ' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+
+ await waitFor(() => {
+ expect(mocks.updateRoutingRuleFn).toHaveBeenCalledWith({
+ data: {
+ ruleId: 'routing_rule_1',
+ name: 'Updated rule',
+ description: null,
+ priority: 25,
+ enabled: true,
+ conditions: {
+ match: 'all',
+ conditions: [{ field: 'channel', op: 'eq', value: 'email' }],
+ },
+ actions: [{ type: 'assignToInbox', value: 'inbox_email' }],
+ inboxIdScope: null,
+ },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Routing rule updated')
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+
+ it('reports update errors and cancels edits', async () => {
+ mocks.updateRoutingRuleFn.mockRejectedValueOnce(new Error('Update failed'))
+ const { onOpenChange } = renderSheet({
+ rule: {
+ id: 'routing_rule_1',
+ name: 'Existing rule',
+ description: 'Existing description',
+ inboxIdScope: 'inbox_existing',
+ priority: 10,
+ enabled: false,
+ conditions: null,
+ actions: null,
+ } as never,
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Valid condition' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Valid action' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Update failed')
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+})
diff --git a/apps/web/src/components/admin/settings/routing/__tests__/routing-rule-list.test.tsx b/apps/web/src/components/admin/settings/routing/__tests__/routing-rule-list.test.tsx
new file mode 100644
index 000000000..e5ca1d97f
--- /dev/null
+++ b/apps/web/src/components/admin/settings/routing/__tests__/routing-rule-list.test.tsx
@@ -0,0 +1,401 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { RoutingRuleList } from '../routing-rule-list'
+
+type MutationOptions = {
+ mutationFn: (vars: TVars) => Promise
+ onSuccess?: (result: TResult) => void
+ onError?: (error: Error) => void
+}
+
+type Rule = {
+ id: string
+ name: string
+ inboxIdScope: string | null
+ enabled: boolean
+ priority: number
+ conditions: { conditions?: unknown[] } | null
+ actions: unknown[] | null
+ matchCount: number
+ lastMatchedAt: string | null
+}
+
+type Inbox = {
+ id: string
+ slug: string
+}
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ reorderRoutingRulesFn: vi.fn(),
+ updateRoutingRuleFn: vi.fn(),
+ deleteRoutingRuleFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+ permissionAllowed: true,
+ rules: [] as Rule[],
+ inboxes: [] as Inbox[],
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useSuspenseQuery: (options: { queryKey: readonly unknown[] }) => ({
+ data: options.queryKey[0] === 'routing-rules' ? mocks.rules : mocks.inboxes,
+ }),
+ useMutation: (options: MutationOptions) => ({
+ isPending: false,
+ mutate: async (vars: TVars) => {
+ try {
+ const result = await options.mutationFn(vars)
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@dnd-kit/core', () => ({
+ DndContext: ({
+ children,
+ onDragEnd,
+ }: {
+ children: ReactNode
+ sensors?: unknown
+ collisionDetection?: unknown
+ onDragEnd: (event: { active: { id: string }; over: { id: string } | null }) => void
+ }) => (
+
+ {children}
+ onDragEnd({ active: { id: 'rule_two' }, over: { id: 'rule_one' } })}
+ >
+ Drag second before first
+
+ onDragEnd({ active: { id: 'rule_one' }, over: { id: 'rule_one' } })}
+ >
+ Drag same item
+
+ onDragEnd({ active: { id: 'missing_rule' }, over: { id: 'rule_one' } })}
+ >
+ Drag missing item
+
+ onDragEnd({ active: { id: 'rule_one' }, over: null })}>
+ Drop without target
+
+
+ ),
+ KeyboardSensor: class KeyboardSensor {},
+ PointerSensor: class PointerSensor {},
+ closestCenter: vi.fn(),
+ useSensor: (sensor: unknown, options?: unknown) => ({ sensor, options }),
+ useSensors: (...sensors: unknown[]) => sensors,
+}))
+
+vi.mock('@dnd-kit/sortable', () => ({
+ SortableContext: ({ children }: { children: ReactNode; items: string[]; strategy: unknown }) => (
+ {children}
+ ),
+ arrayMove: (items: T[], oldIndex: number, newIndex: number): T[] => {
+ const next = items.slice()
+ const [item] = next.splice(oldIndex, 1)
+ next.splice(newIndex, 0, item)
+ return next
+ },
+ sortableKeyboardCoordinates: vi.fn(),
+ useSortable: ({ id }: { id: string }) => ({
+ attributes: { 'data-sortable-id': id },
+ listeners: {},
+ setNodeRef: vi.fn(),
+ transform: null,
+ transition: undefined,
+ isDragging: id === 'dragging_rule',
+ }),
+ verticalListSortingStrategy: 'vertical',
+}))
+
+vi.mock('@dnd-kit/utilities', () => ({
+ CSS: {
+ Transform: {
+ toString: (transform: unknown) => (transform ? 'translate3d(0, 0, 0)' : undefined),
+ },
+ },
+}))
+
+vi.mock('@/components/admin/shared/permission-gate', () => ({
+ PermissionGate: ({
+ children,
+ fallback = null,
+ }: {
+ children: ReactNode
+ fallback?: ReactNode
+ permission: string
+ }) => (mocks.permissionAllowed ? <>{children}> : <>{fallback}>),
+}))
+
+vi.mock('@/components/admin/settings/routing/routing-rule-editor-sheet', () => ({
+ RoutingRuleEditorSheet: ({
+ open,
+ onOpenChange,
+ rule,
+ }: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ rule: Rule
+ }) =>
+ open ? (
+
+ Editing {rule.name}
+ onOpenChange(false)}>
+ Close editor
+
+
+ ) : null,
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ 'aria-label': ariaLabel,
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ size?: string
+ className?: string
+ 'aria-label'?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/switch', () => ({
+ Switch: ({
+ checked,
+ onCheckedChange,
+ }: {
+ checked: boolean
+ onCheckedChange: (checked: boolean) => void
+ }) => (
+ onCheckedChange(!checked)}
+ />
+ ),
+}))
+
+vi.mock('@/components/ui/badge', () => ({
+ Badge: ({ children }: { children: ReactNode; variant?: string; className?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/alert-dialog', () => ({
+ AlertDialog: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogAction: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
+
+ {children}
+
+ ),
+ AlertDialogCancel: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ AlertDialogContent: ({ children }: { children: ReactNode }) => ,
+ AlertDialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogFooter: ({ children }: { children: ReactNode }) => ,
+ AlertDialogHeader: ({ children }: { children: ReactNode }) => ,
+ AlertDialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+ AlertDialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}>,
+}))
+
+vi.mock('@heroicons/react/24/outline', () => ({
+ Bars3Icon: () => bars ,
+ PencilSquareIcon: () => pencil ,
+ TrashIcon: () => trash ,
+}))
+
+vi.mock('@/lib/server/functions/routing', () => ({
+ reorderRoutingRulesFn: mocks.reorderRoutingRulesFn,
+ updateRoutingRuleFn: mocks.updateRoutingRuleFn,
+ deleteRoutingRuleFn: mocks.deleteRoutingRuleFn,
+}))
+
+vi.mock('@/lib/client/queries/routing-rules', () => ({
+ routingRuleQueries: {
+ list: ({ inboxIdScope }: { inboxIdScope?: string }) => ({
+ queryKey: ['routing-rules', { inboxIdScope }],
+ }),
+ },
+}))
+
+vi.mock('@/lib/client/queries/inboxes', () => ({
+ inboxQueries: {
+ list: ({ includeArchived }: { includeArchived?: boolean } = {}) => ({
+ queryKey: ['inboxes', 'list', { includeArchived }],
+ }),
+ },
+}))
+
+vi.mock('@/lib/server/domains/authz', () => ({
+ PERMISSIONS: {
+ ROUTING_RULE_MANAGE: 'routing_rule.manage',
+ },
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.permissionAllowed = true
+ mocks.inboxes = [
+ { id: 'inbox_sales', slug: 'sales' },
+ { id: 'inbox_support', slug: 'support' },
+ ]
+ mocks.rules = [
+ {
+ id: 'rule_one',
+ name: 'Workspace urgent tickets',
+ inboxIdScope: null,
+ enabled: true,
+ priority: 10,
+ conditions: { conditions: [{ field: 'priority' }] },
+ actions: [{ type: 'assign_team' }],
+ matchCount: 1,
+ lastMatchedAt: '2026-06-18T10:00:00.000Z',
+ },
+ {
+ id: 'rule_two',
+ name: 'Sales billing route',
+ inboxIdScope: 'inbox_sales',
+ enabled: false,
+ priority: 20,
+ conditions: { conditions: [{ field: 'subject' }, { field: 'body' }] },
+ actions: [],
+ matchCount: 2,
+ lastMatchedAt: null,
+ },
+ ]
+ mocks.reorderRoutingRulesFn.mockResolvedValue(undefined)
+ mocks.updateRoutingRuleFn.mockResolvedValue({ id: 'rule_one' })
+ mocks.deleteRoutingRuleFn.mockResolvedValue(undefined)
+})
+
+describe('RoutingRuleList', () => {
+ it('renders an empty state when there are no rules', () => {
+ mocks.rules = []
+
+ render( )
+
+ expect(screen.getByText('No routing rules yet.')).toBeInTheDocument()
+ })
+
+ it('renders scoped rule summaries and exposes edit, toggle and delete actions', async () => {
+ render( )
+
+ expect(screen.getByText('Workspace urgent tickets')).toBeInTheDocument()
+ expect(screen.getByText('Sales billing route')).toBeInTheDocument()
+ expect(screen.getByText('Workspace')).toBeInTheDocument()
+ expect(screen.getByText('sales')).toBeInTheDocument()
+ expect(screen.getByText(/1 condition/)).toHaveTextContent('1 condition')
+ expect(screen.getByText(/2 conditions/)).toHaveTextContent('2 conditions')
+
+ fireEvent.click(screen.getByLabelText('enabled-on'))
+ await waitFor(() => {
+ expect(mocks.updateRoutingRuleFn).toHaveBeenCalledWith({
+ data: {
+ ruleId: 'rule_one',
+ enabled: false,
+ },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['routing-rules'] })
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Edit rule' })[0])
+ expect(screen.getByRole('dialog')).toHaveTextContent('Editing Workspace urgent tickets')
+ fireEvent.click(screen.getByRole('button', { name: 'Close editor' }))
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[0])
+ await waitFor(() => {
+ expect(mocks.deleteRoutingRuleFn).toHaveBeenCalledWith({
+ data: { ruleId: 'rule_one' },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Rule deleted')
+ })
+
+ it('reorders rules optimistically and ignores no-op drag events', async () => {
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Drag same item' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Drag missing item' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Drop without target' }))
+ expect(mocks.reorderRoutingRulesFn).not.toHaveBeenCalled()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Drag second before first' }))
+ await waitFor(() => {
+ expect(mocks.reorderRoutingRulesFn).toHaveBeenCalledWith({
+ data: { orderedIds: ['rule_two', 'rule_one'] },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['routing-rules'] })
+ })
+
+ it('restores server order and reports errors when reorder, toggle or delete fail', async () => {
+ mocks.reorderRoutingRulesFn.mockRejectedValueOnce(new Error('Cannot reorder'))
+ mocks.updateRoutingRuleFn.mockRejectedValueOnce(new Error('Cannot toggle'))
+ mocks.deleteRoutingRuleFn.mockRejectedValueOnce(new Error('Cannot delete'))
+
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Drag second before first' }))
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Cannot reorder')
+ })
+
+ fireEvent.click(screen.getByLabelText('enabled-on'))
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Cannot toggle')
+ })
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[0])
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Cannot delete')
+ })
+ })
+
+ it('renders status badges instead of controls when routing management is denied', () => {
+ mocks.permissionAllowed = false
+
+ render( )
+
+ expect(screen.queryByRole('button', { name: 'Drag to reorder' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Edit rule' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Delete rule' })).not.toBeInTheDocument()
+ expect(screen.getByText('Enabled')).toBeInTheDocument()
+ expect(screen.getByText('Disabled')).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/admin/settings/routing/routing-actions-builder.tsx b/apps/web/src/components/admin/settings/routing/routing-actions-builder.tsx
new file mode 100644
index 000000000..52e4f2200
--- /dev/null
+++ b/apps/web/src/components/admin/settings/routing/routing-actions-builder.tsx
@@ -0,0 +1,176 @@
+/**
+ * Actions builder for routing rules. Each action is `{type, value: string}`
+ * where the value is an entity id (inbox/team/principal) or an enum value
+ * (priority/visibility).
+ */
+import type { InboxId, TeamId, PrincipalId } from '@quackback/ids'
+import { Button } from '@/components/ui/button'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { TrashIcon, PlusIcon } from '@heroicons/react/24/outline'
+import { InboxPicker } from '@/components/admin/shared/inbox-picker'
+import { TeamPicker } from '@/components/admin/shared/team-picker'
+import { PrincipalPicker } from '@/components/admin/shared/principal-picker'
+
+const ACTION_TYPES = [
+ 'assignToInbox',
+ 'assignToTeam',
+ 'assignToPrincipal',
+ 'setPriority',
+ 'setVisibility',
+ 'addParticipant',
+] as const
+type ActionType = (typeof ACTION_TYPES)[number]
+
+const PRIORITIES = ['low', 'normal', 'high', 'urgent'] as const
+const VISIBILITY = ['team', 'org', 'shared', 'private'] as const
+
+const TYPE_LABELS: Record = {
+ assignToInbox: 'Assign to inbox',
+ assignToTeam: 'Assign to team',
+ assignToPrincipal: 'Assign to principal',
+ setPriority: 'Set priority',
+ setVisibility: 'Set visibility',
+ addParticipant: 'Add participant',
+}
+
+export interface BuilderAction {
+ type: ActionType
+ value: string
+}
+
+interface Props {
+ value: BuilderAction[]
+ onChange: (next: BuilderAction[]) => void
+}
+
+export function RoutingActionsBuilder({ value, onChange }: Props) {
+ const update = (idx: number, patch: Partial) => {
+ onChange(
+ value.map((a, i) => {
+ if (i !== idx) return a
+ const merged = { ...a, ...patch }
+ if (patch.type !== undefined && patch.type !== a.type) {
+ merged.value = ''
+ }
+ return merged
+ })
+ )
+ }
+ const add = () => onChange([...value, { type: 'assignToInbox', value: '' }])
+ const remove = (idx: number) => onChange(value.filter((_, i) => i !== idx))
+
+ return (
+
+ {value.map((a, idx) => (
+
+
update(idx, { type: v as ActionType })}>
+
+
+
+
+ {ACTION_TYPES.map((t) => (
+
+ {TYPE_LABELS[t]}
+
+ ))}
+
+
+
update(idx, { value: v })}
+ />
+ remove(idx)}
+ aria-label="Remove action"
+ >
+
+
+
+ ))}
+
+
+ Add action
+
+
+ )
+}
+
+function ActionValueInput({
+ type,
+ value,
+ onChange,
+}: {
+ type: ActionType
+ value: string
+ onChange: (v: string) => void
+}) {
+ switch (type) {
+ case 'assignToInbox':
+ return (
+ onChange((v as string) ?? '')}
+ allowClear
+ placeholder="Pick inbox…"
+ />
+ )
+ case 'assignToTeam':
+ return (
+ onChange((v as string) ?? '')}
+ allowClear
+ placeholder="Pick team…"
+ />
+ )
+ case 'assignToPrincipal':
+ case 'addParticipant':
+ return (
+ onChange((v as string) ?? '')}
+ placeholder="Pick principal…"
+ />
+ )
+ case 'setPriority':
+ return (
+
+
+
+
+
+ {PRIORITIES.map((p) => (
+
+ {p}
+
+ ))}
+
+
+ )
+ case 'setVisibility':
+ return (
+
+
+
+
+
+ {VISIBILITY.map((v) => (
+
+ {v}
+
+ ))}
+
+
+ )
+ }
+}
diff --git a/apps/web/src/components/admin/settings/routing/routing-conditions-builder.tsx b/apps/web/src/components/admin/settings/routing/routing-conditions-builder.tsx
new file mode 100644
index 000000000..53506fd8e
--- /dev/null
+++ b/apps/web/src/components/admin/settings/routing/routing-conditions-builder.tsx
@@ -0,0 +1,274 @@
+/**
+ * Conditions builder. Edits a `{match: 'all'|'any', conditions: RoutingCondition[]}`
+ * structure used by routing rules. Per-field value renderer adapts to the
+ * selected field + op (string vs string[] when op === 'in').
+ */
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { TrashIcon, PlusIcon } from '@heroicons/react/24/outline'
+import { cn } from '@/lib/shared/utils'
+
+const FIELDS = [
+ 'subject',
+ 'descriptionText',
+ 'channel',
+ 'priority',
+ 'organizationDomain',
+ 'requesterEmail',
+ 'inboxChannelKind',
+] as const
+type Field = (typeof FIELDS)[number]
+
+const OPS = ['eq', 'contains', 'matches', 'in'] as const
+type Op = (typeof OPS)[number]
+
+const PRIORITIES = ['low', 'normal', 'high', 'urgent'] as const
+const TICKET_CHANNELS = ['portal', 'email', 'api', 'widget'] as const
+const INBOX_CHANNEL_KINDS = ['portal', 'email', 'api', 'widget', 'webhook'] as const
+
+const FIELD_LABELS: Record = {
+ subject: 'Subject',
+ descriptionText: 'Description text',
+ channel: 'Ticket channel',
+ priority: 'Priority',
+ organizationDomain: 'Organization domain',
+ requesterEmail: 'Requester email',
+ inboxChannelKind: 'Inbox channel kind',
+}
+
+export interface BuilderCondition {
+ field: Field
+ op: Op
+ value: string | string[]
+}
+
+export interface BuilderRuleSet {
+ match: 'all' | 'any'
+ conditions: BuilderCondition[]
+}
+
+interface Props {
+ value: BuilderRuleSet
+ onChange: (next: BuilderRuleSet) => void
+}
+
+export function RoutingConditionsBuilder({ value, onChange }: Props) {
+ const updateCondition = (idx: number, patch: Partial) => {
+ const next = value.conditions.map((c, i) => {
+ if (i !== idx) return c
+ const merged = { ...c, ...patch }
+ // Normalize value shape when op flips between array (`in`) and scalar.
+ if (patch.op !== undefined && patch.op !== c.op) {
+ if (patch.op === 'in' && !Array.isArray(merged.value)) {
+ merged.value = merged.value ? [merged.value] : []
+ } else if (patch.op !== 'in' && Array.isArray(merged.value)) {
+ merged.value = merged.value[0] ?? ''
+ }
+ }
+ // When changing field, reset value to a sensible default for that field.
+ if (patch.field !== undefined && patch.field !== c.field) {
+ merged.value = merged.op === 'in' ? [] : ''
+ }
+ return merged
+ })
+ onChange({ ...value, conditions: next })
+ }
+
+ const addCondition = () => {
+ onChange({
+ ...value,
+ conditions: [...value.conditions, { field: 'subject', op: 'contains', value: '' }],
+ })
+ }
+
+ const removeCondition = (idx: number) => {
+ onChange({
+ ...value,
+ conditions: value.conditions.filter((_, i) => i !== idx),
+ })
+ }
+
+ return (
+
+
+
Match
+
+ {(['all', 'any'] as const).map((m) => (
+ onChange({ ...value, match: m })}
+ className={cn(
+ 'px-2.5 py-1 text-xs',
+ value.match === m
+ ? 'bg-primary text-primary-foreground'
+ : 'bg-background text-muted-foreground hover:bg-muted'
+ )}
+ >
+ {m === 'all' ? 'All' : 'Any'}
+
+ ))}
+
+
of the following
+
+
+
+ {value.conditions.map((c, idx) => (
+
+ updateCondition(idx, { field: v as Field })}
+ >
+
+
+
+
+ {FIELDS.map((f) => (
+
+ {FIELD_LABELS[f]}
+
+ ))}
+
+
+ updateCondition(idx, { op: v as Op })}>
+
+
+
+
+ {OPS.map((o) => (
+
+ {o}
+
+ ))}
+
+
+ updateCondition(idx, { value: v })}
+ />
+ removeCondition(idx)}
+ aria-label="Remove condition"
+ >
+
+
+
+ ))}
+
+
+
+
+ Add condition
+
+
+ )
+}
+
+function ConditionValueInput({
+ field,
+ op,
+ value,
+ onChange,
+}: {
+ field: Field
+ op: Op
+ value: string | string[]
+ onChange: (v: string | string[]) => void
+}) {
+ const enumOptions =
+ field === 'channel'
+ ? (TICKET_CHANNELS as readonly string[])
+ : field === 'priority'
+ ? (PRIORITIES as readonly string[])
+ : field === 'inboxChannelKind'
+ ? (INBOX_CHANNEL_KINDS as readonly string[])
+ : null
+
+ // For `in` op on enum fields, render comma-separated select chips.
+ if (op === 'in') {
+ const arrVal = Array.isArray(value) ? value : value ? [value] : []
+ if (enumOptions) {
+ // Multi-toggle
+ return (
+
+ {enumOptions.map((opt) => {
+ const checked = arrVal.includes(opt)
+ return (
+ {
+ const next = checked ? arrVal.filter((v) => v !== opt) : [...arrVal, opt]
+ onChange(next)
+ }}
+ className={
+ 'text-[11px] rounded border px-2 py-0.5 ' +
+ (checked
+ ? 'bg-primary text-primary-foreground border-primary'
+ : 'bg-background border-border/60 text-muted-foreground hover:bg-muted')
+ }
+ >
+ {opt}
+
+ )
+ })}
+
+ )
+ }
+ // Free-text comma list for non-enum fields.
+ return (
+
+ onChange(
+ e.target.value
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean)
+ )
+ }
+ />
+ )
+ }
+
+ // Scalar ops on enum fields → Select.
+ if (enumOptions) {
+ return (
+ onChange(v)}>
+
+
+
+
+ {enumOptions.map((opt) => (
+
+ {opt}
+
+ ))}
+
+
+ )
+ }
+
+ return (
+ onChange(e.target.value)}
+ />
+ )
+}
diff --git a/apps/web/src/components/admin/settings/routing/routing-rule-editor-sheet.tsx b/apps/web/src/components/admin/settings/routing/routing-rule-editor-sheet.tsx
new file mode 100644
index 000000000..2925399e3
--- /dev/null
+++ b/apps/web/src/components/admin/settings/routing/routing-rule-editor-sheet.tsx
@@ -0,0 +1,279 @@
+/**
+ * Routing rule editor — Sheet drawer used for both create and edit. When `rule`
+ * is provided, prefills + calls update; otherwise calls create. Conditions and
+ * actions are jsonb on the server; the builders below shape them.
+ */
+import { useEffect, useState } from 'react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import type { InboxId, RoutingRuleId } from '@quackback/ids'
+import type { RoutingRule } from '@/lib/shared/db-types'
+import { createRoutingRuleFn, updateRoutingRuleFn } from '@/lib/server/functions/routing'
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from '@/components/ui/sheet'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Switch } from '@/components/ui/switch'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { InboxPicker } from '@/components/admin/shared/inbox-picker'
+import {
+ RoutingConditionsBuilder,
+ type BuilderRuleSet,
+ type BuilderCondition,
+} from './routing-conditions-builder'
+import { RoutingActionsBuilder, type BuilderAction } from './routing-actions-builder'
+
+interface Props {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ rule?: RoutingRule
+}
+
+const DEFAULT_RULE_SET: BuilderRuleSet = {
+ match: 'all',
+ conditions: [{ field: 'subject', op: 'contains', value: '' }],
+}
+const DEFAULT_ACTIONS: BuilderAction[] = [{ type: 'assignToInbox', value: '' }]
+
+export function RoutingRuleEditorSheet({ open, onOpenChange, rule }: Props) {
+ const qc = useQueryClient()
+ const isEdit = Boolean(rule)
+
+ const [name, setName] = useState('')
+ const [description, setDescription] = useState('')
+ const [scopeMode, setScopeMode] = useState<'workspace' | 'inbox'>('workspace')
+ const [inboxScope, setInboxScope] = useState(null)
+ const [priority, setPriority] = useState(100)
+ const [enabled, setEnabled] = useState(true)
+ const [ruleSet, setRuleSet] = useState(DEFAULT_RULE_SET)
+ const [actions, setActions] = useState(DEFAULT_ACTIONS)
+
+ // Prefill / reset when sheet opens or rule changes.
+ useEffect(() => {
+ if (!open) return
+ if (rule) {
+ setName(rule.name)
+ setDescription(rule.description ?? '')
+ setScopeMode(rule.inboxIdScope ? 'inbox' : 'workspace')
+ setInboxScope((rule.inboxIdScope as InboxId | null) ?? null)
+ setPriority(rule.priority)
+ setEnabled(rule.enabled)
+ const rsRaw = rule.conditions as {
+ match?: 'all' | 'any'
+ conditions?: BuilderCondition[]
+ } | null
+ setRuleSet({
+ match: rsRaw?.match ?? 'all',
+ conditions:
+ rsRaw?.conditions && rsRaw.conditions.length > 0
+ ? rsRaw.conditions
+ : DEFAULT_RULE_SET.conditions,
+ })
+ const aRaw = (rule.actions as BuilderAction[] | null) ?? []
+ setActions(aRaw.length > 0 ? aRaw : DEFAULT_ACTIONS)
+ } else {
+ setName('')
+ setDescription('')
+ setScopeMode('workspace')
+ setInboxScope(null)
+ setPriority(100)
+ setEnabled(true)
+ setRuleSet(DEFAULT_RULE_SET)
+ setActions(DEFAULT_ACTIONS)
+ }
+ }, [open, rule])
+
+ const validate = (): string | null => {
+ if (!name.trim()) return 'Name is required'
+ if (scopeMode === 'inbox' && !inboxScope) return 'Pick an inbox or switch to workspace scope'
+ if (ruleSet.conditions.length === 0) return 'At least one condition is required'
+ for (const c of ruleSet.conditions) {
+ if (Array.isArray(c.value) ? c.value.length === 0 : !c.value) {
+ return `Condition on "${c.field}" is missing a value`
+ }
+ }
+ if (actions.length === 0) return 'At least one action is required'
+ for (const a of actions) {
+ if (!a.value) return `Action "${a.type}" is missing a value`
+ }
+ return null
+ }
+
+ const createMutation = useMutation({
+ mutationFn: () =>
+ createRoutingRuleFn({
+ data: {
+ name: name.trim(),
+ description: description.trim() || null,
+ priority,
+ enabled,
+ conditions: ruleSet,
+ actions,
+ inboxIdScope: scopeMode === 'inbox' ? inboxScope : null,
+ },
+ }),
+ onSuccess: () => {
+ toast.success('Routing rule created')
+ qc.invalidateQueries({ queryKey: ['routing-rules'] })
+ onOpenChange(false)
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const updateMutation = useMutation({
+ mutationFn: () =>
+ updateRoutingRuleFn({
+ data: {
+ ruleId: rule!.id as RoutingRuleId,
+ name: name.trim(),
+ description: description.trim() || null,
+ priority,
+ enabled,
+ conditions: ruleSet,
+ actions,
+ inboxIdScope: scopeMode === 'inbox' ? inboxScope : null,
+ },
+ }),
+ onSuccess: () => {
+ toast.success('Routing rule updated')
+ qc.invalidateQueries({ queryKey: ['routing-rules'] })
+ onOpenChange(false)
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const handleSave = () => {
+ const err = validate()
+ if (err) {
+ toast.error(err)
+ return
+ }
+ if (isEdit) updateMutation.mutate()
+ else createMutation.mutate()
+ }
+
+ const isPending = createMutation.isPending || updateMutation.isPending
+
+ return (
+
+
+
+ {isEdit ? 'Edit routing rule' : 'New routing rule'}
+
+ Conditions are evaluated against incoming tickets; matching rules apply their actions in
+ order.
+
+
+
+
+
+
+ Basics
+
+
+ Name
+ setName(e.target.value)}
+ placeholder="e.g. Urgent VIP tickets → tier 2"
+ />
+
+
+ Description
+ setDescription(e.target.value)}
+ rows={2}
+ placeholder="Optional"
+ />
+
+
+
+ Scope
+ setScopeMode(v as 'workspace' | 'inbox')}
+ >
+
+
+
+
+ Workspace-wide
+ Specific inbox
+
+
+
+
+ Priority
+ setPriority(Number(e.target.value) || 0)}
+ />
+
+
+ {scopeMode === 'inbox' && (
+
+ Inbox
+
+
+ )}
+
+
+
+ Enabled
+
+
+
+
+
+
+
+
+
+
+ onOpenChange(false)} disabled={isPending}>
+ Cancel
+
+
+ {isEdit ? 'Save changes' : 'Create rule'}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/routing/routing-rule-list.tsx b/apps/web/src/components/admin/settings/routing/routing-rule-list.tsx
new file mode 100644
index 000000000..d018a2fc4
--- /dev/null
+++ b/apps/web/src/components/admin/settings/routing/routing-rule-list.tsx
@@ -0,0 +1,259 @@
+/**
+ * Routing rule list with drag-reorder. Lower priority = runs first.
+ * Shows priority badge, name, scope, enabled toggle, match stats, edit/delete.
+ */
+import { useState, useEffect } from 'react'
+import { useSuspenseQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from '@dnd-kit/core'
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable'
+import { CSS } from '@dnd-kit/utilities'
+import { Bars3Icon, PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline'
+import type { InboxId, RoutingRuleId } from '@quackback/ids'
+import type { RoutingRule } from '@/lib/shared/db-types'
+import {
+ reorderRoutingRulesFn,
+ updateRoutingRuleFn,
+ deleteRoutingRuleFn,
+} from '@/lib/server/functions/routing'
+import { routingRuleQueries } from '@/lib/client/queries/routing-rules'
+import { inboxQueries } from '@/lib/client/queries/inboxes'
+import { Button } from '@/components/ui/button'
+import { Switch } from '@/components/ui/switch'
+import { Badge } from '@/components/ui/badge'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog'
+import { PermissionGate } from '@/components/admin/shared/permission-gate'
+import { PERMISSIONS } from '@/lib/server/domains/authz'
+import { RoutingRuleEditorSheet } from './routing-rule-editor-sheet'
+
+interface Props {
+ inboxIdScope?: InboxId | 'workspace'
+}
+
+export function RoutingRuleList({ inboxIdScope }: Props) {
+ const qc = useQueryClient()
+ const listKey = routingRuleQueries.list({ inboxIdScope }).queryKey
+ const { data: serverRules } = useSuspenseQuery(routingRuleQueries.list({ inboxIdScope }))
+ const { data: inboxes } = useSuspenseQuery(inboxQueries.list({ includeArchived: true }))
+
+ // Local mirror so optimistic reorder can render before server confirms.
+ const [rules, setRules] = useState(serverRules)
+ useEffect(() => {
+ setRules(serverRules)
+ }, [serverRules])
+
+ const inboxLabel = (id: string | null): string => {
+ if (!id) return 'Workspace'
+ const ix = inboxes.find((i) => i.id === id)
+ return ix ? ix.slug : '—'
+ }
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
+ )
+
+ const reorderMutation = useMutation({
+ mutationFn: (orderedIds: RoutingRuleId[]) => reorderRoutingRulesFn({ data: { orderedIds } }),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['routing-rules'] }),
+ onError: (e: Error) => {
+ toast.error(e.message)
+ setRules(serverRules)
+ },
+ })
+
+ const toggleEnabledMutation = useMutation({
+ mutationFn: (vars: { ruleId: RoutingRuleId; enabled: boolean }) =>
+ updateRoutingRuleFn({ data: { ruleId: vars.ruleId, enabled: vars.enabled } }),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['routing-rules'] }),
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: (ruleId: RoutingRuleId) => deleteRoutingRuleFn({ data: { ruleId } }),
+ onSuccess: () => {
+ toast.success('Rule deleted')
+ qc.invalidateQueries({ queryKey: ['routing-rules'] })
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event
+ if (!over || active.id === over.id) return
+ const oldIndex = rules.findIndex((r) => r.id === active.id)
+ const newIndex = rules.findIndex((r) => r.id === over.id)
+ if (oldIndex < 0 || newIndex < 0) return
+ const next = arrayMove(rules, oldIndex, newIndex)
+ setRules(next)
+ reorderMutation.mutate(next.map((r) => r.id as RoutingRuleId))
+ }
+
+ // Force the listKey to be referenced so eslint doesn't flag it; the queryKey
+ // itself is consumed by useSuspenseQuery via routingRuleQueries.list().
+ void listKey
+
+ if (rules.length === 0) {
+ return (
+
+ No routing rules yet.
+
+ )
+ }
+
+ return (
+
+
+ r.id)} strategy={verticalListSortingStrategy}>
+ {rules.map((rule, idx) => (
+
+ toggleEnabledMutation.mutate({ ruleId: rule.id as RoutingRuleId, enabled })
+ }
+ onDelete={() => deleteMutation.mutate(rule.id as RoutingRuleId)}
+ />
+ ))}
+
+
+
+ )
+}
+
+interface RowProps {
+ rule: RoutingRule
+ position: number
+ inboxLabel: string
+ onToggleEnabled: (enabled: boolean) => void
+ onDelete: () => void
+}
+
+function SortableRuleRow({ rule, position, inboxLabel, onToggleEnabled, onDelete }: RowProps) {
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
+ id: rule.id,
+ })
+ const [editOpen, setEditOpen] = useState(false)
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ }
+
+ const conditions = (rule.conditions as { conditions?: unknown[] } | null)?.conditions ?? []
+ const actions = (rule.actions as unknown[]) ?? []
+ const lastMatched = rule.lastMatchedAt ? new Date(rule.lastMatchedAt as unknown as string) : null
+
+ return (
+
+
}
+ >
+
+
+
+
+
+
+ {position}
+
+
+
+
+ {rule.name}
+
+ {inboxLabel}
+
+
+
+ {conditions.length} condition{conditions.length === 1 ? '' : 's'} · {actions.length}{' '}
+ action{actions.length === 1 ? '' : 's'} · {rule.matchCount} match
+ {rule.matchCount === 1 ? '' : 'es'}
+ {lastMatched && (
+ <>
+ {' · last '}
+ {lastMatched.toLocaleDateString()}
+ >
+ )}
+
+
+
+
+ {rule.enabled ? 'Enabled' : 'Disabled'}
+
+ }
+ >
+
+ setEditOpen(true)}
+ aria-label="Edit rule"
+ >
+
+
+
+
+
+
+
+
+
+
+ Delete this routing rule?
+
+ "{rule.name}" will stop running on incoming tickets. This cannot be undone.
+
+
+
+ Cancel
+ Delete
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/security/__tests__/audit-log-csv.test.ts b/apps/web/src/components/admin/settings/security/__tests__/audit-log-csv.test.ts
index c9e61b433..103904344 100644
--- a/apps/web/src/components/admin/settings/security/__tests__/audit-log-csv.test.ts
+++ b/apps/web/src/components/admin/settings/security/__tests__/audit-log-csv.test.ts
@@ -1,46 +1,45 @@
/**
- * CSV export for the audit-log table.
- *
- * The 0070_audit_log_observability migration added request_id (indexed
- * for cross-event forensics), actor_type, and auth_method. These must
- * appear in the CSV export — the export is the operator's primary
- * offline-forensics tool, so leaving the new columns out keeps them
- * effectively invisible.
+ * CSV export for the unified audit-log table.
*/
import { describe, it, expect } from 'vitest'
-import { rowsToCsv } from '../audit-log-page'
-import type { AuditEventRow } from '@/lib/server/functions/audit-log'
+import { rowsToCsv } from '../../audit/audit-csv'
+import type { UnifiedAuditEventRow } from '@/lib/server/domains/audit/audit.unified'
-function row(overrides: Partial = {}): AuditEventRow {
+function row(overrides: Partial = {}): UnifiedAuditEventRow {
return {
id: 'audit_1',
- occurredAt: '2026-05-20T10:30:00.000Z',
+ origin: 'security',
+ occurredAt: new Date('2026-05-20T10:30:00.000Z'),
+ principalId: null,
actorUserId: null,
actorEmail: 'demo@example.com',
+ actorDisplayName: null,
actorRole: 'admin',
- actorIp: '127.0.0.1',
- actorUserAgent: 'Mozilla/5.0',
- eventType: 'auth.signin.succeeded',
- eventOutcome: 'success',
+ actorType: null,
+ authMethod: null,
+ action: 'auth.signin.succeeded',
+ outcome: 'success',
+ source: null,
+ ipAddress: '127.0.0.1',
+ userAgent: 'Mozilla/5.0',
targetType: null,
targetId: null,
- beforeValue: null,
- afterValue: null,
- metadata: null,
requestId: null,
- actorType: null,
- authMethod: null,
+ diff: {},
+ metadata: null,
...overrides,
}
}
-describe('rowsToCsv — audit-log observability columns', () => {
+describe('rowsToCsv — unified audit observability columns', () => {
it('includes request_id, actor_type, auth_method in the header row', () => {
const csv = rowsToCsv([row()])
const [header] = csv.split('\n')
expect(header).toContain('request_id')
expect(header).toContain('actor_type')
expect(header).toContain('auth_method')
+ expect(header).toContain('origin')
+ expect(header).toContain('source')
})
it('emits the values in each data row', () => {
@@ -51,6 +50,35 @@ describe('rowsToCsv — audit-log observability columns', () => {
expect(dataRow).toContain('sso')
})
+ it('exports workspace and security rows together', () => {
+ const csv = rowsToCsv([
+ row({
+ origin: 'workspace',
+ principalId: 'principal_1',
+ actorEmail: 'owner@example.com',
+ actorDisplayName: 'Owner',
+ action: 'ticket.created',
+ outcome: null,
+ source: 'web',
+ targetType: 'ticket',
+ targetId: 'ticket_1',
+ diff: { after: { status: 'open' } },
+ }),
+ row({
+ origin: 'security',
+ action: 'auth.signin.success',
+ requestId: 'req_abc123',
+ actorType: 'user',
+ authMethod: 'sso',
+ }),
+ ])
+
+ expect(csv).toContain('workspace')
+ expect(csv).toContain('ticket.created')
+ expect(csv).toContain('security')
+ expect(csv).toContain('auth.signin.success')
+ })
+
it('emits empty cells (not "null") when the observability fields are null', () => {
const csv = rowsToCsv([row({ requestId: null, actorType: null, authMethod: null })])
// Should not contain the literal string "null" — empty CSV cell instead.
diff --git a/apps/web/src/components/admin/settings/security/__tests__/portal-auth-tab.test.tsx b/apps/web/src/components/admin/settings/security/__tests__/portal-auth-tab.test.tsx
new file mode 100644
index 000000000..137c8259a
--- /dev/null
+++ b/apps/web/src/components/admin/settings/security/__tests__/portal-auth-tab.test.tsx
@@ -0,0 +1,394 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { PortalAuthTab } from '../portal-auth-tab'
+
+const mocks = vi.hoisted(() => ({
+ routerInvalidate: vi.fn(),
+ updatePortalAccessFn: vi.fn(),
+ query: {
+ data: [
+ { id: 'segment_1', name: 'Enterprise' },
+ { id: 'segment_2', name: 'Trial' },
+ ],
+ isLoading: false,
+ isError: false,
+ } as {
+ data?: Array<{ id: string; name: string }>
+ isLoading: boolean
+ isError: boolean
+ },
+ openInviteDialog: vi.fn(),
+ inviteOpenChange: vi.fn(),
+ sendInvites: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ Link: ({
+ children,
+ to,
+ }: {
+ children: ReactNode
+ to: string
+ search?: Record
+ className?: string
+ }) => {children} ,
+ useRouter: () => ({
+ invalidate: mocks.routerInvalidate,
+ }),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQuery: () => mocks.query,
+}))
+
+vi.mock('@/lib/server/functions/portal-access', () => ({
+ updatePortalAccessFn: mocks.updatePortalAccessFn,
+}))
+
+vi.mock('@/lib/server/functions/admin', () => ({
+ listSegmentsFn: vi.fn(),
+}))
+
+vi.mock('@/components/admin/settings/settings-card', () => ({
+ SettingsCard: ({
+ title,
+ description,
+ action,
+ children,
+ }: {
+ title: string
+ description: string
+ action?: ReactNode
+ children: ReactNode
+ }) => (
+
+ {title}
+ {description}
+ {action}
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/admin/settings/portal-privacy-dialog', () => ({
+ PortalPrivacyDialog: ({
+ open,
+ onOpenChange,
+ onConfirm,
+ }: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onConfirm: () => void
+ }) =>
+ open ? (
+
+ Private portal confirmation
+
+ Confirm private
+
+ onOpenChange(false)}>
+ Cancel private
+
+
+ ) : null,
+}))
+
+vi.mock('@/components/admin/users/use-portal-invites', () => ({
+ usePortalInvites: () => ({
+ invites: [
+ { id: 'invite_1', status: 'pending' },
+ { id: 'invite_2', status: 'accepted' },
+ ],
+ pendingCount: 1,
+ acceptedCount: 1,
+ isLoading: false,
+ lastSentSummary: 'Sent 2 invites',
+ dialogOpen: true,
+ emailsInput: 'ada@example.com',
+ messageInput: 'hello',
+ emailError: null,
+ batchResults: [],
+ sendBusy: false,
+ openDialog: mocks.openInviteDialog,
+ onOpenChange: mocks.inviteOpenChange,
+ onEmailsChange: vi.fn(),
+ onMessageChange: vi.fn(),
+ onSend: mocks.sendInvites,
+ }),
+}))
+
+vi.mock('@/components/admin/users/invite-people-dialog', () => ({
+ InvitePeopleDialog: ({
+ open,
+ emailsInput,
+ onOpenChange,
+ onSend,
+ }: {
+ open: boolean
+ emailsInput: string
+ onOpenChange: (open: boolean) => void
+ onSend: () => void
+ [key: string]: unknown
+ }) =>
+ open ? (
+
+ Invite dialog {emailsInput}
+
+ Send invites
+
+ onOpenChange(false)}>
+ Close invites
+
+
+ ) : null,
+}))
+
+vi.mock('@/components/admin/segments/segment-multi-select', () => ({
+ SegmentMultiSelect: ({
+ segments,
+ value,
+ onChange,
+ disabled,
+ }: {
+ segments: Array<{ id: string; name: string }>
+ value: string[]
+ onChange: (value: string[]) => void
+ disabled?: boolean
+ }) => (
+
+
+ Segment picker {segments.map((segment) => segment.name).join(', ')} selected{' '}
+ {value.join(', ') || 'none'}
+
+ onChange(['segment_2'])}>
+ Pick trial segment
+
+
+ ),
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ onClick,
+ disabled,
+ type = 'button',
+ }: {
+ children: ReactNode
+ onClick?: () => void
+ disabled?: boolean
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ size?: string
+ className?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({
+ value,
+ onChange,
+ onKeyDown,
+ placeholder,
+ disabled,
+ 'aria-label': ariaLabel,
+ }: {
+ value?: string
+ onChange?: (event: { target: { value: string } }) => void
+ onKeyDown?: (event: { key: string; preventDefault: () => void }) => void
+ placeholder?: string
+ disabled?: boolean
+ 'aria-label'?: string
+ 'aria-invalid'?: boolean
+ className?: string
+ }) => (
+ onChange?.({ target: { value: event.currentTarget.value } })}
+ onKeyDown={(event) =>
+ onKeyDown?.({ key: event.key, preventDefault: () => event.preventDefault() })
+ }
+ />
+ ),
+}))
+
+vi.mock('@/components/ui/switch', () => ({
+ Switch: ({
+ checked,
+ disabled,
+ onCheckedChange,
+ 'aria-label': ariaLabel,
+ }: {
+ id?: string
+ checked?: boolean
+ disabled?: boolean
+ onCheckedChange?: (checked: boolean) => void
+ 'aria-label'?: string
+ }) => (
+ onCheckedChange?.(event.currentTarget.checked)}
+ />
+ ),
+}))
+
+vi.mock('@heroicons/react/24/solid', () => ({
+ ArrowPathIcon: () => refresh ,
+ ArrowRightIcon: () => right ,
+ GlobeAltIcon: () => globe ,
+ LockClosedIcon: () => lock ,
+ PlusIcon: () => plus ,
+ XMarkIcon: () => remove ,
+}))
+
+function privateConfig(overrides: Record = {}) {
+ return {
+ access: {
+ visibility: 'private',
+ allowedDomains: ['acme.com'],
+ widgetSignIn: true,
+ allowedSegmentIds: ['segment_1'],
+ ...overrides,
+ },
+ } as never
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.query = {
+ data: [
+ { id: 'segment_1', name: 'Enterprise' },
+ { id: 'segment_2', name: 'Trial' },
+ ],
+ isLoading: false,
+ isError: false,
+ }
+ mocks.updatePortalAccessFn.mockResolvedValue(undefined)
+})
+
+describe('PortalAuthTab', () => {
+ it('updates private portal domains, segments, widget sign-in, and invite actions', async () => {
+ render( )
+
+ expect(screen.getByText('Your team always has access.')).toBeInTheDocument()
+ expect(screen.getByText('acme.com')).toBeInTheDocument()
+ expect(
+ screen.getByText(/Segment picker Enterprise, Trial selected segment_1/)
+ ).toBeInTheDocument()
+ expect(
+ screen.getByText('Members of 1 selected segment can access this portal.')
+ ).toBeInTheDocument()
+ expect(screen.getByText('1 pending · 1 accepted')).toBeInTheDocument()
+ expect(screen.getByText('Sent 2 invites')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: /Manage invites/ })).toHaveAttribute(
+ 'href',
+ '/admin/users'
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: /Invite people/ }))
+ expect(mocks.openInviteDialog).toHaveBeenCalled()
+ fireEvent.click(screen.getByRole('button', { name: 'Send invites' }))
+ expect(mocks.sendInvites).toHaveBeenCalled()
+
+ fireEvent.change(screen.getByLabelText('Add email domain'), {
+ target: { value: 'bad domain' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: /Add/ }))
+ expect(screen.getByText('Enter a valid domain, e.g. acme.com')).toBeInTheDocument()
+
+ fireEvent.change(screen.getByLabelText('Add email domain'), {
+ target: { value: '@Example.COM' },
+ })
+ fireEvent.keyDown(screen.getByLabelText('Add email domain'), { key: 'Enter' })
+ await waitFor(() => {
+ expect(mocks.updatePortalAccessFn).toHaveBeenCalledWith({
+ data: {
+ visibility: 'private',
+ allowedDomains: ['acme.com', 'example.com'],
+ widgetSignIn: true,
+ allowedSegmentIds: ['segment_1'],
+ },
+ })
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Remove acme.com' }))
+ await waitFor(() => {
+ expect(mocks.updatePortalAccessFn).toHaveBeenCalledWith({
+ data: {
+ visibility: 'private',
+ allowedDomains: ['example.com'],
+ widgetSignIn: true,
+ allowedSegmentIds: ['segment_1'],
+ },
+ })
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Pick trial segment' }))
+ await waitFor(() => {
+ expect(mocks.updatePortalAccessFn).toHaveBeenCalledWith({
+ data: expect.objectContaining({ allowedSegmentIds: ['segment_2'] }),
+ })
+ })
+
+ fireEvent.click(screen.getByLabelText('Allow widget-authenticated users to access the portal'))
+ await waitFor(() => {
+ expect(mocks.updatePortalAccessFn).toHaveBeenCalledWith({
+ data: expect.objectContaining({ widgetSignIn: false }),
+ })
+ })
+ })
+
+ it('confirms private visibility and reverts optimistic access changes on save failure', async () => {
+ mocks.updatePortalAccessFn.mockRejectedValueOnce(new Error('denied'))
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: /Private/ }))
+ expect(screen.getByText('Private portal confirmation')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel private' }))
+ expect(screen.queryByText('Private portal confirmation')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: /Private/ }))
+ fireEvent.click(screen.getByRole('button', { name: 'Confirm private' }))
+ await waitFor(() => {
+ expect(mocks.updatePortalAccessFn).toHaveBeenCalledWith({
+ data: {
+ visibility: 'private',
+ allowedDomains: [],
+ widgetSignIn: false,
+ allowedSegmentIds: [],
+ },
+ })
+ })
+ expect(screen.queryByText('Your team always has access.')).not.toBeInTheDocument()
+ })
+
+ it('renders segment loading, error, and empty states', () => {
+ mocks.query = { data: undefined, isLoading: true, isError: false }
+ const { rerender } = render( )
+ expect(screen.getByText(/Loading segments/)).toBeInTheDocument()
+
+ mocks.query = { data: undefined, isLoading: false, isError: true }
+ rerender( )
+ expect(
+ screen.getByText('Could not load segments. Reload the page to try again.')
+ ).toBeInTheDocument()
+
+ mocks.query = { data: [], isLoading: false, isError: false }
+ rerender( )
+ expect(
+ screen.getByText('No segments defined yet. Create segments in Customers.')
+ ).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/admin/settings/security/portal-auth-tab.tsx b/apps/web/src/components/admin/settings/security/portal-auth-tab.tsx
index d4466205c..031d7170b 100644
--- a/apps/web/src/components/admin/settings/security/portal-auth-tab.tsx
+++ b/apps/web/src/components/admin/settings/security/portal-auth-tab.tsx
@@ -368,7 +368,7 @@ export function PortalAuthTab({ portalConfig }: PortalAuthTabProps) {
{segmentsQuery.isLoading ? (
Loading segments…
@@ -378,7 +378,7 @@ export function PortalAuthTab({ portalConfig }: PortalAuthTabProps) {
) : (segmentsQuery.data ?? []).length === 0 ? (
- No segments defined yet. Create segments on the People page.
+ No segments defined yet. Create segments in Customers.
) : (
diff --git a/apps/web/src/components/admin/settings/sla/__tests__/business-hours-dialog.test.tsx b/apps/web/src/components/admin/settings/sla/__tests__/business-hours-dialog.test.tsx
new file mode 100644
index 000000000..b4edb03c8
--- /dev/null
+++ b/apps/web/src/components/admin/settings/sla/__tests__/business-hours-dialog.test.tsx
@@ -0,0 +1,249 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { BusinessHoursDialog } from '../business-hours-dialog'
+
+type MutationOptions = {
+ mutationFn: () => Promise
+ onSuccess?: (result: unknown) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ createBusinessHoursFn: vi.fn(),
+ updateBusinessHoursFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useMutation: (options: MutationOptions) => ({
+ isPending: false,
+ mutate: async () => {
+ try {
+ const result = await options.mutationFn()
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@/components/ui/dialog', () => ({
+ Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
+ open ? {children}
: null,
+ DialogContent: ({ children }: { children: ReactNode; className?: string }) => (
+
+ ),
+ DialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ DialogFooter: ({ children }: { children: ReactNode }) => ,
+ DialogHeader: ({ children }: { children: ReactNode }) => ,
+ DialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ 'aria-label': ariaLabel,
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ 'aria-label'?: string
+ variant?: string
+ size?: string
+ className?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({
+ id,
+ value,
+ onChange,
+ placeholder,
+ type = 'text',
+ }: {
+ id?: string
+ value?: string
+ onChange?: (event: React.ChangeEvent) => void
+ placeholder?: string
+ type?: string
+ step?: number
+ className?: string
+ }) => ,
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@heroicons/react/24/outline', () => ({
+ TrashIcon: () => ,
+ PlusIcon: () => ,
+}))
+
+vi.mock('@/lib/server/functions/sla', () => ({
+ createBusinessHoursFn: mocks.createBusinessHoursFn,
+ updateBusinessHoursFn: mocks.updateBusinessHoursFn,
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+function renderDialog(props: Partial> = {}) {
+ const onOpenChange = vi.fn()
+ const view = render( )
+ return { ...view, onOpenChange }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.createBusinessHoursFn.mockResolvedValue({ id: 'business_hours_new' })
+ mocks.updateBusinessHoursFn.mockResolvedValue({ id: 'business_hours_1' })
+})
+
+describe('BusinessHoursDialog', () => {
+ it('does not render dialog content when closed', () => {
+ render( )
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('validates create input and submits a trimmed create payload', async () => {
+ const { container, onOpenChange } = renderDialog()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Create' }))
+ expect(mocks.toastError).toHaveBeenCalledWith('Name is required')
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' EU support ' } })
+ fireEvent.change(screen.getByLabelText('Timezone (IANA)'), { target: { value: '' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Create' }))
+ expect(mocks.toastError).toHaveBeenCalledWith('Timezone is required')
+
+ fireEvent.change(screen.getByLabelText('Timezone (IANA)'), {
+ target: { value: ' Europe/Stockholm ' },
+ })
+ const timeInputs = Array.from(container.querySelectorAll('input[type="time"]'))
+ fireEvent.change(timeInputs[0], { target: { value: '18:00' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Create' }))
+ expect(mocks.toastError).toHaveBeenCalledWith('Mon: range start must be before end')
+
+ fireEvent.change(timeInputs[0], { target: { value: '09:30' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Add holiday' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Create' }))
+ expect(mocks.toastError).toHaveBeenCalledWith('Holiday date must be YYYY-MM-DD')
+
+ fireEvent.change(container.querySelector('input[type="date"]') as HTMLInputElement, {
+ target: { value: '2026-12-24' },
+ })
+ fireEvent.change(screen.getByPlaceholderText('Label (optional)'), {
+ target: { value: 'Christmas Eve' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Create' }))
+
+ await waitFor(() => {
+ expect(mocks.createBusinessHoursFn).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ name: 'EU support',
+ timezone: 'Europe/Stockholm',
+ holidays: [{ date: '2026-12-24', label: 'Christmas Eve' }],
+ }),
+ })
+ })
+ expect(mocks.createBusinessHoursFn.mock.calls[0][0].data.schedule.mon).toEqual([
+ { start: '09:30', end: '17:00' },
+ ])
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Calendar created')
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['business-hours'] })
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+
+ it('supports adding and removing ranges and holidays in edit mode', async () => {
+ const { container, onOpenChange } = renderDialog({
+ row: {
+ id: 'business_hours_1',
+ name: 'Existing calendar',
+ timezone: 'America/New_York',
+ schedule: {
+ mon: [],
+ tue: [{ start: '10:00', end: '16:00' }],
+ wed: [],
+ thu: [],
+ fri: [],
+ sat: [],
+ sun: [],
+ },
+ holidays: [{ date: '2026-01-01', label: 'New year' }],
+ } as never,
+ })
+
+ expect(screen.getByRole('heading', { name: 'Edit calendar' })).toBeInTheDocument()
+ expect(screen.getByDisplayValue('Existing calendar')).toBeInTheDocument()
+ expect(screen.getAllByText('Closed').length).toBeGreaterThan(0)
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Updated calendar' } })
+ fireEvent.click(screen.getAllByRole('button', { name: 'Range' })[0])
+ const timeInputs = Array.from(container.querySelectorAll('input[type="time"]'))
+ fireEvent.change(timeInputs[0], { target: { value: '08:00' } })
+ fireEvent.change(timeInputs[1], { target: { value: '12:00' } })
+ fireEvent.click(screen.getAllByRole('button', { name: 'Remove holiday' })[0])
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+
+ await waitFor(() => {
+ expect(mocks.updateBusinessHoursFn).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ id: 'business_hours_1',
+ name: 'Updated calendar',
+ timezone: 'America/New_York',
+ holidays: [],
+ }),
+ })
+ })
+ expect(mocks.updateBusinessHoursFn.mock.calls[0][0].data.schedule.mon).toEqual([
+ { start: '08:00', end: '12:00' },
+ ])
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Calendar updated')
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+
+ it('removes schedule ranges, cancels, and reports server errors', async () => {
+ mocks.createBusinessHoursFn.mockRejectedValueOnce(new Error('Calendar already exists'))
+ const { container, onOpenChange } = renderDialog()
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Remove range' })[0])
+ expect(screen.getAllByText('Closed').length).toBeGreaterThan(0)
+
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'EU support' } })
+ const timeInputs = Array.from(container.querySelectorAll('input[type="time"]'))
+ fireEvent.change(timeInputs[0], { target: { value: '09:00' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Create' }))
+
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Calendar already exists')
+ })
+ })
+})
diff --git a/apps/web/src/components/admin/settings/sla/__tests__/business-hours-list.test.tsx b/apps/web/src/components/admin/settings/sla/__tests__/business-hours-list.test.tsx
new file mode 100644
index 000000000..09d6a561f
--- /dev/null
+++ b/apps/web/src/components/admin/settings/sla/__tests__/business-hours-list.test.tsx
@@ -0,0 +1,264 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import type { BusinessHoursId } from '@quackback/ids'
+import { BusinessHoursList } from '../business-hours-list'
+
+type BusinessHoursRow = {
+ id: BusinessHoursId
+ name: string
+ timezone: string
+ holidays: unknown[] | null
+ archivedAt: string | null
+}
+
+type MutationOptions = {
+ mutationFn: (id: BusinessHoursId) => Promise
+ onSuccess?: (result: unknown) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ rows: [] as BusinessHoursRow[],
+ archiveBusinessHoursFn: vi.fn(),
+ invalidateQueries: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useSuspenseQuery: () => ({
+ data: mocks.rows,
+ }),
+ useMutation: (options: MutationOptions) => ({
+ mutate: async (id: BusinessHoursId) => {
+ try {
+ const result = await options.mutationFn(id)
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@/lib/client/queries/business-hours', () => ({
+ businessHoursQueries: {
+ list: (params: unknown) => ({ queryKey: ['business-hours', params] }),
+ },
+}))
+
+vi.mock('@/lib/server/functions/sla', () => ({
+ archiveBusinessHoursFn: mocks.archiveBusinessHoursFn,
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+vi.mock('@/components/admin/shared/permission-gate', () => ({
+ PermissionGate: ({ children }: { children: ReactNode; permission: string }) => <>{children}>,
+}))
+
+vi.mock('../business-hours-dialog', () => ({
+ BusinessHoursDialog: ({
+ open,
+ onOpenChange,
+ row,
+ }: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ row?: BusinessHoursRow
+ }) =>
+ open ? (
+
+ Editing {row?.name}
+ onOpenChange(false)}>
+ Close editor
+
+
+ ) : null,
+}))
+
+vi.mock('@/components/ui/table', () => ({
+ Table: ({ children }: { children: ReactNode }) => ,
+ TableBody: ({ children }: { children: ReactNode }) => {children} ,
+ TableCell: ({
+ children,
+ colSpan,
+ }: {
+ children?: ReactNode
+ colSpan?: number
+ className?: string
+ }) => {children} ,
+ TableHead: ({ children }: { children?: ReactNode; className?: string }) => {children} ,
+ TableHeader: ({ children }: { children: ReactNode }) => {children} ,
+ TableRow: ({ children }: { children: ReactNode }) => {children} ,
+}))
+
+vi.mock('@/components/ui/switch', () => ({
+ Switch: ({
+ id,
+ checked,
+ onCheckedChange,
+ }: {
+ id?: string
+ checked: boolean
+ onCheckedChange: (checked: boolean) => void
+ }) => (
+ onCheckedChange(event.currentTarget.checked)}
+ />
+ ),
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string; className?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/badge', () => ({
+ Badge: ({ children }: { children: ReactNode; variant?: string; className?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ onClick,
+ 'aria-label': ariaLabel,
+ }: {
+ children?: ReactNode
+ onClick?: () => void
+ variant?: string
+ size?: string
+ className?: string
+ 'aria-label'?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/alert-dialog', () => ({
+ AlertDialog: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogAction: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
+
+ {children}
+
+ ),
+ AlertDialogCancel: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ AlertDialogContent: ({ children }: { children: ReactNode }) => ,
+ AlertDialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogFooter: ({ children }: { children: ReactNode }) => ,
+ AlertDialogHeader: ({ children }: { children: ReactNode }) => ,
+ AlertDialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+ AlertDialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}>,
+}))
+
+vi.mock('@heroicons/react/24/outline', () => ({
+ ArchiveBoxIcon: () => archive ,
+ PencilSquareIcon: () => edit ,
+}))
+
+function row(overrides: Partial = {}): BusinessHoursRow {
+ return {
+ id: 'business_hours_1' as BusinessHoursId,
+ name: 'EU support',
+ timezone: 'Europe/Stockholm',
+ holidays: [{ date: '2026-12-24' }],
+ archivedAt: null,
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.archiveBusinessHoursFn.mockResolvedValue({ ok: true })
+ mocks.rows = [
+ row(),
+ row({
+ id: 'business_hours_archived' as BusinessHoursId,
+ name: 'Legacy hours',
+ timezone: 'UTC',
+ holidays: null,
+ archivedAt: '2026-06-20T10:00:00.000Z',
+ }),
+ ]
+})
+
+describe('BusinessHoursList', () => {
+ it('renders active calendars by default, reveals archived rows, and opens edit', () => {
+ render( )
+
+ expect(screen.getByText('EU support')).toBeInTheDocument()
+ expect(screen.getByText('Europe/Stockholm')).toBeInTheDocument()
+ expect(screen.getByText('1')).toBeInTheDocument()
+ expect(screen.getByText('Active')).toBeInTheDocument()
+ expect(screen.queryByText('Legacy hours')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByLabelText('Show archived'))
+
+ expect(screen.getByText('Legacy hours')).toBeInTheDocument()
+ expect(screen.getByText('UTC')).toBeInTheDocument()
+ expect(screen.getByText('Archived')).toBeInTheDocument()
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Edit calendar' })[0])
+ expect(screen.getByText(/Editing EU support/)).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close editor' }))
+ expect(screen.queryByText(/Editing EU support/)).not.toBeInTheDocument()
+ })
+
+ it('archives active calendars and refreshes the list', async () => {
+ render( )
+
+ expect(screen.getByText('Archive this calendar?')).toBeInTheDocument()
+ expect(
+ screen.getByText(
+ "SLA policies referencing it will continue to work, but it won't appear in pickers for new policies."
+ )
+ ).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Archive' }))
+
+ await waitFor(() => {
+ expect(mocks.archiveBusinessHoursFn).toHaveBeenCalledWith({
+ data: { id: 'business_hours_1' },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['business-hours'] })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Calendar archived')
+ })
+
+ it('reports archive failures and renders the empty state', async () => {
+ mocks.archiveBusinessHoursFn.mockRejectedValueOnce(new Error('Cannot archive default calendar'))
+ const { rerender } = render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Archive' }))
+
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Cannot archive default calendar')
+ })
+
+ mocks.rows = []
+ rerender( )
+
+ expect(screen.getByText('No calendars yet.')).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/admin/settings/sla/__tests__/sla-escalation-dialog.test.tsx b/apps/web/src/components/admin/settings/sla/__tests__/sla-escalation-dialog.test.tsx
new file mode 100644
index 000000000..b34fb0d3f
--- /dev/null
+++ b/apps/web/src/components/admin/settings/sla/__tests__/sla-escalation-dialog.test.tsx
@@ -0,0 +1,331 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { SlaEscalationDialog } from '../sla-escalation-dialog'
+
+type MutationOptions = {
+ mutationFn: () => Promise
+ onSuccess?: (result: unknown) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ createEscalationRuleFn: vi.fn(),
+ updateEscalationRuleFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useMutation: (options: MutationOptions) => ({
+ isPending: false,
+ mutate: async () => {
+ try {
+ const result = await options.mutationFn()
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@/components/ui/dialog', () => ({
+ Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
+ open ? {children}
: null,
+ DialogContent: ({ children }: { children: ReactNode; className?: string }) => (
+
+ ),
+ DialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ DialogFooter: ({ children }: { children: ReactNode }) => ,
+ DialogHeader: ({ children }: { children: ReactNode }) => ,
+ DialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({
+ id,
+ type = 'text',
+ value,
+ onChange,
+ placeholder,
+ }: {
+ id?: string
+ type?: string
+ value?: string | number
+ onChange?: (event: React.ChangeEvent) => void
+ placeholder?: string
+ }) => ,
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string; className?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/switch', () => ({
+ Switch: ({
+ id,
+ checked,
+ onCheckedChange,
+ }: {
+ id?: string
+ checked: boolean
+ onCheckedChange: (checked: boolean) => void
+ }) => (
+ onCheckedChange(!checked)} />
+ ),
+}))
+
+vi.mock('@/components/ui/select', () => ({
+ Select: ({
+ value,
+ onValueChange,
+ children,
+ }: {
+ value: string
+ onValueChange: (value: string) => void
+ children: ReactNode
+ }) => (
+ onValueChange(event.currentTarget.value)}>
+ {children}
+
+ ),
+ SelectContent: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectItem: ({ children, value }: { children: ReactNode; value: string }) => (
+ {children}
+ ),
+ SelectTrigger: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectValue: () => null,
+}))
+
+vi.mock('@/components/admin/shared/team-picker', () => ({
+ TeamPicker: ({ onValueChange }: { onValueChange: (id: string | null) => void }) => (
+ <>
+ onValueChange('team_escalation')}>
+ Pick escalation team
+
+ onValueChange(null)}>
+ Clear escalation team
+
+ >
+ ),
+}))
+
+vi.mock('@/components/admin/shared/principal-picker', () => ({
+ PrincipalPicker: ({ onValueChange }: { onValueChange: (ids: string[]) => void }) => (
+ onValueChange(['principal_manager', 'principal_owner'])}>
+ Pick escalation principals
+
+ ),
+}))
+
+vi.mock('@/lib/server/functions/sla', () => ({
+ createEscalationRuleFn: mocks.createEscalationRuleFn,
+ updateEscalationRuleFn: mocks.updateEscalationRuleFn,
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+function renderDialog(props: Partial> = {}) {
+ const onOpenChange = vi.fn()
+ const view = render(
+
+ )
+ return { ...view, onOpenChange }
+}
+
+function selects() {
+ return screen.getAllByRole('combobox') as HTMLSelectElement[]
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.createEscalationRuleFn.mockResolvedValue({ id: 'escalation_rule_new' })
+ mocks.updateEscalationRuleFn.mockResolvedValue({ id: 'escalation_rule_1' })
+})
+
+describe('SlaEscalationDialog', () => {
+ it('does not render content while closed', () => {
+ render(
+
+ )
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('validates and creates a team escalation rule', async () => {
+ const { onOpenChange } = renderDialog()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Create' }))
+ expect(mocks.toastError).toHaveBeenCalledWith('Name is required')
+
+ fireEvent.change(screen.getByLabelText('Name'), {
+ target: { value: ' Notify team lead ' },
+ })
+ fireEvent.change(selects()[0], { target: { value: 'resolution' } })
+ fireEvent.change(screen.getByLabelText('Lead minutes (signed)'), {
+ target: { value: '15' },
+ })
+ expect(screen.getByText('Fires 15m before breach')).toBeInTheDocument()
+
+ fireEvent.change(selects()[1], { target: { value: 'team' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Create' }))
+ expect(mocks.toastError).toHaveBeenCalledWith('Pick a team')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Pick escalation team' }))
+ fireEvent.click(screen.getByRole('button', { name: 'in_app' }))
+ fireEvent.click(screen.getByRole('button', { name: 'email' }))
+ fireEvent.click(screen.getByRole('button', { name: 'webhook' }))
+ fireEvent.click(screen.getByLabelText('Enabled'))
+ fireEvent.click(screen.getByRole('button', { name: 'Create' }))
+
+ await waitFor(() => {
+ expect(mocks.createEscalationRuleFn).toHaveBeenCalledWith({
+ data: {
+ policyId: 'sla_policy_1',
+ name: 'Notify team lead',
+ leadMinutes: 15,
+ targetKind: 'resolution',
+ recipientType: 'team',
+ recipientTeamId: 'team_escalation',
+ recipientPrincipalIds: undefined,
+ channels: ['email', 'webhook'],
+ enabled: false,
+ },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Escalation created')
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['sla', 'escalations', 'sla_policy_1'],
+ })
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+
+ it('requires principals and at least one channel before create', async () => {
+ renderDialog()
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Notify owners' } })
+ fireEvent.change(screen.getByLabelText('Lead minutes (signed)'), {
+ target: { value: '-10' },
+ })
+ expect(screen.getByText('Fires 10m after breach')).toBeInTheDocument()
+ fireEvent.change(selects()[1], { target: { value: 'principals' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Create' }))
+ expect(mocks.toastError).toHaveBeenCalledWith('Pick at least one principal')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Pick escalation principals' }))
+ fireEvent.click(screen.getByRole('button', { name: 'in_app' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Create' }))
+ expect(mocks.toastError).toHaveBeenCalledWith('Pick at least one channel')
+
+ fireEvent.click(screen.getByRole('button', { name: 'email' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Create' }))
+
+ await waitFor(() => {
+ expect(mocks.createEscalationRuleFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ leadMinutes: -10,
+ recipientType: 'principals',
+ recipientTeamId: null,
+ recipientPrincipalIds: ['principal_manager', 'principal_owner'],
+ channels: ['email'],
+ }),
+ })
+ )
+ })
+ })
+
+ it('updates an existing escalation and reports update errors', async () => {
+ mocks.updateEscalationRuleFn.mockRejectedValueOnce(new Error('Escalation update failed'))
+ const { onOpenChange } = renderDialog({
+ rule: {
+ id: 'escalation_rule_1',
+ name: 'Existing rule',
+ leadMinutes: 0,
+ targetKind: 'next_response',
+ recipientType: 'principals',
+ recipientTeamId: null,
+ recipientPrincipalIds: null,
+ channels: null,
+ enabled: true,
+ } as never,
+ })
+
+ expect(screen.getByRole('heading', { name: 'Edit escalation' })).toBeInTheDocument()
+ expect(screen.getByText('Fires at breach')).toBeInTheDocument()
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Updated rule' } })
+ fireEvent.change(selects()[1], { target: { value: 'inbox_members' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Escalation update failed')
+ })
+
+ mocks.updateEscalationRuleFn.mockResolvedValueOnce({ id: 'escalation_rule_1' })
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+
+ await waitFor(() => {
+ expect(mocks.updateEscalationRuleFn).toHaveBeenLastCalledWith({
+ data: {
+ id: 'escalation_rule_1',
+ name: 'Updated rule',
+ leadMinutes: 0,
+ targetKind: 'next_response',
+ recipientType: 'inbox_members',
+ recipientTeamId: null,
+ recipientPrincipalIds: undefined,
+ channels: ['in_app'],
+ enabled: true,
+ },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Escalation updated')
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+
+ it('cancels edits through onOpenChange', () => {
+ const { onOpenChange } = renderDialog()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+})
diff --git a/apps/web/src/components/admin/settings/sla/__tests__/sla-escalations-tab.test.tsx b/apps/web/src/components/admin/settings/sla/__tests__/sla-escalations-tab.test.tsx
new file mode 100644
index 000000000..4d2c3041f
--- /dev/null
+++ b/apps/web/src/components/admin/settings/sla/__tests__/sla-escalations-tab.test.tsx
@@ -0,0 +1,313 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { SlaEscalationsTab } from '../sla-escalations-tab'
+
+type Rule = {
+ id: string
+ name: string
+ targetKind: string
+ leadMinutes: number
+ recipientType: string
+ recipientTeamId: string | null
+ recipientPrincipalIds: string[] | null
+ channels: string[] | null
+ enabled: boolean
+}
+
+type MutationOptions = {
+ mutationFn: (vars: TVars) => Promise
+ onSuccess?: (result: TResult) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ updateEscalationRuleFn: vi.fn(),
+ deleteEscalationRuleFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+ permissionAllowed: true,
+ rules: [] as Rule[],
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useSuspenseQuery: () => ({
+ data: mocks.rules,
+ }),
+ useMutation: (options: MutationOptions) => ({
+ isPending: false,
+ mutate: async (vars: TVars) => {
+ try {
+ const result = await options.mutationFn(vars)
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@/components/admin/shared/permission-gate', () => ({
+ PermissionGate: ({
+ children,
+ fallback = null,
+ }: {
+ children: ReactNode
+ fallback?: ReactNode
+ permission: string
+ }) => (mocks.permissionAllowed ? <>{children}> : <>{fallback}>),
+}))
+
+vi.mock('../sla-escalation-dialog', () => ({
+ SlaEscalationDialog: ({
+ open,
+ onOpenChange,
+ rule,
+ }: {
+ policyId: string
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ rule?: Rule
+ }) =>
+ open ? (
+
+ {rule ? `Editing ${rule.name}` : 'Creating escalation'}
+ onOpenChange(false)}>
+ Close escalation dialog
+
+
+ ) : null,
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ 'aria-label': ariaLabel,
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ size?: string
+ className?: string
+ 'aria-label'?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/switch', () => ({
+ Switch: ({
+ checked,
+ onCheckedChange,
+ }: {
+ checked: boolean
+ onCheckedChange: (checked: boolean) => void
+ }) => (
+ onCheckedChange(!checked)}
+ />
+ ),
+}))
+
+vi.mock('@/components/ui/badge', () => ({
+ Badge: ({ children }: { children: ReactNode; variant?: string; className?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/table', () => ({
+ Table: ({ children }: { children: ReactNode }) => ,
+ TableBody: ({ children }: { children: ReactNode }) => {children} ,
+ TableCell: ({
+ children,
+ colSpan,
+ }: {
+ children?: ReactNode
+ colSpan?: number
+ className?: string
+ }) => {children} ,
+ TableHead: ({ children }: { children?: ReactNode; className?: string }) => {children} ,
+ TableHeader: ({ children }: { children: ReactNode }) => {children} ,
+ TableRow: ({ children }: { children: ReactNode }) => {children} ,
+}))
+
+vi.mock('@/components/ui/alert-dialog', () => ({
+ AlertDialog: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogAction: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
+
+ {children}
+
+ ),
+ AlertDialogCancel: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ AlertDialogContent: ({ children }: { children: ReactNode }) => ,
+ AlertDialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogFooter: ({ children }: { children: ReactNode }) => ,
+ AlertDialogHeader: ({ children }: { children: ReactNode }) => ,
+ AlertDialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+ AlertDialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}>,
+}))
+
+vi.mock('@heroicons/react/24/outline', () => ({
+ PencilSquareIcon: () => pencil ,
+ PlusIcon: () => plus ,
+ TrashIcon: () => trash ,
+}))
+
+vi.mock('@/lib/client/queries/sla', () => ({
+ slaQueries: {
+ escalations: (policyId: string) => ({ queryKey: ['sla', 'escalations', policyId] }),
+ },
+}))
+
+vi.mock('@/lib/server/functions/sla', () => ({
+ updateEscalationRuleFn: mocks.updateEscalationRuleFn,
+ deleteEscalationRuleFn: mocks.deleteEscalationRuleFn,
+}))
+
+vi.mock('@/lib/server/domains/authz', () => ({
+ PERMISSIONS: {
+ ESCALATION_RULE_MANAGE: 'escalation_rule.manage',
+ },
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.permissionAllowed = true
+ mocks.rules = [
+ {
+ id: 'rule_before',
+ name: 'Warn before breach',
+ targetKind: 'first_response',
+ leadMinutes: 15,
+ recipientType: 'team',
+ recipientTeamId: 'team_support',
+ recipientPrincipalIds: null,
+ channels: ['email', 'slack'],
+ enabled: true,
+ },
+ {
+ id: 'rule_at',
+ name: 'Escalate at breach',
+ targetKind: 'resolution',
+ leadMinutes: 0,
+ recipientType: 'principals',
+ recipientTeamId: null,
+ recipientPrincipalIds: ['principal_1', 'principal_2'],
+ channels: null,
+ enabled: false,
+ },
+ {
+ id: 'rule_after',
+ name: 'After breach',
+ targetKind: 'resolution',
+ leadMinutes: -30,
+ recipientType: 'manager',
+ recipientTeamId: null,
+ recipientPrincipalIds: null,
+ channels: ['email'],
+ enabled: true,
+ },
+ ]
+ mocks.updateEscalationRuleFn.mockResolvedValue({ id: 'rule_before' })
+ mocks.deleteEscalationRuleFn.mockResolvedValue(undefined)
+})
+
+describe('SlaEscalationsTab', () => {
+ it('renders empty state and opens the create dialog', () => {
+ mocks.rules = []
+ render( )
+
+ expect(screen.getByText('No escalation rules yet.')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'New escalation' }))
+ expect(screen.getByRole('dialog')).toHaveTextContent('Creating escalation')
+ fireEvent.click(screen.getByRole('button', { name: 'Close escalation dialog' }))
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('renders lead-time, recipient and channel labels and opens edit dialog', () => {
+ render( )
+
+ expect(screen.getByText('15m before breach')).toBeInTheDocument()
+ expect(screen.getByText('At breach')).toBeInTheDocument()
+ expect(screen.getByText('30m after breach')).toBeInTheDocument()
+ expect(screen.getByText('team: team_support')).toBeInTheDocument()
+ expect(screen.getByText('2 principal(s)')).toBeInTheDocument()
+ expect(screen.getByText('manager')).toBeInTheDocument()
+ expect(screen.getAllByText('email')).toHaveLength(2)
+ expect(screen.getByText('slack')).toBeInTheDocument()
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Edit escalation' })[0])
+ expect(screen.getByRole('dialog')).toHaveTextContent('Editing Warn before breach')
+ })
+
+ it('toggles and deletes escalation rules with cache invalidation', async () => {
+ render( )
+
+ fireEvent.click(screen.getAllByLabelText('enabled-on')[0])
+ await waitFor(() => {
+ expect(mocks.updateEscalationRuleFn).toHaveBeenCalledWith({
+ data: { id: 'rule_before', enabled: false },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['sla', 'escalations', 'policy_1'],
+ })
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[0])
+ await waitFor(() => {
+ expect(mocks.deleteEscalationRuleFn).toHaveBeenCalledWith({
+ data: { id: 'rule_before' },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Escalation rule deleted')
+ })
+
+ it('reports mutation errors and renders read-only fallback badges without permission', async () => {
+ mocks.updateEscalationRuleFn.mockRejectedValueOnce(new Error('Toggle denied'))
+ mocks.deleteEscalationRuleFn.mockRejectedValueOnce(new Error('Delete denied'))
+ render( )
+
+ fireEvent.click(screen.getAllByLabelText('enabled-on')[0])
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Toggle denied')
+ })
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[0])
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Delete denied')
+ })
+
+ cleanup()
+ mocks.permissionAllowed = false
+ render( )
+ expect(screen.queryByRole('button', { name: 'New escalation' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Edit escalation' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Delete escalation' })).not.toBeInTheDocument()
+ expect(screen.getAllByText('On')).toHaveLength(2)
+ expect(screen.getByText('Off')).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/admin/settings/sla/__tests__/sla-list-and-targets.test.tsx b/apps/web/src/components/admin/settings/sla/__tests__/sla-list-and-targets.test.tsx
new file mode 100644
index 000000000..447652324
--- /dev/null
+++ b/apps/web/src/components/admin/settings/sla/__tests__/sla-list-and-targets.test.tsx
@@ -0,0 +1,351 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import type { SlaPolicyId } from '@quackback/ids'
+import { SlaPolicyList } from '../sla-policy-list'
+import { SlaTargetsTab } from '../sla-targets-tab'
+
+type PolicyRow = {
+ id: SlaPolicyId
+ name: string
+ scope: string
+ appliesToPriorities: string[] | null
+ businessHoursId: string | null
+ enabled: boolean
+ archivedAt: string | null
+}
+
+type CalendarRow = {
+ id: string
+ name: string
+}
+
+type MutationOptions = {
+ mutationFn: (vars: TVars) => Promise
+ onSuccess?: (result: unknown) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ policies: [] as PolicyRow[],
+ calendars: [] as CalendarRow[],
+ updateSlaPolicyFn: vi.fn(),
+ replaceSlaTargetsFn: vi.fn(),
+ invalidateQueries: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useSuspenseQuery: (options: { queryKey?: readonly unknown[] }) => {
+ if (options.queryKey?.[0] === 'sla') {
+ return { data: mocks.policies }
+ }
+ return { data: mocks.calendars }
+ },
+ useMutation: (options: MutationOptions) => ({
+ isPending: false,
+ mutate: async (vars: TVars) => {
+ try {
+ const result = await options.mutationFn(vars)
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ Link: ({
+ children,
+ to,
+ params,
+ }: {
+ children: ReactNode
+ to: string
+ params?: Record
+ className?: string
+ }) => (
+ path.replace(`$${key}`, value),
+ to
+ )}
+ >
+ {children}
+
+ ),
+}))
+
+vi.mock('@/lib/client/queries/sla', () => ({
+ slaQueries: {
+ policies: (params: unknown) => ({ queryKey: ['sla', 'policies', params] }),
+ },
+}))
+
+vi.mock('@/lib/client/queries/business-hours', () => ({
+ businessHoursQueries: {
+ list: (params: unknown) => ({ queryKey: ['business-hours', params] }),
+ },
+}))
+
+vi.mock('@/lib/server/functions/sla', () => ({
+ replaceSlaTargetsFn: mocks.replaceSlaTargetsFn,
+ updateSlaPolicyFn: mocks.updateSlaPolicyFn,
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+vi.mock('@/components/admin/shared/permission-gate', () => ({
+ PermissionGate: ({
+ children,
+ }: {
+ children: ReactNode
+ permission: string
+ fallback?: ReactNode
+ }) => <>{children}>,
+}))
+
+vi.mock('@/components/ui/table', () => ({
+ Table: ({ children }: { children: ReactNode }) => ,
+ TableBody: ({ children }: { children: ReactNode }) => {children} ,
+ TableCell: ({
+ children,
+ colSpan,
+ }: {
+ children?: ReactNode
+ colSpan?: number
+ className?: string
+ }) => {children} ,
+ TableHead: ({ children }: { children?: ReactNode; className?: string }) => {children} ,
+ TableHeader: ({ children }: { children: ReactNode }) => {children} ,
+ TableRow: ({ children }: { children: ReactNode; className?: string }) => {children} ,
+}))
+
+vi.mock('@/components/ui/switch', () => ({
+ Switch: ({
+ id,
+ checked,
+ onCheckedChange,
+ }: {
+ id?: string
+ checked: boolean
+ onCheckedChange: (checked: boolean) => void
+ }) => (
+ onCheckedChange(event.currentTarget.checked)}
+ />
+ ),
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string; className?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/badge', () => ({
+ Badge: ({ children }: { children: ReactNode; variant?: string; className?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({
+ id,
+ value,
+ onChange,
+ placeholder,
+ type = 'text',
+ }: {
+ id?: string
+ value?: string
+ onChange?: (event: { target: { value: string } }) => void
+ placeholder?: string
+ type?: string
+ min?: number
+ className?: string
+ }) => (
+ onChange?.({ target: { value: event.currentTarget.value } })}
+ />
+ ),
+}))
+
+function policy(overrides: Partial = {}): PolicyRow {
+ return {
+ id: 'sla_policy_1' as SlaPolicyId,
+ name: 'Default SLA',
+ scope: 'global',
+ appliesToPriorities: null,
+ businessHoursId: 'business_hours_1',
+ enabled: true,
+ archivedAt: null,
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.updateSlaPolicyFn.mockResolvedValue({ ok: true })
+ mocks.replaceSlaTargetsFn.mockResolvedValue({ ok: true })
+ mocks.calendars = [{ id: 'business_hours_1', name: 'EU support' }]
+ mocks.policies = [
+ policy(),
+ policy({
+ id: 'sla_policy_archived' as SlaPolicyId,
+ name: 'Archived SLA',
+ scope: 'team',
+ appliesToPriorities: ['urgent', 'high'],
+ businessHoursId: 'missing_calendar',
+ enabled: false,
+ archivedAt: '2026-06-20T10:00:00.000Z',
+ }),
+ ]
+})
+
+describe('SlaPolicyList', () => {
+ it('renders active policies, toggles enabled state, and reveals archived rows', async () => {
+ render( )
+
+ expect(screen.getByRole('link', { name: 'Default SLA' })).toHaveAttribute(
+ 'href',
+ '/admin/settings/sla/sla_policy_1'
+ )
+ expect(screen.getByText('global')).toBeInTheDocument()
+ expect(screen.getByText('All')).toBeInTheDocument()
+ expect(screen.getByText('EU support')).toBeInTheDocument()
+ expect(screen.getByText('Active')).toBeInTheDocument()
+ expect(screen.queryByText('Archived SLA')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getAllByRole('checkbox')[1])
+
+ await waitFor(() => {
+ expect(mocks.updateSlaPolicyFn).toHaveBeenCalledWith({
+ data: {
+ id: 'sla_policy_1',
+ enabled: false,
+ },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['sla'] })
+
+ fireEvent.click(screen.getByLabelText('Show archived'))
+
+ expect(screen.getByRole('link', { name: 'Archived SLA' })).toHaveAttribute(
+ 'href',
+ '/admin/settings/sla/sla_policy_archived'
+ )
+ expect(screen.getByText('team')).toBeInTheDocument()
+ expect(screen.getByText('urgent')).toBeInTheDocument()
+ expect(screen.getByText('high')).toBeInTheDocument()
+ expect(screen.getByText('Archived')).toBeInTheDocument()
+ expect(screen.getByText('—')).toBeInTheDocument()
+ })
+
+ it('reports toggle failures and renders an empty state', async () => {
+ mocks.updateSlaPolicyFn.mockRejectedValueOnce(new Error('Cannot pause default SLA'))
+ const { rerender } = render( )
+
+ fireEvent.click(screen.getAllByRole('checkbox')[1])
+
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Cannot pause default SLA')
+ })
+
+ mocks.policies = []
+ rerender( )
+ expect(screen.getByText('No SLA policies yet.')).toBeInTheDocument()
+ })
+})
+
+describe('SlaTargetsTab', () => {
+ it('saves positive integer targets and omits blank or invalid rows', async () => {
+ render(
+
+ )
+
+ expect(screen.getByText(/Leave a target empty/)).toBeInTheDocument()
+ expect(screen.getByLabelText('First response')).toHaveValue(15)
+ expect(screen.getByLabelText('Resolution')).toHaveValue(240)
+
+ fireEvent.change(screen.getByLabelText('First response'), { target: { value: 'abc' } })
+ fireEvent.change(screen.getByLabelText('Next response'), { target: { value: '30' } })
+ fireEvent.change(screen.getByLabelText('Resolution'), { target: { value: '0' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save targets' }))
+
+ await waitFor(() => {
+ expect(mocks.replaceSlaTargetsFn).toHaveBeenCalledWith({
+ data: {
+ policyId: 'sla_policy_1',
+ targets: [{ kind: 'next_response', minutes: 30 }],
+ },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Targets updated')
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['sla'] })
+ })
+
+ it('reports target save failures', async () => {
+ mocks.replaceSlaTargetsFn.mockRejectedValueOnce(new Error('Targets rejected'))
+ render( )
+
+ fireEvent.change(screen.getByLabelText('Resolution'), { target: { value: '60' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save targets' }))
+
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Targets rejected')
+ })
+ })
+})
diff --git a/apps/web/src/components/admin/settings/sla/__tests__/sla-policy-create-dialog.test.tsx b/apps/web/src/components/admin/settings/sla/__tests__/sla-policy-create-dialog.test.tsx
new file mode 100644
index 000000000..23cb9dd72
--- /dev/null
+++ b/apps/web/src/components/admin/settings/sla/__tests__/sla-policy-create-dialog.test.tsx
@@ -0,0 +1,303 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { SlaPolicyCreateDialog } from '../sla-policy-create-dialog'
+
+type MutationOptions = {
+ mutationFn: () => Promise<{ id: string }>
+ onSuccess?: (result: { id: string }) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ navigate: vi.fn(),
+ createSlaPolicyFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useSuspenseQuery: () => ({
+ data: [
+ { id: 'business_hours_active', name: 'Active calendar', archivedAt: null },
+ { id: 'business_hours_archived', name: 'Archived calendar', archivedAt: '2026-01-01' },
+ ],
+ }),
+ useMutation: (options: MutationOptions) => ({
+ isPending: false,
+ mutate: async () => {
+ try {
+ const result = await options.mutationFn()
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ useRouter: () => ({
+ navigate: mocks.navigate,
+ }),
+}))
+
+vi.mock('@/components/ui/dialog', () => ({
+ Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
+ open ? {children}
: null,
+ DialogContent: ({ children }: { children: ReactNode; className?: string }) => (
+
+ ),
+ DialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ DialogFooter: ({ children }: { children: ReactNode }) => ,
+ DialogHeader: ({ children }: { children: ReactNode }) => ,
+ DialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({
+ id,
+ type = 'text',
+ value,
+ onChange,
+ placeholder,
+ }: {
+ id?: string
+ type?: string
+ value?: string | number
+ onChange?: (event: React.ChangeEvent) => void
+ placeholder?: string
+ }) => ,
+}))
+
+vi.mock('@/components/ui/textarea', () => ({
+ Textarea: ({
+ id,
+ value,
+ onChange,
+ placeholder,
+ }: {
+ id?: string
+ value?: string
+ onChange?: (event: React.ChangeEvent) => void
+ placeholder?: string
+ rows?: number
+ }) => ,
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string; className?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/switch', () => ({
+ Switch: ({
+ id,
+ checked,
+ onCheckedChange,
+ }: {
+ id?: string
+ checked: boolean
+ onCheckedChange: (checked: boolean) => void
+ }) => (
+ onCheckedChange(!checked)} />
+ ),
+}))
+
+vi.mock('@/components/ui/select', () => ({
+ Select: ({
+ value,
+ onValueChange,
+ children,
+ }: {
+ value: string
+ onValueChange: (value: string) => void
+ children: ReactNode
+ }) => (
+ onValueChange(event.currentTarget.value)}>
+ {children}
+
+ ),
+ SelectContent: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectItem: ({ children, value }: { children: ReactNode; value: string }) => (
+ {children}
+ ),
+ SelectTrigger: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectValue: () => null,
+}))
+
+vi.mock('@/components/admin/shared/team-picker', () => ({
+ TeamPicker: ({ onValueChange }: { onValueChange: (id: string | null) => void }) => (
+ onValueChange('team_sla')}>
+ Pick SLA team
+
+ ),
+}))
+
+vi.mock('@/components/admin/shared/inbox-picker', () => ({
+ InboxPicker: ({ onValueChange }: { onValueChange: (id: string | null) => void }) => (
+ onValueChange('inbox_sla')}>
+ Pick SLA inbox
+
+ ),
+}))
+
+vi.mock('@/lib/server/functions/sla', () => ({
+ createSlaPolicyFn: mocks.createSlaPolicyFn,
+}))
+
+vi.mock('@/lib/client/queries/business-hours', () => ({
+ businessHoursQueries: {
+ list: (params: unknown) => ({ queryKey: ['business-hours', params] }),
+ },
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+function renderDialog(open = true) {
+ const onOpenChange = vi.fn()
+ const view = render( )
+ return { ...view, onOpenChange }
+}
+
+function selects() {
+ return screen.getAllByRole('combobox') as HTMLSelectElement[]
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.createSlaPolicyFn.mockResolvedValue({ id: 'sla_policy_new' })
+})
+
+describe('SlaPolicyCreateDialog', () => {
+ it('does not render content while closed', () => {
+ renderDialog(false)
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('validates team scope and creates a policy with selected filters', async () => {
+ const { onOpenChange } = renderDialog()
+
+ expect(screen.getByRole('heading', { name: 'New SLA policy' })).toBeInTheDocument()
+ expect(screen.getByText('All priorities')).toBeInTheDocument()
+ expect(screen.queryByText('Archived calendar')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Create policy' }))
+ expect(mocks.toastError).toHaveBeenCalledWith('Name is required')
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Premium SLA ' } })
+ fireEvent.change(screen.getByLabelText('Description'), {
+ target: { value: ' Premium customers only ' },
+ })
+ fireEvent.change(selects()[0], { target: { value: 'team' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Create policy' }))
+ expect(mocks.toastError).toHaveBeenCalledWith('Pick a team for team scope')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Pick SLA team' }))
+ fireEvent.change(screen.getByLabelText('Priority (lower runs first)'), {
+ target: { value: '' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'low' }))
+ fireEvent.click(screen.getByRole('button', { name: 'urgent' }))
+ fireEvent.click(screen.getByRole('button', { name: 'low' }))
+ fireEvent.change(selects()[1], { target: { value: 'business_hours_active' } })
+ fireEvent.click(screen.getByLabelText('Pending'))
+ fireEvent.click(screen.getByLabelText('On hold'))
+ fireEvent.click(screen.getByLabelText('Enabled'))
+ fireEvent.click(screen.getByRole('button', { name: 'Create policy' }))
+
+ await waitFor(() => {
+ expect(mocks.createSlaPolicyFn).toHaveBeenCalledWith({
+ data: {
+ name: 'Premium SLA',
+ description: 'Premium customers only',
+ priority: 0,
+ enabled: false,
+ scope: 'team',
+ scopeTeamId: 'team_sla',
+ scopeInboxId: null,
+ appliesToPriorities: ['urgent'],
+ businessHoursId: 'business_hours_active',
+ pauseOnPending: false,
+ pauseOnOnHold: false,
+ },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Policy created')
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['sla'] })
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ expect(mocks.navigate).toHaveBeenCalledWith({
+ to: '/admin/settings/sla/$policyId',
+ params: { policyId: 'sla_policy_new' },
+ })
+ expect(screen.getByLabelText('Name')).toHaveValue('')
+ })
+
+ it('validates inbox scope and reports create errors', async () => {
+ mocks.createSlaPolicyFn.mockRejectedValueOnce(new Error('Policy already exists'))
+ renderDialog()
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Inbox SLA' } })
+ fireEvent.change(selects()[0], { target: { value: 'inbox' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Create policy' }))
+ expect(mocks.toastError).toHaveBeenCalledWith('Pick an inbox for inbox scope')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Pick SLA inbox' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Create policy' }))
+
+ await waitFor(() => {
+ expect(mocks.createSlaPolicyFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ description: null,
+ scope: 'inbox',
+ scopeTeamId: null,
+ scopeInboxId: 'inbox_sla',
+ appliesToPriorities: undefined,
+ businessHoursId: null,
+ }),
+ })
+ )
+ })
+ expect(mocks.toastError).toHaveBeenCalledWith('Policy already exists')
+ })
+
+ it('cancels through onOpenChange', () => {
+ const { onOpenChange } = renderDialog()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+})
diff --git a/apps/web/src/components/admin/settings/sla/__tests__/sla-policy-overview-tab.test.tsx b/apps/web/src/components/admin/settings/sla/__tests__/sla-policy-overview-tab.test.tsx
new file mode 100644
index 000000000..38f3d91d8
--- /dev/null
+++ b/apps/web/src/components/admin/settings/sla/__tests__/sla-policy-overview-tab.test.tsx
@@ -0,0 +1,312 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { SlaPolicyOverviewTab } from '../sla-policy-overview-tab'
+
+type MutationOptions = {
+ mutationFn: () => Promise
+ onSuccess?: (result: unknown) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ updateSlaPolicyFn: vi.fn(),
+ archiveSlaPolicyFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useSuspenseQuery: () => ({
+ data: [
+ { id: 'business_hours_active', name: 'Active calendar', archivedAt: null },
+ { id: 'business_hours_archived', name: 'Archived calendar', archivedAt: '2026-01-01' },
+ ],
+ }),
+ useMutation: (options: MutationOptions) => ({
+ isPending: false,
+ mutate: async () => {
+ try {
+ const result = await options.mutationFn()
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ size?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({
+ id,
+ type = 'text',
+ value,
+ onChange,
+ }: {
+ id?: string
+ type?: string
+ value?: string | number
+ onChange?: (event: React.ChangeEvent) => void
+ }) => ,
+}))
+
+vi.mock('@/components/ui/textarea', () => ({
+ Textarea: ({
+ id,
+ value,
+ onChange,
+ }: {
+ id?: string
+ value?: string
+ onChange?: (event: React.ChangeEvent) => void
+ rows?: number
+ }) => ,
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string; className?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/switch', () => ({
+ Switch: ({
+ id,
+ checked,
+ onCheckedChange,
+ }: {
+ id?: string
+ checked: boolean
+ onCheckedChange: (checked: boolean) => void
+ }) => (
+ onCheckedChange(!checked)} />
+ ),
+}))
+
+vi.mock('@/components/ui/badge', () => ({
+ Badge: ({ children }: { children: ReactNode; variant?: string }) => {children} ,
+}))
+
+vi.mock('@/components/ui/select', () => ({
+ Select: ({
+ value,
+ onValueChange,
+ children,
+ }: {
+ value: string
+ onValueChange: (value: string) => void
+ children: ReactNode
+ }) => (
+ onValueChange(event.currentTarget.value)}>
+ {children}
+
+ ),
+ SelectContent: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectItem: ({ children, value }: { children: ReactNode; value: string }) => (
+ {children}
+ ),
+ SelectTrigger: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectValue: () => null,
+}))
+
+vi.mock('@/components/ui/alert-dialog', () => ({
+ AlertDialog: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}>,
+ AlertDialogContent: ({ children }: { children: ReactNode }) => ,
+ AlertDialogHeader: ({ children }: { children: ReactNode }) => ,
+ AlertDialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+ AlertDialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogFooter: ({ children }: { children: ReactNode }) => ,
+ AlertDialogCancel: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ AlertDialogAction: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/admin/shared/permission-gate', () => ({
+ PermissionGate: ({ children }: { children: ReactNode; permission: string }) => <>{children}>,
+}))
+
+vi.mock('@/lib/server/functions/sla', () => ({
+ updateSlaPolicyFn: mocks.updateSlaPolicyFn,
+ archiveSlaPolicyFn: mocks.archiveSlaPolicyFn,
+}))
+
+vi.mock('@/lib/client/queries/business-hours', () => ({
+ businessHoursQueries: {
+ list: (params: unknown) => ({ queryKey: ['business-hours', params] }),
+ },
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+function policy(overrides: Record = {}) {
+ return {
+ id: 'sla_policy_1',
+ name: 'Premium policy',
+ description: 'Initial description',
+ priority: 100,
+ enabled: true,
+ scope: 'team',
+ scopeTeamId: 'team_support',
+ scopeInboxId: null,
+ appliesToPriorities: ['low'],
+ businessHoursId: 'business_hours_archived',
+ pauseOnPending: true,
+ pauseOnOnHold: true,
+ archivedAt: null,
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.updateSlaPolicyFn.mockResolvedValue({ id: 'sla_policy_1' })
+ mocks.archiveSlaPolicyFn.mockResolvedValue({ id: 'sla_policy_1' })
+})
+
+describe('SlaPolicyOverviewTab', () => {
+ it('updates editable policy fields and keeps the current archived calendar selectable', async () => {
+ render( )
+
+ expect(screen.getByText('team')).toBeInTheDocument()
+ expect(screen.getByText('team: team_support')).toBeInTheDocument()
+ expect(screen.getByText('Archived calendar')).toBeInTheDocument()
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Updated SLA ' } })
+ fireEvent.change(screen.getByLabelText('Description'), { target: { value: ' ' } })
+ fireEvent.change(screen.getByLabelText('Priority (lower runs first)'), {
+ target: { value: '' },
+ })
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'business_hours_active' } })
+ fireEvent.click(screen.getByRole('button', { name: 'low' }))
+ fireEvent.click(screen.getByRole('button', { name: 'urgent' }))
+ fireEvent.click(screen.getByLabelText('Pending'))
+ fireEvent.click(screen.getByLabelText('On hold'))
+ fireEvent.click(screen.getByLabelText('Enabled'))
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+
+ await waitFor(() => {
+ expect(mocks.updateSlaPolicyFn).toHaveBeenCalledWith({
+ data: {
+ id: 'sla_policy_1',
+ name: 'Updated SLA',
+ description: null,
+ priority: 0,
+ enabled: false,
+ appliesToPriorities: ['urgent'],
+ businessHoursId: 'business_hours_active',
+ pauseOnPending: false,
+ pauseOnOnHold: false,
+ },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Policy updated')
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['sla'] })
+ })
+
+ it('sends all-priorities and 24-7 calendar payloads when filters are cleared', async () => {
+ render(
+
+ )
+
+ expect(screen.getByText('All priorities')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+
+ await waitFor(() => {
+ expect(mocks.updateSlaPolicyFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ appliesToPriorities: undefined,
+ businessHoursId: null,
+ }),
+ })
+ )
+ })
+ })
+
+ it('archives active policies and hides archive controls for archived policies', async () => {
+ const { rerender } = render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Archive' }))
+
+ await waitFor(() => {
+ expect(mocks.archiveSlaPolicyFn).toHaveBeenCalledWith({ data: { id: 'sla_policy_1' } })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Policy archived')
+
+ rerender(
+
+ )
+ expect(screen.queryByText('Archive this SLA policy?')).not.toBeInTheDocument()
+ })
+
+ it('resets local form state on policy changes and reports mutation errors', async () => {
+ mocks.updateSlaPolicyFn.mockRejectedValueOnce(new Error('Update failed'))
+ mocks.archiveSlaPolicyFn.mockRejectedValueOnce(new Error('Archive failed'))
+ const { rerender } = render( )
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Unsaved' } })
+ rerender(
+
+ )
+ expect(screen.getByLabelText('Name')).toHaveValue('Replacement policy')
+ expect(screen.getByText('inbox: inbox_support')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Update failed')
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Archive' }))
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Archive failed')
+ })
+ })
+})
diff --git a/apps/web/src/components/admin/settings/sla/__tests__/sla-tick-trigger.test.tsx b/apps/web/src/components/admin/settings/sla/__tests__/sla-tick-trigger.test.tsx
new file mode 100644
index 000000000..7d1f29a91
--- /dev/null
+++ b/apps/web/src/components/admin/settings/sla/__tests__/sla-tick-trigger.test.tsx
@@ -0,0 +1,114 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { SlaTickTrigger } from '../sla-tick-trigger'
+
+type MutationOptions = {
+ mutationFn: () => Promise
+ onSuccess?: (result: unknown) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ runSlaTickFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+ isPending: false,
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useMutation: (options: MutationOptions) => ({
+ isPending: mocks.isPending,
+ mutate: async () => {
+ try {
+ const result = await options.mutationFn()
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@/lib/server/functions/sla', () => ({
+ runSlaTickFn: mocks.runSlaTickFn,
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+vi.mock('@/components/admin/shared/permission-gate', () => ({
+ PermissionGate: ({ children }: { children: ReactNode; permission: string }) => <>{children}>,
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ variant?: string
+ size?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@heroicons/react/24/outline', () => ({
+ BoltIcon: () => bolt ,
+}))
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.isPending = false
+ mocks.runSlaTickFn.mockResolvedValue({ processed: 3, fired: 2 })
+})
+
+describe('SlaTickTrigger', () => {
+ it('runs the SLA tick and reports processed/fired counts', async () => {
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Run tick now' }))
+
+ await waitFor(() => {
+ expect(mocks.runSlaTickFn).toHaveBeenCalledWith({ data: {} })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Tick ran — processed 3, fired 2')
+ })
+
+ it('uses zero fallback counts and reports failures', async () => {
+ const { rerender } = render( )
+
+ mocks.runSlaTickFn.mockResolvedValueOnce(null)
+ fireEvent.click(screen.getByRole('button', { name: 'Run tick now' }))
+
+ await waitFor(() => {
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Tick ran — processed 0, fired 0')
+ })
+
+ mocks.runSlaTickFn.mockRejectedValueOnce(new Error('Tick failed'))
+ rerender( )
+ fireEvent.click(screen.getByRole('button', { name: 'Run tick now' }))
+
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Tick failed')
+ })
+ })
+
+ it('disables the trigger while pending', () => {
+ mocks.isPending = true
+ render( )
+
+ expect(screen.getByRole('button', { name: 'Run tick now' })).toBeDisabled()
+ })
+})
diff --git a/apps/web/src/components/admin/settings/sla/business-hours-dialog.tsx b/apps/web/src/components/admin/settings/sla/business-hours-dialog.tsx
new file mode 100644
index 000000000..ae94d36be
--- /dev/null
+++ b/apps/web/src/components/admin/settings/sla/business-hours-dialog.tsx
@@ -0,0 +1,333 @@
+/**
+ * Business hours create/edit Dialog. Holds the week-grid editor (per-weekday
+ * `[start,end]` ranges) and a holiday list. Reused for both create and edit
+ * via the optional `row` prop.
+ */
+import { useEffect, useState } from 'react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import type { BusinessHoursId } from '@quackback/ids'
+import type { BusinessHours } from '@/lib/shared/db-types'
+import { createBusinessHoursFn, updateBusinessHoursFn } from '@/lib/server/functions/sla'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { TrashIcon, PlusIcon } from '@heroicons/react/24/outline'
+
+interface Range {
+ start: string
+ end: string
+}
+type DayKey = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun'
+type Schedule = Record
+interface Holiday {
+ date: string
+ label?: string
+}
+
+const DAYS: { key: DayKey; label: string }[] = [
+ { key: 'mon', label: 'Mon' },
+ { key: 'tue', label: 'Tue' },
+ { key: 'wed', label: 'Wed' },
+ { key: 'thu', label: 'Thu' },
+ { key: 'fri', label: 'Fri' },
+ { key: 'sat', label: 'Sat' },
+ { key: 'sun', label: 'Sun' },
+]
+
+const EMPTY_SCHEDULE: Schedule = {
+ mon: [{ start: '09:00', end: '17:00' }],
+ tue: [{ start: '09:00', end: '17:00' }],
+ wed: [{ start: '09:00', end: '17:00' }],
+ thu: [{ start: '09:00', end: '17:00' }],
+ fri: [{ start: '09:00', end: '17:00' }],
+ sat: [],
+ sun: [],
+}
+
+interface Props {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ row?: BusinessHours
+}
+
+export function BusinessHoursDialog({ open, onOpenChange, row }: Props) {
+ const qc = useQueryClient()
+ const isEdit = Boolean(row)
+
+ const [name, setName] = useState('')
+ const [timezone, setTimezone] = useState('UTC')
+ const [schedule, setSchedule] = useState(EMPTY_SCHEDULE)
+ const [holidays, setHolidays] = useState([])
+
+ useEffect(() => {
+ if (!open) return
+ if (row) {
+ setName(row.name)
+ setTimezone(row.timezone)
+ const sRaw = row.schedule as Partial | null
+ setSchedule({
+ mon: sRaw?.mon ?? [],
+ tue: sRaw?.tue ?? [],
+ wed: sRaw?.wed ?? [],
+ thu: sRaw?.thu ?? [],
+ fri: sRaw?.fri ?? [],
+ sat: sRaw?.sat ?? [],
+ sun: sRaw?.sun ?? [],
+ })
+ setHolidays(((row.holidays as Holiday[] | null) ?? []).map((h) => ({ ...h })))
+ } else {
+ setName('')
+ setTimezone('UTC')
+ setSchedule(JSON.parse(JSON.stringify(EMPTY_SCHEDULE)))
+ setHolidays([])
+ }
+ }, [open, row])
+
+ const updateRange = (day: DayKey, idx: number, patch: Partial) => {
+ setSchedule((prev) => ({
+ ...prev,
+ [day]: prev[day].map((r, i) => (i === idx ? { ...r, ...patch } : r)),
+ }))
+ }
+ const addRange = (day: DayKey) => {
+ setSchedule((prev) => ({
+ ...prev,
+ [day]: [...prev[day], { start: '09:00', end: '17:00' }],
+ }))
+ }
+ const removeRange = (day: DayKey, idx: number) => {
+ setSchedule((prev) => ({
+ ...prev,
+ [day]: prev[day].filter((_, i) => i !== idx),
+ }))
+ }
+
+ const validate = (): string | null => {
+ if (!name.trim()) return 'Name is required'
+ if (!timezone.trim()) return 'Timezone is required'
+ const timeRe = /^([01]\d|2[0-3]):[0-5]\d$/
+ for (const { key, label } of DAYS) {
+ for (const r of schedule[key]) {
+ if (!timeRe.test(r.start) || !timeRe.test(r.end)) {
+ return `${label}: invalid time format (use HH:MM)`
+ }
+ if (r.start >= r.end) return `${label}: range start must be before end`
+ }
+ }
+ const dateRe = /^\d{4}-\d{2}-\d{2}$/
+ for (const h of holidays) {
+ if (!dateRe.test(h.date)) return 'Holiday date must be YYYY-MM-DD'
+ }
+ return null
+ }
+
+ const createMutation = useMutation({
+ mutationFn: () =>
+ createBusinessHoursFn({
+ data: {
+ name: name.trim(),
+ timezone: timezone.trim(),
+ schedule,
+ holidays: holidays.length > 0 ? holidays : undefined,
+ },
+ }),
+ onSuccess: () => {
+ toast.success('Calendar created')
+ qc.invalidateQueries({ queryKey: ['business-hours'] })
+ onOpenChange(false)
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const updateMutation = useMutation({
+ mutationFn: () =>
+ updateBusinessHoursFn({
+ data: {
+ id: row!.id as BusinessHoursId,
+ name: name.trim(),
+ timezone: timezone.trim(),
+ schedule,
+ holidays,
+ },
+ }),
+ onSuccess: () => {
+ toast.success('Calendar updated')
+ qc.invalidateQueries({ queryKey: ['business-hours'] })
+ onOpenChange(false)
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const handleSave = () => {
+ const err = validate()
+ if (err) {
+ toast.error(err)
+ return
+ }
+ if (isEdit) updateMutation.mutate()
+ else createMutation.mutate()
+ }
+
+ const isPending = createMutation.isPending || updateMutation.isPending
+
+ return (
+
+
+
+ {isEdit ? 'Edit calendar' : 'New business-hours calendar'}
+
+ Define working hours per weekday. SLA policies use this to compute due times.
+
+
+
+
+
+
+
+
Schedule
+
+ {DAYS.map(({ key, label }) => (
+
+
{label}
+
+
addRange(key)}
+ >
+
+ Range
+
+
+ ))}
+
+
+
+
+
+
Holidays
+
setHolidays((prev) => [...prev, { date: '', label: '' }])}
+ >
+
+ Add holiday
+
+
+ {holidays.length === 0 ? (
+
No holidays.
+ ) : (
+
+ )}
+
+
+
+
+ onOpenChange(false)} disabled={isPending}>
+ Cancel
+
+
+ {isEdit ? 'Save changes' : 'Create'}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/sla/business-hours-list.tsx b/apps/web/src/components/admin/settings/sla/business-hours-list.tsx
new file mode 100644
index 000000000..6f1dc33b5
--- /dev/null
+++ b/apps/web/src/components/admin/settings/sla/business-hours-list.tsx
@@ -0,0 +1,166 @@
+/**
+ * Business-hours list — table with name/timezone/holiday count + edit/archive
+ * actions. "Show archived" toggle. Edit opens the shared dialog with prefill.
+ */
+import { useState } from 'react'
+import { useSuspenseQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import type { BusinessHoursId } from '@quackback/ids'
+import type { BusinessHours } from '@/lib/shared/db-types'
+import { businessHoursQueries } from '@/lib/client/queries/business-hours'
+import { archiveBusinessHoursFn } from '@/lib/server/functions/sla'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Switch } from '@/components/ui/switch'
+import { Label } from '@/components/ui/label'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog'
+import { PencilSquareIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline'
+import { PermissionGate } from '@/components/admin/shared/permission-gate'
+import { PERMISSIONS } from '@/lib/server/domains/authz'
+import { BusinessHoursDialog } from './business-hours-dialog'
+
+export function BusinessHoursList() {
+ const qc = useQueryClient()
+ const [showArchived, setShowArchived] = useState(false)
+ const [editingRow, setEditingRow] = useState(null)
+ const { data: rows } = useSuspenseQuery(businessHoursQueries.list({ includeArchived: true }))
+
+ const archiveMutation = useMutation({
+ mutationFn: (id: BusinessHoursId) => archiveBusinessHoursFn({ data: { id } }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['business-hours'] })
+ toast.success('Calendar archived')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const visible = rows.filter((r) => showArchived || !r.archivedAt)
+
+ return (
+
+
+
+
+ Show archived
+
+
+
+
+
+
+
+ Name
+ Timezone
+ Holidays
+ Status
+
+
+
+
+ {visible.length === 0 ? (
+
+
+ No calendars yet.
+
+
+ ) : (
+ visible.map((row) => {
+ const holidays = (row.holidays as unknown[] | null) ?? []
+ return (
+
+ {row.name}
+ {row.timezone}
+ {holidays.length}
+
+ {row.archivedAt ? (
+
+ Archived
+
+ ) : (
+ Active
+ )}
+
+
+
+
+
setEditingRow(row)}
+ aria-label="Edit calendar"
+ >
+
+
+ {!row.archivedAt && (
+
+
+
+
+
+
+
+
+ Archive this calendar?
+
+ SLA policies referencing it will continue to work, but it
+ won't appear in pickers for new policies.
+
+
+
+ Cancel
+
+ archiveMutation.mutate(row.id as BusinessHoursId)
+ }
+ >
+ Archive
+
+
+
+
+ )}
+
+
+
+
+ )
+ })
+ )}
+
+
+
+
+
{
+ if (!open) setEditingRow(null)
+ }}
+ row={editingRow ?? undefined}
+ />
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/sla/sla-escalation-dialog.tsx b/apps/web/src/components/admin/settings/sla/sla-escalation-dialog.tsx
new file mode 100644
index 000000000..805309a3e
--- /dev/null
+++ b/apps/web/src/components/admin/settings/sla/sla-escalation-dialog.tsx
@@ -0,0 +1,298 @@
+/**
+ * SLA escalation create/edit dialog. Recipient sub-form is conditional on
+ * recipientType. Channels is a multi-toggle. leadMinutes is signed.
+ */
+import { useEffect, useState } from 'react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import type { SlaPolicyId, EscalationRuleId, TeamId, PrincipalId } from '@quackback/ids'
+import type { EscalationRule } from '@/lib/shared/db-types'
+import { createEscalationRuleFn, updateEscalationRuleFn } from '@/lib/server/functions/sla'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { TeamPicker } from '@/components/admin/shared/team-picker'
+import { PrincipalPicker } from '@/components/admin/shared/principal-picker'
+import { cn } from '@/lib/shared/utils'
+
+const TARGET_KINDS = ['first_response', 'next_response', 'resolution'] as const
+const RECIPIENT_TYPES = ['assignee', 'team', 'principals', 'inbox_members'] as const
+const CHANNELS = ['in_app', 'email', 'webhook'] as const
+type TargetKind = (typeof TARGET_KINDS)[number]
+type RecipientType = (typeof RECIPIENT_TYPES)[number]
+type Channel = (typeof CHANNELS)[number]
+
+interface Props {
+ policyId: SlaPolicyId
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ rule?: EscalationRule
+}
+
+export function SlaEscalationDialog({ policyId, open, onOpenChange, rule }: Props) {
+ const qc = useQueryClient()
+ const isEdit = Boolean(rule)
+
+ const [name, setName] = useState('')
+ const [leadMinutes, setLeadMinutes] = useState(0)
+ const [targetKind, setTargetKind] = useState('first_response')
+ const [recipientType, setRecipientType] = useState('assignee')
+ const [recipientTeamId, setRecipientTeamId] = useState(null)
+ const [recipientPrincipalIds, setRecipientPrincipalIds] = useState([])
+ const [channels, setChannels] = useState(['in_app'])
+ const [enabled, setEnabled] = useState(true)
+
+ useEffect(() => {
+ if (!open) return
+ if (rule) {
+ setName(rule.name)
+ setLeadMinutes(rule.leadMinutes)
+ setTargetKind(rule.targetKind as TargetKind)
+ setRecipientType(rule.recipientType as RecipientType)
+ setRecipientTeamId((rule.recipientTeamId as TeamId | null) ?? null)
+ setRecipientPrincipalIds(
+ ((rule.recipientPrincipalIds as string[] | null) ?? []) as PrincipalId[]
+ )
+ setChannels(((rule.channels as Channel[] | null) ?? ['in_app']) as Channel[])
+ setEnabled(rule.enabled)
+ } else {
+ setName('')
+ setLeadMinutes(0)
+ setTargetKind('first_response')
+ setRecipientType('assignee')
+ setRecipientTeamId(null)
+ setRecipientPrincipalIds([])
+ setChannels(['in_app'])
+ setEnabled(true)
+ }
+ }, [open, rule])
+
+ const toggleChannel = (c: Channel) => {
+ setChannels((prev) => (prev.includes(c) ? prev.filter((x) => x !== c) : [...prev, c]))
+ }
+
+ const validate = (): string | null => {
+ if (!name.trim()) return 'Name is required'
+ if (recipientType === 'team' && !recipientTeamId) return 'Pick a team'
+ if (recipientType === 'principals' && recipientPrincipalIds.length === 0)
+ return 'Pick at least one principal'
+ if (channels.length === 0) return 'Pick at least one channel'
+ return null
+ }
+
+ const createMutation = useMutation({
+ mutationFn: () =>
+ createEscalationRuleFn({
+ data: {
+ policyId,
+ name: name.trim(),
+ leadMinutes,
+ targetKind,
+ recipientType,
+ recipientTeamId: recipientType === 'team' ? recipientTeamId : null,
+ recipientPrincipalIds: recipientType === 'principals' ? recipientPrincipalIds : undefined,
+ channels,
+ enabled,
+ },
+ }),
+ onSuccess: () => {
+ toast.success('Escalation created')
+ qc.invalidateQueries({ queryKey: ['sla', 'escalations', policyId] })
+ onOpenChange(false)
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const updateMutation = useMutation({
+ mutationFn: () =>
+ updateEscalationRuleFn({
+ data: {
+ id: rule!.id as EscalationRuleId,
+ name: name.trim(),
+ leadMinutes,
+ targetKind,
+ recipientType,
+ recipientTeamId: recipientType === 'team' ? recipientTeamId : null,
+ recipientPrincipalIds: recipientType === 'principals' ? recipientPrincipalIds : undefined,
+ channels,
+ enabled,
+ },
+ }),
+ onSuccess: () => {
+ toast.success('Escalation updated')
+ qc.invalidateQueries({ queryKey: ['sla', 'escalations', policyId] })
+ onOpenChange(false)
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const handleSave = () => {
+ const err = validate()
+ if (err) {
+ toast.error(err)
+ return
+ }
+ if (isEdit) updateMutation.mutate()
+ else createMutation.mutate()
+ }
+
+ const isPending = createMutation.isPending || updateMutation.isPending
+
+ return (
+
+
+
+ {isEdit ? 'Edit escalation' : 'New escalation'}
+
+ Fires relative to the chosen target's due time. Positive lead = before breach,
+ negative = after.
+
+
+
+
+
+ Name
+ setName(e.target.value)}
+ placeholder="e.g. Notify lead 15m before breach"
+ />
+
+
+
+
+ Target
+ setTargetKind(v as TargetKind)}>
+
+
+
+
+ {TARGET_KINDS.map((k) => (
+
+ {k}
+
+ ))}
+
+
+
+
+
Lead minutes (signed)
+
setLeadMinutes(Number(e.target.value) || 0)}
+ />
+
+ {leadMinutes > 0
+ ? `Fires ${leadMinutes}m before breach`
+ : leadMinutes < 0
+ ? `Fires ${Math.abs(leadMinutes)}m after breach`
+ : 'Fires at breach'}
+
+
+
+
+
+ Recipient type
+ setRecipientType(v as RecipientType)}
+ >
+
+
+
+
+ {RECIPIENT_TYPES.map((t) => (
+
+ {t}
+
+ ))}
+
+
+
+
+ {recipientType === 'team' && (
+
+ Team
+
+
+ )}
+ {recipientType === 'principals' && (
+
+ )}
+
+
+
Channels
+
+ {CHANNELS.map((c) => {
+ const checked = channels.includes(c)
+ return (
+ toggleChannel(c)}
+ className={cn(
+ 'text-[11px] rounded border px-2 py-0.5',
+ checked
+ ? 'bg-primary text-primary-foreground border-primary'
+ : 'bg-background border-border/60 text-muted-foreground hover:bg-muted'
+ )}
+ >
+ {c}
+
+ )
+ })}
+
+
+
+
+
+
+ Enabled
+
+
+
+
+
+ onOpenChange(false)} disabled={isPending}>
+ Cancel
+
+
+ {isEdit ? 'Save changes' : 'Create'}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/sla/sla-escalations-tab.tsx b/apps/web/src/components/admin/settings/sla/sla-escalations-tab.tsx
new file mode 100644
index 000000000..74ac1ddaf
--- /dev/null
+++ b/apps/web/src/components/admin/settings/sla/sla-escalations-tab.tsx
@@ -0,0 +1,213 @@
+/**
+ * SLA escalations tab — list of escalation rules with inline enable toggle,
+ * edit + delete buttons. New/edit opens the shared escalation dialog.
+ */
+import { useState } from 'react'
+import { useSuspenseQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import type { SlaPolicyId, EscalationRuleId } from '@quackback/ids'
+import type { EscalationRule } from '@/lib/shared/db-types'
+import { slaQueries } from '@/lib/client/queries/sla'
+import { updateEscalationRuleFn, deleteEscalationRuleFn } from '@/lib/server/functions/sla'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Switch } from '@/components/ui/switch'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog'
+import { PencilSquareIcon, TrashIcon, PlusIcon } from '@heroicons/react/24/outline'
+import { PermissionGate } from '@/components/admin/shared/permission-gate'
+import { PERMISSIONS } from '@/lib/server/domains/authz'
+import { SlaEscalationDialog } from './sla-escalation-dialog'
+
+function formatLead(lead: number): string {
+ if (lead > 0) return `${lead}m before breach`
+ if (lead < 0) return `${Math.abs(lead)}m after breach`
+ return 'At breach'
+}
+
+export function SlaEscalationsTab({ policyId }: { policyId: SlaPolicyId }) {
+ const qc = useQueryClient()
+ const { data: rules } = useSuspenseQuery(slaQueries.escalations(policyId))
+ const [createOpen, setCreateOpen] = useState(false)
+ const [editingRule, setEditingRule] = useState(null)
+
+ const toggleEnabled = useMutation({
+ mutationFn: (vars: { id: EscalationRuleId; enabled: boolean }) =>
+ updateEscalationRuleFn({ data: { id: vars.id, enabled: vars.enabled } }),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['sla', 'escalations', policyId] }),
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: (id: EscalationRuleId) => deleteEscalationRuleFn({ data: { id } }),
+ onSuccess: () => {
+ toast.success('Escalation rule deleted')
+ qc.invalidateQueries({ queryKey: ['sla', 'escalations', policyId] })
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ return (
+
+
+
+ Escalations fire relative to a target's due time and notify recipients on the chosen
+ channels.
+
+
+ setCreateOpen(true)}>
+
+ New escalation
+
+
+
+
+
+
+
+
+ Name
+ Target
+ When
+ Recipient
+ Channels
+ Enabled
+
+
+
+
+ {rules.length === 0 ? (
+
+
+ No escalation rules yet.
+
+
+ ) : (
+ rules.map((r) => {
+ const channels = (r.channels as string[] | null) ?? []
+ const principalIds = (r.recipientPrincipalIds as string[] | null) ?? []
+ let recipientLabel: string = r.recipientType
+ if (r.recipientType === 'team' && r.recipientTeamId) {
+ recipientLabel = `team: ${String(r.recipientTeamId)}`
+ } else if (r.recipientType === 'principals') {
+ recipientLabel = `${principalIds.length} principal(s)`
+ }
+ return (
+
+ {r.name}
+
+
+ {r.targetKind}
+
+
+ {formatLead(r.leadMinutes)}
+ {recipientLabel}
+
+
+ {channels.map((c) => (
+
+ {c}
+
+ ))}
+
+
+
+
+ {r.enabled ? 'On' : 'Off'}
+
+ }
+ >
+
+ toggleEnabled.mutate({
+ id: r.id as EscalationRuleId,
+ enabled: v,
+ })
+ }
+ />
+
+
+
+
+
+
setEditingRule(r)}
+ aria-label="Edit escalation"
+ >
+
+
+
+
+
+
+
+
+
+
+ Delete this escalation?
+
+ "{r.name}" will stop firing on this policy.
+
+
+
+ Cancel
+ deleteMutation.mutate(r.id as EscalationRuleId)}
+ >
+ Delete
+
+
+
+
+
+
+
+
+ )
+ })
+ )}
+
+
+
+
+
+
{
+ if (!o) setEditingRule(null)
+ }}
+ rule={editingRule ?? undefined}
+ />
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/sla/sla-policy-create-dialog.tsx b/apps/web/src/components/admin/settings/sla/sla-policy-create-dialog.tsx
new file mode 100644
index 000000000..a1cd6596f
--- /dev/null
+++ b/apps/web/src/components/admin/settings/sla/sla-policy-create-dialog.tsx
@@ -0,0 +1,305 @@
+/**
+ * SLA policy create dialog. Scope is creation-only (backend update doesn't
+ * accept scope changes). On success navigates to the detail page so the user
+ * can configure targets + escalations.
+ */
+import { useState } from 'react'
+import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
+import { useRouter } from '@tanstack/react-router'
+import { toast } from 'sonner'
+import type { TeamId, InboxId, BusinessHoursId, SlaPolicyId } from '@quackback/ids'
+import { createSlaPolicyFn } from '@/lib/server/functions/sla'
+import { businessHoursQueries } from '@/lib/client/queries/business-hours'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Switch } from '@/components/ui/switch'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { TeamPicker } from '@/components/admin/shared/team-picker'
+import { InboxPicker } from '@/components/admin/shared/inbox-picker'
+import { cn } from '@/lib/shared/utils'
+
+type Scope = 'workspace' | 'team' | 'inbox'
+const PRIORITIES = ['low', 'normal', 'high', 'urgent'] as const
+
+interface Props {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function SlaPolicyCreateDialog({ open, onOpenChange }: Props) {
+ const qc = useQueryClient()
+ const router = useRouter()
+ const { data: calendars } = useSuspenseQuery(businessHoursQueries.list({}))
+
+ const [name, setName] = useState('')
+ const [description, setDescription] = useState('')
+ const [scope, setScope] = useState('workspace')
+ const [scopeTeamId, setScopeTeamId] = useState(null)
+ const [scopeInboxId, setScopeInboxId] = useState(null)
+ const [appliesToPriorities, setAppliesToPriorities] = useState([])
+ const [businessHoursId, setBusinessHoursId] = useState('')
+ const [pauseOnPending, setPauseOnPending] = useState(true)
+ const [pauseOnOnHold, setPauseOnOnHold] = useState(true)
+ const [enabled, setEnabled] = useState(true)
+ const [priority, setPriority] = useState(100)
+
+ const reset = () => {
+ setName('')
+ setDescription('')
+ setScope('workspace')
+ setScopeTeamId(null)
+ setScopeInboxId(null)
+ setAppliesToPriorities([])
+ setBusinessHoursId('')
+ setPauseOnPending(true)
+ setPauseOnOnHold(true)
+ setEnabled(true)
+ setPriority(100)
+ }
+
+ const togglePriority = (p: string) => {
+ setAppliesToPriorities((prev) =>
+ prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p]
+ )
+ }
+
+ const createMutation = useMutation({
+ mutationFn: () =>
+ createSlaPolicyFn({
+ data: {
+ name: name.trim(),
+ description: description.trim() || null,
+ priority,
+ enabled,
+ scope,
+ scopeTeamId: scope === 'team' ? scopeTeamId : null,
+ scopeInboxId: scope === 'inbox' ? scopeInboxId : null,
+ appliesToPriorities:
+ appliesToPriorities.length > 0
+ ? (appliesToPriorities as ('low' | 'normal' | 'high' | 'urgent')[])
+ : undefined,
+ businessHoursId: businessHoursId ? (businessHoursId as BusinessHoursId) : null,
+ pauseOnPending,
+ pauseOnOnHold,
+ },
+ }),
+ onSuccess: (policy) => {
+ toast.success('Policy created')
+ qc.invalidateQueries({ queryKey: ['sla'] })
+ onOpenChange(false)
+ reset()
+ router.navigate({
+ to: '/admin/settings/sla/$policyId',
+ params: { policyId: policy.id as SlaPolicyId },
+ })
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const handleSave = () => {
+ if (!name.trim()) {
+ toast.error('Name is required')
+ return
+ }
+ if (scope === 'team' && !scopeTeamId) {
+ toast.error('Pick a team for team scope')
+ return
+ }
+ if (scope === 'inbox' && !scopeInboxId) {
+ toast.error('Pick an inbox for inbox scope')
+ return
+ }
+ createMutation.mutate()
+ }
+
+ return (
+ {
+ if (!o) reset()
+ onOpenChange(o)
+ }}
+ >
+
+
+ New SLA policy
+
+ Scope determines which tickets the policy applies to. Targets and escalations are
+ configured after creation.
+
+
+
+
+
+ Name
+ setName(e.target.value)}
+ placeholder="e.g. Premium customer SLA"
+ />
+
+
+ Description
+ setDescription(e.target.value)}
+ rows={2}
+ placeholder="Optional"
+ />
+
+
+
+
+ Scope
+ setScope(v as Scope)}>
+
+
+
+
+ Workspace
+ Team
+ Inbox
+
+
+
+
+ Priority (lower runs first)
+ setPriority(Number(e.target.value) || 0)}
+ />
+
+
+
+ {scope === 'team' && (
+
+ Team
+
+
+ )}
+ {scope === 'inbox' && (
+
+ Inbox
+
+
+ )}
+
+
+
Applies to priorities
+
+ {PRIORITIES.map((p) => {
+ const checked = appliesToPriorities.includes(p)
+ return (
+ togglePriority(p)}
+ className={cn(
+ 'text-[11px] rounded border px-2 py-0.5',
+ checked
+ ? 'bg-primary text-primary-foreground border-primary'
+ : 'bg-background border-border/60 text-muted-foreground hover:bg-muted'
+ )}
+ >
+ {p}
+
+ )
+ })}
+ {appliesToPriorities.length === 0 && (
+
+ All priorities
+
+ )}
+
+
+
+
+ Business hours
+ setBusinessHoursId(v === '__none' ? '' : v)}
+ >
+
+
+
+
+ None (24/7)
+ {calendars
+ .filter((c) => !c.archivedAt)
+ .map((c) => (
+
+ {c.name}
+
+ ))}
+
+
+
+
+
+
Pause clocks when ticket is
+
+
+
+ Pending
+
+
+
+ On hold
+
+
+
+
+
+
+
+ Enabled
+
+
+
+
+
+ onOpenChange(false)}
+ disabled={createMutation.isPending}
+ >
+ Cancel
+
+
+ Create policy
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/sla/sla-policy-list.tsx b/apps/web/src/components/admin/settings/sla/sla-policy-list.tsx
new file mode 100644
index 000000000..300587a0e
--- /dev/null
+++ b/apps/web/src/components/admin/settings/sla/sla-policy-list.tsx
@@ -0,0 +1,148 @@
+/**
+ * SLA policy list. Each row links to detail. Inline enabled toggle.
+ */
+import { useState } from 'react'
+import { useSuspenseQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { Link } from '@tanstack/react-router'
+import { toast } from 'sonner'
+import type { SlaPolicyId } from '@quackback/ids'
+import { slaQueries } from '@/lib/client/queries/sla'
+import { businessHoursQueries } from '@/lib/client/queries/business-hours'
+import { updateSlaPolicyFn } from '@/lib/server/functions/sla'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Switch } from '@/components/ui/switch'
+import { Label } from '@/components/ui/label'
+import { Badge } from '@/components/ui/badge'
+import { PermissionGate } from '@/components/admin/shared/permission-gate'
+import { PERMISSIONS } from '@/lib/server/domains/authz'
+
+export function SlaPolicyList() {
+ const qc = useQueryClient()
+ const [showArchived, setShowArchived] = useState(false)
+ const { data: policies } = useSuspenseQuery(slaQueries.policies({ includeArchived: true }))
+ const { data: calendars } = useSuspenseQuery(businessHoursQueries.list({}))
+
+ const calendarLabel = (id: string | null): string => {
+ if (!id) return '—'
+ const c = calendars.find((x) => x.id === id)
+ return c?.name ?? '—'
+ }
+
+ const toggleEnabled = useMutation({
+ mutationFn: (vars: { id: SlaPolicyId; enabled: boolean }) =>
+ updateSlaPolicyFn({ data: { id: vars.id, enabled: vars.enabled } }),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['sla'] }),
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const visible = policies.filter((p) => showArchived || !p.archivedAt)
+
+ return (
+
+
+
+
+ Show archived
+
+
+
+
+
+
+
+ Name
+ Scope
+ Priorities
+ Business hours
+ Enabled
+ Status
+
+
+
+ {visible.length === 0 ? (
+
+
+ No SLA policies yet.
+
+
+ ) : (
+ visible.map((p) => {
+ const priorities = (p.appliesToPriorities as string[] | null) ?? []
+ return (
+
+
+
+ {p.name}
+
+
+
+
+ {p.scope}
+
+
+
+
+ {priorities.length === 0 ? (
+ All
+ ) : (
+ priorities.map((pr) => (
+
+ {pr}
+
+ ))
+ )}
+
+
+
+ {calendarLabel(p.businessHoursId as string | null)}
+
+
+
+ {p.enabled ? 'On' : 'Off'}
+
+ }
+ >
+
+ toggleEnabled.mutate({
+ id: p.id as SlaPolicyId,
+ enabled: v,
+ })
+ }
+ />
+
+
+
+ {p.archivedAt ? (
+
+ Archived
+
+ ) : (
+ Active
+ )}
+
+
+ )
+ })
+ )}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/sla/sla-policy-overview-tab.tsx b/apps/web/src/components/admin/settings/sla/sla-policy-overview-tab.tsx
new file mode 100644
index 000000000..6be1c3491
--- /dev/null
+++ b/apps/web/src/components/admin/settings/sla/sla-policy-overview-tab.tsx
@@ -0,0 +1,264 @@
+/**
+ * SLA policy overview tab — editable form. Scope is read-only (backend update
+ * doesn't accept scope changes).
+ */
+import { useEffect, useState } from 'react'
+import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import type { BusinessHoursId, SlaPolicyId } from '@quackback/ids'
+import type { SlaPolicy } from '@/lib/shared/db-types'
+import { updateSlaPolicyFn, archiveSlaPolicyFn } from '@/lib/server/functions/sla'
+import { businessHoursQueries } from '@/lib/client/queries/business-hours'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Switch } from '@/components/ui/switch'
+import { Badge } from '@/components/ui/badge'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog'
+import { PermissionGate } from '@/components/admin/shared/permission-gate'
+import { PERMISSIONS } from '@/lib/server/domains/authz'
+import { cn } from '@/lib/shared/utils'
+
+const PRIORITIES = ['low', 'normal', 'high', 'urgent'] as const
+
+export function SlaPolicyOverviewTab({ policy }: { policy: SlaPolicy }) {
+ const qc = useQueryClient()
+ const { data: calendars } = useSuspenseQuery(businessHoursQueries.list({}))
+
+ const [name, setName] = useState(policy.name)
+ const [description, setDescription] = useState(policy.description ?? '')
+ const [priority, setPriority] = useState(policy.priority)
+ const [enabled, setEnabled] = useState(policy.enabled)
+ const [appliesToPriorities, setAppliesToPriorities] = useState(
+ (policy.appliesToPriorities as string[] | null) ?? []
+ )
+ const [businessHoursId, setBusinessHoursId] = useState(
+ (policy.businessHoursId as string | null) ?? ''
+ )
+ const [pauseOnPending, setPauseOnPending] = useState(policy.pauseOnPending)
+ const [pauseOnOnHold, setPauseOnOnHold] = useState(policy.pauseOnOnHold)
+
+ useEffect(() => {
+ setName(policy.name)
+ setDescription(policy.description ?? '')
+ setPriority(policy.priority)
+ setEnabled(policy.enabled)
+ setAppliesToPriorities((policy.appliesToPriorities as string[] | null) ?? [])
+ setBusinessHoursId((policy.businessHoursId as string | null) ?? '')
+ setPauseOnPending(policy.pauseOnPending)
+ setPauseOnOnHold(policy.pauseOnOnHold)
+ }, [policy])
+
+ const togglePriority = (p: string) => {
+ setAppliesToPriorities((prev) =>
+ prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p]
+ )
+ }
+
+ const saveMutation = useMutation({
+ mutationFn: () =>
+ updateSlaPolicyFn({
+ data: {
+ id: policy.id as SlaPolicyId,
+ name: name.trim(),
+ description: description.trim() || null,
+ priority,
+ enabled,
+ appliesToPriorities:
+ appliesToPriorities.length > 0
+ ? (appliesToPriorities as ('low' | 'normal' | 'high' | 'urgent')[])
+ : undefined,
+ businessHoursId: businessHoursId ? (businessHoursId as BusinessHoursId) : null,
+ pauseOnPending,
+ pauseOnOnHold,
+ },
+ }),
+ onSuccess: () => {
+ toast.success('Policy updated')
+ qc.invalidateQueries({ queryKey: ['sla'] })
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const archiveMutation = useMutation({
+ mutationFn: () => archiveSlaPolicyFn({ data: { id: policy.id as SlaPolicyId } }),
+ onSuccess: () => {
+ toast.success('Policy archived')
+ qc.invalidateQueries({ queryKey: ['sla'] })
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ return (
+
+
+
+
Scope (cannot be changed)
+
+ {policy.scope}
+ {policy.scopeTeamId && (
+
+ team: {String(policy.scopeTeamId)}
+
+ )}
+ {policy.scopeInboxId && (
+
+ inbox: {String(policy.scopeInboxId)}
+
+ )}
+
+
+
+
+ Name
+ setName(e.target.value)} />
+
+
+ Description
+ setDescription(e.target.value)}
+ rows={2}
+ />
+
+
+
+ Priority (lower runs first)
+ setPriority(Number(e.target.value) || 0)}
+ />
+
+
+ Business hours
+ setBusinessHoursId(v === '__none' ? '' : v)}
+ >
+
+
+
+
+ None (24/7)
+ {calendars
+ .filter((c) => !c.archivedAt || c.id === businessHoursId)
+ .map((c) => (
+
+ {c.name}
+
+ ))}
+
+
+
+
+
+
+
Applies to priorities
+
+ {PRIORITIES.map((p) => {
+ const checked = appliesToPriorities.includes(p)
+ return (
+ togglePriority(p)}
+ className={cn(
+ 'text-[11px] rounded border px-2 py-0.5',
+ checked
+ ? 'bg-primary text-primary-foreground border-primary'
+ : 'bg-background border-border/60 text-muted-foreground hover:bg-muted'
+ )}
+ >
+ {p}
+
+ )
+ })}
+ {appliesToPriorities.length === 0 && (
+ All priorities
+ )}
+
+
+
+
+
Pause clocks when ticket is
+
+
+
+ Pending
+
+
+
+ On hold
+
+
+
+
+
+
+
+ Enabled
+
+
+
+
+ saveMutation.mutate()} disabled={saveMutation.isPending}>
+ Save changes
+
+
+
+
+ {!policy.archivedAt && (
+
+
Archive
+
+ Archived policies stop binding to new tickets. Existing clocks continue running.
+
+
+
+
+
+ Archive policy
+
+
+
+
+ Archive this SLA policy?
+
+ New tickets will not bind to this policy. This cannot be undone via UI.
+
+
+
+ Cancel
+ archiveMutation.mutate()}>
+ Archive
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/sla/sla-targets-tab.tsx b/apps/web/src/components/admin/settings/sla/sla-targets-tab.tsx
new file mode 100644
index 000000000..4edac955e
--- /dev/null
+++ b/apps/web/src/components/admin/settings/sla/sla-targets-tab.tsx
@@ -0,0 +1,90 @@
+/**
+ * SLA targets tab. Three rows for first_response/next_response/resolution.
+ * Targets are bulk-replaced server-side: omit a kind to remove its target.
+ */
+import { useState } from 'react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import type { SlaPolicyId } from '@quackback/ids'
+import type { SlaTarget } from '@/lib/shared/db-types'
+import { replaceSlaTargetsFn } from '@/lib/server/functions/sla'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { PermissionGate } from '@/components/admin/shared/permission-gate'
+import { PERMISSIONS } from '@/lib/server/domains/authz'
+
+const KINDS = [
+ { key: 'first_response', label: 'First response', desc: 'Time to first agent reply' },
+ { key: 'next_response', label: 'Next response', desc: 'Time between subsequent replies' },
+ { key: 'resolution', label: 'Resolution', desc: 'Time until ticket is resolved' },
+] as const
+type Kind = (typeof KINDS)[number]['key']
+
+interface Props {
+ policyId: SlaPolicyId
+ initialTargets: SlaTarget[]
+}
+
+export function SlaTargetsTab({ policyId, initialTargets }: Props) {
+ const qc = useQueryClient()
+ const initial: Record = { first_response: '', next_response: '', resolution: '' }
+ for (const t of initialTargets) {
+ if (t.kind in initial) initial[t.kind as Kind] = String(t.minutes)
+ }
+ const [values, setValues] = useState>(initial)
+
+ const saveMutation = useMutation({
+ mutationFn: () => {
+ const targets: { kind: Kind; minutes: number }[] = []
+ for (const { key } of KINDS) {
+ const raw = values[key].trim()
+ if (!raw) continue
+ const n = Number(raw)
+ if (Number.isInteger(n) && n > 0) targets.push({ kind: key, minutes: n })
+ }
+ return replaceSlaTargetsFn({ data: { policyId, targets } })
+ },
+ onSuccess: () => {
+ toast.success('Targets updated')
+ qc.invalidateQueries({ queryKey: ['sla'] })
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ return (
+
+
+ Leave a target empty to remove it. Times are in minutes; the SLA engine will compute due
+ timestamps using the policy's business hours.
+
+
+ {KINDS.map(({ key, label, desc }) => (
+
+
+
+ setValues((prev) => ({ ...prev, [key]: e.target.value }))}
+ placeholder="—"
+ className="h-8 text-xs"
+ />
+ min
+
+
+ ))}
+
+
+ saveMutation.mutate()} disabled={saveMutation.isPending}>
+ Save targets
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/sla/sla-tick-trigger.tsx b/apps/web/src/components/admin/settings/sla/sla-tick-trigger.tsx
new file mode 100644
index 000000000..877521747
--- /dev/null
+++ b/apps/web/src/components/admin/settings/sla/sla-tick-trigger.tsx
@@ -0,0 +1,38 @@
+/**
+ * Admin-only "Run escalation tick now" button. Useful for verifying escalation
+ * rules without waiting for the cron job.
+ */
+import { useMutation } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import { runSlaTickFn } from '@/lib/server/functions/sla'
+import { Button } from '@/components/ui/button'
+import { BoltIcon } from '@heroicons/react/24/outline'
+import { PermissionGate } from '@/components/admin/shared/permission-gate'
+import { PERMISSIONS } from '@/lib/server/domains/authz'
+
+export function SlaTickTrigger() {
+ const mutation = useMutation({
+ mutationFn: () => runSlaTickFn({ data: {} }),
+ onSuccess: (result) => {
+ const r = result as { processed?: number; fired?: number } | null
+ const processed = r?.processed ?? 0
+ const fired = r?.fired ?? 0
+ toast.success(`Tick ran — processed ${processed}, fired ${fired}`)
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ return (
+
+ mutation.mutate()}
+ disabled={mutation.isPending}
+ >
+
+ Run tick now
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/team/__tests__/manage-principal-roles-dialog.test.tsx b/apps/web/src/components/admin/settings/team/__tests__/manage-principal-roles-dialog.test.tsx
new file mode 100644
index 000000000..4b7ad784c
--- /dev/null
+++ b/apps/web/src/components/admin/settings/team/__tests__/manage-principal-roles-dialog.test.tsx
@@ -0,0 +1,332 @@
+// @vitest-environment happy-dom
+import type { ChangeEvent, ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { ManagePrincipalRolesDialog } from '../manage-principal-roles-dialog'
+
+type Assignment = {
+ id: string
+ role: {
+ id: string
+ name: string
+ key: string
+ isSystem: boolean
+ }
+ teamName: string | null
+}
+
+type Role = {
+ id: string
+ name: string
+ key: string
+}
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ routerInvalidate: vi.fn(),
+ listAssignmentsForPrincipalFn: vi.fn(),
+ listRolesFn: vi.fn(),
+ assignRoleFn: vi.fn(),
+ revokeRoleAssignmentFn: vi.fn(),
+ assignments: [] as Assignment[],
+ roles: [] as Role[],
+ assignmentsLoading: false,
+ rolesLoading: false,
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useQuery: (options: {
+ queryKey: readonly unknown[]
+ queryFn: () => unknown
+ enabled?: boolean
+ }) => {
+ if (options.enabled === false) {
+ return { data: undefined, isLoading: false }
+ }
+ const [, kind] = options.queryKey
+ if (kind === 'principal-roles') {
+ options.queryFn()
+ return { data: mocks.assignments, isLoading: mocks.assignmentsLoading }
+ }
+ options.queryFn()
+ return { data: mocks.roles, isLoading: mocks.rolesLoading }
+ },
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ useRouter: () => ({
+ invalidate: mocks.routerInvalidate,
+ }),
+}))
+
+vi.mock('@/components/ui/dialog', () => ({
+ Dialog: ({
+ open,
+ children,
+ }: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ children: ReactNode
+ }) => (open ? {children}
: null),
+ DialogContent: ({ children }: { children: ReactNode; className?: string }) => (
+
+ ),
+ DialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ DialogHeader: ({ children }: { children: ReactNode }) => ,
+ DialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ 'aria-label': ariaLabel,
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ size?: string
+ 'aria-label'?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/badge', () => ({
+ Badge: ({ children }: { children: ReactNode; variant?: string; className?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children }: { children: ReactNode; className?: string }) => {children} ,
+}))
+
+vi.mock('@/components/ui/select', () => ({
+ Select: ({
+ value,
+ onValueChange,
+ disabled,
+ children,
+ }: {
+ value: string
+ onValueChange: (value: string) => void
+ disabled?: boolean
+ children: ReactNode
+ }) => (
+ ) => onValueChange(event.currentTarget.value)}
+ >
+ Pick a role
+ {children}
+
+ ),
+ SelectContent: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectItem: ({ children, value }: { children: ReactNode; value: string }) => (
+ {children}
+ ),
+ SelectTrigger: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectValue: ({ placeholder }: { placeholder?: string }) => <>{placeholder}>,
+}))
+
+vi.mock('@/components/admin/shared/team-picker', () => ({
+ TeamPicker: ({
+ value,
+ onValueChange,
+ disabled,
+ placeholder,
+ }: {
+ value: string | null
+ onValueChange: (value: string | null) => void
+ placeholder?: string
+ allowClear?: boolean
+ disabled?: boolean
+ }) => (
+ ) =>
+ onValueChange(event.currentTarget.value || null)
+ }
+ >
+ {placeholder ?? 'Workspace-wide'}
+ Support
+
+ ),
+}))
+
+vi.mock('@heroicons/react/24/outline', () => ({
+ TrashIcon: () => trash ,
+}))
+
+vi.mock('@/lib/server/functions/roles', () => ({
+ listAssignmentsForPrincipalFn: mocks.listAssignmentsForPrincipalFn,
+ listRolesFn: mocks.listRolesFn,
+ assignRoleFn: mocks.assignRoleFn,
+ revokeRoleAssignmentFn: mocks.revokeRoleAssignmentFn,
+}))
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.assignmentsLoading = false
+ mocks.rolesLoading = false
+ mocks.assignments = [
+ {
+ id: 'assignment_owner',
+ role: {
+ id: 'role_owner',
+ name: 'Owner',
+ key: 'owner',
+ isSystem: true,
+ },
+ teamName: null,
+ },
+ {
+ id: 'assignment_agent',
+ role: {
+ id: 'role_agent',
+ name: 'Agent',
+ key: 'agent',
+ isSystem: false,
+ },
+ teamName: 'Support',
+ },
+ ]
+ mocks.roles = [
+ { id: 'role_admin', name: 'Admin', key: 'admin' },
+ { id: 'role_agent', name: 'Agent', key: 'agent' },
+ ]
+ mocks.listAssignmentsForPrincipalFn.mockResolvedValue(mocks.assignments)
+ mocks.listRolesFn.mockResolvedValue(mocks.roles)
+ mocks.assignRoleFn.mockResolvedValue({ id: 'assignment_new' })
+ mocks.revokeRoleAssignmentFn.mockResolvedValue(undefined)
+})
+
+function renderDialog(open = true) {
+ return render(
+
+ )
+}
+
+describe('ManagePrincipalRolesDialog', () => {
+ it('does not render dialog content while closed', () => {
+ renderDialog(false)
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ expect(mocks.listAssignmentsForPrincipalFn).not.toHaveBeenCalled()
+ expect(mocks.listRolesFn).not.toHaveBeenCalled()
+ })
+
+ it('renders loading, empty and populated grant states', () => {
+ mocks.assignmentsLoading = true
+ mocks.rolesLoading = true
+ mocks.assignments = []
+ renderDialog()
+
+ expect(screen.getByText('Loading…')).toBeInTheDocument()
+ expect(screen.getByLabelText('Role')).toBeDisabled()
+ expect(mocks.listAssignmentsForPrincipalFn).toHaveBeenCalledWith({
+ data: { principalId: 'principal_1' },
+ })
+ expect(mocks.listRolesFn).toHaveBeenCalled()
+
+ mocks.assignmentsLoading = false
+ mocks.rolesLoading = false
+ mocks.assignments = []
+ const { rerender } = renderDialog()
+ expect(screen.getByText('No role grants yet.')).toBeInTheDocument()
+
+ mocks.assignments = [
+ {
+ id: 'assignment_owner',
+ role: { id: 'role_owner', name: 'Owner', key: 'owner', isSystem: true },
+ teamName: null,
+ },
+ ]
+ rerender(
+
+ )
+ expect(screen.getByText('Owner')).toBeInTheDocument()
+ expect(screen.getByText('System')).toBeInTheDocument()
+ expect(screen.getByText('Workspace')).toBeInTheDocument()
+ })
+
+ it('grants scoped roles, resets selections and invalidates RBAC queries', async () => {
+ renderDialog()
+
+ expect(screen.getByRole('button', { name: 'Grant role' })).toBeDisabled()
+ fireEvent.change(screen.getByLabelText('Role'), { target: { value: 'role_admin' } })
+ fireEvent.change(screen.getByLabelText('Team'), { target: { value: 'team_support' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Grant role' }))
+
+ await waitFor(() => {
+ expect(mocks.assignRoleFn).toHaveBeenCalledWith({
+ data: {
+ principalId: 'principal_1',
+ roleId: 'role_admin',
+ teamId: 'team_support',
+ },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['admin', 'principal-roles', 'principal_1'],
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'roles'] })
+ expect(mocks.routerInvalidate).toHaveBeenCalled()
+ expect(screen.getByLabelText('Role')).toHaveValue('')
+ expect(screen.getByLabelText('Team')).toHaveValue('')
+ })
+
+ it('revokes grants and reports grant/revoke errors', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ mocks.assignRoleFn.mockRejectedValueOnce(new Error('Grant denied'))
+ mocks.revokeRoleAssignmentFn.mockRejectedValueOnce(new Error('Revoke denied'))
+
+ renderDialog()
+
+ fireEvent.change(screen.getByLabelText('Role'), { target: { value: 'role_admin' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Grant role' }))
+ await waitFor(() => {
+ expect(screen.getByText('Grant denied')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Revoke' })[0])
+ await waitFor(() => {
+ expect(mocks.revokeRoleAssignmentFn).toHaveBeenCalledWith({
+ data: { assignmentId: 'assignment_owner' },
+ })
+ })
+ expect(screen.getByText('Revoke denied')).toBeInTheDocument()
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Revoke' })[0])
+ await waitFor(() => {
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['admin', 'principal-roles', 'principal_1'],
+ })
+ })
+ consoleError.mockRestore()
+ })
+})
diff --git a/apps/web/src/components/admin/settings/team/__tests__/member-actions.test.tsx b/apps/web/src/components/admin/settings/team/__tests__/member-actions.test.tsx
new file mode 100644
index 000000000..2dba33e45
--- /dev/null
+++ b/apps/web/src/components/admin/settings/team/__tests__/member-actions.test.tsx
@@ -0,0 +1,268 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { MemberActions } from '../member-actions'
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ updateMemberRoleFn: vi.fn(),
+ removeTeamMemberFn: vi.fn(),
+ forceSignOutUserFn: vi.fn(),
+ adminResetTwoFactorFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+ alert: vi.fn(),
+ consoleError: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+vi.mock('@/lib/server/functions/admin', () => ({
+ updateMemberRoleFn: mocks.updateMemberRoleFn,
+ removeTeamMemberFn: mocks.removeTeamMemberFn,
+ forceSignOutUserFn: mocks.forceSignOutUserFn,
+}))
+
+vi.mock('@/lib/server/functions/admin-reset-two-factor', () => ({
+ adminResetTwoFactorFn: mocks.adminResetTwoFactorFn,
+}))
+
+vi.mock('../manage-principal-roles-dialog', () => ({
+ ManagePrincipalRolesDialog: ({
+ open,
+ onOpenChange,
+ principalId,
+ principalName,
+ }: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ principalId: string
+ principalName: string
+ }) =>
+ open ? (
+
+ Manage roles for {principalName} ({principalId})
+ onOpenChange(false)}>
+ Close roles
+
+
+ ) : null,
+}))
+
+vi.mock('@/components/shared/confirm-dialog', () => ({
+ ConfirmDialog: ({
+ open,
+ title,
+ description,
+ confirmLabel,
+ onConfirm,
+ onOpenChange,
+ }: {
+ open: boolean
+ title: string
+ description: ReactNode
+ confirmLabel: string
+ variant?: string
+ isPending?: boolean
+ onConfirm: () => void | Promise
+ onOpenChange: (open: boolean) => void
+ }) =>
+ open ? (
+
+ {title}
+ {description}
+ void onConfirm()}>
+ {confirmLabel}
+
+ onOpenChange(false)}>
+ Close confirm
+
+
+ ) : null,
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ variant?: string
+ size?: string
+ className?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/dropdown-menu', () => ({
+ DropdownMenu: ({ children }: { children: ReactNode }) => {children}
,
+ DropdownMenuContent: ({ children }: { children: ReactNode; align?: string }) => (
+ {children}
+ ),
+ DropdownMenuItem: ({
+ children,
+ disabled,
+ onClick,
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ variant?: string
+ className?: string
+ }) => (
+
+ {children}
+
+ ),
+ DropdownMenuSeparator: () => ,
+ DropdownMenuTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => (
+ <>{children}>
+ ),
+}))
+
+vi.mock('@heroicons/react/24/solid', () => ({
+ ArrowRightOnRectangleIcon: () => signout ,
+ EllipsisVerticalIcon: () => menu ,
+ ShieldCheckIcon: () => admin ,
+ ShieldExclamationIcon: () => 2fa ,
+ UserIcon: () => user ,
+ UserMinusIcon: () => remove ,
+}))
+
+vi.mock('@heroicons/react/24/outline', () => ({
+ KeyIcon: () => key ,
+}))
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.updateMemberRoleFn.mockResolvedValue(undefined)
+ mocks.removeTeamMemberFn.mockResolvedValue(undefined)
+ mocks.forceSignOutUserFn.mockResolvedValue({ revokeCount: 2 })
+ mocks.adminResetTwoFactorFn.mockResolvedValue(undefined)
+ mocks.invalidateQueries.mockResolvedValue(undefined)
+ vi.stubGlobal('alert', mocks.alert)
+ vi.spyOn(console, 'error').mockImplementation(mocks.consoleError)
+})
+
+describe('MemberActions', () => {
+ it('runs member administration actions and invalidates team queries', async () => {
+ render(
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: /Make admin/ }))
+ expect(screen.getByRole('heading', { name: 'Make admin?' })).toBeInTheDocument()
+ fireEvent.click(screen.getAllByRole('button', { name: 'Make admin' }).at(-1)!)
+ await waitFor(() => {
+ expect(mocks.updateMemberRoleFn).toHaveBeenCalledWith({
+ data: { principalId: 'principal_1', role: 'admin' },
+ })
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: /Manage roles/ }))
+ expect(screen.getByText(/Manage roles for Ada/)).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Close roles' }))
+ expect(screen.queryByText(/Manage roles for Ada/)).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: /Reset two-factor/ }))
+ expect(
+ screen.getByRole('heading', { name: 'Reset two-factor authentication?' })
+ ).toBeInTheDocument()
+ fireEvent.click(screen.getAllByRole('button', { name: 'Reset two-factor' }).at(-1)!)
+ await waitFor(() => {
+ expect(mocks.adminResetTwoFactorFn).toHaveBeenCalledWith({ data: { userId: 'user_1' } })
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: /Sign out everywhere/ }))
+ expect(screen.getByRole('heading', { name: 'Sign out everywhere?' })).toBeInTheDocument()
+ fireEvent.click(screen.getAllByRole('button', { name: 'Sign out everywhere' }).at(-1)!)
+ await waitFor(() => {
+ expect(mocks.forceSignOutUserFn).toHaveBeenCalledWith({ data: { userId: 'user_1' } })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Signed Ada out of 2 sessions.')
+
+ fireEvent.click(screen.getAllByRole('button', { name: /Remove from team/ })[0])
+ expect(screen.getByRole('heading', { name: 'Remove team member?' })).toBeInTheDocument()
+ fireEvent.click(screen.getAllByRole('button', { name: 'Remove from team' }).at(-1)!)
+ await waitFor(() => {
+ expect(mocks.removeTeamMemberFn).toHaveBeenCalledWith({
+ data: { principalId: 'principal_1' },
+ })
+ })
+
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['settings', 'team'] })
+ })
+
+ it('handles admin demotion, one-session signout copy, and service errors', async () => {
+ mocks.forceSignOutUserFn.mockResolvedValueOnce({ revokeCount: 1 })
+ mocks.updateMemberRoleFn.mockRejectedValueOnce(new Error('cannot demote'))
+ const { rerender } = render(
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: /Make member/ }))
+ expect(screen.getByRole('heading', { name: 'Remove admin privileges?' })).toBeInTheDocument()
+ fireEvent.click(screen.getAllByRole('button', { name: 'Remove admin' }).at(-1)!)
+ await waitFor(() => {
+ expect(mocks.alert).toHaveBeenCalledWith('cannot demote')
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: /Sign out everywhere/ }))
+ fireEvent.click(screen.getAllByRole('button', { name: 'Sign out everywhere' }).at(-1)!)
+ await waitFor(() => {
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Signed Grace out of 1 session.')
+ })
+
+ mocks.forceSignOutUserFn.mockRejectedValueOnce(new Error('session revoke failed'))
+ fireEvent.click(screen.getByRole('button', { name: /Sign out everywhere/ }))
+ fireEvent.click(screen.getAllByRole('button', { name: 'Sign out everywhere' }).at(-1)!)
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('session revoke failed')
+ })
+
+ rerender(
+
+ )
+
+ expect(screen.getByRole('button', { name: /Make member/ })).toBeDisabled()
+ expect(screen.getByRole('button', { name: /Remove from team/ })).toBeDisabled()
+ expect(screen.queryByRole('button', { name: /Reset two-factor/ })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: /Sign out everywhere/ })).not.toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/admin/settings/team/manage-principal-roles-dialog.tsx b/apps/web/src/components/admin/settings/team/manage-principal-roles-dialog.tsx
new file mode 100644
index 000000000..81a51d3a9
--- /dev/null
+++ b/apps/web/src/components/admin/settings/team/manage-principal-roles-dialog.tsx
@@ -0,0 +1,221 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { useRouter } from '@tanstack/react-router'
+import { TrashIcon } from '@heroicons/react/24/outline'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { Label } from '@/components/ui/label'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { TeamPicker } from '@/components/admin/shared/team-picker'
+import {
+ listAssignmentsForPrincipalFn,
+ listRolesFn,
+ assignRoleFn,
+ revokeRoleAssignmentFn,
+} from '@/lib/server/functions/roles'
+import type { PrincipalId, RoleId, RoleAssignmentId, TeamId } from '@quackback/ids'
+
+interface Props {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ principalId: PrincipalId
+ principalName: string
+}
+
+export function ManagePrincipalRolesDialog({
+ open,
+ onOpenChange,
+ principalId,
+ principalName,
+}: Props) {
+ const router = useRouter()
+ const queryClient = useQueryClient()
+ const [isPending, startTransition] = useTransition()
+ const [error, setError] = useState(null)
+ const [busyId, setBusyId] = useState(null)
+
+ const [pickRoleId, setPickRoleId] = useState(null)
+ const [pickTeamId, setPickTeamId] = useState(null)
+ const [granting, setGranting] = useState(false)
+
+ const assignmentsQuery = useQuery({
+ queryKey: ['admin', 'principal-roles', principalId],
+ queryFn: () => listAssignmentsForPrincipalFn({ data: { principalId } }),
+ enabled: open,
+ })
+
+ const rolesQuery = useQuery({
+ queryKey: ['admin', 'roles', 'list'],
+ queryFn: () => listRolesFn(),
+ enabled: open,
+ })
+
+ const invalidate = () => {
+ startTransition(() => {
+ queryClient.invalidateQueries({
+ queryKey: ['admin', 'principal-roles', principalId],
+ })
+ queryClient.invalidateQueries({ queryKey: ['admin', 'roles'] })
+ router.invalidate()
+ })
+ }
+
+ const handleRevoke = async (assignmentId: RoleAssignmentId) => {
+ setError(null)
+ setBusyId(assignmentId)
+ try {
+ await revokeRoleAssignmentFn({ data: { assignmentId } })
+ invalidate()
+ } catch (err) {
+ console.error('Failed to revoke role assignment:', err)
+ setError(err instanceof Error ? err.message : 'Failed to revoke')
+ } finally {
+ setBusyId(null)
+ }
+ }
+
+ const handleGrant = async () => {
+ if (!pickRoleId) return
+ setError(null)
+ setGranting(true)
+ try {
+ await assignRoleFn({
+ data: {
+ principalId,
+ roleId: pickRoleId,
+ teamId: pickTeamId ?? null,
+ },
+ })
+ setPickRoleId(null)
+ setPickTeamId(null)
+ invalidate()
+ } catch (err) {
+ console.error('Failed to grant role:', err)
+ setError(err instanceof Error ? err.message : 'Failed to grant role')
+ } finally {
+ setGranting(false)
+ }
+ }
+
+ const assignments = assignmentsQuery.data ?? []
+ const roles = rolesQuery.data ?? []
+ const busy = isPending || granting
+
+ return (
+
+
+
+ Manage roles
+
+ Grants for {principalName} . Workspace-wide grants apply everywhere;
+ team-scoped grants only apply to that team.
+
+
+
+
+
+
Current grants
+ {assignmentsQuery.isLoading ? (
+
Loading…
+ ) : assignments.length === 0 ? (
+
No role grants yet.
+ ) : (
+
+ {assignments.map((a) => (
+
+
+
+ {a.role.name}
+ {a.role.isSystem && (
+
+ System
+
+ )}
+
+
+ {a.role.key}
+ ·
+ {a.teamName ? (
+
+ Team: {a.teamName}
+
+ ) : (
+
+ Workspace
+
+ )}
+
+
+ handleRevoke(a.id)}
+ disabled={busy || busyId === a.id}
+ aria-label="Revoke"
+ >
+
+
+
+ ))}
+
+ )}
+
+
+
+
Grant a role
+
+ setPickRoleId(v ? (v as RoleId) : null)}
+ disabled={busy || rolesQuery.isLoading}
+ >
+
+
+
+
+ {roles.map((r) => (
+
+ {r.name}
+
+ {r.key}
+
+
+ ))}
+
+
+
+
+
+
+ {granting ? 'Granting…' : 'Grant role'}
+
+
+
+
+ {error &&
{error}
}
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/team/member-actions.tsx b/apps/web/src/components/admin/settings/team/member-actions.tsx
index 71f519280..ba418067a 100644
--- a/apps/web/src/components/admin/settings/team/member-actions.tsx
+++ b/apps/web/src/components/admin/settings/team/member-actions.tsx
@@ -11,6 +11,7 @@ import {
UserMinusIcon,
ArrowRightOnRectangleIcon,
} from '@heroicons/react/24/solid'
+import { KeyIcon } from '@heroicons/react/24/outline'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
@@ -20,6 +21,8 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { ConfirmDialog } from '@/components/shared/confirm-dialog'
+import { ManagePrincipalRolesDialog } from './manage-principal-roles-dialog'
+import type { PrincipalId } from '@quackback/ids'
import {
updateMemberRoleFn,
removeTeamMemberFn,
@@ -46,6 +49,7 @@ export function MemberActions({
const [isLoading, setIsLoading] = useState(false)
const [roleDialogOpen, setRoleDialogOpen] = useState(false)
const [removeDialogOpen, setRemoveDialogOpen] = useState(false)
+ const [manageRolesOpen, setManageRolesOpen] = useState(false)
const [resetTfaDialogOpen, setResetTfaDialogOpen] = useState(false)
const [forceSignOutDialogOpen, setForceSignOutDialogOpen] = useState(false)
@@ -142,6 +146,10 @@ export function MemberActions({
>
)}
+ setManageRolesOpen(true)} className="gap-2">
+
+ Manage roles
+
{userId ? (
setResetTfaDialogOpen(true)} className="gap-2">
@@ -208,6 +216,13 @@ export function MemberActions({
onConfirm={handleRemove}
/>
+
+
= {
+ mutationFn: () => Promise
+ onSuccess?: (result: T) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ createTeamFn: vi.fn(),
+ invalidateQueries: vi.fn(),
+ navigate: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useMutation: (options: MutationOptions) => ({
+ isPending: false,
+ mutate: async () => {
+ try {
+ const result = await options.mutationFn()
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ useRouter: () => ({
+ navigate: mocks.navigate,
+ }),
+}))
+
+vi.mock('@/lib/server/functions/teams', () => ({
+ createTeamFn: mocks.createTeamFn,
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({
+ id,
+ type = 'text',
+ value,
+ onChange,
+ placeholder,
+ }: {
+ id?: string
+ type?: string
+ value?: string
+ onChange?: (event: ChangeEvent) => void
+ required?: boolean
+ maxLength?: number
+ placeholder?: string
+ className?: string
+ }) => ,
+}))
+
+vi.mock('@/components/ui/textarea', () => ({
+ Textarea: ({
+ id,
+ value,
+ onChange,
+ rows,
+ }: {
+ id?: string
+ value?: string
+ onChange?: (event: ChangeEvent) => void
+ rows?: number
+ maxLength?: number
+ }) => ,
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/dialog', () => ({
+ Dialog: ({
+ children,
+ }: {
+ children: ReactNode
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ }) => {children}
,
+ DialogContent: ({ children }: { children: ReactNode; className?: string }) => (
+
+ ),
+ DialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ DialogFooter: ({ children }: { children: ReactNode }) => ,
+ DialogHeader: ({ children }: { children: ReactNode }) => ,
+ DialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+ DialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}>,
+}))
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.createTeamFn.mockResolvedValue({ id: 'team_created' })
+})
+
+describe('TeamCreateDialog', () => {
+ it('requires slug and name and validates the slug format before creating', () => {
+ render(Open} />)
+
+ fireEvent.submit(screen.getByRole('button', { name: 'Create team' }).closest('form')!)
+ expect(mocks.toastError).toHaveBeenCalledWith('Slug and name are required')
+
+ fireEvent.change(screen.getByLabelText('Slug'), { target: { value: 'sales_team' } })
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Sales' } })
+ fireEvent.submit(screen.getByRole('button', { name: 'Create team' }).closest('form')!)
+
+ expect(mocks.toastError).toHaveBeenCalledWith(
+ 'Slug must be lowercase letters, numbers, or hyphens'
+ )
+ expect(mocks.createTeamFn).not.toHaveBeenCalled()
+ })
+
+ it('normalizes slug input, submits trimmed optional fields and navigates on success', async () => {
+ render(Open} />)
+
+ fireEvent.change(screen.getByLabelText('Slug'), { target: { value: 'Tier-1' } })
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Tier 1 ' } })
+ fireEvent.change(screen.getByLabelText('Description'), { target: { value: ' First line ' } })
+ fireEvent.change(screen.getByLabelText('Short label'), { target: { value: ' T1 ' } })
+ fireEvent.change(screen.getByPlaceholderText('#6366f1'), { target: { value: ' #22c55e ' } })
+ fireEvent.submit(screen.getByRole('button', { name: 'Create team' }).closest('form')!)
+
+ await waitFor(() => {
+ expect(mocks.createTeamFn).toHaveBeenCalledWith({
+ data: {
+ slug: 'tier-1',
+ name: 'Tier 1',
+ description: 'First line',
+ shortLabel: 'T1',
+ color: '#22c55e',
+ },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['teams'] })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Team created')
+ expect(mocks.navigate).toHaveBeenCalledWith({
+ to: '/admin/settings/teams/$teamId',
+ params: { teamId: 'team_created' },
+ })
+ expect(screen.getByLabelText('Slug')).toHaveValue('')
+ expect(screen.getByLabelText('Description')).toHaveValue('')
+ })
+
+ it('turns empty optional values into null and reports create failures', async () => {
+ mocks.createTeamFn.mockRejectedValueOnce(new Error('Duplicate team'))
+ render(Open} />)
+
+ fireEvent.change(screen.getByLabelText('Slug'), { target: { value: 'support' } })
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Support' } })
+ fireEvent.change(screen.getByLabelText('Description'), { target: { value: ' ' } })
+ fireEvent.change(screen.getByLabelText('Short label'), { target: { value: ' ' } })
+ fireEvent.change(screen.getByPlaceholderText('#6366f1'), { target: { value: ' ' } })
+ fireEvent.submit(screen.getByRole('button', { name: 'Create team' }).closest('form')!)
+
+ await waitFor(() => {
+ expect(mocks.createTeamFn).toHaveBeenCalledWith({
+ data: {
+ slug: 'support',
+ name: 'Support',
+ description: null,
+ shortLabel: null,
+ color: null,
+ },
+ })
+ })
+ expect(mocks.toastError).toHaveBeenCalledWith('Duplicate team')
+ expect(mocks.navigate).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/web/src/components/admin/settings/teams/__tests__/team-list.test.tsx b/apps/web/src/components/admin/settings/teams/__tests__/team-list.test.tsx
new file mode 100644
index 000000000..d47ecc18d
--- /dev/null
+++ b/apps/web/src/components/admin/settings/teams/__tests__/team-list.test.tsx
@@ -0,0 +1,158 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import type { TeamId } from '@quackback/ids'
+import { TeamList } from '../team-list'
+
+type TeamRow = {
+ id: TeamId
+ name: string
+ slug: string
+ shortLabel: string | null
+ color: string | null
+ archivedAt: string | null
+}
+
+const mocks = vi.hoisted(() => ({
+ teams: [] as TeamRow[],
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useSuspenseQuery: () => ({
+ data: mocks.teams,
+ }),
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ Link: ({
+ children,
+ to,
+ params,
+ }: {
+ children: ReactNode
+ to: string
+ params?: Record
+ className?: string
+ }) => (
+ path.replace(`$${key}`, value),
+ to
+ )}
+ >
+ {children}
+
+ ),
+}))
+
+vi.mock('@/lib/client/queries/teams', () => ({
+ teamQueries: {
+ list: (params: unknown) => ({ queryKey: ['teams', params] }),
+ },
+}))
+
+vi.mock('@/components/ui/table', () => ({
+ Table: ({ children }: { children: ReactNode }) => ,
+ TableBody: ({ children }: { children: ReactNode }) => {children} ,
+ TableCell: ({
+ children,
+ colSpan,
+ }: {
+ children?: ReactNode
+ colSpan?: number
+ className?: string
+ }) => {children} ,
+ TableHead: ({ children }: { children?: ReactNode; className?: string }) => {children} ,
+ TableHeader: ({ children }: { children: ReactNode }) => {children} ,
+ TableRow: ({ children }: { children: ReactNode; className?: string }) => {children} ,
+}))
+
+vi.mock('@/components/ui/switch', () => ({
+ Switch: ({
+ id,
+ checked,
+ onCheckedChange,
+ }: {
+ id?: string
+ checked: boolean
+ onCheckedChange: (checked: boolean) => void
+ }) => (
+ onCheckedChange(event.currentTarget.checked)}
+ />
+ ),
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string; className?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/badge', () => ({
+ Badge: ({ children }: { children: ReactNode; variant?: string; className?: string }) => (
+ {children}
+ ),
+}))
+
+function team(overrides: Partial = {}): TeamRow {
+ return {
+ id: 'team_1' as TeamId,
+ name: 'Support',
+ slug: 'support',
+ shortLabel: 'SUP',
+ color: '#2563eb',
+ archivedAt: null,
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ mocks.teams = [
+ team(),
+ team({
+ id: 'team_archived' as TeamId,
+ name: 'Legacy',
+ slug: 'legacy',
+ shortLabel: null,
+ color: null,
+ archivedAt: '2026-06-20T10:00:00.000Z',
+ }),
+ ]
+})
+
+describe('TeamList', () => {
+ it('renders active teams by default and reveals archived teams', () => {
+ render( )
+
+ expect(screen.getByRole('link', { name: /Support/ })).toHaveAttribute(
+ 'href',
+ '/admin/settings/teams/team_1'
+ )
+ expect(screen.getByText('support')).toBeInTheDocument()
+ expect(screen.getByText('SUP')).toBeInTheDocument()
+ expect(screen.getByText('Active')).toBeInTheDocument()
+ expect(screen.queryByText('Legacy')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByLabelText('Show archived'))
+
+ expect(screen.getByRole('link', { name: /Legacy/ })).toHaveAttribute(
+ 'href',
+ '/admin/settings/teams/team_archived'
+ )
+ expect(screen.getByText('legacy')).toBeInTheDocument()
+ expect(screen.getByText('—')).toBeInTheDocument()
+ expect(screen.getByText('Archived')).toBeInTheDocument()
+ })
+
+ it('renders an empty state when there are no visible teams', () => {
+ mocks.teams = []
+ render( )
+
+ expect(screen.getByText('No teams yet. Create your first team.')).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/admin/settings/teams/__tests__/team-overview-tab.test.tsx b/apps/web/src/components/admin/settings/teams/__tests__/team-overview-tab.test.tsx
new file mode 100644
index 000000000..4dd62ba74
--- /dev/null
+++ b/apps/web/src/components/admin/settings/teams/__tests__/team-overview-tab.test.tsx
@@ -0,0 +1,306 @@
+// @vitest-environment happy-dom
+import type { ChangeEvent, ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { TeamOverviewTab } from '../team-overview-tab'
+
+type TeamProp = Parameters[0]['team']
+
+type MutationOptions = {
+ mutationFn: () => Promise
+ onSuccess?: (result: T) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ updateTeamFn: vi.fn(),
+ archiveTeamFn: vi.fn(),
+ unarchiveTeamFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+ permissionAllowed: true,
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useMutation: (options: MutationOptions) => ({
+ isPending: false,
+ mutate: async () => {
+ try {
+ const result = await options.mutationFn()
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@/components/admin/shared/permission-gate', () => ({
+ PermissionGate: ({
+ children,
+ fallback = null,
+ }: {
+ children: ReactNode
+ fallback?: ReactNode
+ permission: string
+ }) => (mocks.permissionAllowed ? <>{children}> : <>{fallback}>),
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ size?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({
+ id,
+ type = 'text',
+ value,
+ onChange,
+ disabled,
+ readOnly,
+ placeholder,
+ }: {
+ id?: string
+ type?: string
+ value?: string
+ onChange?: (event: ChangeEvent) => void
+ disabled?: boolean
+ readOnly?: boolean
+ required?: boolean
+ maxLength?: number
+ placeholder?: string
+ className?: string
+ }) => (
+
+ ),
+}))
+
+vi.mock('@/components/ui/textarea', () => ({
+ Textarea: ({
+ id,
+ value,
+ onChange,
+ rows,
+ }: {
+ id?: string
+ value?: string
+ onChange?: (event: ChangeEvent) => void
+ rows?: number
+ maxLength?: number
+ }) => ,
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/alert-dialog', () => ({
+ AlertDialog: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogAction: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
+
+ {children}
+
+ ),
+ AlertDialogCancel: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ AlertDialogContent: ({ children }: { children: ReactNode }) => ,
+ AlertDialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogFooter: ({ children }: { children: ReactNode }) => ,
+ AlertDialogHeader: ({ children }: { children: ReactNode }) => ,
+ AlertDialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+ AlertDialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}>,
+}))
+
+vi.mock('@/lib/server/functions/teams', () => ({
+ updateTeamFn: mocks.updateTeamFn,
+ archiveTeamFn: mocks.archiveTeamFn,
+ unarchiveTeamFn: mocks.unarchiveTeamFn,
+}))
+
+vi.mock('@/lib/client/queries/teams', () => ({
+ teamQueries: {
+ detail: (teamId: string) => ({ queryKey: ['teams', 'detail', teamId] }),
+ },
+}))
+
+vi.mock('@/lib/server/domains/authz', () => ({
+ PERMISSIONS: {
+ ADMIN_MANAGE_USERS: 'admin.manage_users',
+ },
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+function team(overrides: Partial = {}): TeamProp {
+ return {
+ id: 'team_support',
+ slug: 'support',
+ name: 'Support',
+ description: 'Handles tickets',
+ shortLabel: 'SUP',
+ color: '#22c55e',
+ archivedAt: null,
+ ...overrides,
+ } as TeamProp
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.permissionAllowed = true
+ mocks.updateTeamFn.mockResolvedValue({ id: 'team_support' })
+ mocks.archiveTeamFn.mockResolvedValue({ id: 'team_support' })
+ mocks.unarchiveTeamFn.mockResolvedValue({ id: 'team_support' })
+})
+
+describe('TeamOverviewTab', () => {
+ it('saves trimmed team fields, invalidates detail/list queries and shows success', async () => {
+ render( )
+
+ expect(screen.getByDisplayValue('support')).toBeDisabled()
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Success ' } })
+ fireEvent.change(screen.getByLabelText('Description'), { target: { value: ' ' } })
+ fireEvent.change(screen.getByLabelText('Short label'), { target: { value: ' CS ' } })
+ fireEvent.change(screen.getByPlaceholderText('#6366f1'), { target: { value: ' #6366f1 ' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+
+ await waitFor(() => {
+ expect(mocks.updateTeamFn).toHaveBeenCalledWith({
+ data: {
+ teamId: 'team_support',
+ name: 'Success',
+ description: null,
+ shortLabel: 'CS',
+ color: '#6366f1',
+ },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['teams', 'detail', 'team_support'],
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['teams'] })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Team updated')
+ })
+
+ it('validates name and reports save failures', async () => {
+ render( )
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' ' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+
+ expect(mocks.toastError).toHaveBeenCalledWith('Name is required')
+ expect(mocks.updateTeamFn).not.toHaveBeenCalled()
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Support' } })
+ mocks.updateTeamFn.mockRejectedValueOnce(new Error('Duplicate team'))
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Duplicate team')
+ })
+ })
+
+ it('resets editable fields when the team prop changes', () => {
+ const { rerender } = render( )
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Unsaved' } })
+ expect(screen.getByLabelText('Name')).toHaveValue('Unsaved')
+
+ rerender(
+
+ )
+
+ expect(screen.getByDisplayValue('sales')).toBeDisabled()
+ expect(screen.getByLabelText('Name')).toHaveValue('Sales')
+ expect(screen.getByLabelText('Description')).toHaveValue('')
+ expect(screen.getByLabelText('Short label')).toHaveValue('')
+ expect(screen.getByLabelText('Color')).toHaveValue('#6366f1')
+ })
+
+ it('archives and unarchives teams and reports archive failures', async () => {
+ const { rerender } = render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Archive' }))
+ await waitFor(() => {
+ expect(mocks.archiveTeamFn).toHaveBeenCalledWith({
+ data: { teamId: 'team_support' },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Team archived')
+
+ mocks.archiveTeamFn.mockRejectedValueOnce(new Error('Archive denied'))
+ fireEvent.click(screen.getByRole('button', { name: 'Archive' }))
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Archive denied')
+ })
+
+ rerender( )
+ fireEvent.click(screen.getByRole('button', { name: 'Unarchive team' }))
+ await waitFor(() => {
+ expect(mocks.unarchiveTeamFn).toHaveBeenCalledWith({
+ data: { teamId: 'team_support' },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Team unarchived')
+
+ mocks.unarchiveTeamFn.mockRejectedValueOnce(new Error('Unarchive denied'))
+ fireEvent.click(screen.getByRole('button', { name: 'Unarchive team' }))
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Unarchive denied')
+ })
+ })
+
+ it('hides mutation controls when user management permission is denied', () => {
+ mocks.permissionAllowed = false
+
+ render( )
+
+ expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Archive team' })).not.toBeInTheDocument()
+ expect(screen.getByLabelText('Name')).toHaveValue('Support')
+ })
+})
diff --git a/apps/web/src/components/admin/settings/teams/team-create-dialog.tsx b/apps/web/src/components/admin/settings/teams/team-create-dialog.tsx
new file mode 100644
index 000000000..8f5dbc39f
--- /dev/null
+++ b/apps/web/src/components/admin/settings/teams/team-create-dialog.tsx
@@ -0,0 +1,183 @@
+/**
+ * Create-team dialog. slug + name + optional description/shortLabel/color.
+ * On success navigates to the team detail page.
+ */
+import { useState } from 'react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { useRouter } from '@tanstack/react-router'
+import { toast } from 'sonner'
+import type { TeamId } from '@quackback/ids'
+import { createTeamFn } from '@/lib/server/functions/teams'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog'
+
+const SLUG_RE = /^[a-z0-9-]+$/
+
+export function TeamCreateDialog({ trigger }: { trigger: React.ReactNode }) {
+ const [open, setOpen] = useState(false)
+ const router = useRouter()
+ const qc = useQueryClient()
+
+ const [slug, setSlug] = useState('')
+ const [name, setName] = useState('')
+ const [description, setDescription] = useState('')
+ const [shortLabel, setShortLabel] = useState('')
+ const [color, setColor] = useState('')
+
+ const reset = () => {
+ setSlug('')
+ setName('')
+ setDescription('')
+ setShortLabel('')
+ setColor('')
+ }
+
+ const mutation = useMutation({
+ mutationFn: () =>
+ createTeamFn({
+ data: {
+ slug: slug.trim(),
+ name: name.trim(),
+ description: description.trim() || null,
+ shortLabel: shortLabel.trim() || null,
+ color: color.trim() || null,
+ },
+ }),
+ onSuccess: (team) => {
+ qc.invalidateQueries({ queryKey: ['teams'] })
+ toast.success('Team created')
+ setOpen(false)
+ reset()
+ router.navigate({
+ to: '/admin/settings/teams/$teamId',
+ params: { teamId: team.id as TeamId },
+ })
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ return (
+
+ {trigger}
+
+
+ New team
+
+ Workspace teams group agents for routing, sharing, and SLA scopes.
+
+
+
+ {
+ e.preventDefault()
+ const slugTrim = slug.trim()
+ if (!slugTrim || !name.trim()) {
+ toast.error('Slug and name are required')
+ return
+ }
+ if (!SLUG_RE.test(slugTrim)) {
+ toast.error('Slug must be lowercase letters, numbers, or hyphens')
+ return
+ }
+ mutation.mutate()
+ }}
+ className="space-y-3"
+ >
+
+
+
+ Description
+ setDescription(e.target.value)}
+ rows={2}
+ maxLength={500}
+ />
+
+
+
+
+
Short label
+
setShortLabel(e.target.value)}
+ placeholder="T1"
+ maxLength={8}
+ />
+
Up to 8 chars. Optional.
+
+
+
+
+
+ setOpen(false)}
+ disabled={mutation.isPending}
+ >
+ Cancel
+
+
+ Create team
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/teams/team-list.tsx b/apps/web/src/components/admin/settings/teams/team-list.tsx
new file mode 100644
index 000000000..6216d7448
--- /dev/null
+++ b/apps/web/src/components/admin/settings/teams/team-list.tsx
@@ -0,0 +1,109 @@
+/**
+ * ` ` — table of workspace teams with name, slug, short label chip,
+ * and active/archived status. Show-archived toggle.
+ */
+import { useState, useMemo } from 'react'
+import { Link } from '@tanstack/react-router'
+import { useSuspenseQuery } from '@tanstack/react-query'
+import type { TeamId } from '@quackback/ids'
+import { teamQueries } from '@/lib/client/queries/teams'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Badge } from '@/components/ui/badge'
+import { Switch } from '@/components/ui/switch'
+import { Label } from '@/components/ui/label'
+
+export function TeamList() {
+ const [showArchived, setShowArchived] = useState(false)
+ const { data: teams } = useSuspenseQuery(teamQueries.list({ includeArchived: true }))
+
+ const rows = useMemo(
+ () => (showArchived ? teams : teams.filter((t) => t.archivedAt == null)),
+ [teams, showArchived]
+ )
+
+ return (
+
+
+
+
+ Show archived
+
+
+
+
+
+
+
+ Name
+ Slug
+ Short label
+ Status
+
+
+
+ {rows.length === 0 ? (
+
+
+ No teams yet. Create your first team.
+
+
+ ) : (
+ rows.map((team) => (
+
+
+
+
+ {team.name}
+
+
+
+ {team.slug}
+
+
+ {team.shortLabel ? (
+
+ {team.shortLabel}
+
+ ) : (
+ —
+ )}
+
+
+ {team.archivedAt ? (
+
+ Archived
+
+ ) : (
+ Active
+ )}
+
+
+ ))
+ )}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/teams/team-members-tab.tsx b/apps/web/src/components/admin/settings/teams/team-members-tab.tsx
new file mode 100644
index 000000000..ffc91532e
--- /dev/null
+++ b/apps/web/src/components/admin/settings/teams/team-members-tab.tsx
@@ -0,0 +1,264 @@
+/**
+ * Members tab for a team detail page. Lists current memberships enriched via
+ * `getPrincipalsByIdsFn`, with inline role select (re-uses upsert
+ * `addTeamMemberFn`) and remove action. Add row uses ``
+ * filtered by current member ids.
+ */
+import { useState, useMemo } from 'react'
+import { useSuspenseQuery, useMutation, useQueryClient, useQuery } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import type { TeamId, PrincipalId } from '@quackback/ids'
+import { addTeamMemberFn, removeTeamMemberFn } from '@/lib/server/functions/teams'
+import { getPrincipalsByIdsFn } from '@/lib/server/functions/principals'
+import { teamQueries } from '@/lib/client/queries/teams'
+import { Button } from '@/components/ui/button'
+import { Avatar } from '@/components/ui/avatar'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog'
+import { TrashIcon } from '@heroicons/react/24/outline'
+import { PrincipalPicker } from '@/components/admin/shared/principal-picker'
+import { PermissionGate } from '@/components/admin/shared/permission-gate'
+import { PERMISSIONS } from '@/lib/server/domains/authz'
+
+const ROLES = ['lead', 'member'] as const
+type MemberRole = (typeof ROLES)[number]
+
+export function TeamMembersTab({ teamId }: { teamId: TeamId }) {
+ const qc = useQueryClient()
+ const { data: memberships } = useSuspenseQuery(teamQueries.members(teamId))
+
+ const principalIds = useMemo(
+ () => memberships.map((m) => m.principalId as PrincipalId),
+ [memberships]
+ )
+
+ const principalsQuery = useQuery({
+ queryKey: ['principals', 'byIds', principalIds],
+ queryFn: () => getPrincipalsByIdsFn({ data: { ids: principalIds } }),
+ enabled: principalIds.length > 0,
+ staleTime: 60_000,
+ })
+ const principalMap = useMemo(() => {
+ const m = new Map()
+ for (const p of principalsQuery.data ?? []) {
+ m.set(p.id, { displayName: p.displayName, avatarUrl: p.avatarUrl })
+ }
+ return m
+ }, [principalsQuery.data])
+
+ const [addPrincipalId, setAddPrincipalId] = useState(null)
+ const [addRole, setAddRole] = useState('member')
+
+ const invalidate = () => qc.invalidateQueries({ queryKey: teamQueries.members(teamId).queryKey })
+
+ const addMutation = useMutation({
+ mutationFn: () =>
+ addTeamMemberFn({
+ data: { teamId, principalId: addPrincipalId!, role: addRole },
+ }),
+ onSuccess: () => {
+ setAddPrincipalId(null)
+ setAddRole('member')
+ invalidate()
+ toast.success('Member added')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ // Role-change reuses addTeamMemberFn (backend is upsert).
+ const updateRoleMutation = useMutation({
+ mutationFn: (vars: { principalId: PrincipalId; role: MemberRole }) =>
+ addTeamMemberFn({
+ data: { teamId, principalId: vars.principalId, role: vars.role },
+ }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Role updated')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const removeMutation = useMutation({
+ mutationFn: (principalId: PrincipalId) => removeTeamMemberFn({ data: { teamId, principalId } }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Member removed')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ return (
+
+
+
Members
+
+ Principals belonging to this team. Leads can be referenced by routing rules and SLA
+ recipients.
+
+
+
+
+
+
+
+
+ Role
+ setAddRole(v as MemberRole)}>
+
+
+
+
+ {ROLES.map((r) => (
+
+ {r}
+
+ ))}
+
+
+
+
addMutation.mutate()}
+ >
+ Add
+
+
+
+
+
+
+
+
+
+ Member
+ Role
+
+
+
+
+ {memberships.length === 0 ? (
+
+
+ No members yet.
+
+
+ ) : (
+ memberships.map((m) => {
+ const info = principalMap.get(m.principalId)
+ const label = info?.displayName ?? m.principalId
+ return (
+
+
+
+
+ {info?.avatarUrl ? (
+
+ ) : (
+ label.slice(0, 2).toUpperCase()
+ )}
+
+
{label}
+
+
+
+ {m.role}}
+ >
+
+ updateRoleMutation.mutate({
+ principalId: m.principalId as PrincipalId,
+ role: v as MemberRole,
+ })
+ }
+ >
+
+
+
+
+ {ROLES.map((r) => (
+
+ {r}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Remove member?
+
+ They will be removed from this team.
+
+
+
+ Cancel
+ removeMutation.mutate(m.principalId as PrincipalId)}
+ >
+ Remove
+
+
+
+
+
+
+
+ )
+ })
+ )}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/teams/team-overview-tab.tsx b/apps/web/src/components/admin/settings/teams/team-overview-tab.tsx
new file mode 100644
index 000000000..ce8d5b20e
--- /dev/null
+++ b/apps/web/src/components/admin/settings/teams/team-overview-tab.tsx
@@ -0,0 +1,209 @@
+/**
+ * Overview tab for a team detail page. Editable form for name/description/
+ * shortLabel/color plus an Archive / Unarchive section. Slug is read-only
+ * because `updateTeamFn` does not accept it.
+ */
+import { useState, useEffect } from 'react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import type { Team } from '@/lib/shared/db-types'
+import { updateTeamFn, archiveTeamFn, unarchiveTeamFn } from '@/lib/server/functions/teams'
+import { teamQueries } from '@/lib/client/queries/teams'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog'
+import { PermissionGate } from '@/components/admin/shared/permission-gate'
+import { PERMISSIONS } from '@/lib/server/domains/authz'
+
+export function TeamOverviewTab({ team }: { team: Team }) {
+ const qc = useQueryClient()
+ const [name, setName] = useState(team.name)
+ const [description, setDescription] = useState(team.description ?? '')
+ const [shortLabel, setShortLabel] = useState(team.shortLabel ?? '')
+ const [color, setColor] = useState(team.color ?? '')
+
+ useEffect(() => {
+ setName(team.name)
+ setDescription(team.description ?? '')
+ setShortLabel(team.shortLabel ?? '')
+ setColor(team.color ?? '')
+ }, [team])
+
+ const invalidate = () => {
+ qc.invalidateQueries({ queryKey: teamQueries.detail(team.id).queryKey })
+ qc.invalidateQueries({ queryKey: ['teams'] })
+ }
+
+ const saveMutation = useMutation({
+ mutationFn: () =>
+ updateTeamFn({
+ data: {
+ teamId: team.id,
+ name: name.trim(),
+ description: description.trim() || null,
+ shortLabel: shortLabel.trim() || null,
+ color: color.trim() || null,
+ },
+ }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Team updated')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const archiveMutation = useMutation({
+ mutationFn: () => archiveTeamFn({ data: { teamId: team.id } }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Team archived')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const unarchiveMutation = useMutation({
+ mutationFn: () => unarchiveTeamFn({ data: { teamId: team.id } }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Team unarchived')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ return (
+
+
{
+ e.preventDefault()
+ if (!name.trim()) {
+ toast.error('Name is required')
+ return
+ }
+ saveMutation.mutate()
+ }}
+ className="space-y-3"
+ >
+
+
+
+ Description
+ setDescription(e.target.value)}
+ rows={2}
+ maxLength={500}
+ />
+
+
+
+
+ Short label
+ setShortLabel(e.target.value)}
+ maxLength={8}
+ placeholder="T1"
+ />
+
+
+
+
+
+
+
+ Save changes
+
+
+
+
+
+
+
+
Archive
+
+ Archived teams are hidden from pickers but historical references remain intact.
+
+ {team.archivedAt ? (
+
unarchiveMutation.mutate()}
+ disabled={unarchiveMutation.isPending}
+ >
+ Unarchive team
+
+ ) : (
+
+
+
+ Archive team
+
+
+
+
+ Archive {team.name}?
+
+ The team will be hidden from pickers. You can unarchive it later.
+
+
+
+ Cancel
+ archiveMutation.mutate()}>
+ Archive
+
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/webhooks/__tests__/webhook-deliveries-table.test.tsx b/apps/web/src/components/admin/settings/webhooks/__tests__/webhook-deliveries-table.test.tsx
new file mode 100644
index 000000000..6b3c83ff6
--- /dev/null
+++ b/apps/web/src/components/admin/settings/webhooks/__tests__/webhook-deliveries-table.test.tsx
@@ -0,0 +1,227 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { WebhookDeliveriesTable } from '../webhook-deliveries-table'
+
+type Delivery = {
+ id: string
+ webhookId: string
+ eventId: string
+ eventType: string
+ attemptNumber: number
+ status: string
+ httpStatus: number | null
+ errorMessage: string | null
+ requestUrl: string
+ requestPayloadBytes: number
+ responseBodySnippet: string | null
+ latencyMs: number | null
+ signatureTimestamp: number
+ attemptedAt: string
+ nextRetryAt: string | null
+}
+
+const mocks = vi.hoisted(() => ({
+ fetchNextPage: vi.fn(),
+ pages: [] as Array<{ deliveries: Delivery[] }>,
+ hasNextPage: false,
+ isFetchingNextPage: false,
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useSuspenseInfiniteQuery: () => ({
+ data: { pages: mocks.pages },
+ hasNextPage: mocks.hasNextPage,
+ isFetchingNextPage: mocks.isFetchingNextPage,
+ fetchNextPage: mocks.fetchNextPage,
+ }),
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ size?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/badge', () => ({
+ Badge: ({ children, variant }: { children: ReactNode; variant?: string; className?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/table', () => ({
+ Table: ({ children }: { children: ReactNode }) => ,
+ TableBody: ({ children }: { children: ReactNode }) => {children} ,
+ TableCell: ({
+ children,
+ colSpan,
+ title,
+ }: {
+ children?: ReactNode
+ colSpan?: number
+ title?: string
+ className?: string
+ }) => (
+
+ {children}
+
+ ),
+ TableHead: ({ children }: { children?: ReactNode; className?: string }) => {children} ,
+ TableHeader: ({ children }: { children: ReactNode }) => {children} ,
+ TableRow: ({ children }: { children: ReactNode; className?: string }) => {children} ,
+}))
+
+vi.mock('@heroicons/react/24/outline', () => ({
+ ChevronDownIcon: () => down ,
+ ChevronRightIcon: () => right ,
+}))
+
+vi.mock('@/lib/client/queries/webhook-deliveries', () => ({
+ webhookDeliveryQueries: {
+ list: (webhookId: string, filters: { status?: string }) => ({
+ queryKey: ['webhook-deliveries', webhookId, filters],
+ }),
+ },
+}))
+
+function delivery(overrides: Partial): Delivery {
+ return {
+ id: 'delivery_1',
+ webhookId: 'webhook_1',
+ eventId: 'event_1',
+ eventType: 'ticket.created',
+ attemptNumber: 1,
+ status: 'success',
+ httpStatus: 200,
+ errorMessage: null,
+ requestUrl: 'https://example.com/webhook',
+ requestPayloadBytes: 128,
+ responseBodySnippet: null,
+ latencyMs: 42,
+ signatureTimestamp: 1_718_707_200,
+ attemptedAt: '2026-06-18T12:00:00.000Z',
+ nextRetryAt: null,
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.pages = []
+ mocks.hasNextPage = false
+ mocks.isFetchingNextPage = false
+})
+
+describe('WebhookDeliveriesTable', () => {
+ it('renders an empty delivery state and hidden pagination controls', () => {
+ render( )
+
+ expect(screen.getByText('No deliveries recorded for this webhook yet.')).toBeInTheDocument()
+ expect(screen.getByText('0 deliveries shown')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument()
+ })
+
+ it('renders all status pills and compact fallback values', () => {
+ mocks.pages = [
+ {
+ deliveries: [
+ delivery({ id: 'success', status: 'success', eventType: 'ticket.created' }),
+ delivery({
+ id: 'retrying',
+ status: 'failed_retryable',
+ eventType: 'ticket.updated',
+ httpStatus: null,
+ latencyMs: null,
+ }),
+ delivery({ id: 'terminal', status: 'failed_terminal', eventType: 'ticket.closed' }),
+ delivery({ id: 'blocked', status: 'blocked_ssrf', eventType: 'webhook.blocked' }),
+ delivery({ id: 'queued', status: 'queued', eventType: 'webhook.queued' }),
+ delivery({ id: 'custom', status: 'custom_state', eventType: 'webhook.custom' }),
+ ],
+ },
+ ]
+
+ render( )
+
+ expect(screen.getByText('Success')).toBeInTheDocument()
+ expect(screen.getByText('Retrying')).toBeInTheDocument()
+ expect(screen.getByText('Failed')).toBeInTheDocument()
+ expect(screen.getByText('Blocked (SSRF)')).toBeInTheDocument()
+ expect(screen.getByText('Queued')).toBeInTheDocument()
+ expect(screen.getByText('custom_state')).toBeInTheDocument()
+ expect(screen.getByText('6 deliveries shown')).toBeInTheDocument()
+ expect(screen.getAllByText('—')).toHaveLength(2)
+ expect(screen.getAllByText('42ms')).toHaveLength(5)
+ })
+
+ it('expands and collapses details, including retry, error and response metadata', () => {
+ mocks.pages = [
+ {
+ deliveries: [
+ delivery({
+ id: 'retrying',
+ status: 'failed_retryable',
+ eventId: 'event_retry',
+ eventType: 'ticket.reply.created',
+ errorMessage: 'Connection refused',
+ responseBodySnippet: '{"ok":false}',
+ nextRetryAt: '2026-06-18T12:10:00.000Z',
+ }),
+ ],
+ },
+ ]
+
+ render( )
+
+ expect(screen.queryByText('Request URL')).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Expand row' }))
+
+ expect(screen.getByRole('button', { name: 'Collapse row' })).toBeInTheDocument()
+ expect(screen.getByText('Request URL')).toBeInTheDocument()
+ expect(screen.getByText('https://example.com/webhook')).toBeInTheDocument()
+ expect(screen.getByText('Event ID')).toBeInTheDocument()
+ expect(screen.getByText('event_retry')).toBeInTheDocument()
+ expect(screen.getByText('Payload size')).toBeInTheDocument()
+ expect(screen.getByText('128 bytes')).toBeInTheDocument()
+ expect(screen.getByText('Signature timestamp')).toBeInTheDocument()
+ expect(screen.getByText(/1718707200/)).toBeInTheDocument()
+ expect(screen.getByText('Next retry')).toBeInTheDocument()
+ expect(screen.getByText('Error')).toBeInTheDocument()
+ expect(screen.getByText('Connection refused')).toBeInTheDocument()
+ expect(screen.getByText('Response body (snippet)')).toBeInTheDocument()
+ expect(screen.getByText('{"ok":false}')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Collapse row' }))
+ expect(screen.queryByText('Request URL')).not.toBeInTheDocument()
+ })
+
+ it('fetches the next page and reflects loading state', () => {
+ mocks.pages = [{ deliveries: [delivery({ id: 'success' })] }]
+ mocks.hasNextPage = true
+
+ const { rerender } = render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Load more' }))
+ expect(mocks.fetchNextPage).toHaveBeenCalledTimes(1)
+
+ mocks.isFetchingNextPage = true
+ rerender( )
+
+ expect(screen.getByRole('button', { name: 'Loading…' })).toBeDisabled()
+ })
+})
diff --git a/apps/web/src/components/admin/settings/webhooks/__tests__/webhook-dialogs.test.tsx b/apps/web/src/components/admin/settings/webhooks/__tests__/webhook-dialogs.test.tsx
new file mode 100644
index 000000000..281b2f543
--- /dev/null
+++ b/apps/web/src/components/admin/settings/webhooks/__tests__/webhook-dialogs.test.tsx
@@ -0,0 +1,571 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { CreateWebhookDialog } from '../create-webhook-dialog'
+import { EditWebhookDialog } from '../edit-webhook-dialog'
+import { TestWebhookDialog } from '../test-webhook-dialog'
+import { WebhookDeliveriesDrawer } from '../webhook-deliveries-drawer'
+
+type WebhookFixture = {
+ id: string
+ url: string
+ events: string[]
+ inboxIds?: string[] | null
+ status: 'active' | 'disabled'
+ failureCount: number
+ lastError?: string | null
+}
+
+const mocks = vi.hoisted(() => ({
+ createWebhookFn: vi.fn(),
+ updateWebhookFn: vi.fn(),
+ testWebhookFn: vi.fn(),
+ invalidateQueries: vi.fn(),
+ routerInvalidate: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ useRouter: () => ({
+ invalidate: mocks.routerInvalidate,
+ }),
+}))
+
+vi.mock('@/lib/server/functions/webhooks', () => ({
+ createWebhookFn: mocks.createWebhookFn,
+ testWebhookFn: mocks.testWebhookFn,
+ updateWebhookFn: mocks.updateWebhookFn,
+}))
+
+vi.mock('@/components/shared/secret-reveal-dialog', () => ({
+ SecretRevealDialog: ({
+ open,
+ title,
+ description,
+ secretLabel,
+ secretValue,
+ confirmLabel,
+ onOpenChange,
+ children,
+ }: {
+ open: boolean
+ title: string
+ description: string
+ secretLabel: string
+ secretValue: string
+ confirmLabel: string
+ onOpenChange: (open: boolean) => void
+ children?: ReactNode
+ }) =>
+ open ? (
+
+ {title}
+ {description}
+ {secretLabel}
+ {secretValue}
+ {children}
+ onOpenChange(false)}>
+ {confirmLabel}
+
+
+ ) : null,
+}))
+
+vi.mock('../webhook-event-picker', () => ({
+ WebhookEventPicker: ({
+ value,
+ onChange,
+ disabled,
+ }: {
+ value: string[]
+ onChange: (value: string[]) => void
+ disabled?: boolean
+ }) => (
+
+ Events: {value.join(', ') || 'none'}
+ onChange([...value, 'ticket.created'])}
+ >
+ Add ticket event
+
+ onChange([])}>
+ Clear events
+
+
+ ),
+}))
+
+vi.mock('../webhook-inbox-picker', () => ({
+ WebhookInboxPicker: ({
+ value,
+ onChange,
+ active,
+ disabled,
+ }: {
+ value: string[]
+ onChange: (value: string[]) => void
+ active: boolean
+ disabled?: boolean
+ }) => (
+
+
+ Inbox picker {active ? 'active' : 'inactive'}: {value.join(', ') || 'all'}
+
+ onChange([...value, 'inbox_1'])}
+ >
+ Add inbox
+
+
+ ),
+}))
+
+vi.mock('../rotate-webhook-secret-dialog', () => ({
+ RotateWebhookSecretDialog: ({
+ open,
+ onOpenChange,
+ onSecretRotated,
+ }: {
+ webhook: WebhookFixture
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSecretRotated: (secret: string) => void
+ }) =>
+ open ? (
+
+ {
+ onSecretRotated('whsec_new')
+ onOpenChange(false)
+ }}
+ >
+ Confirm rotate
+
+
+ ) : null,
+}))
+
+vi.mock('../webhook-deliveries-table', () => ({
+ WebhookDeliveriesTable: ({ webhookId, status }: { webhookId: string; status?: string }) => (
+
+ Deliveries table {webhookId} {status ?? 'all'}
+
+ ),
+}))
+
+vi.mock('@/components/shared/copy-button', () => ({
+ CopyButton: ({
+ value,
+ }: {
+ value: string
+ 'aria-label'?: string
+ variant?: string
+ size?: string
+ }) => Copy {value} ,
+}))
+
+vi.mock('@/components/shared/warning-box', () => ({
+ WarningBox: ({
+ title,
+ description,
+ }: {
+ variant: string
+ title: string
+ description?: string
+ }) => (
+
+ {title}
+ {description && {description}
}
+
+ ),
+}))
+
+vi.mock('@/components/ui/dialog', () => ({
+ Dialog: ({
+ children,
+ open,
+ }: {
+ children: ReactNode
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ }) => (open ? {children}
: null),
+ DialogContent: ({ children }: { children: ReactNode; className?: string }) => (
+
+ ),
+ DialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ DialogFooter: ({ children }: { children: ReactNode }) => ,
+ DialogHeader: ({ children }: { children: ReactNode }) => ,
+ DialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+}))
+
+vi.mock('@/components/ui/sheet', () => ({
+ Sheet: ({
+ children,
+ open,
+ }: {
+ children: ReactNode
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ }) => (open ? {children}
: null),
+ SheetContent: ({ children }: { children: ReactNode; side?: string; className?: string }) => (
+
+ ),
+ SheetDescription: ({
+ children,
+ title,
+ }: {
+ children: ReactNode
+ title?: string
+ className?: string
+ }) => {children}
,
+ SheetHeader: ({ children }: { children: ReactNode }) => ,
+ SheetTitle: ({ children }: { children: ReactNode }) => {children} ,
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ 'aria-label': ariaLabel,
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ size?: string
+ 'aria-label'?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({
+ id,
+ value,
+ onChange,
+ placeholder,
+ type = 'text',
+ disabled,
+ }: {
+ id?: string
+ value?: string
+ onChange?: (event: { target: { value: string } }) => void
+ placeholder?: string
+ type?: string
+ disabled?: boolean
+ required?: boolean
+ }) => (
+ onChange?.({ target: { value: event.currentTarget.value } })}
+ />
+ ),
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string; className?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/switch', () => ({
+ Switch: ({
+ id,
+ checked,
+ onCheckedChange,
+ disabled,
+ 'aria-label': ariaLabel,
+ }: {
+ id?: string
+ checked?: boolean
+ onCheckedChange?: (checked: boolean) => void
+ disabled?: boolean
+ 'aria-label'?: string
+ }) => (
+ onCheckedChange?.(event.currentTarget.checked)}
+ />
+ ),
+}))
+
+vi.mock('@/components/ui/select', async () => {
+ const React = await import('react')
+ const SelectContext = React.createContext<{ onValueChange?: (value: string) => void }>({})
+ return {
+ Select: ({
+ value,
+ onValueChange,
+ children,
+ }: {
+ value?: string
+ onValueChange?: (value: string) => void
+ children?: ReactNode
+ }) => (
+
+ {children}
+
+ ),
+ SelectContent: ({ children }: { children: ReactNode }) => {children}
,
+ SelectItem: ({ children, value }: { children: ReactNode; value: string }) => {
+ const context = React.useContext(SelectContext)
+ return (
+ context.onValueChange?.(value)}>
+ {children}
+
+ )
+ },
+ SelectTrigger: ({ children }: { children?: ReactNode; id?: string; className?: string }) => (
+ <>{children}>
+ ),
+ SelectValue: ({ placeholder }: { placeholder?: string }) => {placeholder} ,
+ }
+})
+
+vi.mock('@heroicons/react/24/outline', () => ({
+ ArrowPathIcon: () => rotate ,
+}))
+
+vi.mock('@heroicons/react/24/solid', () => ({
+ CheckCircleIcon: () => success ,
+ XCircleIcon: () => failure ,
+}))
+
+function webhook(overrides: Partial = {}): WebhookFixture {
+ return {
+ id: 'webhook_1',
+ url: 'https://example.test/webhook',
+ events: ['ticket.created'],
+ inboxIds: null,
+ status: 'active',
+ failureCount: 0,
+ lastError: null,
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.createWebhookFn.mockResolvedValue({ secret: 'whsec_created' })
+ mocks.updateWebhookFn.mockResolvedValue({ ok: true })
+ mocks.testWebhookFn.mockResolvedValue({
+ success: true,
+ eventId: 'evt_test_1',
+ errorMessage: null,
+ })
+})
+
+describe('CreateWebhookDialog', () => {
+ it('creates a webhook with ticket inbox scope and reveals the signing secret', async () => {
+ const onOpenChange = vi.fn()
+ render( )
+
+ fireEvent.change(screen.getByLabelText('Endpoint URL'), {
+ target: { value: 'https://receiver.test/hooks/quackback' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Add ticket event' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Add inbox' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Create Webhook' }))
+
+ await waitFor(() => {
+ expect(mocks.createWebhookFn).toHaveBeenCalledWith({
+ data: {
+ url: 'https://receiver.test/hooks/quackback',
+ events: ['ticket.created'],
+ inboxIds: ['inbox_1'],
+ },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'webhooks'] })
+ expect(mocks.routerInvalidate).toHaveBeenCalled()
+ expect(screen.getByText('Webhook Created')).toBeInTheDocument()
+ expect(screen.getByText('whsec_created')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: "I've saved my secret" }))
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+
+ it('shows create failures with no inbox scope when no inbox is selected', async () => {
+ mocks.createWebhookFn.mockRejectedValueOnce('denied')
+ render( )
+
+ fireEvent.change(screen.getByLabelText('Endpoint URL'), {
+ target: { value: 'https://receiver.test/hooks/quackback' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Add ticket event' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Create Webhook' }))
+
+ await waitFor(() => {
+ expect(screen.getByText('Failed to create webhook')).toBeInTheDocument()
+ })
+ expect(mocks.createWebhookFn).toHaveBeenCalledWith({
+ data: {
+ url: 'https://receiver.test/hooks/quackback',
+ events: ['ticket.created'],
+ inboxIds: undefined,
+ },
+ })
+ })
+})
+
+describe('EditWebhookDialog', () => {
+ it('updates webhook configuration, rotates the secret, and shows auto-disabled warnings', async () => {
+ const onOpenChange = vi.fn()
+ render(
+
+ )
+
+ expect(screen.getByText('Auto-disabled after 50 failures')).toBeInTheDocument()
+ expect(screen.getByText('Last error: Endpoint returned 500')).toBeInTheDocument()
+
+ fireEvent.change(screen.getByLabelText('Endpoint URL'), {
+ target: { value: 'https://receiver.test/new' },
+ })
+ fireEvent.click(screen.getByLabelText('Toggle webhook enabled'))
+ fireEvent.click(screen.getByRole('button', { name: 'Add inbox' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
+
+ await waitFor(() => {
+ expect(mocks.updateWebhookFn).toHaveBeenCalledWith({
+ data: {
+ webhookId: 'webhook_1',
+ url: 'https://receiver.test/new',
+ events: ['ticket.created'],
+ inboxIds: ['inbox_old', 'inbox_1'],
+ status: 'active',
+ },
+ })
+ })
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+
+ fireEvent.click(screen.getByRole('button', { name: 'Rotate signing secret' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Confirm rotate' }))
+ expect(screen.getByText('whsec_new')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Copy whsec_new' })).toBeInTheDocument()
+ })
+
+ it('validates events and reports update failures', async () => {
+ mocks.updateWebhookFn.mockRejectedValueOnce(new Error('Webhook rejected'))
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Clear events' }))
+ fireEvent.submit(screen.getByLabelText('Endpoint URL').closest('form') as HTMLFormElement)
+ expect(screen.getByText('Select at least one event')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Add ticket event' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
+
+ await waitFor(() => {
+ expect(screen.getByText('Webhook rejected')).toBeInTheDocument()
+ })
+ })
+})
+
+describe('TestWebhookDialog', () => {
+ it('sends test events, reports outcomes, and resets on close', async () => {
+ const onOpenChange = vi.fn()
+ render( )
+
+ expect(screen.getByText(/Posts a canonical sample payload/)).toHaveTextContent(
+ 'https://example.test/webhook'
+ )
+ fireEvent.click(screen.getByRole('button', { name: 'Send test' }))
+
+ await waitFor(() => {
+ expect(mocks.testWebhookFn).toHaveBeenCalledWith({
+ data: { webhookId: 'webhook_1', eventType: 'ticket.created' },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['admin', 'webhook-deliveries'],
+ })
+ expect(screen.getByText('Delivered successfully')).toBeInTheDocument()
+ expect(screen.getByText('Event id: evt_test_1')).toBeInTheDocument()
+
+ mocks.testWebhookFn.mockResolvedValueOnce({
+ success: false,
+ eventId: 'evt_test_2',
+ errorMessage: 'HTTP 500',
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Send test' }))
+ await waitFor(() => {
+ expect(screen.getByText('Delivery failed')).toBeInTheDocument()
+ })
+ expect(screen.getByText('HTTP 500')).toBeInTheDocument()
+
+ mocks.testWebhookFn.mockRejectedValueOnce('network down')
+ fireEvent.click(screen.getByRole('button', { name: 'Send test' }))
+ await waitFor(() => {
+ expect(screen.getByText('Test failed')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close' }))
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+
+ it('renders nothing without a selected webhook', () => {
+ render( )
+
+ expect(screen.queryByText('Send test event')).not.toBeInTheDocument()
+ })
+})
+
+describe('WebhookDeliveriesDrawer', () => {
+ it('filters deliveries by status and hides content without a webhook', () => {
+ const { rerender } = render(
+
+ )
+
+ expect(screen.getByText('Deliveries')).toBeInTheDocument()
+ expect(screen.getByText('https://example.test/webhook')).toHaveAttribute(
+ 'title',
+ 'https://example.test/webhook'
+ )
+ expect(screen.getByText('Deliveries table webhook_1 all')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Failed' }))
+ expect(screen.getByText('Deliveries table webhook_1 failed_terminal')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Any' }))
+ expect(screen.getByText('Deliveries table webhook_1 all')).toBeInTheDocument()
+
+ rerender( )
+ expect(screen.queryByText('Deliveries')).not.toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/admin/settings/webhooks/__tests__/webhook-event-picker.defaults.test.tsx b/apps/web/src/components/admin/settings/webhooks/__tests__/webhook-event-picker.defaults.test.tsx
new file mode 100644
index 000000000..b12cc81b9
--- /dev/null
+++ b/apps/web/src/components/admin/settings/webhooks/__tests__/webhook-event-picker.defaults.test.tsx
@@ -0,0 +1,73 @@
+// @vitest-environment happy-dom
+/**
+ * Phase 6 housekeeping: lock in that no webhook event category — including
+ * `configuration` — is auto-selected. The create-webhook dialog initializes
+ * `selectedEvents` to `[]`; verify the picker honours that and renders every
+ * category with a `(0/N)` counter and no checkboxes pre-checked.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+
+vi.mock('@/lib/server/functions/webhooks', () => ({
+ fetchSamplePayloadsFn: vi.fn(async () => ({})),
+}))
+
+import { WebhookEventPicker } from '../webhook-event-picker'
+import { WEBHOOK_EVENT_CATEGORIES, WEBHOOK_EVENT_CONFIG } from '@/lib/shared/webhook-events'
+
+function renderPicker(value: string[] = []) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ return render(
+
+
+
+ )
+}
+
+describe('WebhookEventPicker defaults (Phase 6 housekeeping)', () => {
+ it('renders every category with 0/N selected when value is empty', () => {
+ renderPicker([])
+
+ for (const cat of WEBHOOK_EVENT_CATEGORIES) {
+ const total = WEBHOOK_EVENT_CONFIG.filter((e) => e.category === cat.id).length
+ if (total === 0) continue
+ const counter = `(0/${total})`
+ const matches = screen.getAllByText((_, node) => {
+ if (!node) return false
+ const txt = node.textContent ?? ''
+ return txt.includes(cat.label) && txt.includes(counter)
+ })
+ expect(matches.length).toBeGreaterThan(0)
+ }
+ })
+
+ it('does NOT pre-check the configuration category (or any other)', () => {
+ renderPicker([])
+
+ const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[]
+ expect(checkboxes.length).toBe(WEBHOOK_EVENT_CONFIG.length)
+ for (const cb of checkboxes) {
+ // Radix renders checked state via aria-checked + data-state, not the
+ // native `checked` prop.
+ expect(cb.getAttribute('aria-checked')).not.toBe('true')
+ expect(cb.getAttribute('data-state')).not.toBe('checked')
+ }
+ })
+
+ it('configuration category is opt-in (no events from it included by default)', () => {
+ renderPicker([])
+ const configurationEventIds = WEBHOOK_EVENT_CONFIG.filter(
+ (e) => e.category === 'configuration'
+ ).map((e) => e.id)
+ expect(configurationEventIds.length).toBeGreaterThan(0)
+ // Sanity: the picker exposes per-event aria-labels of the form
+ // "Subscribe to events". Every configuration checkbox must be
+ // unchecked.
+ for (const id of configurationEventIds) {
+ const cfg = WEBHOOK_EVENT_CONFIG.find((e) => e.id === id)!
+ const cb = screen.getByLabelText(`Subscribe to ${cfg.label} events`) as HTMLInputElement
+ expect(cb.getAttribute('aria-checked')).not.toBe('true')
+ }
+ })
+})
diff --git a/apps/web/src/components/admin/settings/webhooks/__tests__/webhook-inbox-picker.test.tsx b/apps/web/src/components/admin/settings/webhooks/__tests__/webhook-inbox-picker.test.tsx
new file mode 100644
index 000000000..9a2250cc1
--- /dev/null
+++ b/apps/web/src/components/admin/settings/webhooks/__tests__/webhook-inbox-picker.test.tsx
@@ -0,0 +1,121 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { WebhookInboxPicker } from '../webhook-inbox-picker'
+
+const mocks = vi.hoisted(() => ({
+ query: {
+ data: [
+ { id: 'inbox_1', name: 'Support', slug: 'support' },
+ { id: 'inbox_2', name: 'Billing', slug: 'billing' },
+ ],
+ isLoading: false,
+ } as {
+ data?: Array<{ id: string; name: string; slug: string }>
+ isLoading: boolean
+ },
+}))
+
+vi.mock('@/lib/client/hooks/use-inboxes-queries', () => ({
+ useInboxes: vi.fn(() => mocks.query),
+}))
+
+vi.mock('@/components/ui/checkbox', () => ({
+ Checkbox: ({
+ checked,
+ disabled,
+ onCheckedChange,
+ 'aria-label': ariaLabel,
+ }: {
+ checked?: boolean
+ disabled?: boolean
+ onCheckedChange?: () => void
+ 'aria-label'?: string
+ }) => (
+ onCheckedChange?.()}
+ >
+ {checked ? 'checked' : 'unchecked'}
+
+ ),
+}))
+
+vi.mock('@/components/ui/label', () => ({
+ Label: ({ children }: { children: ReactNode; className?: string }) => {children} ,
+}))
+
+beforeEach(() => {
+ mocks.query = {
+ data: [
+ { id: 'inbox_1', name: 'Support', slug: 'support' },
+ { id: 'inbox_2', name: 'Billing', slug: 'billing' },
+ ],
+ isLoading: false,
+ }
+})
+
+describe('WebhookInboxPicker', () => {
+ it('selects, clears, and removes inbox filters while active', () => {
+ const onChange = vi.fn()
+ const { rerender } = render(
+
+ )
+
+ expect(screen.getByText('Inboxes (optional)')).toBeInTheDocument()
+ expect(screen.getByText('Empty = match tickets in any inbox.')).toBeInTheDocument()
+ expect(screen.getByText('Support')).toBeInTheDocument()
+ expect(screen.getByText('support')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Filter by inbox Support' }))
+ expect(onChange).toHaveBeenCalledWith(['inbox_1'])
+
+ rerender( )
+ expect(
+ screen.getByText('Only deliver ticket events from the selected inbox.')
+ ).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Filter by inbox Support' })).toHaveAttribute(
+ 'aria-pressed',
+ 'true'
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Filter by inbox Support' }))
+ expect(onChange).toHaveBeenCalledWith([])
+
+ rerender( )
+ expect(
+ screen.getByText('Only deliver ticket events from the selected inboxes.')
+ ).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
+ expect(onChange).toHaveBeenCalledWith([])
+ })
+
+ it('disables filters and shows the inactive hint when no ticket events are selected', () => {
+ const onChange = vi.fn()
+ render( )
+
+ expect(screen.getByText(/Filter is ignored unless/)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Filter by inbox Support' })).toBeDisabled()
+ fireEvent.click(screen.getByRole('button', { name: 'Filter by inbox Support' }))
+ expect(onChange).not.toHaveBeenCalled()
+ })
+
+ it('renders loading and empty inbox states', () => {
+ mocks.query = { data: undefined, isLoading: true }
+ const { rerender } = render(
+
+ )
+
+ expect(screen.getByText(/Loading inboxes/)).toBeInTheDocument()
+
+ mocks.query = { data: [], isLoading: false }
+ rerender( )
+
+ expect(screen.getByText('No inboxes configured.')).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/admin/settings/webhooks/__tests__/webhooks-settings.test.tsx b/apps/web/src/components/admin/settings/webhooks/__tests__/webhooks-settings.test.tsx
new file mode 100644
index 000000000..46a4f6f88
--- /dev/null
+++ b/apps/web/src/components/admin/settings/webhooks/__tests__/webhooks-settings.test.tsx
@@ -0,0 +1,312 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { WebhooksSettings } from '../webhooks-settings'
+
+type WebhookFixture = {
+ id: string
+ url: string
+ events: string[]
+ status: 'active' | 'disabled'
+ failureCount: number
+ lastTriggeredAt?: Date | null
+ lastError?: string | null
+}
+
+vi.mock('../create-webhook-dialog', () => ({
+ CreateWebhookDialog: ({
+ open,
+ onOpenChange,
+ }: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ }) =>
+ open ? (
+
+ Create webhook dialog
+ onOpenChange(false)}>
+ Close create
+
+
+ ) : null,
+}))
+
+vi.mock('../edit-webhook-dialog', () => ({
+ EditWebhookDialog: ({
+ webhook,
+ open,
+ onOpenChange,
+ }: {
+ webhook: WebhookFixture
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ }) =>
+ open ? (
+
+ Edit {webhook.url}
+ onOpenChange(false)}>
+ Close edit
+
+
+ ) : null,
+}))
+
+vi.mock('../delete-webhook-dialog', () => ({
+ DeleteWebhookDialog: ({
+ webhook,
+ open,
+ onOpenChange,
+ }: {
+ webhook: WebhookFixture
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ }) =>
+ open ? (
+
+ Delete {webhook.url}
+ onOpenChange(false)}>
+ Close delete
+
+
+ ) : null,
+}))
+
+vi.mock('../webhook-deliveries-drawer', () => ({
+ WebhookDeliveriesDrawer: ({
+ webhook,
+ onOpenChange,
+ }: {
+ webhook: WebhookFixture | null
+ onOpenChange: (open: boolean) => void
+ }) =>
+ webhook ? (
+
+ Deliveries {webhook.url}
+ onOpenChange(false)}>
+ Close deliveries
+
+
+ ) : null,
+}))
+
+vi.mock('@/components/shared/empty-state', () => ({
+ EmptyState: ({
+ title,
+ description,
+ action,
+ }: {
+ icon: unknown
+ title: string
+ description: string
+ action?: ReactNode
+ }) => (
+
+ {title}
+ {description}
+ {action}
+
+ ),
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ onClick,
+ disabled,
+ 'aria-label': ariaLabel,
+ }: {
+ children: ReactNode
+ onClick?: () => void
+ disabled?: boolean
+ variant?: string
+ size?: string
+ className?: string
+ 'aria-label'?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/badge', () => ({
+ Badge: ({ children, title }: { children: ReactNode; variant?: string; title?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/dropdown-menu', () => ({
+ DropdownMenu: ({ children }: { children: ReactNode }) => {children}
,
+ DropdownMenuContent: ({ children }: { children: ReactNode; align?: string }) => (
+ {children}
+ ),
+ DropdownMenuItem: ({
+ children,
+ onClick,
+ }: {
+ children: ReactNode
+ onClick?: () => void
+ className?: string
+ }) => (
+
+ {children}
+
+ ),
+ DropdownMenuTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => (
+ <>{children}>
+ ),
+}))
+
+vi.mock('@heroicons/react/24/outline', () => ({
+ BoltIcon: () => bolt ,
+ PencilIcon: () => edit ,
+ PlusIcon: () => plus ,
+ QueueListIcon: () => queue ,
+ TrashIcon: () => trash ,
+}))
+
+vi.mock('@heroicons/react/24/solid', () => ({
+ EllipsisVerticalIcon: () => menu ,
+}))
+
+function webhook(overrides: Partial = {}): WebhookFixture {
+ return {
+ id: overrides.id ?? 'webhook_1',
+ url: overrides.url ?? 'https://receiver.test/hook',
+ events: overrides.events ?? ['post.created', 'ticket.created'],
+ status: overrides.status ?? 'active',
+ failureCount: overrides.failureCount ?? 0,
+ lastTriggeredAt: overrides.lastTriggeredAt ?? null,
+ lastError: overrides.lastError ?? null,
+ }
+}
+
+describe('WebhooksSettings', () => {
+ it('opens the create dialog from the empty state and closes it', () => {
+ render( )
+
+ expect(screen.getByText('No webhooks configured')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: /Create your first webhook/ }))
+ expect(screen.getByText('Create webhook dialog')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close create' }))
+ expect(screen.queryByText('Create webhook dialog')).not.toBeInTheDocument()
+ })
+
+ it('renders statuses, event labels, timestamps, errors, and opens row actions', () => {
+ render(
+
+ )
+
+ expect(screen.getByText('5 of 25 webhooks')).toBeInTheDocument()
+ expect(screen.getByText('Active')).toBeInTheDocument()
+ expect(screen.getByText('Issues (2)')).toHaveAttribute('title', '2 consecutive failures')
+ expect(screen.getByText('Failing (25/50)')).toHaveAttribute('title', '25 consecutive failures')
+ expect(screen.getByText('Disabled')).toBeInTheDocument()
+ expect(screen.getByText('Auto-disabled')).toHaveAttribute(
+ 'title',
+ 'Auto-disabled after 50 failures'
+ )
+ expect(screen.getByText(/New Post/)).toHaveTextContent('ticket.created')
+ expect(screen.getByText('New Comment')).toBeInTheDocument()
+ expect(screen.getByText('Status Changed')).toBeInTheDocument()
+ expect(screen.getByText('Changelog Published')).toBeInTheDocument()
+ expect(screen.getByText('unknown.event')).toBeInTheDocument()
+ expect(screen.getByText(/Last fired/)).toBeInTheDocument()
+ expect(screen.getByText('Error: HTTP 502')).toHaveAttribute('title', 'HTTP 502')
+ expect(screen.getByText('Error: too many failures')).toHaveAttribute(
+ 'title',
+ 'too many failures'
+ )
+
+ fireEvent.click(
+ screen.getByRole('button', {
+ name: 'View deliveries for https://receiver.test/active',
+ })
+ )
+ expect(screen.getByText('Deliveries https://receiver.test/active')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Close deliveries' }))
+ expect(screen.queryByText('Deliveries https://receiver.test/active')).not.toBeInTheDocument()
+
+ fireEvent.click(
+ screen.getByRole('button', {
+ name: 'Edit webhook https://receiver.test/active',
+ })
+ )
+ expect(screen.getByText('Edit https://receiver.test/active')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Close edit' }))
+ expect(screen.queryByText('Edit https://receiver.test/active')).not.toBeInTheDocument()
+
+ fireEvent.click(
+ screen.getByRole('button', {
+ name: 'Delete webhook https://receiver.test/active',
+ })
+ )
+ expect(screen.getByText('Delete https://receiver.test/active')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Close delete' }))
+ expect(screen.queryByText('Delete https://receiver.test/active')).not.toBeInTheDocument()
+ })
+
+ it('opens the create dialog from the header and disables creation at the limit', () => {
+ const { rerender } = render(
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: /Create Webhook/ }))
+ expect(screen.getByText('Create webhook dialog')).toBeInTheDocument()
+
+ rerender(
+
+ webhook({
+ id: `webhook_${index}`,
+ url: `https://receiver.test/${index}`,
+ })
+ ) as never
+ }
+ />
+ )
+
+ expect(screen.getByRole('button', { name: /Create Webhook/ })).toBeDisabled()
+ })
+})
diff --git a/apps/web/src/components/admin/settings/webhooks/create-webhook-dialog.tsx b/apps/web/src/components/admin/settings/webhooks/create-webhook-dialog.tsx
index 3ffd54b58..35df02921 100644
--- a/apps/web/src/components/admin/settings/webhooks/create-webhook-dialog.tsx
+++ b/apps/web/src/components/admin/settings/webhooks/create-webhook-dialog.tsx
@@ -7,7 +7,6 @@ import { SecretRevealDialog } from '@/components/shared/secret-reveal-dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
-import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
@@ -17,7 +16,9 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { createWebhookFn } from '@/lib/server/functions/webhooks'
-import { WEBHOOK_EVENTS, WEBHOOK_EVENT_CONFIG } from '@/lib/shared/webhook-events'
+import { WEBHOOK_EVENTS } from '@/lib/shared/webhook-events'
+import { WebhookEventPicker } from './webhook-event-picker'
+import { WebhookInboxPicker } from './webhook-inbox-picker'
interface CreateWebhookDialogProps {
open: boolean
@@ -32,6 +33,7 @@ export function CreateWebhookDialog({ open, onOpenChange }: CreateWebhookDialogP
// Form state
const [url, setUrl] = useState('')
const [selectedEvents, setSelectedEvents] = useState([])
+ const [selectedInboxIds, setSelectedInboxIds] = useState([])
const [error, setError] = useState(null)
// Secret reveal state
@@ -51,6 +53,7 @@ export function CreateWebhookDialog({ open, onOpenChange }: CreateWebhookDialogP
data: {
url,
events: selectedEvents as (typeof WEBHOOK_EVENTS)[number][],
+ inboxIds: selectedInboxIds.length > 0 ? selectedInboxIds : undefined,
},
})
@@ -69,17 +72,12 @@ export function CreateWebhookDialog({ open, onOpenChange }: CreateWebhookDialogP
const handleClose = () => {
setUrl('')
setSelectedEvents([])
+ setSelectedInboxIds([])
setError(null)
setCreatedSecret(null)
onOpenChange(false)
}
- const toggleEvent = (eventId: string) => {
- setSelectedEvents((prev) =>
- prev.includes(eventId) ? prev.filter((e) => e !== eventId) : [...prev, eventId]
- )
- }
-
// Secret reveal view
if (createdSecret) {
return (
@@ -134,29 +132,18 @@ export function CreateWebhookDialog({ open, onOpenChange }: CreateWebhookDialogP
Must be HTTPS in production
-
-
Events
-
- {WEBHOOK_EVENT_CONFIG.map((event) => (
-
- toggleEvent(event.id)}
- disabled={isPending}
- className="mt-0.5"
- aria-label={`Subscribe to ${event.label} events`}
- />
-
-
{event.label}
-
{event.description}
-
-
- ))}
-
-
+
+
+ e.startsWith('ticket.'))}
+ />
{error && {error}
}
diff --git a/apps/web/src/components/admin/settings/webhooks/edit-webhook-dialog.tsx b/apps/web/src/components/admin/settings/webhooks/edit-webhook-dialog.tsx
index 54547a02c..14a1e1330 100644
--- a/apps/web/src/components/admin/settings/webhooks/edit-webhook-dialog.tsx
+++ b/apps/web/src/components/admin/settings/webhooks/edit-webhook-dialog.tsx
@@ -6,7 +6,6 @@ import { useRouter } from '@tanstack/react-router'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
-import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
@@ -20,7 +19,9 @@ import { updateWebhookFn } from '@/lib/server/functions/webhooks'
import { ArrowPathIcon } from '@heroicons/react/24/outline'
import { CopyButton } from '@/components/shared/copy-button'
import { WarningBox } from '@/components/shared/warning-box'
-import { WEBHOOK_EVENTS, WEBHOOK_EVENT_CONFIG } from '@/lib/shared/webhook-events'
+import { WEBHOOK_EVENTS } from '@/lib/shared/webhook-events'
+import { WebhookEventPicker } from './webhook-event-picker'
+import { WebhookInboxPicker } from './webhook-inbox-picker'
import { RotateWebhookSecretDialog } from './rotate-webhook-secret-dialog'
import type { Webhook } from '@/lib/shared/types'
@@ -38,6 +39,7 @@ export function EditWebhookDialog({ webhook, open, onOpenChange }: EditWebhookDi
// Form state
const [url, setUrl] = useState(webhook.url)
const [selectedEvents, setSelectedEvents] = useState(webhook.events)
+ const [selectedInboxIds, setSelectedInboxIds] = useState(webhook.inboxIds ?? [])
const [isEnabled, setIsEnabled] = useState(webhook.status === 'active')
const [error, setError] = useState(null)
@@ -49,6 +51,7 @@ export function EditWebhookDialog({ webhook, open, onOpenChange }: EditWebhookDi
useEffect(() => {
setUrl(webhook.url)
setSelectedEvents(webhook.events)
+ setSelectedInboxIds(webhook.inboxIds ?? [])
setIsEnabled(webhook.status === 'active')
setError(null)
setNewSecret(null)
@@ -69,6 +72,7 @@ export function EditWebhookDialog({ webhook, open, onOpenChange }: EditWebhookDi
webhookId: webhook.id,
url,
events: selectedEvents as (typeof WEBHOOK_EVENTS)[number][],
+ inboxIds: selectedInboxIds.length > 0 ? selectedInboxIds : null,
status: isEnabled ? 'active' : 'disabled',
},
})
@@ -84,12 +88,6 @@ export function EditWebhookDialog({ webhook, open, onOpenChange }: EditWebhookDi
}
}
- const toggleEvent = (eventId: string) => {
- setSelectedEvents((prev) =>
- prev.includes(eventId) ? prev.filter((e) => e !== eventId) : [...prev, eventId]
- )
- }
-
const handleSecretRotated = (secret: string) => {
setNewSecret(secret)
}
@@ -120,29 +118,18 @@ export function EditWebhookDialog({ webhook, open, onOpenChange }: EditWebhookDi
/>
-
-
Events
-
- {WEBHOOK_EVENT_CONFIG.map((event) => (
-
- toggleEvent(event.id)}
- disabled={isPending}
- className="mt-0.5"
- aria-label={`Subscribe to ${event.label} events`}
- />
-
-
{event.label}
-
{event.description}
-
-
- ))}
-
-
+
+
+ e.startsWith('ticket.'))}
+ />
@@ -192,7 +179,7 @@ export function EditWebhookDialog({ webhook, open, onOpenChange }: EditWebhookDi
) : (
-
+
Rotate to generate a new signing secret
void
+}
+
+interface TestOutcome {
+ success: boolean
+ errorMessage?: string | null
+ eventId: string
+}
+
+export function TestWebhookDialog({ webhook, onOpenChange }: TestWebhookDialogProps) {
+ const queryClient = useQueryClient()
+ const [eventType, setEventType] = useState('')
+ const [pending, setPending] = useState(false)
+ const [outcome, setOutcome] = useState(null)
+ const [error, setError] = useState(null)
+
+ if (!webhook) return null
+
+ const events = webhook.events
+ const selectedEvent = eventType || events[0] || ''
+
+ async function handleSend() {
+ setPending(true)
+ setError(null)
+ setOutcome(null)
+ try {
+ if (!webhook) return
+ const result = await testWebhookFn({
+ data: { webhookId: webhook.id, eventType: selectedEvent as never },
+ })
+ setOutcome(result as TestOutcome)
+ // refresh deliveries list so the new attempt appears immediately
+ void queryClient.invalidateQueries({ queryKey: ['admin', 'webhook-deliveries'] })
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Test failed')
+ } finally {
+ setPending(false)
+ }
+ }
+
+ function close(open: boolean) {
+ if (open) return
+ setOutcome(null)
+ setError(null)
+ setEventType('')
+ onOpenChange(false)
+ }
+
+ return (
+
+
+
+ Send test event
+
+ Posts a canonical sample payload to{' '}
+ {webhook.url} and reports the
+ HTTP outcome. The attempt is logged to the deliveries list with an
+ evt_test_id so receivers can
+ ignore it.
+
+
+
+
+
+ Event type
+
+
+
+
+
+ {events.map((ev) => (
+
+ {ev}
+
+ ))}
+
+
+
+
+ {outcome && (
+
+ {outcome.success ? (
+
+ ) : (
+
+ )}
+
+
+ {outcome.success ? 'Delivered successfully' : 'Delivery failed'}
+
+
Event id: {outcome.eventId}
+ {outcome.errorMessage &&
{outcome.errorMessage}
}
+
+
+ )}
+
+ {error &&
{error}
}
+
+
+
+ close(false)} disabled={pending}>
+ Close
+
+
+ {pending ? 'Sending…' : 'Send test'}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/webhooks/webhook-deliveries-drawer.tsx b/apps/web/src/components/admin/settings/webhooks/webhook-deliveries-drawer.tsx
new file mode 100644
index 000000000..4572201cd
--- /dev/null
+++ b/apps/web/src/components/admin/settings/webhooks/webhook-deliveries-drawer.tsx
@@ -0,0 +1,78 @@
+/**
+ * Drawer for inspecting webhook delivery attempts. Opens when `webhook` is
+ * non-null; closes when `onOpenChange(false)` is called.
+ */
+import { useState, Suspense } from 'react'
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from '@/components/ui/sheet'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Label } from '@/components/ui/label'
+import type { WebhookId } from '@quackback/ids'
+import type { Webhook } from '@/lib/shared/types'
+import type { WebhookDeliveryStatusFilter } from '@/lib/client/queries/webhook-deliveries'
+import { WebhookDeliveriesTable } from './webhook-deliveries-table'
+
+interface Props {
+ webhook: Webhook | null
+ onOpenChange: (open: boolean) => void
+}
+
+const ALL = '__all__'
+
+export function WebhookDeliveriesDrawer({ webhook, onOpenChange }: Props) {
+ const [status, setStatus] = useState(undefined)
+
+ return (
+
+
+
+ Deliveries
+
+ {webhook?.url ?? ''}
+
+
+
+ {webhook && (
+
+
+ Status:
+
+ setStatus(v === ALL ? undefined : (v as WebhookDeliveryStatusFilter))
+ }
+ >
+
+
+
+
+ Any
+ Success
+ Retrying
+ Failed
+ Blocked (SSRF)
+ Queued
+
+
+
+
+
Loading… }>
+
+
+
+ )}
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/webhooks/webhook-deliveries-table.tsx b/apps/web/src/components/admin/settings/webhooks/webhook-deliveries-table.tsx
new file mode 100644
index 000000000..1675476b0
--- /dev/null
+++ b/apps/web/src/components/admin/settings/webhooks/webhook-deliveries-table.tsx
@@ -0,0 +1,242 @@
+/**
+ * Webhook deliveries table — cursor-paged list of attempts. Status filter
+ * passed in by the parent drawer; rows expand to show request URL, response
+ * snippet, error message, and retry metadata.
+ */
+import { Fragment, useState, useMemo } from 'react'
+import { useSuspenseInfiniteQuery } from '@tanstack/react-query'
+import type { WebhookId } from '@quackback/ids'
+import {
+ webhookDeliveryQueries,
+ type WebhookDeliveryStatusFilter,
+} from '@/lib/client/queries/webhook-deliveries'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline'
+
+interface Props {
+ webhookId: WebhookId
+ status?: WebhookDeliveryStatusFilter
+}
+
+type DeliveryRow = {
+ id: string
+ webhookId: string
+ eventId: string
+ eventType: string
+ attemptNumber: number
+ status: string
+ httpStatus: number | null
+ errorMessage: string | null
+ requestUrl: string
+ requestPayloadBytes: number
+ responseBodySnippet: string | null
+ latencyMs: number | null
+ signatureTimestamp: number
+ attemptedAt: string
+ nextRetryAt: string | null
+}
+
+function StatusPill({ status }: { status: string }) {
+ switch (status) {
+ case 'success':
+ return (
+
+ Success
+
+ )
+ case 'failed_retryable':
+ return (
+
+ Retrying
+
+ )
+ case 'failed_terminal':
+ return
Failed
+ case 'blocked_ssrf':
+ return (
+
+ Blocked (SSRF)
+
+ )
+ case 'queued':
+ return
Queued
+ default:
+ return
{status}
+ }
+}
+
+export function WebhookDeliveriesTable({ webhookId, status }: Props) {
+ const query = useSuspenseInfiniteQuery(webhookDeliveryQueries.list(webhookId, { status }))
+ const rows = useMemo
(
+ () => query.data.pages.flatMap((p) => p.deliveries as DeliveryRow[]),
+ [query.data]
+ )
+
+ const [expanded, setExpanded] = useState>(new Set())
+ const toggle = (id: string) => {
+ setExpanded((prev) => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+
+ return (
+
+
+
+
+
+
+ Attempted at
+ #
+ Status
+ HTTP
+ Latency
+ Event
+
+
+
+ {rows.length === 0 ? (
+
+
+ No deliveries recorded for this webhook yet.
+
+
+ ) : (
+ rows.map((row) => {
+ const isOpen = expanded.has(row.id)
+ return (
+
+
+
+ toggle(row.id)}
+ className="text-muted-foreground hover:text-foreground"
+ >
+ {isOpen ? (
+
+ ) : (
+
+ )}
+
+
+
+ {new Date(row.attemptedAt).toLocaleString()}
+
+ {row.attemptNumber}
+
+
+
+ {row.httpStatus ?? '—'}
+
+ {row.latencyMs != null ? `${row.latencyMs}ms` : '—'}
+
+
+
+ {row.eventType}
+
+
+
+ {isOpen && (
+
+
+
+
+
+
+ )}
+
+ )
+ })
+ )}
+
+
+
+
+
+ {rows.length} deliveries shown
+ {query.hasNextPage && (
+ query.fetchNextPage()}
+ >
+ {query.isFetchingNextPage ? 'Loading…' : 'Load more'}
+
+ )}
+
+
+ )
+}
+
+function DeliveryDetail({ row }: { row: DeliveryRow }) {
+ return (
+
+
+
+
+
+
+ {row.nextRetryAt && (
+
+ )}
+
+
+ {row.errorMessage && (
+
+
+ Error
+
+
+ {row.errorMessage}
+
+
+ )}
+
+ {row.responseBodySnippet && (
+
+
+ Response body (snippet)
+
+
+ {row.responseBodySnippet}
+
+
+ )}
+
+ )
+}
+
+function Field({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/webhooks/webhook-event-picker.tsx b/apps/web/src/components/admin/settings/webhooks/webhook-event-picker.tsx
new file mode 100644
index 000000000..02ac44cc2
--- /dev/null
+++ b/apps/web/src/components/admin/settings/webhooks/webhook-event-picker.tsx
@@ -0,0 +1,163 @@
+'use client'
+
+import { useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Label } from '@/components/ui/label'
+import {
+ WEBHOOK_EVENT_CATEGORIES,
+ WEBHOOK_EVENT_CONFIG,
+ type WebhookEventCategory,
+} from '@/lib/shared/webhook-events'
+import { fetchSamplePayloadsFn } from '@/lib/server/functions/webhooks'
+
+interface WebhookEventPickerProps {
+ value: string[]
+ onChange: (next: string[]) => void
+ disabled?: boolean
+}
+
+/**
+ * Grouped event picker shared by the create + edit webhook dialogs.
+ *
+ * Renders one section per `WEBHOOK_EVENT_CATEGORIES` entry with a
+ * "Select all" / "Clear" pair scoped to that category. Categories with no
+ * events (shouldn't happen given the static config, but defensive) are
+ * skipped.
+ */
+export function WebhookEventPicker({ value, onChange, disabled }: WebhookEventPickerProps) {
+ const [previewIds, setPreviewIds] = useState>(new Set())
+ const samplesQuery = useQuery({
+ queryKey: ['admin', 'webhook-sample-payloads'],
+ queryFn: () => fetchSamplePayloadsFn(),
+ enabled: previewIds.size > 0,
+ staleTime: Infinity,
+ })
+
+ const togglePreview = (eventId: string) => {
+ setPreviewIds((prev) => {
+ const next = new Set(prev)
+ if (next.has(eventId)) next.delete(eventId)
+ else next.add(eventId)
+ return next
+ })
+ }
+
+ const toggleEvent = (eventId: string) => {
+ onChange(value.includes(eventId) ? value.filter((e) => e !== eventId) : [...value, eventId])
+ }
+
+ const selectAllInCategory = (category: WebhookEventCategory) => {
+ const ids = WEBHOOK_EVENT_CONFIG.filter((e) => e.category === category).map((e) => e.id)
+ const merged = Array.from(new Set([...value, ...ids]))
+ onChange(merged)
+ }
+
+ const clearCategory = (category: WebhookEventCategory) => {
+ const ids = new Set(
+ WEBHOOK_EVENT_CONFIG.filter((e) => e.category === category).map((e) => e.id)
+ )
+ onChange(value.filter((id) => !ids.has(id)))
+ }
+
+ return (
+
+
Events
+
+ {WEBHOOK_EVENT_CATEGORIES.map((category) => {
+ const events = WEBHOOK_EVENT_CONFIG.filter((e) => e.category === category.id)
+ if (events.length === 0) return null
+ const selectedInCategory = events.filter((e) => value.includes(e.id)).length
+ return (
+
+
+
+ {category.label}
+
+ ({selectedInCategory}/{events.length})
+
+
+
+ selectAllInCategory(category.id)}
+ disabled={disabled || selectedInCategory === events.length}
+ >
+ Select all
+
+ ·
+ clearCategory(category.id)}
+ disabled={disabled || selectedInCategory === 0}
+ >
+ Clear
+
+
+
+
+ {events.map((event) => {
+ const isPreviewOpen = previewIds.has(event.id)
+ const samples = samplesQuery.data as Record
| undefined
+ const sample = samples?.[event.id]
+ return (
+
+
+
toggleEvent(event.id)}
+ disabled={disabled}
+ className="mt-0.5"
+ aria-label={`Subscribe to ${event.label} events`}
+ />
+
+
{event.label}
+
{event.description}
+
+ togglePreview(event.id)}
+ className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1 shrink-0"
+ aria-expanded={isPreviewOpen}
+ aria-label={`${isPreviewOpen ? 'Hide' : 'Show'} sample payload for ${event.label}`}
+ >
+ {isPreviewOpen ? (
+
+ ) : (
+
+ )}
+ Sample
+
+
+ {isPreviewOpen && (
+
+ {samplesQuery.isLoading ? (
+
Loading…
+ ) : sample ? (
+
+ {JSON.stringify(sample, null, 2)}
+
+ ) : (
+
+ No sample available for this event.
+
+ )}
+
+ )}
+
+ )
+ })}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/webhooks/webhook-inbox-picker.tsx b/apps/web/src/components/admin/settings/webhooks/webhook-inbox-picker.tsx
new file mode 100644
index 000000000..13f18dc35
--- /dev/null
+++ b/apps/web/src/components/admin/settings/webhooks/webhook-inbox-picker.tsx
@@ -0,0 +1,84 @@
+'use client'
+
+import { useInboxes } from '@/lib/client/hooks/use-inboxes-queries'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Label } from '@/components/ui/label'
+
+interface WebhookInboxPickerProps {
+ /** Currently selected inbox IDs (string ids; backend stores text[]). */
+ value: string[]
+ onChange: (next: string[]) => void
+ disabled?: boolean
+ /**
+ * When false, the picker still renders but is muted with a hint that the
+ * filter is ignored unless at least one ticket.* event is selected.
+ */
+ active: boolean
+}
+
+/**
+ * Optional inbox multi-select rendered under the event picker in the create
+ * and edit webhook dialogs. Empty selection means "all inboxes" (matches the
+ * backend semantics: `inboxIds === null || []` → match all).
+ *
+ * Phase 4 — per-inbox webhook filtering.
+ */
+export function WebhookInboxPicker({ value, onChange, disabled, active }: WebhookInboxPickerProps) {
+ const inboxesQuery = useInboxes({ includeArchived: false })
+ const inboxes = inboxesQuery.data ?? []
+
+ const toggle = (id: string) => {
+ onChange(value.includes(id) ? value.filter((v) => v !== id) : [...value, id])
+ }
+ const clear = () => onChange([])
+
+ return (
+
+
+ Inboxes (optional)
+ {value.length > 0 && (
+
+ Clear
+
+ )}
+
+
+ {active
+ ? value.length === 0
+ ? 'Empty = match tickets in any inbox.'
+ : `Only deliver ticket events from the selected inbox${value.length === 1 ? '' : 'es'}.`
+ : 'Filter is ignored unless at least one ticket.* event is selected above.'}
+
+ {inboxesQuery.isLoading ? (
+
Loading inboxes…
+ ) : inboxes.length === 0 ? (
+
No inboxes configured.
+ ) : (
+
+ {inboxes.map((inbox) => (
+
+ toggle(inbox.id)}
+ disabled={disabled || !active}
+ aria-label={`Filter by inbox ${inbox.name}`}
+ />
+
+
{inbox.name}
+
{inbox.slug}
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/apps/web/src/components/admin/settings/webhooks/webhooks-settings.tsx b/apps/web/src/components/admin/settings/webhooks/webhooks-settings.tsx
index 0eaea06ca..5fc7caafa 100644
--- a/apps/web/src/components/admin/settings/webhooks/webhooks-settings.tsx
+++ b/apps/web/src/components/admin/settings/webhooks/webhooks-settings.tsx
@@ -2,7 +2,13 @@
import { useState } from 'react'
import { formatDistanceToNow } from 'date-fns'
-import { PlusIcon, BoltIcon, TrashIcon, PencilIcon } from '@heroicons/react/24/outline'
+import {
+ PlusIcon,
+ BoltIcon,
+ TrashIcon,
+ PencilIcon,
+ QueueListIcon,
+} from '@heroicons/react/24/outline'
import { EmptyState } from '@/components/shared/empty-state'
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'
import { Button } from '@/components/ui/button'
@@ -16,6 +22,7 @@ import {
import { CreateWebhookDialog } from './create-webhook-dialog'
import { EditWebhookDialog } from './edit-webhook-dialog'
import { DeleteWebhookDialog } from './delete-webhook-dialog'
+import { WebhookDeliveriesDrawer } from './webhook-deliveries-drawer'
import type { Webhook } from '@/lib/shared/types'
const EVENT_LABELS: Record = {
@@ -33,6 +40,7 @@ export function WebhooksSettings({ webhooks }: WebhooksSettingsProps) {
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [editWebhook, setEditWebhook] = useState(null)
const [deleteWebhook, setDeleteWebhook] = useState(null)
+ const [deliveriesWebhook, setDeliveriesWebhook] = useState(null)
const getStatusBadge = (webhook: Webhook) => {
if (webhook.status === 'disabled') {
@@ -145,6 +153,14 @@ export function WebhooksSettings({ webhooks }: WebhooksSettingsProps) {
{/* Desktop: show buttons */}
+
setDeliveriesWebhook(webhook)}
+ aria-label={`View deliveries for ${webhook.url}`}
+ >
+
+
+ setDeliveriesWebhook(webhook)}>
+
+ View deliveries
+
setEditWebhook(webhook)}>
Edit Webhook
@@ -210,6 +230,11 @@ export function WebhooksSettings({ webhooks }: WebhooksSettingsProps) {
onOpenChange={(open) => !open && setDeleteWebhook(null)}
/>
)}
+
+ !open && setDeliveriesWebhook(null)}
+ />
)
}
diff --git a/apps/web/src/components/admin/settings/widget/__tests__/widget-ticketing-toggle.test.tsx b/apps/web/src/components/admin/settings/widget/__tests__/widget-ticketing-toggle.test.tsx
new file mode 100644
index 000000000..a365dfed1
--- /dev/null
+++ b/apps/web/src/components/admin/settings/widget/__tests__/widget-ticketing-toggle.test.tsx
@@ -0,0 +1,74 @@
+// @vitest-environment happy-dom
+import { describe, it, expect, vi, afterEach } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { WidgetTicketingToggle } from '../widget-ticketing-toggle'
+
+const mocks = vi.hoisted(() => ({
+ invalidate: vi.fn(),
+ updateWidgetConfigFn: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-router', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useRouter: () => ({ invalidate: mocks.invalidate }),
+ }
+})
+
+vi.mock('@/lib/server/functions/settings', () => ({
+ updateWidgetConfigFn: mocks.updateWidgetConfigFn,
+}))
+
+afterEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('WidgetTicketingToggle', () => {
+ it('persists ticketing.enabled and invalidates settings', async () => {
+ mocks.updateWidgetConfigFn.mockResolvedValueOnce({})
+ const onEnabledChange = vi.fn()
+ render( )
+
+ fireEvent.click(screen.getByRole('switch', { name: 'Support tickets' }))
+
+ await waitFor(() =>
+ expect(mocks.updateWidgetConfigFn).toHaveBeenCalledWith({
+ data: { ticketing: { enabled: true } },
+ })
+ )
+ await waitFor(() => expect(mocks.invalidate).toHaveBeenCalledTimes(1))
+ expect(onEnabledChange).toHaveBeenCalledWith(true)
+ })
+
+ it('shows a saving state while the update is pending', async () => {
+ let resolveUpdate!: () => void
+ mocks.updateWidgetConfigFn.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveUpdate = resolve
+ })
+ )
+ render( )
+
+ fireEvent.click(screen.getByRole('switch', { name: 'Support tickets' }))
+
+ expect(screen.getByRole('switch', { name: 'Support tickets' })).toBeDisabled()
+ resolveUpdate()
+ await waitFor(() =>
+ expect(screen.getByRole('switch', { name: 'Support tickets' })).not.toBeDisabled()
+ )
+ })
+
+ it('rolls back local and preview state when saving fails', async () => {
+ mocks.updateWidgetConfigFn.mockRejectedValueOnce(new Error('failed'))
+ const onEnabledChange = vi.fn()
+ render( )
+
+ const toggle = screen.getByRole('switch', { name: 'Support tickets' })
+ fireEvent.click(toggle)
+
+ await waitFor(() => expect(toggle).not.toBeChecked())
+ expect(onEnabledChange).toHaveBeenNthCalledWith(1, true)
+ expect(onEnabledChange).toHaveBeenNthCalledWith(2, false)
+ })
+})
diff --git a/apps/web/src/components/admin/settings/widget/widget-ticketing-toggle.tsx b/apps/web/src/components/admin/settings/widget/widget-ticketing-toggle.tsx
new file mode 100644
index 000000000..c7027fb6d
--- /dev/null
+++ b/apps/web/src/components/admin/settings/widget/widget-ticketing-toggle.tsx
@@ -0,0 +1,69 @@
+import { useRouter } from '@tanstack/react-router'
+import { useState, useTransition } from 'react'
+import { SettingsCard } from '@/components/admin/settings/settings-card'
+import { InlineSpinner } from '@/components/admin/settings/inline-spinner'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+import { updateWidgetConfigFn } from '@/lib/server/functions/settings'
+
+interface WidgetTicketingToggleProps {
+ initialEnabled: boolean
+ onEnabledChange?: (enabled: boolean) => void
+}
+
+export function WidgetTicketingToggle({
+ initialEnabled,
+ onEnabledChange,
+}: WidgetTicketingToggleProps) {
+ const router = useRouter()
+ const [isPending, startTransition] = useTransition()
+ const [saving, setSaving] = useState(false)
+ const [enabled, setEnabled] = useState(initialEnabled)
+
+ async function handleToggle(checked: boolean) {
+ const previous = enabled
+ setEnabled(checked)
+ onEnabledChange?.(checked)
+ setSaving(true)
+ try {
+ await updateWidgetConfigFn({ data: { ticketing: { enabled: checked } } })
+ startTransition(() => router.invalidate())
+ } catch {
+ setEnabled(previous)
+ onEnabledChange?.(previous)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const isBusy = saving || isPending
+
+ return (
+
+
+
+
+ Enable support tickets in the widget
+
+
+ Visitors can open and follow up on support tickets from the widget. Disabling this hides
+ ticket entry points and keeps widget ticket APIs unavailable.
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/shared/__tests__/principal-picker.test.tsx b/apps/web/src/components/admin/shared/__tests__/principal-picker.test.tsx
new file mode 100644
index 000000000..119eb824b
--- /dev/null
+++ b/apps/web/src/components/admin/shared/__tests__/principal-picker.test.tsx
@@ -0,0 +1,318 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { act } from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { PrincipalPicker } from '../principal-picker'
+
+type PrincipalRow = {
+ id: string
+ displayName: string | null
+ email: string | null
+ avatarUrl: string | null
+ role: string
+}
+
+const mocks = vi.hoisted(() => ({
+ searchPrincipalsFn: vi.fn(),
+ getPrincipalsByIdsFn: vi.fn(),
+ searchRows: [] as PrincipalRow[],
+ selectedRows: [] as PrincipalRow[],
+ searchLoading: false,
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQuery: (options: {
+ queryKey: readonly unknown[]
+ queryFn: () => unknown
+ enabled?: boolean
+ }) => {
+ const [, kind] = options.queryKey
+ if (options.enabled === false) {
+ return {
+ data: undefined,
+ isLoading: false,
+ }
+ }
+ options.queryFn()
+ return {
+ data: kind === 'search' ? mocks.searchRows : mocks.selectedRows,
+ isLoading: kind === 'search' ? mocks.searchLoading : false,
+ }
+ },
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ role,
+ 'aria-expanded': ariaExpanded,
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ role?: string
+ 'aria-expanded'?: boolean
+ variant?: string
+ className?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/popover', () => {
+ let currentOpen = false
+ let setOpen: (open: boolean) => void = () => undefined
+
+ return {
+ Popover: ({
+ open,
+ onOpenChange,
+ children,
+ }: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ children: ReactNode
+ }) => {
+ currentOpen = open
+ setOpen = onOpenChange
+ return {children}
+ },
+ PopoverTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => (
+ setOpen(true)}>{children}
+ ),
+ PopoverContent: ({ children }: { children: ReactNode; className?: string; align?: string }) =>
+ currentOpen ? : null,
+ PopoverAnchor: ({ children }: { children: ReactNode }) => <>{children}>,
+ }
+})
+
+vi.mock('@/components/ui/command', () => ({
+ Command: ({ children }: { children: ReactNode; shouldFilter?: boolean }) => {children}
,
+ CommandEmpty: ({ children }: { children: ReactNode }) => {children}
,
+ CommandGroup: ({ children }: { children: ReactNode }) => {children}
,
+ CommandInput: ({
+ value,
+ onValueChange,
+ placeholder,
+ }: {
+ value: string
+ onValueChange: (value: string) => void
+ placeholder?: string
+ }) => (
+ onValueChange(event.currentTarget.value)}
+ />
+ ),
+ CommandItem: ({
+ children,
+ onSelect,
+ value,
+ }: {
+ children: ReactNode
+ onSelect?: (value: string) => void
+ value: string
+ className?: string
+ }) => (
+ onSelect?.(value)}>
+ {children}
+
+ ),
+ CommandList: ({ children }: { children: ReactNode }) => {children}
,
+}))
+
+vi.mock('@/components/ui/avatar', () => ({
+ Avatar: ({ name, src }: { name?: string | null; src?: string | null; className?: string }) => (
+ {src ? `avatar:${src}` : `avatar:${name ?? 'empty'}`}
+ ),
+}))
+
+vi.mock('@heroicons/react/24/solid', () => ({
+ CheckIcon: ({ className }: { className?: string }) => (
+ check
+ ),
+ ChevronUpDownIcon: () => chevron ,
+}))
+
+vi.mock('@/lib/server/functions/principals', () => ({
+ searchPrincipalsFn: mocks.searchPrincipalsFn,
+ getPrincipalsByIdsFn: mocks.getPrincipalsByIdsFn,
+}))
+
+function row(overrides: Partial): PrincipalRow {
+ return {
+ id: 'principal_1',
+ displayName: 'Ada Admin',
+ email: 'ada@example.com',
+ avatarUrl: null,
+ role: 'admin',
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ vi.useFakeTimers()
+ vi.clearAllMocks()
+ mocks.searchLoading = false
+ mocks.searchRows = [
+ row({ id: 'principal_1', displayName: 'Ada Admin', email: 'ada@example.com' }),
+ row({
+ id: 'principal_email_only',
+ displayName: null,
+ email: 'email-only@example.com',
+ role: 'user',
+ }),
+ row({
+ id: 'principal_id_only',
+ displayName: null,
+ email: null,
+ role: 'agent',
+ avatarUrl: 'https://example.com/avatar.png',
+ }),
+ ]
+ mocks.selectedRows = [row({ id: 'principal_1', displayName: 'Ada Admin' })]
+ mocks.searchPrincipalsFn.mockImplementation(() => mocks.searchRows)
+ mocks.getPrincipalsByIdsFn.mockImplementation(() => mocks.selectedRows)
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+function openPicker() {
+ fireEvent.click(screen.getByRole('combobox'))
+}
+
+describe('PrincipalPicker', () => {
+ it('renders placeholder labels, opens search and sends debounced search parameters', () => {
+ const onValueChange = vi.fn()
+ render(
+
+ )
+
+ expect(screen.getByRole('combobox')).toHaveTextContent('Pick person')
+ expect(mocks.searchPrincipalsFn).not.toHaveBeenCalled()
+
+ openPicker()
+ expect(mocks.searchPrincipalsFn).toHaveBeenLastCalledWith({
+ data: {
+ query: undefined,
+ roleFilter: ['user'],
+ excludeIds: ['principal_1'],
+ limit: 25,
+ },
+ })
+
+ fireEvent.change(screen.getByLabelText('Search by name or email…'), {
+ target: { value: 'ada' },
+ })
+ act(() => {
+ vi.advanceTimersByTime(250)
+ })
+
+ expect(mocks.searchPrincipalsFn).toHaveBeenLastCalledWith({
+ data: {
+ query: 'ada',
+ roleFilter: ['user'],
+ excludeIds: ['principal_1'],
+ limit: 25,
+ },
+ })
+ expect(screen.getByText('Ada Admin')).toBeInTheDocument()
+ expect(screen.getAllByText('email-only@example.com')).toHaveLength(2)
+ expect(screen.getByText('principal_id_only')).toBeInTheDocument()
+ expect(screen.getByText('avatar:https://example.com/avatar.png')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('option', { name: /Unassigned/ }))
+ expect(onValueChange).toHaveBeenCalledWith(null)
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
+ })
+
+ it('selects a single principal, closes the popover and resolves selected labels', () => {
+ const onValueChange = vi.fn()
+ const { rerender } = render( )
+
+ openPicker()
+ fireEvent.click(screen.getByRole('option', { name: /Ada Admin/ }))
+
+ expect(onValueChange).toHaveBeenCalledWith('principal_1')
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
+
+ rerender( )
+ expect(mocks.getPrincipalsByIdsFn).toHaveBeenCalledWith({
+ data: { ids: ['principal_1'] },
+ })
+ expect(screen.getByRole('combobox')).toHaveTextContent('Ada Admin')
+ })
+
+ it('adds and removes selections in multi-select mode and renders selected counts', () => {
+ const onValueChange = vi.fn()
+ const { rerender } = render(
+
+ )
+
+ expect(screen.getByRole('combobox')).toHaveTextContent('Select recipients')
+ openPicker()
+ fireEvent.click(screen.getByRole('option', { name: /Ada Admin/ }))
+ expect(onValueChange).toHaveBeenCalledWith(['principal_1'])
+
+ mocks.selectedRows = [row({ id: 'principal_1', displayName: null, email: 'ada@example.com' })]
+ rerender(
+
+ )
+ expect(screen.getByRole('combobox')).toHaveTextContent('ada@example.com')
+
+ openPicker()
+ fireEvent.click(screen.getByRole('option', { name: /Ada Admin/ }))
+ expect(onValueChange).toHaveBeenCalledWith([])
+
+ rerender(
+
+ )
+ expect(screen.getByRole('combobox')).toHaveTextContent('2 selected')
+ })
+
+ it('renders loading and no-match states and respects disabled trigger state', () => {
+ mocks.searchRows = []
+ mocks.searchLoading = true
+ const { rerender } = render( )
+
+ expect(screen.getByRole('combobox')).toBeDisabled()
+
+ rerender( )
+ openPicker()
+ expect(screen.getByText('Searching…')).toBeInTheDocument()
+
+ mocks.searchLoading = false
+ rerender( )
+ expect(screen.getByText('No matches.')).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/admin/shared/__tests__/resource-pickers.test.tsx b/apps/web/src/components/admin/shared/__tests__/resource-pickers.test.tsx
new file mode 100644
index 000000000..8e6c53244
--- /dev/null
+++ b/apps/web/src/components/admin/shared/__tests__/resource-pickers.test.tsx
@@ -0,0 +1,382 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { ContactPicker } from '../contact-picker'
+import { InboxPicker } from '../inbox-picker'
+import { OrgPicker } from '../org-picker'
+import { PermissionGate } from '../permission-gate'
+import { ResourcePicker } from '../resource-picker'
+import { StatusPicker } from '../status-picker'
+import { TeamPicker } from '../team-picker'
+
+const mocks = vi.hoisted(() => ({
+ contactSearchCalls: [] as Array<[string, boolean]>,
+ organizationCalls: [] as Array<{ query?: string }>,
+ inboxCalls: [] as Array<{ includeArchived?: boolean }>,
+ teamCalls: [] as Array<{ includeArchived?: boolean }>,
+ hasPermission: true,
+}))
+
+vi.mock('@/lib/client/hooks/use-orgs-contacts-queries', () => ({
+ useContactSearch: (query: string, enabled: boolean) => {
+ mocks.contactSearchCalls.push([query, enabled])
+ return {
+ isLoading: query === 'loading',
+ data:
+ query === 'empty'
+ ? []
+ : [
+ { id: 'contact_1', name: 'Ada Lovelace', email: 'ada@example.com' },
+ { id: 'contact_2', name: null, email: 'contact@example.com' },
+ ],
+ }
+ },
+ useOrganizations: (params: { query?: string }) => {
+ mocks.organizationCalls.push(params)
+ return {
+ isLoading: params.query === 'loading',
+ data:
+ params.query === 'empty'
+ ? []
+ : [
+ { id: 'org_1', name: 'Acme', domain: 'acme.com' },
+ { id: 'org_2', name: 'Globex', domain: null },
+ ],
+ }
+ },
+}))
+
+vi.mock('@/lib/client/hooks/use-inboxes-queries', () => ({
+ useInboxes: (params: { includeArchived?: boolean }) => {
+ mocks.inboxCalls.push(params)
+ return {
+ isLoading: false,
+ data: [
+ { id: 'inbox_1', name: 'Support', slug: 'support' },
+ { id: 'inbox_2', name: 'Billing', slug: 'billing' },
+ ],
+ }
+ },
+}))
+
+vi.mock('@/lib/client/hooks/use-teams-queries', () => ({
+ useTeams: (params: { includeArchived?: boolean }) => {
+ mocks.teamCalls.push(params)
+ return {
+ isLoading: false,
+ data: [
+ { id: 'team_1', name: 'Success', slug: 'success', color: '#22c55e', shortLabel: 'CS' },
+ { id: 'team_2', name: 'Sales', slug: 'sales', color: null, shortLabel: null },
+ ],
+ }
+ },
+}))
+
+vi.mock('@/lib/client/hooks/use-tickets-queries', () => ({
+ useTicketStatuses: () => ({
+ isLoading: false,
+ data: [
+ { id: 'status_1', name: 'Open', category: 'open', color: '#22c55e' },
+ { id: 'status_2', name: 'Closed', category: 'closed', color: null },
+ ],
+ }),
+}))
+
+vi.mock('@/lib/client/hooks/use-authz-queries', () => ({
+ useHasPermission: vi.fn((_permission, _params) => mocks.hasPermission),
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ onClick,
+ disabled,
+ role,
+ 'aria-expanded': ariaExpanded,
+ }: {
+ children: ReactNode
+ onClick?: () => void
+ disabled?: boolean
+ role?: string
+ 'aria-expanded'?: boolean
+ variant?: string
+ className?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/popover', async () => {
+ const React = await import('react')
+ const PopoverContext = React.createContext<{ open: boolean; setOpen: (open: boolean) => void }>({
+ open: false,
+ setOpen: () => {},
+ })
+ return {
+ Popover: ({
+ children,
+ open,
+ onOpenChange,
+ }: {
+ children: ReactNode
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ }) => (
+
+ {children}
+
+ ),
+ PopoverContent: ({ children }: { children: ReactNode; className?: string; align?: string }) => {
+ const context = React.useContext(PopoverContext)
+ return context.open ? : null
+ },
+ PopoverTrigger: ({ children }: { children: React.ReactElement; asChild?: boolean }) => {
+ const context = React.useContext(PopoverContext)
+ return React.cloneElement(children as React.ReactElement<{ onClick?: () => void }>, {
+ onClick: () => context.setOpen(true),
+ })
+ },
+ }
+})
+
+vi.mock('@/components/ui/command', () => ({
+ Command: ({ children }: { children: ReactNode; shouldFilter?: boolean }) => {children}
,
+ CommandEmpty: ({ children }: { children: ReactNode }) => {children}
,
+ CommandGroup: ({ children }: { children: ReactNode }) => {children}
,
+ CommandInput: ({
+ placeholder,
+ value,
+ onValueChange,
+ }: {
+ placeholder?: string
+ value: string
+ onValueChange: (value: string) => void
+ }) => (
+ onValueChange(event.currentTarget.value)}
+ />
+ ),
+ CommandItem: ({
+ children,
+ onSelect,
+ value,
+ }: {
+ children: ReactNode
+ onSelect?: () => void
+ value?: string
+ className?: string
+ }) => (
+
+ {children}
+
+ ),
+ CommandList: ({ children }: { children: ReactNode }) => {children}
,
+}))
+
+vi.mock('@heroicons/react/24/solid', () => ({
+ CheckIcon: ({ className }: { className?: string }) => check ,
+ ChevronUpDownIcon: () => chevron ,
+}))
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.contactSearchCalls = []
+ mocks.organizationCalls = []
+ mocks.inboxCalls = []
+ mocks.teamCalls = []
+ mocks.hasPermission = true
+})
+
+describe('ResourcePicker', () => {
+ it('selects, clears, searches, and renders labels for single values', () => {
+ const onValueChange = vi.fn()
+ const onSearchChange = vi.fn()
+ const { rerender } = render(
+ new },
+ { id: 'beta', label: 'Beta', leading: avatar },
+ ]}
+ placeholder="Pick one"
+ searchPlaceholder="Search resources"
+ emptyMessage="Nothing here"
+ allowClear
+ clearLabel="No resource"
+ onSearchChange={onSearchChange}
+ />
+ )
+
+ expect(screen.getByRole('combobox')).toHaveTextContent('Pick one')
+ fireEvent.click(screen.getByRole('combobox'))
+ expect(screen.getByText('No resource')).toBeInTheDocument()
+ fireEvent.change(screen.getByPlaceholderText('Search resources'), {
+ target: { value: 'be' },
+ })
+ expect(onSearchChange).toHaveBeenCalledWith('be')
+ fireEvent.click(screen.getByRole('button', { name: /Beta/ }))
+ expect(onValueChange).toHaveBeenCalledWith('beta')
+
+ rerender(
+
+ )
+ expect(screen.getByRole('combobox')).toHaveTextContent('Alpha')
+ fireEvent.click(screen.getByRole('combobox'))
+ fireEvent.click(screen.getByRole('button', { name: /Clear/ }))
+ expect(onValueChange).toHaveBeenCalledWith(null)
+ })
+
+ it('toggles multiple values and reports loading empty state', () => {
+ const onValueChange = vi.fn()
+ const { rerender } = render(
+
+ )
+
+ expect(screen.getByRole('combobox')).toHaveTextContent('2 selected')
+ fireEvent.click(screen.getByRole('combobox'))
+ fireEvent.click(screen.getByRole('button', { name: /Alpha/ }))
+ expect(onValueChange).toHaveBeenCalledWith(['missing'])
+ fireEvent.click(screen.getByRole('button', { name: /Beta/ }))
+ expect(onValueChange).toHaveBeenCalledWith(['alpha', 'missing', 'beta'])
+
+ rerender(
+
+ )
+ fireEvent.click(screen.getByRole('combobox'))
+ expect(screen.getByText(/Loading/)).toBeInTheDocument()
+ })
+})
+
+describe('picker wrappers', () => {
+ it('maps contacts, organizations, and inboxes into resource options', () => {
+ const onContactChange = vi.fn()
+ const onOrgChange = vi.fn()
+ const onInboxChange = vi.fn()
+ const onInboxMultiChange = vi.fn()
+ const onTeamChange = vi.fn()
+ const onTeamMultiChange = vi.fn()
+ const onStatusChange = vi.fn()
+
+ render(
+ <>
+
+
+
+
+
+
+
+ >
+ )
+
+ fireEvent.click(screen.getAllByRole('combobox')[0]!)
+ fireEvent.change(screen.getByPlaceholderText(/Search by name or email/), {
+ target: { value: 'ada' },
+ })
+ expect(mocks.contactSearchCalls.at(-1)).toEqual(['ada', true])
+ fireEvent.click(screen.getByRole('button', { name: /Ada Lovelace/ }))
+ expect(onContactChange).toHaveBeenCalledWith('contact_1')
+
+ fireEvent.click(screen.getAllByRole('combobox')[1]!)
+ fireEvent.change(screen.getByPlaceholderText(/Search organizations/), {
+ target: { value: 'acme' },
+ })
+ expect(mocks.organizationCalls.at(-1)).toEqual({ query: 'acme' })
+ fireEvent.click(screen.getByRole('button', { name: /Acme/ }))
+ expect(onOrgChange).toHaveBeenCalledWith('org_1')
+
+ fireEvent.click(screen.getAllByRole('combobox')[2]!)
+ expect(mocks.inboxCalls[0]).toEqual({ includeArchived: true })
+ fireEvent.click(screen.getByRole('button', { name: /Support/ }))
+ expect(onInboxChange).toHaveBeenCalledWith('inbox_1')
+
+ fireEvent.click(screen.getAllByRole('combobox')[3]!)
+ fireEvent.click(screen.getAllByRole('button', { name: /Billing/ }).at(-1)!)
+ expect(onInboxMultiChange).toHaveBeenCalledWith(['inbox_1', 'inbox_2'])
+
+ fireEvent.click(screen.getAllByRole('combobox')[4]!)
+ expect(mocks.teamCalls[0]).toEqual({ includeArchived: true })
+ fireEvent.click(screen.getAllByRole('button', { name: /Success/ }).at(-1)!)
+ expect(onTeamChange).toHaveBeenCalledWith('team_1')
+
+ fireEvent.click(screen.getAllByRole('combobox')[5]!)
+ fireEvent.click(screen.getAllByRole('button', { name: /Sales/ }).at(-1)!)
+ expect(onTeamMultiChange).toHaveBeenCalledWith(['team_1', 'team_2'])
+
+ fireEvent.click(screen.getAllByRole('combobox')[6]!)
+ fireEvent.click(screen.getAllByRole('button', { name: /Closed/ }).at(-1)!)
+ expect(onStatusChange).toHaveBeenCalledWith('status_2')
+ })
+
+ it('renders wrapper empty and loading messages', () => {
+ const { rerender } = render( )
+
+ fireEvent.click(screen.getByRole('combobox'))
+ expect(screen.getByText(/Type to search/)).toBeInTheDocument()
+
+ rerender( )
+ fireEvent.click(screen.getByRole('combobox'))
+ fireEvent.change(screen.getByPlaceholderText(/Search organizations/), {
+ target: { value: 'empty' },
+ })
+ expect(screen.getByText('No matches.')).toBeInTheDocument()
+ })
+})
+
+describe('PermissionGate', () => {
+ it('renders children or fallback from permission state', () => {
+ const { rerender } = render(
+ Denied}
+ >
+ Allowed
+
+ )
+
+ expect(screen.getByText('Allowed')).toBeInTheDocument()
+
+ mocks.hasPermission = false
+ rerender(
+ Denied}>
+ Allowed
+
+ )
+
+ expect(screen.getByText('Denied')).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/admin/shared/__tests__/shared-controls.test.tsx b/apps/web/src/components/admin/shared/__tests__/shared-controls.test.tsx
new file mode 100644
index 000000000..e97d634da
--- /dev/null
+++ b/apps/web/src/components/admin/shared/__tests__/shared-controls.test.tsx
@@ -0,0 +1,138 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { createRouteErrorComponent, RouteErrorBoundary } from '../route-error-boundary'
+import { ScopeSelector } from '../scope-selector'
+
+vi.mock('@/components/ui/alert', () => ({
+ Alert: ({ children }: { children: ReactNode; variant?: string; className?: string }) => (
+
+ ),
+ AlertDescription: ({ children }: { children: ReactNode; className?: string }) => (
+ {children}
+ ),
+ AlertTitle: ({ children }: { children: ReactNode }) => {children} ,
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ variant?: string
+ size?: string
+ className?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('../team-picker', () => ({
+ TeamPicker: ({
+ value,
+ onValueChange,
+ disabled,
+ }: {
+ value: string | null
+ onValueChange: (value: string | null) => void
+ disabled?: boolean
+ }) => (
+
+ Team picker {value ?? 'none'}
+ onValueChange('team_2')}>
+ Pick team
+
+
+ ),
+}))
+
+vi.mock('../inbox-picker', () => ({
+ InboxPicker: ({
+ value,
+ onValueChange,
+ disabled,
+ }: {
+ value: string | null
+ onValueChange: (value: string | null) => void
+ disabled?: boolean
+ }) => (
+
+ Inbox picker {value ?? 'none'}
+ onValueChange('inbox_2')}>
+ Pick inbox
+
+
+ ),
+}))
+
+vi.mock('@heroicons/react/24/outline', () => ({
+ ExclamationCircleIcon: () => error ,
+}))
+
+describe('RouteErrorBoundary', () => {
+ it('renders errors and factory-bound titles with reset actions', () => {
+ const reset = vi.fn()
+ const { rerender } = render(
+
+ )
+
+ expect(screen.getByRole('heading', { name: 'Custom failure' })).toBeInTheDocument()
+ expect(screen.getByText('Loader failed')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
+ expect(reset).toHaveBeenCalled()
+
+ const Bound = createRouteErrorComponent('Bound failure')
+ rerender( )
+ expect(screen.getByRole('heading', { name: 'Bound failure' })).toBeInTheDocument()
+ expect(screen.getByText('Route failed')).toBeInTheDocument()
+ })
+})
+
+describe('ScopeSelector', () => {
+ it('switches workspace, team, and inbox scopes', () => {
+ const onValueChange = vi.fn()
+ const { rerender } = render(
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'team' }))
+ expect(onValueChange).toHaveBeenCalledWith({ kind: 'team', teamId: null, inboxId: null })
+
+ rerender(
+
+ )
+ expect(screen.getByText(/Team picker team_1/)).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Pick team' }))
+ expect(onValueChange).toHaveBeenCalledWith({
+ kind: 'team',
+ teamId: 'team_2',
+ inboxId: null,
+ })
+
+ rerender(
+
+ )
+ expect(screen.getByText(/Inbox picker inbox_1/)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Pick inbox' })).toBeDisabled()
+ })
+})
diff --git a/apps/web/src/components/admin/shared/contact-picker.tsx b/apps/web/src/components/admin/shared/contact-picker.tsx
new file mode 100644
index 000000000..921efad71
--- /dev/null
+++ b/apps/web/src/components/admin/shared/contact-picker.tsx
@@ -0,0 +1,43 @@
+/**
+ * ` ` — single contact picker with cross-org search.
+ */
+import { useState } from 'react'
+import type { ContactId } from '@quackback/ids'
+import { useContactSearch } from '@/lib/client/hooks/use-orgs-contacts-queries'
+import { ResourcePicker } from './resource-picker'
+
+interface Props {
+ value: ContactId | null
+ onValueChange: (value: ContactId | null) => void
+ placeholder?: string
+ className?: string
+ disabled?: boolean
+ allowClear?: boolean
+}
+
+export function ContactPicker(props: Props) {
+ const [query, setQuery] = useState('')
+ const { data: contacts = [], isLoading } = useContactSearch(query, query.length > 0)
+ const options = contacts.map((c) => ({
+ id: c.id as ContactId,
+ label: c.name ?? c.email ?? c.id,
+ description: c.email ?? undefined,
+ }))
+ return (
+
+ value={props.value}
+ onValueChange={props.onValueChange}
+ options={options}
+ isLoading={isLoading}
+ placeholder={props.placeholder ?? 'Select contact…'}
+ searchPlaceholder="Search by name or email…"
+ emptyMessage={
+ query.length === 0 ? 'Type to search…' : isLoading ? 'Searching…' : 'No matches.'
+ }
+ disabled={props.disabled}
+ className={props.className}
+ allowClear={props.allowClear}
+ onSearchChange={setQuery}
+ />
+ )
+}
diff --git a/apps/web/src/components/admin/shared/inbox-picker.tsx b/apps/web/src/components/admin/shared/inbox-picker.tsx
new file mode 100644
index 000000000..c6bfc23b8
--- /dev/null
+++ b/apps/web/src/components/admin/shared/inbox-picker.tsx
@@ -0,0 +1,69 @@
+/**
+ * ` ` — single/multi inbox picker.
+ */
+import type { InboxId } from '@quackback/ids'
+import { useInboxes } from '@/lib/client/hooks/use-inboxes-queries'
+import { ResourcePicker } from './resource-picker'
+
+interface BaseProps {
+ placeholder?: string
+ className?: string
+ disabled?: boolean
+ includeArchived?: boolean
+}
+
+interface SingleProps extends BaseProps {
+ multiple?: false
+ value: InboxId | null
+ onValueChange: (value: InboxId | null) => void
+ allowClear?: boolean
+}
+
+interface MultiProps extends BaseProps {
+ multiple: true
+ value: InboxId[]
+ onValueChange: (value: InboxId[]) => void
+}
+
+export function InboxPicker(props: SingleProps | MultiProps) {
+ const { data: inboxes = [], isLoading } = useInboxes({
+ includeArchived: props.includeArchived,
+ })
+
+ const options = inboxes.map((i) => ({
+ id: i.id as InboxId,
+ label: i.name,
+ description: i.slug,
+ }))
+
+ if (props.multiple) {
+ return (
+
+ multiple
+ value={props.value}
+ onValueChange={props.onValueChange}
+ options={options}
+ isLoading={isLoading}
+ placeholder={props.placeholder ?? 'Select inboxes…'}
+ searchPlaceholder="Search inboxes…"
+ emptyMessage="No inboxes."
+ disabled={props.disabled}
+ className={props.className}
+ />
+ )
+ }
+ return (
+
+ value={props.value}
+ onValueChange={props.onValueChange}
+ options={options}
+ isLoading={isLoading}
+ placeholder={props.placeholder ?? 'Select inbox…'}
+ searchPlaceholder="Search inboxes…"
+ emptyMessage="No inboxes."
+ disabled={props.disabled}
+ className={props.className}
+ allowClear={props.allowClear}
+ />
+ )
+}
diff --git a/apps/web/src/components/admin/shared/index.ts b/apps/web/src/components/admin/shared/index.ts
new file mode 100644
index 000000000..5839b0e0b
--- /dev/null
+++ b/apps/web/src/components/admin/shared/index.ts
@@ -0,0 +1,16 @@
+/**
+ * Barrel export for shared admin/agent UI primitives.
+ */
+export { PermissionGate } from './permission-gate'
+export { PrincipalPicker } from './principal-picker'
+export { TeamPicker } from './team-picker'
+export { InboxPicker } from './inbox-picker'
+export { StatusPicker } from './status-picker'
+export { OrgPicker } from './org-picker'
+export { ContactPicker } from './contact-picker'
+export { ScopeSelector } from './scope-selector'
+export type { ScopeKind, ScopeValue } from './scope-selector'
+export { ResourcePicker } from './resource-picker'
+export type { PickerOption, ResourcePickerProps } from './resource-picker'
+export { RouteErrorBoundary, createRouteErrorComponent } from './route-error-boundary'
+export type { RouteErrorBoundaryProps } from './route-error-boundary'
diff --git a/apps/web/src/components/admin/shared/org-picker.tsx b/apps/web/src/components/admin/shared/org-picker.tsx
new file mode 100644
index 000000000..a7c6a3d27
--- /dev/null
+++ b/apps/web/src/components/admin/shared/org-picker.tsx
@@ -0,0 +1,41 @@
+/**
+ * ` ` — single organization picker with type-ahead.
+ */
+import { useState } from 'react'
+import type { OrganizationId } from '@quackback/ids'
+import { useOrganizations } from '@/lib/client/hooks/use-orgs-contacts-queries'
+import { ResourcePicker } from './resource-picker'
+
+interface Props {
+ value: OrganizationId | null
+ onValueChange: (value: OrganizationId | null) => void
+ placeholder?: string
+ className?: string
+ disabled?: boolean
+ allowClear?: boolean
+}
+
+export function OrgPicker(props: Props) {
+ const [query, setQuery] = useState('')
+ const { data: orgs = [], isLoading } = useOrganizations({ query: query || undefined })
+ const options = orgs.map((o) => ({
+ id: o.id as OrganizationId,
+ label: o.name,
+ description: o.domain ?? undefined,
+ }))
+ return (
+
+ value={props.value}
+ onValueChange={props.onValueChange}
+ options={options}
+ isLoading={isLoading}
+ placeholder={props.placeholder ?? 'Select organization…'}
+ searchPlaceholder="Search organizations…"
+ emptyMessage={isLoading ? 'Searching…' : 'No matches.'}
+ disabled={props.disabled}
+ className={props.className}
+ allowClear={props.allowClear}
+ onSearchChange={setQuery}
+ />
+ )
+}
diff --git a/apps/web/src/components/admin/shared/permission-gate.tsx b/apps/web/src/components/admin/shared/permission-gate.tsx
new file mode 100644
index 000000000..5959cb46f
--- /dev/null
+++ b/apps/web/src/components/admin/shared/permission-gate.tsx
@@ -0,0 +1,33 @@
+/**
+ * ` ` — render children only if the actor holds `permission`.
+ *
+ * Renders nothing while the underlying permission query is loading unless
+ * `loadingFallback` is provided. Use the `fallback` prop to render an
+ * alternate UI (e.g. an explanatory tooltip) when the actor is denied.
+ */
+import type { ReactNode } from 'react'
+import type { TeamId } from '@quackback/ids'
+import { useHasPermission } from '@/lib/client/hooks/use-authz-queries'
+import type { PermissionKey } from '@/lib/server/domains/authz'
+
+interface PermissionGateProps {
+ permission: PermissionKey
+ /** Optional team scope — passes `teamId` through to `useHasPermission`. */
+ teamId?: TeamId | null
+ children: ReactNode
+ fallback?: ReactNode
+ /** When true, treat loading state as "allowed" so the UI renders optimistically. */
+ loadingFallback?: boolean
+}
+
+export function PermissionGate({
+ permission,
+ teamId,
+ children,
+ fallback = null,
+ loadingFallback,
+}: PermissionGateProps) {
+ const allowed = useHasPermission(permission, { teamId, loadingFallback })
+ if (!allowed) return <>{fallback}>
+ return <>{children}>
+}
diff --git a/apps/web/src/components/admin/shared/principal-picker.tsx b/apps/web/src/components/admin/shared/principal-picker.tsx
new file mode 100644
index 000000000..56cf41805
--- /dev/null
+++ b/apps/web/src/components/admin/shared/principal-picker.tsx
@@ -0,0 +1,202 @@
+/**
+ * ` ` — async combobox over `searchPrincipalsFn`.
+ *
+ * Fires a debounced query as the user types and renders a picker list with
+ * avatar + name + email + role. Used by every assignee / member / recipient
+ * affordance in the agent and admin UIs.
+ */
+import { useState, useMemo, useEffect } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { ChevronUpDownIcon, CheckIcon } from '@heroicons/react/24/solid'
+import type { PrincipalId } from '@quackback/ids'
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { Button } from '@/components/ui/button'
+import { Avatar } from '@/components/ui/avatar'
+import { cn } from '@/lib/shared/utils'
+import {
+ searchPrincipalsFn,
+ getPrincipalsByIdsFn,
+ type PrincipalSearchRow,
+} from '@/lib/server/functions/principals'
+
+interface BaseProps {
+ placeholder?: string
+ /** Filter the search to specific principal roles (e.g. 'user' for portal users). */
+ roleFilter?: string[]
+ /** Hide these IDs from the result list. Useful for "add member" flows. */
+ excludeIds?: PrincipalId[]
+ disabled?: boolean
+ className?: string
+}
+
+interface SinglePickerProps extends BaseProps {
+ multiple?: false
+ value: PrincipalId | null
+ onValueChange: (value: PrincipalId | null) => void
+ /** Show "Unassigned" sentinel option that emits `null`. */
+ allowUnassigned?: boolean
+}
+
+interface MultiPickerProps extends BaseProps {
+ multiple: true
+ value: PrincipalId[]
+ onValueChange: (value: PrincipalId[]) => void
+}
+
+type PrincipalPickerProps = SinglePickerProps | MultiPickerProps
+
+const principalSearchKey = (q: string, roleFilter?: string[], excludeIds?: PrincipalId[]) =>
+ ['principals', 'search', q, roleFilter ?? [], excludeIds ?? []] as const
+
+export function PrincipalPicker(props: PrincipalPickerProps) {
+ const [open, setOpen] = useState(false)
+ const [search, setSearch] = useState('')
+ const [debouncedSearch, setDebouncedSearch] = useState('')
+ useEffect(() => {
+ const t = setTimeout(() => setDebouncedSearch(search), 200)
+ return () => clearTimeout(t)
+ }, [search])
+
+ const { data: results = [], isLoading } = useQuery({
+ queryKey: principalSearchKey(debouncedSearch, props.roleFilter, props.excludeIds),
+ queryFn: () =>
+ searchPrincipalsFn({
+ data: {
+ query: debouncedSearch || undefined,
+ roleFilter: props.roleFilter,
+ excludeIds: props.excludeIds,
+ limit: 25,
+ },
+ }),
+ enabled: open,
+ staleTime: 30_000,
+ })
+
+ // Resolve labels for current value(s) so the trigger always renders.
+ const valueIds = useMemo(
+ () => (props.multiple ? props.value : props.value ? [props.value] : []),
+ [props]
+ )
+ const { data: selected = [] } = useQuery({
+ queryKey: ['principals', 'byIds', valueIds],
+ queryFn: () => getPrincipalsByIdsFn({ data: { ids: valueIds } }),
+ enabled: valueIds.length > 0,
+ staleTime: 60_000,
+ })
+
+ const selectedById = useMemo(() => new Map(selected.map((s) => [s.id, s] as const)), [selected])
+
+ function toggle(id: PrincipalId) {
+ if (props.multiple) {
+ const next = props.value.includes(id)
+ ? props.value.filter((v) => v !== id)
+ : [...props.value, id]
+ props.onValueChange(next)
+ } else {
+ props.onValueChange(id)
+ setOpen(false)
+ }
+ }
+
+ function renderTriggerLabel() {
+ if (props.multiple) {
+ if (props.value.length === 0) return props.placeholder ?? 'Select people…'
+ if (props.value.length === 1) {
+ const row = selectedById.get(props.value[0]!)
+ return labelOf(row) ?? '1 selected'
+ }
+ return `${props.value.length} selected`
+ }
+ if (!props.value) return props.placeholder ?? 'Select person…'
+ const row = selectedById.get(props.value)
+ return labelOf(row) ?? '…'
+ }
+
+ return (
+
+
+
+ {renderTriggerLabel()}
+
+
+
+
+
+
+
+ {isLoading ? 'Searching…' : 'No matches.'}
+
+ {!props.multiple && 'allowUnassigned' in props && props.allowUnassigned && (
+ {
+ ;(props as SinglePickerProps).onValueChange(null)
+ setOpen(false)
+ }}
+ >
+
+ Unassigned
+
+ )}
+ {results.map((row) => {
+ const isSelected = props.multiple
+ ? props.value.includes(row.id)
+ : props.value === row.id
+ return (
+ toggle(row.id)}
+ className="flex items-center gap-2"
+ >
+
+
+
+
+ {row.displayName ?? row.email ?? row.id}
+
+ {row.email && (
+
{row.email}
+ )}
+
+ {row.role}
+
+ )
+ })}
+
+
+
+
+
+ )
+}
+
+function labelOf(row: PrincipalSearchRow | undefined): string | null {
+ if (!row) return null
+ return row.displayName ?? row.email ?? row.id
+}
diff --git a/apps/web/src/components/admin/shared/resource-picker.tsx b/apps/web/src/components/admin/shared/resource-picker.tsx
new file mode 100644
index 000000000..a8710a354
--- /dev/null
+++ b/apps/web/src/components/admin/shared/resource-picker.tsx
@@ -0,0 +1,165 @@
+/**
+ * Generic searchable single/multi resource picker built on Popover + cmdk.
+ *
+ * Used by ` `, ` `, ` `,
+ * ` `, ` `. Keeps the network layer external —
+ * the consumer wires its own query hook and passes the result rows in.
+ */
+import { useState, type ReactNode } from 'react'
+import { ChevronUpDownIcon, CheckIcon } from '@heroicons/react/24/solid'
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { Button } from '@/components/ui/button'
+import { cn } from '@/lib/shared/utils'
+
+export interface PickerOption {
+ id: TId
+ label: string
+ description?: string
+ /** Optional leading visual (avatar / colour chip). */
+ leading?: ReactNode
+ /** Optional trailing badge (count / role label). */
+ trailing?: ReactNode
+}
+
+interface BaseProps {
+ options: PickerOption[]
+ isLoading?: boolean
+ placeholder?: string
+ searchPlaceholder?: string
+ emptyMessage?: string
+ disabled?: boolean
+ className?: string
+ onSearchChange?: (q: string) => void
+}
+
+interface SingleProps extends BaseProps {
+ multiple?: false
+ value: TId | null
+ onValueChange: (value: TId | null) => void
+ allowClear?: boolean
+ clearLabel?: string
+}
+
+interface MultiProps extends BaseProps {
+ multiple: true
+ value: TId[]
+ onValueChange: (value: TId[]) => void
+}
+
+export type ResourcePickerProps = SingleProps | MultiProps
+
+export function ResourcePicker(props: ResourcePickerProps) {
+ const [open, setOpen] = useState(false)
+ const [search, setSearch] = useState('')
+ const optionsById = new Map(props.options.map((o) => [o.id, o]))
+
+ function toggle(id: TId) {
+ if (props.multiple) {
+ const next = props.value.includes(id)
+ ? props.value.filter((v) => v !== id)
+ : [...props.value, id]
+ props.onValueChange(next)
+ } else {
+ props.onValueChange(id)
+ setOpen(false)
+ }
+ }
+
+ function triggerLabel(): string {
+ if (props.multiple) {
+ if (props.value.length === 0) return props.placeholder ?? 'Select…'
+ if (props.value.length === 1) return optionsById.get(props.value[0]!)?.label ?? '1 selected'
+ return `${props.value.length} selected`
+ }
+ if (!props.value) return props.placeholder ?? 'Select…'
+ return optionsById.get(props.value)?.label ?? '…'
+ }
+
+ return (
+
+
+
+ {triggerLabel()}
+
+
+
+
+
+ {
+ setSearch(q)
+ props.onSearchChange?.(q)
+ }}
+ />
+
+
+ {props.isLoading ? 'Loading…' : (props.emptyMessage ?? 'No results.')}
+
+
+ {!props.multiple && props.allowClear && (
+ {
+ ;(props as SingleProps).onValueChange(null)
+ setOpen(false)
+ }}
+ >
+
+ {props.clearLabel ?? 'Clear'}
+
+ )}
+ {props.options.map((opt) => {
+ const isSelected = props.multiple
+ ? props.value.includes(opt.id)
+ : props.value === opt.id
+ return (
+ toggle(opt.id)}
+ className="flex items-center gap-2"
+ >
+
+ {opt.leading}
+
+
{opt.label}
+ {opt.description && (
+
+ {opt.description}
+
+ )}
+
+ {opt.trailing}
+
+ )
+ })}
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/shared/route-error-boundary.tsx b/apps/web/src/components/admin/shared/route-error-boundary.tsx
new file mode 100644
index 000000000..4dcac609c
--- /dev/null
+++ b/apps/web/src/components/admin/shared/route-error-boundary.tsx
@@ -0,0 +1,44 @@
+/**
+ * Shared route-level error boundary for admin routes. Wire into a route via
+ * `errorComponent: createRouteErrorComponent('Failed to load …')`.
+ */
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Button } from '@/components/ui/button'
+import { ExclamationCircleIcon } from '@heroicons/react/24/outline'
+
+export interface RouteErrorBoundaryProps {
+ error: Error
+ reset: () => void
+ title?: string
+}
+
+export function RouteErrorBoundary({
+ error,
+ reset,
+ title = 'Something went wrong',
+}: RouteErrorBoundaryProps) {
+ return (
+
+
+
+ {title}
+
+ {error.message}
+
+ Try again
+
+
+
+
+ )
+}
+
+/**
+ * Convenience factory: returns a TanStack Router `errorComponent` bound to a
+ * specific title.
+ */
+export function createRouteErrorComponent(title: string) {
+ return function BoundRouteErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
+ return
+ }
+}
diff --git a/apps/web/src/components/admin/shared/scope-selector.tsx b/apps/web/src/components/admin/shared/scope-selector.tsx
new file mode 100644
index 000000000..8a71d0a49
--- /dev/null
+++ b/apps/web/src/components/admin/shared/scope-selector.tsx
@@ -0,0 +1,68 @@
+/**
+ * ` ` — workspace | team | inbox toggle reused by SLA and
+ * routing-rule editors.
+ */
+import { Button } from '@/components/ui/button'
+import { cn } from '@/lib/shared/utils'
+import { TeamPicker } from './team-picker'
+import { InboxPicker } from './inbox-picker'
+import type { TeamId, InboxId } from '@quackback/ids'
+
+export type ScopeKind = 'workspace' | 'team' | 'inbox'
+
+export interface ScopeValue {
+ kind: ScopeKind
+ teamId?: TeamId | null
+ inboxId?: InboxId | null
+}
+
+interface Props {
+ value: ScopeValue
+ onValueChange: (value: ScopeValue) => void
+ /** Restrict the toggleable kinds (e.g. omit 'inbox' for routing rules). */
+ allowedKinds?: ScopeKind[]
+ disabled?: boolean
+ className?: string
+}
+
+export function ScopeSelector({
+ value,
+ onValueChange,
+ allowedKinds = ['workspace', 'team', 'inbox'],
+ disabled,
+ className,
+}: Props) {
+ return (
+
+
+ {allowedKinds.map((kind) => (
+ onValueChange({ kind, teamId: null, inboxId: null })}
+ >
+ {kind}
+
+ ))}
+
+ {value.kind === 'team' && (
+
onValueChange({ kind: 'team', teamId, inboxId: null })}
+ disabled={disabled}
+ />
+ )}
+ {value.kind === 'inbox' && (
+ onValueChange({ kind: 'inbox', teamId: null, inboxId })}
+ disabled={disabled}
+ />
+ )}
+
+ )
+}
diff --git a/apps/web/src/components/admin/shared/status-picker.tsx b/apps/web/src/components/admin/shared/status-picker.tsx
new file mode 100644
index 000000000..cfbbb5cd2
--- /dev/null
+++ b/apps/web/src/components/admin/shared/status-picker.tsx
@@ -0,0 +1,43 @@
+/**
+ * ` ` — single ticket-status picker over `useTicketStatuses()`.
+ */
+import type { TicketStatusId } from '@quackback/ids'
+import { useTicketStatuses } from '@/lib/client/hooks/use-tickets-queries'
+import { ResourcePicker } from './resource-picker'
+
+interface Props {
+ value: TicketStatusId | null
+ onValueChange: (value: TicketStatusId | null) => void
+ placeholder?: string
+ className?: string
+ disabled?: boolean
+}
+
+export function StatusPicker(props: Props) {
+ const { data: statuses = [], isLoading } = useTicketStatuses()
+ const options = statuses.map((s) => ({
+ id: s.id as TicketStatusId,
+ label: s.name,
+ description: s.category,
+ leading: s.color ? (
+
+ ) : null,
+ }))
+ return (
+
+ value={props.value}
+ onValueChange={props.onValueChange}
+ options={options}
+ isLoading={isLoading}
+ placeholder={props.placeholder ?? 'Select status…'}
+ searchPlaceholder="Search statuses…"
+ emptyMessage="No statuses."
+ disabled={props.disabled}
+ className={props.className}
+ />
+ )
+}
diff --git a/apps/web/src/components/admin/shared/team-picker.tsx b/apps/web/src/components/admin/shared/team-picker.tsx
new file mode 100644
index 000000000..d2d4274f6
--- /dev/null
+++ b/apps/web/src/components/admin/shared/team-picker.tsx
@@ -0,0 +1,77 @@
+/**
+ * ` ` — single/multi team picker over `useTeams()`.
+ */
+import type { TeamId } from '@quackback/ids'
+import { useTeams } from '@/lib/client/hooks/use-teams-queries'
+import { ResourcePicker } from './resource-picker'
+
+interface BaseProps {
+ placeholder?: string
+ className?: string
+ disabled?: boolean
+ includeArchived?: boolean
+}
+
+interface SingleProps extends BaseProps {
+ multiple?: false
+ value: TeamId | null
+ onValueChange: (value: TeamId | null) => void
+ allowClear?: boolean
+}
+
+interface MultiProps extends BaseProps {
+ multiple: true
+ value: TeamId[]
+ onValueChange: (value: TeamId[]) => void
+}
+
+export function TeamPicker(props: SingleProps | MultiProps) {
+ const { data: teams = [], isLoading } = useTeams({ includeArchived: props.includeArchived })
+
+ const options = teams.map((t) => ({
+ id: t.id as TeamId,
+ label: t.name,
+ description: t.slug,
+ leading: t.color ? (
+
+ ) : null,
+ trailing: t.shortLabel ? (
+ {t.shortLabel}
+ ) : null,
+ }))
+
+ if (props.multiple) {
+ return (
+
+ multiple
+ value={props.value}
+ onValueChange={props.onValueChange}
+ options={options}
+ isLoading={isLoading}
+ placeholder={props.placeholder ?? 'Select teams…'}
+ searchPlaceholder="Search teams…"
+ emptyMessage="No teams."
+ disabled={props.disabled}
+ className={props.className}
+ />
+ )
+ }
+ return (
+
+ value={props.value}
+ onValueChange={props.onValueChange}
+ options={options}
+ isLoading={isLoading}
+ placeholder={props.placeholder ?? 'Select team…'}
+ searchPlaceholder="Search teams…"
+ emptyMessage="No teams."
+ disabled={props.disabled}
+ className={props.className}
+ allowClear={props.allowClear}
+ />
+ )
+}
diff --git a/apps/web/src/components/admin/tickets/__tests__/ticket-activity-timeline.test.tsx b/apps/web/src/components/admin/tickets/__tests__/ticket-activity-timeline.test.tsx
new file mode 100644
index 000000000..71508467e
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/__tests__/ticket-activity-timeline.test.tsx
@@ -0,0 +1,326 @@
+// @vitest-environment happy-dom
+import { render, screen } from '@testing-library/react'
+import { useSuspenseQuery } from '@tanstack/react-query'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { TicketActivityTimeline } from '../ticket-activity-timeline'
+
+vi.mock('@tanstack/react-query', () => ({
+ useSuspenseQuery: vi.fn(),
+}))
+
+vi.mock('@/lib/client/queries/tickets', () => ({
+ ticketQueries: {
+ activity: vi.fn(() => ({ queryKey: ['tickets', 'activity'] })),
+ },
+}))
+
+vi.mock('@/components/ui/time-ago', () => ({
+ TimeAgo: () => 2 minutes ago ,
+}))
+
+const useSuspenseQueryMock = vi.mocked(useSuspenseQuery)
+
+function mockActivity(rows: unknown[]) {
+ useSuspenseQueryMock.mockReturnValue({ data: rows } as never)
+}
+
+describe('TicketActivityTimeline', () => {
+ beforeEach(() => {
+ useSuspenseQueryMock.mockReset()
+ })
+
+ it('renders an empty activity state', () => {
+ mockActivity([])
+
+ render( )
+
+ expect(screen.getByText('No activity yet.')).toBeInTheDocument()
+ })
+
+ it('renders description changes without exposing raw diff metadata or principal IDs', () => {
+ mockActivity([
+ {
+ id: 'ticket_act_1',
+ principalId: 'principal_01ktxq7sh1fevtx68ee59xpvx0',
+ type: 'ticket.updated',
+ actorName: null,
+ createdAt: '2026-06-12T10:00:00.000Z',
+ metadata: {
+ diff: {
+ descriptionText: {
+ from: 'old raw description',
+ to: 'new raw description',
+ },
+ },
+ },
+ },
+ ])
+
+ render( )
+
+ expect(screen.getByText('Someone updated the description')).toBeInTheDocument()
+ expect(screen.getByText('Description changed')).toBeInTheDocument()
+ expect(screen.queryByText(/descriptionText/i)).not.toBeInTheDocument()
+ expect(screen.queryByText(/principal_01ktxq7/i)).not.toBeInTheDocument()
+ expect(screen.queryByText(/old raw description/i)).not.toBeInTheDocument()
+ })
+
+ it('renders field, status, and thread activity as readable summaries', () => {
+ mockActivity([
+ {
+ id: 'ticket_act_1',
+ principalId: 'principal_1',
+ type: 'ticket.updated',
+ actorName: 'Meli',
+ createdAt: '2026-06-12T10:00:00.000Z',
+ metadata: {
+ diff: {
+ priority: { from: 'normal', to: 'urgent' },
+ },
+ },
+ },
+ {
+ id: 'ticket_act_2',
+ principalId: null,
+ type: 'ticket.status_changed',
+ actorName: null,
+ createdAt: '2026-06-12T09:00:00.000Z',
+ metadata: {
+ from: { statusId: 'ticket_status_old', category: 'open' },
+ to: { statusId: 'ticket_status_new', category: 'pending' },
+ },
+ },
+ {
+ id: 'ticket_act_3',
+ principalId: 'principal_2',
+ type: 'thread.added',
+ actorName: 'Agent',
+ createdAt: '2026-06-12T08:00:00.000Z',
+ metadata: { threadId: 'ticket_thread_1', audience: 'public' },
+ },
+ ])
+
+ render( )
+
+ expect(screen.getByText('Meli changed priority')).toBeInTheDocument()
+ expect(screen.getByText('Normal')).toBeInTheDocument()
+ expect(screen.getByText('Urgent')).toBeInTheDocument()
+ expect(screen.getByText('System changed status')).toBeInTheDocument()
+ expect(screen.getByText('Open')).toBeInTheDocument()
+ expect(screen.getByText('Pending')).toBeInTheDocument()
+ expect(screen.getByText('Agent posted a public reply')).toBeInTheDocument()
+ expect(
+ screen.queryByText(/ticket\.updated|thread\.added|ticket_thread_1/i)
+ ).not.toBeInTheDocument()
+ })
+
+ it('renders the remaining event types and summarized metadata', () => {
+ mockActivity([
+ {
+ id: 'ticket_act_created',
+ principalId: 'principal_1',
+ type: 'ticket.created',
+ actorName: null,
+ createdAt: '2026-06-12T10:00:00.000Z',
+ metadata: { statusCategory: 'closed', priority: 'high', channel: 'github' },
+ },
+ {
+ id: 'ticket_act_routed',
+ principalId: null,
+ type: 'ticket.routed',
+ actorName: null,
+ createdAt: '2026-06-12T09:59:00.000Z',
+ metadata: {
+ ruleId: 'routing_rule_1',
+ inboxId: 'inbox_1',
+ primaryTeamId: 'team_1',
+ assigneePrincipalId: 'principal_1',
+ },
+ },
+ {
+ id: 'ticket_act_updated_many',
+ principalId: 'principal_2',
+ type: 'ticket.updated',
+ actorName: 'Agent',
+ createdAt: '2026-06-12T09:58:00.000Z',
+ metadata: {
+ diff: {
+ subject: { from: 'Old subject', to: 'New subject' },
+ visibilityScope: { from: 'team', to: 'private' },
+ customFlag: { from: false, to: true },
+ score: { from: 1, to: 2 },
+ assigneePrincipalId: {
+ from: 'principal_01ktxq7sh1fevtx68ee59xpvx0',
+ to: null,
+ },
+ },
+ },
+ },
+ {
+ id: 'ticket_act_assigned',
+ principalId: 'principal_2',
+ type: 'ticket.assigned',
+ actorName: 'Agent',
+ createdAt: '2026-06-12T09:57:00.000Z',
+ metadata: {
+ from: { teamId: 'team_old' },
+ to: { principalId: 'principal_3', teamId: 'team_1' },
+ },
+ },
+ {
+ id: 'ticket_act_no_assignee',
+ principalId: 'principal_2',
+ type: 'ticket.assigned',
+ actorName: 'Agent',
+ createdAt: '2026-06-12T09:56:00.000Z',
+ metadata: { from: {}, to: {} },
+ },
+ {
+ id: 'ticket_act_unassigned',
+ principalId: 'principal_2',
+ type: 'ticket.unassigned',
+ actorName: 'Agent',
+ createdAt: '2026-06-12T09:55:00.000Z',
+ metadata: {},
+ },
+ {
+ id: 'ticket_act_deleted',
+ principalId: 'principal_2',
+ type: 'ticket.deleted',
+ actorName: 'Agent',
+ createdAt: '2026-06-12T09:54:00.000Z',
+ metadata: {},
+ },
+ {
+ id: 'ticket_act_restored',
+ principalId: 'principal_2',
+ type: 'ticket.restored',
+ actorName: 'Agent',
+ createdAt: '2026-06-12T09:53:00.000Z',
+ metadata: {},
+ },
+ {
+ id: 'ticket_act_thread_unknown',
+ principalId: 'principal_2',
+ type: 'thread.added',
+ actorName: 'Agent',
+ createdAt: '2026-06-12T09:52:00.000Z',
+ metadata: { audience: 'shared-team' },
+ },
+ {
+ id: 'ticket_act_thread_edited',
+ principalId: 'principal_2',
+ type: 'thread.edited',
+ actorName: 'Agent',
+ createdAt: '2026-06-12T09:51:00.000Z',
+ metadata: {},
+ },
+ {
+ id: 'ticket_act_thread_deleted',
+ principalId: 'principal_2',
+ type: 'thread.deleted',
+ actorName: 'Agent',
+ createdAt: '2026-06-12T09:50:00.000Z',
+ metadata: {},
+ },
+ {
+ id: 'ticket_act_participant_added',
+ principalId: 'principal_2',
+ type: 'participant.added',
+ actorName: 'Agent',
+ createdAt: '2026-06-12T09:49:00.000Z',
+ metadata: { role: 'collaborator', principalId: 'principal_3', contactId: 'contact_1' },
+ },
+ {
+ id: 'ticket_act_participant_removed',
+ principalId: 'principal_2',
+ type: 'participant.removed',
+ actorName: 'Agent',
+ createdAt: '2026-06-12T09:48:00.000Z',
+ metadata: {},
+ },
+ {
+ id: 'ticket_act_shared',
+ principalId: 'principal_2',
+ type: 'ticket.shared',
+ actorName: 'Agent',
+ createdAt: '2026-06-12T09:47:00.000Z',
+ metadata: { accessLevel: 'full' },
+ },
+ {
+ id: 'ticket_act_unshared',
+ principalId: 'principal_2',
+ type: 'ticket.unshared',
+ actorName: 'Agent',
+ createdAt: '2026-06-12T09:46:00.000Z',
+ metadata: {},
+ },
+ {
+ id: 'ticket_act_attachment_added',
+ principalId: 'principal_2',
+ type: 'attachment.added',
+ actorName: 'Agent',
+ createdAt: '2026-06-12T09:45:00.000Z',
+ metadata: {
+ filename:
+ 'very-long-diagnostic-bundle-name-that-should-be-truncated-before-rendering-because-it-is-more-than-one-hundred-and-twenty-characters.zip',
+ },
+ },
+ {
+ id: 'ticket_act_attachment_removed',
+ principalId: 'principal_2',
+ type: 'attachment.removed',
+ actorName: 'Agent',
+ createdAt: '2026-06-12T09:44:00.000Z',
+ metadata: {},
+ },
+ {
+ id: 'ticket_act_default',
+ principalId: 'principal_2',
+ type: 'custom.audit_event',
+ actorName: ' ',
+ createdAt: '2026-06-12T09:43:00.000Z',
+ metadata: {},
+ },
+ ])
+
+ render(
+
+ )
+
+ expect(screen.getByText('Ada created this ticket')).toBeInTheDocument()
+ expect(screen.getByText('Closed')).toBeInTheDocument()
+ expect(screen.getByText('GitHub')).toBeInTheDocument()
+ expect(screen.getByText('System routed this ticket')).toBeInTheDocument()
+ expect(screen.getByText('Matched routing rule')).toBeInTheDocument()
+ expect(screen.getByText('Agent updated 5 fields')).toBeInTheDocument()
+ expect(screen.getByText('Old subject')).toBeInTheDocument()
+ expect(screen.getByText('Private')).toBeInTheDocument()
+ expect(screen.getByText('Enabled')).toBeInTheDocument()
+ expect(screen.getByText('Assigned to person and team')).toBeInTheDocument()
+ expect(screen.getByText('Previously assigned')).toBeInTheDocument()
+ expect(screen.getByText('No assignee')).toBeInTheDocument()
+ expect(screen.getByText('Agent removed the assignee')).toBeInTheDocument()
+ expect(screen.getByText('Agent deleted this ticket')).toBeInTheDocument()
+ expect(screen.getByText('Agent restored this ticket')).toBeInTheDocument()
+ expect(screen.getByText('Agent posted shared team')).toBeInTheDocument()
+ expect(screen.getByText('Agent edited a reply')).toBeInTheDocument()
+ expect(screen.getByText('Agent deleted a reply')).toBeInTheDocument()
+ expect(screen.getByText('Agent added a participant')).toBeInTheDocument()
+ expect(screen.getByText('Collaborator')).toBeInTheDocument()
+ expect(screen.getByText('User')).toBeInTheDocument()
+ expect(screen.getByText('Contact')).toBeInTheDocument()
+ expect(screen.getByText('Agent removed a participant')).toBeInTheDocument()
+ expect(screen.getByText('Agent shared this ticket with a team')).toBeInTheDocument()
+ expect(screen.getByText('Full access')).toBeInTheDocument()
+ expect(screen.getByText('Agent removed a team share')).toBeInTheDocument()
+ expect(screen.getByText('Agent added an attachment')).toBeInTheDocument()
+ expect(screen.getByText(/very-long-diagnostic-bundle-name/)).toBeInTheDocument()
+ expect(screen.getByText('Agent removed an attachment')).toBeInTheDocument()
+ expect(screen.getByText('Grace recorded Custom Audit Event')).toBeInTheDocument()
+ expect(screen.queryByText(/principal_01ktxq7/i)).not.toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/admin/tickets/__tests__/ticket-header-and-chips.test.tsx b/apps/web/src/components/admin/tickets/__tests__/ticket-header-and-chips.test.tsx
new file mode 100644
index 000000000..c4559dab9
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/__tests__/ticket-header-and-chips.test.tsx
@@ -0,0 +1,322 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { SlaClockChip } from '../sla-clock-chip'
+import { TicketChannelIcon, type TicketChannel } from '../ticket-channel-icon'
+import { TicketDetailHeader } from '../ticket-detail-header'
+
+const mocks = vi.hoisted(() => ({
+ navigate: vi.fn(),
+ invalidateQueries: vi.fn(),
+ takeTicketFn: vi.fn(),
+ returnTicketFn: vi.fn(),
+ softDeleteTicketFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ Link: ({ children, to }: { children: ReactNode; to: string; className?: string }) => (
+ {children}
+ ),
+ useRouter: () => ({
+ navigate: mocks.navigate,
+ }),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useMutation: ({
+ mutationFn,
+ onSuccess,
+ onError,
+ }: {
+ mutationFn: () => Promise
+ onSuccess?: () => void
+ onError?: (error: Error) => void
+ }) => ({
+ isPending: false,
+ mutate: () => {
+ void mutationFn()
+ .then(() => onSuccess?.())
+ .catch((error) => onError?.(error instanceof Error ? error : new Error(String(error))))
+ },
+ }),
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+vi.mock('@/lib/server/functions/tickets', () => ({
+ takeTicketFn: mocks.takeTicketFn,
+ returnTicketFn: mocks.returnTicketFn,
+ softDeleteTicketFn: mocks.softDeleteTicketFn,
+}))
+
+vi.mock('@/lib/client/queries/tickets', () => ({
+ ticketQueries: {
+ detail: (ticketId: string) => ({ queryKey: ['tickets', 'detail', ticketId] }),
+ },
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ onClick,
+ disabled,
+ 'aria-label': ariaLabel,
+ asChild,
+ }: {
+ children: ReactNode
+ onClick?: () => void
+ disabled?: boolean
+ 'aria-label'?: string
+ asChild?: boolean
+ variant?: string
+ size?: string
+ }) =>
+ asChild ? (
+ <>{children}>
+ ) : (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/time-ago', () => ({
+ TimeAgo: ({ date }: { date: Date | string }) => {String(date)} ,
+}))
+
+vi.mock('../ticket-priority-chip', () => ({
+ TicketPriorityChip: ({ priority }: { priority: string }) => Priority {priority} ,
+}))
+
+vi.mock('../ticket-subscription-menu', () => ({
+ TicketSubscriptionMenu: ({ ticketId }: { ticketId: string }) => (
+ Subscribe {ticketId}
+ ),
+}))
+
+vi.mock('@/components/ui/alert-dialog', async () => {
+ const React = await import('react')
+ const AlertDialogContext = React.createContext<{
+ open: boolean
+ setOpen: (open: boolean) => void
+ }>({
+ open: false,
+ setOpen: () => {},
+ })
+ return {
+ AlertDialog: ({ children }: { children: ReactNode }) => {
+ const [open, setOpen] = React.useState(false)
+ return (
+
+ {children}
+
+ )
+ },
+ AlertDialogAction: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
+
+ {children}
+
+ ),
+ AlertDialogCancel: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ AlertDialogContent: ({ children }: { children: ReactNode }) => {
+ const context = React.useContext(AlertDialogContext)
+ return context.open ? : null
+ },
+ AlertDialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogFooter: ({ children }: { children: ReactNode }) => ,
+ AlertDialogHeader: ({ children }: { children: ReactNode }) => ,
+ AlertDialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+ AlertDialogTrigger: ({ children }: { children: React.ReactElement; asChild?: boolean }) => {
+ const context = React.useContext(AlertDialogContext)
+ return React.cloneElement(children as React.ReactElement<{ onClick?: () => void }>, {
+ onClick: () => context.setOpen(true),
+ })
+ },
+ }
+})
+
+vi.mock('@heroicons/react/24/outline', () => ({
+ ArrowLeftIcon: () => back ,
+ ChatBubbleBottomCenterTextIcon: ({ 'aria-label': ariaLabel }: { 'aria-label'?: string }) => (
+ widget
+ ),
+ CodeBracketIcon: ({ 'aria-label': ariaLabel }: { 'aria-label'?: string }) => (
+ api
+ ),
+ EnvelopeIcon: ({ 'aria-label': ariaLabel }: { 'aria-label'?: string }) => (
+ email
+ ),
+ GlobeAltIcon: ({ 'aria-label': ariaLabel }: { 'aria-label'?: string }) => (
+ portal
+ ),
+ TrashIcon: () => trash ,
+}))
+
+function ticket(overrides: Partial[0]['ticket']> = {}) {
+ return {
+ id: 'ticket_1' as never,
+ subject: 'Login is broken',
+ channel: 'widget',
+ priority: 'high',
+ visibilityScope: 'team',
+ updatedAt: '2026-06-20T10:00:00.000Z',
+ assigneePrincipalId: null,
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ vi.spyOn(Date, 'now').mockReturnValue(new Date('2026-06-20T10:00:00.000Z').getTime())
+ mocks.takeTicketFn.mockResolvedValue(undefined)
+ mocks.returnTicketFn.mockResolvedValue(undefined)
+ mocks.softDeleteTicketFn.mockResolvedValue(undefined)
+ mocks.invalidateQueries.mockResolvedValue(undefined)
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+})
+
+describe('SlaClockChip', () => {
+ it('formats clock states, urgency colors, and kind labels', () => {
+ const { rerender } = render(
+
+ )
+ expect(screen.getByText('First response:')).toBeInTheDocument()
+ expect(screen.getByText('2h 0m')).toHaveAttribute(
+ 'title',
+ expect.stringContaining('First response due')
+ )
+
+ rerender(
+
+ )
+ expect(screen.getByText('2d 1h')).toBeInTheDocument()
+
+ rerender(
+
+ )
+ expect(screen.getByText('30m')).toBeInTheDocument()
+
+ rerender(
+
+ )
+ expect(screen.getByText(/5m/)).toBeInTheDocument()
+
+ rerender(
+
+ )
+ expect(screen.getByText('Met')).toBeInTheDocument()
+
+ rerender(
+
+ )
+ expect(screen.getByText('Paused')).toBeInTheDocument()
+
+ rerender(
+
+ )
+ expect(screen.getByText('Cancelled')).toBeInTheDocument()
+ })
+})
+
+describe('TicketChannelIcon', () => {
+ it('maps every channel to an icon', () => {
+ ;(['email', 'portal', 'api', 'widget'] as TicketChannel[]).forEach((channel) => {
+ const { unmount } = render( )
+ expect(screen.getByLabelText(channel)).toBeInTheDocument()
+ unmount()
+ })
+ })
+})
+
+describe('TicketDetailHeader', () => {
+ it('takes, returns, subscribes, and deletes tickets', async () => {
+ const { rerender } = render(
+
+ )
+
+ expect(screen.getByRole('link', { name: /Queue/ })).toHaveAttribute('href', '/admin/tickets')
+ expect(screen.getByText('Login is broken')).toBeInTheDocument()
+ expect(screen.getByLabelText('widget')).toBeInTheDocument()
+ expect(screen.getByText('Priority high')).toBeInTheDocument()
+ expect(screen.getByText('Team')).toBeInTheDocument()
+ expect(screen.getByText('Subscribe ticket_1')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Take' }))
+ await waitFor(() => {
+ expect(mocks.takeTicketFn).toHaveBeenCalledWith({ data: { ticketId: 'ticket_1' } })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['tickets', 'detail', 'ticket_1'],
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['tickets', 'list'] })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Assigned to you')
+
+ rerender(
+
+ )
+ expect(screen.getByText('custom')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Return' }))
+ await waitFor(() => {
+ expect(mocks.returnTicketFn).toHaveBeenCalledWith({ data: { ticketId: 'ticket_1' } })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Returned to team')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Delete ticket' }))
+ expect(screen.getByRole('heading', { name: 'Delete ticket?' })).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
+ await waitFor(() => {
+ expect(mocks.softDeleteTicketFn).toHaveBeenCalledWith({ data: { ticketId: 'ticket_1' } })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Ticket deleted')
+ expect(mocks.navigate).toHaveBeenCalledWith({ to: '/admin/tickets' })
+ })
+
+ it('reports mutation failures', async () => {
+ mocks.takeTicketFn.mockRejectedValueOnce(new Error('not allowed'))
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Take' }))
+
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('not allowed')
+ })
+ })
+})
diff --git a/apps/web/src/components/admin/tickets/__tests__/ticket-properties-panel.test.tsx b/apps/web/src/components/admin/tickets/__tests__/ticket-properties-panel.test.tsx
new file mode 100644
index 000000000..64feaa35f
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/__tests__/ticket-properties-panel.test.tsx
@@ -0,0 +1,381 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { TicketPropertiesPanel } from '../ticket-properties-panel'
+
+type MutationOptions = {
+ mutationFn: (value: unknown) => Promise
+ onSuccess?: (result: unknown) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ getQueryData: vi.fn(),
+ setQueryData: vi.fn(),
+ invalidateQueries: vi.fn(),
+ assignTicketFn: vi.fn(),
+ transitionTicketStatusFn: vi.fn(),
+ updateTicketFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ getQueryData: mocks.getQueryData,
+ setQueryData: mocks.setQueryData,
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useMutation: (options: MutationOptions) => ({
+ mutate: async (value: unknown) => {
+ try {
+ const result = await options.mutationFn(value)
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ onClick,
+ type = 'button',
+ }: {
+ children: ReactNode
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ size?: string
+ variant?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: ({
+ value,
+ onChange,
+ }: {
+ value?: string
+ onChange?: (event: React.ChangeEvent) => void
+ className?: string
+ }) => ,
+}))
+
+vi.mock('@/components/ui/select', () => ({
+ Select: ({
+ value,
+ onValueChange,
+ children,
+ }: {
+ value: string
+ onValueChange: (value: string) => void
+ children: ReactNode
+ }) => (
+ onValueChange(event.currentTarget.value)}>
+ {children}
+
+ ),
+ SelectContent: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectItem: ({ children, value }: { children: ReactNode; value: string }) => (
+ {children}
+ ),
+ SelectTrigger: ({ children }: { children: ReactNode }) => <>{children}>,
+ SelectValue: () => null,
+}))
+
+vi.mock('@/components/admin/shared/principal-picker', () => ({
+ PrincipalPicker: ({ onValueChange }: { onValueChange: (id: string | null) => void }) => (
+ <>
+ onValueChange('principal_agent')}>
+ Assign agent
+
+ onValueChange(null)}>
+ Unassign agent
+
+ >
+ ),
+}))
+
+vi.mock('@/components/admin/shared/status-picker', () => ({
+ StatusPicker: ({ onValueChange }: { onValueChange: (id: string | null) => void }) => (
+ <>
+ onValueChange('ticket_status_solved')}>
+ Pick status
+
+ onValueChange(null)}>
+ Clear status
+
+ >
+ ),
+}))
+
+vi.mock('@/components/admin/shared/inbox-picker', () => ({
+ InboxPicker: ({ onValueChange }: { onValueChange: (id: string | null) => void }) => (
+ <>
+ onValueChange('inbox_support')}>
+ Pick inbox
+
+ onValueChange(null)}>
+ Clear inbox
+
+ >
+ ),
+}))
+
+vi.mock('@/components/admin/shared/team-picker', () => ({
+ TeamPicker: ({ onValueChange }: { onValueChange: (id: string | null) => void }) => (
+ <>
+ onValueChange('team_primary')}>
+ Pick team
+
+ onValueChange(null)}>
+ Clear team
+
+ >
+ ),
+}))
+
+vi.mock('@/components/admin/shared/org-picker', () => ({
+ OrgPicker: ({ onValueChange }: { onValueChange: (id: string | null) => void }) => (
+ <>
+ onValueChange('organization_acme')}>
+ Pick organization
+
+ onValueChange(null)}>
+ Clear organization
+
+ >
+ ),
+}))
+
+vi.mock('@/components/admin/shared/contact-picker', () => ({
+ ContactPicker: ({ onValueChange }: { onValueChange: (id: string | null) => void }) => (
+ <>
+ onValueChange('contact_requester')}>
+ Pick contact
+
+ onValueChange(null)}>
+ Clear contact
+
+ >
+ ),
+}))
+
+vi.mock('@/lib/server/functions/tickets', () => ({
+ assignTicketFn: mocks.assignTicketFn,
+ transitionTicketStatusFn: mocks.transitionTicketStatusFn,
+ updateTicketFn: mocks.updateTicketFn,
+}))
+
+vi.mock('@/lib/client/queries/tickets', () => ({
+ ticketQueries: {
+ detail: (ticketId: string) => ({ queryKey: ['tickets', ticketId, 'detail'] }),
+ },
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+function ticket(overrides: Record = {}) {
+ return {
+ id: 'ticket_1',
+ subject: 'Original subject',
+ statusId: 'ticket_status_open',
+ priority: 'normal',
+ visibilityScope: 'team',
+ primaryTeamId: null,
+ inboxId: null,
+ organizationId: null,
+ requesterContactId: null,
+ assigneePrincipalId: null,
+ updatedAt: '2026-06-19T10:00:00.000Z',
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.getQueryData.mockReturnValue({ updatedAt: '2026-06-19T10:30:00.000Z' })
+ mocks.assignTicketFn.mockResolvedValue({ id: 'ticket_1', updatedAt: '2026-06-19T10:31:00.000Z' })
+ mocks.transitionTicketStatusFn.mockResolvedValue({
+ id: 'ticket_1',
+ updatedAt: '2026-06-19T10:32:00.000Z',
+ })
+ mocks.updateTicketFn.mockResolvedValue({ id: 'ticket_1', updatedAt: '2026-06-19T10:33:00.000Z' })
+})
+
+describe('TicketPropertiesPanel', () => {
+ it('edits and cancels the subject inline', async () => {
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Original subject' }))
+ fireEvent.change(screen.getByLabelText('Subject draft'), {
+ target: { value: 'Unsaved subject' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+
+ expect(screen.getByRole('button', { name: 'Original subject' })).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Original subject' }))
+ fireEvent.change(screen.getByLabelText('Subject draft'), {
+ target: { value: 'Updated subject' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
+
+ await waitFor(() => {
+ expect(mocks.updateTicketFn).toHaveBeenCalledWith({
+ data: {
+ ticketId: 'ticket_1',
+ expectedUpdatedAt: '2026-06-19T10:30:00.000Z',
+ subject: 'Updated subject',
+ },
+ })
+ })
+ expect(mocks.setQueryData).toHaveBeenCalledWith(['tickets', 'ticket_1', 'detail'], {
+ id: 'ticket_1',
+ updatedAt: '2026-06-19T10:33:00.000Z',
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['tickets', 'ticket_1', 'detail'],
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['tickets', 'list'] })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Ticket updated')
+ })
+
+ it('updates assignee and status with optimistic concurrency timestamps', async () => {
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Assign agent' }))
+ await waitFor(() => {
+ expect(mocks.assignTicketFn).toHaveBeenCalledWith({
+ data: {
+ ticketId: 'ticket_1',
+ expectedUpdatedAt: '2026-06-19T10:30:00.000Z',
+ assigneePrincipalId: 'principal_agent',
+ },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Assignee updated')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Unassign agent' }))
+ await waitFor(() => {
+ expect(mocks.assignTicketFn).toHaveBeenLastCalledWith({
+ data: {
+ ticketId: 'ticket_1',
+ expectedUpdatedAt: '2026-06-19T10:30:00.000Z',
+ assigneePrincipalId: null,
+ },
+ })
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Clear status' }))
+ expect(mocks.transitionTicketStatusFn).not.toHaveBeenCalled()
+ fireEvent.click(screen.getByRole('button', { name: 'Pick status' }))
+ await waitFor(() => {
+ expect(mocks.transitionTicketStatusFn).toHaveBeenCalledWith({
+ data: {
+ ticketId: 'ticket_1',
+ expectedUpdatedAt: '2026-06-19T10:30:00.000Z',
+ statusId: 'ticket_status_solved',
+ },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Status updated')
+ })
+
+ it('updates ticket properties from selects and pickers', async () => {
+ render( )
+
+ fireEvent.change(screen.getAllByRole('combobox')[0], { target: { value: 'urgent' } })
+ fireEvent.change(screen.getAllByRole('combobox')[1], { target: { value: 'private' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Pick inbox' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Clear team' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Pick organization' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Clear contact' }))
+
+ await waitFor(() => {
+ expect(mocks.updateTicketFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ priority: 'urgent' }),
+ })
+ )
+ })
+ expect(mocks.updateTicketFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ visibilityScope: 'private' }),
+ })
+ )
+ expect(mocks.updateTicketFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ inboxId: 'inbox_support' }),
+ })
+ )
+ expect(mocks.updateTicketFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ primaryTeamId: null }),
+ })
+ )
+ expect(mocks.updateTicketFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ organizationId: 'organization_acme' }),
+ })
+ )
+ expect(mocks.updateTicketFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ requesterContactId: null }),
+ })
+ )
+ })
+
+ it('falls back to the ticket timestamp and handles stale-conflict refresh actions', async () => {
+ mocks.getQueryData.mockReturnValue(undefined)
+ mocks.updateTicketFn.mockRejectedValueOnce(new Error('stale conflict'))
+ render( )
+
+ fireEvent.change(screen.getAllByRole('combobox')[0], { target: { value: 'high' } })
+
+ await waitFor(() => {
+ expect(mocks.updateTicketFn).toHaveBeenCalledWith({
+ data: {
+ ticketId: 'ticket_1',
+ expectedUpdatedAt: '2026-06-19T10:00:00.000Z',
+ priority: 'high',
+ },
+ })
+ })
+ expect(mocks.toastError).toHaveBeenCalledWith(
+ 'Ticket changed — refresh',
+ expect.objectContaining({
+ action: expect.objectContaining({ label: 'Refresh' }),
+ })
+ )
+
+ const refresh = mocks.toastError.mock.calls[0][1].action.onClick as () => void
+ refresh()
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['tickets', 'ticket_1', 'detail'],
+ })
+ })
+
+ it('reports non-conflict mutation errors', async () => {
+ mocks.assignTicketFn.mockRejectedValueOnce(new Error('No permission'))
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Assign agent' }))
+
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('No permission')
+ })
+ })
+})
diff --git a/apps/web/src/components/admin/tickets/__tests__/ticket-queue-table.test.tsx b/apps/web/src/components/admin/tickets/__tests__/ticket-queue-table.test.tsx
new file mode 100644
index 000000000..01a57ef46
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/__tests__/ticket-queue-table.test.tsx
@@ -0,0 +1,346 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { TicketQueueTable } from '../ticket-queue-table'
+
+type MutationOptions = {
+ mutationFn: (value: unknown) => Promise<{ succeeded: string[] }>
+ onSuccess?: (result: { succeeded: string[] }) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ bulkAssignTicketsFn: vi.fn(),
+ bulkTransitionTicketsFn: vi.fn(),
+ bulkChangeInboxFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ Link: ({
+ to,
+ params,
+ children,
+ }: {
+ to: string
+ params: Record
+ children: ReactNode
+ }) => {children} ,
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useMutation: (options: MutationOptions) => ({
+ mutate: async (value: unknown) => {
+ try {
+ const result = await options.mutationFn(value)
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@/components/ui/table', () => ({
+ Table: ({ children }: { children: ReactNode }) => ,
+ TableHeader: ({ children }: { children: ReactNode }) => {children} ,
+ TableBody: ({ children }: { children: ReactNode }) => {children} ,
+ TableRow: ({ children }: { children: ReactNode }) => {children} ,
+ TableHead: ({ children }: { children: ReactNode }) => {children} ,
+ TableCell: ({ children }: { children: ReactNode }) => {children} ,
+}))
+
+vi.mock('@/components/ui/checkbox', () => ({
+ Checkbox: ({
+ checked,
+ onCheckedChange,
+ 'aria-label': ariaLabel,
+ }: {
+ checked?: boolean | 'indeterminate'
+ onCheckedChange?: (checked: boolean) => void
+ 'aria-label'?: string
+ }) => (
+ onCheckedChange?.(checked !== true)}
+ />
+ ),
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ onClick,
+ }: {
+ children: ReactNode
+ onClick?: () => void
+ size?: string
+ variant?: string
+ className?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/popover', () => ({
+ Popover: ({ children }: { children: ReactNode }) => {children}
,
+ PopoverTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}>,
+ PopoverContent: ({ children }: { children: ReactNode; align?: string; className?: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/components/ui/alert-dialog', () => ({
+ AlertDialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
+ open ? {children}
: null,
+ AlertDialogContent: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogHeader: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+ AlertDialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogFooter: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDialogCancel: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
+
+ {children}
+
+ ),
+ AlertDialogAction: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/time-ago', () => ({
+ TimeAgo: ({ date }: { date: Date | string }) => {String(date)} ,
+}))
+
+vi.mock('@/components/admin/shared/permission-gate', () => ({
+ PermissionGate: ({ children }: { children: ReactNode; permission: string }) => <>{children}>,
+}))
+
+vi.mock('@/components/admin/shared/principal-picker', () => ({
+ PrincipalPicker: ({ onValueChange }: { onValueChange: (id: string | null) => void }) => (
+ onValueChange('principal_assignee')}>
+ Pick assignee
+
+ ),
+}))
+
+vi.mock('@/components/admin/shared/status-picker', () => ({
+ StatusPicker: ({ onValueChange }: { onValueChange: (id: string | null) => void }) => (
+ onValueChange('ticket_status_solved')}>
+ Pick status
+
+ ),
+}))
+
+vi.mock('@/components/admin/shared/inbox-picker', () => ({
+ InboxPicker: ({ onValueChange }: { onValueChange: (id: string | null) => void }) => (
+ <>
+ onValueChange('inbox_support')}>
+ Pick inbox
+
+ onValueChange(null)}>
+ Clear inbox
+
+ >
+ ),
+}))
+
+vi.mock('../ticket-status-pill', () => ({
+ TicketStatusPill: ({ name, category }: { name: string; category: string }) => (
+
+ {name}:{category}
+
+ ),
+}))
+
+vi.mock('../ticket-priority-chip', () => ({
+ TicketPriorityChip: ({ priority }: { priority: string }) => priority:{priority} ,
+}))
+
+vi.mock('../ticket-channel-icon', () => ({
+ TicketChannelIcon: ({ channel }: { channel: string }) => channel:{channel} ,
+}))
+
+vi.mock('@/lib/server/functions/tickets', () => ({
+ bulkAssignTicketsFn: mocks.bulkAssignTicketsFn,
+ bulkTransitionTicketsFn: mocks.bulkTransitionTicketsFn,
+ bulkChangeInboxFn: mocks.bulkChangeInboxFn,
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+const statuses = [
+ {
+ id: 'ticket_status_open',
+ name: 'Open',
+ category: 'open' as const,
+ },
+ {
+ id: 'ticket_status_solved',
+ name: 'Solved',
+ category: 'solved' as const,
+ },
+]
+
+function row(id: number, overrides: Record = {}) {
+ return {
+ id: `ticket_${id}`,
+ subject: `Ticket ${id}`,
+ statusId: 'ticket_status_open',
+ priority: id % 2 === 0 ? 'high' : 'normal',
+ channel: id % 2 === 0 ? 'email' : 'widget',
+ lastActivityAt: `2026-06-19T10:${String(id).padStart(2, '0')}:00.000Z`,
+ assigneePrincipalId: null,
+ ...overrides,
+ }
+}
+
+function renderTable(rows = [row(1), row(2)]) {
+ return render(
+
+ )
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.bulkAssignTicketsFn.mockResolvedValue({ succeeded: ['ticket_1'] })
+ mocks.bulkTransitionTicketsFn.mockResolvedValue({ succeeded: ['ticket_1', 'ticket_2'] })
+ mocks.bulkChangeInboxFn.mockResolvedValue({ succeeded: ['ticket_1', 'ticket_2'] })
+})
+
+describe('TicketQueueTable', () => {
+ it('renders the empty queue state', () => {
+ renderTable([])
+
+ expect(screen.getByText('No tickets in this view.')).toBeInTheDocument()
+ })
+
+ it('renders ticket rows and runs assign, transition, and inbox bulk actions', async () => {
+ renderTable([row(1), row(2, { statusId: 'missing_status' })])
+
+ expect(screen.getByRole('link', { name: 'Ticket 1' })).toHaveAttribute(
+ 'href',
+ '/admin/tickets/ticket_1'
+ )
+ expect(screen.getByText('Open:open')).toBeInTheDocument()
+ expect(screen.getByText('priority:normal')).toBeInTheDocument()
+ expect(screen.getByText('channel:widget')).toBeInTheDocument()
+ expect(screen.getByText('—')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByLabelText('Select Ticket 1'))
+
+ expect(screen.getByText('1 selected')).toBeInTheDocument()
+ expect(screen.getByLabelText('Select all')).toHaveAttribute('data-indeterminate', 'true')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Pick assignee' }))
+
+ await waitFor(() => {
+ expect(mocks.bulkAssignTicketsFn).toHaveBeenCalledWith({
+ data: {
+ ticketIds: ['ticket_1'],
+ assigneePrincipalId: 'principal_assignee',
+ },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Assigned 1 ticket')
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['tickets', 'queue'] })
+
+ fireEvent.click(screen.getByLabelText('Select all'))
+ expect(screen.getByText('2 selected')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Pick status' }))
+ await waitFor(() => {
+ expect(mocks.bulkTransitionTicketsFn).toHaveBeenCalledWith({
+ data: {
+ ticketIds: ['ticket_1', 'ticket_2'],
+ statusId: 'ticket_status_solved',
+ },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Transitioned 2 tickets')
+
+ fireEvent.click(screen.getByLabelText('Select all'))
+ fireEvent.click(screen.getByRole('button', { name: 'Pick inbox' }))
+ await waitFor(() => {
+ expect(mocks.bulkChangeInboxFn).toHaveBeenCalledWith({
+ data: {
+ ticketIds: ['ticket_1', 'ticket_2'],
+ inboxId: 'inbox_support',
+ },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Moved 2 tickets')
+ })
+
+ it('clears selected rows and reports mutation failures', async () => {
+ mocks.bulkAssignTicketsFn.mockRejectedValueOnce(new Error('No assignment permission'))
+ renderTable([row(1)])
+
+ fireEvent.click(screen.getByLabelText('Select Ticket 1'))
+ fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
+
+ expect(screen.queryByText('1 selected')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByLabelText('Select Ticket 1'))
+ fireEvent.click(screen.getByRole('button', { name: 'Pick assignee' }))
+
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('No assignment permission')
+ })
+ })
+
+ it('requires confirmation before applying large bulk actions', async () => {
+ const manyRows = Array.from({ length: 51 }, (_, index) => row(index + 1))
+ renderTable(manyRows)
+
+ fireEvent.click(screen.getByLabelText('Select all'))
+ fireEvent.click(screen.getByRole('button', { name: 'Pick status' }))
+
+ expect(screen.getByRole('alertdialog')).toBeInTheDocument()
+ expect(
+ screen.getByText('You are about to apply this action to 51 tickets. Continue?')
+ ).toBeInTheDocument()
+ expect(mocks.bulkTransitionTicketsFn).not.toHaveBeenCalled()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }))
+
+ await waitFor(() => {
+ expect(mocks.bulkTransitionTicketsFn).toHaveBeenCalledWith({
+ data: {
+ ticketIds: manyRows.map((ticket) => ticket.id),
+ statusId: 'ticket_status_solved',
+ },
+ })
+ })
+ })
+
+ it('can cancel a pending large bulk action', () => {
+ const manyRows = Array.from({ length: 51 }, (_, index) => row(index + 1))
+ renderTable(manyRows)
+
+ fireEvent.click(screen.getByLabelText('Select all'))
+ fireEvent.click(screen.getByRole('button', { name: 'Clear inbox' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
+ expect(mocks.bulkChangeInboxFn).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/web/src/components/admin/tickets/__tests__/ticket-side-panels.test.tsx b/apps/web/src/components/admin/tickets/__tests__/ticket-side-panels.test.tsx
new file mode 100644
index 000000000..beeb91178
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/__tests__/ticket-side-panels.test.tsx
@@ -0,0 +1,595 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { TicketLinkedIssues } from '../ticket-linked-issues'
+import { TicketParticipantsList } from '../ticket-participants-list'
+import { TicketPriorityChip, type TicketPriority } from '../ticket-priority-chip'
+import { TicketQueueSidebar } from '../ticket-queue-sidebar'
+import { TicketSharesPanel } from '../ticket-shares-panel'
+import { TicketSlaPanel } from '../ticket-sla-panel'
+import { TicketStatusPill, type StatusCategory } from '../ticket-status-pill'
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ linkedIssuesQuery: {
+ data: [] as Array<{
+ id: string
+ externalUrl: string | null
+ externalDisplayId: string
+ syncDirection: 'outbound' | 'inbound' | 'bidirectional'
+ integrationId: string | null
+ }>,
+ isLoading: false,
+ },
+ myInboxesQuery: {
+ data: [] as Array<{ id: string; name: string }>,
+ isLoading: false,
+ },
+ slaClocks: [] as Array<{
+ id: string
+ kind: string
+ state: string
+ dueAt: string
+ breachedAt?: string | null
+ metAt?: string | null
+ }>,
+ manualSyncTicketFn: vi.fn(),
+ addParticipantFn: vi.fn(),
+ removeParticipantFn: vi.fn(),
+ shareTicketFn: vi.fn(),
+ revokeShareFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQuery: () => mocks.linkedIssuesQuery,
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useSuspenseQuery: () => ({ data: mocks.slaClocks }),
+ useMutation: ({
+ mutationFn,
+ onSuccess,
+ onError,
+ }: {
+ mutationFn: (value?: unknown) => Promise
+ onSuccess?: (value: unknown) => void
+ onError?: (error: Error) => void
+ }) => ({
+ isPending: false,
+ mutate: (value?: unknown) => {
+ void mutationFn(value)
+ .then((result) => onSuccess?.(result))
+ .catch((error) => onError?.(error instanceof Error ? error : new Error(String(error))))
+ },
+ }),
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ Link: ({
+ children,
+ to,
+ search,
+ className,
+ }: {
+ children: ReactNode
+ to: string
+ search?: Record
+ className?: string
+ }) => (
+ ).toString()}`}
+ className={className}
+ >
+ {children}
+
+ ),
+}))
+
+vi.mock('@/lib/client/queries/tickets', () => ({
+ ticketQueries: {
+ externalLinks: (ticketId: string) => ({ queryKey: ['tickets', 'externalLinks', ticketId] }),
+ participants: (ticketId: string) => ({ queryKey: ['tickets', 'participants', ticketId] }),
+ shares: (ticketId: string) => ({ queryKey: ['tickets', 'shares', ticketId] }),
+ slaClocks: (ticketId: string) => ({ queryKey: ['tickets', 'slaClocks', ticketId] }),
+ },
+}))
+
+vi.mock('@/lib/server/functions/tickets', () => ({
+ manualSyncTicketFn: mocks.manualSyncTicketFn,
+ addParticipantFn: mocks.addParticipantFn,
+ removeParticipantFn: mocks.removeParticipantFn,
+ revokeShareFn: mocks.revokeShareFn,
+ shareTicketFn: mocks.shareTicketFn,
+}))
+
+vi.mock('@/lib/client/hooks/use-inboxes-queries', () => ({
+ useMyInboxes: () => mocks.myInboxesQuery,
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ onClick,
+ disabled,
+ title,
+ 'aria-label': ariaLabel,
+ }: {
+ children: ReactNode
+ onClick?: () => void
+ disabled?: boolean
+ title?: string
+ 'aria-label'?: string
+ variant?: string
+ size?: string
+ className?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/select', async () => {
+ const React = await import('react')
+ const SelectContext = React.createContext<{
+ onValueChange?: (value: string) => void
+ value?: string
+ }>({})
+ return {
+ Select: ({
+ value,
+ onValueChange,
+ children,
+ }: {
+ value?: string
+ onValueChange?: (value: string) => void
+ children: ReactNode
+ }) => (
+
+ {children}
+
+ ),
+ SelectContent: ({ children }: { children: ReactNode }) => {children}
,
+ SelectItem: ({ children, value }: { children: ReactNode; value: string }) => {
+ const context = React.useContext(SelectContext)
+ return (
+ context.onValueChange?.(value)}>
+ {children}
+
+ )
+ },
+ SelectTrigger: ({ children }: { children?: ReactNode; className?: string }) => <>{children}>,
+ SelectValue: () => {
+ const context = React.useContext(SelectContext)
+ return {context.value}
+ },
+ }
+})
+
+vi.mock('@/components/admin/shared/principal-picker', () => ({
+ PrincipalPicker: ({
+ value,
+ onValueChange,
+ }: {
+ value: string | null
+ onValueChange: (value: string | null) => void
+ }) => (
+
+ Principal {value ?? 'none'}
+ onValueChange('principal_2')}>
+ Pick principal
+
+
+ ),
+}))
+
+vi.mock('@/components/admin/shared/contact-picker', () => ({
+ ContactPicker: ({
+ value,
+ onValueChange,
+ }: {
+ value: string | null
+ onValueChange: (value: string | null) => void
+ }) => (
+
+ Contact {value ?? 'none'}
+ onValueChange('contact_2')}>
+ Pick contact
+
+
+ ),
+}))
+
+vi.mock('@/components/admin/shared/team-picker', () => ({
+ TeamPicker: ({
+ value,
+ onValueChange,
+ placeholder,
+ }: {
+ value: string | null
+ onValueChange: (value: string | null) => void
+ placeholder?: string
+ }) => (
+
+ Team {value ?? 'none'} {placeholder}
+ onValueChange('team_2')}>
+ Pick team
+
+
+ ),
+}))
+
+vi.mock('@heroicons/react/24/outline', () => ({
+ ChevronDownIcon: () => down ,
+ ChevronRightIcon: () => right ,
+ GlobeAltIcon: () => globe ,
+ InboxIcon: () => inbox ,
+ QuestionMarkCircleIcon: () => unknown ,
+ ShareIcon: () => share ,
+ UserIcon: () => user ,
+ UsersIcon: () => users ,
+ XMarkIcon: () => remove ,
+}))
+
+vi.mock('lucide-react', () => ({
+ ExternalLink: () => external ,
+ GitBranch: () => branch ,
+ RefreshCw: ({ className }: { className?: string }) => sync ,
+}))
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.linkedIssuesQuery = { data: [], isLoading: false }
+ mocks.myInboxesQuery = { data: [], isLoading: false }
+ mocks.slaClocks = []
+ mocks.manualSyncTicketFn.mockResolvedValue({ success: true })
+ mocks.addParticipantFn.mockResolvedValue(undefined)
+ mocks.removeParticipantFn.mockResolvedValue(undefined)
+ mocks.revokeShareFn.mockResolvedValue(undefined)
+ mocks.shareTicketFn.mockResolvedValue(undefined)
+ mocks.invalidateQueries.mockResolvedValue(undefined)
+})
+
+describe('TicketLinkedIssues', () => {
+ it('renders linked issues, sync directions, and sync outcomes', async () => {
+ mocks.linkedIssuesQuery = {
+ isLoading: false,
+ data: [
+ {
+ id: 'link_1',
+ externalUrl: 'https://github.test/issues/1',
+ externalDisplayId: 'GH-1',
+ syncDirection: 'outbound',
+ integrationId: 'integration_1',
+ },
+ {
+ id: 'link_2',
+ externalUrl: null,
+ externalDisplayId: 'GH-2',
+ syncDirection: 'inbound',
+ integrationId: null,
+ },
+ {
+ id: 'link_3',
+ externalUrl: 'https://github.test/issues/3',
+ externalDisplayId: 'GH-3',
+ syncDirection: 'bidirectional',
+ integrationId: 'integration_3',
+ },
+ ],
+ }
+
+ render( )
+
+ expect(screen.getByText('Linked Issues')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: /GH-1/ })).toHaveAttribute(
+ 'href',
+ 'https://github.test/issues/1'
+ )
+ expect(screen.getByRole('link', { name: /GH-2/ })).toHaveAttribute('href', '#')
+ expect(screen.getByText('→')).toBeInTheDocument()
+ expect(screen.getByText('←')).toBeInTheDocument()
+ expect(screen.getByText('↔')).toBeInTheDocument()
+
+ fireEvent.click(screen.getAllByTitle('Sync to GitHub')[0]!)
+ await waitFor(() => {
+ expect(mocks.manualSyncTicketFn).toHaveBeenCalledWith({
+ data: { ticketId: 'ticket_1', integrationId: 'integration_1', direction: 'push' },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Sync completed')
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['tickets', 'externalLinks', 'ticket_1'],
+ })
+
+ mocks.manualSyncTicketFn.mockResolvedValueOnce({ success: false, error: 'conflict' })
+ fireEvent.click(screen.getAllByTitle('Sync to GitHub')[2]!)
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('conflict')
+ })
+ })
+
+ it('renders nothing while loading, empty, or failed sync', async () => {
+ const { container, rerender } = render( )
+ expect(container).toBeEmptyDOMElement()
+
+ mocks.linkedIssuesQuery = { data: [], isLoading: true }
+ rerender( )
+ expect(container).toBeEmptyDOMElement()
+
+ mocks.linkedIssuesQuery = {
+ isLoading: false,
+ data: [
+ {
+ id: 'link_1',
+ externalUrl: null,
+ externalDisplayId: 'GH-1',
+ syncDirection: 'outbound',
+ integrationId: 'integration_1',
+ },
+ ],
+ }
+ mocks.manualSyncTicketFn.mockRejectedValueOnce(new Error('network'))
+ rerender( )
+ fireEvent.click(screen.getByTitle('Sync to GitHub'))
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Sync failed')
+ })
+ })
+})
+
+describe('TicketParticipantsList', () => {
+ it('lists, removes, and adds principal and contact participants', async () => {
+ render(
+
+ )
+
+ expect(screen.getByText('Ada')).toBeInTheDocument()
+ expect(screen.getByText('Grace')).toBeInTheDocument()
+ expect(screen.getByText('—')).toBeInTheDocument()
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Remove participant' })[0]!)
+ await waitFor(() => {
+ expect(mocks.removeParticipantFn).toHaveBeenCalledWith({
+ data: { ticketId: 'ticket_1', participantId: 'participant_1' },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Participant removed')
+
+ expect(screen.getByRole('button', { name: 'Add participant' })).toBeDisabled()
+ fireEvent.click(screen.getByRole('button', { name: 'Pick principal' }))
+ fireEvent.click(screen.getByRole('button', { name: 'collaborator' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Add participant' }))
+ await waitFor(() => {
+ expect(mocks.addParticipantFn).toHaveBeenCalledWith({
+ data: {
+ ticketId: 'ticket_1',
+ role: 'collaborator',
+ principalId: 'principal_2',
+ contactId: null,
+ },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Participant added')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Contact' }))
+ fireEvent.click(screen.getByRole('button', { name: 'cc' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Pick contact' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Add participant' }))
+ await waitFor(() => {
+ expect(mocks.addParticipantFn).toHaveBeenCalledWith({
+ data: {
+ ticketId: 'ticket_1',
+ role: 'cc',
+ principalId: null,
+ contactId: 'contact_2',
+ },
+ })
+ })
+ })
+
+ it('shows empty participants and mutation errors', async () => {
+ mocks.addParticipantFn.mockRejectedValueOnce(new Error('duplicate'))
+ render( )
+
+ expect(screen.getByText('No participants.')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Pick principal' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Add participant' }))
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('duplicate')
+ })
+ })
+})
+
+describe('TicketPriorityChip', () => {
+ it('renders all priority labels', () => {
+ ;(['low', 'normal', 'high', 'urgent'] as TicketPriority[]).forEach((priority) => {
+ const { unmount } = render( )
+ expect(screen.getByText(priority)).toHaveClass('extra')
+ unmount()
+ })
+ })
+})
+
+describe('TicketSharesPanel', () => {
+ it('lists, revokes, and adds team shares when permitted', async () => {
+ render(
+
+ )
+
+ expect(screen.getByText('Success')).toBeInTheDocument()
+ expect(screen.getByText('team_unknown')).toBeInTheDocument()
+ fireEvent.click(screen.getAllByRole('button', { name: 'Revoke share' })[0]!)
+ await waitFor(() => {
+ expect(mocks.revokeShareFn).toHaveBeenCalledWith({ data: { shareId: 'share_1' } })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Share revoked')
+
+ expect(screen.getByRole('button', { name: 'Share' })).toBeDisabled()
+ fireEvent.click(screen.getByRole('button', { name: 'Pick team' }))
+ fireEvent.click(screen.getByRole('button', { name: 'full' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Share' }))
+ await waitFor(() => {
+ expect(mocks.shareTicketFn).toHaveBeenCalledWith({
+ data: { ticketId: 'ticket_1', teamId: 'team_2', accessLevel: 'full' },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Ticket shared')
+ })
+
+ it('hides share controls when not permitted and reports share errors', async () => {
+ const { rerender } = render(
+
+ )
+ expect(screen.getByText('Not shared with any teams.')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Share' })).not.toBeInTheDocument()
+
+ mocks.shareTicketFn.mockRejectedValueOnce(new Error('cannot share'))
+ rerender( )
+ fireEvent.click(screen.getByRole('button', { name: 'Pick team' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Share' }))
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('cannot share')
+ })
+ })
+})
+
+describe('TicketSlaPanel and TicketStatusPill', () => {
+ it('renders empty and populated SLA clocks', () => {
+ const { rerender } = render( )
+ expect(screen.getByText('No SLA clocks on this ticket.')).toBeInTheDocument()
+
+ mocks.slaClocks = [
+ {
+ id: 'clock_1',
+ kind: 'first_response',
+ state: 'running',
+ dueAt: '2026-06-20T12:00:00.000Z',
+ breachedAt: null,
+ metAt: null,
+ },
+ {
+ id: 'clock_2',
+ kind: 'custom_kind',
+ state: 'paused',
+ dueAt: '2026-06-20T12:00:00.000Z',
+ },
+ ]
+ rerender( )
+ expect(screen.getByText('First response')).toBeInTheDocument()
+ expect(screen.getByText('custom_kind')).toBeInTheDocument()
+ })
+
+ it('renders all status category pills', () => {
+ ;(['open', 'pending', 'on_hold', 'solved', 'closed'] as StatusCategory[]).forEach(
+ (category) => {
+ const { unmount } = render(
+
+ )
+ expect(screen.getByText(`Status ${category}`)).toHaveClass('extra')
+ unmount()
+ }
+ )
+ })
+})
+
+describe('TicketQueueSidebar', () => {
+ it('renders saved views, inbox links, and collapse behavior', () => {
+ mocks.myInboxesQuery = {
+ isLoading: false,
+ data: [
+ { id: 'inbox_1', name: 'Support' },
+ { id: 'inbox_2', name: 'Billing' },
+ ],
+ }
+
+ render( )
+
+ expect(screen.getByRole('link', { name: /Assigned to me/ })).toHaveAttribute(
+ 'href',
+ expect.stringContaining('scope=my_assigned')
+ )
+ expect(screen.getByRole('link', { name: /All/ })).toHaveAttribute(
+ 'href',
+ expect.stringContaining('scope=all')
+ )
+ expect(screen.getByRole('link', { name: /Billing/ })).toHaveAttribute(
+ 'href',
+ expect.stringContaining('inboxId=inbox_2')
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: /By inbox/ }))
+ expect(screen.queryByRole('link', { name: /Support/ })).not.toBeInTheDocument()
+ })
+
+ it('renders loading and empty inbox states', () => {
+ mocks.myInboxesQuery = { data: [], isLoading: true }
+ const { rerender } = render( )
+ expect(screen.getByText(/Loading/)).toBeInTheDocument()
+
+ mocks.myInboxesQuery = { data: [], isLoading: false }
+ rerender( )
+ expect(screen.getByText('No inboxes')).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/admin/tickets/__tests__/ticket-subscription-menu.test.tsx b/apps/web/src/components/admin/tickets/__tests__/ticket-subscription-menu.test.tsx
new file mode 100644
index 000000000..d4bb0c997
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/__tests__/ticket-subscription-menu.test.tsx
@@ -0,0 +1,294 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { TicketSubscriptionMenu } from '../ticket-subscription-menu'
+
+type Subscription = {
+ mutedUntil: string | null
+ notifyThreads: boolean
+ notifyStatus: boolean
+ notifyAssignment: boolean
+ notifyParticipants: boolean
+ notifyShares: boolean
+ notifySla: boolean
+}
+
+type MutationOptions = {
+ mutationFn: (vars: TVars) => Promise
+ onSuccess?: (result: TResult) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ getMyTicketSubscriptionFn: vi.fn(),
+ subscribeToTicketFn: vi.fn(),
+ unsubscribeFromTicketFn: vi.fn(),
+ updateTicketSubscriptionPrefsFn: vi.fn(),
+ muteTicketFn: vi.fn(),
+ unmuteTicketFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+ subscription: null as Subscription | null,
+ isLoading: false,
+ pending: false,
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useQuery: (options: { queryFn: () => unknown }) => {
+ options.queryFn()
+ return {
+ data: mocks.subscription,
+ isLoading: mocks.isLoading,
+ }
+ },
+ useMutation: (options: MutationOptions) => ({
+ isPending: mocks.pending,
+ mutate: async (vars: TVars) => {
+ try {
+ const result = await options.mutationFn(vars)
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ 'aria-label': ariaLabel,
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ variant?: string
+ size?: string
+ 'aria-label'?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/dropdown-menu', () => ({
+ DropdownMenu: ({ children }: { children: ReactNode }) => {children}
,
+ DropdownMenuCheckboxItem: ({
+ children,
+ checked,
+ onCheckedChange,
+ onSelect,
+ }: {
+ children: ReactNode
+ checked: boolean
+ onCheckedChange: (checked: boolean) => void
+ onSelect?: (event: { preventDefault: () => void }) => void
+ }) => (
+ {
+ onCheckedChange(!checked)
+ onSelect?.({ preventDefault: vi.fn() })
+ }}
+ >
+ {children}
+
+ ),
+ DropdownMenuContent: ({
+ children,
+ }: {
+ children: ReactNode
+ align?: string
+ className?: string
+ }) => {children}
,
+ DropdownMenuItem: ({
+ children,
+ onSelect,
+ }: {
+ children: ReactNode
+ onSelect?: () => void
+ className?: string
+ }) => (
+
+ {children}
+
+ ),
+ DropdownMenuLabel: ({ children }: { children: ReactNode }) => {children}
,
+ DropdownMenuSeparator: () => ,
+ DropdownMenuSub: ({ children }: { children: ReactNode }) => {children}
,
+ DropdownMenuSubContent: ({ children }: { children: ReactNode; className?: string }) => (
+ {children}
+ ),
+ DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => {children}
,
+ DropdownMenuTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => (
+ <>{children}>
+ ),
+}))
+
+vi.mock('@heroicons/react/24/outline', () => ({
+ BellAlertIcon: () => bell-alert ,
+ BellIcon: () => bell ,
+ BellSlashIcon: () => bell-slash ,
+}))
+
+vi.mock('@heroicons/react/24/solid', () => ({
+ BellIcon: () => bell-solid ,
+}))
+
+vi.mock('@/lib/server/functions/notifications', () => ({
+ getMyTicketSubscriptionFn: mocks.getMyTicketSubscriptionFn,
+ subscribeToTicketFn: mocks.subscribeToTicketFn,
+ unsubscribeFromTicketFn: mocks.unsubscribeFromTicketFn,
+ updateTicketSubscriptionPrefsFn: mocks.updateTicketSubscriptionPrefsFn,
+ muteTicketFn: mocks.muteTicketFn,
+ unmuteTicketFn: mocks.unmuteTicketFn,
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+function subscription(overrides: Partial = {}): Subscription {
+ return {
+ mutedUntil: null,
+ notifyThreads: true,
+ notifyStatus: false,
+ notifyAssignment: true,
+ notifyParticipants: false,
+ notifyShares: true,
+ notifySla: false,
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.subscription = null
+ mocks.isLoading = false
+ mocks.pending = false
+ mocks.getMyTicketSubscriptionFn.mockResolvedValue(mocks.subscription)
+ mocks.subscribeToTicketFn.mockResolvedValue(subscription())
+ mocks.unsubscribeFromTicketFn.mockResolvedValue(undefined)
+ mocks.updateTicketSubscriptionPrefsFn.mockResolvedValue(subscription())
+ mocks.muteTicketFn.mockResolvedValue(subscription({ mutedUntil: '2099-01-01T00:00:00.000Z' }))
+ mocks.unmuteTicketFn.mockResolvedValue(subscription())
+})
+
+describe('TicketSubscriptionMenu', () => {
+ it('subscribes when no subscription exists and handles subscribe errors', async () => {
+ render( )
+
+ expect(screen.getByRole('button', { name: 'Subscribe to ticket' })).toBeEnabled()
+ expect(mocks.getMyTicketSubscriptionFn).toHaveBeenCalledWith({
+ data: { ticketId: 'ticket_1' },
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Subscribe' }))
+ await waitFor(() => {
+ expect(mocks.subscribeToTicketFn).toHaveBeenCalledWith({
+ data: { ticketId: 'ticket_1' },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['tickets', 'my-subscription', 'ticket_1'],
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Subscribed')
+
+ mocks.subscribeToTicketFn.mockRejectedValueOnce(new Error('Subscribe denied'))
+ fireEvent.click(screen.getByRole('button', { name: 'Subscribe' }))
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Subscribe denied')
+ })
+ })
+
+ it('unsubscribes, updates preferences and mutes subscribed tickets', async () => {
+ mocks.subscription = subscription()
+ render( )
+
+ expect(screen.getByRole('button', { name: 'Subscription menu (subscribed)' })).toBeEnabled()
+ fireEvent.click(screen.getByRole('button', { name: 'Unsubscribe' }))
+ await waitFor(() => {
+ expect(mocks.unsubscribeFromTicketFn).toHaveBeenCalledWith({
+ data: { ticketId: 'ticket_1' },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Unsubscribed')
+
+ fireEvent.click(screen.getByRole('menuitemcheckbox', { name: 'Status changes' }))
+ await waitFor(() => {
+ expect(mocks.updateTicketSubscriptionPrefsFn).toHaveBeenCalledWith({
+ data: {
+ ticketId: 'ticket_1',
+ patch: { notifyStatus: true },
+ },
+ })
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Mute until I unmute' }))
+ await waitFor(() => {
+ expect(mocks.muteTicketFn).toHaveBeenCalledWith({
+ data: { ticketId: 'ticket_1' },
+ })
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Muted')
+ })
+
+ it('unmutes muted subscriptions and reports mutation errors', async () => {
+ mocks.subscription = subscription({ mutedUntil: '2099-01-01T00:00:00.000Z' })
+ mocks.updateTicketSubscriptionPrefsFn.mockRejectedValueOnce(new Error('Prefs denied'))
+ mocks.muteTicketFn.mockRejectedValueOnce(new Error('Mute denied'))
+ mocks.unmuteTicketFn.mockRejectedValueOnce(new Error('Unmute denied'))
+ mocks.unsubscribeFromTicketFn.mockRejectedValueOnce(new Error('Unsubscribe denied'))
+
+ render( )
+
+ expect(screen.getByRole('button', { name: 'Subscription menu (muted)' })).toBeEnabled()
+
+ fireEvent.click(screen.getByRole('menuitemcheckbox', { name: 'New replies' }))
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Prefs denied')
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Mute for 1 hour' }))
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Mute denied')
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Unmute' }))
+ await waitFor(() => {
+ expect(mocks.unmuteTicketFn).toHaveBeenCalledWith({
+ data: { ticketId: 'ticket_1' },
+ })
+ })
+ expect(mocks.toastError).toHaveBeenCalledWith('Unmute denied')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Unsubscribe' }))
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Unsubscribe denied')
+ })
+ })
+
+ it('disables the trigger while loading or any mutation is pending', () => {
+ mocks.isLoading = true
+ const { rerender } = render( )
+
+ expect(screen.getByRole('button', { name: 'Subscribe to ticket' })).toBeDisabled()
+
+ mocks.isLoading = false
+ mocks.pending = true
+ rerender( )
+
+ expect(screen.getByRole('button', { name: 'Subscribe to ticket' })).toBeDisabled()
+ })
+})
diff --git a/apps/web/src/components/admin/tickets/__tests__/ticket-thread-composer.test.tsx b/apps/web/src/components/admin/tickets/__tests__/ticket-thread-composer.test.tsx
new file mode 100644
index 000000000..703be3f46
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/__tests__/ticket-thread-composer.test.tsx
@@ -0,0 +1,373 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { TicketThreadComposer } from '../ticket-thread-composer'
+
+type MutationOptions = {
+ mutationFn: () => Promise<{ id?: string }>
+ onSuccess?: (result: { id?: string }) => void
+ onError?: (error: Error) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ addThreadFn: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+ uploadImage: vi.fn(),
+ fetchMock: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ useMutation: (options: MutationOptions) => ({
+ isPending: false,
+ mutate: async () => {
+ try {
+ const result = await options.mutationFn()
+ options.onSuccess?.(result)
+ } catch (error) {
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+ }),
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ size?: string
+ variant?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/tabs', () => {
+ let changeTab: (value: string) => void = () => undefined
+
+ return {
+ Tabs: ({
+ children,
+ onValueChange,
+ }: {
+ children: ReactNode
+ value: string
+ onValueChange: (value: string) => void
+ className?: string
+ }) => {
+ changeTab = onValueChange
+ return {children}
+ },
+ TabsList: ({ children }: { children: ReactNode }) => {children}
,
+ TabsTrigger: ({ children, value }: { children: ReactNode; value: string }) => (
+ changeTab(value)}>
+ {children}
+
+ ),
+ }
+})
+
+vi.mock('@/components/ui/rich-text-editor', () => ({
+ RichTextEditor: ({
+ onChange,
+ placeholder,
+ onImageUpload,
+ }: {
+ onChange: (json: { type: 'doc'; content?: unknown[] }, html: string, markdown: string) => void
+ placeholder: string
+ onImageUpload?: (file: File) => Promise
+ }) => (
+
+ {
+ const text = event.currentTarget.value
+ onChange(
+ {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: text ? [{ type: 'text', text }] : [],
+ },
+ ],
+ },
+ '',
+ text
+ )
+ }}
+ />
+
+ onChange(
+ {
+ type: 'doc',
+ content: [
+ {
+ type: 'heading',
+ content: [{ type: 'text', text: 'From rich json' }],
+ },
+ ],
+ },
+ '',
+ ''
+ )
+ }
+ >
+ Set JSON body
+
+ void onImageUpload?.(new File(['image'], 'inline.png'))}>
+ Upload inline image
+
+
+ ),
+}))
+
+vi.mock('@/components/admin/shared/team-picker', () => ({
+ TeamPicker: ({ onValueChange }: { onValueChange: (id: string | null) => void }) => (
+ onValueChange('team_success')}>
+ Pick sharing team
+
+ ),
+}))
+
+vi.mock('@/lib/server/functions/tickets', () => ({
+ addThreadFn: mocks.addThreadFn,
+}))
+
+vi.mock('@/lib/client/queries/tickets', () => ({
+ ticketQueries: {
+ attachments: (ticketId: string, threadId: string) => ({
+ queryKey: ['tickets', ticketId, 'threads', threadId, 'attachments'],
+ }),
+ threads: (ticketId: string) => ({ queryKey: ['tickets', ticketId, 'threads'] }),
+ detail: (ticketId: string) => ({ queryKey: ['tickets', ticketId, 'detail'] }),
+ },
+}))
+
+vi.mock('@/lib/client/hooks/use-image-upload', () => ({
+ useImageUpload: ({ prefix }: { prefix: string }) => ({
+ upload: (file: File) => mocks.uploadImage({ prefix, file }),
+ }),
+}))
+
+vi.mock('lucide-react', () => ({
+ X: () => Remove attachment ,
+ Upload: () => Upload icon ,
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+function renderComposer(props: Partial> = {}) {
+ return render(
+
+ )
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.addThreadFn.mockResolvedValue({ id: 'ticket_thread_1' })
+ mocks.uploadImage.mockResolvedValue({ url: 'https://cdn.test/inline.png' })
+ mocks.fetchMock.mockResolvedValue({
+ ok: true,
+ text: async () => '',
+ })
+ vi.stubGlobal('fetch', mocks.fetchMock)
+})
+
+describe('TicketThreadComposer', () => {
+ it('renders a permission-denied state when no audience is allowed', () => {
+ renderComposer({ canPublic: false, canInternal: false, canShared: false })
+
+ expect(
+ screen.getByText("You don't have permission to reply on this ticket.")
+ ).toBeInTheDocument()
+ })
+
+ it('posts a public reply and invalidates ticket queries', async () => {
+ const onPosted = vi.fn()
+ renderComposer({ onPosted })
+
+ expect(screen.getByRole('button', { name: 'Post' })).toBeDisabled()
+ fireEvent.change(screen.getByLabelText(/Reply to customer/), {
+ target: { value: 'Customer-visible reply' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Post' }))
+
+ await waitFor(() => {
+ expect(mocks.addThreadFn).toHaveBeenCalledWith({
+ data: {
+ ticketId: 'ticket_1',
+ audience: 'public',
+ bodyJson: {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Customer-visible reply' }],
+ },
+ ],
+ },
+ bodyText: 'Customer-visible reply',
+ sharedWithTeamId: null,
+ },
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['tickets', 'ticket_1', 'threads'],
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['tickets', 'ticket_1', 'detail'],
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['tickets', 'list'] })
+ expect(onPosted).toHaveBeenCalled()
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Reply posted')
+ })
+
+ it('posts an internal note using plain text extracted from rich JSON', async () => {
+ renderComposer({ canPublic: false, canShared: false })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Set JSON body' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Post' }))
+
+ await waitFor(() => {
+ expect(mocks.addThreadFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ audience: 'internal',
+ bodyText: 'From rich json',
+ sharedWithTeamId: null,
+ }),
+ })
+ )
+ })
+ })
+
+ it('requires a selected team before posting a shared-team note', async () => {
+ renderComposer({ canPublic: false, canInternal: false })
+
+ fireEvent.change(screen.getByLabelText(/Visible to your team/), {
+ target: { value: 'Shared note' },
+ })
+ expect(screen.getByRole('button', { name: 'Post' })).toBeDisabled()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Pick sharing team' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Post' }))
+
+ await waitFor(() => {
+ expect(mocks.addThreadFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ audience: 'shared_team',
+ bodyText: 'Shared note',
+ sharedWithTeamId: 'team_success',
+ }),
+ })
+ )
+ })
+ })
+
+ it('uploads selected attachments and reports per-file upload failures', async () => {
+ mocks.fetchMock
+ .mockResolvedValueOnce({ ok: true, text: async () => '' })
+ .mockResolvedValueOnce({ ok: false, text: async () => 'storage down' })
+ const { container } = renderComposer()
+
+ fireEvent.change(screen.getByLabelText(/Reply to customer/), {
+ target: { value: 'Reply with files' },
+ })
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement
+ fireEvent.change(input, {
+ target: {
+ files: [
+ new File(['one'], 'one.txt', { type: 'text/plain' }),
+ new File(['two'], 'two.txt', { type: 'text/plain' }),
+ ],
+ },
+ })
+
+ expect(screen.getByText('Attachments (2)')).toBeInTheDocument()
+ expect(screen.getByText('one.txt')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Post' }))
+
+ await waitFor(() => {
+ expect(mocks.fetchMock).toHaveBeenCalledTimes(2)
+ })
+ expect(mocks.fetchMock).toHaveBeenCalledWith(
+ '/api/v1/tickets/ticket_1/threads/ticket_thread_1/attachments',
+ expect.objectContaining({
+ method: 'POST',
+ body: expect.any(FormData),
+ })
+ )
+ expect(mocks.toastError).toHaveBeenCalledWith(
+ 'Failed to upload two.txt: Upload failed: storage down'
+ )
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['tickets', 'ticket_1', 'threads', 'ticket_thread_1', 'attachments'],
+ })
+ })
+
+ it('can remove selected attachments before posting', () => {
+ const { container } = renderComposer()
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement
+ fireEvent.change(input, {
+ target: {
+ files: [new File(['one'], 'one.txt'), new File(['two'], 'two.txt')],
+ },
+ })
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Remove attachment' })[0])
+
+ expect(screen.queryByText('one.txt')).not.toBeInTheDocument()
+ expect(screen.getByText('two.txt')).toBeInTheDocument()
+ expect(screen.getByText('Attachments (1)')).toBeInTheDocument()
+ })
+
+ it('reports posting failures and wires inline image uploads', async () => {
+ mocks.addThreadFn.mockRejectedValueOnce(new Error('Cannot reply to closed ticket'))
+ renderComposer()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Upload inline image' }))
+ expect(mocks.uploadImage).toHaveBeenCalledWith({
+ prefix: 'uploads',
+ file: expect.objectContaining({ name: 'inline.png' }),
+ })
+
+ fireEvent.change(screen.getByLabelText(/Reply to customer/), {
+ target: { value: 'Reply fails' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Post' }))
+
+ expect(await screen.findByText('Post')).toBeInTheDocument()
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith('Cannot reply to closed ticket')
+ })
+ })
+})
diff --git a/apps/web/src/components/admin/tickets/__tests__/ticket-thread-feed.test.tsx b/apps/web/src/components/admin/tickets/__tests__/ticket-thread-feed.test.tsx
new file mode 100644
index 000000000..26e98fc52
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/__tests__/ticket-thread-feed.test.tsx
@@ -0,0 +1,272 @@
+// @vitest-environment happy-dom
+import { describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import type { ReactElement } from 'react'
+import type { JSONContent } from '@tiptap/react'
+import { TicketThreadFeed } from '../ticket-thread-feed'
+
+// Thread rows render a `ThreadAttachmentsLoader` that calls
+// `useQuery(ticketQueries.attachments(...))`. Stub the query options so the
+// loader resolves to an empty attachment list (and renders nothing) instead of
+// hitting a real server function.
+vi.mock('@/lib/client/queries/tickets', () => ({
+ ticketQueries: {
+ attachments: vi.fn((ticketId: string, threadId: string) => ({
+ queryKey: ['tickets', ticketId, 'threads', threadId, 'attachments'],
+ queryFn: async () => [],
+ })),
+ },
+}))
+
+// TicketAttachments uses react-intl , which needs an
+// IntlProvider this test doesn't supply. The attachment list is not under
+// test here (these tests assert on author labels), so stub it out.
+vi.mock('@/components/tickets/ticket-attachments', () => ({
+ TicketAttachments: () => null,
+}))
+
+function renderWithClient(ui: ReactElement) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ return render({ui} )
+}
+
+vi.mock('@/components/ui/rich-text-editor', () => {
+ function textFromDoc(content: unknown): string {
+ if (!content || typeof content !== 'object') return ''
+ let out = ''
+ const walk = (node: { type?: string; text?: unknown; content?: unknown[] }) => {
+ if (node.type === 'text' && typeof node.text === 'string') out += node.text
+ node.content?.forEach((child) => walk(child as never))
+ }
+ walk(content as never)
+ return out
+ }
+
+ return {
+ isRichTextContent: (content: unknown) =>
+ typeof content === 'object' &&
+ content !== null &&
+ 'type' in content &&
+ (content as { type?: string }).type === 'doc',
+ RichTextContent: ({ content }: { content: unknown }) => (
+ {textFromDoc(content)}
+ ),
+ RichTextEditor: ({
+ value,
+ onChange,
+ placeholder,
+ }: {
+ value?: JSONContent
+ onChange: (json: JSONContent, html: string, markdown: string) => void
+ placeholder: string
+ }) => (
+ {
+ const text = event.currentTarget.value
+ onChange(
+ {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: text ? [{ type: 'text', text }] : [],
+ },
+ ],
+ },
+ '',
+ text
+ )
+ }}
+ />
+ ),
+ }
+})
+
+describe('TicketThreadFeed description editing', () => {
+ it('renders a read-only description when no update callback is provided', () => {
+ renderWithClient(
+
+ )
+
+ expect(screen.getByText('Original description')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument()
+ })
+
+ it('calls onDescriptionUpdate with edited rich text content', () => {
+ const onDescriptionUpdate = vi.fn()
+ renderWithClient(
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: /add a description/i }))
+ fireEvent.change(screen.getByLabelText('Add a description...'), {
+ target: { value: 'Updated description' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
+
+ expect(onDescriptionUpdate).toHaveBeenCalledWith(
+ {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Updated description' }],
+ },
+ ],
+ },
+ 'Updated description'
+ )
+ })
+
+ it('seeds the editor from plain text when editing an existing description', () => {
+ const onDescriptionUpdate = vi.fn()
+ renderWithClient(
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: /edit/i }))
+ expect(screen.getByLabelText('Add a description...')).toHaveValue('Line oneLine two')
+ })
+
+ it('saves existing rich text by deriving fallback plain text from JSON', () => {
+ const onDescriptionUpdate = vi.fn()
+ const descriptionJson: JSONContent = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'JSON description' }],
+ },
+ ],
+ }
+
+ renderWithClient(
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: /edit/i }))
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
+
+ expect(onDescriptionUpdate).toHaveBeenCalledWith(descriptionJson, 'JSON description')
+ })
+
+ it('treats media-only rich text as a visible description', () => {
+ renderWithClient(
+
+ )
+
+ expect(screen.getByText('Description')).toBeInTheDocument()
+ expect(screen.getByTestId('rich-text-content')).toBeInTheDocument()
+ })
+})
+
+describe('TicketThreadFeed author labels', () => {
+ it('renders principal display names instead of raw principal IDs', () => {
+ renderWithClient(
+
+ )
+
+ expect(screen.getByText('Meli Sunbul')).toBeInTheDocument()
+ expect(screen.queryByText(/principal_01ktxq7/i)).not.toBeInTheDocument()
+ })
+
+ it('does not expose raw principal IDs when a display name is missing', () => {
+ renderWithClient(
+
+ )
+
+ expect(screen.getByText('Unknown')).toBeInTheDocument()
+ expect(screen.queryByText('principal_missing_name')).not.toBeInTheDocument()
+ })
+
+ it('renders audience labels, team names, system authors, and edited markers', () => {
+ renderWithClient(
+
+ )
+
+ expect(screen.getByText('System')).toBeInTheDocument()
+ expect(screen.getByText('Internal note')).toBeInTheDocument()
+ expect(screen.getByText('(edited)')).toBeInTheDocument()
+ expect(screen.getByText('Agent Smith')).toBeInTheDocument()
+ expect(screen.getByText(/Shared with team/)).toBeInTheDocument()
+ expect(screen.getByText(/Billing/)).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/admin/tickets/sla-clock-chip.tsx b/apps/web/src/components/admin/tickets/sla-clock-chip.tsx
new file mode 100644
index 000000000..320d7e1c9
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/sla-clock-chip.tsx
@@ -0,0 +1,90 @@
+/**
+ * Compact countdown chip for an SLA clock. Color ramps from green (>1h to due)
+ * through amber (<1h) to red (breached).
+ */
+import { useEffect, useState } from 'react'
+import { cn } from '@/lib/shared/utils'
+
+export type SlaClockState = 'running' | 'paused' | 'met' | 'breached' | 'cancelled'
+export type SlaClockKind = 'first_response' | 'next_response' | 'resolution'
+
+export interface SlaClockChipInput {
+ kind: SlaClockKind
+ state: SlaClockState
+ dueAt: string | Date
+ breachedAt?: string | Date | null
+ metAt?: string | Date | null
+}
+
+export interface SlaClockChipProps {
+ clock: SlaClockChipInput
+ className?: string
+ /** Show the kind label (e.g. "First response") inline. */
+ showKind?: boolean
+}
+
+function formatDelta(ms: number): string {
+ const abs = Math.abs(ms)
+ const minutes = Math.floor(abs / 60_000)
+ if (minutes < 1) return '<1m'
+ if (minutes < 60) return `${minutes}m`
+ const hours = Math.floor(minutes / 60)
+ if (hours < 24) return `${hours}h ${minutes % 60}m`
+ const days = Math.floor(hours / 24)
+ return `${days}d ${hours % 24}h`
+}
+
+const KIND_LABEL: Record = {
+ first_response: 'First response',
+ next_response: 'Next response',
+ resolution: 'Resolution',
+}
+
+export function SlaClockChip({ clock, className, showKind = false }: SlaClockChipProps) {
+ // Re-render every 30s so countdown stays fresh.
+ const [, setTick] = useState(0)
+ useEffect(() => {
+ const t = setInterval(() => setTick((v) => v + 1), 30_000)
+ return () => clearInterval(t)
+ }, [])
+
+ const due = new Date(clock.dueAt).getTime()
+ const now = Date.now()
+ const remainingMs = due - now
+
+ let label: string
+ let style: string
+ if (clock.state === 'met') {
+ label = 'Met'
+ style = 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200'
+ } else if (clock.state === 'breached' || remainingMs < 0) {
+ label = `−${formatDelta(remainingMs)}`
+ style = 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-200'
+ } else if (clock.state === 'paused') {
+ label = 'Paused'
+ style = 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200'
+ } else if (clock.state === 'cancelled') {
+ label = 'Cancelled'
+ style = 'bg-muted text-muted-foreground'
+ } else {
+ label = formatDelta(remainingMs)
+ style =
+ remainingMs < 60 * 60_000
+ ? 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200'
+ : 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200'
+ }
+
+ return (
+
+ {showKind && {KIND_LABEL[clock.kind]}: }
+ {label}
+
+ )
+}
diff --git a/apps/web/src/components/admin/tickets/ticket-activity-timeline.tsx b/apps/web/src/components/admin/tickets/ticket-activity-timeline.tsx
new file mode 100644
index 000000000..b146efbad
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/ticket-activity-timeline.tsx
@@ -0,0 +1,515 @@
+/**
+ * Right-panel "Activity" tab. Renders the ticket's audit timeline pulled via
+ * `ticketQueries.activity()`. Each row turns the stored audit event and
+ * metadata into a readable product summary instead of exposing raw JSON/IDs.
+ */
+import { useSuspenseQuery } from '@tanstack/react-query'
+import type { TicketId } from '@quackback/ids'
+import { ticketQueries } from '@/lib/client/queries/tickets'
+import { TimeAgo } from '@/components/ui/time-ago'
+import { Badge } from '@/components/ui/badge'
+import {
+ ArrowRight,
+ CheckCircle2,
+ MessageSquare,
+ Paperclip,
+ Pencil,
+ Plus,
+ RotateCcw,
+ Route,
+ Settings2,
+ Share2,
+ Trash2,
+ UserCheck,
+ UserMinus,
+ UserPlus,
+ UserX,
+ type LucideIcon,
+} from 'lucide-react'
+import type { ReactNode } from 'react'
+
+export interface TicketActivityTimelineProps {
+ ticketId: TicketId
+ principalNames?: Record
+}
+
+interface ActivityRow {
+ id: string
+ principalId: string | null
+ type: string
+ metadata: unknown
+ createdAt: Date | string
+ actorName?: string | null
+ actorAvatarUrl?: string | null
+}
+
+interface ActivityDisplay {
+ icon: LucideIcon
+ title: string
+ detail?: ReactNode
+}
+
+interface ChangeSummary {
+ key: string
+ label: string
+ from: string | null
+ to: string | null
+}
+
+const FIELD_LABELS: Record = {
+ description: 'Description',
+ descriptionJson: 'Description',
+ descriptionText: 'Description',
+ subject: 'Subject',
+ priority: 'Priority',
+ visibilityScope: 'Visibility',
+ primaryTeamId: 'Primary team',
+ assigneePrincipalId: 'Assignee',
+ assigneeTeamId: 'Assignee team',
+ organizationId: 'Organization',
+ requesterContactId: 'Requester contact',
+ inboxId: 'Inbox',
+}
+
+const FIELD_ACTIONS: Record = {
+ description: 'updated the description',
+ descriptionJson: 'updated the description',
+ descriptionText: 'updated the description',
+ subject: 'changed the subject',
+ priority: 'changed priority',
+ visibilityScope: 'changed visibility',
+ primaryTeamId: 'changed primary team',
+ assigneePrincipalId: 'changed assignee',
+ assigneeTeamId: 'changed assignee team',
+ organizationId: 'changed organization',
+ requesterContactId: 'changed requester contact',
+ inboxId: 'changed inbox',
+}
+
+const CATEGORY_LABELS: Record = {
+ open: 'Open',
+ pending: 'Pending',
+ on_hold: 'On hold',
+ solved: 'Solved',
+ closed: 'Closed',
+}
+
+const PRIORITY_LABELS: Record = {
+ low: 'Low',
+ normal: 'Normal',
+ high: 'High',
+ urgent: 'Urgent',
+}
+
+const VISIBILITY_LABELS: Record = {
+ team: 'Team',
+ org: 'Organization',
+ shared: 'Shared',
+ private: 'Private',
+}
+
+const CHANNEL_LABELS: Record = {
+ api: 'API',
+ email: 'Email',
+ widget: 'Widget',
+ portal: 'Portal',
+ github: 'GitHub',
+}
+
+const AUDIENCE_LABELS: Record = {
+ public: 'Public reply',
+ internal: 'Internal note',
+ shared_team: 'Shared-team note',
+}
+
+const AUDIENCE_ACTIONS: Record = {
+ public: 'posted a public reply',
+ internal: 'posted an internal note',
+ shared_team: 'posted a shared-team note',
+}
+
+const PARTICIPANT_ROLE_LABELS: Record = {
+ watcher: 'Watcher',
+ collaborator: 'Collaborator',
+ cc: 'CC',
+}
+
+const ACCESS_LABELS: Record = {
+ read: 'Read access',
+ comment: 'Comment access',
+ full: 'Full access',
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function actorLabel(row: ActivityRow, principalNames?: Record): string {
+ const name = row.actorName?.trim()
+ if (name) return name
+ if (row.principalId && principalNames?.[row.principalId]) return principalNames[row.principalId]
+ return row.principalId ? 'Someone' : 'System'
+}
+
+function titleCaseToken(value: string): string {
+ return value
+ .replace(/[_-]+/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .replace(/\b\w/g, (char) => char.toUpperCase())
+}
+
+function truncate(value: string, max = 80): string {
+ if (value.length <= max) return value
+ return `${value.slice(0, max - 1).trimEnd()}...`
+}
+
+function isOpaqueIdentifier(value: string): boolean {
+ return /^[a-z]+_[a-z0-9]{8,}$/i.test(value) || /^[0-9a-f-]{24,}$/i.test(value)
+}
+
+function formatKnownValue(field: string, value: string): string {
+ if (field === 'priority') return PRIORITY_LABELS[value] ?? titleCaseToken(value)
+ if (field === 'visibilityScope') return VISIBILITY_LABELS[value] ?? titleCaseToken(value)
+ if (field === 'category' || field === 'statusCategory') {
+ return CATEGORY_LABELS[value] ?? titleCaseToken(value)
+ }
+ if (field === 'channel') return CHANNEL_LABELS[value] ?? titleCaseToken(value)
+ if (field === 'audience') return AUDIENCE_LABELS[value] ?? titleCaseToken(value)
+ if (field === 'accessLevel') return ACCESS_LABELS[value] ?? titleCaseToken(value)
+ if (field === 'role') return PARTICIPANT_ROLE_LABELS[value] ?? titleCaseToken(value)
+ return value
+}
+
+function formatChangeValue(field: string, value: unknown): string | null {
+ if (value == null || value === '') return 'None'
+ if (field === 'description' || field === 'descriptionJson' || field === 'descriptionText') {
+ return null
+ }
+ if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled'
+ if (typeof value === 'number') return String(value)
+ if (typeof value !== 'string') return null
+
+ const trimmed = value.trim()
+ if (!trimmed) return 'Empty'
+ if (field.endsWith('Id') || isOpaqueIdentifier(trimmed)) return null
+
+ return truncate(formatKnownValue(field, trimmed))
+}
+
+function formatEventType(type: string): string {
+ return titleCaseToken(type.split('.').join(' '))
+}
+
+function detailPill(value: string) {
+ return (
+
+ {value}
+
+ )
+}
+
+function ChangeLine({ change }: { change: ChangeSummary }) {
+ const hasValues = change.from !== null || change.to !== null
+
+ if (!hasValues) {
+ return {change.label} changed
+ }
+
+ return (
+
+ {change.label}:
+ {change.from && detailPill(change.from)}
+
+ {change.to && detailPill(change.to)}
+
+ )
+}
+
+function ChangeList({ changes }: { changes: ChangeSummary[] }) {
+ if (changes.length === 0) return null
+
+ return (
+
+ {changes.map((change) => (
+
+
+
+ ))}
+
+ )
+}
+
+function SummaryPills({ items }: { items: Array }) {
+ const visible = items.filter((item): item is string => Boolean(item))
+ if (visible.length === 0) return null
+
+ return (
+
+ {visible.map((item) => (
+
+ {item}
+
+ ))}
+
+ )
+}
+
+function collapseDiffKey(field: string): string {
+ return field === 'descriptionJson' || field === 'descriptionText' ? 'description' : field
+}
+
+function summarizeDiff(metadata: unknown): ChangeSummary[] {
+ if (!isRecord(metadata) || !isRecord(metadata.diff)) return []
+
+ const changes: ChangeSummary[] = []
+ const seen = new Set()
+
+ for (const [field, rawChange] of Object.entries(metadata.diff)) {
+ if (!isRecord(rawChange)) continue
+ const key = collapseDiffKey(field)
+ if (seen.has(key)) continue
+ seen.add(key)
+
+ changes.push({
+ key,
+ label: FIELD_LABELS[key] ?? titleCaseToken(key),
+ from: formatChangeValue(key, rawChange.from),
+ to: formatChangeValue(key, rawChange.to),
+ })
+ }
+
+ return changes
+}
+
+function updatedDisplay(row: ActivityRow, actor: string): ActivityDisplay {
+ const changes = summarizeDiff(row.metadata)
+ if (changes.length === 0) {
+ return { icon: Pencil, title: `${actor} updated this ticket` }
+ }
+
+ if (changes.length === 1) {
+ const action = FIELD_ACTIONS[changes[0].key] ?? `changed ${changes[0].label.toLowerCase()}`
+ return {
+ icon: Pencil,
+ title: `${actor} ${action}`,
+ detail: ,
+ }
+ }
+
+ return {
+ icon: Pencil,
+ title: `${actor} updated ${changes.length} fields`,
+ detail: ,
+ }
+}
+
+function nestedRecord(metadata: unknown, key: string): Record | null {
+ return isRecord(metadata) && isRecord(metadata[key]) ? metadata[key] : null
+}
+
+function stringMeta(metadata: unknown, key: string): string | null {
+ if (!isRecord(metadata)) return null
+ const value = metadata[key]
+ return typeof value === 'string' ? value : null
+}
+
+function statusChangeDetail(metadata: unknown) {
+ const from = nestedRecord(metadata, 'from')
+ const to = nestedRecord(metadata, 'to')
+ const fromCategory = typeof from?.category === 'string' ? from.category : null
+ const toCategory = typeof to?.category === 'string' ? to.category : null
+
+ if (!fromCategory && !toCategory) return null
+
+ return (
+
+ )
+}
+
+function assignmentDetail(metadata: unknown) {
+ const to = nestedRecord(metadata, 'to')
+ const from = nestedRecord(metadata, 'from')
+ const next = to
+ ? [to.principalId ? 'person' : null, to.teamId ? 'team' : null].filter(Boolean).join(' and ')
+ : ''
+ const previous = from?.principalId || from?.teamId ? 'Previously assigned' : null
+
+ return (
+
+ )
+}
+
+function routedDetail(metadata: unknown) {
+ if (!isRecord(metadata)) return null
+ return (
+
+ )
+}
+
+function activityDisplay(row: ActivityRow, actor: string): ActivityDisplay {
+ const metadata = row.metadata
+
+ switch (row.type) {
+ case 'ticket.created': {
+ const statusCategory = stringMeta(metadata, 'statusCategory')
+ const priority = stringMeta(metadata, 'priority')
+ const channel = stringMeta(metadata, 'channel')
+ return {
+ icon: Plus,
+ title: `${actor} created this ticket`,
+ detail: (
+
+ ),
+ }
+ }
+ case 'ticket.routed':
+ return { icon: Route, title: `${actor} routed this ticket`, detail: routedDetail(metadata) }
+ case 'ticket.updated':
+ return updatedDisplay(row, actor)
+ case 'ticket.assigned':
+ return {
+ icon: UserCheck,
+ title: `${actor} assigned this ticket`,
+ detail: assignmentDetail(metadata),
+ }
+ case 'ticket.unassigned':
+ return { icon: UserMinus, title: `${actor} removed the assignee` }
+ case 'ticket.status_changed':
+ return {
+ icon: CheckCircle2,
+ title: `${actor} changed status`,
+ detail: statusChangeDetail(metadata),
+ }
+ case 'ticket.deleted':
+ return { icon: Trash2, title: `${actor} deleted this ticket` }
+ case 'ticket.restored':
+ return { icon: RotateCcw, title: `${actor} restored this ticket` }
+ case 'thread.added': {
+ const audience = stringMeta(metadata, 'audience')
+ const label = audience ? (AUDIENCE_LABELS[audience] ?? titleCaseToken(audience)) : 'Reply'
+ const action = audience
+ ? (AUDIENCE_ACTIONS[audience] ?? `posted ${label.toLowerCase()}`)
+ : 'posted a reply'
+ return {
+ icon: MessageSquare,
+ title: `${actor} ${action}`,
+ detail: ,
+ }
+ }
+ case 'thread.edited':
+ return { icon: Pencil, title: `${actor} edited a reply` }
+ case 'thread.deleted':
+ return { icon: Trash2, title: `${actor} deleted a reply` }
+ case 'participant.added': {
+ const role = stringMeta(metadata, 'role')
+ return {
+ icon: UserPlus,
+ title: `${actor} added a participant`,
+ detail: (
+
+ ),
+ }
+ }
+ case 'participant.removed':
+ return { icon: UserX, title: `${actor} removed a participant` }
+ case 'ticket.shared': {
+ const access = stringMeta(metadata, 'accessLevel')
+ return {
+ icon: Share2,
+ title: `${actor} shared this ticket with a team`,
+ detail: ,
+ }
+ }
+ case 'ticket.unshared':
+ return { icon: Share2, title: `${actor} removed a team share` }
+ case 'attachment.added': {
+ const filename = stringMeta(metadata, 'filename')
+ return {
+ icon: Paperclip,
+ title: `${actor} added an attachment`,
+ detail: filename ? (
+
+ {truncate(filename, 120)}
+
+ ) : null,
+ }
+ }
+ case 'attachment.removed':
+ return { icon: Paperclip, title: `${actor} removed an attachment` }
+ default:
+ return {
+ icon: Settings2,
+ title: `${actor} recorded ${formatEventType(row.type)}`,
+ }
+ }
+}
+
+export function TicketActivityTimeline({ ticketId, principalNames }: TicketActivityTimelineProps) {
+ const { data } = useSuspenseQuery(ticketQueries.activity(ticketId, { limit: 100 }))
+ const activity = data as ActivityRow[]
+
+ if (activity.length === 0) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {activity.map((row) => {
+ const actor = actorLabel(row, principalNames)
+ const display = activityDisplay(row, actor)
+ const Icon = display.icon
+
+ return (
+
+
+
+
+
+
{display.title}
+
+ {display.detail}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/apps/web/src/components/admin/tickets/ticket-channel-icon.tsx b/apps/web/src/components/admin/tickets/ticket-channel-icon.tsx
new file mode 100644
index 000000000..18066f2b1
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/ticket-channel-icon.tsx
@@ -0,0 +1,29 @@
+/**
+ * Channel icon for tickets. Maps the channel to a Heroicon.
+ */
+import {
+ EnvelopeIcon,
+ GlobeAltIcon,
+ CodeBracketIcon,
+ ChatBubbleBottomCenterTextIcon,
+} from '@heroicons/react/24/outline'
+import { cn } from '@/lib/shared/utils'
+
+export type TicketChannel = 'email' | 'portal' | 'api' | 'widget'
+
+export interface TicketChannelIconProps {
+ channel: TicketChannel
+ className?: string
+}
+
+const iconMap: Record = {
+ email: EnvelopeIcon,
+ portal: GlobeAltIcon,
+ api: CodeBracketIcon,
+ widget: ChatBubbleBottomCenterTextIcon,
+}
+
+export function TicketChannelIcon({ channel, className }: TicketChannelIconProps) {
+ const Icon = iconMap[channel]
+ return
+}
diff --git a/apps/web/src/components/admin/tickets/ticket-detail-header.tsx b/apps/web/src/components/admin/tickets/ticket-detail-header.tsx
new file mode 100644
index 000000000..bf46d9702
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/ticket-detail-header.tsx
@@ -0,0 +1,148 @@
+/**
+ * Detail-page header for a single ticket. Shows subject, breadcrumb back to
+ * queue, channel/priority/visibility metadata, and Take/Return + delete
+ * actions. Status transitions live in the properties panel.
+ */
+import { Link, useRouter } from '@tanstack/react-router'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { ArrowLeftIcon, TrashIcon } from '@heroicons/react/24/outline'
+import type { TicketId, PrincipalId } from '@quackback/ids'
+import { Button } from '@/components/ui/button'
+import { TimeAgo } from '@/components/ui/time-ago'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog'
+import { TicketChannelIcon, type TicketChannel } from './ticket-channel-icon'
+import { TicketPriorityChip, type TicketPriority } from './ticket-priority-chip'
+import { TicketSubscriptionMenu } from './ticket-subscription-menu'
+import { takeTicketFn, returnTicketFn, softDeleteTicketFn } from '@/lib/server/functions/tickets'
+import { ticketQueries } from '@/lib/client/queries/tickets'
+import { toast } from 'sonner'
+
+const VISIBILITY_LABELS: Record = {
+ team: 'Team',
+ org: 'Organization',
+ shared: 'Shared',
+ private: 'Private',
+}
+
+export interface TicketDetailHeaderProps {
+ ticket: {
+ id: TicketId
+ subject: string
+ channel: string
+ priority: string
+ visibilityScope: string
+ updatedAt: Date | string
+ assigneePrincipalId: PrincipalId | null
+ }
+ currentPrincipalId: PrincipalId
+}
+
+export function TicketDetailHeader({ ticket, currentPrincipalId }: TicketDetailHeaderProps) {
+ const router = useRouter()
+ const qc = useQueryClient()
+
+ const invalidate = () => {
+ qc.invalidateQueries({ queryKey: ticketQueries.detail(ticket.id).queryKey })
+ qc.invalidateQueries({ queryKey: ['tickets', 'list'] })
+ }
+
+ const takeMutation = useMutation({
+ mutationFn: () => takeTicketFn({ data: { ticketId: ticket.id } }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Assigned to you')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const returnMutation = useMutation({
+ mutationFn: () => returnTicketFn({ data: { ticketId: ticket.id } }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Returned to team')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: () => softDeleteTicketFn({ data: { ticketId: ticket.id } }),
+ onSuccess: () => {
+ toast.success('Ticket deleted')
+ router.navigate({ to: '/admin/tickets' })
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const isMine = ticket.assigneePrincipalId === currentPrincipalId
+
+ return (
+
+
+
+
+ Queue
+
+
+
+
{ticket.subject}
+
+
+
+ {VISIBILITY_LABELS[ticket.visibilityScope] ?? ticket.visibilityScope}
+ •
+
+ updated
+
+
+
+
+ {isMine ? (
+
returnMutation.mutate()}
+ disabled={returnMutation.isPending}
+ >
+ Return
+
+ ) : (
+
takeMutation.mutate()} disabled={takeMutation.isPending}>
+ Take
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ Delete ticket?
+
+ This soft-deletes the ticket. It will be hidden from queues but retained in audit
+ history.
+
+
+
+ Cancel
+ deleteMutation.mutate()}>Delete
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/tickets/ticket-linked-issues.tsx b/apps/web/src/components/admin/tickets/ticket-linked-issues.tsx
new file mode 100644
index 000000000..30cd37e1a
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/ticket-linked-issues.tsx
@@ -0,0 +1,84 @@
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import type { TicketId } from '@quackback/ids'
+import { ticketQueries } from '@/lib/client/queries/tickets'
+import { manualSyncTicketFn } from '@/lib/server/functions/tickets'
+import { Button } from '@/components/ui/button'
+import { ExternalLink, RefreshCw, GitBranch } from 'lucide-react'
+import { toast } from 'sonner'
+
+interface TicketLinkedIssuesProps {
+ ticketId: TicketId
+}
+
+export function TicketLinkedIssues({ ticketId }: TicketLinkedIssuesProps) {
+ const queryClient = useQueryClient()
+ const { data: links, isLoading } = useQuery(ticketQueries.externalLinks(ticketId))
+
+ const syncMutation = useMutation({
+ mutationFn: (params: { integrationId: string; direction: 'push' | 'pull' }) =>
+ manualSyncTicketFn({
+ data: { ticketId, integrationId: params.integrationId, direction: params.direction },
+ }),
+ onSuccess: (result: { success: boolean; error?: string }) => {
+ if (result.success) {
+ toast.success('Sync completed')
+ queryClient.invalidateQueries({ queryKey: ['tickets', 'externalLinks', ticketId] })
+ } else {
+ toast.error(result.error ?? 'Sync failed')
+ }
+ },
+ onError: () => toast.error('Sync failed'),
+ })
+
+ if (isLoading || !links?.length) return null
+
+ return (
+
+
+ Linked Issues
+
+
+ {links.map((link) => (
+
+
+
+ {link.externalDisplayId}
+
+
+
+ {link.syncDirection === 'outbound'
+ ? '→'
+ : link.syncDirection === 'inbound'
+ ? '←'
+ : '↔'}
+
+
+ link.integrationId &&
+ syncMutation.mutate({
+ integrationId: link.integrationId,
+ direction: 'push',
+ })
+ }
+ title="Sync to GitHub"
+ >
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/tickets/ticket-participants-list.tsx b/apps/web/src/components/admin/tickets/ticket-participants-list.tsx
new file mode 100644
index 000000000..63ea2a6be
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/ticket-participants-list.tsx
@@ -0,0 +1,163 @@
+/**
+ * Right-panel "Participants" tab. Lists current participants and allows adding
+ * a new principal-or-contact participant with a role (watcher / collaborator /
+ * cc). Add row toggles between principal and contact pickers.
+ */
+import { useState } from 'react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import type { TicketId, PrincipalId, ContactId, TicketParticipantId } from '@quackback/ids'
+import { Button } from '@/components/ui/button'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { XMarkIcon } from '@heroicons/react/24/outline'
+import { PrincipalPicker } from '@/components/admin/shared/principal-picker'
+import { ContactPicker } from '@/components/admin/shared/contact-picker'
+import { addParticipantFn, removeParticipantFn } from '@/lib/server/functions/tickets'
+import { ticketQueries } from '@/lib/client/queries/tickets'
+import { toast } from 'sonner'
+
+const ROLES = ['watcher', 'collaborator', 'cc'] as const
+type ParticipantRole = (typeof ROLES)[number]
+
+export interface ParticipantRow {
+ id: TicketParticipantId
+ ticketId: TicketId
+ principalId: PrincipalId | null
+ contactId: ContactId | null
+ role: ParticipantRole | string
+}
+
+export interface TicketParticipantsListProps {
+ ticketId: TicketId
+ participants: ParticipantRow[]
+ principalNames?: Record
+ contactNames?: Record
+}
+
+export function TicketParticipantsList({
+ ticketId,
+ participants,
+ principalNames,
+ contactNames,
+}: TicketParticipantsListProps) {
+ const qc = useQueryClient()
+ const [kind, setKind] = useState<'principal' | 'contact'>('principal')
+ const [role, setRole] = useState('watcher')
+ const [principalId, setPrincipalId] = useState(null)
+ const [contactId, setContactId] = useState(null)
+
+ const invalidate = () =>
+ qc.invalidateQueries({ queryKey: ticketQueries.participants(ticketId).queryKey })
+
+ const addMutation = useMutation({
+ mutationFn: () =>
+ addParticipantFn({
+ data: {
+ ticketId,
+ role,
+ principalId: kind === 'principal' ? principalId : null,
+ contactId: kind === 'contact' ? contactId : null,
+ },
+ }),
+ onSuccess: () => {
+ setPrincipalId(null)
+ setContactId(null)
+ invalidate()
+ toast.success('Participant added')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const removeMutation = useMutation({
+ mutationFn: (participantId: TicketParticipantId) =>
+ removeParticipantFn({ data: { ticketId, participantId } }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Participant removed')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const canSubmit =
+ (kind === 'principal' && principalId !== null) || (kind === 'contact' && contactId !== null)
+
+ return (
+
+ {participants.length === 0 ? (
+
No participants.
+ ) : (
+
+ {participants.map((p) => {
+ const label = p.principalId
+ ? (principalNames?.[p.principalId] ?? p.principalId)
+ : p.contactId
+ ? (contactNames?.[p.contactId] ?? p.contactId)
+ : '—'
+ return (
+
+
+ {label}
+ ({p.role})
+
+ removeMutation.mutate(p.id)}
+ disabled={removeMutation.isPending}
+ aria-label="Remove participant"
+ >
+
+
+
+ )
+ })}
+
+ )}
+
+
+
+ setKind(v as 'principal' | 'contact')}>
+
+
+
+
+ User
+ Contact
+
+
+ setRole(v as ParticipantRole)}>
+
+
+
+
+ {ROLES.map((r) => (
+
+ {r}
+
+ ))}
+
+
+
+ {kind === 'principal' ? (
+
+ ) : (
+
+ )}
+
addMutation.mutate()}
+ >
+ Add participant
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/tickets/ticket-priority-chip.tsx b/apps/web/src/components/admin/tickets/ticket-priority-chip.tsx
new file mode 100644
index 000000000..b331378b3
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/ticket-priority-chip.tsx
@@ -0,0 +1,32 @@
+/**
+ * Color-coded chip for ticket priority.
+ */
+import { cn } from '@/lib/shared/utils'
+
+export type TicketPriority = 'low' | 'normal' | 'high' | 'urgent'
+
+export interface TicketPriorityChipProps {
+ priority: TicketPriority
+ className?: string
+}
+
+const priorityStyles: Record = {
+ low: 'bg-muted text-muted-foreground',
+ normal: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
+ high: 'bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-200',
+ urgent: 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-200',
+}
+
+export function TicketPriorityChip({ priority, className }: TicketPriorityChipProps) {
+ return (
+
+ {priority}
+
+ )
+}
diff --git a/apps/web/src/components/admin/tickets/ticket-properties-panel.tsx b/apps/web/src/components/admin/tickets/ticket-properties-panel.tsx
new file mode 100644
index 000000000..057198530
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/ticket-properties-panel.tsx
@@ -0,0 +1,305 @@
+/**
+ * Right-panel "Properties" tab for a ticket. Inline editors for assignee,
+ * status, priority, visibility, inbox, organization, requester contact, and
+ * subject. Every mutation passes `expectedUpdatedAt` from the cached ticket
+ * for optimistic-concurrency.
+ */
+import { useState } from 'react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import type {
+ TicketId,
+ PrincipalId,
+ TicketStatusId,
+ InboxId,
+ OrganizationId,
+ ContactId,
+ TeamId,
+} from '@quackback/ids'
+import { Input } from '@/components/ui/input'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Button } from '@/components/ui/button'
+import { PrincipalPicker } from '@/components/admin/shared/principal-picker'
+import { StatusPicker } from '@/components/admin/shared/status-picker'
+import { InboxPicker } from '@/components/admin/shared/inbox-picker'
+import { OrgPicker } from '@/components/admin/shared/org-picker'
+import { ContactPicker } from '@/components/admin/shared/contact-picker'
+import { TeamPicker } from '@/components/admin/shared/team-picker'
+import {
+ assignTicketFn,
+ transitionTicketStatusFn,
+ updateTicketFn,
+} from '@/lib/server/functions/tickets'
+import { ticketQueries } from '@/lib/client/queries/tickets'
+import { toast } from 'sonner'
+
+export interface TicketPropertiesPanelProps {
+ ticket: {
+ id: TicketId
+ subject: string
+ statusId: TicketStatusId | null
+ priority: string
+ visibilityScope: string
+ primaryTeamId: TeamId | null
+ inboxId: InboxId | null
+ organizationId: OrganizationId | null
+ requesterContactId: ContactId | null
+ assigneePrincipalId: PrincipalId | null
+ updatedAt: Date | string
+ }
+}
+
+const PRIORITIES = ['low', 'normal', 'high', 'urgent'] as const
+const VISIBILITY = ['team', 'org', 'shared', 'private'] as const
+const VISIBILITY_LABELS: Record<(typeof VISIBILITY)[number], string> = {
+ team: 'Team',
+ org: 'Organization',
+ shared: 'Shared',
+ private: 'Private',
+}
+
+export function TicketPropertiesPanel({ ticket }: TicketPropertiesPanelProps) {
+ const qc = useQueryClient()
+ const [editingSubject, setEditingSubject] = useState(false)
+ const [subjectDraft, setSubjectDraft] = useState(ticket.subject)
+
+ const expectedUpdatedAt = () => {
+ const latest = qc.getQueryData<{ updatedAt: Date | string }>(
+ ticketQueries.detail(ticket.id).queryKey
+ )
+ return new Date(latest?.updatedAt ?? ticket.updatedAt).toISOString()
+ }
+
+ const invalidate = () => {
+ qc.invalidateQueries({ queryKey: ticketQueries.detail(ticket.id).queryKey })
+ qc.invalidateQueries({ queryKey: ['tickets', 'list'] })
+ }
+
+ const onErr = (e: Error) => {
+ if (/conflict|stale/i.test(e.message)) {
+ toast.error('Ticket changed — refresh', {
+ action: { label: 'Refresh', onClick: invalidate },
+ })
+ } else {
+ toast.error(e.message)
+ }
+ }
+
+ const assignMutation = useMutation({
+ mutationFn: (assigneePrincipalId: PrincipalId | null) =>
+ assignTicketFn({
+ data: { ticketId: ticket.id, expectedUpdatedAt: expectedUpdatedAt(), assigneePrincipalId },
+ }),
+ onSuccess: (updated) => {
+ qc.setQueryData(ticketQueries.detail(ticket.id).queryKey, updated)
+ invalidate()
+ toast.success('Assignee updated')
+ },
+ onError: onErr,
+ })
+
+ const statusMutation = useMutation({
+ mutationFn: (statusId: TicketStatusId) =>
+ transitionTicketStatusFn({
+ data: { ticketId: ticket.id, expectedUpdatedAt: expectedUpdatedAt(), statusId },
+ }),
+ onSuccess: (updated) => {
+ qc.setQueryData(ticketQueries.detail(ticket.id).queryKey, updated)
+ invalidate()
+ toast.success('Status updated')
+ },
+ onError: onErr,
+ })
+
+ const updateMutation = useMutation({
+ mutationFn: (patch: Parameters[0]['data']) =>
+ updateTicketFn({ data: { ...patch, expectedUpdatedAt: expectedUpdatedAt() } }),
+ onSuccess: (updated) => {
+ qc.setQueryData(ticketQueries.detail(ticket.id).queryKey, updated)
+ invalidate()
+ toast.success('Ticket updated')
+ },
+ onError: onErr,
+ })
+
+ return (
+
+
+
+
+ assignMutation.mutate(id)}
+ allowUnassigned
+ />
+
+
+
+ id && statusMutation.mutate(id)}
+ />
+
+
+
+
+ updateMutation.mutate({
+ ticketId: ticket.id,
+ expectedUpdatedAt: expectedUpdatedAt(),
+ priority: v as (typeof PRIORITIES)[number],
+ })
+ }
+ >
+
+
+
+
+ {PRIORITIES.map((p) => (
+
+ {p}
+
+ ))}
+
+
+
+
+
+
+ updateMutation.mutate({
+ ticketId: ticket.id,
+ expectedUpdatedAt: expectedUpdatedAt(),
+ visibilityScope: v as (typeof VISIBILITY)[number],
+ })
+ }
+ >
+
+
+
+
+ {VISIBILITY.map((v) => (
+
+ {VISIBILITY_LABELS[v]}
+
+ ))}
+
+
+
+
+
+
+ updateMutation.mutate({
+ ticketId: ticket.id,
+ expectedUpdatedAt: expectedUpdatedAt(),
+ inboxId: id ?? null,
+ })
+ }
+ allowClear
+ />
+
+
+
+
+ updateMutation.mutate({
+ ticketId: ticket.id,
+ expectedUpdatedAt: expectedUpdatedAt(),
+ primaryTeamId: id ?? null,
+ })
+ }
+ allowClear
+ />
+
+
+
+
+ updateMutation.mutate({
+ ticketId: ticket.id,
+ expectedUpdatedAt: expectedUpdatedAt(),
+ organizationId: id ?? null,
+ })
+ }
+ allowClear
+ />
+
+
+
+
+ updateMutation.mutate({
+ ticketId: ticket.id,
+ expectedUpdatedAt: expectedUpdatedAt(),
+ requesterContactId: id ?? null,
+ })
+ }
+ allowClear
+ />
+
+
+ )
+}
+
+function Section({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+
+ {label}
+
+ {children}
+
+ )
+}
diff --git a/apps/web/src/components/admin/tickets/ticket-queue-sidebar.tsx b/apps/web/src/components/admin/tickets/ticket-queue-sidebar.tsx
new file mode 100644
index 000000000..86a5b7d60
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/ticket-queue-sidebar.tsx
@@ -0,0 +1,115 @@
+/**
+ * Left-rail sidebar for the tickets queue. Renders saved-view buttons and an
+ * expandable "By inbox" group of inboxes the actor is a member of.
+ */
+import { useState } from 'react'
+import { Link } from '@tanstack/react-router'
+import {
+ InboxIcon,
+ UserIcon,
+ UsersIcon,
+ ShareIcon,
+ QuestionMarkCircleIcon,
+ GlobeAltIcon,
+ ChevronDownIcon,
+ ChevronRightIcon,
+} from '@heroicons/react/24/outline'
+import { useMyInboxes } from '@/lib/client/hooks/use-inboxes-queries'
+import { cn } from '@/lib/shared/utils'
+import type { TicketsSearch } from '@/routes/admin/tickets'
+
+interface SavedView {
+ scope: TicketsSearch['scope']
+ label: string
+ icon: typeof InboxIcon
+}
+
+const SAVED_VIEWS: SavedView[] = [
+ { scope: 'my_assigned', label: 'Assigned to me', icon: UserIcon },
+ { scope: 'my_team', label: 'My team', icon: UsersIcon },
+ { scope: 'shared_with_me', label: 'Shared with me', icon: ShareIcon },
+ { scope: 'unassigned', label: 'Unassigned', icon: QuestionMarkCircleIcon },
+ { scope: 'my_inbox', label: 'My inboxes', icon: InboxIcon },
+ { scope: 'all', label: 'All', icon: GlobeAltIcon },
+]
+
+export interface TicketQueueSidebarProps {
+ activeScope: TicketsSearch['scope']
+ activeInboxId?: string
+}
+
+export function TicketQueueSidebar({ activeScope, activeInboxId }: TicketQueueSidebarProps) {
+ const [inboxesOpen, setInboxesOpen] = useState(true)
+ const myInboxesQuery = useMyInboxes()
+
+ return (
+
+
+ {SAVED_VIEWS.map((v) => {
+ const Icon = v.icon
+ const isActive = activeScope === v.scope && !activeInboxId
+ return (
+
+
+ {v.label}
+
+ )
+ })}
+
+
+
+
setInboxesOpen((v) => !v)}
+ className="flex w-full items-center gap-1 rounded px-2 py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground"
+ >
+ {inboxesOpen ? (
+
+ ) : (
+
+ )}
+ By inbox
+
+ {inboxesOpen && (
+
+ {myInboxesQuery.isLoading && (
+
Loading…
+ )}
+ {myInboxesQuery.data?.length === 0 && (
+
No inboxes
+ )}
+ {myInboxesQuery.data?.map((inbox) => {
+ const isActive = activeScope === 'inbox' && activeInboxId === inbox.id
+ return (
+
+
+
{inbox.name}
+
+ )
+ })}
+
+ )}
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/tickets/ticket-queue-table.tsx b/apps/web/src/components/admin/tickets/ticket-queue-table.tsx
new file mode 100644
index 000000000..0a95f8a8f
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/ticket-queue-table.tsx
@@ -0,0 +1,317 @@
+/**
+ * Tickets queue table — checkbox bulk-select, status pill, priority chip,
+ * channel icon, last-activity timestamp. Bulk-action toolbar is gated by the
+ * `TICKET_BULK_OPERATE` permission.
+ */
+import { useMemo, useState } from 'react'
+import { Link } from '@tanstack/react-router'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import type { TicketId, InboxId, PrincipalId, TicketStatusId } from '@quackback/ids'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Button } from '@/components/ui/button'
+import { TimeAgo } from '@/components/ui/time-ago'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { TicketStatusPill, type StatusCategory } from './ticket-status-pill'
+import { TicketPriorityChip, type TicketPriority } from './ticket-priority-chip'
+import { TicketChannelIcon, type TicketChannel } from './ticket-channel-icon'
+import { PermissionGate } from '@/components/admin/shared/permission-gate'
+import { PrincipalPicker } from '@/components/admin/shared/principal-picker'
+import { StatusPicker } from '@/components/admin/shared/status-picker'
+import { InboxPicker } from '@/components/admin/shared/inbox-picker'
+import { PERMISSIONS } from '@/lib/server/domains/authz'
+import {
+ bulkAssignTicketsFn,
+ bulkTransitionTicketsFn,
+ bulkChangeInboxFn,
+} from '@/lib/server/functions/tickets'
+import { toast } from 'sonner'
+
+interface TicketRow {
+ id: string
+ subject: string
+ statusId: string
+ priority: string
+ channel: string
+ lastActivityAt: Date | string
+ assigneePrincipalId: string | null
+}
+
+interface StatusRow {
+ id: string
+ name: string
+ category: StatusCategory
+}
+
+export interface TicketQueueTableProps {
+ rows: TicketRow[]
+ statuses: StatusRow[]
+ invalidateKey: readonly unknown[]
+}
+
+const BULK_CONFIRM_THRESHOLD = 50
+
+export function TicketQueueTable({ rows, statuses, invalidateKey }: TicketQueueTableProps) {
+ const queryClient = useQueryClient()
+ const statusById = useMemo(() => new Map(statuses.map((s) => [s.id, s])), [statuses])
+ const [selected, setSelected] = useState>(new Set())
+
+ const allSelected = rows.length > 0 && selected.size === rows.length
+ const someSelected = selected.size > 0 && selected.size < rows.length
+
+ const toggleAll = () => {
+ if (allSelected) setSelected(new Set())
+ else setSelected(new Set(rows.map((r) => r.id)))
+ }
+ const toggleOne = (id: string) => {
+ setSelected((prev) => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+
+ const invalidate = () => queryClient.invalidateQueries({ queryKey: invalidateKey })
+
+ const assignMutation = useMutation({
+ mutationFn: (assigneePrincipalId: PrincipalId) =>
+ bulkAssignTicketsFn({
+ data: {
+ ticketIds: Array.from(selected) as TicketId[],
+ assigneePrincipalId,
+ },
+ }),
+ onSuccess: (res) => {
+ const n = res.succeeded.length
+ toast.success(`Assigned ${n} ticket${n === 1 ? '' : 's'}`)
+ setSelected(new Set())
+ invalidate()
+ },
+ onError: (e: Error) => toast.error(e.message ?? 'Bulk assign failed'),
+ })
+
+ const transitionMutation = useMutation({
+ mutationFn: (statusId: TicketStatusId) =>
+ bulkTransitionTicketsFn({
+ data: {
+ ticketIds: Array.from(selected) as TicketId[],
+ statusId,
+ },
+ }),
+ onSuccess: (res) => {
+ const n = res.succeeded.length
+ toast.success(`Transitioned ${n} ticket${n === 1 ? '' : 's'}`)
+ setSelected(new Set())
+ invalidate()
+ },
+ onError: (e: Error) => toast.error(e.message ?? 'Bulk transition failed'),
+ })
+
+ const changeInboxMutation = useMutation({
+ mutationFn: (inboxId: InboxId | null) =>
+ bulkChangeInboxFn({
+ data: {
+ ticketIds: Array.from(selected) as TicketId[],
+ inboxId,
+ },
+ }),
+ onSuccess: (res) => {
+ const n = res.succeeded.length
+ toast.success(`Moved ${n} ticket${n === 1 ? '' : 's'}`)
+ setSelected(new Set())
+ invalidate()
+ },
+ onError: (e: Error) => toast.error(e.message ?? 'Bulk change inbox failed'),
+ })
+
+ // ---------------------------------------------------------- confirm modal
+ const [pending, setPending] = useState void)>(null)
+ const guard = (action: () => void) => {
+ if (selected.size > BULK_CONFIRM_THRESHOLD) setPending(() => action)
+ else action()
+ }
+
+ if (rows.length === 0) {
+ return (
+
+ No tickets in this view.
+
+ )
+ }
+
+ return (
+
+
+ {selected.size > 0 && (
+
+
{selected.size} selected
+
+
+
+
+ Assign…
+
+
+
+
+ id && guard(() => assignMutation.mutate(id as PrincipalId))
+ }
+ placeholder="Pick assignee…"
+ />
+
+
+
+
+
+
+ Transition…
+
+
+
+
+ id && guard(() => transitionMutation.mutate(id as TicketStatusId))
+ }
+ placeholder="Pick status…"
+ />
+
+
+
+
+
+
+ Change inbox…
+
+
+
+
+ guard(() => changeInboxMutation.mutate((id as InboxId | null) ?? null))
+ }
+ placeholder="Pick inbox…"
+ allowClear
+ />
+
+
+
+
setSelected(new Set())}
+ >
+ Clear
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ Subject
+ Status
+ Priority
+ Channel
+ Last activity
+
+
+
+ {rows.map((t) => {
+ const status = statusById.get(t.statusId)
+ const isSel = selected.has(t.id)
+ return (
+
+ e.stopPropagation()}>
+ toggleOne(t.id)}
+ aria-label={`Select ${t.subject}`}
+ />
+
+
+
+ {t.subject}
+
+
+
+ {status ? (
+
+ ) : (
+ —
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ )
+ })}
+
+
+
+
+
!v && setPending(null)}>
+
+
+ Confirm bulk action
+
+ You are about to apply this action to {selected.size} tickets. Continue?
+
+
+
+ setPending(null)}>Cancel
+ {
+ pending?.()
+ setPending(null)
+ }}
+ >
+ Apply
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/tickets/ticket-shares-panel.tsx b/apps/web/src/components/admin/tickets/ticket-shares-panel.tsx
new file mode 100644
index 000000000..11019684b
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/ticket-shares-panel.tsx
@@ -0,0 +1,127 @@
+/**
+ * Right-panel "Shares" tab. Lists current cross-team shares of the ticket and
+ * lets a user with `ticket.share_cross_team` add or revoke shares.
+ */
+import { useState } from 'react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import type { TicketId, TeamId, TicketShareId } from '@quackback/ids'
+import { Button } from '@/components/ui/button'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { XMarkIcon } from '@heroicons/react/24/outline'
+import { TeamPicker } from '@/components/admin/shared/team-picker'
+import { shareTicketFn, revokeShareFn } from '@/lib/server/functions/tickets'
+import { ticketQueries } from '@/lib/client/queries/tickets'
+import { toast } from 'sonner'
+
+const ACCESS_LEVELS = ['read', 'comment', 'full'] as const
+type AccessLevel = (typeof ACCESS_LEVELS)[number]
+
+export interface ShareRow {
+ id: TicketShareId
+ ticketId: TicketId
+ teamId: TeamId
+ accessLevel: AccessLevel | string
+}
+
+export interface TicketSharesPanelProps {
+ ticketId: TicketId
+ shares: ShareRow[]
+ teamNames?: Record
+ canShare: boolean
+}
+
+export function TicketSharesPanel({
+ ticketId,
+ shares,
+ teamNames,
+ canShare,
+}: TicketSharesPanelProps) {
+ const qc = useQueryClient()
+ const [teamId, setTeamId] = useState(null)
+ const [accessLevel, setAccessLevel] = useState('read')
+
+ const invalidate = () =>
+ qc.invalidateQueries({ queryKey: ticketQueries.shares(ticketId).queryKey })
+
+ const addMutation = useMutation({
+ mutationFn: () => shareTicketFn({ data: { ticketId, teamId: teamId!, accessLevel } }),
+ onSuccess: () => {
+ setTeamId(null)
+ invalidate()
+ toast.success('Ticket shared')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const revokeMutation = useMutation({
+ mutationFn: (shareId: TicketShareId) => revokeShareFn({ data: { shareId } }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Share revoked')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ return (
+
+ {shares.length === 0 ? (
+
Not shared with any teams.
+ ) : (
+
+ {shares.map((s) => (
+
+
+ {teamNames?.[s.teamId] ?? s.teamId}
+ ({s.accessLevel})
+
+ {canShare && (
+ revokeMutation.mutate(s.id)}
+ disabled={revokeMutation.isPending}
+ >
+
+
+ )}
+
+ ))}
+
+ )}
+
+ {canShare && (
+
+
+ setAccessLevel(v as AccessLevel)}>
+
+
+
+
+ {ACCESS_LEVELS.map((l) => (
+
+ {l}
+
+ ))}
+
+
+ addMutation.mutate()}
+ >
+ Share
+
+
+ )}
+
+ )
+}
diff --git a/apps/web/src/components/admin/tickets/ticket-sla-panel.tsx b/apps/web/src/components/admin/tickets/ticket-sla-panel.tsx
new file mode 100644
index 000000000..931555903
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/ticket-sla-panel.tsx
@@ -0,0 +1,49 @@
+/**
+ * Right-panel "SLA" tab. Read-only summary of the ticket's active SLA clocks,
+ * each rendered with ` `. Policy management lives in admin
+ * settings; we only display state here.
+ */
+import { useSuspenseQuery } from '@tanstack/react-query'
+import type { TicketId } from '@quackback/ids'
+import { ticketQueries } from '@/lib/client/queries/tickets'
+import { SlaClockChip, type SlaClockKind, type SlaClockState } from './sla-clock-chip'
+
+export interface TicketSlaPanelProps {
+ ticketId: TicketId
+}
+
+const KIND_LABELS: Record = {
+ first_response: 'First response',
+ next_response: 'Next response',
+ resolution: 'Resolution',
+}
+
+export function TicketSlaPanel({ ticketId }: TicketSlaPanelProps) {
+ const { data } = useSuspenseQuery(ticketQueries.slaClocks(ticketId))
+
+ if (data.length === 0) {
+ return No SLA clocks on this ticket.
+ }
+
+ return (
+
+ {data.map((clock) => (
+
+ {KIND_LABELS[clock.kind] ?? clock.kind}
+
+
+ ))}
+
+ )
+}
diff --git a/apps/web/src/components/admin/tickets/ticket-status-pill.tsx b/apps/web/src/components/admin/tickets/ticket-status-pill.tsx
new file mode 100644
index 000000000..c5873b4eb
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/ticket-status-pill.tsx
@@ -0,0 +1,35 @@
+/**
+ * Color-coded status pill for tickets. Uses statusCategory to pick the colour
+ * ramp; the displayed label comes from the status's `name` field.
+ */
+import { cn } from '@/lib/shared/utils'
+
+export type StatusCategory = 'open' | 'pending' | 'on_hold' | 'solved' | 'closed'
+
+export interface TicketStatusPillProps {
+ name: string
+ category: StatusCategory
+ className?: string
+}
+
+const categoryStyles: Record = {
+ open: 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-200',
+ pending: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200',
+ on_hold: 'bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-200',
+ solved: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200',
+ closed: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
+}
+
+export function TicketStatusPill({ name, category, className }: TicketStatusPillProps) {
+ return (
+
+ {name}
+
+ )
+}
diff --git a/apps/web/src/components/admin/tickets/ticket-subscription-menu.tsx b/apps/web/src/components/admin/tickets/ticket-subscription-menu.tsx
new file mode 100644
index 000000000..9341f9295
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/ticket-subscription-menu.tsx
@@ -0,0 +1,210 @@
+/**
+ * Per-ticket subscription dropdown rendered in the ticket detail header.
+ * Lets the current principal subscribe / unsubscribe / toggle the 6 event
+ * preferences / mute (1h, 1d, 1w, until unmute).
+ */
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { BellIcon, BellSlashIcon, BellAlertIcon } from '@heroicons/react/24/outline'
+import { BellIcon as BellIconSolid } from '@heroicons/react/24/solid'
+import { toast } from 'sonner'
+import type { TicketId } from '@quackback/ids'
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import {
+ getMyTicketSubscriptionFn,
+ subscribeToTicketFn,
+ unsubscribeFromTicketFn,
+ updateTicketSubscriptionPrefsFn,
+ muteTicketFn,
+ unmuteTicketFn,
+} from '@/lib/server/functions/notifications'
+
+const PREF_FLAGS = [
+ { key: 'notifyThreads', label: 'New replies' },
+ { key: 'notifyStatus', label: 'Status changes' },
+ { key: 'notifyAssignment', label: 'Assignment changes' },
+ { key: 'notifyParticipants', label: 'Participant changes' },
+ { key: 'notifyShares', label: 'Share grants' },
+ { key: 'notifySla', label: 'SLA warnings & breaches' },
+] as const
+
+type PrefKey = (typeof PREF_FLAGS)[number]['key']
+
+const MUTE_DURATIONS: Array<{ label: string; ms: number | null }> = [
+ { label: 'Mute for 1 hour', ms: 60 * 60 * 1000 },
+ { label: 'Mute for 1 day', ms: 24 * 60 * 60 * 1000 },
+ { label: 'Mute for 1 week', ms: 7 * 24 * 60 * 60 * 1000 },
+ { label: 'Mute until I unmute', ms: null },
+]
+
+export interface TicketSubscriptionMenuProps {
+ ticketId: TicketId
+}
+
+export function TicketSubscriptionMenu({ ticketId }: TicketSubscriptionMenuProps) {
+ const qc = useQueryClient()
+ const queryKey = ['tickets', 'my-subscription', ticketId] as const
+
+ const { data: sub, isLoading } = useQuery({
+ queryKey,
+ queryFn: () => getMyTicketSubscriptionFn({ data: { ticketId } }),
+ })
+
+ const invalidate = () => qc.invalidateQueries({ queryKey })
+
+ const subscribeMutation = useMutation({
+ mutationFn: () => subscribeToTicketFn({ data: { ticketId } }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Subscribed')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const unsubscribeMutation = useMutation({
+ mutationFn: () => unsubscribeFromTicketFn({ data: { ticketId } }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Unsubscribed')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const updatePrefsMutation = useMutation({
+ mutationFn: (patch: Partial>) =>
+ updateTicketSubscriptionPrefsFn({ data: { ticketId, patch } }),
+ onSuccess: () => invalidate(),
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const muteMutation = useMutation({
+ mutationFn: (untilIso?: string) =>
+ muteTicketFn({ data: untilIso ? { ticketId, untilIso } : { ticketId } }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Muted')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const unmuteMutation = useMutation({
+ mutationFn: () => unmuteTicketFn({ data: { ticketId } }),
+ onSuccess: () => {
+ invalidate()
+ toast.success('Unmuted')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const isSubscribed = !!sub
+ const mutedUntil = sub?.mutedUntil ? new Date(sub.mutedUntil) : null
+ const isMuted = !!mutedUntil && mutedUntil.getTime() > Date.now()
+
+ let Icon = BellIcon
+ let iconClass = 'text-muted-foreground'
+ let ariaLabel = 'Subscribe to ticket'
+ if (isMuted) {
+ Icon = BellSlashIcon
+ iconClass = 'text-amber-500'
+ ariaLabel = 'Subscription menu (muted)'
+ } else if (isSubscribed) {
+ Icon = BellIconSolid
+ iconClass = 'text-primary'
+ ariaLabel = 'Subscription menu (subscribed)'
+ }
+
+ const busy =
+ isLoading ||
+ subscribeMutation.isPending ||
+ unsubscribeMutation.isPending ||
+ updatePrefsMutation.isPending ||
+ muteMutation.isPending ||
+ unmuteMutation.isPending
+
+ return (
+
+
+
+
+
+
+
+ Notifications
+
+ {!isSubscribed ? (
+ subscribeMutation.mutate()}>
+
+ Subscribe
+
+ ) : (
+ <>
+ unsubscribeMutation.mutate()}>
+
+ Unsubscribe
+
+
+
+
+ Customize prefs
+
+
+ {PREF_FLAGS.map((f) => (
+
+ updatePrefsMutation.mutate({ [f.key]: !!checked })
+ }
+ onSelect={(e) => e.preventDefault()}
+ >
+ {f.label}
+
+ ))}
+
+
+
+
+
+ Mute
+
+
+ {MUTE_DURATIONS.map((d) => (
+
+ muteMutation.mutate(
+ d.ms === null ? undefined : new Date(Date.now() + d.ms).toISOString()
+ )
+ }
+ >
+ {d.label}
+
+ ))}
+
+
+ {isMuted && (
+ <>
+
+ unmuteMutation.mutate()}>
+
+ Unmute
+
+ >
+ )}
+ >
+ )}
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/tickets/ticket-thread-composer.tsx b/apps/web/src/components/admin/tickets/ticket-thread-composer.tsx
new file mode 100644
index 000000000..d8bf88e3c
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/ticket-thread-composer.tsx
@@ -0,0 +1,250 @@
+/**
+ * Composer for posting a new thread on a ticket. Audience tabs (Public /
+ * Internal / Shared with team) are gated by the actor's permissions for this
+ * ticket. The shared-team tab requires picking a team via ` `.
+ */
+import { useState, useMemo, useRef } from 'react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import type { JSONContent } from '@tiptap/react'
+import type { TicketId, TeamId, TicketThreadId } from '@quackback/ids'
+import { Button } from '@/components/ui/button'
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { RichTextEditor } from '@/components/ui/rich-text-editor'
+import { TeamPicker } from '@/components/admin/shared/team-picker'
+import { addThreadFn } from '@/lib/server/functions/tickets'
+import { ticketQueries } from '@/lib/client/queries/tickets'
+import { toast } from 'sonner'
+import { cn } from '@/lib/shared/utils'
+import { X, Upload } from 'lucide-react'
+import { useImageUpload } from '@/lib/client/hooks/use-image-upload'
+
+export type ComposerAudience = 'public' | 'internal' | 'shared_team'
+
+export interface TicketThreadComposerProps {
+ ticketId: TicketId
+ /** Permission flags resolved by the parent (uses canReplyPublic etc). */
+ canPublic: boolean
+ canInternal: boolean
+ canShared: boolean
+ onPosted?: () => void
+}
+
+function plainTextFromJson(json: JSONContent | null): string {
+ if (!json) return ''
+ let out = ''
+ const walk = (node: JSONContent) => {
+ if (node.type === 'text' && typeof node.text === 'string') out += node.text
+ if (node.content) node.content.forEach(walk)
+ if (node.type === 'paragraph' || node.type === 'heading') out += '\n'
+ }
+ walk(json)
+ return out.trim()
+}
+
+export function TicketThreadComposer({
+ ticketId,
+ canPublic,
+ canInternal,
+ canShared,
+ onPosted,
+}: TicketThreadComposerProps) {
+ const qc = useQueryClient()
+ const { upload: uploadImage } = useImageUpload({ prefix: 'uploads' })
+ const fileInputRef = useRef(null)
+ const allowedTabs = useMemo(() => {
+ const tabs: ComposerAudience[] = []
+ if (canPublic) tabs.push('public')
+ if (canInternal) tabs.push('internal')
+ if (canShared) tabs.push('shared_team')
+ return tabs
+ }, [canPublic, canInternal, canShared])
+
+ const [audience, setAudience] = useState(allowedTabs[0] ?? 'internal')
+ const [body, setBody] = useState(null)
+ const [bodyText, setBodyText] = useState('')
+ const [sharedTeamId, setSharedTeamId] = useState(null)
+ const [selectedFiles, setSelectedFiles] = useState([])
+
+ const postMutation = useMutation({
+ mutationFn: async () => {
+ const thread = await addThreadFn({
+ data: {
+ ticketId,
+ audience,
+ bodyJson: body as unknown as { type: 'doc'; content?: unknown[] } | null,
+ bodyText: bodyText || plainTextFromJson(body),
+ sharedWithTeamId: audience === 'shared_team' ? sharedTeamId : null,
+ },
+ })
+
+ // Upload files if any
+ if (selectedFiles.length > 0 && thread?.id) {
+ const threadId = thread.id as TicketThreadId
+ for (const file of selectedFiles) {
+ try {
+ const formData = new FormData()
+ formData.append('file', file)
+ const res = await fetch(`/api/v1/tickets/${ticketId}/threads/${threadId}/attachments`, {
+ method: 'POST',
+ body: formData,
+ })
+ if (!res.ok) {
+ const error = await res.text()
+ throw new Error(`Upload failed: ${error}`)
+ }
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Upload failed'
+ toast.error(`Failed to upload ${file.name}: ${msg}`)
+ }
+ }
+ // Invalidate attachments query for this thread
+ qc.invalidateQueries({ queryKey: ticketQueries.attachments(ticketId, threadId).queryKey })
+ }
+
+ return thread
+ },
+ onSuccess: () => {
+ setBody(null)
+ setBodyText('')
+ setSelectedFiles([])
+ qc.invalidateQueries({ queryKey: ticketQueries.threads(ticketId).queryKey })
+ qc.invalidateQueries({ queryKey: ticketQueries.detail(ticketId).queryKey })
+ qc.invalidateQueries({ queryKey: ['tickets', 'list'] })
+ onPosted?.()
+ toast.success('Reply posted')
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ if (allowedTabs.length === 0) {
+ return (
+
+ You don't have permission to reply on this ticket.
+
+ )
+ }
+
+ const isEmpty = !bodyText.trim() && (!body || plainTextFromJson(body).length === 0)
+ const sharedNeedsTeam = audience === 'shared_team' && !sharedTeamId
+
+ return (
+
+
setAudience(v as ComposerAudience)}
+ className="mb-2"
+ >
+
+ {canPublic && Public reply }
+ {canInternal && Internal note }
+ {canShared && Share with team }
+
+
+
+ {audience === 'shared_team' && (
+
+
+
+ )}
+
+
+ {
+ setBody(json)
+ setBodyText(markdown)
+ }}
+ placeholder={
+ audience === 'public'
+ ? 'Reply to customer…'
+ : audience === 'internal'
+ ? 'Internal note (only agents can see this)…'
+ : 'Visible to your team and the picked team…'
+ }
+ minHeight="100px"
+ features={{
+ headings: false,
+ codeBlocks: true,
+ blockquotes: true,
+ dividers: false,
+ images: true,
+ taskLists: false,
+ tables: false,
+ embeds: false,
+ slashMenu: false,
+ }}
+ onImageUpload={uploadImage}
+ />
+
+
+ {/* File picker and list */}
+
{
+ if (e.target.files) {
+ setSelectedFiles(Array.from(e.target.files))
+ }
+ }}
+ />
+
+ {selectedFiles.length > 0 && (
+
+
+ Attachments ({selectedFiles.length})
+
+
+ {selectedFiles.map((file) => (
+
+ {file.name}
+
+ setSelectedFiles((prev) => prev.filter((f) => f.name !== file.name))
+ }
+ className="ml-2 hover:text-red-500"
+ >
+
+
+
+ ))}
+
+
+ )}
+
+
+ fileInputRef.current?.click()}
+ >
+
+ Attach files
+
+ postMutation.mutate()}
+ disabled={isEmpty || sharedNeedsTeam || postMutation.isPending}
+ >
+ {postMutation.isPending ? 'Posting…' : 'Post'}
+
+
+
+ )
+}
diff --git a/apps/web/src/components/admin/tickets/ticket-thread-feed.tsx b/apps/web/src/components/admin/tickets/ticket-thread-feed.tsx
new file mode 100644
index 000000000..c032fe675
--- /dev/null
+++ b/apps/web/src/components/admin/tickets/ticket-thread-feed.tsx
@@ -0,0 +1,284 @@
+/**
+ * Thread feed for the ticket-detail page. Renders threads in chronological
+ * order with audience-aware bubble styling: public = neutral card, internal =
+ * yellow tinted, shared_team = purple tinted with the team label.
+ */
+import { useCallback, useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import type { JSONContent } from '@tiptap/react'
+import type { TicketId, TeamId, PrincipalId, TicketThreadId } from '@quackback/ids'
+import { cn } from '@/lib/shared/utils'
+import { TimeAgo } from '@/components/ui/time-ago'
+import {
+ RichTextContent,
+ RichTextEditor,
+ isRichTextContent,
+} from '@/components/ui/rich-text-editor'
+import { Button } from '@/components/ui/button'
+import { Pencil } from 'lucide-react'
+import { TicketAttachments } from '@/components/tickets/ticket-attachments'
+import { ticketQueries } from '@/lib/client/queries/tickets'
+import { useImageUpload } from '@/lib/client/hooks/use-image-upload'
+
+export interface ThreadRow {
+ id: TicketThreadId
+ ticketId: TicketId
+ principalId: PrincipalId | null
+ audience: 'public' | 'internal' | 'shared_team'
+ bodyJson: unknown
+ bodyText: string
+ sharedWithTeamId: TeamId | null
+ createdAt: Date | string
+ editedAt: Date | string | null
+}
+
+export interface TicketThreadFeedProps {
+ threads: ThreadRow[]
+ /** Optional fallback ticketId for attachment lookups when thread rows omit it. */
+ fallbackTicketId?: TicketId
+ /** Optional map of teamId → teamName for nicer "Shared with X" labels. */
+ teamNames?: Record
+ /** Optional map of principalId → display name for author labels. */
+ principalNames?: Record
+ /** Optional initial-description block (rendered before first thread). */
+ description?: { text: string | null; json: unknown } | null
+ /** Callback to save an edited description. When provided, the description is editable. */
+ onDescriptionUpdate?: (json: JSONContent | null, text: string | null) => void
+ /** Whether a description update is currently saving. */
+ isDescriptionSaving?: boolean
+}
+
+const DESCRIPTION_EDITOR_FEATURES = {
+ headings: false,
+ codeBlocks: true,
+ blockquotes: true,
+ dividers: false,
+ images: true,
+ taskLists: false,
+ tables: false,
+ embeds: false,
+ slashMenu: false,
+} as const
+
+function plainTextFromJson(json: JSONContent | null): string {
+ if (!json) return ''
+ let out = ''
+ const walk = (node: JSONContent) => {
+ if (node.type === 'text' && typeof node.text === 'string') out += node.text
+ if (node.content) node.content.forEach(walk)
+ if (node.type === 'paragraph' || node.type === 'heading') out += '\n'
+ }
+ walk(json)
+ return out.trim()
+}
+
+function textToDoc(text: string): JSONContent {
+ return {
+ type: 'doc',
+ content: text.split('\n').map((line) => ({
+ type: 'paragraph',
+ content: line ? [{ type: 'text', text: line }] : undefined,
+ })),
+ }
+}
+
+function hasMeaningfulJsonContent(content: unknown): content is JSONContent {
+ if (!isRichTextContent(content)) return false
+ const visit = (node: JSONContent): boolean => {
+ if (node.type === 'text') return typeof node.text === 'string' && node.text.trim().length > 0
+ if (node.type === 'image' || node.type === 'quackbackEmbed' || node.type === 'horizontalRule') {
+ return true
+ }
+ return node.content?.some(visit) ?? false
+ }
+ return visit(content)
+}
+
+function hasDescriptionContent(
+ description: { text: string | null; json: unknown } | null | undefined
+) {
+ return Boolean(
+ description &&
+ ((description.text?.trim() ?? '').length > 0 || hasMeaningfulJsonContent(description.json))
+ )
+}
+
+const audienceStyles: Record = {
+ public: 'border-border/50 bg-background',
+ internal: 'border-amber-300/60 bg-amber-50/60 dark:border-amber-900/40 dark:bg-amber-950/20',
+ shared_team:
+ 'border-purple-300/60 bg-purple-50/60 dark:border-purple-900/40 dark:bg-purple-950/20',
+}
+
+const audienceLabels: Record = {
+ public: 'Public',
+ internal: 'Internal note',
+ shared_team: 'Shared with team',
+}
+
+export function TicketThreadFeed({
+ threads,
+ fallbackTicketId,
+ teamNames,
+ principalNames,
+ description,
+ onDescriptionUpdate,
+ isDescriptionSaving,
+}: TicketThreadFeedProps) {
+ const { upload: uploadImage } = useImageUpload({ prefix: 'uploads' })
+ const hasDesc = hasDescriptionContent(description)
+ const [editingDescription, setEditingDescription] = useState(false)
+ const [descDraft, setDescDraft] = useState(null)
+ const [descDraftText, setDescDraftText] = useState('')
+
+ const startEditingDescription = useCallback(() => {
+ const currentText = description?.text ?? ''
+ const currentJson = isRichTextContent(description?.json)
+ ? (description!.json as JSONContent)
+ : null
+ setDescDraft(currentJson ?? (currentText ? textToDoc(currentText) : null))
+ setDescDraftText(currentText)
+ setEditingDescription(true)
+ }, [description])
+
+ const cancelEditingDescription = useCallback(() => {
+ setEditingDescription(false)
+ setDescDraft(null)
+ setDescDraftText('')
+ }, [])
+
+ const saveDescription = useCallback(() => {
+ const nextText = descDraftText.trim() || plainTextFromJson(descDraft)
+ const nextJson = hasMeaningfulJsonContent(descDraft) ? descDraft : null
+ onDescriptionUpdate?.(nextJson, nextText || null)
+ setEditingDescription(false)
+ }, [descDraft, descDraftText, onDescriptionUpdate])
+
+ if (threads.length === 0 && !hasDesc) {
+ if (!onDescriptionUpdate) {
+ return No replies yet.
+ }
+ }
+ return (
+
+ {editingDescription ? (
+
+ Description
+
+ {
+ setDescDraft(json)
+ setDescDraftText(markdown)
+ }}
+ placeholder="Add a description..."
+ minHeight="100px"
+ features={DESCRIPTION_EDITOR_FEATURES}
+ onImageUpload={uploadImage}
+ />
+
+
+
+ Cancel
+
+
+ {isDescriptionSaving ? 'Saving...' : 'Save'}
+
+
+
+ ) : hasDesc ? (
+
+
+ Description
+ {onDescriptionUpdate && (
+
+
+ Edit
+
+ )}
+
+ {hasMeaningfulJsonContent(description!.json) ? (
+
+ ) : (
+ {description!.text}
+ )}
+
+ ) : onDescriptionUpdate ? (
+
+ Add a description...
+
+ ) : null}
+ {threads.map((th) => {
+ const teamLabel =
+ th.audience === 'shared_team' && th.sharedWithTeamId
+ ? (teamNames?.[th.sharedWithTeamId] ?? th.sharedWithTeamId)
+ : null
+ const author = th.principalId ? (principalNames?.[th.principalId] ?? 'Unknown') : 'System'
+ return (
+
+
+ {isRichTextContent(th.bodyJson) ? (
+
+ ) : (
+ {th.bodyText}
+ )}
+
+
+ )
+ })}
+
+ )
+}
+
+function ThreadAttachmentsLoader({
+ ticketId,
+ threadId,
+}: {
+ ticketId: TicketId
+ threadId: TicketThreadId
+}) {
+ const {
+ data: attachments,
+ isLoading,
+ isError,
+ } = useQuery(ticketQueries.attachments(ticketId, threadId))
+
+ if (isError || (!isLoading && (!attachments || attachments.length === 0))) {
+ return null
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/web/src/components/auth/auth-dialog.tsx b/apps/web/src/components/auth/auth-dialog.tsx
index b254fa324..f0371f4a5 100644
--- a/apps/web/src/components/auth/auth-dialog.tsx
+++ b/apps/web/src/components/auth/auth-dialog.tsx
@@ -78,6 +78,7 @@ export function AuthDialog({ authConfig, workspaceName }: AuthDialogProps) {
workspaceName={workspaceName}
callbackUrl={callbackUrl}
onModeSwitch={setMode}
+ onSuccess={onAuthSuccess}
onContextChange={setFormContext}
/>
diff --git a/apps/web/src/components/auth/portal-auth-form-inline.tsx b/apps/web/src/components/auth/portal-auth-form-inline.tsx
index fd243d405..508603278 100644
--- a/apps/web/src/components/auth/portal-auth-form-inline.tsx
+++ b/apps/web/src/components/auth/portal-auth-form-inline.tsx
@@ -66,6 +66,15 @@ interface PortalAuthFormInlineProps {
workspaceName?: string
callbackUrl?: string
onModeSwitch?: (mode: 'login' | 'signup') => void
+ /**
+ * Called immediately when authentication completes in this window.
+ *
+ * Same-window auth still posts to the BroadcastChannel for any other tabs,
+ * but BroadcastChannel delivery within a single window is racy (especially
+ * with browser extensions like password managers in the mix), so we also
+ * notify our hosting dialog directly to guarantee it closes.
+ */
+ onSuccess?: () => void
/** Lets the surrounding dialog adapt its header to the form's step. */
onContextChange?: (ctx: { step: AuthFormStep; email: string }) => void
}
@@ -148,6 +157,7 @@ export function PortalAuthFormInline({
workspaceName,
callbackUrl,
onModeSwitch,
+ onSuccess,
onContextChange,
}: PortalAuthFormInlineProps) {
const intl = useIntl()
@@ -398,6 +408,7 @@ export function PortalAuthFormInline({
return
}
postAuthSuccess()
+ onSuccess?.()
} catch (err) {
setError(
err instanceof Error
diff --git a/apps/web/src/components/notifications/__tests__/notification-item.test.tsx b/apps/web/src/components/notifications/__tests__/notification-item.test.tsx
new file mode 100644
index 000000000..6c2361847
--- /dev/null
+++ b/apps/web/src/components/notifications/__tests__/notification-item.test.tsx
@@ -0,0 +1,162 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { NotificationItem } from '../notification-item'
+
+const mocks = vi.hoisted(() => ({
+ pathname: '/admin/dashboard',
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ Link: ({
+ children,
+ to,
+ params,
+ search,
+ onClick,
+ }: {
+ children: ReactNode
+ to: string
+ params?: Record
+ search?: Record
+ onClick?: () => void
+ }) => (
+ {
+ event.preventDefault()
+ onClick?.()
+ }}
+ >
+ {children}
+
+ ),
+ useRouterState: ({ select }: { select: (state: { location: { pathname: string } }) => string }) =>
+ select({ location: { pathname: mocks.pathname } }),
+}))
+
+vi.mock('date-fns', () => ({
+ formatDistanceToNow: () => '2 minutes ago',
+}))
+
+vi.mock('../notification-type-config', () => ({
+ getNotificationTypeConfig: (type: string) => ({
+ bgClass: `bg-${type}`,
+ iconClass: `text-${type}`,
+ icon: ({ className }: { className?: string }) => icon ,
+ }),
+}))
+
+function notification(overrides: Record = {}) {
+ return {
+ id: 'notification_1',
+ type: 'ticket_assigned',
+ title: 'Assigned to you',
+ body: 'A ticket needs attention',
+ readAt: null,
+ createdAt: '2026-06-20T10:00:00.000Z',
+ ticketId: null,
+ postId: null,
+ post: null,
+ conversationId: null,
+ ...overrides,
+ } as never
+}
+
+beforeEach(() => {
+ mocks.pathname = '/admin/dashboard'
+})
+
+describe('NotificationItem', () => {
+ it('links ticket notifications and marks unread items as read', () => {
+ const onMarkAsRead = vi.fn()
+ const onClick = vi.fn()
+ render(
+
+ )
+
+ const link = screen.getByRole('link', { name: /Assigned to you/ })
+ expect(link).toHaveAttribute('href', expect.stringContaining('/admin/tickets/$ticketId'))
+ expect(link).toHaveAttribute('href', expect.stringContaining('ticket_1'))
+ expect(screen.getByText('A ticket needs attention')).toBeInTheDocument()
+ expect(screen.getByText('2 minutes ago')).toBeInTheDocument()
+
+ fireEvent.click(link)
+ expect(onMarkAsRead).toHaveBeenCalledWith('notification_1')
+ expect(onClick).toHaveBeenCalled()
+ })
+
+ it('links post and chat mention notifications', () => {
+ const { rerender } = render(
+
+ )
+
+ expect(screen.getByRole('link', { name: /Assigned to you/ })).toHaveAttribute(
+ 'href',
+ expect.stringContaining('/b/$slug/posts/$postId')
+ )
+
+ rerender(
+
+ )
+
+ expect(screen.getByRole('link', { name: /Mentioned in chat/ })).toHaveAttribute(
+ 'href',
+ expect.stringContaining('/admin/inbox')
+ )
+ expect(screen.getByRole('link', { name: /Mentioned in chat/ })).toHaveAttribute(
+ 'href',
+ expect.stringContaining('conversation_1')
+ )
+ })
+
+ it('uses admin/public fallback links and full variant click handling', () => {
+ const onMarkAsRead = vi.fn()
+ const { rerender } = render( )
+
+ expect(screen.getByRole('link', { name: /Assigned to you/ })).toHaveAttribute(
+ 'href',
+ expect.stringContaining('/admin/notifications')
+ )
+
+ mocks.pathname = '/roadmap'
+ rerender( )
+ expect(screen.getByRole('link', { name: /Public notice/ })).toHaveAttribute(
+ 'href',
+ expect.stringContaining('/notifications')
+ )
+
+ rerender(
+
+ )
+
+ expect(screen.getByText('Roadmap source')).toBeInTheDocument()
+ fireEvent.click(screen.getByText('Full notice').closest('div') as HTMLElement)
+ expect(onMarkAsRead).toHaveBeenCalledWith('notification_1')
+ })
+})
diff --git a/apps/web/src/components/notifications/notification-item.tsx b/apps/web/src/components/notifications/notification-item.tsx
index d45ec72d3..3eebfc3ab 100644
--- a/apps/web/src/components/notifications/notification-item.tsx
+++ b/apps/web/src/components/notifications/notification-item.tsx
@@ -52,6 +52,18 @@ export function NotificationItem({
/>
)
+ if (notification.ticketId) {
+ return (
+
+ {content}
+
+ )
+ }
+
if (notification.post && notification.postId) {
return (
({
+ navigate: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ Link: ({
+ children,
+ to,
+ params,
+ }: {
+ children: ReactNode
+ to: string
+ params?: Record
+ className?: string
+ }) => {children} ,
+ useNavigate: () => mocks.navigate,
+}))
+
+vi.mock('date-fns', () => ({
+ format: () => 'Jun 20, 2026',
+ formatDistanceToNow: () => '5 minutes ago',
+}))
+
+vi.mock('react-intl', () => ({
+ FormattedMessage: ({ defaultMessage }: { id: string; defaultMessage: string }) => (
+ <>{defaultMessage}>
+ ),
+ useIntl: () => ({
+ formatMessage: (descriptor: { defaultMessage: string }, values?: Record) =>
+ descriptor.defaultMessage
+ .replace('{date}', values?.date ?? '')
+ .replace('{when}', values?.when ?? ''),
+ }),
+}))
+
+describe('portal ticket components', () => {
+ it('renders detail header and list row metadata', () => {
+ render(
+ <>
+
+
+ >
+ )
+
+ expect(screen.getByRole('heading', { name: 'Cannot access account' })).toBeInTheDocument()
+ expect(screen.getByText(/Opened Jun 20, 2026/)).toBeInTheDocument()
+ expect(screen.getByText(/Last update 5 minutes ago/)).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: /Billing question/ })).toHaveAttribute(
+ 'href',
+ expect.stringContaining('ticket_1')
+ )
+ expect(screen.getByText('Updated 5 minutes ago')).toBeInTheDocument()
+ expect(screen.getByText('Open')).toBeInTheDocument()
+ expect(screen.getByText('Pending')).toBeInTheDocument()
+ })
+
+ it('navigates ticket status filters', () => {
+ render( )
+
+ expect(screen.getByRole('group', { name: 'Filter tickets by status' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Pending' })).toHaveAttribute('aria-pressed', 'true')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Closed' }))
+ expect(mocks.navigate).toHaveBeenCalledWith({ search: { status: 'closed' } })
+
+ fireEvent.click(screen.getByRole('button', { name: 'All' }))
+ expect(mocks.navigate).toHaveBeenCalledWith({ search: { status: 'all' } })
+ })
+})
diff --git a/apps/web/src/components/public/tickets/__tests__/portal-ticket-reply-composer.test.tsx b/apps/web/src/components/public/tickets/__tests__/portal-ticket-reply-composer.test.tsx
new file mode 100644
index 000000000..ec5917ac4
--- /dev/null
+++ b/apps/web/src/components/public/tickets/__tests__/portal-ticket-reply-composer.test.tsx
@@ -0,0 +1,303 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { PortalTicketReplyComposer } from '../portal-ticket-reply-composer'
+
+type ReplyPayload = {
+ bodyJson: unknown
+ bodyText: string
+}
+
+type FetchResponse = {
+ ok: boolean
+ text: () => Promise
+}
+
+const mocks = vi.hoisted(() => ({
+ invalidateQueries: vi.fn(),
+ ensureQueryData: vi.fn(),
+ mutateAsync: vi.fn(),
+ uploadImage: vi.fn(),
+ fetch: vi.fn(),
+ replyPending: false,
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ ensureQueryData: mocks.ensureQueryData,
+ }),
+}))
+
+vi.mock('react-intl', () => ({
+ FormattedMessage: ({ defaultMessage }: { id: string; defaultMessage: string }) => (
+ <>{defaultMessage}>
+ ),
+ useIntl: () => ({
+ formatMessage: ({ defaultMessage }: { id: string; defaultMessage: string }) => defaultMessage,
+ }),
+}))
+
+vi.mock('@/components/ui/rich-text-editor', () => ({
+ RichTextEditor: ({
+ value,
+ placeholder,
+ onChange,
+ onImageUpload,
+ }: {
+ value?: unknown
+ onChange: (json: unknown) => void
+ placeholder: string
+ minHeight?: string
+ features?: Record
+ onImageUpload?: (file: File) => Promise
+ }) => (
+
+
{placeholder}
+
editor-value:{value ? 'set' : 'empty'}
+
+ onChange({
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Hello support' }],
+ },
+ ],
+ })
+ }
+ >
+ Set reply text
+
+
+ onChange({
+ type: 'doc',
+ content: [{ type: 'heading', content: [] }],
+ })
+ }
+ >
+ Set empty heading
+
+
onImageUpload?.(new File(['image'], 'inline.png', { type: 'image/png' }))}
+ >
+ Upload inline image
+
+
+ ),
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ 'aria-busy': ariaBusy,
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ size?: string
+ variant?: string
+ 'aria-busy'?: boolean
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('lucide-react', () => ({
+ Upload: () => upload ,
+ X: () => remove ,
+}))
+
+vi.mock('@/lib/client/queries/portal-tickets', () => ({
+ useReplyToMyTicket: () => ({
+ mutateAsync: mocks.mutateAsync,
+ isPending: mocks.replyPending,
+ }),
+ portalTicketQueries: {
+ detail: (ticketId: string) => ({
+ queryKey: ['portal-ticket', 'detail', ticketId],
+ }),
+ },
+}))
+
+vi.mock('@/lib/client/queries/tickets', () => ({
+ ticketQueries: {
+ attachments: (ticketId: string, threadId: string) => ({
+ queryKey: ['ticket-attachments', ticketId, threadId],
+ }),
+ },
+}))
+
+vi.mock('@/lib/client/hooks/use-image-upload', () => ({
+ usePortalImageUpload: () => ({
+ upload: mocks.uploadImage,
+ }),
+}))
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.replyPending = false
+ mocks.mutateAsync.mockResolvedValue(undefined)
+ mocks.uploadImage.mockResolvedValue({ url: 'https://example.com/inline.png' })
+ mocks.ensureQueryData.mockResolvedValue({
+ threads: [{ id: 'thread_created' }],
+ })
+ mocks.fetch.mockResolvedValue({
+ ok: true,
+ text: async () => '',
+ } satisfies FetchResponse)
+ vi.stubGlobal('fetch', mocks.fetch)
+})
+
+describe('PortalTicketReplyComposer', () => {
+ it('renders the closed ticket state without reply controls', () => {
+ render( )
+
+ expect(
+ screen.getByText('This ticket is closed. Open a new one to follow up.')
+ ).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Send reply' })).not.toBeInTheDocument()
+ })
+
+ it('keeps send disabled for empty editor content and shows pending state', () => {
+ const { rerender } = render(
+
+ )
+
+ expect(screen.getByRole('button', { name: 'Send reply' })).toBeDisabled()
+ fireEvent.click(screen.getByRole('button', { name: 'Set empty heading' }))
+ expect(screen.getByRole('button', { name: 'Send reply' })).toBeDisabled()
+
+ mocks.replyPending = true
+ fireEvent.click(screen.getByRole('button', { name: 'Set reply text' }))
+ rerender( )
+
+ expect(screen.getByRole('button', { name: 'Sending…' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: 'Sending…' })).toHaveAttribute('aria-busy', 'true')
+ })
+
+ it('submits a rich-text reply, uploads inline images and resets after success', async () => {
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Upload inline image' }))
+ expect(mocks.uploadImage).toHaveBeenCalledTimes(1)
+
+ fireEvent.click(screen.getByRole('button', { name: 'Set reply text' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Send reply' }))
+
+ await waitFor(() => {
+ expect(mocks.mutateAsync).toHaveBeenCalledWith({
+ bodyJson: {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Hello support' }],
+ },
+ ],
+ },
+ bodyText: 'Hello support',
+ } satisfies ReplyPayload)
+ })
+ expect(screen.getByText('editor-value:empty')).toBeInTheDocument()
+ })
+
+ it('uploads selected attachments to the created thread and invalidates attachment queries', async () => {
+ mocks.fetch
+ .mockResolvedValueOnce({
+ ok: true,
+ text: async () => '',
+ } satisfies FetchResponse)
+ .mockResolvedValueOnce({
+ ok: false,
+ text: async () => 'upload failed',
+ } satisfies FetchResponse)
+
+ const { container } = render(
+
+ )
+
+ const input = container.querySelector('input[type="file"]')
+ expect(input).not.toBeNull()
+ fireEvent.change(input!, {
+ target: {
+ files: [
+ new File(['one'], 'one.png', { type: 'image/png' }),
+ new File(['two'], 'two.png', { type: 'image/png' }),
+ ],
+ },
+ })
+ expect(screen.getByText('Attachments (2)')).toBeInTheDocument()
+ expect(screen.getByText('one.png')).toBeInTheDocument()
+ expect(screen.getByText('two.png')).toBeInTheDocument()
+
+ fireEvent.click(screen.getAllByRole('button', { name: '' })[0])
+ expect(screen.queryByText('one.png')).not.toBeInTheDocument()
+ expect(screen.getByText('Attachments (1)')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Set reply text' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Send reply' }))
+
+ await waitFor(() => {
+ expect(mocks.ensureQueryData).toHaveBeenCalledWith({
+ queryKey: ['portal-ticket', 'detail', 'ticket_1'],
+ })
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['portal-ticket', 'detail', 'ticket_1'],
+ })
+ expect(mocks.fetch).toHaveBeenCalledWith(
+ '/api/v1/tickets/ticket_1/threads/thread_created/attachments',
+ expect.objectContaining({
+ method: 'POST',
+ body: expect.any(FormData),
+ })
+ )
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['ticket-attachments', 'ticket_1', 'thread_created'],
+ })
+ })
+
+ it('keeps reply failures and best-effort upload failures from escaping the composer', async () => {
+ mocks.mutateAsync.mockRejectedValueOnce(new Error('Reply denied'))
+ const { rerender } = render(
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Set reply text' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Send reply' }))
+
+ await waitFor(() => {
+ expect(mocks.mutateAsync).toHaveBeenCalled()
+ })
+ expect(screen.getByText('editor-value:set')).toBeInTheDocument()
+
+ mocks.mutateAsync.mockResolvedValue(undefined)
+ mocks.fetch.mockRejectedValueOnce(new Error('Upload failed'))
+ const input = document.querySelector('input[type="file"]')
+ fireEvent.change(input!, {
+ target: {
+ files: [new File(['one'], 'one.png', { type: 'image/png' })],
+ },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Send reply' }))
+
+ await waitFor(() => {
+ expect(mocks.fetch).toHaveBeenCalled()
+ })
+ rerender( )
+ expect(screen.getByText('editor-value:empty')).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/public/tickets/__tests__/portal-ticket-thread-feed.test.tsx b/apps/web/src/components/public/tickets/__tests__/portal-ticket-thread-feed.test.tsx
new file mode 100644
index 000000000..5c8b9ace7
--- /dev/null
+++ b/apps/web/src/components/public/tickets/__tests__/portal-ticket-thread-feed.test.tsx
@@ -0,0 +1,281 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { PortalTicketThreadFeed, type PortalThread } from '../portal-ticket-thread-feed'
+
+type AttachmentState = {
+ data?: Array<{ id: string; filename: string }>
+ isLoading?: boolean
+ isError?: boolean
+}
+
+const mocks = vi.hoisted(() => ({
+ uploadImage: vi.fn(),
+ attachmentsByThread: {} as Record,
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQuery: (options: { queryKey: readonly unknown[] }) => {
+ const threadId = String(options.queryKey[2])
+ const state = mocks.attachmentsByThread[threadId] ?? {}
+ return {
+ data: state.data,
+ isLoading: state.isLoading ?? false,
+ isError: state.isError ?? false,
+ }
+ },
+}))
+
+vi.mock('react-intl', () => ({
+ FormattedMessage: ({ defaultMessage }: { id: string; defaultMessage: string }) => (
+ <>{defaultMessage}>
+ ),
+ useIntl: () => ({
+ formatMessage: ({ defaultMessage }: { id: string; defaultMessage: string }) => defaultMessage,
+ }),
+}))
+
+vi.mock('date-fns', () => ({
+ formatDistanceToNow: (date: Date) => `distance:${date.toISOString()}`,
+}))
+
+vi.mock('@/components/ui/rich-text-editor', () => ({
+ RichTextContent: ({ content, className }: { content: { type: string }; className?: string }) => (
+ rich:{content.type}
+ ),
+ RichTextEditor: ({
+ placeholder,
+ onChange,
+ onImageUpload,
+ }: {
+ value?: unknown
+ onChange: (json: { type: string }, html: string, markdown: string) => void
+ placeholder: string
+ minHeight?: string
+ features?: Record
+ onImageUpload?: (file: File) => Promise
+ }) => (
+
+
{placeholder}
+
onChange({ type: 'doc' }, 'Updated
', 'Updated markdown')}
+ >
+ Change draft
+
+
onImageUpload?.(new File(['image'], 'image.png', { type: 'image/png' }))}
+ >
+ Upload image
+
+
+ ),
+ isRichTextContent: (value: unknown) =>
+ typeof value === 'object' &&
+ value !== null &&
+ 'type' in value &&
+ (value as { type?: unknown }).type === 'doc',
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ type = 'button',
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ type?: 'button' | 'submit' | 'reset'
+ size?: string
+ variant?: string
+ className?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('lucide-react', () => ({
+ Pencil: () => pencil ,
+}))
+
+vi.mock('@/components/tickets/ticket-attachments', () => ({
+ TicketAttachments: ({
+ attachments,
+ isLoading,
+ }: {
+ attachments: Array<{ id: string; filename: string }>
+ isLoading: boolean
+ }) => (
+
+ {isLoading
+ ? 'Loading attachments'
+ : `Attachments:${attachments.map((a) => a.filename).join(',')}`}
+
+ ),
+}))
+
+vi.mock('@/lib/client/queries/tickets', () => ({
+ ticketQueries: {
+ attachments: (ticketId: string, threadId: string) => ({
+ queryKey: ['ticket-attachments', ticketId, threadId],
+ }),
+ },
+}))
+
+vi.mock('@/lib/client/hooks/use-image-upload', () => ({
+ usePortalImageUpload: () => ({
+ upload: mocks.uploadImage,
+ }),
+}))
+
+function thread(overrides: Partial): PortalThread {
+ return {
+ id: 'thread_1' as never,
+ ticketId: 'ticket_1' as never,
+ principalId: 'principal_viewer' as never,
+ bodyJson: null,
+ bodyText: 'Plain reply',
+ createdAt: new Date('2026-06-18T12:00:00.000Z'),
+ editedAt: null,
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.attachmentsByThread = {}
+ mocks.uploadImage.mockResolvedValue({ url: 'https://example.com/image.png' })
+})
+
+describe('PortalTicketThreadFeed', () => {
+ it('renders a no-replies empty state when there is no description or edit callback', () => {
+ render( )
+
+ expect(screen.getByText('No replies yet.')).toBeInTheDocument()
+ })
+
+ it('adds and edits the ticket description using rich-text drafts', () => {
+ const onDescriptionUpdate = vi.fn()
+ const { rerender } = render(
+
+ )
+
+ fireEvent.click(screen.getByText('+ Add a description…'))
+ expect(screen.getByText('Add a description…')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Change draft' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Upload image' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
+
+ expect(onDescriptionUpdate).toHaveBeenCalledWith({ type: 'doc' }, 'Updated markdown')
+ expect(mocks.uploadImage).toHaveBeenCalledTimes(1)
+
+ onDescriptionUpdate.mockClear()
+ rerender(
+
+ )
+
+ expect(screen.getByText('Existing plain description')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: /Edit/ }))
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+ expect(screen.getByText('Existing plain description')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: /Edit/ }))
+ fireEvent.click(screen.getByRole('button', { name: 'Change draft' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
+ expect(onDescriptionUpdate).toHaveBeenCalledWith({ type: 'doc' }, 'Updated markdown')
+ })
+
+ it('renders rich descriptions and disables save actions while a description is saving', () => {
+ render(
+
+ )
+
+ expect(screen.getByText('rich:doc')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: /Edit/ }))
+
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: 'Saving…' })).toBeDisabled()
+ })
+
+ it('labels viewer and support replies, shows edited state and loads attachments', () => {
+ mocks.attachmentsByThread = {
+ thread_viewer: {
+ data: [{ id: 'attachment_1', filename: 'screen.png' }],
+ },
+ thread_support: {
+ data: [],
+ },
+ thread_loading: {
+ isLoading: true,
+ },
+ thread_error: {
+ isError: true,
+ },
+ }
+
+ render(
+
+ )
+
+ expect(screen.getByLabelText('Reply from You')).toBeInTheDocument()
+ expect(screen.getAllByLabelText('Reply from Support team')).toHaveLength(3)
+ expect(screen.getByText('rich:doc')).toBeInTheDocument()
+ expect(screen.getByText('Staff reply')).toBeInTheDocument()
+ expect(screen.getByText('(edited)')).toBeInTheDocument()
+ expect(screen.getAllByText(/distance:2026-06-18T12:00:00.000Z/)).toHaveLength(4)
+ expect(screen.getByText('Attachments:screen.png')).toBeInTheDocument()
+ expect(screen.getByText('Loading attachments')).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/public/tickets/portal-ticket-detail-header.tsx b/apps/web/src/components/public/tickets/portal-ticket-detail-header.tsx
new file mode 100644
index 000000000..51785229f
--- /dev/null
+++ b/apps/web/src/components/public/tickets/portal-ticket-detail-header.tsx
@@ -0,0 +1,41 @@
+import { format, formatDistanceToNow } from 'date-fns'
+import { useIntl } from 'react-intl'
+import { TicketStatusPill } from '@/components/admin/tickets/ticket-status-pill'
+import type { PortalStatusCategory } from '@/lib/client/queries/portal-tickets'
+
+export interface PortalTicketDetailHeaderProps {
+ subject: string
+ statusName: string
+ statusCategory: PortalStatusCategory
+ createdAt: Date
+ lastActivityAt: Date
+}
+
+export function PortalTicketDetailHeader({
+ subject,
+ statusName,
+ statusCategory,
+ createdAt,
+ lastActivityAt,
+}: PortalTicketDetailHeaderProps) {
+ const intl = useIntl()
+ return (
+
+
+
{subject}
+
+
+
+ {intl.formatMessage(
+ { id: 'portal.tickets.detail.openedAt', defaultMessage: 'Opened {date}' },
+ { date: format(createdAt, 'PP') }
+ )}
+ {' · '}
+ {intl.formatMessage(
+ { id: 'portal.tickets.detail.lastUpdate', defaultMessage: 'Last update {when}' },
+ { when: formatDistanceToNow(lastActivityAt, { addSuffix: true }) }
+ )}
+
+
+ )
+}
diff --git a/apps/web/src/components/public/tickets/portal-ticket-reply-composer.tsx b/apps/web/src/components/public/tickets/portal-ticket-reply-composer.tsx
new file mode 100644
index 000000000..13a44f6e4
--- /dev/null
+++ b/apps/web/src/components/public/tickets/portal-ticket-reply-composer.tsx
@@ -0,0 +1,196 @@
+import { useState, useMemo, useRef } from 'react'
+import type { JSONContent } from '@tiptap/react'
+import { FormattedMessage, useIntl } from 'react-intl'
+import { Button } from '@/components/ui/button'
+import { RichTextEditor } from '@/components/ui/rich-text-editor'
+import { useReplyToMyTicket } from '@/lib/client/queries/portal-tickets'
+import type { TicketId, TicketThreadId } from '@quackback/ids'
+import { X, Upload } from 'lucide-react'
+import { useQueryClient } from '@tanstack/react-query'
+import { portalTicketQueries } from '@/lib/client/queries/portal-tickets'
+import { ticketQueries } from '@/lib/client/queries/tickets'
+import { usePortalImageUpload } from '@/lib/client/hooks/use-image-upload'
+
+export interface PortalTicketReplyComposerProps {
+ ticketId: TicketId
+ /** When true, the composer is rendered in a disabled "ticket closed" state. */
+ isClosed: boolean
+}
+
+function plainTextFromJson(json: JSONContent | null): string {
+ if (!json) return ''
+ let out = ''
+ const walk = (node: JSONContent) => {
+ if (node.type === 'text' && typeof node.text === 'string') out += node.text
+ if (node.content) node.content.forEach(walk)
+ if (node.type === 'paragraph' || node.type === 'heading') out += '\n'
+ }
+ walk(json)
+ return out.trim()
+}
+
+export function PortalTicketReplyComposer({ ticketId, isClosed }: PortalTicketReplyComposerProps) {
+ const intl = useIntl()
+ const qc = useQueryClient()
+ const { upload: uploadImage } = usePortalImageUpload()
+ const fileInputRef = useRef(null)
+ const [body, setBody] = useState(null)
+ const [selectedFiles, setSelectedFiles] = useState([])
+ const reply = useReplyToMyTicket(ticketId)
+
+ const placeholder = intl.formatMessage({
+ id: 'portal.tickets.composer.placeholder',
+ defaultMessage: 'Type your reply…',
+ })
+
+ const text = useMemo(() => plainTextFromJson(body), [body])
+ const isEmpty = text.length === 0
+
+ const handleReply = async () => {
+ try {
+ // Post the reply
+ await reply.mutateAsync({ bodyJson: body, bodyText: text })
+
+ // Upload files if any and we have a thread
+ // For portal, we need to get the new threadId from the API
+ // Since the mutation doesn't return it, we'll fetch the latest thread
+ if (selectedFiles.length > 0) {
+ // Refetch threads to get the newly created one
+ await qc.invalidateQueries({ queryKey: portalTicketQueries.detail(ticketId).queryKey })
+ const updatedData = await qc.ensureQueryData(portalTicketQueries.detail(ticketId))
+ const newThread = updatedData.threads?.[updatedData.threads.length - 1]
+
+ if (newThread?.id) {
+ const threadId = newThread.id as TicketThreadId
+ for (const file of selectedFiles) {
+ try {
+ const formData = new FormData()
+ formData.append('file', file)
+ const res = await fetch(
+ `/api/v1/tickets/${ticketId}/threads/${threadId}/attachments`,
+ {
+ method: 'POST',
+ body: formData,
+ }
+ )
+ if (!res.ok) {
+ await res.text()
+ } else {
+ // Invalidate attachments query
+ qc.invalidateQueries({
+ queryKey: ticketQueries.attachments(ticketId, threadId).queryKey,
+ })
+ }
+ } catch {
+ // Best-effort upload: reply is already created.
+ }
+ }
+ }
+ }
+
+ setBody(null)
+ setSelectedFiles([])
+ } catch {
+ // Error handling is done by the mutation
+ }
+ }
+
+ if (isClosed) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
setBody(json)}
+ placeholder={placeholder}
+ minHeight="100px"
+ features={{
+ headings: false,
+ codeBlocks: true,
+ blockquotes: true,
+ dividers: false,
+ images: true,
+ taskLists: false,
+ tables: false,
+ embeds: false,
+ slashMenu: false,
+ }}
+ onImageUpload={uploadImage}
+ />
+
+ {/* File picker and list */}
+ {
+ if (e.target.files) {
+ setSelectedFiles(Array.from(e.target.files))
+ }
+ }}
+ />
+
+ {selectedFiles.length > 0 && (
+
+
+ Attachments ({selectedFiles.length})
+
+
+ {selectedFiles.map((file) => (
+
+ {file.name}
+
+ setSelectedFiles((prev) => prev.filter((f) => f.name !== file.name))
+ }
+ className="ml-2 hover:text-red-500"
+ >
+
+
+
+ ))}
+
+
+ )}
+
+
+ fileInputRef.current?.click()}
+ >
+
+ Attach files
+
+
+ {reply.isPending ? (
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/web/src/components/public/tickets/portal-ticket-row.tsx b/apps/web/src/components/public/tickets/portal-ticket-row.tsx
new file mode 100644
index 000000000..cb5b477fd
--- /dev/null
+++ b/apps/web/src/components/public/tickets/portal-ticket-row.tsx
@@ -0,0 +1,38 @@
+import { Link } from '@tanstack/react-router'
+import { formatDistanceToNow } from 'date-fns'
+import { useIntl } from 'react-intl'
+import { TicketStatusPill } from '@/components/admin/tickets/ticket-status-pill'
+import type { PortalTicketRow } from '@/lib/client/queries/portal-tickets'
+
+export interface PortalTicketRowProps {
+ ticket: PortalTicketRow
+}
+
+export function PortalTicketRowItem({ ticket }: PortalTicketRowProps) {
+ const intl = useIntl()
+ const updated = formatDistanceToNow(ticket.lastActivityAt, { addSuffix: true })
+ return (
+
+
+
+
{ticket.subject}
+
+ {intl.formatMessage(
+ { id: 'portal.tickets.row.updated', defaultMessage: 'Updated {when}' },
+ { when: updated }
+ )}
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/public/tickets/portal-ticket-status-filter.tsx b/apps/web/src/components/public/tickets/portal-ticket-status-filter.tsx
new file mode 100644
index 000000000..922c07223
--- /dev/null
+++ b/apps/web/src/components/public/tickets/portal-ticket-status-filter.tsx
@@ -0,0 +1,44 @@
+import { useNavigate } from '@tanstack/react-router'
+import { FormattedMessage } from 'react-intl'
+import { cn } from '@/lib/shared/utils'
+
+export type StatusFilterValue = 'open' | 'pending' | 'solved' | 'closed' | 'all'
+
+const FILTERS: ReadonlyArray<{ value: StatusFilterValue; messageId: string; label: string }> = [
+ { value: 'open', messageId: 'portal.tickets.filter.open', label: 'Open' },
+ { value: 'pending', messageId: 'portal.tickets.filter.pending', label: 'Pending' },
+ { value: 'solved', messageId: 'portal.tickets.filter.solved', label: 'Solved' },
+ { value: 'closed', messageId: 'portal.tickets.filter.closed', label: 'Closed' },
+ { value: 'all', messageId: 'portal.tickets.filter.all', label: 'All' },
+]
+
+export interface PortalTicketStatusFilterProps {
+ value: StatusFilterValue
+}
+
+export function PortalTicketStatusFilter({ value }: PortalTicketStatusFilterProps) {
+ const navigate = useNavigate({ from: '/tickets/' })
+ return (
+
+ {FILTERS.map((f) => {
+ const active = f.value === value
+ return (
+ navigate({ search: { status: f.value } })}
+ className={cn(
+ 'inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition',
+ active
+ ? 'border-foreground bg-foreground text-background'
+ : 'border-border bg-background text-muted-foreground hover:border-foreground/40 hover:text-foreground'
+ )}
+ >
+
+
+ )
+ })}
+
+ )
+}
diff --git a/apps/web/src/components/public/tickets/portal-ticket-thread-feed.tsx b/apps/web/src/components/public/tickets/portal-ticket-thread-feed.tsx
new file mode 100644
index 000000000..58561669f
--- /dev/null
+++ b/apps/web/src/components/public/tickets/portal-ticket-thread-feed.tsx
@@ -0,0 +1,248 @@
+import { useState, useCallback } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { formatDistanceToNow } from 'date-fns'
+import { FormattedMessage, useIntl } from 'react-intl'
+import type { JSONContent } from '@tiptap/react'
+import type { TicketId, TicketThreadId, PrincipalId } from '@quackback/ids'
+import {
+ RichTextContent,
+ RichTextEditor,
+ isRichTextContent,
+} from '@/components/ui/rich-text-editor'
+import { Button } from '@/components/ui/button'
+import { Pencil } from 'lucide-react'
+import { TicketAttachments } from '@/components/tickets/ticket-attachments'
+import { ticketQueries } from '@/lib/client/queries/tickets'
+import { usePortalImageUpload } from '@/lib/client/hooks/use-image-upload'
+
+export interface PortalThread {
+ id: TicketThreadId
+ ticketId: TicketId
+ principalId: PrincipalId | null
+ bodyJson: unknown
+ bodyText: string
+ createdAt: Date
+ editedAt: Date | null
+}
+
+export interface PortalTicketThreadFeedProps {
+ threads: PortalThread[]
+ /** Map of principalId → display name (the viewer's own name; staff names are masked). */
+ principalNames: Record
+ /** The viewer's own principalId — their messages are labelled "You". */
+ viewerPrincipalId: PrincipalId | null
+ /** Optional initial-description block, rendered above threads. */
+ description?: { text: string | null; json: unknown } | null
+ /** Callback to save an edited description. When provided, the description is editable. */
+ onDescriptionUpdate?: (json: JSONContent | null, text: string | null) => void
+ /** Whether a description update is currently saving. */
+ isDescriptionSaving?: boolean
+}
+
+function authorLabel(
+ principalId: string | null,
+ principalNames: Record,
+ viewerPrincipalId: string | null,
+ intl: ReturnType
+): string {
+ if (!principalId) {
+ return intl.formatMessage({
+ id: 'portal.tickets.detail.supportTeam',
+ defaultMessage: 'Support team',
+ })
+ }
+ if (principalId === viewerPrincipalId) {
+ return intl.formatMessage({ id: 'portal.tickets.detail.you', defaultMessage: 'You' })
+ }
+ // Any other principal is staff — collapse to the unified "Support team" label.
+ // (The principalNames map is kept so future variants can use it without
+ // a new round-trip.)
+ void principalNames
+ return intl.formatMessage({
+ id: 'portal.tickets.detail.supportTeam',
+ defaultMessage: 'Support team',
+ })
+}
+
+export function PortalTicketThreadFeed({
+ threads,
+ principalNames,
+ viewerPrincipalId,
+ description,
+ onDescriptionUpdate,
+ isDescriptionSaving,
+}: PortalTicketThreadFeedProps) {
+ const intl = useIntl()
+ const { upload: uploadImage } = usePortalImageUpload()
+ const hasDesc = description && (description.text || isRichTextContent(description.json))
+ const [editingDescription, setEditingDescription] = useState(false)
+ const [descDraft, setDescDraft] = useState(null)
+ const [descDraftText, setDescDraftText] = useState('')
+
+ const startEditing = useCallback(() => {
+ setDescDraft(isRichTextContent(description?.json) ? (description!.json as JSONContent) : null)
+ setDescDraftText(description?.text ?? '')
+ setEditingDescription(true)
+ }, [description])
+
+ const cancelEditing = useCallback(() => {
+ setEditingDescription(false)
+ setDescDraft(null)
+ setDescDraftText('')
+ }, [])
+
+ const saveDescription = useCallback(() => {
+ onDescriptionUpdate?.(descDraft, descDraftText || null)
+ setEditingDescription(false)
+ }, [onDescriptionUpdate, descDraft, descDraftText])
+
+ if (!hasDesc && threads.length === 0 && !onDescriptionUpdate) {
+ return (
+
+
+
+ )
+ }
+ return (
+
+ {editingDescription ? (
+
+
+
+ {
+ setDescDraft(json)
+ setDescDraftText(markdown)
+ }}
+ placeholder={intl.formatMessage({
+ id: 'portal.tickets.detail.descriptionPlaceholder',
+ defaultMessage: 'Add a description…',
+ })}
+ minHeight="80px"
+ features={{
+ headings: false,
+ codeBlocks: true,
+ blockquotes: true,
+ dividers: false,
+ images: true,
+ taskLists: false,
+ tables: false,
+ embeds: false,
+ slashMenu: false,
+ }}
+ onImageUpload={uploadImage}
+ />
+
+
+
+
+
+
+ {isDescriptionSaving ? (
+
+ ) : (
+
+ )}
+
+
+
+ ) : hasDesc ? (
+
+
+
+ {onDescriptionUpdate && (
+
+
+
+
+ )}
+
+ {isRichTextContent(description!.json) ? (
+
+ ) : (
+ {description!.text}
+ )}
+
+ ) : onDescriptionUpdate ? (
+
+
+
+ ) : null}
+ {threads.map((th) => {
+ const author = authorLabel(th.principalId, principalNames, viewerPrincipalId, intl)
+ const isViewer = th.principalId === viewerPrincipalId
+ return (
+
+
+
+ {author}
+
+
+ · {formatDistanceToNow(th.createdAt, { addSuffix: true })}
+ {th.editedAt && (
+
+ (
+ )
+
+ )}
+
+
+ {isRichTextContent(th.bodyJson) ? (
+
+ ) : (
+ {th.bodyText}
+ )}
+
+
+ )
+ })}
+
+ )
+}
+
+function PortalThreadAttachmentsLoader({
+ ticketId,
+ threadId,
+}: {
+ ticketId: TicketId
+ threadId: TicketThreadId
+}) {
+ const {
+ data: attachments,
+ isLoading,
+ isError,
+ } = useQuery(ticketQueries.attachments(ticketId, threadId))
+
+ if (isError || (!isLoading && (!attachments || attachments.length === 0))) {
+ return null
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/web/src/components/tickets/__tests__/ticket-attachments.test.tsx b/apps/web/src/components/tickets/__tests__/ticket-attachments.test.tsx
new file mode 100644
index 000000000..3d9011ae2
--- /dev/null
+++ b/apps/web/src/components/tickets/__tests__/ticket-attachments.test.tsx
@@ -0,0 +1,144 @@
+// @vitest-environment happy-dom
+import { IntlProvider } from 'react-intl'
+import { describe, expect, it } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { TicketAttachments } from '../ticket-attachments'
+
+type Attachment = Parameters[0]['attachments'][number]
+
+function attachment(overrides: Partial): Attachment {
+ return {
+ id: 'att_1',
+ filename: 'report.txt',
+ mimeType: 'text/plain',
+ sizeBytes: 0,
+ publicUrl: 'https://cdn.example.com/report.txt',
+ createdAt: '2026-06-20T10:00:00.000Z',
+ ...overrides,
+ }
+}
+
+function renderAttachments(attachments: Attachment[], isLoading = false) {
+ return render(
+
+
+
+ )
+}
+
+describe('TicketAttachments', () => {
+ it('renders loading and empty states without attachment cards', () => {
+ const { rerender, container } = render(
+
+
+
+ )
+
+ expect(screen.getByText('Loading attachments...')).toBeInTheDocument()
+
+ rerender(
+
+
+
+ )
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('renders file metadata, download links and preview controls only for previewable files', () => {
+ renderAttachments([
+ attachment({
+ id: 'image',
+ filename: 'screenshot.png',
+ mimeType: 'image/png',
+ sizeBytes: 2048,
+ publicUrl: 'https://cdn.example.com/screenshot.png',
+ }),
+ attachment({
+ id: 'pdf',
+ filename: 'invoice.pdf',
+ mimeType: 'application/pdf',
+ sizeBytes: 1048576,
+ publicUrl: 'https://cdn.example.com/invoice.pdf',
+ }),
+ attachment({
+ id: 'audio',
+ filename: 'call.mp3',
+ mimeType: 'audio/mpeg',
+ sizeBytes: 0,
+ publicUrl: 'https://cdn.example.com/call.mp3',
+ }),
+ attachment({
+ id: 'zip',
+ filename: 'archive.zip',
+ mimeType: 'application/zip',
+ sizeBytes: 512,
+ publicUrl: null,
+ }),
+ ])
+
+ expect(screen.getByText('Attachments (4)')).toBeInTheDocument()
+ expect(screen.getByText('screenshot.png')).toBeInTheDocument()
+ expect(screen.getByText('2 KB')).toBeInTheDocument()
+ expect(screen.getByText('invoice.pdf')).toBeInTheDocument()
+ expect(screen.getByText('1 MB')).toBeInTheDocument()
+ expect(screen.getByText('call.mp3')).toBeInTheDocument()
+ expect(screen.getByText('0 B')).toBeInTheDocument()
+ expect(screen.getByText('archive.zip')).toBeInTheDocument()
+ expect(screen.getAllByRole('button', { name: /Preview/ })).toHaveLength(2)
+ const downloadLinks = screen.getAllByRole('link', { name: /Download/ })
+ expect(downloadLinks).toHaveLength(3)
+ expect(downloadLinks[0]).toHaveAttribute('download', 'screenshot.png')
+ })
+
+ it('toggles image, video, pdf and fallback previews', () => {
+ const { container } = renderAttachments([
+ attachment({
+ id: 'image',
+ filename: 'screenshot.png',
+ mimeType: 'image/png',
+ publicUrl: 'https://cdn.example.com/screenshot.png',
+ }),
+ attachment({
+ id: 'video',
+ filename: 'demo.mp4',
+ mimeType: 'video/mp4',
+ publicUrl: 'https://cdn.example.com/demo.mp4',
+ }),
+ attachment({
+ id: 'pdf',
+ filename: 'invoice.pdf',
+ mimeType: 'application/pdf',
+ publicUrl: 'about:blank',
+ }),
+ attachment({
+ id: 'csv',
+ filename: 'export.csv',
+ mimeType: 'text/csv',
+ publicUrl: 'https://cdn.example.com/export.csv',
+ }),
+ ])
+
+ const previewButtons = screen.getAllByRole('button', { name: /Preview/ })
+
+ fireEvent.click(previewButtons[0])
+ expect(screen.getByAltText('screenshot.png')).toHaveAttribute(
+ 'src',
+ 'https://cdn.example.com/screenshot.png'
+ )
+
+ fireEvent.click(previewButtons[0])
+ expect(screen.queryByAltText('screenshot.png')).not.toBeInTheDocument()
+
+ fireEvent.click(previewButtons[1])
+ expect(container.querySelector('video[src="https://cdn.example.com/demo.mp4"]')).not.toBeNull()
+
+ fireEvent.click(previewButtons[2])
+ expect(container.querySelector('iframe[title="invoice.pdf"]')).toHaveAttribute(
+ 'src',
+ 'about:blank#toolbar=0'
+ )
+
+ fireEvent.click(previewButtons[3])
+ expect(screen.getByText('Preview not available')).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/tickets/ticket-attachments.tsx b/apps/web/src/components/tickets/ticket-attachments.tsx
new file mode 100644
index 000000000..28490853c
--- /dev/null
+++ b/apps/web/src/components/tickets/ticket-attachments.tsx
@@ -0,0 +1,160 @@
+import { useState } from 'react'
+import { FormattedMessage } from 'react-intl'
+import { Download, Eye, Loader2 } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+
+interface Attachment {
+ id: string
+ filename: string
+ mimeType: string
+ sizeBytes: number
+ publicUrl: string | null
+ createdAt: string
+}
+
+interface TicketAttachmentsProps {
+ attachments: Attachment[]
+ isLoading?: boolean
+}
+
+function formatFileSize(bytes: number): string {
+ if (bytes === 0) return '0 B'
+ const k = 1024
+ const sizes = ['B', 'KB', 'MB', 'GB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+}
+
+function getFileIcon(mimeType: string) {
+ if (mimeType.startsWith('image/')) return '🖼️'
+ if (mimeType.startsWith('video/')) return '🎬'
+ if (mimeType.startsWith('audio/')) return '🎵'
+ if (mimeType === 'application/pdf') return '📄'
+ if (
+ mimeType.includes('word') ||
+ mimeType.includes('document') ||
+ mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
+ ) {
+ return '📝'
+ }
+ if (
+ mimeType.includes('spreadsheet') ||
+ mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+ ) {
+ return '📊'
+ }
+ if (mimeType.includes('zip') || mimeType.includes('compressed')) return '📦'
+ return '📎'
+}
+
+function canPreview(mimeType: string): boolean {
+ return (
+ mimeType.startsWith('image/') ||
+ mimeType.startsWith('video/') ||
+ mimeType === 'application/pdf' ||
+ mimeType === 'text/plain' ||
+ mimeType === 'text/csv'
+ )
+}
+
+export function TicketAttachments({ attachments, isLoading }: TicketAttachmentsProps) {
+ const [previewId, setPreviewId] = useState(null)
+
+ if (isLoading) {
+ return (
+
+
+
+
+ )
+ }
+
+ if (!attachments || attachments.length === 0) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+ {attachments.map((attachment) => (
+
+
+
{getFileIcon(attachment.mimeType)}
+
+
+ {attachment.filename}
+
+
+ {formatFileSize(attachment.sizeBytes)}
+
+
+
+
+
+ {attachment.publicUrl && canPreview(attachment.mimeType) && (
+
setPreviewId(previewId === attachment.id ? null : attachment.id)}
+ >
+
+
+
+ )}
+ {attachment.publicUrl && (
+
+
+
+
+
+
+ )}
+
+
+ {previewId === attachment.id && attachment.publicUrl && (
+
+ {attachment.mimeType.startsWith('image/') ? (
+
+ ) : attachment.mimeType.startsWith('video/') ? (
+
+ ) : attachment.mimeType === 'application/pdf' ? (
+
+ ) : (
+
+
+
+ )}
+
+ )}
+
+ ))}
+
+
+ )
+}
diff --git a/apps/web/src/components/tickets/ticket-create-editor-features.ts b/apps/web/src/components/tickets/ticket-create-editor-features.ts
new file mode 100644
index 000000000..f5013983b
--- /dev/null
+++ b/apps/web/src/components/tickets/ticket-create-editor-features.ts
@@ -0,0 +1,20 @@
+import type { EditorFeatures } from '@/components/ui/rich-text-editor'
+
+/**
+ * Shared editor capabilities for ticket creation across portal/admin/widget.
+ * Keeps the same core authoring experience everywhere while allowing image uploads.
+ */
+export const TICKET_CREATE_EDITOR_FEATURES: EditorFeatures = {
+ headings: true,
+ images: true,
+ codeBlocks: true,
+ bubbleMenu: true,
+ slashMenu: true,
+ taskLists: true,
+ blockquotes: true,
+ tables: false,
+ dividers: true,
+ embeds: false,
+ quackbackEmbeds: true,
+ emojiPicker: true,
+}
diff --git a/apps/web/src/lib/README.md b/apps/web/src/lib/README.md
index 312fc9718..16f5ce028 100644
--- a/apps/web/src/lib/README.md
+++ b/apps/web/src/lib/README.md
@@ -92,3 +92,7 @@ server/domains/posts/
- **Mutations**: All in `client/mutations/`, named by domain
- **Services**: Max 400 lines, split by responsibility
- **Hooks**: Max 300 lines, queries only (no mutations)
+- **Server functions**: Use `GET` only when input is safe to appear in URLs
+ (public slugs, locale, pagination, resource IDs). Use `POST` for sensitive
+ or private input such as tokens, secrets, emails, credentials, free-text
+ searches, and private filter objects.
diff --git a/apps/web/src/lib/client/hooks/__tests__/teams-webhooks-subs-hooks.diffcov.test.ts b/apps/web/src/lib/client/hooks/__tests__/teams-webhooks-subs-hooks.diffcov.test.ts
new file mode 100644
index 000000000..12e94317a
--- /dev/null
+++ b/apps/web/src/lib/client/hooks/__tests__/teams-webhooks-subs-hooks.diffcov.test.ts
@@ -0,0 +1,132 @@
+/**
+ * Differential-coverage tests for the teams / webhook-deliveries / ticket-
+ * subscription query hooks and the integration mutation hooks. react-query is
+ * stubbed to capture each hook's options so the queryFn / getNextPageParam /
+ * mutationFn / onSettled closures execute (including the null-id disabled keys).
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+const m = vi.hoisted(() => {
+ const fn = vi.fn((..._a: unknown[]) => Promise.resolve('ok'))
+ const svcFns = (...names: string[]) =>
+ Object.fromEntries(names.map((n) => [n, (...a: unknown[]) => fn(n, ...a)]))
+ return {
+ queryOpts: [] as Array>,
+ infiniteOpts: [] as Array>,
+ mutationOpts: [] as Array>,
+ invalidate: vi.fn(),
+ fn,
+ svcFns,
+ }
+})
+
+vi.mock('@tanstack/react-query', () => ({
+ useQuery: (o: Record) => {
+ m.queryOpts.push(o)
+ return {}
+ },
+ useInfiniteQuery: (o: Record) => {
+ m.infiniteOpts.push(o)
+ return {}
+ },
+ useMutation: (o: Record) => {
+ m.mutationOpts.push(o)
+ return {}
+ },
+ useQueryClient: () => ({ invalidateQueries: m.invalidate }),
+}))
+vi.mock('@/lib/server/functions/teams', () =>
+ m.svcFns('listTeamsFn', 'getTeamFn', 'listTeamMembersFn')
+)
+vi.mock('@/lib/server/functions/webhook-deliveries', () => m.svcFns('listWebhookDeliveriesFn'))
+vi.mock('@/lib/server/functions/notifications', () => m.svcFns('listTicketSubscriptionsFn'))
+vi.mock('@/lib/server/functions/integrations', () =>
+ m.svcFns(
+ 'updateIntegrationFn',
+ 'deleteIntegrationFn',
+ 'addNotificationChannelFn',
+ 'updateNotificationChannelFn',
+ 'removeNotificationChannelFn',
+ 'addMonitoredChannelFn',
+ 'updateMonitoredChannelFn',
+ 'removeMonitoredChannelFn',
+ 'upsertUserMappingFn',
+ 'deleteUserMappingFn'
+ )
+)
+
+import { useTeams, useTeam, useTeamMembers } from '../use-teams-queries'
+import { useWebhookDeliveries } from '../use-webhook-deliveries-queries'
+import { useTicketSubscriptions } from '../use-ticket-subscriptions-queries'
+import * as integ from '../../mutations/integrations'
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ m.queryOpts = []
+ m.infiniteOpts = []
+ m.mutationOpts = []
+})
+
+describe('teams query hooks', () => {
+ it('builds list/detail/members queries (with + without an id)', async () => {
+ useTeams({ includeArchived: true, enabled: false })
+ useTeam('team_1' as never)
+ useTeam(null)
+ useTeamMembers('team_1' as never)
+ useTeamMembers(undefined)
+ // run every captured queryFn closure
+ for (const o of m.queryOpts) await (o.queryFn as () => unknown)()
+ const disabled = m.queryOpts.filter((o) => o.enabled === false)
+ expect(disabled.length).toBeGreaterThanOrEqual(2) // null team + null members
+ expect(m.fn).toHaveBeenCalledWith('listTeamsFn', expect.anything())
+ })
+})
+
+describe('webhook deliveries hook', () => {
+ it('builds an infinite query, runs queryFn + getNextPageParam, and the null key', async () => {
+ useWebhookDeliveries('wh_1' as never, { status: 'failed_retryable' })
+ useWebhookDeliveries(null)
+ const opts = m.infiniteOpts[0]!
+ await (opts.queryFn as (c: { pageParam: unknown }) => unknown)({
+ pageParam: { cursorAttemptedAt: 't', cursorId: 'd' },
+ })
+ await (opts.queryFn as (c: { pageParam: unknown }) => unknown)({ pageParam: null })
+ const gnp = opts.getNextPageParam as (l: unknown) => unknown
+ expect(gnp({ nextCursor: 'c2' })).toBe('c2')
+ expect(gnp({})).toBeUndefined()
+ expect(m.infiniteOpts[1]!.enabled).toBe(false)
+ })
+})
+
+describe('ticket subscriptions hook', () => {
+ it('builds the query with + without a ticket id', async () => {
+ useTicketSubscriptions('ticket_1' as never)
+ useTicketSubscriptions(null)
+ await (m.queryOpts[0]!.queryFn as () => unknown)()
+ expect(m.queryOpts[1]!.enabled).toBe(false)
+ })
+})
+
+describe('integration mutation hooks', () => {
+ it('every mutation runs its mutationFn + onSettled invalidation', async () => {
+ const hooks = [
+ integ.useUpdateIntegration,
+ integ.useDeleteIntegration,
+ integ.useAddNotificationChannel,
+ integ.useUpdateNotificationChannel,
+ integ.useRemoveNotificationChannel,
+ integ.useAddMonitoredChannel,
+ integ.useUpdateMonitoredChannel,
+ integ.useRemoveMonitoredChannel,
+ integ.useUpsertUserMapping,
+ integ.useDeleteUserMapping,
+ ]
+ for (const h of hooks) h()
+ expect(m.mutationOpts).toHaveLength(10)
+ for (const o of m.mutationOpts) {
+ await (o.mutationFn as (i: unknown) => unknown)({})
+ ;(o.onSettled as () => void)()
+ }
+ expect(m.invalidate).toHaveBeenCalledTimes(10)
+ })
+})
diff --git a/apps/web/src/lib/client/hooks/__tests__/use-ticket-create-with-attachments.diffcov.test.tsx b/apps/web/src/lib/client/hooks/__tests__/use-ticket-create-with-attachments.diffcov.test.tsx
new file mode 100644
index 000000000..3cd510907
--- /dev/null
+++ b/apps/web/src/lib/client/hooks/__tests__/use-ticket-create-with-attachments.diffcov.test.tsx
@@ -0,0 +1,218 @@
+// @vitest-environment happy-dom
+
+import { renderHook, act } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ useTicketCreateWithAttachments,
+ type FileToAttach,
+} from '../use-ticket-create-with-attachments'
+
+const TICKET = { id: 'ticket_1' as never, title: 'demo' }
+const THREAD = { id: 'thread_1' as never }
+
+function makeFile(name: string): File {
+ return new File(['content'], name, { type: 'text/plain' })
+}
+
+function makeAttachments(...names: string[]): FileToAttach[] {
+ return names.map((name, i) => ({ file: makeFile(name), id: `f${i}` }))
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('useTicketCreateWithAttachments', () => {
+ it('starts with a clean, idle state', () => {
+ const { result } = renderHook(() =>
+ useTicketCreateWithAttachments({
+ createFn: vi.fn(),
+ createThreadFn: vi.fn(),
+ uploadFileFn: vi.fn(),
+ files: [],
+ })
+ )
+ expect(result.current.state).toEqual({
+ isUploading: false,
+ uploadProgress: {},
+ uploadErrors: {},
+ successCount: 0,
+ failureCount: 0,
+ isDone: false,
+ })
+ })
+
+ it('creates the ticket and finishes immediately when there are no files', async () => {
+ const createFn = vi.fn().mockResolvedValue(TICKET)
+ const createThreadFn = vi.fn()
+ const uploadFileFn = vi.fn()
+ const { result } = renderHook(() =>
+ useTicketCreateWithAttachments({
+ createFn,
+ createThreadFn,
+ uploadFileFn,
+ files: [],
+ })
+ )
+
+ let returned: unknown
+ await act(async () => {
+ returned = await result.current.execute()
+ })
+
+ expect(returned).toBe(TICKET)
+ expect(createFn).toHaveBeenCalledTimes(1)
+ expect(createThreadFn).not.toHaveBeenCalled()
+ expect(uploadFileFn).not.toHaveBeenCalled()
+ expect(result.current.state.isUploading).toBe(false)
+ expect(result.current.state.isDone).toBe(true)
+ expect(result.current.state.successCount).toBe(0)
+ expect(result.current.state.failureCount).toBe(0)
+ })
+
+ it('uploads files, tracking both per-file success and per-file failure', async () => {
+ const createFn = vi.fn().mockResolvedValue(TICKET)
+ const createThreadFn = vi.fn().mockResolvedValue(THREAD)
+ const uploadFileFn = vi
+ .fn()
+ .mockResolvedValueOnce({ success: true })
+ .mockResolvedValueOnce({ success: false, error: 'too big' })
+ const onFileUploadComplete = vi.fn()
+ const files = makeAttachments('ok.txt', 'bad.txt')
+
+ const { result } = renderHook(() =>
+ useTicketCreateWithAttachments({
+ createFn,
+ createThreadFn,
+ uploadFileFn,
+ files,
+ onFileUploadComplete,
+ })
+ )
+
+ let returned: unknown
+ await act(async () => {
+ returned = await result.current.execute()
+ })
+
+ expect(returned).toBe(TICKET)
+ expect(createThreadFn).toHaveBeenCalledWith(TICKET.id)
+ expect(uploadFileFn).toHaveBeenCalledTimes(2)
+ expect(result.current.state.successCount).toBe(1)
+ expect(result.current.state.failureCount).toBe(1)
+ expect(result.current.state.uploadErrors).toEqual({ f1: 'too big' })
+ expect(result.current.state.isUploading).toBe(false)
+ expect(result.current.state.isDone).toBe(true)
+ expect(onFileUploadComplete).toHaveBeenCalledWith({
+ id: 'f0',
+ success: true,
+ error: undefined,
+ })
+ expect(onFileUploadComplete).toHaveBeenCalledWith({
+ id: 'f1',
+ success: false,
+ error: 'too big',
+ })
+ })
+
+ it('captures a thrown upload error in the per-file catch branch', async () => {
+ const createFn = vi.fn().mockResolvedValue(TICKET)
+ const createThreadFn = vi.fn().mockResolvedValue(THREAD)
+ const uploadFileFn = vi.fn().mockRejectedValue(new Error('network down'))
+ const onFileUploadComplete = vi.fn()
+ const files = makeAttachments('one.txt')
+
+ const { result } = renderHook(() =>
+ useTicketCreateWithAttachments({
+ createFn,
+ createThreadFn,
+ uploadFileFn,
+ files,
+ onFileUploadComplete,
+ })
+ )
+
+ await act(async () => {
+ await result.current.execute()
+ })
+
+ expect(result.current.state.failureCount).toBe(1)
+ expect(result.current.state.uploadErrors).toEqual({ f0: 'network down' })
+ expect(onFileUploadComplete).toHaveBeenCalledWith({
+ id: 'f0',
+ success: false,
+ error: 'network down',
+ })
+ })
+
+ it('falls back to "Unknown error" when a non-Error is thrown during upload', async () => {
+ const createFn = vi.fn().mockResolvedValue(TICKET)
+ const createThreadFn = vi.fn().mockResolvedValue(THREAD)
+ const uploadFileFn = vi.fn().mockRejectedValue('boom')
+ const files = makeAttachments('one.txt')
+
+ const { result } = renderHook(() =>
+ useTicketCreateWithAttachments({
+ createFn,
+ createThreadFn,
+ uploadFileFn,
+ files,
+ })
+ )
+
+ await act(async () => {
+ await result.current.execute()
+ })
+
+ expect(result.current.state.uploadErrors).toEqual({ f0: 'Unknown error' })
+ })
+
+ it('surfaces a ticket-creation failure via the outer catch branch', async () => {
+ const createFn = vi.fn().mockRejectedValue(new Error('create failed'))
+ const { result } = renderHook(() =>
+ useTicketCreateWithAttachments({
+ createFn,
+ createThreadFn: vi.fn(),
+ uploadFileFn: vi.fn(),
+ files: makeAttachments('one.txt'),
+ })
+ )
+
+ let caught: unknown
+ await act(async () => {
+ await result.current.execute().catch((e: unknown) => {
+ caught = e
+ })
+ })
+ expect((caught as Error).message).toBe('create failed')
+
+ expect(result.current.state.isUploading).toBe(false)
+ expect(result.current.state.isDone).toBe(true)
+ expect(result.current.state.failureCount).toBe(1)
+ expect(result.current.state.uploadErrors).toEqual({ general: 'create failed' })
+ })
+
+ it('uses the generic message when a non-Error is thrown during ticket creation', async () => {
+ const createFn = vi.fn().mockRejectedValue('nope')
+ const { result } = renderHook(() =>
+ useTicketCreateWithAttachments({
+ createFn,
+ createThreadFn: vi.fn(),
+ uploadFileFn: vi.fn(),
+ files: [],
+ })
+ )
+
+ let caught: unknown
+ await act(async () => {
+ await result.current.execute().catch((e: unknown) => {
+ caught = e
+ })
+ })
+ expect(caught).toBe('nope')
+
+ expect(result.current.state.uploadErrors).toEqual({
+ general: 'Failed to create ticket',
+ })
+ })
+})
diff --git a/apps/web/src/lib/client/hooks/__tests__/use-tickets-queries.diffcov.test.ts b/apps/web/src/lib/client/hooks/__tests__/use-tickets-queries.diffcov.test.ts
new file mode 100644
index 000000000..0e0eeef81
--- /dev/null
+++ b/apps/web/src/lib/client/hooks/__tests__/use-tickets-queries.diffcov.test.ts
@@ -0,0 +1,190 @@
+// @vitest-environment happy-dom
+
+import { renderHook } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ ticketsKeys,
+ useTicket,
+ useTicketParticipants,
+ useTicketShares,
+ useTicketStatuses,
+ useTicketThreads,
+ useTickets,
+} from '../use-tickets-queries'
+
+type QueryOptions = {
+ queryKey: readonly unknown[]
+ queryFn: () => unknown
+ enabled?: boolean
+ staleTime?: number
+ refetchInterval?: number
+}
+
+const mocks = vi.hoisted(() => ({
+ queryOptions: [] as QueryOptions[],
+ queryResult: { data: undefined as unknown, isLoading: false },
+ listTicketsFn: vi.fn(),
+ getTicketFn: vi.fn(),
+ listThreadsFn: vi.fn(),
+ listSharesFn: vi.fn(),
+ listParticipantsFn: vi.fn(),
+ listTicketStatusesFn: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQuery: (options: QueryOptions) => {
+ mocks.queryOptions.push(options)
+ return mocks.queryResult
+ },
+}))
+
+vi.mock('@/lib/server/functions/tickets', () => ({
+ listTicketsFn: (input: unknown) => mocks.listTicketsFn(input),
+ getTicketFn: (input: unknown) => mocks.getTicketFn(input),
+ listThreadsFn: (input: unknown) => mocks.listThreadsFn(input),
+ listSharesFn: (input: unknown) => mocks.listSharesFn(input),
+ listParticipantsFn: (input: unknown) => mocks.listParticipantsFn(input),
+ listTicketStatusesFn: () => mocks.listTicketStatusesFn(),
+}))
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.queryOptions = []
+ mocks.queryResult = { data: undefined, isLoading: false }
+})
+
+describe('ticketsKeys', () => {
+ it('builds the stable query-key hierarchy', () => {
+ expect(ticketsKeys.all).toEqual(['tickets'])
+ expect(ticketsKeys.lists()).toEqual(['tickets', 'list'])
+ expect(ticketsKeys.list({ scope: 'all' })).toEqual(['tickets', 'list', { scope: 'all' }])
+ expect(ticketsKeys.detail('ticket_1' as never)).toEqual(['tickets', 'detail', 'ticket_1'])
+ expect(ticketsKeys.threads('ticket_1' as never)).toEqual(['tickets', 'threads', 'ticket_1'])
+ expect(ticketsKeys.shares('ticket_1' as never)).toEqual(['tickets', 'shares', 'ticket_1'])
+ expect(ticketsKeys.participants('ticket_1' as never)).toEqual([
+ 'tickets',
+ 'participants',
+ 'ticket_1',
+ ])
+ expect(ticketsKeys.statuses()).toEqual(['tickets', 'statuses'])
+ })
+})
+
+describe('useTickets', () => {
+ it('builds the list query with defaults', () => {
+ renderHook(() => useTickets({ scope: 'all' }))
+ const query = mocks.queryOptions.at(-1)!
+ expect(query.queryKey).toEqual(ticketsKeys.list({ scope: 'all' }))
+ expect(query.enabled).toBe(true)
+ expect(query.staleTime).toBe(10_000)
+ expect(query.refetchInterval).toBe(15_000)
+ query.queryFn()
+ expect(mocks.listTicketsFn).toHaveBeenCalledWith({ data: { scope: 'all' } })
+ })
+
+ it('honours explicit enabled and refetchInterval overrides', () => {
+ renderHook(() =>
+ useTickets({ scope: 'my_assigned' }, { enabled: false, refetchInterval: 5_000 })
+ )
+ const query = mocks.queryOptions.at(-1)!
+ expect(query.enabled).toBe(false)
+ expect(query.refetchInterval).toBe(5_000)
+ })
+})
+
+describe('useTicket', () => {
+ it('builds the detail query when an id is present', () => {
+ renderHook(() => useTicket('ticket_1' as never))
+ const query = mocks.queryOptions.at(-1)!
+ expect(query.queryKey).toEqual(ticketsKeys.detail('ticket_1' as never))
+ expect(query.enabled).toBe(true)
+ query.queryFn()
+ expect(mocks.getTicketFn).toHaveBeenCalledWith({ data: { ticketId: 'ticket_1' } })
+ })
+
+ it('disables and uses the none key when id is missing', () => {
+ renderHook(() => useTicket(null))
+ expect(mocks.queryOptions.at(-1)).toMatchObject({
+ queryKey: ['tickets', 'detail', 'none'],
+ enabled: false,
+ })
+ })
+})
+
+describe('useTicketThreads', () => {
+ it('builds the threads query when an id is present', () => {
+ renderHook(() => useTicketThreads('ticket_1' as never))
+ const query = mocks.queryOptions.at(-1)!
+ expect(query.queryKey).toEqual(ticketsKeys.threads('ticket_1' as never))
+ expect(query.enabled).toBe(true)
+ query.queryFn()
+ expect(mocks.listThreadsFn).toHaveBeenCalledWith({ data: { ticketId: 'ticket_1' } })
+ })
+
+ it('disables and uses the none key when id is missing', () => {
+ renderHook(() => useTicketThreads(undefined))
+ expect(mocks.queryOptions.at(-1)).toMatchObject({
+ queryKey: ['tickets', 'threads', 'none'],
+ enabled: false,
+ })
+ })
+})
+
+describe('useTicketShares', () => {
+ it('builds the shares query when an id is present', () => {
+ renderHook(() => useTicketShares('ticket_1' as never))
+ const query = mocks.queryOptions.at(-1)!
+ expect(query.queryKey).toEqual(ticketsKeys.shares('ticket_1' as never))
+ expect(query.enabled).toBe(true)
+ expect(query.staleTime).toBe(30_000)
+ query.queryFn()
+ expect(mocks.listSharesFn).toHaveBeenCalledWith({ data: { ticketId: 'ticket_1' } })
+ })
+
+ it('disables and uses the none key when id is missing', () => {
+ renderHook(() => useTicketShares(null))
+ expect(mocks.queryOptions.at(-1)).toMatchObject({
+ queryKey: ['tickets', 'shares', 'none'],
+ enabled: false,
+ })
+ })
+})
+
+describe('useTicketParticipants', () => {
+ it('builds the participants query when an id is present', () => {
+ renderHook(() => useTicketParticipants('ticket_1' as never))
+ const query = mocks.queryOptions.at(-1)!
+ expect(query.queryKey).toEqual(ticketsKeys.participants('ticket_1' as never))
+ expect(query.enabled).toBe(true)
+ expect(query.staleTime).toBe(30_000)
+ query.queryFn()
+ expect(mocks.listParticipantsFn).toHaveBeenCalledWith({
+ data: { ticketId: 'ticket_1' },
+ })
+ })
+
+ it('disables and uses the none key when id is missing', () => {
+ renderHook(() => useTicketParticipants(undefined))
+ expect(mocks.queryOptions.at(-1)).toMatchObject({
+ queryKey: ['tickets', 'participants', 'none'],
+ enabled: false,
+ })
+ })
+})
+
+describe('useTicketStatuses', () => {
+ it('builds the statuses query enabled by default', () => {
+ renderHook(() => useTicketStatuses())
+ const query = mocks.queryOptions.at(-1)!
+ expect(query.queryKey).toEqual(ticketsKeys.statuses())
+ expect(query.enabled).toBe(true)
+ expect(query.staleTime).toBe(5 * 60_000)
+ query.queryFn()
+ expect(mocks.listTicketStatusesFn).toHaveBeenCalled()
+ })
+
+ it('honours the disabled flag', () => {
+ renderHook(() => useTicketStatuses(false))
+ expect(mocks.queryOptions.at(-1)).toMatchObject({ enabled: false })
+ })
+})
diff --git a/apps/web/src/lib/client/hooks/use-audit-queries.ts b/apps/web/src/lib/client/hooks/use-audit-queries.ts
new file mode 100644
index 000000000..7eedd82fa
--- /dev/null
+++ b/apps/web/src/lib/client/hooks/use-audit-queries.ts
@@ -0,0 +1,44 @@
+/**
+ * Audit log query hooks (read-only). Uses cursor pagination via
+ * `useInfiniteQuery` for the timeline view.
+ */
+import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
+import { listAuditEventsPagedFn } from '@/lib/server/functions/audit'
+
+export interface AuditFilters {
+ principalId?: string
+ action?: string
+ actionPrefix?: string
+ targetType?: string
+ targetId?: string
+ source?: 'web' | 'api' | 'integration' | 'system' | 'mcp'
+ from?: string
+ to?: string
+}
+
+export const auditKeys = {
+ all: ['audit'] as const,
+ list: (filters: AuditFilters) => [...auditKeys.all, 'list', filters] as const,
+}
+
+export function useAuditEventsInfinite(filters: AuditFilters, enabled = true) {
+ return useInfiniteQuery({
+ queryKey: auditKeys.list(filters),
+ initialPageParam: undefined as string | undefined,
+ queryFn: ({ pageParam }) =>
+ listAuditEventsPagedFn({ data: { ...filters, cursor: pageParam, limit: 50 } }),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ getNextPageParam: (last: any) => last?.nextCursor ?? undefined,
+ enabled,
+ staleTime: 30_000,
+ })
+}
+
+export function useAuditEvents(filters: AuditFilters, enabled = true) {
+ return useQuery({
+ queryKey: [...auditKeys.list(filters), 'first'],
+ queryFn: () => listAuditEventsPagedFn({ data: { ...filters, limit: 50 } }),
+ enabled,
+ staleTime: 30_000,
+ })
+}
diff --git a/apps/web/src/lib/client/hooks/use-authz-queries.ts b/apps/web/src/lib/client/hooks/use-authz-queries.ts
new file mode 100644
index 000000000..6a9c269fd
--- /dev/null
+++ b/apps/web/src/lib/client/hooks/use-authz-queries.ts
@@ -0,0 +1,49 @@
+/**
+ * Authz / permissions client hook.
+ *
+ * Caches the actor's full permission set workspace-wide for the session;
+ * consumed by ` `, sidebar nav filtering, and per-control
+ * disable logic.
+ */
+import { useQuery } from '@tanstack/react-query'
+import type { TeamId } from '@quackback/ids'
+import { getMyPermissionsFn } from '@/lib/server/functions/authz'
+import type { MyPermissionsResult } from '@/lib/server/functions/authz'
+import type { PermissionKey } from '@/lib/server/domains/authz'
+
+export const authzKeys = {
+ all: ['authz'] as const,
+ me: () => [...authzKeys.all, 'me'] as const,
+}
+
+export function useMyPermissions(enabled = true): ReturnType> {
+ return useQuery({
+ queryKey: authzKeys.me(),
+ queryFn: () => getMyPermissionsFn(),
+ enabled,
+ staleTime: 60_000,
+ })
+}
+
+/**
+ * Convenience selector — returns true if the actor holds `permission` either
+ * workspace-wide or via any team-scoped grant.
+ *
+ * Falls back to `false` while the underlying query is loading; UI should
+ * skeleton/disable rather than render. Pass `loadingFallback` to override.
+ */
+export function useHasPermission(
+ permission: PermissionKey,
+ options: { teamId?: TeamId | null; loadingFallback?: boolean } = {}
+): boolean {
+ const { data, isLoading } = useMyPermissions()
+ if (isLoading) return options.loadingFallback ?? false
+ if (!data) return false
+ if (data.workspacePermissions.includes(permission)) return true
+ if (options.teamId) {
+ const team = data.teamPermissions.find((t) => t.teamId === options.teamId)
+ return !!team && team.permissions.includes(permission)
+ }
+ // Permission may be granted by any team scope.
+ return data.teamPermissions.some((t) => t.permissions.includes(permission))
+}
diff --git a/apps/web/src/lib/client/hooks/use-inboxes-queries.ts b/apps/web/src/lib/client/hooks/use-inboxes-queries.ts
new file mode 100644
index 000000000..b93a8942f
--- /dev/null
+++ b/apps/web/src/lib/client/hooks/use-inboxes-queries.ts
@@ -0,0 +1,67 @@
+/**
+ * Inboxes query hooks.
+ */
+import { useQuery } from '@tanstack/react-query'
+import type { InboxId } from '@quackback/ids'
+import {
+ listInboxesFn,
+ getInboxFn,
+ listInboxChannelsFn,
+ listInboxMembershipsFn,
+ listMyInboxesFn,
+} from '@/lib/server/functions/inboxes'
+
+export const inboxesKeys = {
+ all: ['inboxes'] as const,
+ lists: () => [...inboxesKeys.all, 'list'] as const,
+ list: (filters: { includeArchived?: boolean }) => [...inboxesKeys.lists(), filters] as const,
+ myList: () => [...inboxesKeys.all, 'mine'] as const,
+ detail: (id: InboxId) => [...inboxesKeys.all, 'detail', id] as const,
+ channels: (id: InboxId) => [...inboxesKeys.all, 'channels', id] as const,
+ memberships: (id: InboxId) => [...inboxesKeys.all, 'memberships', id] as const,
+}
+
+export function useInboxes(opts: { includeArchived?: boolean; enabled?: boolean } = {}) {
+ return useQuery({
+ queryKey: inboxesKeys.list({ includeArchived: opts.includeArchived }),
+ queryFn: () => listInboxesFn({ data: { includeArchived: opts.includeArchived } }),
+ enabled: opts.enabled ?? true,
+ staleTime: 60_000,
+ })
+}
+
+export function useMyInboxes(enabled = true) {
+ return useQuery({
+ queryKey: inboxesKeys.myList(),
+ queryFn: () => listMyInboxesFn(),
+ enabled,
+ staleTime: 60_000,
+ })
+}
+
+export function useInbox(inboxId: InboxId | null | undefined) {
+ return useQuery({
+ queryKey: inboxId ? inboxesKeys.detail(inboxId) : ['inboxes', 'detail', 'none'],
+ queryFn: () => getInboxFn({ data: { inboxId: inboxId! } }),
+ enabled: !!inboxId,
+ staleTime: 60_000,
+ })
+}
+
+export function useInboxChannels(inboxId: InboxId | null | undefined) {
+ return useQuery({
+ queryKey: inboxId ? inboxesKeys.channels(inboxId) : ['inboxes', 'channels', 'none'],
+ queryFn: () => listInboxChannelsFn({ data: { inboxId: inboxId! } }),
+ enabled: !!inboxId,
+ staleTime: 30_000,
+ })
+}
+
+export function useInboxMemberships(inboxId: InboxId | null | undefined) {
+ return useQuery({
+ queryKey: inboxId ? inboxesKeys.memberships(inboxId) : ['inboxes', 'memberships', 'none'],
+ queryFn: () => listInboxMembershipsFn({ data: { inboxId: inboxId! } }),
+ enabled: !!inboxId,
+ staleTime: 30_000,
+ })
+}
diff --git a/apps/web/src/lib/client/hooks/use-notifications-queries.ts b/apps/web/src/lib/client/hooks/use-notifications-queries.ts
index 86e6dccec..961962e50 100644
--- a/apps/web/src/lib/client/hooks/use-notifications-queries.ts
+++ b/apps/web/src/lib/client/hooks/use-notifications-queries.ts
@@ -33,6 +33,7 @@ export interface SerializedNotification {
body: string | null
postId: string | null
commentId: string | null
+ ticketId: string | null
/** Target conversation for chat notifications (from metadata); null otherwise. */
conversationId: string | null
readAt: string | null
diff --git a/apps/web/src/lib/client/hooks/use-orgs-contacts-queries.ts b/apps/web/src/lib/client/hooks/use-orgs-contacts-queries.ts
new file mode 100644
index 000000000..99c2b70a8
--- /dev/null
+++ b/apps/web/src/lib/client/hooks/use-orgs-contacts-queries.ts
@@ -0,0 +1,87 @@
+/**
+ * Organizations + contacts query hooks.
+ */
+import { useQuery } from '@tanstack/react-query'
+import type { OrganizationId, ContactId } from '@quackback/ids'
+import { listOrganizationsFn, getOrganizationFn } from '@/lib/server/functions/organizations'
+import {
+ searchContactsFn,
+ listContactsForOrganizationFn,
+ getContactFn,
+ listLinksForContactFn,
+} from '@/lib/server/functions/contacts'
+
+export const orgsKeys = {
+ all: ['orgs'] as const,
+ lists: () => [...orgsKeys.all, 'list'] as const,
+ list: (filters: { query?: string; includeArchived?: boolean }) =>
+ [...orgsKeys.lists(), filters] as const,
+ detail: (id: OrganizationId) => [...orgsKeys.all, 'detail', id] as const,
+}
+
+export const contactsKeys = {
+ all: ['contacts'] as const,
+ search: (q: string) => [...contactsKeys.all, 'search', q] as const,
+ byOrg: (id: OrganizationId) => [...contactsKeys.all, 'byOrg', id] as const,
+ detail: (id: ContactId) => [...contactsKeys.all, 'detail', id] as const,
+ links: (id: ContactId) => [...contactsKeys.all, 'links', id] as const,
+}
+
+export function useOrganizations(
+ opts: { query?: string; includeArchived?: boolean; enabled?: boolean } = {}
+) {
+ return useQuery({
+ queryKey: orgsKeys.list({ query: opts.query, includeArchived: opts.includeArchived }),
+ queryFn: () =>
+ listOrganizationsFn({
+ data: { search: opts.query, includeArchived: opts.includeArchived },
+ }),
+ enabled: opts.enabled ?? true,
+ staleTime: 30_000,
+ })
+}
+
+export function useOrganization(id: OrganizationId | null | undefined) {
+ return useQuery({
+ queryKey: id ? orgsKeys.detail(id) : ['orgs', 'detail', 'none'],
+ queryFn: () => getOrganizationFn({ data: { organizationId: id! } }),
+ enabled: !!id,
+ staleTime: 60_000,
+ })
+}
+
+export function useContactSearch(query: string, enabled = true) {
+ return useQuery({
+ queryKey: contactsKeys.search(query),
+ queryFn: () => searchContactsFn({ data: { query } }),
+ enabled,
+ staleTime: 30_000,
+ })
+}
+
+export function useContactsForOrganization(orgId: OrganizationId | null | undefined) {
+ return useQuery({
+ queryKey: orgId ? contactsKeys.byOrg(orgId) : ['contacts', 'byOrg', 'none'],
+ queryFn: () => listContactsForOrganizationFn({ data: { organizationId: orgId! } }),
+ enabled: !!orgId,
+ staleTime: 30_000,
+ })
+}
+
+export function useContact(id: ContactId | null | undefined) {
+ return useQuery({
+ queryKey: id ? contactsKeys.detail(id) : ['contacts', 'detail', 'none'],
+ queryFn: () => getContactFn({ data: { contactId: id! } }),
+ enabled: !!id,
+ staleTime: 60_000,
+ })
+}
+
+export function useContactLinks(id: ContactId | null | undefined) {
+ return useQuery({
+ queryKey: id ? contactsKeys.links(id) : ['contacts', 'links', 'none'],
+ queryFn: () => listLinksForContactFn({ data: { contactId: id! } }),
+ enabled: !!id,
+ staleTime: 30_000,
+ })
+}
diff --git a/apps/web/src/lib/client/hooks/use-routing-queries.ts b/apps/web/src/lib/client/hooks/use-routing-queries.ts
new file mode 100644
index 000000000..c37ca2690
--- /dev/null
+++ b/apps/web/src/lib/client/hooks/use-routing-queries.ts
@@ -0,0 +1,37 @@
+/**
+ * Routing rules query hooks.
+ */
+import { useQuery } from '@tanstack/react-query'
+import type { RoutingRuleId, InboxId } from '@quackback/ids'
+import { listRoutingRulesFn, getRoutingRuleFn } from '@/lib/server/functions/routing'
+
+type InboxScope = InboxId | 'workspace' | undefined
+
+export const routingKeys = {
+ all: ['routing'] as const,
+ list: (filters: { inboxIdScope?: InboxScope; enabledOnly?: boolean }) =>
+ [...routingKeys.all, 'list', filters] as const,
+ detail: (id: RoutingRuleId) => [...routingKeys.all, 'detail', id] as const,
+}
+
+export function useRoutingRules(
+ filters: { inboxIdScope?: InboxScope; enabledOnly?: boolean } = {}
+) {
+ return useQuery({
+ queryKey: routingKeys.list(filters),
+ queryFn: () =>
+ listRoutingRulesFn({
+ data: { inboxIdScope: filters.inboxIdScope, enabledOnly: filters.enabledOnly },
+ }),
+ staleTime: 30_000,
+ })
+}
+
+export function useRoutingRule(id: RoutingRuleId | null | undefined) {
+ return useQuery({
+ queryKey: id ? routingKeys.detail(id) : ['routing', 'detail', 'none'],
+ queryFn: () => getRoutingRuleFn({ data: { ruleId: id! } }),
+ enabled: !!id,
+ staleTime: 60_000,
+ })
+}
diff --git a/apps/web/src/lib/client/hooks/use-sla-queries.ts b/apps/web/src/lib/client/hooks/use-sla-queries.ts
new file mode 100644
index 000000000..c08dbb6c0
--- /dev/null
+++ b/apps/web/src/lib/client/hooks/use-sla-queries.ts
@@ -0,0 +1,96 @@
+/**
+ * SLA + business-hours + escalations + ticket-clocks query hooks.
+ */
+import { useQuery } from '@tanstack/react-query'
+import type { SlaPolicyId, BusinessHoursId, TicketId } from '@quackback/ids'
+import {
+ listBusinessHoursFn,
+ getBusinessHoursFn,
+ listSlaPoliciesFn,
+ getSlaPolicyFn,
+ listEscalationRulesFn,
+ getTicketSlaClocksFn,
+ listBreachingClocksFn,
+} from '@/lib/server/functions/sla'
+
+export const slaKeys = {
+ all: ['sla'] as const,
+ policies: (includeArchived?: boolean) =>
+ [...slaKeys.all, 'policies', { includeArchived }] as const,
+ policy: (id: SlaPolicyId) => [...slaKeys.all, 'policy', id] as const,
+ escalations: (id: SlaPolicyId) => [...slaKeys.all, 'escalations', id] as const,
+ ticketClocks: (ticketId: TicketId) => [...slaKeys.all, 'ticketClocks', ticketId] as const,
+ breaching: () => [...slaKeys.all, 'breaching'] as const,
+}
+
+export const businessHoursKeys = {
+ all: ['businessHours'] as const,
+ list: (includeArchived?: boolean) =>
+ [...businessHoursKeys.all, 'list', { includeArchived }] as const,
+ detail: (id: BusinessHoursId) => [...businessHoursKeys.all, 'detail', id] as const,
+}
+
+export function useSlaPolicies(opts: { includeArchived?: boolean; enabled?: boolean } = {}) {
+ return useQuery({
+ queryKey: slaKeys.policies(opts.includeArchived),
+ queryFn: () => listSlaPoliciesFn({ data: { includeArchived: opts.includeArchived } }),
+ enabled: opts.enabled ?? true,
+ staleTime: 60_000,
+ })
+}
+
+export function useSlaPolicy(id: SlaPolicyId | null | undefined) {
+ return useQuery({
+ queryKey: id ? slaKeys.policy(id) : ['sla', 'policy', 'none'],
+ queryFn: () => getSlaPolicyFn({ data: { id: id! } }),
+ enabled: !!id,
+ staleTime: 60_000,
+ })
+}
+
+export function useEscalationRules(policyId: SlaPolicyId | null | undefined) {
+ return useQuery({
+ queryKey: policyId ? slaKeys.escalations(policyId) : ['sla', 'escalations', 'none'],
+ queryFn: () => listEscalationRulesFn({ data: { policyId: policyId! } }),
+ enabled: !!policyId,
+ staleTime: 30_000,
+ })
+}
+
+export function useTicketSlaClocks(ticketId: TicketId | null | undefined) {
+ return useQuery({
+ queryKey: ticketId ? slaKeys.ticketClocks(ticketId) : ['sla', 'ticketClocks', 'none'],
+ queryFn: () => getTicketSlaClocksFn({ data: { ticketId: ticketId! } }),
+ enabled: !!ticketId,
+ staleTime: 15_000,
+ refetchInterval: 30_000,
+ })
+}
+
+export function useBreachingClocks(enabled = true) {
+ return useQuery({
+ queryKey: slaKeys.breaching(),
+ queryFn: () => listBreachingClocksFn({ data: {} }),
+ enabled,
+ staleTime: 30_000,
+ refetchInterval: 60_000,
+ })
+}
+
+export function useBusinessHoursList(opts: { includeArchived?: boolean; enabled?: boolean } = {}) {
+ return useQuery({
+ queryKey: businessHoursKeys.list(opts.includeArchived),
+ queryFn: () => listBusinessHoursFn({ data: { includeArchived: opts.includeArchived } }),
+ enabled: opts.enabled ?? true,
+ staleTime: 60_000,
+ })
+}
+
+export function useBusinessHours(id: BusinessHoursId | null | undefined) {
+ return useQuery({
+ queryKey: id ? businessHoursKeys.detail(id) : ['businessHours', 'detail', 'none'],
+ queryFn: () => getBusinessHoursFn({ data: { id: id! } }),
+ enabled: !!id,
+ staleTime: 60_000,
+ })
+}
diff --git a/apps/web/src/lib/client/hooks/use-teams-queries.ts b/apps/web/src/lib/client/hooks/use-teams-queries.ts
new file mode 100644
index 000000000..7b3afba07
--- /dev/null
+++ b/apps/web/src/lib/client/hooks/use-teams-queries.ts
@@ -0,0 +1,41 @@
+/**
+ * Teams query hooks — `useTeams()`, `useTeam()`, `useTeamMembers()`.
+ */
+import { useQuery } from '@tanstack/react-query'
+import type { TeamId } from '@quackback/ids'
+import { listTeamsFn, getTeamFn, listTeamMembersFn } from '@/lib/server/functions/teams'
+
+export const teamsKeys = {
+ all: ['teams'] as const,
+ lists: () => [...teamsKeys.all, 'list'] as const,
+ list: (filters: { includeArchived?: boolean }) => [...teamsKeys.lists(), filters] as const,
+ detail: (id: TeamId) => [...teamsKeys.all, 'detail', id] as const,
+ members: (id: TeamId) => [...teamsKeys.all, 'members', id] as const,
+}
+
+export function useTeams(opts: { includeArchived?: boolean; enabled?: boolean } = {}) {
+ return useQuery({
+ queryKey: teamsKeys.list({ includeArchived: opts.includeArchived }),
+ queryFn: () => listTeamsFn({ data: { includeArchived: opts.includeArchived } }),
+ enabled: opts.enabled ?? true,
+ staleTime: 60_000,
+ })
+}
+
+export function useTeam(teamId: TeamId | null | undefined) {
+ return useQuery({
+ queryKey: teamId ? teamsKeys.detail(teamId) : ['teams', 'detail', 'none'],
+ queryFn: () => getTeamFn({ data: { teamId: teamId! } }),
+ enabled: !!teamId,
+ staleTime: 60_000,
+ })
+}
+
+export function useTeamMembers(teamId: TeamId | null | undefined) {
+ return useQuery({
+ queryKey: teamId ? teamsKeys.members(teamId) : ['teams', 'members', 'none'],
+ queryFn: () => listTeamMembersFn({ data: { teamId: teamId! } }),
+ enabled: !!teamId,
+ staleTime: 30_000,
+ })
+}
diff --git a/apps/web/src/lib/client/hooks/use-ticket-create-with-attachments.ts b/apps/web/src/lib/client/hooks/use-ticket-create-with-attachments.ts
new file mode 100644
index 000000000..fe6763e15
--- /dev/null
+++ b/apps/web/src/lib/client/hooks/use-ticket-create-with-attachments.ts
@@ -0,0 +1,172 @@
+/**
+ * Shared client hook for ticket creation with file attachments.
+ *
+ * Orchestrates a two-step process:
+ * 1. Create the ticket via the provided mutation
+ * 2. If files are selected, create an initial thread and upload them
+ *
+ * Handles partial failures gracefully: if ticket creation succeeds but file
+ * uploads fail, the ticket is preserved and the user sees per-file errors
+ * with retry options.
+ */
+
+import { useState, useCallback } from 'react'
+import type { TicketId, TicketThreadId } from '@quackback/ids'
+
+export interface FileToAttach {
+ file: File
+ id: string // unique client-side ID for tracking
+}
+
+export interface AttachmentUploadResult {
+ id: string // matches FileToAttach.id
+ success: boolean
+ error?: string
+}
+
+export interface TicketCreateWithAttachmentsState {
+ isUploading: boolean
+ uploadProgress: Record // by FileToAttach.id
+ uploadErrors: Record // by FileToAttach.id
+ successCount: number
+ failureCount: number
+ isDone: boolean
+}
+
+interface TicketCreateOptions {
+ /**
+ * Mutation function that creates the ticket.
+ * Should return { id: TicketId, ... }
+ */
+ createFn: () => Promise
+ /**
+ * Function to create the initial thread for the ticket.
+ * Called with (ticketId) and should return { id: TicketThreadId }
+ */
+ createThreadFn: (ticketId: TicketId) => Promise<{ id: TicketThreadId }>
+ /**
+ * Function to upload a single file to a thread.
+ * Called with (ticketId, threadId, file) and should return { success: boolean, error?: string }
+ */
+ uploadFileFn: (
+ ticketId: TicketId,
+ threadId: TicketThreadId,
+ file: File
+ ) => Promise<{ success: boolean; error?: string }>
+ /**
+ * Files to attach after ticket creation.
+ */
+ files: FileToAttach[]
+ /**
+ * Optional callback on each file upload completion.
+ */
+ onFileUploadComplete?: (result: AttachmentUploadResult) => void
+}
+
+export function useTicketCreateWithAttachments({
+ createFn,
+ createThreadFn,
+ uploadFileFn,
+ files,
+ onFileUploadComplete,
+}: TicketCreateOptions) {
+ const [state, setState] = useState({
+ isUploading: false,
+ uploadProgress: {},
+ uploadErrors: {},
+ successCount: 0,
+ failureCount: 0,
+ isDone: false,
+ })
+
+ const execute = useCallback(async (): Promise => {
+ try {
+ setState((prev) => ({
+ ...prev,
+ isUploading: true,
+ uploadErrors: {},
+ uploadProgress: {},
+ successCount: 0,
+ failureCount: 0,
+ isDone: false,
+ }))
+
+ // Step 1: Create the ticket
+ const ticket = await createFn()
+
+ // If no files, we're done
+ if (files.length === 0) {
+ setState((prev) => ({
+ ...prev,
+ isUploading: false,
+ isDone: true,
+ }))
+ return ticket
+ }
+
+ // Step 2: Ensure there's an initial thread
+ const { id: threadId } = await createThreadFn(ticket.id)
+
+ // Step 3: Upload files in parallel with progress tracking
+ const uploadPromises = files.map(async ({ file, id: fileId }) => {
+ try {
+ const result = await uploadFileFn(ticket.id, threadId, file)
+ const uploadResult: AttachmentUploadResult = {
+ id: fileId,
+ success: result.success,
+ error: result.error,
+ }
+ setState((prev) => ({
+ ...prev,
+ successCount: result.success ? prev.successCount + 1 : prev.successCount,
+ failureCount: !result.success ? prev.failureCount + 1 : prev.failureCount,
+ uploadErrors: result.error
+ ? { ...prev.uploadErrors, [fileId]: result.error }
+ : { ...prev.uploadErrors },
+ }))
+ onFileUploadComplete?.(uploadResult)
+ return uploadResult
+ } catch (err) {
+ const error = err instanceof Error ? err.message : 'Unknown error'
+ const uploadResult: AttachmentUploadResult = {
+ id: fileId,
+ success: false,
+ error,
+ }
+ setState((prev) => ({
+ ...prev,
+ failureCount: prev.failureCount + 1,
+ uploadErrors: { ...prev.uploadErrors, [fileId]: error },
+ }))
+ onFileUploadComplete?.(uploadResult)
+ return uploadResult
+ }
+ })
+
+ await Promise.all(uploadPromises)
+
+ setState((prev) => ({
+ ...prev,
+ isUploading: false,
+ isDone: true,
+ }))
+
+ return ticket
+ } catch (err) {
+ const error = err instanceof Error ? err.message : 'Failed to create ticket'
+ setState((prev) => ({
+ ...prev,
+ isUploading: false,
+ isDone: true,
+ uploadErrors: { general: error },
+ failureCount: prev.failureCount + 1,
+ }))
+ throw err
+ }
+ }, [createFn, createThreadFn, uploadFileFn, files, onFileUploadComplete])
+
+ return {
+ state,
+ execute,
+ }
+}
diff --git a/apps/web/src/lib/client/hooks/use-ticket-subscriptions-queries.ts b/apps/web/src/lib/client/hooks/use-ticket-subscriptions-queries.ts
new file mode 100644
index 000000000..9052db614
--- /dev/null
+++ b/apps/web/src/lib/client/hooks/use-ticket-subscriptions-queries.ts
@@ -0,0 +1,24 @@
+/**
+ * Ticket subscription query hooks (per-ticket subscriber list).
+ * The current actor's subscription state is derived from `listTicketSubscriptionsFn`
+ * by filtering on `principalId` client-side until a one-row read fn is added.
+ */
+import { useQuery } from '@tanstack/react-query'
+import type { TicketId } from '@quackback/ids'
+import { listTicketSubscriptionsFn } from '@/lib/server/functions/notifications'
+
+export const ticketSubscriptionsKeys = {
+ all: ['ticketSubscriptions'] as const,
+ forTicket: (ticketId: TicketId) => [...ticketSubscriptionsKeys.all, ticketId] as const,
+}
+
+export function useTicketSubscriptions(ticketId: TicketId | null | undefined) {
+ return useQuery({
+ queryKey: ticketId
+ ? ticketSubscriptionsKeys.forTicket(ticketId)
+ : ['ticketSubscriptions', 'none'],
+ queryFn: () => listTicketSubscriptionsFn({ data: { ticketId: ticketId! } }),
+ enabled: !!ticketId,
+ staleTime: 30_000,
+ })
+}
diff --git a/apps/web/src/lib/client/hooks/use-tickets-queries.ts b/apps/web/src/lib/client/hooks/use-tickets-queries.ts
new file mode 100644
index 000000000..005ef4c1a
--- /dev/null
+++ b/apps/web/src/lib/client/hooks/use-tickets-queries.ts
@@ -0,0 +1,103 @@
+/**
+ * Tickets query hooks. Mutations live in the corresponding components and
+ * call the server-fns directly with `useMutation` to keep payload typing tight.
+ */
+import { useQuery } from '@tanstack/react-query'
+import type { TicketId } from '@quackback/ids'
+import {
+ listTicketsFn,
+ getTicketFn,
+ listThreadsFn,
+ listSharesFn,
+ listParticipantsFn,
+ listTicketStatusesFn,
+} from '@/lib/server/functions/tickets'
+
+export const ticketsKeys = {
+ all: ['tickets'] as const,
+ lists: () => [...ticketsKeys.all, 'list'] as const,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ list: (filters: Record) => [...ticketsKeys.lists(), filters] as const,
+ detail: (id: TicketId) => [...ticketsKeys.all, 'detail', id] as const,
+ threads: (id: TicketId) => [...ticketsKeys.all, 'threads', id] as const,
+ shares: (id: TicketId) => [...ticketsKeys.all, 'shares', id] as const,
+ participants: (id: TicketId) => [...ticketsKeys.all, 'participants', id] as const,
+ statuses: () => [...ticketsKeys.all, 'statuses'] as const,
+}
+
+export type TicketScope =
+ | 'all'
+ | 'my_assigned'
+ | 'my_team'
+ | 'shared_with_me'
+ | 'unassigned'
+ | 'my_inbox'
+ | 'inbox'
+
+export interface TicketListFilters {
+ scope: TicketScope
+ statusCategory?: 'open' | 'pending' | 'on_hold' | 'solved' | 'closed'
+ search?: string
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ [k: string]: any
+}
+
+export function useTickets(
+ filters: TicketListFilters,
+ options: { enabled?: boolean; refetchInterval?: number } = {}
+) {
+ return useQuery({
+ queryKey: ticketsKeys.list(filters),
+ queryFn: () => listTicketsFn({ data: filters }),
+ enabled: options.enabled ?? true,
+ staleTime: 10_000,
+ refetchInterval: options.refetchInterval ?? 15_000,
+ })
+}
+
+export function useTicket(ticketId: TicketId | null | undefined) {
+ return useQuery({
+ queryKey: ticketId ? ticketsKeys.detail(ticketId) : ['tickets', 'detail', 'none'],
+ queryFn: () => getTicketFn({ data: { ticketId: ticketId! } }),
+ enabled: !!ticketId,
+ staleTime: 10_000,
+ refetchInterval: 15_000,
+ })
+}
+
+export function useTicketThreads(ticketId: TicketId | null | undefined) {
+ return useQuery({
+ queryKey: ticketId ? ticketsKeys.threads(ticketId) : ['tickets', 'threads', 'none'],
+ queryFn: () => listThreadsFn({ data: { ticketId: ticketId! } }),
+ enabled: !!ticketId,
+ staleTime: 10_000,
+ refetchInterval: 15_000,
+ })
+}
+
+export function useTicketShares(ticketId: TicketId | null | undefined) {
+ return useQuery({
+ queryKey: ticketId ? ticketsKeys.shares(ticketId) : ['tickets', 'shares', 'none'],
+ queryFn: () => listSharesFn({ data: { ticketId: ticketId! } }),
+ enabled: !!ticketId,
+ staleTime: 30_000,
+ })
+}
+
+export function useTicketParticipants(ticketId: TicketId | null | undefined) {
+ return useQuery({
+ queryKey: ticketId ? ticketsKeys.participants(ticketId) : ['tickets', 'participants', 'none'],
+ queryFn: () => listParticipantsFn({ data: { ticketId: ticketId! } }),
+ enabled: !!ticketId,
+ staleTime: 30_000,
+ })
+}
+
+export function useTicketStatuses(enabled = true) {
+ return useQuery({
+ queryKey: ticketsKeys.statuses(),
+ queryFn: () => listTicketStatusesFn(),
+ enabled,
+ staleTime: 5 * 60_000,
+ })
+}
diff --git a/apps/web/src/lib/client/hooks/use-webhook-deliveries-queries.ts b/apps/web/src/lib/client/hooks/use-webhook-deliveries-queries.ts
new file mode 100644
index 000000000..c0deefb9d
--- /dev/null
+++ b/apps/web/src/lib/client/hooks/use-webhook-deliveries-queries.ts
@@ -0,0 +1,40 @@
+/**
+ * Webhook deliveries query hook (admin-only inspector).
+ */
+import { useInfiniteQuery } from '@tanstack/react-query'
+import type { WebhookId } from '@quackback/ids'
+import { listWebhookDeliveriesFn } from '@/lib/server/functions/webhook-deliveries'
+
+type DeliveryStatus = 'queued' | 'success' | 'failed_retryable' | 'failed_terminal' | 'blocked_ssrf'
+
+export const webhookDeliveriesKeys = {
+ all: ['webhookDeliveries'] as const,
+ list: (webhookId: WebhookId, status?: DeliveryStatus) =>
+ [...webhookDeliveriesKeys.all, webhookId, status ?? 'all'] as const,
+}
+
+export function useWebhookDeliveries(
+ webhookId: WebhookId | null | undefined,
+ options: { status?: DeliveryStatus; enabled?: boolean } = {}
+) {
+ return useInfiniteQuery({
+ queryKey: webhookId
+ ? webhookDeliveriesKeys.list(webhookId, options.status)
+ : ['webhookDeliveries', 'none'],
+ initialPageParam: null as { cursorAttemptedAt: string; cursorId: string } | null,
+ queryFn: ({ pageParam }) =>
+ listWebhookDeliveriesFn({
+ data: {
+ webhookId: webhookId!,
+ status: options.status,
+ cursorAttemptedAt: pageParam?.cursorAttemptedAt,
+ cursorId: pageParam?.cursorId,
+ limit: 50,
+ },
+ }),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ getNextPageParam: (last: any) => last?.nextCursor ?? undefined,
+ enabled: !!webhookId && (options.enabled ?? true),
+ staleTime: 15_000,
+ })
+}
diff --git a/apps/web/src/lib/client/queries/__tests__/contacts.diffcov.test.ts b/apps/web/src/lib/client/queries/__tests__/contacts.diffcov.test.ts
new file mode 100644
index 000000000..48c9590f3
--- /dev/null
+++ b/apps/web/src/lib/client/queries/__tests__/contacts.diffcov.test.ts
@@ -0,0 +1,150 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { ContactId, OrganizationId } from '@quackback/ids'
+
+const mocks = vi.hoisted(() => ({
+ searchContactsFn: vi.fn(),
+ listContactsForOrganizationFn: vi.fn(),
+ getContactFn: vi.fn(),
+ listLinksForContactFn: vi.fn(),
+}))
+
+vi.mock('@/lib/server/functions/contacts', () => ({
+ searchContactsFn: (input: unknown) => mocks.searchContactsFn(input),
+ listContactsForOrganizationFn: (input: unknown) => mocks.listContactsForOrganizationFn(input),
+ getContactFn: (input: unknown) => mocks.getContactFn(input),
+ listLinksForContactFn: (input: unknown) => mocks.listLinksForContactFn(input),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ queryOptions: (options: unknown) => options,
+}))
+
+import { contactQueries } from '../contacts'
+
+const orgId = 'organization_1' as OrganizationId
+const contactId = 'contact_1' as ContactId
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('contactQueries.all', () => {
+ it('exposes the root key', () => {
+ expect(contactQueries.all).toEqual(['contacts'])
+ })
+})
+
+describe('contactQueries.search', () => {
+ it('defaults to an empty filter object and omits blank query/email', async () => {
+ const options = contactQueries.search()
+ expect(options.queryKey).toEqual(['contacts', 'search', {}])
+ expect(options.staleTime).toBe(30_000)
+
+ mocks.searchContactsFn.mockResolvedValueOnce([])
+ await options.queryFn!({} as never)
+
+ expect(mocks.searchContactsFn).toHaveBeenCalledWith({
+ data: {
+ query: undefined,
+ email: undefined,
+ organizationId: undefined,
+ includeArchived: undefined,
+ limit: 100,
+ },
+ })
+ })
+
+ it('trims and forwards a populated query, email and organization filter', async () => {
+ const filters = {
+ query: ' alice ',
+ email: ' a@b.com ',
+ organizationId: orgId,
+ includeArchived: true,
+ }
+ const options = contactQueries.search(filters)
+ expect(options.queryKey).toEqual(['contacts', 'search', filters])
+
+ mocks.searchContactsFn.mockResolvedValueOnce([{ id: contactId }])
+ await options.queryFn!({} as never)
+
+ expect(mocks.searchContactsFn).toHaveBeenCalledWith({
+ data: {
+ query: 'alice',
+ email: 'a@b.com',
+ organizationId: orgId,
+ includeArchived: true,
+ limit: 100,
+ },
+ })
+ })
+
+ it('collapses whitespace-only query/email to undefined', async () => {
+ const options = contactQueries.search({ query: ' ', email: ' ' })
+
+ mocks.searchContactsFn.mockResolvedValueOnce([])
+ await options.queryFn!({} as never)
+
+ expect(mocks.searchContactsFn).toHaveBeenCalledWith({
+ data: {
+ query: undefined,
+ email: undefined,
+ organizationId: undefined,
+ includeArchived: undefined,
+ limit: 100,
+ },
+ })
+ })
+})
+
+describe('contactQueries.byOrg', () => {
+ it('builds the org-scoped query with default filters', async () => {
+ const options = contactQueries.byOrg(orgId)
+ expect(options.queryKey).toEqual(['contacts', 'byOrg', orgId, {}])
+ expect(options.staleTime).toBe(30_000)
+
+ mocks.listContactsForOrganizationFn.mockResolvedValueOnce([])
+ await options.queryFn!({} as never)
+
+ expect(mocks.listContactsForOrganizationFn).toHaveBeenCalledWith({
+ data: { organizationId: orgId, includeArchived: undefined, limit: 200 },
+ })
+ })
+
+ it('forwards includeArchived when provided', async () => {
+ const options = contactQueries.byOrg(orgId, { includeArchived: true })
+ expect(options.queryKey).toEqual(['contacts', 'byOrg', orgId, { includeArchived: true }])
+
+ mocks.listContactsForOrganizationFn.mockResolvedValueOnce([])
+ await options.queryFn!({} as never)
+
+ expect(mocks.listContactsForOrganizationFn).toHaveBeenCalledWith({
+ data: { organizationId: orgId, includeArchived: true, limit: 200 },
+ })
+ })
+})
+
+describe('contactQueries.detail', () => {
+ it('builds the detail query and calls getContactFn', async () => {
+ const options = contactQueries.detail(contactId)
+ expect(options.queryKey).toEqual(['contacts', 'detail', contactId])
+ expect(options.staleTime).toBe(30_000)
+
+ mocks.getContactFn.mockResolvedValueOnce({ id: contactId })
+ await options.queryFn!({} as never)
+
+ expect(mocks.getContactFn).toHaveBeenCalledWith({ data: { contactId } })
+ })
+})
+
+describe('contactQueries.links', () => {
+ it('builds the links query and calls listLinksForContactFn', async () => {
+ const options = contactQueries.links(contactId)
+ expect(options.queryKey).toEqual(['contacts', 'links', contactId])
+ expect(options.staleTime).toBe(30_000)
+
+ mocks.listLinksForContactFn.mockResolvedValueOnce([])
+ await options.queryFn!({} as never)
+
+ expect(mocks.listLinksForContactFn).toHaveBeenCalledWith({ data: { contactId } })
+ })
+})
diff --git a/apps/web/src/lib/client/queries/__tests__/inboxes.diffcov.test.ts b/apps/web/src/lib/client/queries/__tests__/inboxes.diffcov.test.ts
new file mode 100644
index 000000000..ef199f1d8
--- /dev/null
+++ b/apps/web/src/lib/client/queries/__tests__/inboxes.diffcov.test.ts
@@ -0,0 +1,87 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { InboxId } from '@quackback/ids'
+
+const mocks = vi.hoisted(() => ({
+ listInboxesFn: vi.fn(),
+ getInboxFn: vi.fn(),
+ listInboxChannelsFn: vi.fn(),
+ listInboxMembershipsFn: vi.fn(),
+}))
+
+vi.mock('@/lib/server/functions/inboxes', () => ({
+ listInboxesFn: (input: unknown) => mocks.listInboxesFn(input),
+ getInboxFn: (input: unknown) => mocks.getInboxFn(input),
+ listInboxChannelsFn: (input: unknown) => mocks.listInboxChannelsFn(input),
+ listInboxMembershipsFn: (input: unknown) => mocks.listInboxMembershipsFn(input),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ queryOptions: (options: unknown) => options,
+}))
+
+import { inboxQueries } from '../inboxes'
+
+const inboxId = 'inbox_1' as InboxId
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('inboxQueries.list', () => {
+ it('defaults to an empty params object and forwards it', async () => {
+ const options = inboxQueries.list()
+ expect(options.queryKey).toEqual(['inboxes', 'list', {}])
+ expect(options.staleTime).toBe(30_000)
+
+ mocks.listInboxesFn.mockResolvedValueOnce([])
+ await options.queryFn!({} as never)
+
+ expect(mocks.listInboxesFn).toHaveBeenCalledWith({ data: {} })
+ })
+
+ it('forwards includeArchived when provided', async () => {
+ const options = inboxQueries.list({ includeArchived: true })
+ expect(options.queryKey).toEqual(['inboxes', 'list', { includeArchived: true }])
+
+ mocks.listInboxesFn.mockResolvedValueOnce([])
+ await options.queryFn!({} as never)
+
+ expect(mocks.listInboxesFn).toHaveBeenCalledWith({ data: { includeArchived: true } })
+ })
+})
+
+describe('inboxQueries.detail', () => {
+ it('builds the detail query and calls getInboxFn', async () => {
+ const options = inboxQueries.detail(inboxId)
+ expect(options.queryKey).toEqual(['inboxes', 'detail', inboxId])
+
+ mocks.getInboxFn.mockResolvedValueOnce({ id: inboxId })
+ await options.queryFn!({} as never)
+
+ expect(mocks.getInboxFn).toHaveBeenCalledWith({ data: { inboxId } })
+ })
+})
+
+describe('inboxQueries.channels', () => {
+ it('builds the channels query and calls listInboxChannelsFn', async () => {
+ const options = inboxQueries.channels(inboxId)
+ expect(options.queryKey).toEqual(['inboxes', 'channels', inboxId])
+
+ mocks.listInboxChannelsFn.mockResolvedValueOnce([])
+ await options.queryFn!({} as never)
+
+ expect(mocks.listInboxChannelsFn).toHaveBeenCalledWith({ data: { inboxId } })
+ })
+})
+
+describe('inboxQueries.memberships', () => {
+ it('builds the memberships query and calls listInboxMembershipsFn', async () => {
+ const options = inboxQueries.memberships(inboxId)
+ expect(options.queryKey).toEqual(['inboxes', 'memberships', inboxId])
+
+ mocks.listInboxMembershipsFn.mockResolvedValueOnce([])
+ await options.queryFn!({} as never)
+
+ expect(mocks.listInboxMembershipsFn).toHaveBeenCalledWith({ data: { inboxId } })
+ })
+})
diff --git a/apps/web/src/lib/client/queries/__tests__/organizations.diffcov.test.ts b/apps/web/src/lib/client/queries/__tests__/organizations.diffcov.test.ts
new file mode 100644
index 000000000..60e421704
--- /dev/null
+++ b/apps/web/src/lib/client/queries/__tests__/organizations.diffcov.test.ts
@@ -0,0 +1,81 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { OrganizationId } from '@quackback/ids'
+
+const mocks = vi.hoisted(() => ({
+ listOrganizationsFn: vi.fn(),
+ getOrganizationFn: vi.fn(),
+}))
+
+vi.mock('@/lib/server/functions/organizations', () => ({
+ listOrganizationsFn: (input: unknown) => mocks.listOrganizationsFn(input),
+ getOrganizationFn: (input: unknown) => mocks.getOrganizationFn(input),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ queryOptions: (options: unknown) => options,
+}))
+
+import { organizationQueries } from '../organizations'
+
+const organizationId = 'org_1' as OrganizationId
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('organizationQueries.all', () => {
+ it('exposes the root key', () => {
+ expect(organizationQueries.all).toEqual(['organizations'])
+ })
+})
+
+describe('organizationQueries.list', () => {
+ it('defaults to empty filters and omits a blank search', async () => {
+ const options = organizationQueries.list()
+ expect(options.queryKey).toEqual(['organizations', 'list', {}])
+ expect(options.staleTime).toBe(30_000)
+
+ mocks.listOrganizationsFn.mockResolvedValueOnce([])
+ await options.queryFn!({} as never)
+
+ expect(mocks.listOrganizationsFn).toHaveBeenCalledWith({
+ data: { search: undefined, includeArchived: undefined, limit: 200 },
+ })
+ })
+
+ it('collapses a whitespace-only search to undefined', async () => {
+ const options = organizationQueries.list({ search: ' ' })
+
+ mocks.listOrganizationsFn.mockResolvedValueOnce([])
+ await options.queryFn!({} as never)
+
+ expect(mocks.listOrganizationsFn).toHaveBeenCalledWith({
+ data: { search: undefined, includeArchived: undefined, limit: 200 },
+ })
+ })
+
+ it('trims a populated search and forwards includeArchived', async () => {
+ const filters = { search: ' acme ', includeArchived: true }
+ const options = organizationQueries.list(filters)
+ expect(options.queryKey).toEqual(['organizations', 'list', filters])
+
+ mocks.listOrganizationsFn.mockResolvedValueOnce([{ id: organizationId }])
+ await options.queryFn!({} as never)
+
+ expect(mocks.listOrganizationsFn).toHaveBeenCalledWith({
+ data: { search: 'acme', includeArchived: true, limit: 200 },
+ })
+ })
+})
+
+describe('organizationQueries.detail', () => {
+ it('builds the detail query and calls getOrganizationFn', async () => {
+ const options = organizationQueries.detail(organizationId)
+ expect(options.queryKey).toEqual(['organizations', 'detail', organizationId])
+
+ mocks.getOrganizationFn.mockResolvedValueOnce({ id: organizationId })
+ await options.queryFn!({} as never)
+
+ expect(mocks.getOrganizationFn).toHaveBeenCalledWith({ data: { organizationId } })
+ })
+})
diff --git a/apps/web/src/lib/client/queries/__tests__/portal-tickets.diffcov.test.ts b/apps/web/src/lib/client/queries/__tests__/portal-tickets.diffcov.test.ts
new file mode 100644
index 000000000..f761368e2
--- /dev/null
+++ b/apps/web/src/lib/client/queries/__tests__/portal-tickets.diffcov.test.ts
@@ -0,0 +1,269 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { TicketId } from '@quackback/ids'
+
+const mocks = vi.hoisted(() => ({
+ listMyTicketsFn: vi.fn(),
+ getMyTicketFn: vi.fn(),
+ replyToMyTicketFn: vi.fn(),
+ createMyTicketFn: vi.fn(),
+ invalidateQueries: vi.fn(),
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+}))
+
+vi.mock('@/lib/server/functions/portal-tickets', () => ({
+ listMyTicketsFn: (input: unknown) => mocks.listMyTicketsFn(input),
+ getMyTicketFn: (input: unknown) => mocks.getMyTicketFn(input),
+ replyToMyTicketFn: (input: unknown) => mocks.replyToMyTicketFn(input),
+ createMyTicketFn: (input: unknown) => mocks.createMyTicketFn(input),
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: (...args: unknown[]) => mocks.toastSuccess(...args),
+ error: (...args: unknown[]) => mocks.toastError(...args),
+ },
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ queryOptions: (options: unknown) => options,
+ useMutation: (options: unknown) => options,
+ useQueryClient: () => ({ invalidateQueries: mocks.invalidateQueries }),
+}))
+
+import { portalTicketQueries, useReplyToMyTicket, useCreateMyTicket } from '../portal-tickets'
+
+const ticketId = 'ticket_abc' as TicketId
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('portalTicketQueries.list', () => {
+ it('uses the "all" key when no statusCategory is given and maps rows into Date instances', async () => {
+ const options = portalTicketQueries.list()
+ expect(options.queryKey).toEqual(['portal', 'tickets', 'list', 'all'])
+
+ mocks.listMyTicketsFn.mockResolvedValueOnce({
+ rows: [
+ {
+ id: ticketId,
+ subject: 'Need help',
+ statusName: 'Open',
+ statusCategory: 'open',
+ statusColor: '#fff',
+ lastActivityAt: '2026-01-02T00:00:00.000Z',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ },
+ ],
+ total: 1,
+ })
+
+ const result = (await options.queryFn!({} as never)) as {
+ rows: Array<{ lastActivityAt: Date; createdAt: Date; statusColor: string | null }>
+ total: number
+ }
+
+ expect(mocks.listMyTicketsFn).toHaveBeenCalledWith({
+ data: { statusCategory: undefined },
+ })
+ expect(result.total).toBe(1)
+ expect(result.rows[0].lastActivityAt).toBeInstanceOf(Date)
+ expect(result.rows[0].createdAt).toBeInstanceOf(Date)
+ expect(result.rows[0].statusColor).toBe('#fff')
+ })
+
+ it('passes the provided statusCategory through to the key and the server fn', async () => {
+ const options = portalTicketQueries.list({ statusCategory: 'solved' })
+ expect(options.queryKey).toEqual(['portal', 'tickets', 'list', 'solved'])
+
+ mocks.listMyTicketsFn.mockResolvedValueOnce({ rows: [], total: 0 })
+
+ const result = (await options.queryFn!({} as never)) as { rows: unknown[]; total: number }
+
+ expect(mocks.listMyTicketsFn).toHaveBeenCalledWith({
+ data: { statusCategory: 'solved' },
+ })
+ expect(result.rows).toEqual([])
+ expect(result.total).toBe(0)
+ })
+})
+
+describe('portalTicketQueries.detail', () => {
+ it('revives ticket and thread dates, including a non-null editedAt', async () => {
+ const options = portalTicketQueries.detail(ticketId)
+ expect(options.queryKey).toEqual(['portal', 'tickets', 'detail', ticketId])
+
+ mocks.getMyTicketFn.mockResolvedValueOnce({
+ ticket: {
+ id: ticketId,
+ subject: 'Hello',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ lastActivityAt: '2026-01-03T00:00:00.000Z',
+ },
+ threads: [
+ {
+ id: 'thread_1',
+ createdAt: '2026-01-02T00:00:00.000Z',
+ editedAt: '2026-01-02T01:00:00.000Z',
+ },
+ ],
+ principalNames: { p1: 'Alice' },
+ viewerPrincipalId: 'p1',
+ viewerRelationship: 'requester',
+ })
+
+ const result = (await options.queryFn!({} as never)) as {
+ ticket: { createdAt: Date; lastActivityAt: Date }
+ threads: Array<{ ticketId: TicketId; createdAt: Date; editedAt: Date | null }>
+ principalNames: Record
+ viewerPrincipalId: string
+ viewerRelationship: string
+ }
+
+ expect(mocks.getMyTicketFn).toHaveBeenCalledWith({ data: { ticketId } })
+ expect(result.ticket.createdAt).toBeInstanceOf(Date)
+ expect(result.ticket.lastActivityAt).toBeInstanceOf(Date)
+ expect(result.threads[0].ticketId).toBe(ticketId)
+ expect(result.threads[0].createdAt).toBeInstanceOf(Date)
+ expect(result.threads[0].editedAt).toBeInstanceOf(Date)
+ expect(result.principalNames).toEqual({ p1: 'Alice' })
+ expect(result.viewerPrincipalId).toBe('p1')
+ expect(result.viewerRelationship).toBe('requester')
+ })
+
+ it('keeps editedAt null when the thread was never edited', async () => {
+ const options = portalTicketQueries.detail(ticketId)
+
+ mocks.getMyTicketFn.mockResolvedValueOnce({
+ ticket: {
+ id: ticketId,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ lastActivityAt: '2026-01-03T00:00:00.000Z',
+ },
+ threads: [{ id: 'thread_2', createdAt: '2026-01-02T00:00:00.000Z', editedAt: null }],
+ principalNames: {},
+ viewerPrincipalId: 'p1',
+ viewerRelationship: 'requester',
+ })
+
+ const result = (await options.queryFn!({} as never)) as {
+ threads: Array<{ editedAt: Date | null }>
+ }
+
+ expect(result.threads[0].editedAt).toBeNull()
+ })
+})
+
+describe('useReplyToMyTicket', () => {
+ it('forwards normalized payload, invalidates caches and toasts on success', async () => {
+ const mutation = useReplyToMyTicket(ticketId) as unknown as {
+ mutationFn: (input: { bodyJson?: unknown; bodyText?: string | null }) => unknown
+ onSuccess: () => void
+ onError: (e: Error) => void
+ }
+
+ mocks.replyToMyTicketFn.mockResolvedValueOnce({ ok: true })
+ await mutation.mutationFn({ bodyJson: { type: 'doc' }, bodyText: 'hi' })
+ expect(mocks.replyToMyTicketFn).toHaveBeenCalledWith({
+ data: { ticketId, bodyJson: { type: 'doc' }, bodyText: 'hi' },
+ })
+
+ mutation.onSuccess()
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['portal', 'tickets', 'detail', ticketId],
+ })
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['portal', 'tickets', 'list'],
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Reply sent')
+ })
+
+ it('defaults missing body fields to null', async () => {
+ const mutation = useReplyToMyTicket(ticketId) as unknown as {
+ mutationFn: (input: { bodyJson?: unknown; bodyText?: string | null }) => unknown
+ }
+ mocks.replyToMyTicketFn.mockResolvedValueOnce({ ok: true })
+ await mutation.mutationFn({})
+ expect(mocks.replyToMyTicketFn).toHaveBeenCalledWith({
+ data: { ticketId, bodyJson: null, bodyText: null },
+ })
+ })
+
+ it('shows the error message on failure', () => {
+ const mutation = useReplyToMyTicket(ticketId) as unknown as { onError: (e: Error) => void }
+ mutation.onError(new Error('boom'))
+ expect(mocks.toastError).toHaveBeenCalledWith('boom')
+ })
+
+ it('falls back to a generic message when the error has no message', () => {
+ const mutation = useReplyToMyTicket(ticketId) as unknown as { onError: (e: Error) => void }
+ mutation.onError(new Error(''))
+ expect(mocks.toastError).toHaveBeenCalledWith('Failed to send reply')
+ })
+})
+
+describe('useCreateMyTicket', () => {
+ it('forwards normalized payload, invalidates the list and toasts on success', async () => {
+ const mutation = useCreateMyTicket() as unknown as {
+ mutationFn: (input: {
+ subject: string
+ descriptionJson?: unknown
+ descriptionText?: string | null
+ priority?: 'low' | 'normal' | 'high' | 'urgent'
+ }) => unknown
+ onSuccess: () => void
+ onError: (e: Error) => void
+ }
+
+ mocks.createMyTicketFn.mockResolvedValueOnce({ id: ticketId })
+ await mutation.mutationFn({
+ subject: 'Subject',
+ descriptionJson: { type: 'doc' },
+ descriptionText: 'desc',
+ priority: 'high',
+ })
+ expect(mocks.createMyTicketFn).toHaveBeenCalledWith({
+ data: {
+ subject: 'Subject',
+ descriptionJson: { type: 'doc' },
+ descriptionText: 'desc',
+ priority: 'high',
+ },
+ })
+
+ mutation.onSuccess()
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['portal', 'tickets', 'list'],
+ })
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Ticket created')
+ })
+
+ it('defaults missing description fields to null', async () => {
+ const mutation = useCreateMyTicket() as unknown as {
+ mutationFn: (input: { subject: string }) => unknown
+ }
+ mocks.createMyTicketFn.mockResolvedValueOnce({ id: ticketId })
+ await mutation.mutationFn({ subject: 'Only subject' })
+ expect(mocks.createMyTicketFn).toHaveBeenCalledWith({
+ data: {
+ subject: 'Only subject',
+ descriptionJson: null,
+ descriptionText: null,
+ priority: undefined,
+ },
+ })
+ })
+
+ it('shows the error message on failure', () => {
+ const mutation = useCreateMyTicket() as unknown as { onError: (e: Error) => void }
+ mutation.onError(new Error('nope'))
+ expect(mocks.toastError).toHaveBeenCalledWith('nope')
+ })
+
+ it('falls back to a generic message when the error has no message', () => {
+ const mutation = useCreateMyTicket() as unknown as { onError: (e: Error) => void }
+ mutation.onError(new Error(''))
+ expect(mocks.toastError).toHaveBeenCalledWith('Failed to create ticket')
+ })
+})
diff --git a/apps/web/src/lib/client/queries/__tests__/sla.diffcov.test.ts b/apps/web/src/lib/client/queries/__tests__/sla.diffcov.test.ts
new file mode 100644
index 000000000..27a3b4280
--- /dev/null
+++ b/apps/web/src/lib/client/queries/__tests__/sla.diffcov.test.ts
@@ -0,0 +1,73 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { SlaPolicyId } from '@quackback/ids'
+
+const mocks = vi.hoisted(() => ({
+ listSlaPoliciesFn: vi.fn(),
+ getSlaPolicyFn: vi.fn(),
+ listEscalationRulesFn: vi.fn(),
+}))
+
+vi.mock('@/lib/server/functions/sla', () => ({
+ listSlaPoliciesFn: (input: unknown) => mocks.listSlaPoliciesFn(input),
+ getSlaPolicyFn: (input: unknown) => mocks.getSlaPolicyFn(input),
+ listEscalationRulesFn: (input: unknown) => mocks.listEscalationRulesFn(input),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ queryOptions: (options: unknown) => options,
+}))
+
+import { slaQueries } from '../sla'
+
+const policyId = 'sla_pol_1' as SlaPolicyId
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('slaQueries.policies', () => {
+ it('defaults to an empty params object and forwards it', async () => {
+ const options = slaQueries.policies()
+ expect(options.queryKey).toEqual(['sla', 'policies', {}])
+ expect(options.staleTime).toBe(30_000)
+
+ mocks.listSlaPoliciesFn.mockResolvedValueOnce([])
+ await options.queryFn!({} as never)
+
+ expect(mocks.listSlaPoliciesFn).toHaveBeenCalledWith({ data: {} })
+ })
+
+ it('forwards includeArchived when provided', async () => {
+ const options = slaQueries.policies({ includeArchived: true })
+ expect(options.queryKey).toEqual(['sla', 'policies', { includeArchived: true }])
+
+ mocks.listSlaPoliciesFn.mockResolvedValueOnce([])
+ await options.queryFn!({} as never)
+
+ expect(mocks.listSlaPoliciesFn).toHaveBeenCalledWith({ data: { includeArchived: true } })
+ })
+})
+
+describe('slaQueries.policy', () => {
+ it('builds the policy query and calls getSlaPolicyFn', async () => {
+ const options = slaQueries.policy(policyId)
+ expect(options.queryKey).toEqual(['sla', 'policy', policyId])
+
+ mocks.getSlaPolicyFn.mockResolvedValueOnce({ id: policyId })
+ await options.queryFn!({} as never)
+
+ expect(mocks.getSlaPolicyFn).toHaveBeenCalledWith({ data: { id: policyId } })
+ })
+})
+
+describe('slaQueries.escalations', () => {
+ it('builds the escalations query and calls listEscalationRulesFn', async () => {
+ const options = slaQueries.escalations(policyId)
+ expect(options.queryKey).toEqual(['sla', 'escalations', policyId])
+
+ mocks.listEscalationRulesFn.mockResolvedValueOnce([])
+ await options.queryFn!({} as never)
+
+ expect(mocks.listEscalationRulesFn).toHaveBeenCalledWith({ data: { policyId } })
+ })
+})
diff --git a/apps/web/src/lib/client/queries/__tests__/teams.diffcov.test.ts b/apps/web/src/lib/client/queries/__tests__/teams.diffcov.test.ts
new file mode 100644
index 000000000..b069c36c8
--- /dev/null
+++ b/apps/web/src/lib/client/queries/__tests__/teams.diffcov.test.ts
@@ -0,0 +1,79 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { TeamId } from '@quackback/ids'
+
+const mocks = vi.hoisted(() => ({
+ listTeamsFn: vi.fn(),
+ getTeamFn: vi.fn(),
+ listTeamMembersFn: vi.fn(),
+}))
+
+vi.mock('@/lib/server/functions/teams', () => ({
+ listTeamsFn: (input: unknown) => mocks.listTeamsFn(input),
+ getTeamFn: (input: unknown) => mocks.getTeamFn(input),
+ listTeamMembersFn: (input: unknown) => mocks.listTeamMembersFn(input),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ queryOptions: (options: unknown) => options,
+}))
+
+import { teamQueries } from '../teams'
+
+const teamId = 'team_1' as TeamId
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('teamQueries.all', () => {
+ it('exposes the root key', () => {
+ expect(teamQueries.all).toEqual(['teams'])
+ })
+})
+
+describe('teamQueries.list', () => {
+ it('defaults to empty filters and forwards an undefined includeArchived', async () => {
+ const options = teamQueries.list()
+ expect(options.queryKey).toEqual(['teams', 'list', {}])
+ expect(options.staleTime).toBe(30_000)
+
+ mocks.listTeamsFn.mockResolvedValueOnce([])
+ await options.queryFn!({} as never)
+
+ expect(mocks.listTeamsFn).toHaveBeenCalledWith({ data: { includeArchived: undefined } })
+ })
+
+ it('forwards includeArchived when provided', async () => {
+ const options = teamQueries.list({ includeArchived: true })
+ expect(options.queryKey).toEqual(['teams', 'list', { includeArchived: true }])
+
+ mocks.listTeamsFn.mockResolvedValueOnce([])
+ await options.queryFn!({} as never)
+
+ expect(mocks.listTeamsFn).toHaveBeenCalledWith({ data: { includeArchived: true } })
+ })
+})
+
+describe('teamQueries.detail', () => {
+ it('builds the detail query and calls getTeamFn', async () => {
+ const options = teamQueries.detail(teamId)
+ expect(options.queryKey).toEqual(['teams', 'detail', teamId])
+
+ mocks.getTeamFn.mockResolvedValueOnce({ id: teamId })
+ await options.queryFn!({} as never)
+
+ expect(mocks.getTeamFn).toHaveBeenCalledWith({ data: { teamId } })
+ })
+})
+
+describe('teamQueries.members', () => {
+ it('builds the members query and calls listTeamMembersFn', async () => {
+ const options = teamQueries.members(teamId)
+ expect(options.queryKey).toEqual(['teams', 'members', teamId])
+
+ mocks.listTeamMembersFn.mockResolvedValueOnce([])
+ await options.queryFn!({} as never)
+
+ expect(mocks.listTeamMembersFn).toHaveBeenCalledWith({ data: { teamId } })
+ })
+})
diff --git a/apps/web/src/lib/client/queries/__tests__/webhook-deliveries.diffcov.test.ts b/apps/web/src/lib/client/queries/__tests__/webhook-deliveries.diffcov.test.ts
new file mode 100644
index 000000000..6d7762c62
--- /dev/null
+++ b/apps/web/src/lib/client/queries/__tests__/webhook-deliveries.diffcov.test.ts
@@ -0,0 +1,82 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { WebhookId } from '@quackback/ids'
+
+const mocks = vi.hoisted(() => ({
+ listWebhookDeliveriesFn: vi.fn(),
+}))
+
+vi.mock('@/lib/server/functions/webhook-deliveries', () => ({
+ listWebhookDeliveriesFn: (input: unknown) => mocks.listWebhookDeliveriesFn(input),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ infiniteQueryOptions: (options: unknown) => options,
+}))
+
+import { webhookDeliveryQueries } from '../webhook-deliveries'
+
+const webhookId = 'webhook_1' as WebhookId
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('webhookDeliveryQueries.all', () => {
+ it('exposes the root key', () => {
+ expect(webhookDeliveryQueries.all).toEqual(['webhook-deliveries'])
+ })
+})
+
+describe('webhookDeliveryQueries.list', () => {
+ it('defaults to empty filters and forwards an undefined cursor', async () => {
+ const options = webhookDeliveryQueries.list(webhookId)
+ expect(options.queryKey).toEqual(['webhook-deliveries', 'list', webhookId, {}])
+ expect(options.staleTime).toBe(15_000)
+
+ mocks.listWebhookDeliveriesFn.mockResolvedValueOnce({ rows: [], nextCursor: null })
+ await options.queryFn!({ pageParam: undefined } as never)
+
+ expect(mocks.listWebhookDeliveriesFn).toHaveBeenCalledWith({
+ data: {
+ webhookId,
+ limit: 50,
+ status: undefined,
+ cursorAttemptedAt: undefined,
+ cursorId: undefined,
+ },
+ })
+ })
+
+ it('forwards the status filter and a populated cursor', async () => {
+ const filters = { status: 'failed_terminal' as const }
+ const options = webhookDeliveryQueries.list(webhookId, filters)
+ expect(options.queryKey).toEqual(['webhook-deliveries', 'list', webhookId, filters])
+
+ mocks.listWebhookDeliveriesFn.mockResolvedValueOnce({ rows: [], nextCursor: null })
+ await options.queryFn!({
+ pageParam: { cursorAttemptedAt: '2026-01-01T00:00:00.000Z', cursorId: 'd1' },
+ } as never)
+
+ expect(mocks.listWebhookDeliveriesFn).toHaveBeenCalledWith({
+ data: {
+ webhookId,
+ limit: 50,
+ status: 'failed_terminal',
+ cursorAttemptedAt: '2026-01-01T00:00:00.000Z',
+ cursorId: 'd1',
+ },
+ })
+ })
+
+ it('reads the next cursor and falls back to undefined when null', () => {
+ const options = webhookDeliveryQueries.list(webhookId)
+ const cursor = { cursorAttemptedAt: '2026-01-02T00:00:00.000Z', cursorId: 'd2' }
+
+ expect(
+ (options.getNextPageParam as (p: unknown) => unknown)({ nextCursor: cursor } as never)
+ ).toEqual(cursor)
+ expect(
+ (options.getNextPageParam as (p: unknown) => unknown)({ nextCursor: null } as never)
+ ).toBeUndefined()
+ })
+})
diff --git a/apps/web/src/lib/client/queries/audit.ts b/apps/web/src/lib/client/queries/audit.ts
new file mode 100644
index 000000000..61e4e3ec0
--- /dev/null
+++ b/apps/web/src/lib/client/queries/audit.ts
@@ -0,0 +1,85 @@
+/**
+ * Audit log queries — cursor-paged unified event feed + distinct-actions list.
+ */
+import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query'
+import type { PrincipalId } from '@quackback/ids'
+import { listUnifiedAuditEventsFn, getUnifiedAuditActionsFn } from '@/lib/server/functions/audit'
+
+export type AuditSourceFilter = 'web' | 'api' | 'integration' | 'system' | 'mcp'
+export type AuditOriginFilter = 'workspace' | 'security'
+export type AuditTimeRange = '7d' | '30d' | '90d' | 'all' | 'custom'
+
+export interface AuditFilters {
+ origin?: AuditOriginFilter
+ principalId?: PrincipalId | null
+ actorEmail?: string
+ /** Exact match on action/event type. Mutually exclusive with `actionPrefix`. */
+ action?: string
+ /** Prefix match (`like 'foo%'`) on action/event type. */
+ actionPrefix?: string
+ targetType?: string
+ targetId?: string
+ source?: AuditSourceFilter
+ /** ISO datetime — inclusive lower bound. */
+ fromIso?: string
+ /** ISO datetime — inclusive upper bound. */
+ toIso?: string
+}
+
+export const DEFAULT_AUDIT_TIME_RANGE: AuditTimeRange = '30d'
+export const DEFAULT_EXCLUDED_SECURITY_ACTIONS = ['portal.widget_handshake.consumed'] as const
+
+const STALE = 15_000
+const ACTIONS_STALE = 5 * 60_000
+
+export function rangeToFromIso(range: AuditTimeRange): string | undefined {
+ if (range === 'all' || range === 'custom') return undefined
+ const days = range === '7d' ? 7 : range === '30d' ? 30 : 90
+ const minuteMs = 60 * 1000
+ const now = Math.floor(Date.now() / minuteMs) * minuteMs
+ return new Date(now - days * 24 * 60 * 60 * 1000).toISOString()
+}
+
+export function defaultAuditFilters(): AuditFilters {
+ return {
+ fromIso: rangeToFromIso(DEFAULT_AUDIT_TIME_RANGE),
+ }
+}
+
+export const auditQueries = {
+ all: ['audit'] as const,
+ list: (filters: AuditFilters = {}) =>
+ infiniteQueryOptions({
+ queryKey: ['audit', 'list', filters] as const,
+ queryFn: ({ pageParam }) =>
+ listUnifiedAuditEventsFn({
+ data: {
+ origin: filters.origin,
+ principalId: filters.principalId ?? undefined,
+ actorEmail: filters.actorEmail,
+ action: filters.action,
+ actionPrefix: filters.actionPrefix,
+ targetType: filters.targetType,
+ targetId: filters.targetId,
+ source: filters.source,
+ from: filters.fromIso,
+ to: filters.toIso,
+ cursor: (pageParam as string | undefined) ?? undefined,
+ limit: 50,
+ excludeSecurityActions:
+ filters.action || filters.actionPrefix
+ ? undefined
+ : [...DEFAULT_EXCLUDED_SECURITY_ACTIONS],
+ },
+ }),
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (last) => (last as { nextCursor?: string | null }).nextCursor ?? undefined,
+ staleTime: STALE,
+ }),
+ actions: () =>
+ queryOptions({
+ queryKey: ['audit', 'actions'] as const,
+ queryFn: () => getUnifiedAuditActionsFn(),
+ staleTime: ACTIONS_STALE,
+ }),
+}
diff --git a/apps/web/src/lib/client/queries/business-hours.ts b/apps/web/src/lib/client/queries/business-hours.ts
new file mode 100644
index 000000000..8b2ecfffe
--- /dev/null
+++ b/apps/web/src/lib/client/queries/business-hours.ts
@@ -0,0 +1,21 @@
+/**
+ * Query factory for business-hours admin reads.
+ */
+import { queryOptions } from '@tanstack/react-query'
+import type { BusinessHoursId } from '@quackback/ids'
+import { listBusinessHoursFn, getBusinessHoursFn } from '@/lib/server/functions/sla'
+
+export const businessHoursQueries = {
+ list: (params: { includeArchived?: boolean } = {}) =>
+ queryOptions({
+ queryKey: ['business-hours', 'list', params] as const,
+ queryFn: () => listBusinessHoursFn({ data: params }),
+ staleTime: 30_000,
+ }),
+ detail: (id: BusinessHoursId) =>
+ queryOptions({
+ queryKey: ['business-hours', 'detail', id] as const,
+ queryFn: () => getBusinessHoursFn({ data: { id } }),
+ staleTime: 30_000,
+ }),
+}
diff --git a/apps/web/src/lib/client/queries/contacts.ts b/apps/web/src/lib/client/queries/contacts.ts
new file mode 100644
index 000000000..28667656e
--- /dev/null
+++ b/apps/web/src/lib/client/queries/contacts.ts
@@ -0,0 +1,64 @@
+/**
+ * Contact queries — search, byOrg, detail, links.
+ */
+import { queryOptions } from '@tanstack/react-query'
+import type { ContactId, OrganizationId } from '@quackback/ids'
+import {
+ searchContactsFn,
+ listContactsForOrganizationFn,
+ getContactFn,
+ listLinksForContactFn,
+} from '@/lib/server/functions/contacts'
+
+const STALE = 30_000
+
+export const contactQueries = {
+ all: ['contacts'] as const,
+ search: (
+ filters: {
+ query?: string
+ email?: string
+ organizationId?: OrganizationId
+ includeArchived?: boolean
+ } = {}
+ ) =>
+ queryOptions({
+ queryKey: ['contacts', 'search', filters] as const,
+ queryFn: () =>
+ searchContactsFn({
+ data: {
+ query: filters.query?.trim() || undefined,
+ email: filters.email?.trim() || undefined,
+ organizationId: filters.organizationId,
+ includeArchived: filters.includeArchived,
+ limit: 100,
+ },
+ }),
+ staleTime: STALE,
+ }),
+ byOrg: (organizationId: OrganizationId, filters: { includeArchived?: boolean } = {}) =>
+ queryOptions({
+ queryKey: ['contacts', 'byOrg', organizationId, filters] as const,
+ queryFn: () =>
+ listContactsForOrganizationFn({
+ data: {
+ organizationId,
+ includeArchived: filters.includeArchived,
+ limit: 200,
+ },
+ }),
+ staleTime: STALE,
+ }),
+ detail: (contactId: ContactId) =>
+ queryOptions({
+ queryKey: ['contacts', 'detail', contactId] as const,
+ queryFn: () => getContactFn({ data: { contactId } }),
+ staleTime: STALE,
+ }),
+ links: (contactId: ContactId) =>
+ queryOptions({
+ queryKey: ['contacts', 'links', contactId] as const,
+ queryFn: () => listLinksForContactFn({ data: { contactId } }),
+ staleTime: STALE,
+ }),
+}
diff --git a/apps/web/src/lib/client/queries/inboxes.ts b/apps/web/src/lib/client/queries/inboxes.ts
new file mode 100644
index 000000000..ec10e2b01
--- /dev/null
+++ b/apps/web/src/lib/client/queries/inboxes.ts
@@ -0,0 +1,40 @@
+/**
+ * TanStack Query factory for inbox admin reads. Mirrors `ticketQueries`:
+ * route loaders pre-fetch via `ensureQueryData`; components read via
+ * `useSuspenseQuery`.
+ */
+import { queryOptions } from '@tanstack/react-query'
+import type { InboxId } from '@quackback/ids'
+import {
+ listInboxesFn,
+ getInboxFn,
+ listInboxChannelsFn,
+ listInboxMembershipsFn,
+} from '@/lib/server/functions/inboxes'
+
+export const inboxQueries = {
+ list: (params: { includeArchived?: boolean } = {}) =>
+ queryOptions({
+ queryKey: ['inboxes', 'list', params] as const,
+ queryFn: () => listInboxesFn({ data: params }),
+ staleTime: 30_000,
+ }),
+ detail: (inboxId: InboxId) =>
+ queryOptions({
+ queryKey: ['inboxes', 'detail', inboxId] as const,
+ queryFn: () => getInboxFn({ data: { inboxId } }),
+ staleTime: 30_000,
+ }),
+ channels: (inboxId: InboxId) =>
+ queryOptions({
+ queryKey: ['inboxes', 'channels', inboxId] as const,
+ queryFn: () => listInboxChannelsFn({ data: { inboxId } }),
+ staleTime: 30_000,
+ }),
+ memberships: (inboxId: InboxId) =>
+ queryOptions({
+ queryKey: ['inboxes', 'memberships', inboxId] as const,
+ queryFn: () => listInboxMembershipsFn({ data: { inboxId } }),
+ staleTime: 30_000,
+ }),
+}
diff --git a/apps/web/src/lib/client/queries/organizations.ts b/apps/web/src/lib/client/queries/organizations.ts
new file mode 100644
index 000000000..c0ed22181
--- /dev/null
+++ b/apps/web/src/lib/client/queries/organizations.ts
@@ -0,0 +1,31 @@
+/**
+ * Organization queries — list (with search) + detail.
+ */
+import { queryOptions } from '@tanstack/react-query'
+import type { OrganizationId } from '@quackback/ids'
+import { listOrganizationsFn, getOrganizationFn } from '@/lib/server/functions/organizations'
+
+const STALE = 30_000
+
+export const organizationQueries = {
+ all: ['organizations'] as const,
+ list: (filters: { search?: string; includeArchived?: boolean } = {}) =>
+ queryOptions({
+ queryKey: ['organizations', 'list', filters] as const,
+ queryFn: () =>
+ listOrganizationsFn({
+ data: {
+ search: filters.search?.trim() || undefined,
+ includeArchived: filters.includeArchived,
+ limit: 200,
+ },
+ }),
+ staleTime: STALE,
+ }),
+ detail: (organizationId: OrganizationId) =>
+ queryOptions({
+ queryKey: ['organizations', 'detail', organizationId] as const,
+ queryFn: () => getOrganizationFn({ data: { organizationId } }),
+ staleTime: STALE,
+ }),
+}
diff --git a/apps/web/src/lib/client/queries/portal-tickets.ts b/apps/web/src/lib/client/queries/portal-tickets.ts
new file mode 100644
index 000000000..bdc428c6c
--- /dev/null
+++ b/apps/web/src/lib/client/queries/portal-tickets.ts
@@ -0,0 +1,131 @@
+import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import {
+ listMyTicketsFn,
+ getMyTicketFn,
+ replyToMyTicketFn,
+ createMyTicketFn,
+} from '@/lib/server/functions/portal-tickets'
+import type { TicketId } from '@quackback/ids'
+import type { JSONContent } from '@tiptap/react'
+
+export type PortalStatusCategory = 'open' | 'pending' | 'on_hold' | 'solved' | 'closed'
+
+export interface PortalTicketRow {
+ id: TicketId
+ subject: string
+ statusName: string
+ statusCategory: PortalStatusCategory
+ statusColor: string | null
+ lastActivityAt: Date
+ createdAt: Date
+}
+
+/**
+ * Query factories for the portal tickets surface.
+ *
+ * Keys are flat tuples so list invalidation can use a prefix match. Server
+ * responses use ISO date strings; we revive into `Date` here so components
+ * can call `formatDistanceToNow` directly.
+ */
+export const portalTicketQueries = {
+ list: (params: { statusCategory?: PortalStatusCategory } = {}) =>
+ queryOptions({
+ queryKey: ['portal', 'tickets', 'list', params.statusCategory ?? 'all'] as const,
+ queryFn: async () => {
+ const data = await listMyTicketsFn({
+ data: { statusCategory: params.statusCategory },
+ })
+ return {
+ rows: data.rows.map(
+ (r): PortalTicketRow => ({
+ id: r.id,
+ subject: r.subject,
+ statusName: r.statusName,
+ statusCategory: r.statusCategory,
+ statusColor: r.statusColor,
+ lastActivityAt: new Date(r.lastActivityAt),
+ createdAt: new Date(r.createdAt),
+ })
+ ),
+ total: data.total,
+ }
+ },
+ }),
+
+ detail: (ticketId: TicketId) =>
+ queryOptions({
+ queryKey: ['portal', 'tickets', 'detail', ticketId] as const,
+ queryFn: async () => {
+ const data = await getMyTicketFn({ data: { ticketId } })
+ return {
+ ticket: {
+ ...data.ticket,
+ createdAt: new Date(data.ticket.createdAt),
+ lastActivityAt: new Date(data.ticket.lastActivityAt),
+ },
+ threads: data.threads.map((t) => ({
+ ...t,
+ ticketId,
+ createdAt: new Date(t.createdAt),
+ editedAt: t.editedAt ? new Date(t.editedAt) : null,
+ })),
+ principalNames: data.principalNames,
+ viewerPrincipalId: data.viewerPrincipalId,
+ viewerRelationship: data.viewerRelationship,
+ }
+ },
+ }),
+}
+
+/**
+ * Reply mutation. Invalidates both the affected detail key and the entire
+ * tickets-list namespace so any visible status filter refreshes.
+ */
+export function useReplyToMyTicket(ticketId: TicketId) {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (input: { bodyJson?: unknown; bodyText?: string | null }) =>
+ replyToMyTicketFn({
+ data: {
+ ticketId,
+ bodyJson: (input.bodyJson ?? null) as { type: 'doc'; content?: unknown[] } | null,
+ bodyText: input.bodyText ?? null,
+ },
+ }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['portal', 'tickets', 'detail', ticketId] })
+ qc.invalidateQueries({ queryKey: ['portal', 'tickets', 'list'] })
+ toast.success('Reply sent')
+ },
+ onError: (e: Error) => toast.error(e.message || 'Failed to send reply'),
+ })
+}
+
+export function useCreateMyTicket() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (input: {
+ subject: string
+ descriptionJson?: JSONContent | null
+ descriptionText?: string | null
+ priority?: 'low' | 'normal' | 'high' | 'urgent'
+ }) =>
+ createMyTicketFn({
+ data: {
+ subject: input.subject,
+ descriptionJson: (input.descriptionJson ?? null) as {
+ type: 'doc'
+ content?: unknown[]
+ } | null,
+ descriptionText: input.descriptionText ?? null,
+ priority: input.priority,
+ },
+ }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['portal', 'tickets', 'list'] })
+ toast.success('Ticket created')
+ },
+ onError: (e: Error) => toast.error(e.message || 'Failed to create ticket'),
+ })
+}
diff --git a/apps/web/src/lib/client/queries/routing-rules.ts b/apps/web/src/lib/client/queries/routing-rules.ts
new file mode 100644
index 000000000..4b148af55
--- /dev/null
+++ b/apps/web/src/lib/client/queries/routing-rules.ts
@@ -0,0 +1,23 @@
+/**
+ * TanStack Query factory for routing-rule admin reads. Mirrors `inboxQueries`:
+ * route loaders pre-fetch via `ensureQueryData`; components read via
+ * `useSuspenseQuery`.
+ */
+import { queryOptions } from '@tanstack/react-query'
+import type { InboxId, RoutingRuleId } from '@quackback/ids'
+import { listRoutingRulesFn, getRoutingRuleFn } from '@/lib/server/functions/routing'
+
+export const routingRuleQueries = {
+ list: (params: { inboxIdScope?: InboxId | 'workspace'; enabledOnly?: boolean } = {}) =>
+ queryOptions({
+ queryKey: ['routing-rules', 'list', params] as const,
+ queryFn: () => listRoutingRulesFn({ data: params }),
+ staleTime: 30_000,
+ }),
+ detail: (ruleId: RoutingRuleId) =>
+ queryOptions({
+ queryKey: ['routing-rules', 'detail', ruleId] as const,
+ queryFn: () => getRoutingRuleFn({ data: { ruleId } }),
+ staleTime: 30_000,
+ }),
+}
diff --git a/apps/web/src/lib/client/queries/sla.ts b/apps/web/src/lib/client/queries/sla.ts
new file mode 100644
index 000000000..90bdd09fe
--- /dev/null
+++ b/apps/web/src/lib/client/queries/sla.ts
@@ -0,0 +1,31 @@
+/**
+ * Query factory for SLA admin reads.
+ */
+import { queryOptions } from '@tanstack/react-query'
+import type { SlaPolicyId } from '@quackback/ids'
+import {
+ listSlaPoliciesFn,
+ getSlaPolicyFn,
+ listEscalationRulesFn,
+} from '@/lib/server/functions/sla'
+
+export const slaQueries = {
+ policies: (params: { includeArchived?: boolean } = {}) =>
+ queryOptions({
+ queryKey: ['sla', 'policies', params] as const,
+ queryFn: () => listSlaPoliciesFn({ data: params }),
+ staleTime: 30_000,
+ }),
+ policy: (id: SlaPolicyId) =>
+ queryOptions({
+ queryKey: ['sla', 'policy', id] as const,
+ queryFn: () => getSlaPolicyFn({ data: { id } }),
+ staleTime: 30_000,
+ }),
+ escalations: (policyId: SlaPolicyId) =>
+ queryOptions({
+ queryKey: ['sla', 'escalations', policyId] as const,
+ queryFn: () => listEscalationRulesFn({ data: { policyId } }),
+ staleTime: 30_000,
+ }),
+}
diff --git a/apps/web/src/lib/client/queries/teams.ts b/apps/web/src/lib/client/queries/teams.ts
new file mode 100644
index 000000000..4cf566772
--- /dev/null
+++ b/apps/web/src/lib/client/queries/teams.ts
@@ -0,0 +1,30 @@
+/**
+ * Team query factory — list / detail / members. Mirrors `inboxQueries`.
+ */
+import { queryOptions } from '@tanstack/react-query'
+import type { TeamId } from '@quackback/ids'
+import { listTeamsFn, getTeamFn, listTeamMembersFn } from '@/lib/server/functions/teams'
+
+const STALE = 30_000
+
+export const teamQueries = {
+ all: ['teams'] as const,
+ list: (filters: { includeArchived?: boolean } = {}) =>
+ queryOptions({
+ queryKey: ['teams', 'list', filters] as const,
+ queryFn: () => listTeamsFn({ data: { includeArchived: filters.includeArchived } }),
+ staleTime: STALE,
+ }),
+ detail: (teamId: TeamId) =>
+ queryOptions({
+ queryKey: ['teams', 'detail', teamId] as const,
+ queryFn: () => getTeamFn({ data: { teamId } }),
+ staleTime: STALE,
+ }),
+ members: (teamId: TeamId) =>
+ queryOptions({
+ queryKey: ['teams', 'members', teamId] as const,
+ queryFn: () => listTeamMembersFn({ data: { teamId } }),
+ staleTime: STALE,
+ }),
+}
diff --git a/apps/web/src/lib/client/queries/tickets.ts b/apps/web/src/lib/client/queries/tickets.ts
new file mode 100644
index 000000000..a098ccadd
--- /dev/null
+++ b/apps/web/src/lib/client/queries/tickets.ts
@@ -0,0 +1,138 @@
+/**
+ * TanStack Query factory for ticket-related reads.
+ *
+ * Mirrors the pattern in `queries/admin.ts`: queries return
+ * `queryOptions(...)` objects so callers can pass them directly into
+ * `useQuery` / `useSuspenseQuery` and `queryClient.ensureQueryData(...)`.
+ */
+import { queryOptions } from '@tanstack/react-query'
+import type { TicketId, TicketThreadId } from '@quackback/ids'
+import {
+ listTicketsFn,
+ getTicketFn,
+ listThreadsFn,
+ listParticipantsFn,
+ listSharesFn,
+ listTicketStatusesFn,
+ listTicketActivityFn,
+} from '@/lib/server/functions/tickets'
+import { getTicketSlaClocksFn } from '@/lib/server/functions/sla'
+import { listMyInboxesFn } from '@/lib/server/functions/inboxes'
+
+export type QueueScope =
+ | 'all'
+ | 'my_assigned'
+ | 'my_team'
+ | 'shared_with_me'
+ | 'unassigned'
+ | 'my_inbox'
+ | 'inbox'
+
+export type StatusCategory = 'open' | 'pending' | 'on_hold' | 'solved' | 'closed'
+
+export interface TicketQueueParams {
+ scope: QueueScope
+ statusCategory?: StatusCategory
+ search?: string
+ inboxId?: string | null
+ limit?: number
+ offset?: number
+ sort?: 'last_activity_desc' | 'created_desc' | 'created_asc'
+}
+
+export const ticketQueries = {
+ list: (params: TicketQueueParams) =>
+ queryOptions({
+ queryKey: ['tickets', 'list', params] as const,
+ queryFn: () => listTicketsFn({ data: params }),
+ staleTime: 10_000,
+ }),
+ detail: (ticketId: TicketId) =>
+ queryOptions({
+ queryKey: ['tickets', 'detail', ticketId] as const,
+ queryFn: () => getTicketFn({ data: { ticketId } }),
+ staleTime: 1_000,
+ }),
+ threads: (ticketId: TicketId) =>
+ queryOptions({
+ queryKey: ['tickets', 'threads', ticketId] as const,
+ queryFn: () => listThreadsFn({ data: { ticketId } }),
+ staleTime: 5_000,
+ }),
+ participants: (ticketId: TicketId) =>
+ queryOptions({
+ queryKey: ['tickets', 'participants', ticketId] as const,
+ queryFn: () => listParticipantsFn({ data: { ticketId } }),
+ staleTime: 30_000,
+ }),
+ shares: (ticketId: TicketId) =>
+ queryOptions({
+ queryKey: ['tickets', 'shares', ticketId] as const,
+ queryFn: () => listSharesFn({ data: { ticketId } }),
+ staleTime: 30_000,
+ }),
+ statuses: () =>
+ queryOptions({
+ queryKey: ['tickets', 'statuses'] as const,
+ queryFn: () => listTicketStatusesFn(),
+ staleTime: 5 * 60_000,
+ }),
+ slaClocks: (ticketId: TicketId, includeAll = false) =>
+ queryOptions({
+ queryKey: ['tickets', 'slaClocks', ticketId, includeAll] as const,
+ queryFn: () => getTicketSlaClocksFn({ data: { ticketId, includeAll } }),
+ staleTime: 15_000,
+ refetchInterval: 30_000,
+ }),
+ activity: (ticketId: TicketId, opts: { limit?: number; before?: string } = {}) =>
+ queryOptions({
+ queryKey: ['tickets', 'activity', ticketId, opts] as const,
+ queryFn: () => listTicketActivityFn({ data: { ticketId, ...opts } }),
+ staleTime: 10_000,
+ }),
+ myInboxes: () =>
+ queryOptions({
+ queryKey: ['tickets', 'myInboxes'] as const,
+ queryFn: () => listMyInboxesFn(),
+ staleTime: 60_000,
+ }),
+ /** External links (GitHub issues etc.) for a ticket. TODO: wire to server function in Phase 8. */
+ externalLinks: (ticketId: TicketId) =>
+ queryOptions({
+ queryKey: ['tickets', 'externalLinks', ticketId] as const,
+ queryFn: () =>
+ Promise.resolve(
+ [] as Array<{
+ id: string
+ integrationId: string | null
+ integrationType: string
+ externalId: string
+ externalDisplayId: string | null
+ externalUrl: string | null
+ syncDirection: string
+ }>
+ ),
+ staleTime: 30_000,
+ }),
+ /** Attachments for a specific thread. */
+ attachments: (ticketId: TicketId, threadId: TicketThreadId) =>
+ queryOptions({
+ queryKey: ['tickets', 'attachments', ticketId, threadId] as const,
+ queryFn: async () => {
+ const res = await fetch(`/api/v1/tickets/${ticketId}/threads/${threadId}/attachments`)
+ if (!res.ok) {
+ throw new Error(`Failed to load attachments: ${res.statusText}`)
+ }
+ const data = await res.json()
+ return (data.data ?? (Array.isArray(data) ? data : [])) as Array<{
+ id: string
+ filename: string
+ mimeType: string
+ sizeBytes: number
+ publicUrl: string | null
+ createdAt: string
+ }>
+ },
+ staleTime: 30_000,
+ }),
+}
diff --git a/apps/web/src/lib/client/queries/webhook-deliveries.ts b/apps/web/src/lib/client/queries/webhook-deliveries.ts
new file mode 100644
index 000000000..3419f58ac
--- /dev/null
+++ b/apps/web/src/lib/client/queries/webhook-deliveries.ts
@@ -0,0 +1,48 @@
+/**
+ * Webhook delivery queries — cursor-paged delivery feed for the inspector
+ * drawer. Backend cursor shape is `{cursorAttemptedAt, cursorId}` (or null).
+ */
+import { infiniteQueryOptions } from '@tanstack/react-query'
+import type { WebhookId } from '@quackback/ids'
+import { listWebhookDeliveriesFn } from '@/lib/server/functions/webhook-deliveries'
+
+export type WebhookDeliveryStatusFilter =
+ | 'queued'
+ | 'success'
+ | 'failed_retryable'
+ | 'failed_terminal'
+ | 'blocked_ssrf'
+
+interface ListFilters {
+ status?: WebhookDeliveryStatusFilter
+}
+
+interface CursorParam {
+ cursorAttemptedAt: string
+ cursorId: string
+}
+
+const STALE = 15_000
+
+export const webhookDeliveryQueries = {
+ all: ['webhook-deliveries'] as const,
+ list: (webhookId: WebhookId, filters: ListFilters = {}) =>
+ infiniteQueryOptions({
+ queryKey: ['webhook-deliveries', 'list', webhookId, filters] as const,
+ queryFn: ({ pageParam }) => {
+ const cursor = pageParam as CursorParam | undefined
+ return listWebhookDeliveriesFn({
+ data: {
+ webhookId,
+ limit: 50,
+ status: filters.status,
+ cursorAttemptedAt: cursor?.cursorAttemptedAt,
+ cursorId: cursor?.cursorId,
+ },
+ })
+ },
+ initialPageParam: undefined as CursorParam | undefined,
+ getNextPageParam: (last) => (last.nextCursor as CursorParam | null) ?? undefined,
+ staleTime: STALE,
+ }),
+}
diff --git a/apps/web/src/lib/client/utils/__tests__/handle-ticket-conflict.diffcov.test.ts b/apps/web/src/lib/client/utils/__tests__/handle-ticket-conflict.diffcov.test.ts
new file mode 100644
index 000000000..8d100e5a2
--- /dev/null
+++ b/apps/web/src/lib/client/utils/__tests__/handle-ticket-conflict.diffcov.test.ts
@@ -0,0 +1,41 @@
+/**
+ * Differential-coverage tests for handle-ticket-conflict — the conflict
+ * detection branches (code / message regex / neither) and the refresh action.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+const m = vi.hoisted(() => ({ toastError: vi.fn(), invalidate: vi.fn() }))
+
+vi.mock('sonner', () => ({ toast: { error: (...a: unknown[]) => m.toastError(...a) } }))
+vi.mock('@/lib/client/queries/tickets', () => ({
+ ticketQueries: {
+ detail: (id: string) => ({ queryKey: ['tickets', 'detail', id] }),
+ threads: (id: string) => ({ queryKey: ['tickets', 'threads', id] }),
+ activity: (id: string) => ({ queryKey: ['tickets', 'activity', id] }),
+ },
+}))
+
+import { handleTicketConflict } from '../handle-ticket-conflict'
+
+const qc = { invalidateQueries: (...a: unknown[]) => m.invalidate(...a) } as never
+
+beforeEach(() => vi.clearAllMocks())
+
+describe('handleTicketConflict', () => {
+ it('surfaces a refresh toast on a coded conflict and wires the refresh action', () => {
+ handleTicketConflict(Object.assign(new Error('boom'), { code: 'CONFLICT' }), qc, 't1' as never)
+ const opts = m.toastError.mock.calls[0][1] as { action: { onClick: () => void } }
+ opts.action.onClick()
+ expect(m.invalidate).toHaveBeenCalledTimes(3)
+ })
+ it('detects TICKET_CONFLICT code and message-regex conflicts', () => {
+ handleTicketConflict({ code: 'TICKET_CONFLICT' }, qc, 't1' as never)
+ handleTicketConflict(new Error('row is stale, refresh'), qc, 't1' as never)
+ expect(m.toastError).toHaveBeenCalledTimes(2)
+ })
+ it('falls through to a plain error toast for non-conflicts (incl. non-Error values)', () => {
+ handleTicketConflict(new Error('something else'), qc, 't1' as never)
+ handleTicketConflict('a raw string', qc, 't1' as never)
+ expect(m.toastError).toHaveBeenLastCalledWith('a raw string')
+ })
+})
diff --git a/apps/web/src/lib/client/utils/handle-ticket-conflict.ts b/apps/web/src/lib/client/utils/handle-ticket-conflict.ts
new file mode 100644
index 000000000..e9272c99a
--- /dev/null
+++ b/apps/web/src/lib/client/utils/handle-ticket-conflict.ts
@@ -0,0 +1,35 @@
+/**
+ * Helper that translates ticket-mutation errors into UX-friendly toasts. When
+ * the server reports a `ConflictError` (stale `expectedUpdatedAt`), we surface
+ * a "Refresh" action that re-fetches the ticket detail. Other errors fall
+ * through to a plain error toast.
+ */
+import { toast } from 'sonner'
+import type { QueryClient } from '@tanstack/react-query'
+import type { TicketId } from '@quackback/ids'
+import { ticketQueries } from '@/lib/client/queries/tickets'
+
+export function handleTicketConflict(error: unknown, qc: QueryClient, ticketId: TicketId): void {
+ const message = error instanceof Error ? error.message : String(error)
+ const code = (error as { code?: string } | null)?.code
+ const isConflict =
+ code === 'TICKET_CONFLICT' ||
+ code === 'CONFLICT' ||
+ /conflict|stale|version|expectedupdatedat/i.test(message)
+
+ const refresh = () => {
+ qc.invalidateQueries({ queryKey: ticketQueries.detail(ticketId).queryKey })
+ qc.invalidateQueries({ queryKey: ticketQueries.threads(ticketId).queryKey })
+ qc.invalidateQueries({ queryKey: ticketQueries.activity(ticketId).queryKey })
+ }
+
+ if (isConflict) {
+ toast.error('Ticket was changed by someone else.', {
+ description: 'Refresh to get the latest version.',
+ action: { label: 'Refresh', onClick: refresh },
+ })
+ return
+ }
+
+ toast.error(message)
+}
diff --git a/apps/web/src/lib/client/widget/__tests__/tickets-api-reopen.gap.test.ts b/apps/web/src/lib/client/widget/__tests__/tickets-api-reopen.gap.test.ts
new file mode 100644
index 000000000..a0595085b
--- /dev/null
+++ b/apps/web/src/lib/client/widget/__tests__/tickets-api-reopen.gap.test.ts
@@ -0,0 +1,27 @@
+/**
+ * Differential-coverage test for reopenWidgetTicket — POSTs to the reopen
+ * endpoint via widgetFetch.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+
+vi.mock('@/lib/client/widget-auth', () => ({ getWidgetAuthHeaders: () => ({}) }))
+
+import { reopenWidgetTicket } from '../tickets-api'
+
+beforeEach(() => vi.restoreAllMocks())
+afterEach(() => vi.restoreAllMocks())
+
+describe('reopenWidgetTicket', () => {
+ it('POSTs to the ticket reopen endpoint', async () => {
+ const fetchSpy = vi
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ data: { id: 't1', status: 'open' } }), { status: 200 })
+ )
+ await reopenWidgetTicket('t1')
+ expect(fetchSpy).toHaveBeenCalledWith(
+ '/api/widget/tickets/t1/reopen',
+ expect.objectContaining({ method: 'POST' })
+ )
+ })
+})
diff --git a/apps/web/src/lib/client/widget/__tests__/tickets-api.test.ts b/apps/web/src/lib/client/widget/__tests__/tickets-api.test.ts
new file mode 100644
index 000000000..48e4c3c8e
--- /dev/null
+++ b/apps/web/src/lib/client/widget/__tests__/tickets-api.test.ts
@@ -0,0 +1,161 @@
+// @vitest-environment happy-dom
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+// Mock the auth-headers helper before importing the module under test.
+vi.mock('@/lib/client/widget-auth', () => ({
+ getWidgetAuthHeaders: () => ({ Authorization: 'Bearer test-token' }),
+}))
+
+import {
+ createWidgetTicket,
+ getWidgetTicket,
+ listWidgetTickets,
+ replyToWidgetTicket,
+ resolveWidgetTicket,
+ updateWidgetTicketDescription,
+ WidgetTicketError,
+} from '../tickets-api'
+
+const originalFetch = globalThis.fetch
+
+function mockFetch(impl: (input: RequestInfo | URL, init?: RequestInit) => Promise) {
+ globalThis.fetch = vi.fn(impl) as unknown as typeof fetch
+}
+
+beforeEach(() => {
+ vi.restoreAllMocks()
+})
+
+afterEach(() => {
+ globalThis.fetch = originalFetch
+})
+
+describe('widget tickets-api', () => {
+ it('listWidgetTickets injects bearer + parses data', async () => {
+ let capturedUrl = ''
+ let capturedHeaders: Record = {}
+ mockFetch(async (input, init) => {
+ capturedUrl = String(input)
+ capturedHeaders = init?.headers as Record
+ return new Response(
+ JSON.stringify({ data: { rows: [{ id: 't1', subject: 'Hi' }], total: 1 } }),
+ { status: 200, headers: { 'content-type': 'application/json' } }
+ )
+ })
+ const out = await listWidgetTickets({ statusCategory: 'open', limit: 10 })
+ expect(out.total).toBe(1)
+ expect(out.rows[0].subject).toBe('Hi')
+ expect(capturedUrl).toContain('/api/widget/tickets?')
+ expect(capturedUrl).toContain('statusCategory=open')
+ expect(capturedUrl).toContain('limit=10')
+ expect(capturedHeaders.Authorization).toBe('Bearer test-token')
+ })
+
+ it('getWidgetTicket encodes the path segment', async () => {
+ let capturedUrl = ''
+ mockFetch(async (input) => {
+ capturedUrl = String(input)
+ return new Response(JSON.stringify({ data: { ticket: { id: 't1' } } }), { status: 200 })
+ })
+ await getWidgetTicket('ticket_abc/with space')
+ expect(capturedUrl).toBe('/api/widget/tickets/ticket_abc%2Fwith%20space')
+ })
+
+ it('createWidgetTicket sends JSON body with content-type', async () => {
+ let capturedHeaders: Record = {}
+ let capturedBody = ''
+ mockFetch(async (_input, init) => {
+ capturedHeaders = init?.headers as Record
+ capturedBody = init?.body as string
+ return new Response(JSON.stringify({ data: { id: 't1', subject: 'S' } }), { status: 200 })
+ })
+ await createWidgetTicket({ subject: 'S', bodyText: 'B', priority: 'high' })
+ expect(capturedHeaders['Content-Type']).toBe('application/json')
+ expect(capturedHeaders.Authorization).toBe('Bearer test-token')
+ const parsed = JSON.parse(capturedBody)
+ expect(parsed).toEqual({ subject: 'S', bodyText: 'B', priority: 'high' })
+ })
+
+ it('replyToWidgetTicket POSTs bodyText to /replies', async () => {
+ let capturedUrl = ''
+ let capturedBody = ''
+ mockFetch(async (input, init) => {
+ capturedUrl = String(input)
+ capturedBody = init?.body as string
+ return new Response(JSON.stringify({ data: { id: 'th1' } }), { status: 200 })
+ })
+ await replyToWidgetTicket('t_1', 'hello')
+ expect(capturedUrl).toBe('/api/widget/tickets/t_1/replies')
+ expect(JSON.parse(capturedBody)).toEqual({ bodyText: 'hello' })
+ })
+
+ it('updateWidgetTicketDescription PATCHes description with optimistic timestamp', async () => {
+ let capturedUrl = ''
+ let capturedMethod = ''
+ let capturedBody = ''
+ mockFetch(async (input, init) => {
+ capturedUrl = String(input)
+ capturedMethod = init?.method ?? ''
+ capturedBody = init?.body as string
+ return new Response(
+ JSON.stringify({ data: { id: 't_1', updatedAt: '2026-01-01T00:00:01Z' } }),
+ {
+ status: 200,
+ }
+ )
+ })
+ await updateWidgetTicketDescription('t_1', {
+ expectedUpdatedAt: '2026-01-01T00:00:00Z',
+ descriptionJson: { type: 'doc', content: [] },
+ descriptionText: 'updated',
+ })
+ expect(capturedUrl).toBe('/api/widget/tickets/t_1')
+ expect(capturedMethod).toBe('PATCH')
+ expect(JSON.parse(capturedBody)).toEqual({
+ expectedUpdatedAt: '2026-01-01T00:00:00Z',
+ descriptionJson: { type: 'doc', content: [] },
+ descriptionText: 'updated',
+ })
+ })
+
+ it('resolveWidgetTicket POSTs to /resolve and returns alreadyResolved', async () => {
+ mockFetch(
+ async () =>
+ new Response(
+ JSON.stringify({
+ data: { id: 't1', statusId: 's1', statusCategory: 'solved', alreadyResolved: true },
+ }),
+ { status: 200 }
+ )
+ )
+ const out = await resolveWidgetTicket('t1')
+ expect(out.alreadyResolved).toBe(true)
+ })
+
+ it('throws typed WidgetTicketError on non-2xx', async () => {
+ mockFetch(
+ async () =>
+ new Response(JSON.stringify({ code: 'IDENTITY_REQUIRED', message: 'need identity' }), {
+ status: 403,
+ })
+ )
+ await expect(listWidgetTickets()).rejects.toMatchObject({
+ name: 'WidgetTicketError',
+ code: 'IDENTITY_REQUIRED',
+ status: 403,
+ message: 'need identity',
+ })
+ })
+
+ it('uses fallback error code when body has no envelope', async () => {
+ mockFetch(async () => new Response('{}', { status: 500 }))
+ try {
+ await listWidgetTickets()
+ throw new Error('should have thrown')
+ } catch (err) {
+ expect(err).toBeInstanceOf(WidgetTicketError)
+ expect((err as WidgetTicketError).code).toBe('UNKNOWN')
+ expect((err as WidgetTicketError).status).toBe(500)
+ }
+ })
+})
diff --git a/apps/web/src/lib/client/widget/tickets-api.ts b/apps/web/src/lib/client/widget/tickets-api.ts
new file mode 100644
index 000000000..de2fa4e71
--- /dev/null
+++ b/apps/web/src/lib/client/widget/tickets-api.ts
@@ -0,0 +1,242 @@
+/**
+ * Client-side fetch wrappers for the widget ticket endpoints introduced in
+ * Phase 2. Handles Bearer token injection and maps the `{ code, message }`
+ * error envelope to a typed `WidgetTicketError`.
+ */
+import { getWidgetAuthHeaders } from '@/lib/client/widget-auth'
+import type { TicketId, TicketStatusId, PrincipalId } from '@quackback/ids'
+
+export type StatusCategory = 'open' | 'pending' | 'on_hold' | 'solved' | 'closed'
+export type WidgetSupportPriority = 'low' | 'normal' | 'high' | 'urgent'
+
+export interface WidgetSupportCategory {
+ categoryKey: string
+ label: string
+ description?: string
+ icon?: string
+ defaultPriority?: WidgetSupportPriority
+ allowedPriorities?: WidgetSupportPriority[]
+ visible?: boolean
+ display?: {
+ showPrioritySelector?: boolean
+ showAttachments?: boolean
+ showResolveAction?: boolean
+ showReopenAction?: boolean
+ emptyStateTitle?: string
+ emptyStateDescription?: string
+ }
+}
+
+export interface WidgetTicketRow {
+ id: TicketId
+ subject: string
+ statusId: TicketStatusId
+ statusCategory: StatusCategory
+ statusName: string
+ statusColor: string | null
+ lastActivityAt: string
+ createdAt: string
+}
+
+export interface WidgetTicketDetail {
+ id: TicketId
+ subject: string
+ descriptionJson: unknown | null
+ descriptionText: string | null
+ statusId: TicketStatusId
+ statusCategory: StatusCategory
+ statusName: string
+ statusColor: string | null
+ createdAt: string
+ lastActivityAt: string
+ updatedAt: string
+}
+
+export interface WidgetTicketThread {
+ id: string
+ principalId: PrincipalId | null
+ audience: 'public'
+ bodyJson: unknown | null
+ bodyText: string | null
+ createdAt: string
+ editedAt: string | null
+}
+
+export interface WidgetTicketDetailResponse {
+ ticket: WidgetTicketDetail
+ threads: WidgetTicketThread[]
+ principalNames: Record
+ viewerPrincipalId: PrincipalId | null
+}
+
+export interface WidgetTicketCreateInput {
+ subject: string
+ bodyJson?: { type: 'doc'; content?: unknown[] } | null
+ bodyText?: string | null
+ priority?: WidgetSupportPriority
+ categoryKey?: string
+}
+
+export interface WidgetTicketDescriptionUpdateInput {
+ expectedUpdatedAt: string
+ descriptionJson: { type: 'doc'; content?: unknown[] } | null
+ descriptionText: string | null
+}
+
+export interface WidgetTicketDescriptionUpdateResponse {
+ id: TicketId
+ updatedAt: string
+}
+
+export interface WidgetTicketCreateResponse {
+ id: TicketId
+ subject: string
+ statusId: TicketStatusId
+ statusCategory: StatusCategory
+ statusName: string
+ statusColor: string | null
+ createdAt: string
+ lastActivityAt: string
+ initialThreadId?: string
+}
+
+export interface WidgetTicketReplyResponse {
+ id: string
+ ticketId: TicketId
+ audience: 'public'
+ createdAt: string
+}
+
+export interface WidgetTicketResolveResponse {
+ id: TicketId
+ statusId: TicketStatusId
+ statusCategory: StatusCategory | 'closed'
+ alreadyResolved: boolean
+ updatedAt?: string
+}
+
+export interface WidgetTicketReopenResponse {
+ id: TicketId
+ statusId: TicketStatusId
+ statusCategory: StatusCategory
+ alreadyOpen: boolean
+ updatedAt?: string
+}
+
+export class WidgetTicketError extends Error {
+ readonly code: string
+ readonly status: number
+ constructor(code: string, message: string, status: number) {
+ super(message)
+ this.name = 'WidgetTicketError'
+ this.code = code
+ this.status = status
+ }
+}
+
+async function widgetFetch(
+ url: string,
+ init: RequestInit & { jsonBody?: unknown } = {}
+): Promise {
+ const { jsonBody, headers, ...rest } = init
+ const finalHeaders: Record = {
+ Accept: 'application/json',
+ ...getWidgetAuthHeaders(),
+ ...(jsonBody !== undefined ? { 'Content-Type': 'application/json' } : {}),
+ ...((headers as Record | undefined) ?? {}),
+ }
+ const res = await fetch(url, {
+ ...rest,
+ headers: finalHeaders,
+ body: jsonBody !== undefined ? JSON.stringify(jsonBody) : (init.body ?? undefined),
+ })
+ let payload: unknown = null
+ try {
+ payload = await res.json()
+ } catch {
+ // empty
+ }
+ if (!res.ok) {
+ const env = (payload ?? {}) as {
+ error?: { code?: string; message?: string }
+ code?: string
+ message?: string
+ }
+ const code = env.error?.code ?? env.code ?? 'UNKNOWN'
+ const message = env.error?.message ?? env.message ?? `Request failed with ${res.status}`
+ throw new WidgetTicketError(code, message, res.status)
+ }
+ const env = (payload ?? {}) as { data?: T }
+ if (env.data === undefined) {
+ throw new WidgetTicketError('UNKNOWN', 'Malformed response', res.status)
+ }
+ return env.data
+}
+
+export interface ListWidgetTicketsParams {
+ statusCategory?: StatusCategory
+ limit?: number
+ offset?: number
+}
+
+export async function listWidgetTickets(
+ params: ListWidgetTicketsParams = {}
+): Promise<{ rows: WidgetTicketRow[]; total: number }> {
+ const search = new URLSearchParams()
+ if (params.statusCategory) search.set('statusCategory', params.statusCategory)
+ if (params.limit != null) search.set('limit', String(params.limit))
+ if (params.offset != null) search.set('offset', String(params.offset))
+ const qs = search.toString()
+ return widgetFetch(`/api/widget/tickets${qs ? `?${qs}` : ''}`)
+}
+
+export async function getWidgetTicket(
+ ticketId: TicketId | string
+): Promise {
+ return widgetFetch(`/api/widget/tickets/${encodeURIComponent(ticketId)}`)
+}
+
+export async function createWidgetTicket(
+ input: WidgetTicketCreateInput
+): Promise {
+ return widgetFetch(`/api/widget/tickets`, {
+ method: 'POST',
+ jsonBody: input,
+ })
+}
+
+export async function replyToWidgetTicket(
+ ticketId: TicketId | string,
+ bodyText: string
+): Promise {
+ return widgetFetch(`/api/widget/tickets/${encodeURIComponent(ticketId)}/replies`, {
+ method: 'POST',
+ jsonBody: { bodyText },
+ })
+}
+
+export async function updateWidgetTicketDescription(
+ ticketId: TicketId | string,
+ input: WidgetTicketDescriptionUpdateInput
+): Promise {
+ return widgetFetch(`/api/widget/tickets/${encodeURIComponent(ticketId)}`, {
+ method: 'PATCH',
+ jsonBody: input,
+ })
+}
+
+export async function resolveWidgetTicket(
+ ticketId: TicketId | string
+): Promise {
+ return widgetFetch(`/api/widget/tickets/${encodeURIComponent(ticketId)}/resolve`, {
+ method: 'POST',
+ })
+}
+
+export async function reopenWidgetTicket(
+ ticketId: TicketId | string
+): Promise {
+ return widgetFetch(`/api/widget/tickets/${encodeURIComponent(ticketId)}/reopen`, {
+ method: 'POST',
+ })
+}
diff --git a/apps/web/src/lib/server/audit/__tests__/log.test.ts b/apps/web/src/lib/server/audit/__tests__/log.test.ts
index 92d93e3ae..b6006653c 100644
--- a/apps/web/src/lib/server/audit/__tests__/log.test.ts
+++ b/apps/web/src/lib/server/audit/__tests__/log.test.ts
@@ -211,6 +211,9 @@ describe('actorFromAuth', () => {
user: { id: 'user_admin1' as never, email: 'admin@example.com', name: 'A', image: null },
principal: { id: 'principal_admin1' as never, role: 'admin', type: 'user' },
settings: { id: 'workspace_1' as never, slug: 's', name: 'n', logoKey: null },
+ ipAddress: null,
+ userAgent: null,
+ source: 'web',
})
expect(actor).toEqual({
diff --git a/apps/web/src/lib/server/audit/log.ts b/apps/web/src/lib/server/audit/log.ts
index 16637966b..48ddd0a9b 100644
--- a/apps/web/src/lib/server/audit/log.ts
+++ b/apps/web/src/lib/server/audit/log.ts
@@ -68,6 +68,8 @@ export type AuditEventType =
| 'moderation.default.changed'
| 'portal.visibility.changed'
| 'portal.allowed_domains.changed'
+ | 'portal_tabs.config_changed'
+ | 'portal_tabs.segment_override_changed'
| 'post.moderation.approved'
| 'post.moderation.rejected'
| 'post.moderation.held'
@@ -90,6 +92,9 @@ export type AuditEventType =
| 'team.invite.expired'
// v1 portal segment allowlist
| 'portal.allowed_segments.changed'
+ // v1 support surface access policies
+ | 'widget.chat_access.changed'
+ | 'portal.support_access.changed'
// v1 portal widget sign-in toggle
| 'portal.widget_signin.changed'
// v1 widget OTT handoff
diff --git a/apps/web/src/lib/server/auth/__tests__/email-signin.test.ts b/apps/web/src/lib/server/auth/__tests__/email-signin.test.ts
index 003b36a79..7df80a61d 100644
--- a/apps/web/src/lib/server/auth/__tests__/email-signin.test.ts
+++ b/apps/web/src/lib/server/auth/__tests__/email-signin.test.ts
@@ -30,7 +30,10 @@ vi.mock('@quackback/email', () => ({
vi.mock('@/lib/server/storage/s3', () => ({ getEmailSafeUrl: () => null }))
-vi.mock('@/lib/server/config', () => ({ config: { baseUrl: 'https://acme.quackback.io' } }))
+vi.mock('@/lib/server/config', () => ({
+ config: { baseUrl: 'https://acme.quackback.io' },
+ getBaseUrl: () => 'https://acme.quackback.io',
+}))
import { requestEmailSignin } from '../email-signin'
diff --git a/apps/web/src/lib/server/auth/__tests__/link-contact.test.ts b/apps/web/src/lib/server/auth/__tests__/link-contact.test.ts
new file mode 100644
index 000000000..b15a1384e
--- /dev/null
+++ b/apps/web/src/lib/server/auth/__tests__/link-contact.test.ts
@@ -0,0 +1,116 @@
+/**
+ * Unit tests for `linkContactForUser` — the auth-hook helper that links a
+ * portal user to a CRM contact based on their verified email.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import type { UserId } from '@quackback/ids'
+
+const findOrCreateByEmailMock = vi.fn()
+const linkContactToUserMock = vi.fn()
+
+vi.mock('@/lib/server/domains/organizations/contact.service', () => ({
+ findOrCreateByEmail: (...args: unknown[]) => findOrCreateByEmailMock(...args),
+ linkContactToUser: (...args: unknown[]) => linkContactToUserMock(...args),
+}))
+
+import { linkContactForUser } from '../link-contact'
+
+const USER_ID = 'user_123' as UserId
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ findOrCreateByEmailMock.mockReset().mockResolvedValue({ id: 'contact_x' })
+ linkContactToUserMock.mockReset().mockResolvedValue({ id: 'cu_link_x' })
+})
+
+describe('linkContactForUser', () => {
+ it('skips when user is anonymous', async () => {
+ await linkContactForUser({
+ userId: USER_ID,
+ email: '[email protected]',
+ emailVerified: true,
+ anonymous: true,
+ })
+ expect(findOrCreateByEmailMock).not.toHaveBeenCalled()
+ expect(linkContactToUserMock).not.toHaveBeenCalled()
+ })
+
+ it('skips when email is null', async () => {
+ await linkContactForUser({
+ userId: USER_ID,
+ email: null,
+ emailVerified: true,
+ anonymous: false,
+ })
+ expect(findOrCreateByEmailMock).not.toHaveBeenCalled()
+ expect(linkContactToUserMock).not.toHaveBeenCalled()
+ })
+
+ it('skips when email is undefined', async () => {
+ await linkContactForUser({
+ userId: USER_ID,
+ email: undefined,
+ emailVerified: true,
+ anonymous: false,
+ })
+ expect(findOrCreateByEmailMock).not.toHaveBeenCalled()
+ expect(linkContactToUserMock).not.toHaveBeenCalled()
+ })
+
+ it('skips when email is not verified', async () => {
+ await linkContactForUser({
+ userId: USER_ID,
+ email: '[email protected]',
+ emailVerified: false,
+ anonymous: false,
+ })
+ expect(findOrCreateByEmailMock).not.toHaveBeenCalled()
+ expect(linkContactToUserMock).not.toHaveBeenCalled()
+ })
+
+ it('links when email is verified — calls both services with system actor', async () => {
+ await linkContactForUser({
+ userId: USER_ID,
+ email: '[email protected]',
+ emailVerified: true,
+ anonymous: false,
+ })
+ expect(findOrCreateByEmailMock).toHaveBeenCalledTimes(1)
+ expect(findOrCreateByEmailMock).toHaveBeenCalledWith({ email: '[email protected]' })
+ expect(linkContactToUserMock).toHaveBeenCalledTimes(1)
+ expect(linkContactToUserMock).toHaveBeenCalledWith({
+ contactId: 'contact_x',
+ userId: USER_ID,
+ linkedByPrincipalId: null,
+ })
+ })
+
+ it('is idempotent — repeated calls reissue the same idempotent service calls', async () => {
+ const input = {
+ userId: USER_ID,
+ email: '[email protected]',
+ emailVerified: true,
+ anonymous: false,
+ }
+ await linkContactForUser(input)
+ await linkContactForUser(input)
+ expect(findOrCreateByEmailMock).toHaveBeenCalledTimes(2)
+ expect(linkContactToUserMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('swallows errors and logs them so signup is not broken', async () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ findOrCreateByEmailMock.mockRejectedValueOnce(new Error('db down'))
+ await expect(
+ linkContactForUser({
+ userId: USER_ID,
+ email: '[email protected]',
+ emailVerified: true,
+ anonymous: false,
+ })
+ ).resolves.toBeUndefined()
+ expect(errorSpy).toHaveBeenCalledTimes(1)
+ expect(linkContactToUserMock).not.toHaveBeenCalled()
+ errorSpy.mockRestore()
+ })
+})
diff --git a/apps/web/src/lib/server/auth/__tests__/widget-link-contact.test.ts b/apps/web/src/lib/server/auth/__tests__/widget-link-contact.test.ts
new file mode 100644
index 000000000..97919d40c
--- /dev/null
+++ b/apps/web/src/lib/server/auth/__tests__/widget-link-contact.test.ts
@@ -0,0 +1,103 @@
+/**
+ * Unit tests for `linkContactForWidgetUser` — the widget-side variant of the
+ * portal `linkContactForUser` hook. Gated on a verified ssoToken (rather than
+ * `emailVerified`) because widget identify never sets `emailVerified`.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import type { UserId } from '@quackback/ids'
+
+const findOrCreateByEmailMock = vi.fn()
+const linkContactToUserMock = vi.fn()
+
+vi.mock('@/lib/server/domains/organizations/contact.service', () => ({
+ findOrCreateByEmail: (...args: unknown[]) => findOrCreateByEmailMock(...args),
+ linkContactToUser: (...args: unknown[]) => linkContactToUserMock(...args),
+}))
+
+import { linkContactForWidgetUser } from '../link-contact'
+
+const USER_ID = 'user_123' as UserId
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ findOrCreateByEmailMock.mockReset().mockResolvedValue({ id: 'contact_x' })
+ linkContactToUserMock.mockReset().mockResolvedValue({ id: 'cu_link_x' })
+})
+
+describe('linkContactForWidgetUser', () => {
+ it('skips and returns null contactId when not verified', async () => {
+ const result = await linkContactForWidgetUser({
+ userId: USER_ID,
+ email: '[email protected]',
+ verified: false,
+ })
+ expect(result).toEqual({ contactId: null })
+ expect(findOrCreateByEmailMock).not.toHaveBeenCalled()
+ expect(linkContactToUserMock).not.toHaveBeenCalled()
+ })
+
+ it('skips when email is null (verified but no email)', async () => {
+ const result = await linkContactForWidgetUser({
+ userId: USER_ID,
+ email: null,
+ verified: true,
+ })
+ expect(result).toEqual({ contactId: null })
+ expect(findOrCreateByEmailMock).not.toHaveBeenCalled()
+ expect(linkContactToUserMock).not.toHaveBeenCalled()
+ })
+
+ it('skips when email is undefined', async () => {
+ const result = await linkContactForWidgetUser({
+ userId: USER_ID,
+ email: undefined,
+ verified: true,
+ })
+ expect(result).toEqual({ contactId: null })
+ expect(findOrCreateByEmailMock).not.toHaveBeenCalled()
+ expect(linkContactToUserMock).not.toHaveBeenCalled()
+ })
+
+ it('links and returns contactId when verified with email', async () => {
+ const result = await linkContactForWidgetUser({
+ userId: USER_ID,
+ email: '[email protected]',
+ verified: true,
+ })
+ expect(result).toEqual({ contactId: 'contact_x' })
+ expect(findOrCreateByEmailMock).toHaveBeenCalledTimes(1)
+ expect(findOrCreateByEmailMock).toHaveBeenCalledWith({ email: '[email protected]' })
+ expect(linkContactToUserMock).toHaveBeenCalledTimes(1)
+ expect(linkContactToUserMock).toHaveBeenCalledWith({
+ contactId: 'contact_x',
+ userId: USER_ID,
+ linkedByPrincipalId: null,
+ })
+ })
+
+ it('is idempotent — repeated calls reissue the same idempotent service calls', async () => {
+ const input = {
+ userId: USER_ID,
+ email: '[email protected]',
+ verified: true,
+ }
+ await linkContactForWidgetUser(input)
+ await linkContactForWidgetUser(input)
+ expect(findOrCreateByEmailMock).toHaveBeenCalledTimes(2)
+ expect(linkContactToUserMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('swallows errors, logs them and returns null contactId so identify is not broken', async () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ findOrCreateByEmailMock.mockRejectedValueOnce(new Error('db down'))
+ const result = await linkContactForWidgetUser({
+ userId: USER_ID,
+ email: '[email protected]',
+ verified: true,
+ })
+ expect(result).toEqual({ contactId: null })
+ expect(errorSpy).toHaveBeenCalledTimes(1)
+ expect(linkContactToUserMock).not.toHaveBeenCalled()
+ errorSpy.mockRestore()
+ })
+})
diff --git a/apps/web/src/lib/server/auth/email-signin.ts b/apps/web/src/lib/server/auth/email-signin.ts
index 1f196e59c..300058753 100644
--- a/apps/web/src/lib/server/auth/email-signin.ts
+++ b/apps/web/src/lib/server/auth/email-signin.ts
@@ -1,6 +1,6 @@
import { getAuth, getOTP } from './index'
import { mintMagicLinkUrl } from './magic-link-mint'
-import { config } from '@/lib/server/config'
+import { resolvePublicBaseUrl } from '@/lib/server/public-url'
import { logger } from '@/lib/server/logger'
const log = logger.child({ component: 'auth-email-signin' })
@@ -17,9 +17,10 @@ export async function requestEmailSignin(opts: {
callbackURL: string
}): Promise {
const auth = await getAuth()
+ const portalUrl = resolvePublicBaseUrl()
const headers = new Headers({
- Origin: config.baseUrl,
- Host: new URL(config.baseUrl).host,
+ Origin: portalUrl,
+ Host: new URL(portalUrl).host,
})
const { db } = await import('@/lib/server/db')
@@ -42,7 +43,7 @@ export async function requestEmailSignin(opts: {
email: opts.email,
callbackPath: opts.callbackURL,
errorCallbackPath,
- portalUrl: config.baseUrl,
+ portalUrl,
}),
auth.api.sendVerificationOTP({
body: { email: opts.email, type: 'sign-in' },
diff --git a/apps/web/src/lib/server/auth/index.ts b/apps/web/src/lib/server/auth/index.ts
index 0c6d7bd02..3ca44601e 100644
--- a/apps/web/src/lib/server/auth/index.ts
+++ b/apps/web/src/lib/server/auth/index.ts
@@ -13,8 +13,10 @@ import {
import { oauthProvider } from '@better-auth/oauth-provider'
import { tanstackStartCookies } from 'better-auth/tanstack-start'
import { generateId } from '@quackback/ids'
+import { MCP_SCOPES } from '@/lib/server/mcp/types'
import { config } from '@/lib/server/config'
import { logger } from '@/lib/server/logger'
+import { rewriteUrlToPublicBaseUrl } from '@/lib/server/public-url'
import type { GenericOAuthConfig } from './build-oauth-configs'
import { isSignInMethodEnabled } from '@/lib/shared/signin-methods'
@@ -279,7 +281,11 @@ async function createAuth() {
const { getEmailSafeUrl } = await import('@/lib/server/storage/s3')
const settings = await db.query.settings.findFirst({ columns: { logoKey: true } })
const logoUrl = getEmailSafeUrl(settings?.logoKey) ?? undefined
- await sendPasswordResetEmail({ to: user.email, resetLink: url, logoUrl })
+ await sendPasswordResetEmail({
+ to: user.email,
+ resetLink: rewriteUrlToPublicBaseUrl(url),
+ logoUrl,
+ })
},
resetPasswordTokenExpiresIn: 60 * 60 * 24, // 24 hours
},
@@ -328,6 +334,7 @@ async function createAuth() {
after: async (user) => {
// Cast user.id to the branded TypeID type for database operations
const userId = user.id as ReturnType>
+ const isAnonymous = (user as Record).isAnonymous === true
// Check if member already exists (in case of race conditions)
const existingPrincipal = await db.query.principal.findFirst({
@@ -335,7 +342,6 @@ async function createAuth() {
})
if (!existingPrincipal) {
- const isAnonymous = (user as Record).isAnonymous === true
await db.insert(principalTable).values({
id: generateId('principal'),
userId,
@@ -358,6 +364,32 @@ async function createAuth() {
'created principal record'
)
}
+
+ // Link the new user to a CRM contact when their email is verified.
+ // Best-effort: failures are swallowed inside `linkContactForUser`.
+ const { linkContactForUser } = await import('./link-contact')
+ await linkContactForUser({
+ userId,
+ email: user.email ?? null,
+ emailVerified: user.emailVerified === true,
+ anonymous: isAnonymous,
+ })
+ },
+ },
+ update: {
+ after: async (user) => {
+ // Mirror the create hook: re-evaluate the contact link whenever
+ // a user row changes (most importantly when `emailVerified` flips
+ // from false → true after the OTP / magic-link round-trip).
+ const userId = user.id as ReturnType>
+ const isAnonymous = (user as Record).isAnonymous === true
+ const { linkContactForUser } = await import('./link-contact')
+ await linkContactForUser({
+ userId,
+ email: user.email ?? null,
+ emailVerified: user.emailVerified === true,
+ anonymous: isAnonymous,
+ })
},
},
},
@@ -413,34 +445,18 @@ async function createAuth() {
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
- // Quackback-specific scopes
- scopes: [
- 'openid',
- 'profile',
- 'email',
- 'offline_access',
- 'read:feedback',
- 'write:feedback',
- 'write:changelog',
- 'read:article',
- 'write:article',
- 'read:chat',
- 'write:chat',
- ],
+ // Quackback-specific scopes (OIDC + the full MCP scope catalogue).
+ scopes: ['openid', 'profile', 'email', 'offline_access', ...MCP_SCOPES],
- // Default scopes for dynamically registered clients
+ // Default scopes for dynamically registered clients. Mirrors the full
+ // catalogue minus `manage:admin`, which is sensitive (role/API-key/
+ // webhook management) and must be granted explicitly via consent.
clientRegistrationDefaultScopes: [
'openid',
'profile',
'email',
- 'read:feedback',
'offline_access',
- 'write:feedback',
- 'write:changelog',
- 'read:article',
- 'write:article',
- 'read:chat',
- 'write:chat',
+ ...MCP_SCOPES.filter((s) => s !== 'manage:admin'),
],
// MCP endpoint is a valid token audience
diff --git a/apps/web/src/lib/server/auth/link-contact.ts b/apps/web/src/lib/server/auth/link-contact.ts
new file mode 100644
index 000000000..dd7408649
--- /dev/null
+++ b/apps/web/src/lib/server/auth/link-contact.ts
@@ -0,0 +1,88 @@
+/**
+ * Auto-link a portal user to a CRM contact based on their verified email.
+ *
+ * Called from better-auth's `databaseHooks.user.create.after` and
+ * `user.update.after`. Idempotent and best-effort — failures are logged
+ * but never thrown so a transient DB hiccup cannot break signup or session
+ * mutation.
+ *
+ * Gated on `emailVerified === true` so we only ever associate identities
+ * the user has demonstrably proven they own.
+ */
+import type { ContactId, UserId } from '@quackback/ids'
+
+export interface LinkContactForUserInput {
+ userId: UserId
+ email: string | null | undefined
+ emailVerified: boolean
+ /** Anonymous (widget-only) users have no real email and must be skipped. */
+ anonymous: boolean
+}
+
+export async function linkContactForUser(input: LinkContactForUserInput): Promise {
+ if (input.anonymous) return
+ if (!input.email) return
+ if (!input.emailVerified) return
+
+ try {
+ const { findOrCreateByEmail, linkContactToUser } =
+ await import('@/lib/server/domains/organizations/contact.service')
+ const contact = await findOrCreateByEmail({ email: input.email })
+ await linkContactToUser({
+ contactId: contact.id,
+ userId: input.userId,
+ linkedByPrincipalId: null,
+ })
+ } catch (err) {
+ console.error('[auth:link-contact] failed to link user to contact', {
+ userId: input.userId,
+ error: err instanceof Error ? err.message : err,
+ })
+ }
+}
+
+export interface LinkContactForWidgetUserInput {
+ userId: UserId
+ email: string | null | undefined
+ /**
+ * True only when the identify call carried a verified `ssoToken` (HS256 JWT
+ * signed with the workspace widget secret). Unverified identifies carry an
+ * attacker-spoofable email and must NOT auto-link to a contact, mirroring
+ * the portal's `emailVerified` gate.
+ */
+ verified: boolean
+}
+
+/**
+ * Auto-link a widget user to a CRM contact when their identity has been
+ * cryptographically verified via `ssoToken`. Used by `POST /api/widget/identify`
+ * so subsequent widget requests can resolve a `contactId` and authorise
+ * ticket list/detail/reply operations.
+ *
+ * Best-effort and idempotent — failures are logged and never thrown so a
+ * transient DB hiccup cannot break widget identify.
+ */
+export async function linkContactForWidgetUser(
+ input: LinkContactForWidgetUserInput
+): Promise<{ contactId: ContactId | null }> {
+ if (!input.verified) return { contactId: null }
+ if (!input.email) return { contactId: null }
+
+ try {
+ const { findOrCreateByEmail, linkContactToUser } =
+ await import('@/lib/server/domains/organizations/contact.service')
+ const contact = await findOrCreateByEmail({ email: input.email })
+ await linkContactToUser({
+ contactId: contact.id,
+ userId: input.userId,
+ linkedByPrincipalId: null,
+ })
+ return { contactId: contact.id as ContactId }
+ } catch (err) {
+ console.error('[auth:link-contact] failed to link widget user to contact', {
+ userId: input.userId,
+ error: err instanceof Error ? err.message : err,
+ })
+ return { contactId: null }
+ }
+}
diff --git a/apps/web/src/lib/server/db.ts b/apps/web/src/lib/server/db.ts
index 6cdadcc0e..5740118e3 100644
--- a/apps/web/src/lib/server/db.ts
+++ b/apps/web/src/lib/server/db.ts
@@ -176,11 +176,18 @@ export {
postExternalLinks,
postExternalLinksRelations,
// Schema tables - changelog
+ changelogCategories,
+ changelogCategoriesRelations,
changelogEntries,
changelogEntriesRelations,
changelogEntryPosts,
changelogEntryPostsRelations,
+ changelogProducts,
+ changelogProductsRelations,
+ changelogSegmentVisibility,
+ changelogSegmentVisibilityRelations,
// Schema tables - live chat
+ type ChangelogVisibilityConfig,
conversations,
conversationsRelations,
chatMessages,
@@ -224,6 +231,8 @@ export {
segmentsRelations,
userSegments,
userSegmentsRelations,
+ portalTabSegmentOverrides,
+ portalTabSegmentOverridesRelations,
type SegmentRules,
type SegmentCondition,
type SegmentRuleOperator,
@@ -266,16 +275,121 @@ export {
helpCenterArticlesRelations,
helpCenterArticleFeedback,
helpCenterArticleFeedbackRelations,
+ // Schema tables - ticketing (Phase 1: RBAC + teams + audit)
+ teams,
+ teamsRelations,
+ teamMemberships,
+ teamMembershipsRelations,
+ roles,
+ permissions,
+ rolePermissions,
+ principalRoleAssignments,
+ auditEvents,
+ // Schema tables - ticketing (Phase 2: organizations & contacts)
+ organizations,
+ organizationsRelations,
+ contacts,
+ contactsRelations,
+ contactUserLinks,
+ contactUserLinksRelations,
+ // Schema tables - ticketing (Phase 3: ticket core)
+ ticketStatuses,
+ DEFAULT_TICKET_STATUSES,
+ TICKET_STATUS_CATEGORIES,
+ tickets,
+ ticketsRelations,
+ ticketThreads,
+ ticketThreadsRelations,
+ ticketAttachments,
+ ticketAttachmentsRelations,
+ ticketParticipants,
+ ticketParticipantsRelations,
+ ticketShares,
+ ticketSharesRelations,
+ ticketActivity,
+ ticketActivityRelations,
+ TICKET_PRIORITIES,
+ TICKET_CHANNELS,
+ TICKET_VISIBILITY_SCOPES,
+ TICKET_THREAD_AUDIENCES,
+ TICKET_PARTICIPANT_ROLES,
+ TICKET_SHARE_LEVELS,
+ // Schema tables - ticketing (Phase 4: inboxes, channels, routing)
+ inboxes,
+ inboxesRelations,
+ inboxChannels,
+ inboxChannelsRelations,
+ inboxMemberships,
+ inboxMembershipsRelations,
+ routingRules,
+ routingRulesRelations,
+ INBOX_CHANNEL_KINDS,
+ INBOX_MEMBERSHIP_ROLES,
+ // Schema tables - ticketing (Phase 5: SLA + escalations)
+ businessHours,
+ businessHoursRelations,
+ slaPolicies,
+ slaPoliciesRelations,
+ slaTargets,
+ slaTargetsRelations,
+ ticketSlaClocks,
+ ticketSlaClocksRelations,
+ escalationRules,
+ escalationRulesRelations,
+ slaEscalationLog,
+ slaEscalationLogRelations,
+ SLA_TARGET_KINDS,
+ SLA_CLOCK_STATES,
+ SLA_POLICY_SCOPES,
+ ESCALATION_RECIPIENT_TYPES,
+ ESCALATION_CHANNELS,
+ // Schema tables - ticketing (Phase 7: subscriptions + webhook delivery log)
+ ticketSubscriptions,
+ ticketSubscriptionsRelations,
+ webhookDeliveries,
+ webhookDeliveriesRelations,
+ // Schema tables - ticket external links & user mappings (GitHub sync)
+ ticketExternalLinks,
+ ticketExternalLinksRelations,
+ ticketThreadExternalLinks,
+ ticketThreadExternalLinksRelations,
+ integrationUserMappings,
+ integrationUserMappingsRelations,
+ // Schema tables - integration sync log (observability)
+ integrationSyncLog,
+ integrationSyncLogRelations,
// Schema tables - push devices
pushDevices,
+ // Schema tables - widget profiles
+ widgetApplications,
+ widgetApplicationsRelations,
+ widgetEnvironmentProfiles,
+ widgetEnvironmentProfilesRelations,
// Types/constants
REACTION_EMOJIS,
USE_CASE_TYPES,
} from '@quackback/db'
// Re-export schema types not covered by @quackback/db/types
-export type { ServiceMetadata } from '@quackback/db'
-export type { IdentityProviderAttributeMapping } from '@quackback/db'
+export type {
+ ServiceMetadata,
+ AuditSource,
+ InboxChannelKind,
+ InboxMembershipRole,
+ OrgMetadata,
+ TicketPriority,
+ TicketVisibilityScope,
+ TicketStatusCategory,
+ WidgetProfileChangelogMode,
+ WidgetProfileConfigOverrides,
+ WidgetProfileContentFilters,
+ WidgetProfileSupportCategory,
+ WidgetProfileSupportConfig,
+ WidgetProfileSupportDisplayRules,
+ WidgetProfileTicketListScope,
+ WidgetProfileTicketPriority,
+ IdentityProviderAttributeMapping
+} from '@quackback/db'
// Re-export types (for client components that need types without side effects)
export * from '@quackback/db/types'
diff --git a/apps/web/src/lib/server/domains/api/schemas/principals.ts b/apps/web/src/lib/server/domains/api/schemas/principals.ts
new file mode 100644
index 000000000..99a38c62d
--- /dev/null
+++ b/apps/web/src/lib/server/domains/api/schemas/principals.ts
@@ -0,0 +1,77 @@
+/**
+ * Team principal schema registrations.
+ */
+import 'zod-openapi'
+import { z } from 'zod'
+import { asSchema, registerPath, TypeIdSchema } from '../openapi'
+import { TimestampSchema, ValidationErrorSchema } from './common'
+
+const TeamPrincipalSchema = z.object({
+ id: TypeIdSchema,
+ userId: z.string(),
+ name: z.string().nullable(),
+ email: z.string().email().nullable(),
+ image: z.string().nullable(),
+ role: z.enum(['admin', 'member']),
+ createdAt: TimestampSchema,
+})
+
+registerPath('/principals', {
+ get: {
+ tags: ['Principals'],
+ summary: 'List team principals',
+ description: 'Lists human team members with admin or member roles.',
+ responses: {
+ 200: {
+ description: 'Team principals',
+ content: {
+ 'application/json': {
+ schema: asSchema(z.object({ data: z.array(TeamPrincipalSchema) })),
+ },
+ },
+ },
+ },
+ },
+})
+
+registerPath('/principals/{principalId}', {
+ get: {
+ tags: ['Principals'],
+ summary: 'Get a team principal',
+ parameters: [
+ { name: 'principalId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ ],
+ responses: { 200: { description: 'Team principal' } },
+ },
+ patch: {
+ tags: ['Principals'],
+ summary: 'Update a team principal role',
+ parameters: [
+ { name: 'principalId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ ],
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: asSchema(z.object({ role: z.enum(['admin', 'member']) })),
+ },
+ },
+ },
+ responses: {
+ 200: { description: 'Updated team principal' },
+ 400: {
+ description: 'Validation error',
+ content: { 'application/json': { schema: ValidationErrorSchema } },
+ },
+ },
+ },
+ delete: {
+ tags: ['Principals'],
+ summary: 'Remove a team principal',
+ description: 'Converts the team member to a portal user.',
+ parameters: [
+ { name: 'principalId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ ],
+ responses: { 204: { description: 'Removed' } },
+ },
+})
diff --git a/apps/web/src/lib/server/domains/api/schemas/roles.ts b/apps/web/src/lib/server/domains/api/schemas/roles.ts
new file mode 100644
index 000000000..12c5190f8
--- /dev/null
+++ b/apps/web/src/lib/server/domains/api/schemas/roles.ts
@@ -0,0 +1,245 @@
+/**
+ * RBAC schema registrations: roles + permissions, the permission catalogue, and
+ * principal role assignments.
+ *
+ * All endpoints are gated by the `admin.manage_roles` scope/permission: the API
+ * key must carry the scope AND the calling principal must hold the permission.
+ */
+import 'zod-openapi'
+import { z } from 'zod'
+import {
+ registerPath,
+ TypeIdSchema,
+ createItemResponseSchema,
+ createPaginatedResponseSchema,
+ asSchema,
+} from '../openapi'
+import { TimestampSchema, UnauthorizedErrorSchema } from './common'
+
+const RoleSchema = z.object({
+ id: TypeIdSchema.meta({ example: 'role_01h455vb4pex5vsknk084sn02q' }),
+ key: z.string(),
+ name: z.string(),
+ description: z.string().nullable(),
+ isSystem: z.boolean(),
+ permissionCount: z.number().optional(),
+ createdAt: TimestampSchema,
+ updatedAt: TimestampSchema,
+})
+
+const RoleWithPermissionsSchema = RoleSchema.extend({
+ permissionKeys: z.array(z.string()),
+})
+
+const RoleAssignmentSchema = z.object({
+ id: TypeIdSchema.meta({ example: 'role_asgn_01h455vb4pex5vsknk084sn02q' }),
+ principalId: TypeIdSchema,
+ roleId: TypeIdSchema,
+ teamId: TypeIdSchema.nullable(),
+})
+
+registerPath('/roles', {
+ get: {
+ tags: ['RBAC'],
+ summary: 'List roles (with permission counts)',
+ description: 'Requires the `admin.manage_roles` scope/permission.',
+ responses: {
+ 200: {
+ description: 'Roles',
+ content: {
+ 'application/json': { schema: createPaginatedResponseSchema(RoleSchema, 'Roles') },
+ },
+ },
+ 401: {
+ description: 'Unauthorized',
+ content: { 'application/json': { schema: UnauthorizedErrorSchema } },
+ },
+ 403: { description: 'admin.manage_roles permission required' },
+ },
+ },
+ post: {
+ tags: ['RBAC'],
+ summary: 'Create a custom role',
+ description: 'Requires the `admin.manage_roles` scope/permission.',
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: asSchema(
+ z.object({
+ key: z.string().min(1).max(64),
+ name: z.string().min(1).max(200),
+ description: z.string().max(1000).nullable().optional(),
+ permissionKeys: z.array(z.string()).max(200).optional(),
+ })
+ ),
+ },
+ },
+ },
+ responses: {
+ 201: {
+ description: 'Role created',
+ content: {
+ 'application/json': {
+ schema: createItemResponseSchema(RoleWithPermissionsSchema, 'Role'),
+ },
+ },
+ },
+ },
+ },
+})
+
+registerPath('/roles/{roleId}', {
+ get: {
+ tags: ['RBAC'],
+ summary: 'Get a role with its permissions',
+ parameters: [{ name: 'roleId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ responses: {
+ 200: {
+ description: 'Role',
+ content: {
+ 'application/json': {
+ schema: createItemResponseSchema(RoleWithPermissionsSchema, 'Role'),
+ },
+ },
+ },
+ },
+ },
+ patch: {
+ tags: ['RBAC'],
+ summary: 'Rename / re-describe a role',
+ parameters: [{ name: 'roleId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: asSchema(
+ z.object({
+ name: z.string().min(1).max(200),
+ description: z.string().max(1000).nullable().optional(),
+ })
+ ),
+ },
+ },
+ },
+ responses: { 200: { description: 'Updated' } },
+ },
+ delete: {
+ tags: ['RBAC'],
+ summary: 'Delete a custom role (system roles are rejected)',
+ parameters: [{ name: 'roleId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ responses: { 204: { description: 'Deleted' } },
+ },
+})
+
+registerPath('/roles/{roleId}/permissions', {
+ put: {
+ tags: ['RBAC'],
+ summary: "Replace a role's permission set",
+ parameters: [{ name: 'roleId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: asSchema(z.object({ permissionKeys: z.array(z.string()).max(200) })),
+ },
+ },
+ },
+ responses: {
+ 200: {
+ description: 'Updated',
+ content: {
+ 'application/json': {
+ schema: createItemResponseSchema(RoleWithPermissionsSchema, 'Role'),
+ },
+ },
+ },
+ },
+ },
+})
+
+registerPath('/permissions', {
+ get: {
+ tags: ['RBAC'],
+ summary: 'List the RBAC permission catalogue',
+ description:
+ 'Reference data: every permission key plus the category → keys grouping. Requires the `admin.manage_roles` scope/permission.',
+ responses: {
+ 200: {
+ description: 'Permission catalogue',
+ content: {
+ 'application/json': {
+ schema: createItemResponseSchema(
+ z.object({
+ permissions: z.array(z.string()),
+ categories: z.record(z.string(), z.array(z.string())),
+ }),
+ 'Catalogue'
+ ),
+ },
+ },
+ },
+ },
+ },
+})
+
+registerPath('/principals/{principalId}/roles', {
+ get: {
+ tags: ['RBAC'],
+ summary: "List a principal's role assignments",
+ parameters: [
+ { name: 'principalId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ ],
+ responses: {
+ 200: {
+ description: 'Assignments',
+ content: {
+ 'application/json': {
+ schema: createPaginatedResponseSchema(RoleAssignmentSchema, 'Assignments'),
+ },
+ },
+ },
+ },
+ },
+ post: {
+ tags: ['RBAC'],
+ summary: 'Assign a role to a principal (optionally team-scoped)',
+ parameters: [
+ { name: 'principalId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ ],
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: asSchema(
+ z.object({
+ roleId: TypeIdSchema,
+ teamId: TypeIdSchema.nullable().optional(),
+ })
+ ),
+ },
+ },
+ },
+ responses: {
+ 201: {
+ description: 'Assigned',
+ content: {
+ 'application/json': {
+ schema: createItemResponseSchema(RoleAssignmentSchema, 'Assignment'),
+ },
+ },
+ },
+ },
+ },
+})
+
+registerPath('/role-assignments/{assignmentId}', {
+ delete: {
+ tags: ['RBAC'],
+ summary: 'Revoke a role assignment',
+ parameters: [
+ { name: 'assignmentId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ ],
+ responses: { 204: { description: 'Revoked' } },
+ },
+})
diff --git a/apps/web/src/lib/server/domains/api/schemas/teams.ts b/apps/web/src/lib/server/domains/api/schemas/teams.ts
new file mode 100644
index 000000000..9d38dd6c1
--- /dev/null
+++ b/apps/web/src/lib/server/domains/api/schemas/teams.ts
@@ -0,0 +1,210 @@
+/**
+ * Teams schema registrations: teams CRUD, archive/unarchive, and membership.
+ *
+ * Config-plane resource, scope-gated with the `team.*` permissions: the API key
+ * must carry the scope AND the calling principal must hold the permission.
+ */
+import 'zod-openapi'
+import { z } from 'zod'
+import {
+ registerPath,
+ TypeIdSchema,
+ createItemResponseSchema,
+ createPaginatedResponseSchema,
+ asSchema,
+} from '../openapi'
+import { TimestampSchema, NullableTimestampSchema, UnauthorizedErrorSchema } from './common'
+
+const TeamSchema = z.object({
+ id: TypeIdSchema.meta({ example: 'team_01h455vb4pex5vsknk084sn02q' }),
+ slug: z.string(),
+ name: z.string(),
+ description: z.string().nullable(),
+ shortLabel: z.string().nullable(),
+ color: z.string().nullable(),
+ archivedAt: NullableTimestampSchema,
+ createdAt: TimestampSchema,
+ updatedAt: TimestampSchema,
+})
+
+const TeamMemberSchema = z.object({
+ teamId: TypeIdSchema,
+ principalId: TypeIdSchema,
+ role: z.enum(['lead', 'member']),
+ createdAt: TimestampSchema,
+})
+
+registerPath('/teams', {
+ get: {
+ tags: ['Teams'],
+ summary: 'List teams',
+ description:
+ 'Requires the `team.view` scope/permission. Pass `?includeArchived=true` to include archived teams.',
+ parameters: [
+ {
+ name: 'includeArchived',
+ in: 'query',
+ schema: asSchema(z.enum(['true', 'false']).optional()),
+ },
+ ],
+ responses: {
+ 200: {
+ description: 'Teams',
+ content: {
+ 'application/json': { schema: createPaginatedResponseSchema(TeamSchema, 'Teams') },
+ },
+ },
+ 401: {
+ description: 'Unauthorized',
+ content: { 'application/json': { schema: UnauthorizedErrorSchema } },
+ },
+ 403: { description: 'team.view permission required' },
+ },
+ },
+ post: {
+ tags: ['Teams'],
+ summary: 'Create a team',
+ description: 'Requires the `team.manage` scope/permission.',
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: asSchema(
+ z.object({
+ slug: z.string().regex(/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/),
+ name: z.string().min(1).max(200),
+ description: z.string().max(1000).nullable().optional(),
+ shortLabel: z.string().max(40).nullable().optional(),
+ color: z.string().max(16).nullable().optional(),
+ })
+ ),
+ },
+ },
+ },
+ responses: {
+ 201: {
+ description: 'Team created',
+ content: { 'application/json': { schema: createItemResponseSchema(TeamSchema, 'Team') } },
+ },
+ 403: { description: 'team.manage permission required' },
+ },
+ },
+})
+
+registerPath('/teams/{teamId}', {
+ get: {
+ tags: ['Teams'],
+ summary: 'Get a team',
+ parameters: [{ name: 'teamId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ responses: {
+ 200: {
+ description: 'Team',
+ content: { 'application/json': { schema: createItemResponseSchema(TeamSchema, 'Team') } },
+ },
+ 404: { description: 'Team not found' },
+ },
+ },
+ patch: {
+ tags: ['Teams'],
+ summary: 'Update a team',
+ description: 'Requires the `team.manage` scope/permission.',
+ parameters: [{ name: 'teamId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: asSchema(
+ z.object({
+ name: z.string().min(1).max(200).optional(),
+ description: z.string().max(1000).nullable().optional(),
+ shortLabel: z.string().max(40).nullable().optional(),
+ color: z.string().max(16).nullable().optional(),
+ })
+ ),
+ },
+ },
+ },
+ responses: { 200: { description: 'Updated' } },
+ },
+ delete: {
+ tags: ['Teams'],
+ summary: 'Archive a team (use POST /unarchive to restore)',
+ description: 'Requires the `team.manage` scope/permission.',
+ parameters: [{ name: 'teamId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ responses: { 204: { description: 'Archived' } },
+ },
+})
+
+registerPath('/teams/{teamId}/unarchive', {
+ post: {
+ tags: ['Teams'],
+ summary: 'Restore an archived team',
+ description: 'Requires the `team.manage` scope/permission.',
+ parameters: [{ name: 'teamId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ responses: {
+ 200: {
+ description: 'Restored',
+ content: { 'application/json': { schema: createItemResponseSchema(TeamSchema, 'Team') } },
+ },
+ 404: { description: 'Team not found' },
+ },
+ },
+})
+
+registerPath('/teams/{teamId}/members', {
+ get: {
+ tags: ['Teams'],
+ summary: 'List team members',
+ parameters: [{ name: 'teamId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ responses: {
+ 200: {
+ description: 'Members',
+ content: {
+ 'application/json': {
+ schema: createPaginatedResponseSchema(TeamMemberSchema, 'Members'),
+ },
+ },
+ },
+ },
+ },
+ post: {
+ tags: ['Teams'],
+ summary: 'Add or update a team member',
+ description: 'Requires the `team.manage` scope/permission.',
+ parameters: [{ name: 'teamId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: asSchema(
+ z.object({
+ principalId: TypeIdSchema,
+ role: z.enum(['lead', 'member']).optional(),
+ })
+ ),
+ },
+ },
+ },
+ responses: {
+ 201: {
+ description: 'Member added',
+ content: {
+ 'application/json': { schema: createItemResponseSchema(TeamMemberSchema, 'Member') },
+ },
+ },
+ },
+ },
+})
+
+registerPath('/teams/{teamId}/members/{principalId}', {
+ delete: {
+ tags: ['Teams'],
+ summary: 'Remove a team member',
+ description: 'Requires the `team.manage` scope/permission.',
+ parameters: [
+ { name: 'teamId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ { name: 'principalId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ ],
+ responses: { 204: { description: 'Removed' } },
+ },
+})
diff --git a/apps/web/src/lib/server/domains/api/schemas/ticket-statuses.ts b/apps/web/src/lib/server/domains/api/schemas/ticket-statuses.ts
new file mode 100644
index 000000000..177e4b514
--- /dev/null
+++ b/apps/web/src/lib/server/domains/api/schemas/ticket-statuses.ts
@@ -0,0 +1,192 @@
+/**
+ * Ticket-statuses API schema registrations.
+ *
+ * The workflow status catalogue used by `/api/v1/tickets/:id/transition`.
+ * Distinct from `/statuses`, which covers feedback-board post statuses.
+ */
+import 'zod-openapi'
+import { z } from 'zod'
+import {
+ registerPath,
+ TypeIdSchema,
+ createItemResponseSchema,
+ createPaginatedResponseSchema,
+ asSchema,
+} from '../openapi'
+import {
+ TimestampSchema,
+ NullableTimestampSchema,
+ HexColorSchema,
+ SlugSchema,
+ UnauthorizedErrorSchema,
+ NotFoundErrorSchema,
+ ValidationErrorSchema,
+} from './common'
+
+const STATUS_CATEGORIES = ['open', 'pending', 'on_hold', 'solved', 'closed'] as const
+
+const TicketStatusSchema = z
+ .object({
+ id: TypeIdSchema.meta({ example: 'ticket_status_01h455vb4pex5vsknk084sn02q' }),
+ name: z.string(),
+ slug: SlugSchema,
+ color: HexColorSchema.nullable(),
+ category: z.enum(STATUS_CATEGORIES),
+ position: z.number().int(),
+ isDefault: z.boolean(),
+ isSystem: z.boolean().meta({ description: 'System statuses cannot be archived' }),
+ createdAt: TimestampSchema,
+ deletedAt: NullableTimestampSchema,
+ })
+ .meta({ description: 'Ticket workflow status (workflow state)' })
+
+const CreateTicketStatusSchema = z
+ .object({
+ name: z.string().min(1).max(50),
+ slug: z
+ .string()
+ .min(1)
+ .max(50)
+ .regex(/^[a-z0-9_-]+$/, 'slug must match [a-z0-9_-]+'),
+ color: HexColorSchema.optional(),
+ category: z.enum(STATUS_CATEGORIES),
+ position: z.number().int().min(0).optional(),
+ isDefault: z.boolean().optional(),
+ })
+ .meta({ description: 'Create ticket-status request body' })
+
+const UpdateTicketStatusSchema = z
+ .object({
+ name: z.string().min(1).max(50).optional(),
+ color: HexColorSchema.optional(),
+ category: z.enum(STATUS_CATEGORIES).optional(),
+ position: z.number().int().min(0).optional(),
+ isDefault: z.boolean().optional(),
+ })
+ .meta({ description: 'Update ticket-status request body' })
+
+registerPath('/ticket-statuses', {
+ get: {
+ tags: ['Ticket Statuses'],
+ summary: 'List ticket statuses',
+ description: 'Returns the workspace ticket-status catalogue, ordered by position.',
+ parameters: [
+ {
+ name: 'includeDeleted',
+ in: 'query',
+ required: false,
+ schema: { type: 'boolean' },
+ description: 'Include archived statuses',
+ },
+ ],
+ responses: {
+ 200: {
+ description: 'List of ticket statuses',
+ content: {
+ 'application/json': {
+ schema: createPaginatedResponseSchema(TicketStatusSchema, 'Statuses'),
+ },
+ },
+ },
+ 401: {
+ description: 'Unauthorized',
+ content: { 'application/json': { schema: UnauthorizedErrorSchema } },
+ },
+ },
+ },
+ post: {
+ tags: ['Ticket Statuses'],
+ summary: 'Create a ticket status (admin)',
+ requestBody: {
+ required: true,
+ content: { 'application/json': { schema: asSchema(CreateTicketStatusSchema) } },
+ },
+ responses: {
+ 201: {
+ description: 'Status created',
+ content: {
+ 'application/json': {
+ schema: createItemResponseSchema(TicketStatusSchema, 'Status'),
+ },
+ },
+ },
+ 400: {
+ description: 'Validation error',
+ content: { 'application/json': { schema: ValidationErrorSchema } },
+ },
+ 401: {
+ description: 'Unauthorized',
+ content: { 'application/json': { schema: UnauthorizedErrorSchema } },
+ },
+ 409: { description: 'Slug already in use' },
+ },
+ },
+})
+
+registerPath('/ticket-statuses/{statusId}', {
+ get: {
+ tags: ['Ticket Statuses'],
+ summary: 'Get a ticket status',
+ parameters: [{ name: 'statusId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ responses: {
+ 200: {
+ description: 'Status',
+ content: {
+ 'application/json': {
+ schema: createItemResponseSchema(TicketStatusSchema, 'Status'),
+ },
+ },
+ },
+ 404: {
+ description: 'Status not found',
+ content: { 'application/json': { schema: NotFoundErrorSchema } },
+ },
+ },
+ },
+ patch: {
+ tags: ['Ticket Statuses'],
+ summary: 'Update a ticket status (admin)',
+ parameters: [{ name: 'statusId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ requestBody: {
+ required: true,
+ content: { 'application/json': { schema: asSchema(UpdateTicketStatusSchema) } },
+ },
+ responses: {
+ 200: {
+ description: 'Status updated',
+ content: {
+ 'application/json': {
+ schema: createItemResponseSchema(TicketStatusSchema, 'Status'),
+ },
+ },
+ },
+ 400: {
+ description: 'Validation error',
+ content: { 'application/json': { schema: ValidationErrorSchema } },
+ },
+ 404: {
+ description: 'Status not found',
+ content: { 'application/json': { schema: NotFoundErrorSchema } },
+ },
+ },
+ },
+ delete: {
+ tags: ['Ticket Statuses'],
+ summary: 'Archive a ticket status (admin)',
+ description:
+ 'Soft-archives the status (sets `deletedAt`). Returns 409 if any active ticket still references the status, and rejects with 400 for system statuses.',
+ parameters: [{ name: 'statusId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ responses: {
+ 204: { description: 'Archived' },
+ 400: {
+ description: 'System status — cannot archive',
+ content: { 'application/json': { schema: ValidationErrorSchema } },
+ },
+ 404: {
+ description: 'Status not found',
+ content: { 'application/json': { schema: NotFoundErrorSchema } },
+ },
+ 409: { description: 'Status is still referenced by active tickets' },
+ },
+ },
+})
diff --git a/apps/web/src/lib/server/domains/api/schemas/tickets.ts b/apps/web/src/lib/server/domains/api/schemas/tickets.ts
new file mode 100644
index 000000000..b08f598e7
--- /dev/null
+++ b/apps/web/src/lib/server/domains/api/schemas/tickets.ts
@@ -0,0 +1,913 @@
+/**
+ * Tickets API schema registrations (Phase 3-7).
+ *
+ * Covers the public ticket-management surface: queue list, CRUD, threads,
+ * participants, share, take/return, bulk ops, and SLA read.
+ */
+import 'zod-openapi'
+import { z } from 'zod'
+import {
+ registerPath,
+ TypeIdSchema,
+ createItemResponseSchema,
+ createPaginatedResponseSchema,
+ asSchema,
+} from '../openapi'
+import {
+ TimestampSchema,
+ NullableTimestampSchema,
+ UnauthorizedErrorSchema,
+ NotFoundErrorSchema,
+ ValidationErrorSchema,
+} from './common'
+
+const TICKET_PRIORITIES = ['low', 'normal', 'high', 'urgent'] as const
+const TICKET_CHANNELS = ['portal', 'email', 'api', 'widget'] as const
+const TICKET_VISIBILITY = ['team', 'org', 'shared', 'private'] as const
+const STATUS_CATEGORIES = ['open', 'pending', 'on_hold', 'solved', 'closed'] as const
+const SCOPES = [
+ 'all',
+ 'my_assigned',
+ 'my_team',
+ 'shared_with_me',
+ 'unassigned',
+ 'my_inbox',
+ 'inbox',
+] as const
+
+const TicketSchema = z
+ .object({
+ id: TypeIdSchema.meta({ example: 'ticket_01h455vb4pex5vsknk084sn02q' }),
+ subject: z.string(),
+ descriptionText: z.string().nullable(),
+ priority: z.enum(TICKET_PRIORITIES),
+ channel: z.enum(TICKET_CHANNELS),
+ visibilityScope: z.enum(TICKET_VISIBILITY),
+ statusId: TypeIdSchema.nullable(),
+ primaryTeamId: TypeIdSchema.nullable(),
+ assigneePrincipalId: TypeIdSchema.nullable(),
+ assigneeTeamId: TypeIdSchema.nullable(),
+ requesterPrincipalId: TypeIdSchema.nullable(),
+ requesterContactId: TypeIdSchema.nullable(),
+ organizationId: TypeIdSchema.nullable(),
+ inboxId: TypeIdSchema.nullable(),
+ slaPolicyId: TypeIdSchema.nullable(),
+ firstResponseAt: NullableTimestampSchema,
+ resolvedAt: NullableTimestampSchema,
+ reopenedAt: NullableTimestampSchema,
+ closedAt: NullableTimestampSchema,
+ lastActivityAt: NullableTimestampSchema,
+ createdAt: TimestampSchema,
+ updatedAt: TimestampSchema,
+ })
+ .meta({ description: 'Ticket header record' })
+
+const CreateTicketSchema = z
+ .object({
+ subject: z.string().min(1).max(500),
+ descriptionJson: z.unknown().nullable().optional(),
+ descriptionText: z.string().max(100_000).nullable().optional(),
+ priority: z.enum(TICKET_PRIORITIES).optional(),
+ channel: z.enum(TICKET_CHANNELS).optional(),
+ visibilityScope: z.enum(TICKET_VISIBILITY).optional(),
+ statusId: TypeIdSchema.nullable().optional(),
+ primaryTeamId: TypeIdSchema.nullable().optional(),
+ assigneePrincipalId: TypeIdSchema.nullable().optional(),
+ assigneeTeamId: TypeIdSchema.nullable().optional(),
+ requesterPrincipalId: TypeIdSchema.nullable().optional(),
+ requesterContactId: TypeIdSchema.nullable().optional(),
+ organizationId: TypeIdSchema.nullable().optional(),
+ inboxId: TypeIdSchema.nullable().optional(),
+ })
+ .meta({ description: 'Create ticket request body' })
+
+const PatchTicketSchema = z
+ .object({
+ expectedUpdatedAt: TimestampSchema,
+ subject: z.string().min(1).max(500).optional(),
+ priority: z.enum(TICKET_PRIORITIES).optional(),
+ visibilityScope: z.enum(TICKET_VISIBILITY).optional(),
+ primaryTeamId: TypeIdSchema.nullable().optional(),
+ organizationId: TypeIdSchema.nullable().optional(),
+ requesterContactId: TypeIdSchema.nullable().optional(),
+ })
+ .meta({ description: 'Patch ticket request body (optimistic concurrency)' })
+
+const ThreadSchema = z
+ .object({
+ id: TypeIdSchema.meta({ example: 'ticket_thread_01h455vb4pex5vsknk084sn02q' }),
+ ticketId: TypeIdSchema,
+ principalId: TypeIdSchema.nullable(),
+ audience: z.enum(['public', 'internal', 'shared_team']),
+ sharedWithTeamId: TypeIdSchema.nullable(),
+ bodyText: z.string().nullable(),
+ bodyJson: z.unknown().nullable(),
+ createdAt: TimestampSchema,
+ editedAt: NullableTimestampSchema,
+ })
+ .meta({ description: 'Ticket thread (message)' })
+
+const AddThreadSchema = z
+ .object({
+ audience: z.enum(['public', 'internal', 'shared_team']),
+ bodyJson: z.unknown().nullable().optional(),
+ bodyText: z.string().max(100_000).nullable().optional(),
+ sharedWithTeamId: TypeIdSchema.nullable().optional(),
+ })
+ .meta({ description: 'Add thread to ticket request body' })
+
+const ParticipantSchema = z
+ .object({
+ id: TypeIdSchema,
+ ticketId: TypeIdSchema,
+ principalId: TypeIdSchema.nullable(),
+ contactId: TypeIdSchema.nullable(),
+ role: z.enum(['watcher', 'collaborator', 'cc']),
+ createdAt: TimestampSchema,
+ })
+ .meta({ description: 'Ticket participant' })
+
+const ShareSchema = z
+ .object({
+ id: TypeIdSchema,
+ ticketId: TypeIdSchema,
+ teamId: TypeIdSchema,
+ accessLevel: z.enum(['read', 'comment', 'full']),
+ grantedByPrincipalId: TypeIdSchema.nullable(),
+ grantedAt: TimestampSchema,
+ revokedAt: NullableTimestampSchema,
+ })
+ .meta({ description: 'Cross-team share grant' })
+
+const SlaClockSchema = z
+ .object({
+ id: TypeIdSchema,
+ ticketId: TypeIdSchema,
+ kind: z.enum(['first_response', 'next_response', 'resolution']),
+ state: z.enum(['running', 'paused', 'met', 'breached', 'cancelled']),
+ startedAt: TimestampSchema,
+ dueAt: TimestampSchema,
+ pausedAt: NullableTimestampSchema,
+ breachedAt: NullableTimestampSchema,
+ metAt: NullableTimestampSchema,
+ })
+ .meta({ description: 'Per-ticket SLA clock' })
+
+const BulkResultSchema = z
+ .object({
+ succeeded: z.array(TypeIdSchema),
+ failed: z.array(z.object({ ticketId: TypeIdSchema, reason: z.string() })),
+ })
+ .meta({ description: 'Best-effort bulk operation result' })
+
+// ---------------------------------------------------------------------------
+// Routes
+// ---------------------------------------------------------------------------
+
+registerPath('/tickets', {
+ get: {
+ tags: ['Tickets'],
+ summary: 'List tickets in scope',
+ description: 'Returns tickets visible to the caller, filtered by scope.',
+ parameters: [
+ { name: 'scope', in: 'query', schema: asSchema(z.enum(SCOPES).default('my_team')) },
+ {
+ name: 'statusCategory',
+ in: 'query',
+ schema: asSchema(z.enum(STATUS_CATEGORIES).optional()),
+ },
+ { name: 'inboxId', in: 'query', schema: asSchema(z.string().optional()) },
+ { name: 'search', in: 'query', schema: asSchema(z.string().optional()) },
+ { name: 'cursor', in: 'query', schema: asSchema(z.string().optional()) },
+ {
+ name: 'limit',
+ in: 'query',
+ schema: asSchema(z.coerce.number().min(1).max(200).optional()),
+ },
+ ],
+ responses: {
+ 200: {
+ description: 'Ticket queue',
+ content: {
+ 'application/json': {
+ schema: createPaginatedResponseSchema(TicketSchema, 'Tickets'),
+ },
+ },
+ },
+ 401: {
+ description: 'Unauthorized',
+ content: { 'application/json': { schema: UnauthorizedErrorSchema } },
+ },
+ },
+ },
+ post: {
+ tags: ['Tickets'],
+ summary: 'Create a ticket',
+ requestBody: {
+ required: true,
+ content: { 'application/json': { schema: asSchema(CreateTicketSchema) } },
+ },
+ responses: {
+ 201: {
+ description: 'Ticket created',
+ content: {
+ 'application/json': { schema: createItemResponseSchema(TicketSchema, 'Ticket') },
+ },
+ },
+ 400: {
+ description: 'Validation error',
+ content: { 'application/json': { schema: ValidationErrorSchema } },
+ },
+ },
+ },
+})
+
+registerPath('/tickets/{ticketId}', {
+ get: {
+ tags: ['Tickets'],
+ summary: 'Get a ticket',
+ parameters: [{ name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ responses: {
+ 200: {
+ description: 'Ticket',
+ content: {
+ 'application/json': { schema: createItemResponseSchema(TicketSchema, 'Ticket') },
+ },
+ },
+ 404: {
+ description: 'Not found',
+ content: { 'application/json': { schema: NotFoundErrorSchema } },
+ },
+ },
+ },
+ patch: {
+ tags: ['Tickets'],
+ summary: 'Update ticket header (optimistic concurrency)',
+ parameters: [{ name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ requestBody: {
+ required: true,
+ content: { 'application/json': { schema: asSchema(PatchTicketSchema) } },
+ },
+ responses: {
+ 200: {
+ description: 'Updated ticket',
+ content: {
+ 'application/json': { schema: createItemResponseSchema(TicketSchema, 'Ticket') },
+ },
+ },
+ 409: { description: 'Conflict (stale expectedUpdatedAt)' },
+ },
+ },
+ delete: {
+ tags: ['Tickets'],
+ summary: 'Soft-delete a ticket',
+ parameters: [{ name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ responses: { 204: { description: 'Deleted' } },
+ },
+})
+
+registerPath('/tickets/{ticketId}/threads', {
+ get: {
+ tags: ['Tickets'],
+ summary: 'List threads on a ticket (audience-filtered)',
+ parameters: [{ name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ responses: {
+ 200: {
+ description: 'Threads',
+ content: {
+ 'application/json': { schema: createPaginatedResponseSchema(ThreadSchema, 'Threads') },
+ },
+ },
+ },
+ },
+ post: {
+ tags: ['Tickets'],
+ summary: 'Add a thread (public reply / internal note / shared note)',
+ parameters: [{ name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ requestBody: {
+ required: true,
+ content: { 'application/json': { schema: asSchema(AddThreadSchema) } },
+ },
+ responses: {
+ 201: {
+ description: 'Thread created',
+ content: {
+ 'application/json': { schema: createItemResponseSchema(ThreadSchema, 'Thread') },
+ },
+ },
+ },
+ },
+})
+
+registerPath('/tickets/{ticketId}/participants', {
+ get: {
+ tags: ['Tickets'],
+ summary: 'List participants',
+ parameters: [{ name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ responses: {
+ 200: {
+ description: 'Participants',
+ content: {
+ 'application/json': {
+ schema: createPaginatedResponseSchema(ParticipantSchema, 'Participants'),
+ },
+ },
+ },
+ },
+ },
+ post: {
+ tags: ['Tickets'],
+ summary: 'Add a participant',
+ parameters: [{ name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: asSchema(
+ z.object({
+ role: z.enum(['watcher', 'collaborator', 'cc']),
+ principalId: TypeIdSchema.optional(),
+ contactId: TypeIdSchema.optional(),
+ })
+ ),
+ },
+ },
+ },
+ responses: { 201: { description: 'Participant added' } },
+ },
+})
+
+registerPath('/tickets/{ticketId}/shares', {
+ get: {
+ tags: ['Tickets'],
+ summary: 'List share grants',
+ parameters: [{ name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ responses: {
+ 200: {
+ description: 'Shares',
+ content: {
+ 'application/json': { schema: createPaginatedResponseSchema(ShareSchema, 'Shares') },
+ },
+ },
+ },
+ },
+ post: {
+ tags: ['Tickets'],
+ summary: 'Share ticket with another team',
+ parameters: [{ name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: asSchema(
+ z.object({
+ teamId: TypeIdSchema,
+ accessLevel: z.enum(['read', 'comment', 'full']).default('read'),
+ })
+ ),
+ },
+ },
+ },
+ responses: { 201: { description: 'Share created' } },
+ },
+})
+
+registerPath('/tickets/{ticketId}/take', {
+ post: {
+ tags: ['Tickets'],
+ summary: 'Take (self-assign) a ticket',
+ parameters: [{ name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: asSchema(z.object({ expectedUpdatedAt: TimestampSchema })),
+ },
+ },
+ },
+ responses: { 200: { description: 'Ticket taken' } },
+ },
+})
+
+registerPath('/tickets/{ticketId}/return', {
+ post: {
+ tags: ['Tickets'],
+ summary: 'Return (un-self-assign) a ticket',
+ parameters: [{ name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ responses: { 200: { description: 'Ticket returned' } },
+ },
+})
+
+registerPath('/tickets/{ticketId}/sla', {
+ get: {
+ tags: ['Tickets'],
+ summary: 'Get active SLA clocks for a ticket',
+ parameters: [{ name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ responses: {
+ 200: {
+ description: 'Clocks',
+ content: {
+ 'application/json': { schema: createPaginatedResponseSchema(SlaClockSchema, 'Clocks') },
+ },
+ },
+ },
+ },
+})
+
+registerPath('/tickets/bulk/assign', {
+ post: {
+ tags: ['Tickets'],
+ summary: 'Bulk-assign tickets (best-effort)',
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: asSchema(
+ z.object({
+ ticketIds: z.array(TypeIdSchema).min(1).max(500),
+ assigneePrincipalId: TypeIdSchema.nullable(),
+ })
+ ),
+ },
+ },
+ },
+ responses: {
+ 200: {
+ description: 'Per-ticket result',
+ content: { 'application/json': { schema: BulkResultSchema } },
+ },
+ },
+ },
+})
+
+registerPath('/tickets/bulk/transition', {
+ post: {
+ tags: ['Tickets'],
+ summary: 'Bulk-transition ticket statuses (best-effort)',
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: asSchema(
+ z.object({
+ ticketIds: z.array(TypeIdSchema).min(1).max(500),
+ statusId: TypeIdSchema,
+ })
+ ),
+ },
+ },
+ },
+ responses: { 200: { description: 'Per-ticket result' } },
+ },
+})
+
+registerPath('/tickets/bulk/change-inbox', {
+ post: {
+ tags: ['Tickets'],
+ summary: 'Bulk-move tickets to a different inbox (best-effort)',
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: asSchema(
+ z.object({
+ ticketIds: z.array(TypeIdSchema).min(1).max(500),
+ inboxId: TypeIdSchema.nullable(),
+ })
+ ),
+ },
+ },
+ },
+ responses: { 200: { description: 'Per-ticket result' } },
+ },
+})
+
+// ---------------------------------------------------------------------------
+// Phase 1 additions: restore, activity, thread edit/delete, attachments
+// ---------------------------------------------------------------------------
+
+const ActivityRowSchema = z
+ .object({
+ id: TypeIdSchema,
+ ticketId: TypeIdSchema,
+ principalId: TypeIdSchema.nullable(),
+ type: z.string().meta({
+ description: 'Event type, e.g. ticket.created, ticket.status_changed, thread.added',
+ }),
+ metadata: z.unknown(),
+ createdAt: TimestampSchema,
+ actorName: z.string().nullable(),
+ actorAvatarUrl: z.string().nullable(),
+ })
+ .meta({ description: 'Single ticket-activity event' })
+
+const ActivityResponseSchema = z
+ .object({
+ data: z.object({
+ activity: z.array(ActivityRowSchema),
+ nextCursor: z
+ .string()
+ .nullable()
+ .meta({ description: 'ISO timestamp cursor for the next page; null when none' }),
+ }),
+ })
+ .meta({ description: 'Ticket activity timeline response' })
+
+const ThreadEditSchema = z
+ .object({
+ bodyJson: z.unknown().nullable().optional(),
+ bodyText: z.string().max(100_000).nullable().optional(),
+ })
+ .meta({ description: 'Edit thread body — author only. Provide bodyJson or bodyText.' })
+
+const AttachmentSchema = z
+ .object({
+ id: TypeIdSchema.meta({ example: 'ticket_att_01h455vb4pex5vsknk084sn02q' }),
+ threadId: TypeIdSchema,
+ uploadedByPrincipalId: TypeIdSchema.nullable(),
+ filename: z.string(),
+ mimeType: z.string(),
+ sizeBytes: z.number().int(),
+ storageKey: z.string(),
+ publicUrl: z.string().nullable(),
+ createdAt: TimestampSchema,
+ })
+ .meta({ description: 'Ticket attachment metadata' })
+
+registerPath('/tickets/{ticketId}/restore', {
+ post: {
+ tags: ['Tickets'],
+ summary: 'Restore a soft-deleted ticket (admin)',
+ description:
+ 'Pairs with `DELETE /tickets/{ticketId}`. Admin-only. Returns 409 if the ticket is not deleted.',
+ parameters: [{ name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ responses: {
+ 200: {
+ description: 'Ticket restored',
+ content: {
+ 'application/json': { schema: createItemResponseSchema(TicketSchema, 'Ticket') },
+ },
+ },
+ 401: {
+ description: 'Unauthorized',
+ content: { 'application/json': { schema: UnauthorizedErrorSchema } },
+ },
+ 404: {
+ description: 'Ticket not found',
+ content: { 'application/json': { schema: NotFoundErrorSchema } },
+ },
+ 409: { description: 'Ticket is not deleted' },
+ },
+ },
+})
+
+registerPath('/tickets/{ticketId}/activity', {
+ get: {
+ tags: ['Tickets'],
+ summary: 'List ticket activity (timeline)',
+ description:
+ 'Reverse-chronological feed of all activity events on the ticket. Use `before` (ISO timestamp) for pagination.',
+ parameters: [
+ { name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ {
+ name: 'before',
+ in: 'query',
+ required: false,
+ schema: { type: 'string', format: 'date-time' },
+ description: 'Return rows strictly older than this timestamp',
+ },
+ {
+ name: 'limit',
+ in: 'query',
+ required: false,
+ schema: { type: 'integer', minimum: 1, maximum: 200 },
+ description: 'Default 50, max 200',
+ },
+ ],
+ responses: {
+ 200: {
+ description: 'Activity timeline page',
+ content: { 'application/json': { schema: asSchema(ActivityResponseSchema) } },
+ },
+ 401: {
+ description: 'Unauthorized',
+ content: { 'application/json': { schema: UnauthorizedErrorSchema } },
+ },
+ 404: {
+ description: 'Ticket not found',
+ content: { 'application/json': { schema: NotFoundErrorSchema } },
+ },
+ },
+ },
+})
+
+registerPath('/tickets/{ticketId}/threads/{threadId}', {
+ get: {
+ tags: ['Tickets'],
+ summary: 'Get a single thread',
+ parameters: [
+ { name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ { name: 'threadId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ ],
+ responses: {
+ 200: {
+ description: 'Thread detail',
+ content: {
+ 'application/json': { schema: createItemResponseSchema(ThreadSchema, 'Thread') },
+ },
+ },
+ 404: {
+ description: 'Ticket or thread not found',
+ content: { 'application/json': { schema: NotFoundErrorSchema } },
+ },
+ },
+ },
+ patch: {
+ tags: ['Tickets'],
+ summary: 'Edit a thread (author only)',
+ description: 'Only the original author may edit. Stamps `editedAt` and `editedByPrincipalId`.',
+ parameters: [
+ { name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ { name: 'threadId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ ],
+ requestBody: {
+ required: true,
+ content: { 'application/json': { schema: asSchema(ThreadEditSchema) } },
+ },
+ responses: {
+ 200: {
+ description: 'Thread updated',
+ content: {
+ 'application/json': { schema: createItemResponseSchema(ThreadSchema, 'Thread') },
+ },
+ },
+ 400: {
+ description: 'Validation error',
+ content: { 'application/json': { schema: ValidationErrorSchema } },
+ },
+ 403: { description: 'Not the author' },
+ 404: {
+ description: 'Ticket or thread not found',
+ content: { 'application/json': { schema: NotFoundErrorSchema } },
+ },
+ },
+ },
+ delete: {
+ tags: ['Tickets'],
+ summary: 'Soft-delete a thread',
+ description:
+ 'Author or any caller with `ticket.edit_fields` may soft-delete. The row is marked `deletedAt`.',
+ parameters: [
+ { name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ { name: 'threadId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ ],
+ responses: {
+ 204: { description: 'Deleted' },
+ 403: { description: 'Not the author and lacks ticket.edit_fields' },
+ 404: {
+ description: 'Ticket or thread not found',
+ content: { 'application/json': { schema: NotFoundErrorSchema } },
+ },
+ },
+ },
+})
+
+registerPath('/tickets/{ticketId}/threads/{threadId}/attachments', {
+ get: {
+ tags: ['Tickets'],
+ summary: 'List attachments on a thread',
+ parameters: [
+ { name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ { name: 'threadId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ ],
+ responses: {
+ 200: {
+ description: 'Attachments',
+ content: {
+ 'application/json': {
+ schema: createPaginatedResponseSchema(AttachmentSchema, 'Attachments'),
+ },
+ },
+ },
+ 404: {
+ description: 'Ticket or thread not found',
+ content: { 'application/json': { schema: NotFoundErrorSchema } },
+ },
+ },
+ },
+ post: {
+ tags: ['Tickets'],
+ summary: 'Upload an attachment to a thread (multipart)',
+ description:
+ 'multipart/form-data with a `file` field. Image MIME types only (jpeg/png/gif/webp/avif), 5 MB cap.',
+ parameters: [
+ { name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ { name: 'threadId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ ],
+ requestBody: {
+ required: true,
+ content: {
+ 'multipart/form-data': {
+ schema: {
+ type: 'object',
+ properties: {
+ file: { type: 'string', format: 'binary' },
+ },
+ required: ['file'],
+ },
+ },
+ },
+ },
+ responses: {
+ 201: {
+ description: 'Attachment created',
+ content: {
+ 'application/json': {
+ schema: createItemResponseSchema(AttachmentSchema, 'Attachment'),
+ },
+ },
+ },
+ 400: {
+ description: 'Invalid upload',
+ content: { 'application/json': { schema: ValidationErrorSchema } },
+ },
+ 404: {
+ description: 'Ticket or thread not found',
+ content: { 'application/json': { schema: NotFoundErrorSchema } },
+ },
+ },
+ },
+})
+
+registerPath('/tickets/{ticketId}/threads/{threadId}/attachments/{attachmentId}', {
+ get: {
+ tags: ['Tickets'],
+ summary: 'Get a single attachment',
+ parameters: [
+ { name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ { name: 'threadId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ { name: 'attachmentId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ ],
+ responses: {
+ 200: {
+ description: 'Attachment detail',
+ content: {
+ 'application/json': { schema: createItemResponseSchema(AttachmentSchema, 'Attachment') },
+ },
+ },
+ 404: {
+ description: 'Ticket / thread / attachment not found',
+ content: { 'application/json': { schema: NotFoundErrorSchema } },
+ },
+ },
+ },
+ delete: {
+ tags: ['Tickets'],
+ summary: 'Delete an attachment',
+ description:
+ 'Removes the metadata row. Allowed for the original uploader or any caller with `ticket.edit_fields`.',
+ parameters: [
+ { name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ { name: 'threadId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ { name: 'attachmentId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ ],
+ responses: {
+ 204: { description: 'Deleted' },
+ 403: { description: 'Not the uploader and lacks ticket.edit_fields' },
+ 404: {
+ description: 'Ticket / thread / attachment not found',
+ content: { 'application/json': { schema: NotFoundErrorSchema } },
+ },
+ },
+ },
+})
+
+// ---------------------------------------------------------------------------
+// Assign & Transition
+// ---------------------------------------------------------------------------
+
+registerPath('/tickets/{ticketId}/assign', {
+ post: {
+ tags: ['Tickets'],
+ summary: 'Assign a ticket',
+ description:
+ 'Assign to an agent (assigneePrincipalId) and/or a team (assigneeTeamId). Requires `ticket.assign_any` or `ticket.assign_self` if assigning to self.',
+ parameters: [{ name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: asSchema(
+ z.object({
+ expectedUpdatedAt: z.string().datetime(),
+ assigneePrincipalId: TypeIdSchema.nullable().optional(),
+ assigneeTeamId: TypeIdSchema.nullable().optional(),
+ })
+ ),
+ },
+ },
+ },
+ responses: {
+ 200: {
+ description: 'Ticket assigned',
+ content: {
+ 'application/json': { schema: createItemResponseSchema(TicketSchema, 'Ticket') },
+ },
+ },
+ 400: {
+ description: 'Validation error',
+ content: { 'application/json': { schema: ValidationErrorSchema } },
+ },
+ 403: { description: 'Insufficient permissions' },
+ 404: {
+ description: 'Ticket not found',
+ content: { 'application/json': { schema: NotFoundErrorSchema } },
+ },
+ },
+ },
+})
+
+registerPath('/tickets/{ticketId}/transition', {
+ post: {
+ tags: ['Tickets'],
+ summary: 'Transition ticket status',
+ description:
+ 'Move the ticket to a new status. Sets lifecycle timestamps based on the destination category. Requires `ticket.edit_fields`.',
+ parameters: [{ name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) }],
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: asSchema(
+ z.object({
+ expectedUpdatedAt: z.string().datetime(),
+ statusId: TypeIdSchema,
+ })
+ ),
+ },
+ },
+ },
+ responses: {
+ 200: {
+ description: 'Ticket transitioned',
+ content: {
+ 'application/json': { schema: createItemResponseSchema(TicketSchema, 'Ticket') },
+ },
+ },
+ 400: {
+ description: 'Validation error',
+ content: { 'application/json': { schema: ValidationErrorSchema } },
+ },
+ 403: { description: 'ticket.edit_fields required' },
+ 404: {
+ description: 'Ticket not found',
+ content: { 'application/json': { schema: NotFoundErrorSchema } },
+ },
+ },
+ },
+})
+
+// ---------------------------------------------------------------------------
+// Sub-resource deletions
+// ---------------------------------------------------------------------------
+
+registerPath('/tickets/{ticketId}/shares/{shareId}', {
+ delete: {
+ tags: ['Tickets'],
+ summary: 'Revoke a share',
+ description: 'Revoke a cross-team share grant. Requires `ticket.share_cross_team`.',
+ parameters: [
+ { name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ { name: 'shareId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ ],
+ responses: {
+ 204: { description: 'Share revoked' },
+ 403: { description: 'ticket.share_cross_team required' },
+ 404: {
+ description: 'Ticket or share not found',
+ content: { 'application/json': { schema: NotFoundErrorSchema } },
+ },
+ },
+ },
+})
+
+registerPath('/tickets/{ticketId}/participants/{participantId}', {
+ delete: {
+ tags: ['Tickets'],
+ summary: 'Remove a participant',
+ description:
+ 'Remove a watcher/collaborator/CC from the ticket. Requires `ticket.manage_participants`.',
+ parameters: [
+ { name: 'ticketId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ { name: 'participantId', in: 'path', required: true, schema: asSchema(TypeIdSchema) },
+ ],
+ responses: {
+ 204: { description: 'Participant removed' },
+ 403: { description: 'ticket.manage_participants required' },
+ 404: {
+ description: 'Ticket or participant not found',
+ content: { 'application/json': { schema: NotFoundErrorSchema } },
+ },
+ },
+ },
+})
diff --git a/apps/web/src/lib/server/domains/audit/__tests__/audit-unified.test.ts b/apps/web/src/lib/server/domains/audit/__tests__/audit-unified.test.ts
new file mode 100644
index 000000000..d7d03f5c1
--- /dev/null
+++ b/apps/web/src/lib/server/domains/audit/__tests__/audit-unified.test.ts
@@ -0,0 +1,494 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const hoisted = vi.hoisted(() => ({
+ workspaceLimitMock: vi.fn(),
+ securityLimitMock: vi.fn(),
+ workspaceActionsLimitMock: vi.fn(),
+ securityActionsLimitMock: vi.fn(),
+ workspaceWhereMock: vi.fn(),
+ securityWhereMock: vi.fn(),
+ workspaceOrderByMock: vi.fn(),
+ securityOrderByMock: vi.fn(),
+ workspaceLeftJoinMock: vi.fn(),
+ selectMock: vi.fn(),
+ selectDistinctMock: vi.fn(),
+ andMock: vi.fn(),
+ descMock: vi.fn(),
+ eqMock: vi.fn(),
+ gteMock: vi.fn(),
+ ilikeMock: vi.fn(),
+ likeMock: vi.fn(),
+ ltMock: vi.fn(),
+ lteMock: vi.fn(),
+ notInArrayMock: vi.fn(),
+ orMock: vi.fn(),
+}))
+
+function tableChain(kind: 'workspace' | 'security') {
+ const chain = {
+ leftJoin: (...args: unknown[]) => {
+ hoisted.workspaceLeftJoinMock(...args)
+ return chain
+ },
+ where: (condition: unknown) => {
+ if (kind === 'workspace') hoisted.workspaceWhereMock(condition)
+ else hoisted.securityWhereMock(condition)
+ return chain
+ },
+ orderBy: (...args: unknown[]) => {
+ if (kind === 'workspace') hoisted.workspaceOrderByMock(...args)
+ else hoisted.securityOrderByMock(...args)
+ return chain
+ },
+ limit: (limit: number) =>
+ kind === 'workspace' ? hoisted.workspaceLimitMock(limit) : hoisted.securityLimitMock(limit),
+ }
+ return chain
+}
+
+function distinctChain(kind: 'workspace' | 'security') {
+ const chain = {
+ orderBy: () => chain,
+ limit: (limit: number) =>
+ kind === 'workspace'
+ ? hoisted.workspaceActionsLimitMock(limit)
+ : hoisted.securityActionsLimitMock(limit),
+ }
+ return chain
+}
+
+vi.mock('@/lib/server/db', () => ({
+ and: (...args: unknown[]) => hoisted.andMock(...args),
+ auditEvents: {
+ _table: 'workspace',
+ id: 'auditEvents.id',
+ createdAt: 'auditEvents.createdAt',
+ principalId: 'auditEvents.principalId',
+ action: 'auditEvents.action',
+ targetType: 'auditEvents.targetType',
+ targetId: 'auditEvents.targetId',
+ diff: 'auditEvents.diff',
+ source: 'auditEvents.source',
+ ipAddress: 'auditEvents.ipAddress',
+ userAgent: 'auditEvents.userAgent',
+ },
+ auditLog: {
+ _table: 'security',
+ id: 'auditLog.id',
+ occurredAt: 'auditLog.occurredAt',
+ actorUserId: 'auditLog.actorUserId',
+ actorEmail: 'auditLog.actorEmail',
+ actorRole: 'auditLog.actorRole',
+ actorIp: 'auditLog.actorIp',
+ actorUserAgent: 'auditLog.actorUserAgent',
+ eventType: 'auditLog.eventType',
+ eventOutcome: 'auditLog.eventOutcome',
+ targetType: 'auditLog.targetType',
+ targetId: 'auditLog.targetId',
+ beforeValue: 'auditLog.beforeValue',
+ afterValue: 'auditLog.afterValue',
+ metadata: 'auditLog.metadata',
+ requestId: 'auditLog.requestId',
+ actorType: 'auditLog.actorType',
+ authMethod: 'auditLog.authMethod',
+ },
+ db: {
+ select: (...args: unknown[]) => hoisted.selectMock(...args),
+ selectDistinct: (...args: unknown[]) => hoisted.selectDistinctMock(...args),
+ },
+ desc: (...args: unknown[]) => hoisted.descMock(...args),
+ eq: (...args: unknown[]) => hoisted.eqMock(...args),
+ gte: (...args: unknown[]) => hoisted.gteMock(...args),
+ ilike: (...args: unknown[]) => hoisted.ilikeMock(...args),
+ like: (...args: unknown[]) => hoisted.likeMock(...args),
+ lt: (...args: unknown[]) => hoisted.ltMock(...args),
+ lte: (...args: unknown[]) => hoisted.lteMock(...args),
+ notInArray: (...args: unknown[]) => hoisted.notInArrayMock(...args),
+ or: (...args: unknown[]) => hoisted.orMock(...args),
+ principal: {
+ id: 'principal.id',
+ userId: 'principal.userId',
+ displayName: 'principal.displayName',
+ role: 'principal.role',
+ type: 'principal.type',
+ },
+ user: {
+ id: 'user.id',
+ email: 'user.email',
+ name: 'user.name',
+ },
+}))
+
+import {
+ decodeUnifiedAuditCursor,
+ encodeUnifiedAuditCursor,
+ listUnifiedAuditActions,
+ listUnifiedAuditEvents,
+ pageUnifiedAuditRows,
+ type UnifiedAuditEventRow,
+} from '../audit.unified'
+
+function row(overrides: Partial): UnifiedAuditEventRow {
+ return {
+ id: 'audit_default',
+ origin: 'workspace',
+ occurredAt: new Date('2026-06-01T12:00:00.000Z'),
+ principalId: null,
+ actorUserId: null,
+ actorEmail: null,
+ actorDisplayName: null,
+ actorRole: null,
+ actorType: null,
+ authMethod: null,
+ action: 'ticket.created',
+ outcome: null,
+ source: 'web',
+ targetType: 'ticket',
+ targetId: 'ticket_1',
+ requestId: null,
+ ipAddress: null,
+ userAgent: null,
+ diff: {},
+ metadata: null,
+ ...overrides,
+ }
+}
+
+function workspaceDbRow(overrides: Record = {}) {
+ return {
+ id: 'audit_workspace',
+ createdAt: new Date('2026-06-01T12:00:00.000Z'),
+ principalId: 'principal_1',
+ action: 'ticket.created',
+ targetType: 'ticket',
+ targetId: 'ticket_1',
+ diff: { after: { title: 'Hello' } },
+ source: 'web',
+ ipAddress: '203.0.113.10',
+ userAgent: 'vitest',
+ actorUserId: 'user_1',
+ actorEmail: 'agent@example.com',
+ actorDisplayName: null,
+ actorRole: 'admin',
+ actorType: 'user',
+ userName: 'Agent User',
+ ...overrides,
+ }
+}
+
+function securityDbRow(overrides: Record = {}) {
+ return {
+ id: 'audit_security',
+ occurredAt: new Date('2026-06-01T12:01:00.000Z'),
+ actorUserId: 'user_2',
+ actorEmail: 'security@example.com',
+ actorRole: 'admin',
+ actorIp: '203.0.113.11',
+ actorUserAgent: 'vitest-security',
+ eventType: 'auth.signin.success',
+ eventOutcome: 'success',
+ targetType: 'user',
+ targetId: 'user_2',
+ beforeValue: { enabled: false },
+ afterValue: { enabled: true },
+ metadata: { provider: 'sso' },
+ requestId: 'req_123',
+ actorType: 'user',
+ authMethod: 'sso',
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ vi.resetAllMocks()
+ hoisted.andMock.mockImplementation((...parts: unknown[]) => ['and', ...parts])
+ hoisted.descMock.mockImplementation((column: unknown) => ['desc', column])
+ hoisted.eqMock.mockImplementation((left: unknown, right: unknown) => ['eq', left, right])
+ hoisted.gteMock.mockImplementation((left: unknown, right: unknown) => ['gte', left, right])
+ hoisted.ilikeMock.mockImplementation((left: unknown, right: unknown) => ['ilike', left, right])
+ hoisted.likeMock.mockImplementation((left: unknown, right: unknown) => ['like', left, right])
+ hoisted.ltMock.mockImplementation((left: unknown, right: unknown) => ['lt', left, right])
+ hoisted.lteMock.mockImplementation((left: unknown, right: unknown) => ['lte', left, right])
+ hoisted.notInArrayMock.mockImplementation((left: unknown, right: unknown) => [
+ 'notInArray',
+ left,
+ right,
+ ])
+ hoisted.orMock.mockImplementation((...parts: unknown[]) => ['or', ...parts])
+ hoisted.selectMock.mockReturnValue({
+ from: (table: { _table?: 'workspace' | 'security' }) => tableChain(table._table ?? 'workspace'),
+ })
+ hoisted.selectDistinctMock.mockReturnValue({
+ from: (table: { _table?: 'workspace' | 'security' }) =>
+ distinctChain(table._table ?? 'workspace'),
+ })
+ hoisted.workspaceLimitMock.mockResolvedValue([])
+ hoisted.securityLimitMock.mockResolvedValue([])
+ hoisted.workspaceActionsLimitMock.mockResolvedValue([])
+ hoisted.securityActionsLimitMock.mockResolvedValue([])
+})
+
+describe('pageUnifiedAuditRows', () => {
+ it('sorts mixed workspace and security rows by timestamp, origin, then id', () => {
+ const page = pageUnifiedAuditRows(
+ [
+ row({
+ id: 'audit_a',
+ origin: 'security',
+ occurredAt: new Date('2026-06-01T12:00:00.000Z'),
+ action: 'auth.signin.success',
+ }),
+ row({
+ id: 'audit_b',
+ origin: 'workspace',
+ occurredAt: new Date('2026-06-01T12:00:00.000Z'),
+ action: 'ticket.updated',
+ }),
+ row({
+ id: 'audit_c',
+ origin: 'workspace',
+ occurredAt: new Date('2026-06-01T12:01:00.000Z'),
+ action: 'role.granted',
+ }),
+ ],
+ { limit: 10 }
+ )
+
+ expect(page.items.map((item) => `${item.origin}:${item.id}`)).toEqual([
+ 'workspace:audit_c',
+ 'workspace:audit_b',
+ 'security:audit_a',
+ ])
+ })
+
+ it('paginates without duplicates when the cursor lands between origins at the same timestamp', () => {
+ const rows = [
+ row({
+ id: 'audit_c',
+ origin: 'workspace',
+ occurredAt: new Date('2026-06-01T12:00:00.000Z'),
+ }),
+ row({
+ id: 'audit_b',
+ origin: 'workspace',
+ occurredAt: new Date('2026-06-01T12:00:00.000Z'),
+ }),
+ row({
+ id: 'audit_z',
+ origin: 'security',
+ occurredAt: new Date('2026-06-01T12:00:00.000Z'),
+ }),
+ ]
+
+ const first = pageUnifiedAuditRows(rows, { limit: 2 })
+ const second = pageUnifiedAuditRows(rows, { limit: 2, cursor: first.nextCursor ?? undefined })
+
+ expect(first.items.map((item) => `${item.origin}:${item.id}`)).toEqual([
+ 'workspace:audit_c',
+ 'workspace:audit_b',
+ ])
+ expect(second.items.map((item) => `${item.origin}:${item.id}`)).toEqual(['security:audit_z'])
+ })
+
+ it('preserves security observability fields in paged rows', () => {
+ const security = row({
+ id: 'audit_security',
+ origin: 'security',
+ action: 'auth.signin.success',
+ outcome: 'success',
+ requestId: 'req_abc123',
+ actorType: 'user',
+ authMethod: 'sso',
+ metadata: { method: 'sso' },
+ })
+
+ const page = pageUnifiedAuditRows([security], {
+ cursor: encodeUnifiedAuditCursor(
+ row({
+ id: 'audit_newer',
+ origin: 'workspace',
+ occurredAt: new Date('2026-06-01T12:01:00.000Z'),
+ })
+ ),
+ })
+
+ expect(page.items[0]).toMatchObject({
+ requestId: 'req_abc123',
+ actorType: 'user',
+ authMethod: 'sso',
+ metadata: { method: 'sso' },
+ })
+ })
+
+ it('decodes missing, malformed, and valid cursors defensively', () => {
+ expect(decodeUnifiedAuditCursor(undefined)).toBeNull()
+ expect(decodeUnifiedAuditCursor('not-valid-base64-json')).toBeNull()
+ expect(
+ decodeUnifiedAuditCursor(
+ Buffer.from(JSON.stringify({ t: 'bad', o: 'workspace', i: 'x' })).toString('base64url')
+ )
+ ).toBeNull()
+
+ const cursor = encodeUnifiedAuditCursor(
+ row({ id: 'audit_cursor', occurredAt: new Date('2026-06-01T12:02:00.000Z') })
+ )
+ expect(decodeUnifiedAuditCursor(cursor)).toEqual({
+ t: new Date('2026-06-01T12:02:00.000Z').getTime(),
+ o: 'workspace',
+ i: 'audit_cursor',
+ })
+ })
+})
+
+describe('listUnifiedAuditEvents', () => {
+ it('queries workspace and security audit stores, normalizes rows, and applies shared filters', async () => {
+ hoisted.workspaceLimitMock.mockResolvedValue([
+ workspaceDbRow({ actorDisplayName: null, userName: 'Workspace Agent' }),
+ ])
+ hoisted.securityLimitMock.mockResolvedValue([securityDbRow()])
+ const cursor = encodeUnifiedAuditCursor(
+ row({
+ id: 'audit_cursor',
+ origin: 'security',
+ occurredAt: new Date('2026-06-01T12:02:00.000Z'),
+ })
+ )
+
+ const page = await listUnifiedAuditEvents({
+ actionPrefix: 'ticket.',
+ targetType: 'ticket',
+ targetId: 'ticket_1',
+ actorEmail: ' Agent ',
+ from: new Date('2026-06-01T00:00:00.000Z'),
+ to: new Date('2026-06-02T00:00:00.000Z'),
+ cursor,
+ limit: 500,
+ })
+
+ expect(page.items).toHaveLength(2)
+ expect(page.items[0]).toMatchObject({
+ id: 'audit_security',
+ origin: 'security',
+ action: 'auth.signin.success',
+ outcome: 'success',
+ requestId: 'req_123',
+ ipAddress: '203.0.113.11',
+ userAgent: 'vitest-security',
+ diff: {
+ before: { enabled: false },
+ after: { enabled: true },
+ context: {
+ metadata: { provider: 'sso' },
+ requestId: 'req_123',
+ actorType: 'user',
+ authMethod: 'sso',
+ },
+ },
+ })
+ expect(page.items[1]).toMatchObject({
+ id: 'audit_workspace',
+ origin: 'workspace',
+ actorDisplayName: 'Workspace Agent',
+ source: 'web',
+ diff: { after: { title: 'Hello' } },
+ })
+ expect(hoisted.workspaceLimitMock).toHaveBeenCalledWith(201)
+ expect(hoisted.securityLimitMock).toHaveBeenCalledWith(201)
+ expect(hoisted.ilikeMock).toHaveBeenCalledWith('user.email', '%Agent%')
+ expect(hoisted.ilikeMock).toHaveBeenCalledWith('auditLog.actorEmail', '%Agent%')
+ expect(hoisted.workspaceWhereMock).toHaveBeenCalledWith(expect.arrayContaining(['and']))
+ expect(hoisted.securityWhereMock).toHaveBeenCalledWith(expect.arrayContaining(['and']))
+ expect(hoisted.workspaceLeftJoinMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('queries only workspace rows when workspace-only filters cannot apply to security audit', async () => {
+ const workspace = workspaceDbRow({
+ id: 'audit_workspace_only',
+ actorDisplayName: 'Direct Name',
+ userName: 'Fallback Name',
+ })
+ hoisted.workspaceLimitMock.mockResolvedValue([workspace])
+
+ const page = await listUnifiedAuditEvents({
+ principalId: 'principal_1' as never,
+ source: 'api',
+ action: 'ticket.updated',
+ limit: 0,
+ })
+
+ expect(page.items).toHaveLength(1)
+ expect(page.items[0]).toMatchObject({
+ id: 'audit_workspace_only',
+ actorDisplayName: 'Direct Name',
+ source: 'web',
+ })
+ expect(hoisted.workspaceLimitMock).toHaveBeenCalledWith(2)
+ expect(hoisted.securityLimitMock).not.toHaveBeenCalled()
+ expect(hoisted.eqMock).toHaveBeenCalledWith('auditEvents.principalId', 'principal_1')
+ expect(hoisted.eqMock).toHaveBeenCalledWith('auditEvents.source', 'api')
+ })
+
+ it('queries only security rows with exclusions and cursor ordering from a workspace cursor', async () => {
+ hoisted.securityLimitMock.mockResolvedValue([
+ securityDbRow({
+ id: 'audit_security_contextless',
+ beforeValue: null,
+ afterValue: undefined,
+ metadata: null,
+ requestId: null,
+ actorType: null,
+ authMethod: null,
+ }),
+ ])
+ const cursor = encodeUnifiedAuditCursor(
+ row({
+ id: 'audit_workspace_cursor',
+ origin: 'workspace',
+ occurredAt: new Date('2026-06-01T12:02:00.000Z'),
+ })
+ )
+
+ const page = await listUnifiedAuditEvents({
+ origin: 'security',
+ excludeSecurityActions: ['auth.session.refresh'],
+ cursor,
+ })
+
+ expect(page.items).toHaveLength(1)
+ expect(page.items[0]).toMatchObject({
+ id: 'audit_security_contextless',
+ origin: 'security',
+ diff: {},
+ metadata: null,
+ })
+ expect(hoisted.workspaceLimitMock).not.toHaveBeenCalled()
+ expect(hoisted.notInArrayMock).toHaveBeenCalledWith('auditLog.eventType', [
+ 'auth.session.refresh',
+ ])
+ expect(hoisted.lteMock).toHaveBeenCalledWith(
+ 'auditLog.occurredAt',
+ new Date('2026-06-01T12:02:00.000Z')
+ )
+ })
+})
+
+describe('listUnifiedAuditActions', () => {
+ it('merges and sorts distinct action names across stores', async () => {
+ hoisted.workspaceActionsLimitMock.mockResolvedValue([
+ { action: 'ticket.updated' },
+ { action: 'role.assigned' },
+ ])
+ hoisted.securityActionsLimitMock.mockResolvedValue([
+ { action: 'auth.signin.success' },
+ { action: 'ticket.updated' },
+ ])
+
+ await expect(listUnifiedAuditActions()).resolves.toEqual([
+ 'auth.signin.success',
+ 'role.assigned',
+ 'ticket.updated',
+ ])
+ expect(hoisted.workspaceActionsLimitMock).toHaveBeenCalledWith(200)
+ expect(hoisted.securityActionsLimitMock).toHaveBeenCalledWith(200)
+ })
+})
diff --git a/apps/web/src/lib/server/domains/audit/__tests__/audit.context.diffcov.test.ts b/apps/web/src/lib/server/domains/audit/__tests__/audit.context.diffcov.test.ts
new file mode 100644
index 000000000..ff48e6552
--- /dev/null
+++ b/apps/web/src/lib/server/domains/audit/__tests__/audit.context.diffcov.test.ts
@@ -0,0 +1,59 @@
+/**
+ * Differential-coverage tests for audit.context — every branch of
+ * buildAuditContext: null auth, the AuthContext (principal) shape, and the
+ * ApiAuthContext (principalId) shape, each with present and defaulted fields.
+ */
+import { describe, it, expect } from 'vitest'
+import { buildAuditContext } from '../audit.context'
+
+describe('buildAuditContext', () => {
+ it('returns system attribution for null/undefined auth', () => {
+ expect(buildAuditContext(null)).toEqual({
+ principalId: null,
+ ipAddress: null,
+ userAgent: null,
+ source: 'system',
+ })
+ expect(buildAuditContext(undefined)).toMatchObject({ source: 'system' })
+ })
+
+ it('maps an AuthContext with all fields present', () => {
+ expect(
+ buildAuditContext({
+ principal: { id: 'p1' as never },
+ ipAddress: '1.2.3.4',
+ userAgent: 'agent',
+ source: 'api',
+ } as never)
+ ).toEqual({ principalId: 'p1', ipAddress: '1.2.3.4', userAgent: 'agent', source: 'api' })
+ })
+
+ it('defaults an AuthContext with missing fields (web source)', () => {
+ expect(buildAuditContext({ principal: { id: 'p1' as never } } as never)).toEqual({
+ principalId: 'p1',
+ ipAddress: null,
+ userAgent: null,
+ source: 'web',
+ })
+ })
+
+ it('maps an ApiAuthContext with all fields present', () => {
+ expect(
+ buildAuditContext({
+ principalId: 'p2' as never,
+ ipAddress: '5.6.7.8',
+ userAgent: 'ua',
+ source: 'web' as never,
+ })
+ ).toEqual({ principalId: 'p2', ipAddress: '5.6.7.8', userAgent: 'ua', source: 'web' })
+ })
+
+ it('defaults an ApiAuthContext with missing fields (api source)', () => {
+ expect(buildAuditContext({ principalId: 'p2' as never } as never)).toEqual({
+ principalId: 'p2',
+ ipAddress: null,
+ userAgent: null,
+ source: 'api',
+ })
+ })
+})
diff --git a/apps/web/src/lib/server/domains/audit/__tests__/audit.queries.diffcov.test.ts b/apps/web/src/lib/server/domains/audit/__tests__/audit.queries.diffcov.test.ts
new file mode 100644
index 000000000..4e8772788
--- /dev/null
+++ b/apps/web/src/lib/server/domains/audit/__tests__/audit.queries.diffcov.test.ts
@@ -0,0 +1,126 @@
+/**
+ * Differential-coverage tests for audit.queries — cursor encode/decode,
+ * the full filter-condition matrix, cursor pagination (hasMore / nextCursor),
+ * and distinct-action listing.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+const m = vi.hoisted(() => ({
+ limitMock: vi.fn(),
+ distinctLimitMock: vi.fn(),
+}))
+
+vi.mock('@/lib/server/db', () => {
+ const selectChain = {
+ from: () => ({
+ where: () => ({ orderBy: () => ({ limit: m.limitMock }) }),
+ }),
+ }
+ const distinctChain = {
+ from: () => ({ orderBy: () => ({ limit: m.distinctLimitMock }) }),
+ }
+ const col = (name: string) => name
+ return {
+ db: {
+ select: vi.fn(() => selectChain),
+ selectDistinct: vi.fn(() => distinctChain),
+ },
+ auditEvents: {
+ id: col('id'),
+ createdAt: col('createdAt'),
+ principalId: col('principalId'),
+ action: col('action'),
+ targetType: col('targetType'),
+ targetId: col('targetId'),
+ diff: col('diff'),
+ source: col('source'),
+ ipAddress: col('ipAddress'),
+ userAgent: col('userAgent'),
+ },
+ and: vi.fn((...a) => ({ and: a })),
+ or: vi.fn((...a) => ({ or: a })),
+ eq: vi.fn((a, b) => ({ eq: [a, b] })),
+ gte: vi.fn((a, b) => ({ gte: [a, b] })),
+ lte: vi.fn((a, b) => ({ lte: [a, b] })),
+ lt: vi.fn((a, b) => ({ lt: [a, b] })),
+ like: vi.fn((a, b) => ({ like: [a, b] })),
+ desc: vi.fn((a) => a),
+ asc: vi.fn((a) => a),
+ sql: vi.fn(),
+ }
+})
+
+import { encodeCursor, decodeCursor, listAuditEvents, listDistinctActions } from '../audit.queries'
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ m.limitMock.mockResolvedValue([])
+ m.distinctLimitMock.mockResolvedValue([])
+})
+
+describe('cursor encode/decode', () => {
+ it('round-trips a cursor', () => {
+ const cursor = encodeCursor(new Date('2026-01-02T03:04:05Z'), 'evt_1')
+ const decoded = decodeCursor(cursor)
+ expect(decoded).toEqual({ t: new Date('2026-01-02T03:04:05Z').getTime(), i: 'evt_1' })
+ })
+
+ it('returns null on malformed base64/json', () => {
+ expect(decodeCursor('!!!not-base64-json!!!')).toBeNull()
+ })
+
+ it('returns null when the payload has the wrong shape', () => {
+ const bad = Buffer.from(JSON.stringify({ t: 'x', i: 5 }), 'utf8').toString('base64url')
+ expect(decodeCursor(bad)).toBeNull()
+ })
+})
+
+describe('listAuditEvents', () => {
+ it('applies every filter plus a valid cursor and clamps the limit', async () => {
+ const cursor = encodeCursor(new Date('2026-01-01T00:00:00Z'), 'evt_cursor')
+ m.limitMock.mockResolvedValueOnce([])
+ const res = await listAuditEvents({
+ principalId: 'p1' as never,
+ action: 'ticket.created',
+ actionPrefix: 'ticket.',
+ targetType: 'ticket',
+ targetId: 't1',
+ source: 'api' as never,
+ from: new Date('2025-01-01'),
+ to: new Date('2026-12-31'),
+ cursor,
+ limit: 9999,
+ })
+ expect(res).toEqual({ items: [], nextCursor: null })
+ })
+
+ it('ignores an undecodable cursor', async () => {
+ const res = await listAuditEvents({ cursor: 'garbage', limit: -5 })
+ expect(res.nextCursor).toBeNull()
+ })
+
+ it('returns nextCursor when there are more rows than the limit', async () => {
+ const rows = [
+ { id: 'a', createdAt: new Date('2026-03-03') },
+ { id: 'b', createdAt: new Date('2026-02-02') },
+ { id: 'c', createdAt: new Date('2026-01-01') },
+ ]
+ m.limitMock.mockResolvedValueOnce(rows)
+ const res = await listAuditEvents({ limit: 2 })
+ expect(res.items).toHaveLength(2)
+ expect(res.nextCursor).toEqual(encodeCursor(new Date('2026-02-02'), 'b'))
+ })
+
+ it('runs with no filters (where undefined)', async () => {
+ m.limitMock.mockResolvedValueOnce([{ id: 'a', createdAt: new Date('2026-01-01') }])
+ const res = await listAuditEvents()
+ expect(res.nextCursor).toBeNull()
+ })
+})
+
+describe('listDistinctActions', () => {
+ it('maps rows to action strings', async () => {
+ m.distinctLimitMock.mockResolvedValueOnce([{ action: 'a.x' }, { action: 'b.y' }])
+ expect(await listDistinctActions()).toEqual(['a.x', 'b.y'])
+ })
+})
diff --git a/apps/web/src/lib/server/domains/audit/__tests__/audit.service.diffcov.test.ts b/apps/web/src/lib/server/domains/audit/__tests__/audit.service.diffcov.test.ts
new file mode 100644
index 000000000..30a8e1eb1
--- /dev/null
+++ b/apps/web/src/lib/server/domains/audit/__tests__/audit.service.diffcov.test.ts
@@ -0,0 +1,105 @@
+/**
+ * Differential-coverage tests for audit.service — recordEvent default fill-in,
+ * the missing-row and error fallbacks, and the listEvents filter matrix.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+const m = vi.hoisted(() => ({
+ returning: vi.fn(),
+ limit: vi.fn(),
+ insertValues: vi.fn(),
+}))
+
+vi.mock('@/lib/server/db', () => ({
+ db: {
+ insert: vi.fn(() => ({
+ values: (v: unknown) => {
+ m.insertValues(v)
+ return { returning: m.returning }
+ },
+ })),
+ select: vi.fn(() => ({
+ from: () => ({ where: () => ({ orderBy: () => ({ limit: m.limit }) }) }),
+ })),
+ },
+ auditEvents: {
+ id: 'ae.id',
+ principalId: 'ae.principalId',
+ action: 'ae.action',
+ targetType: 'ae.targetType',
+ targetId: 'ae.targetId',
+ createdAt: 'ae.createdAt',
+ },
+ desc: vi.fn((a) => a),
+ eq: vi.fn((a, b) => ({ eq: [a, b] })),
+ and: vi.fn((...a) => ({ and: a })),
+ gte: vi.fn((a, b) => ({ gte: [a, b] })),
+ lte: vi.fn((a, b) => ({ lte: [a, b] })),
+}))
+
+import { recordEvent, listEvents } from '../audit.service'
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ m.returning.mockResolvedValue([{ id: 'audit_1' }])
+ m.limit.mockResolvedValue([])
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+})
+
+describe('recordEvent', () => {
+ it('records with default fill-ins and returns the id', async () => {
+ const id = await recordEvent({ action: 'ticket.created', targetType: 'ticket' })
+ expect(id).toBe('audit_1')
+ expect(m.insertValues).toHaveBeenCalledWith(
+ expect.objectContaining({ principalId: null, source: 'web', diff: {} })
+ )
+ })
+
+ it('records with all fields provided', async () => {
+ await recordEvent({
+ principalId: 'p1' as never,
+ action: 'role.granted',
+ targetType: 'role',
+ targetId: 'r1',
+ diff: { context: {} } as never,
+ source: 'api' as never,
+ ipAddress: '1.2.3.4',
+ userAgent: 'agent',
+ })
+ expect(m.insertValues).toHaveBeenCalledWith(
+ expect.objectContaining({ source: 'api', ipAddress: '1.2.3.4' })
+ )
+ })
+
+ it('returns null when no row comes back', async () => {
+ m.returning.mockResolvedValueOnce([])
+ expect(await recordEvent({ action: 'a', targetType: 't' })).toBeNull()
+ })
+
+ it('swallows insert errors and returns null', async () => {
+ m.returning.mockRejectedValueOnce(new Error('db down'))
+ expect(await recordEvent({ action: 'a', targetType: 't' })).toBeNull()
+ expect(console.error).toHaveBeenCalled()
+ })
+})
+
+describe('listEvents', () => {
+ it('applies every filter and clamps the limit', async () => {
+ m.limit.mockResolvedValueOnce([{ id: 'audit_1' }])
+ const res = await listEvents({
+ principalId: 'p1' as never,
+ action: 'ticket.created',
+ targetType: 'ticket',
+ targetId: 't1',
+ since: new Date('2025-01-01'),
+ until: new Date('2026-01-01'),
+ limit: 99999,
+ })
+ expect(res).toEqual([{ id: 'audit_1' }])
+ })
+
+ it('runs with no filters (where undefined, default limit)', async () => {
+ const res = await listEvents()
+ expect(res).toEqual([])
+ })
+})
diff --git a/apps/web/src/lib/server/domains/audit/__tests__/audit.unified.diffcov.test.ts b/apps/web/src/lib/server/domains/audit/__tests__/audit.unified.diffcov.test.ts
new file mode 100644
index 000000000..84923f39c
--- /dev/null
+++ b/apps/web/src/lib/server/domains/audit/__tests__/audit.unified.diffcov.test.ts
@@ -0,0 +1,251 @@
+/**
+ * Differential-coverage tests for audit.unified — cursor encode/decode,
+ * row comparison + in-memory paging, the workspace/security query gating
+ * (origin/principal/source), cursor-condition ordering, security diff shaping,
+ * and the distinct-actions union.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+const m = vi.hoisted(() => {
+ const chain: Record = {}
+ for (const k of ['from', 'leftJoin', 'where', 'orderBy']) chain[k] = () => chain
+ chain.limit = () => Promise.resolve(m.selectResult())
+ return { chain, selectResult: vi.fn(), distinctResult: vi.fn() }
+})
+
+vi.mock('@/lib/server/db', () => ({
+ db: {
+ select: () => m.chain,
+ selectDistinct: () => ({
+ from: () => ({ orderBy: () => ({ limit: () => m.distinctResult() }) }),
+ }),
+ },
+ and: vi.fn((...a) => ({ and: a })),
+ or: vi.fn((...a) => ({ or: a })),
+ eq: vi.fn(),
+ gte: vi.fn(),
+ lte: vi.fn(),
+ lt: vi.fn(),
+ like: vi.fn(),
+ ilike: vi.fn(),
+ notInArray: vi.fn(),
+ desc: vi.fn(),
+ auditEvents: {
+ id: 'ae.id',
+ action: 'ae.action',
+ createdAt: 'ae.createdAt',
+ principalId: 'ae.principalId',
+ targetType: 'ae.targetType',
+ targetId: 'ae.targetId',
+ source: 'ae.source',
+ },
+ auditLog: {
+ id: 'al.id',
+ eventType: 'al.eventType',
+ occurredAt: 'al.occurredAt',
+ targetType: 'al.targetType',
+ targetId: 'al.targetId',
+ actorEmail: 'al.actorEmail',
+ },
+ principal: { id: 'pr.id', userId: 'pr.userId' },
+ user: { id: 'u.id', email: 'u.email' },
+}))
+
+import {
+ encodeUnifiedAuditCursor,
+ decodeUnifiedAuditCursor,
+ compareUnifiedAuditRows,
+ pageUnifiedAuditRows,
+ listUnifiedAuditEvents,
+ listUnifiedAuditActions,
+} from '../audit.unified'
+
+const row = (over: Record = {}) => ({
+ id: 'r1',
+ origin: 'workspace' as const,
+ occurredAt: new Date('2026-03-03'),
+ principalId: null,
+ actorUserId: null,
+ actorEmail: null,
+ actorDisplayName: null,
+ actorRole: null,
+ actorType: null,
+ authMethod: null,
+ action: 'a',
+ outcome: null,
+ source: null,
+ targetType: null,
+ targetId: null,
+ requestId: null,
+ ipAddress: null,
+ userAgent: null,
+ diff: null,
+ metadata: null,
+ ...over,
+})
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ m.selectResult.mockReturnValue([])
+ m.distinctResult.mockResolvedValue([])
+})
+
+describe('cursor encode/decode', () => {
+ it('round-trips', () => {
+ const c = encodeUnifiedAuditCursor({
+ occurredAt: new Date('2026-01-01'),
+ origin: 'security',
+ id: 'x',
+ })
+ expect(decodeUnifiedAuditCursor(c)).toEqual({
+ t: new Date('2026-01-01').getTime(),
+ o: 'security',
+ i: 'x',
+ })
+ })
+ it('returns null for undefined / malformed / wrong-shape cursors', () => {
+ expect(decodeUnifiedAuditCursor(undefined)).toBeNull()
+ expect(decodeUnifiedAuditCursor('!!notbase64json')).toBeNull()
+ const bad = Buffer.from(JSON.stringify({ t: 'x', o: 'nope', i: 1 }), 'utf8').toString(
+ 'base64url'
+ )
+ expect(decodeUnifiedAuditCursor(bad)).toBeNull()
+ })
+})
+
+describe('compareUnifiedAuditRows + paging', () => {
+ it('orders by time, then origin, then id', () => {
+ const older = row({ occurredAt: new Date('2026-01-01') })
+ const newer = row({ occurredAt: new Date('2026-02-02') })
+ expect(compareUnifiedAuditRows(newer, older)).toBeLessThan(0)
+ const wsp = row({ origin: 'workspace' })
+ const sec = row({ origin: 'security' })
+ expect(compareUnifiedAuditRows(wsp, sec)).toBeLessThan(0)
+ expect(compareUnifiedAuditRows(row({ id: 'b' }), row({ id: 'a' }))).toBeLessThan(0)
+ })
+ it('pages: sorts, slices to limit, and emits a next cursor', () => {
+ const page = pageUnifiedAuditRows(
+ [
+ row({ id: 'a', occurredAt: new Date('2026-03-03') }),
+ row({ id: 'b', occurredAt: new Date('2026-02-02') }),
+ row({ id: 'c', occurredAt: new Date('2026-01-01') }),
+ ],
+ { limit: 2 }
+ )
+ expect(page.items).toHaveLength(2)
+ expect(page.nextCursor).not.toBeNull()
+ })
+ it('pages with a cursor (keeps only rows after the cursor position)', () => {
+ // Descending feed: the cursor is the last (oldest) row of the previous
+ // page, so page 2 keeps rows OLDER than it.
+ const cursor = encodeUnifiedAuditCursor({
+ occurredAt: new Date('2026-02-15'),
+ origin: 'workspace',
+ id: 'b',
+ })
+ const page = pageUnifiedAuditRows(
+ [
+ row({ id: 'a', occurredAt: new Date('2026-03-03') }),
+ row({ id: 'z', occurredAt: new Date('2026-01-01') }),
+ ],
+ { cursor }
+ )
+ expect(page.items.map((r) => r.id)).toEqual(['z'])
+ })
+})
+
+describe('listUnifiedAuditEvents query gating', () => {
+ it('queries only workspace when origin=workspace and maps rows', async () => {
+ m.selectResult.mockReturnValueOnce([
+ {
+ id: 'w1',
+ createdAt: new Date('2026-03-03'),
+ principalId: 'p1',
+ action: 'ticket.created',
+ targetType: 't',
+ targetId: 'x',
+ diff: {},
+ source: 'web',
+ ipAddress: null,
+ userAgent: null,
+ actorUserId: 'u1',
+ actorEmail: 'a@x.test',
+ actorDisplayName: null,
+ actorRole: 'agent',
+ actorType: 'user',
+ userName: 'Fallback',
+ },
+ ])
+ const page = await listUnifiedAuditEvents({
+ origin: 'workspace',
+ action: 'ticket.created',
+ actorEmail: ' a ',
+ from: new Date('2025-01-01'),
+ to: new Date('2026-12-31'),
+ targetType: 't',
+ targetId: 'x',
+ })
+ expect(page.items[0].actorDisplayName).toBe('Fallback')
+ })
+ it('skips the security store when a principal filter is set', async () => {
+ await listUnifiedAuditEvents({ principalId: 'p1' as never })
+ // both branches still produce a valid page
+ expect(m.selectResult).toHaveBeenCalled()
+ })
+ it('queries only security when origin=security and shapes the diff + cursor', async () => {
+ const cursor = encodeUnifiedAuditCursor({
+ occurredAt: new Date('2026-02-15'),
+ origin: 'workspace',
+ id: 'b',
+ })
+ m.selectResult.mockReturnValueOnce([
+ {
+ id: 's1',
+ occurredAt: new Date('2026-01-01'),
+ actorUserId: 'u1',
+ actorEmail: 'a@x.test',
+ actorRole: 'admin',
+ actorIp: '1.1.1.1',
+ actorUserAgent: 'ua',
+ eventType: 'auth.login',
+ eventOutcome: 'success',
+ targetType: null,
+ targetId: null,
+ beforeValue: { x: 1 },
+ afterValue: { x: 2 },
+ metadata: { k: 'v' },
+ requestId: 'req_1',
+ actorType: 'user',
+ authMethod: 'password',
+ },
+ ])
+ const page = await listUnifiedAuditEvents({
+ origin: 'security',
+ actionPrefix: 'auth.',
+ excludeSecurityActions: ['noise'],
+ cursor,
+ })
+ expect(page.items[0].diff).toMatchObject({
+ before: { x: 1 },
+ after: { x: 2 },
+ context: { requestId: 'req_1' },
+ })
+ })
+ it('queries both stores by default (cursor ordering across origins)', async () => {
+ const cursor = encodeUnifiedAuditCursor({
+ occurredAt: new Date('2026-02-15'),
+ origin: 'security',
+ id: 'b',
+ })
+ await listUnifiedAuditEvents({ cursor })
+ expect(m.selectResult).toHaveBeenCalledTimes(2)
+ })
+})
+
+describe('listUnifiedAuditActions', () => {
+ it('unions + sorts distinct actions from both stores', async () => {
+ m.distinctResult.mockResolvedValueOnce([{ action: 'b' }, { action: 'a' }])
+ m.distinctResult.mockResolvedValueOnce([{ action: 'a' }, { action: 'c' }])
+ expect(await listUnifiedAuditActions()).toEqual(['a', 'b', 'c'])
+ })
+})
diff --git a/apps/web/src/lib/server/domains/audit/audit.context.ts b/apps/web/src/lib/server/domains/audit/audit.context.ts
new file mode 100644
index 000000000..7fa0f3a4c
--- /dev/null
+++ b/apps/web/src/lib/server/domains/audit/audit.context.ts
@@ -0,0 +1,45 @@
+/**
+ * Build a partial audit context (principal + IP/UA + source) from any
+ * AuthContext-like value. Lets write paths emit `recordEvent` without
+ * each caller manually re-reading request headers.
+ */
+
+import type { AuthContext } from '@/lib/server/functions/auth-helpers'
+import type { ApiAuthContext } from '@/lib/server/domains/api/auth'
+import type { PrincipalId } from '@quackback/ids'
+import type { AuditSource } from '@/lib/server/db'
+
+export interface AuditAttribution {
+ principalId: PrincipalId | null
+ ipAddress: string | null
+ userAgent: string | null
+ source: AuditSource
+}
+
+export type AuditAuthLike =
+ | (Pick & {
+ principal: { id: PrincipalId }
+ })
+ | Pick
+ | null
+ | undefined
+
+export function buildAuditContext(auth: AuditAuthLike): AuditAttribution {
+ if (!auth) {
+ return { principalId: null, ipAddress: null, userAgent: null, source: 'system' }
+ }
+ if ('principal' in auth) {
+ return {
+ principalId: auth.principal.id,
+ ipAddress: auth.ipAddress ?? null,
+ userAgent: auth.userAgent ?? null,
+ source: auth.source ?? 'web',
+ }
+ }
+ return {
+ principalId: auth.principalId,
+ ipAddress: auth.ipAddress ?? null,
+ userAgent: auth.userAgent ?? null,
+ source: auth.source ?? 'api',
+ }
+}
diff --git a/apps/web/src/lib/server/domains/audit/audit.queries.ts b/apps/web/src/lib/server/domains/audit/audit.queries.ts
new file mode 100644
index 000000000..14409a81e
--- /dev/null
+++ b/apps/web/src/lib/server/domains/audit/audit.queries.ts
@@ -0,0 +1,144 @@
+/**
+ * Read-side queries for audit_events. The write path lives in
+ * `audit.service.ts` (`recordEvent`); this module provides filtered listing
+ * with cursor-based pagination for both server functions and the public
+ * REST endpoint.
+ */
+
+import { db, auditEvents, and, or, eq, gte, lte, lt, like, desc, sql, asc } from '@/lib/server/db'
+import type { PrincipalId } from '@quackback/ids'
+import type { AuditSource } from '@/lib/server/db'
+
+export interface ListAuditEventsInput {
+ principalId?: PrincipalId
+ action?: string
+ /** Match all actions starting with this dotted prefix (e.g. "ticket."). */
+ actionPrefix?: string
+ targetType?: string
+ targetId?: string
+ source?: AuditSource
+ /** Inclusive lower bound on createdAt. */
+ from?: Date
+ /** Inclusive upper bound on createdAt. */
+ to?: Date
+ /** Opaque cursor returned by a previous call (`encodeCursor`). */
+ cursor?: string
+ /** Page size; clamped to [1, 200], default 50. */
+ limit?: number
+}
+
+export interface ListAuditEventsPage {
+ items: Awaited> extends never ? never : AuditEventRow[]
+ nextCursor: string | null
+}
+
+type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue }
+
+export type AuditEventRow = {
+ id: string
+ createdAt: Date
+ principalId: string | null
+ action: string
+ targetType: string
+ targetId: string | null
+ diff: JsonValue
+ source: AuditSource
+ ipAddress: string | null
+ userAgent: string | null
+}
+
+interface CursorPayload {
+ t: number // createdAt epoch ms
+ i: string // event id
+}
+
+export function encodeCursor(createdAt: Date, id: string): string {
+ const payload: CursorPayload = { t: createdAt.getTime(), i: id }
+ return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
+}
+
+export function decodeCursor(cursor: string): CursorPayload | null {
+ try {
+ const json = Buffer.from(cursor, 'base64url').toString('utf8')
+ const obj = JSON.parse(json) as Partial
+ if (typeof obj.t !== 'number' || typeof obj.i !== 'string') return null
+ return { t: obj.t, i: obj.i }
+ } catch {
+ return null
+ }
+}
+
+export async function listAuditEvents(
+ input: ListAuditEventsInput = {}
+): Promise<{ items: AuditEventRow[]; nextCursor: string | null }> {
+ const limit = Math.min(Math.max(input.limit ?? 50, 1), 200)
+ const conds = []
+ if (input.principalId) conds.push(eq(auditEvents.principalId, input.principalId))
+ if (input.action) conds.push(eq(auditEvents.action, input.action))
+ if (input.actionPrefix) conds.push(like(auditEvents.action, `${input.actionPrefix}%`))
+ if (input.targetType) conds.push(eq(auditEvents.targetType, input.targetType))
+ if (input.targetId) conds.push(eq(auditEvents.targetId, input.targetId))
+ if (input.source) conds.push(eq(auditEvents.source, input.source))
+ if (input.from) conds.push(gte(auditEvents.createdAt, input.from))
+ if (input.to) conds.push(lte(auditEvents.createdAt, input.to))
+
+ if (input.cursor) {
+ const c = decodeCursor(input.cursor)
+ if (c) {
+ const cursorAt = new Date(c.t)
+ // Strict (createdAt, id) lexicographic less-than to match
+ // ORDER BY created_at DESC, id DESC.
+ conds.push(
+ or(
+ lt(auditEvents.createdAt, cursorAt),
+ and(eq(auditEvents.createdAt, cursorAt), lt(auditEvents.id, c.i as never))
+ )!
+ )
+ }
+ }
+
+ const where = conds.length > 0 ? and(...conds) : undefined
+ const rows = await db
+ .select({
+ id: auditEvents.id,
+ createdAt: auditEvents.createdAt,
+ principalId: auditEvents.principalId,
+ action: auditEvents.action,
+ targetType: auditEvents.targetType,
+ targetId: auditEvents.targetId,
+ diff: auditEvents.diff,
+ source: auditEvents.source,
+ ipAddress: auditEvents.ipAddress,
+ userAgent: auditEvents.userAgent,
+ })
+ .from(auditEvents)
+ .where(where)
+ .orderBy(desc(auditEvents.createdAt), desc(auditEvents.id))
+ .limit(limit + 1)
+
+ const hasMore = rows.length > limit
+ const items = (hasMore ? rows.slice(0, limit) : rows) as unknown as AuditEventRow[]
+ const nextCursor =
+ hasMore && items.length > 0
+ ? encodeCursor(items[items.length - 1].createdAt, String(items[items.length - 1].id))
+ : null
+
+ // Touch sql to keep the import used by future filters.
+ void sql
+
+ return { items, nextCursor }
+}
+
+/**
+ * Distinct action keys present in the audit log, ascending. Used by the
+ * admin audit page to populate the action combobox without needing the
+ * caller to know the dotted-key vocabulary up front.
+ */
+export async function listDistinctActions(): Promise {
+ const rows = await db
+ .selectDistinct({ action: auditEvents.action })
+ .from(auditEvents)
+ .orderBy(asc(auditEvents.action))
+ .limit(200)
+ return rows.map((r) => r.action)
+}
diff --git a/apps/web/src/lib/server/domains/audit/audit.service.ts b/apps/web/src/lib/server/domains/audit/audit.service.ts
new file mode 100644
index 000000000..271f41fcd
--- /dev/null
+++ b/apps/web/src/lib/server/domains/audit/audit.service.ts
@@ -0,0 +1,82 @@
+/**
+ * Audit domain — append-only workspace audit log.
+ *
+ * `recordEvent` is the single write entry-point and is intentionally
+ * fire-and-forget-friendly: the caller's transaction does NOT need to wait
+ * for the insert. We log errors instead of propagating them, because losing
+ * an audit row should never crash a write path.
+ *
+ * For test/debug determinism, callers that need synchronous behaviour can
+ * `await recordEvent(...)`.
+ */
+
+import { db, auditEvents, desc, eq, and, gte, lte } from '@/lib/server/db'
+import type { PrincipalId, AuditEventId } from '@quackback/ids'
+import type { AuditDiff, AuditSource } from '@/lib/server/db'
+
+export interface RecordEventInput {
+ /** Actor performing the action; null for system-initiated. */
+ principalId?: PrincipalId | null
+ /** Dotted action name (e.g. "role.granted", "ticket.shared"). */
+ action: string
+ /** Type of the resource the action targets. */
+ targetType: string
+ /** TypeID of the resource (stored as text). */
+ targetId?: string | null
+ diff?: AuditDiff
+ source?: AuditSource
+ ipAddress?: string | null
+ userAgent?: string | null
+}
+
+export async function recordEvent(input: RecordEventInput): Promise {
+ try {
+ const [row] = await db
+ .insert(auditEvents)
+ .values({
+ principalId: input.principalId ?? null,
+ action: input.action,
+ targetType: input.targetType,
+ targetId: input.targetId ?? null,
+ diff: input.diff ?? {},
+ source: input.source ?? 'web',
+ ipAddress: input.ipAddress ?? null,
+ userAgent: input.userAgent ?? null,
+ })
+ .returning({ id: auditEvents.id })
+ return (row?.id as AuditEventId | undefined) ?? null
+ } catch (error) {
+ console.error('[domain:audit] failed to record event', { action: input.action, error })
+ return null
+ }
+}
+
+export interface ListEventsFilter {
+ principalId?: PrincipalId
+ action?: string
+ targetType?: string
+ targetId?: string
+ /** Inclusive lower bound. */
+ since?: Date
+ /** Inclusive upper bound. */
+ until?: Date
+ limit?: number
+}
+
+export async function listEvents(filter: ListEventsFilter = {}) {
+ const conditions = []
+ if (filter.principalId) conditions.push(eq(auditEvents.principalId, filter.principalId))
+ if (filter.action) conditions.push(eq(auditEvents.action, filter.action))
+ if (filter.targetType) conditions.push(eq(auditEvents.targetType, filter.targetType))
+ if (filter.targetId) conditions.push(eq(auditEvents.targetId, filter.targetId))
+ if (filter.since) conditions.push(gte(auditEvents.createdAt, filter.since))
+ if (filter.until) conditions.push(lte(auditEvents.createdAt, filter.until))
+
+ const where = conditions.length > 0 ? and(...conditions) : undefined
+ return db
+ .select()
+ .from(auditEvents)
+ .where(where)
+ .orderBy(desc(auditEvents.createdAt))
+ .limit(Math.min(filter.limit ?? 100, 500))
+}
diff --git a/apps/web/src/lib/server/domains/audit/audit.unified.ts b/apps/web/src/lib/server/domains/audit/audit.unified.ts
new file mode 100644
index 000000000..177219429
--- /dev/null
+++ b/apps/web/src/lib/server/domains/audit/audit.unified.ts
@@ -0,0 +1,414 @@
+/**
+ * Read-side union of the two append-only audit stores:
+ * - audit_events: workspace operational audit rows
+ * - audit_log: security/auth/admin audit rows
+ *
+ * Writers stay separate. This module only normalizes both shapes for the
+ * canonical admin audit page.
+ */
+import type { AnyColumn, SQL } from 'drizzle-orm'
+import type { PrincipalId } from '@quackback/ids'
+import type { AuditSource } from '@/lib/server/db'
+import {
+ and,
+ auditEvents,
+ auditLog,
+ db,
+ desc,
+ eq,
+ gte,
+ ilike,
+ like,
+ lt,
+ lte,
+ notInArray,
+ or,
+ principal,
+ user,
+} from '@/lib/server/db'
+
+export type UnifiedAuditOrigin = 'workspace' | 'security'
+
+type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue }
+export type UnifiedAuditDiff = { [k: string]: JsonValue }
+
+export interface UnifiedAuditEventRow {
+ id: string
+ origin: UnifiedAuditOrigin
+ occurredAt: Date
+ principalId: string | null
+ actorUserId: string | null
+ actorEmail: string | null
+ actorDisplayName: string | null
+ actorRole: string | null
+ actorType: string | null
+ authMethod: string | null
+ action: string
+ outcome: 'success' | 'failure' | null
+ source: AuditSource | null
+ targetType: string | null
+ targetId: string | null
+ requestId: string | null
+ ipAddress: string | null
+ userAgent: string | null
+ diff: JsonValue
+ metadata: JsonValue
+}
+
+export interface ListUnifiedAuditEventsInput {
+ origin?: UnifiedAuditOrigin
+ principalId?: PrincipalId
+ actorEmail?: string
+ action?: string
+ actionPrefix?: string
+ targetType?: string
+ targetId?: string
+ source?: AuditSource
+ from?: Date
+ to?: Date
+ cursor?: string
+ limit?: number
+ excludeSecurityActions?: string[]
+}
+
+export interface ListUnifiedAuditEventsPage {
+ items: UnifiedAuditEventRow[]
+ nextCursor: string | null
+}
+
+interface UnifiedCursorPayload {
+ t: number
+ o: UnifiedAuditOrigin
+ i: string
+}
+
+const ORIGIN_ORDER: Record = {
+ workspace: 0,
+ security: 1,
+}
+
+const DEFAULT_LIMIT = 50
+const MAX_LIMIT = 200
+
+export function encodeUnifiedAuditCursor(
+ row: Pick
+): string {
+ const payload: UnifiedCursorPayload = {
+ t: row.occurredAt.getTime(),
+ o: row.origin,
+ i: row.id,
+ }
+ return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
+}
+
+export function decodeUnifiedAuditCursor(cursor: string | undefined): UnifiedCursorPayload | null {
+ if (!cursor) return null
+ try {
+ const json = Buffer.from(cursor, 'base64url').toString('utf8')
+ const obj = JSON.parse(json) as Partial
+ if (
+ typeof obj.t !== 'number' ||
+ typeof obj.i !== 'string' ||
+ (obj.o !== 'workspace' && obj.o !== 'security')
+ ) {
+ return null
+ }
+ return { t: obj.t, o: obj.o, i: obj.i }
+ } catch {
+ return null
+ }
+}
+
+export function compareUnifiedAuditRows(
+ a: Pick,
+ b: Pick
+): number {
+ const timeDiff = b.occurredAt.getTime() - a.occurredAt.getTime()
+ if (timeDiff !== 0) return timeDiff
+
+ const originDiff = ORIGIN_ORDER[a.origin] - ORIGIN_ORDER[b.origin]
+ if (originDiff !== 0) return originDiff
+
+ return b.id.localeCompare(a.id)
+}
+
+function cursorRow(
+ cursor: UnifiedCursorPayload
+): Pick {
+ return {
+ occurredAt: new Date(cursor.t),
+ origin: cursor.o,
+ id: cursor.i,
+ }
+}
+
+function isAfterCursor(row: UnifiedAuditEventRow, cursor: UnifiedCursorPayload | null): boolean {
+ return !cursor || compareUnifiedAuditRows(row, cursorRow(cursor)) > 0
+}
+
+export function pageUnifiedAuditRows(
+ rows: UnifiedAuditEventRow[],
+ opts: { limit?: number; cursor?: string } = {}
+): ListUnifiedAuditEventsPage {
+ const limit = Math.min(Math.max(opts.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT)
+ const cursor = decodeUnifiedAuditCursor(opts.cursor)
+ const sorted = rows.filter((row) => isAfterCursor(row, cursor)).sort(compareUnifiedAuditRows)
+ const items = sorted.slice(0, limit)
+ const hasMore = sorted.length > limit
+ const nextCursor =
+ hasMore && items.length > 0 ? encodeUnifiedAuditCursor(items[items.length - 1]) : null
+
+ return { items, nextCursor }
+}
+
+function buildCursorCondition(
+ origin: UnifiedAuditOrigin,
+ occurredAtColumn: AnyColumn,
+ idColumn: AnyColumn,
+ cursor: UnifiedCursorPayload | null
+): SQL | undefined {
+ if (!cursor) return undefined
+
+ const at = new Date(cursor.t)
+ const orderDiff = ORIGIN_ORDER[origin] - ORIGIN_ORDER[cursor.o]
+
+ if (orderDiff > 0) {
+ return lte(occurredAtColumn, at)
+ }
+ if (orderDiff < 0) {
+ return lt(occurredAtColumn, at)
+ }
+
+ return or(lt(occurredAtColumn, at), and(eq(occurredAtColumn, at), lt(idColumn, cursor.i)))!
+}
+
+function cleanNeedle(value: string | undefined): string | undefined {
+ const trimmed = value?.trim()
+ return trimmed ? `%${trimmed}%` : undefined
+}
+
+function shouldQueryWorkspace(input: ListUnifiedAuditEventsInput): boolean {
+ return input.origin !== 'security'
+}
+
+function shouldQuerySecurity(input: ListUnifiedAuditEventsInput): boolean {
+ if (input.origin === 'workspace') return false
+ if (input.principalId) return false
+ if (input.source) return false
+ return true
+}
+
+function compactContext(
+ entries: Record
+): Record | undefined {
+ const context = Object.fromEntries(
+ Object.entries(entries).filter(([, value]) => value !== null && value !== undefined)
+ ) as Record
+ return Object.keys(context).length > 0 ? context : undefined
+}
+
+function securityDiff(row: {
+ beforeValue: unknown
+ afterValue: unknown
+ metadata: unknown
+ requestId: string | null
+ actorType: string | null
+ authMethod: string | null
+}): JsonValue {
+ const diff: UnifiedAuditDiff = {}
+ if (row.beforeValue !== null && row.beforeValue !== undefined) {
+ diff.before = row.beforeValue as JsonValue
+ }
+ if (row.afterValue !== null && row.afterValue !== undefined) {
+ diff.after = row.afterValue as JsonValue
+ }
+ const context = compactContext({
+ metadata: row.metadata as JsonValue,
+ requestId: row.requestId,
+ actorType: row.actorType,
+ authMethod: row.authMethod,
+ })
+ if (context) diff.context = context
+ return diff
+}
+
+export async function listUnifiedAuditEvents(
+ input: ListUnifiedAuditEventsInput = {}
+): Promise {
+ const limit = Math.min(Math.max(input.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT)
+ const cursor = decodeUnifiedAuditCursor(input.cursor)
+ const actorEmailNeedle = cleanNeedle(input.actorEmail)
+ const pageSize = limit + 1
+
+ const queries: Array> = []
+
+ if (shouldQueryWorkspace(input)) {
+ const conds: SQL[] = []
+ if (input.principalId) conds.push(eq(auditEvents.principalId, input.principalId))
+ if (input.action) conds.push(eq(auditEvents.action, input.action))
+ if (input.actionPrefix) conds.push(like(auditEvents.action, `${input.actionPrefix}%`))
+ if (input.targetType) conds.push(eq(auditEvents.targetType, input.targetType))
+ if (input.targetId) conds.push(eq(auditEvents.targetId, input.targetId))
+ if (input.source) conds.push(eq(auditEvents.source, input.source))
+ if (input.from) conds.push(gte(auditEvents.createdAt, input.from))
+ if (input.to) conds.push(lte(auditEvents.createdAt, input.to))
+ if (actorEmailNeedle) conds.push(ilike(user.email, actorEmailNeedle))
+ const cursorCond = buildCursorCondition(
+ 'workspace',
+ auditEvents.createdAt,
+ auditEvents.id,
+ cursor
+ )
+ if (cursorCond) conds.push(cursorCond)
+
+ const where = conds.length > 0 ? and(...conds) : undefined
+
+ queries.push(
+ db
+ .select({
+ id: auditEvents.id,
+ createdAt: auditEvents.createdAt,
+ principalId: auditEvents.principalId,
+ action: auditEvents.action,
+ targetType: auditEvents.targetType,
+ targetId: auditEvents.targetId,
+ diff: auditEvents.diff,
+ source: auditEvents.source,
+ ipAddress: auditEvents.ipAddress,
+ userAgent: auditEvents.userAgent,
+ actorUserId: principal.userId,
+ actorEmail: user.email,
+ actorDisplayName: principal.displayName,
+ actorRole: principal.role,
+ actorType: principal.type,
+ userName: user.name,
+ })
+ .from(auditEvents)
+ .leftJoin(principal, eq(auditEvents.principalId, principal.id))
+ .leftJoin(user, eq(principal.userId, user.id))
+ .where(where)
+ .orderBy(desc(auditEvents.createdAt), desc(auditEvents.id))
+ .limit(pageSize)
+ .then((rows) =>
+ rows.map((row) => ({
+ id: row.id,
+ origin: 'workspace' as const,
+ occurredAt: row.createdAt,
+ principalId: row.principalId,
+ actorUserId: row.actorUserId,
+ actorEmail: row.actorEmail,
+ actorDisplayName: row.actorDisplayName ?? row.userName ?? null,
+ actorRole: row.actorRole,
+ actorType: row.actorType,
+ authMethod: null,
+ action: row.action,
+ outcome: null,
+ source: row.source,
+ targetType: row.targetType,
+ targetId: row.targetId,
+ requestId: null,
+ ipAddress: row.ipAddress,
+ userAgent: row.userAgent,
+ diff: row.diff as JsonValue,
+ metadata: null,
+ }))
+ )
+ )
+ }
+
+ if (shouldQuerySecurity(input)) {
+ const conds: SQL[] = []
+ if (input.action) conds.push(eq(auditLog.eventType, input.action))
+ if (input.actionPrefix) conds.push(like(auditLog.eventType, `${input.actionPrefix}%`))
+ if (input.targetType) conds.push(eq(auditLog.targetType, input.targetType))
+ if (input.targetId) conds.push(eq(auditLog.targetId, input.targetId))
+ if (input.from) conds.push(gte(auditLog.occurredAt, input.from))
+ if (input.to) conds.push(lte(auditLog.occurredAt, input.to))
+ if (actorEmailNeedle) conds.push(ilike(auditLog.actorEmail, actorEmailNeedle))
+ if (
+ !input.action &&
+ !input.actionPrefix &&
+ input.excludeSecurityActions &&
+ input.excludeSecurityActions.length > 0
+ ) {
+ conds.push(notInArray(auditLog.eventType, input.excludeSecurityActions))
+ }
+ const cursorCond = buildCursorCondition('security', auditLog.occurredAt, auditLog.id, cursor)
+ if (cursorCond) conds.push(cursorCond)
+
+ const where = conds.length > 0 ? and(...conds) : undefined
+
+ queries.push(
+ db
+ .select({
+ id: auditLog.id,
+ occurredAt: auditLog.occurredAt,
+ actorUserId: auditLog.actorUserId,
+ actorEmail: auditLog.actorEmail,
+ actorRole: auditLog.actorRole,
+ actorIp: auditLog.actorIp,
+ actorUserAgent: auditLog.actorUserAgent,
+ eventType: auditLog.eventType,
+ eventOutcome: auditLog.eventOutcome,
+ targetType: auditLog.targetType,
+ targetId: auditLog.targetId,
+ beforeValue: auditLog.beforeValue,
+ afterValue: auditLog.afterValue,
+ metadata: auditLog.metadata,
+ requestId: auditLog.requestId,
+ actorType: auditLog.actorType,
+ authMethod: auditLog.authMethod,
+ })
+ .from(auditLog)
+ .where(where)
+ .orderBy(desc(auditLog.occurredAt), desc(auditLog.id))
+ .limit(pageSize)
+ .then((rows) =>
+ rows.map((row) => ({
+ id: row.id,
+ origin: 'security' as const,
+ occurredAt: row.occurredAt,
+ principalId: null,
+ actorUserId: row.actorUserId,
+ actorEmail: row.actorEmail,
+ actorDisplayName: null,
+ actorRole: row.actorRole,
+ actorType: row.actorType,
+ authMethod: row.authMethod,
+ action: row.eventType,
+ outcome: row.eventOutcome as 'success' | 'failure',
+ source: null,
+ targetType: row.targetType,
+ targetId: row.targetId,
+ requestId: row.requestId,
+ ipAddress: row.actorIp,
+ userAgent: row.actorUserAgent,
+ diff: securityDiff(row),
+ metadata: (row.metadata as JsonValue) ?? null,
+ }))
+ )
+ )
+ }
+
+ return pageUnifiedAuditRows((await Promise.all(queries)).flat(), {
+ limit,
+ cursor: input.cursor,
+ })
+}
+
+export async function listUnifiedAuditActions(): Promise {
+ const [workspaceRows, securityRows] = await Promise.all([
+ db
+ .selectDistinct({ action: auditEvents.action })
+ .from(auditEvents)
+ .orderBy(auditEvents.action)
+ .limit(200),
+ db
+ .selectDistinct({ action: auditLog.eventType })
+ .from(auditLog)
+ .orderBy(auditLog.eventType)
+ .limit(200),
+ ])
+
+ return Array.from(new Set([...workspaceRows, ...securityRows].map((row) => row.action))).sort()
+}
diff --git a/apps/web/src/lib/server/domains/audit/index.ts b/apps/web/src/lib/server/domains/audit/index.ts
new file mode 100644
index 000000000..a1a90478d
--- /dev/null
+++ b/apps/web/src/lib/server/domains/audit/index.ts
@@ -0,0 +1,15 @@
+export {
+ recordEvent,
+ listEvents,
+ type RecordEventInput,
+ type ListEventsFilter,
+} from './audit.service'
+export {
+ listAuditEvents,
+ listDistinctActions,
+ encodeCursor,
+ decodeCursor,
+ type ListAuditEventsInput,
+ type AuditEventRow,
+} from './audit.queries'
+export { buildAuditContext, type AuditAttribution, type AuditAuthLike } from './audit.context'
diff --git a/apps/web/src/lib/server/domains/authz/__tests__/authz.service-db.test.ts b/apps/web/src/lib/server/domains/authz/__tests__/authz.service-db.test.ts
new file mode 100644
index 000000000..50f26eda1
--- /dev/null
+++ b/apps/web/src/lib/server/domains/authz/__tests__/authz.service-db.test.ts
@@ -0,0 +1,158 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PrincipalId, TeamId } from '@quackback/ids'
+
+const hoisted = vi.hoisted(() => ({
+ mockTeamMembershipsFindMany: vi.fn(),
+ mockPrincipalRoleAssignmentsFindMany: vi.fn(),
+ mockPrincipalFindFirst: vi.fn(),
+ mockSelect: vi.fn(),
+ mockEq: vi.fn(),
+ mockInArray: vi.fn(),
+}))
+
+vi.mock('@/lib/server/db', () => ({
+ db: {
+ query: {
+ teamMemberships: {
+ findMany: (...args: unknown[]) => hoisted.mockTeamMembershipsFindMany(...args),
+ },
+ principalRoleAssignments: {
+ findMany: (...args: unknown[]) => hoisted.mockPrincipalRoleAssignmentsFindMany(...args),
+ },
+ principal: {
+ findFirst: (...args: unknown[]) => hoisted.mockPrincipalFindFirst(...args),
+ },
+ },
+ select: (...args: unknown[]) => hoisted.mockSelect(...args),
+ },
+ principal: {
+ id: 'principal.id',
+ },
+ principalRoleAssignments: {
+ principalId: 'principalRoleAssignments.principalId',
+ },
+ rolePermissions: {
+ roleId: 'rolePermissions.roleId',
+ permissionId: 'rolePermissions.permissionId',
+ },
+ permissions: {
+ id: 'permissions.id',
+ key: 'permissions.key',
+ },
+ teamMemberships: {
+ principalId: 'teamMemberships.principalId',
+ },
+ eq: (...args: unknown[]) => hoisted.mockEq(...args),
+ inArray: (...args: unknown[]) => hoisted.mockInArray(...args),
+}))
+
+const { assertPermission, loadPermissionSet } = await import('../authz.service')
+const { PERMISSIONS } = await import('../authz.permissions')
+
+type SelectChain = {
+ from: ReturnType
+ innerJoin: ReturnType
+ where: ReturnType
+ then: Promise['then']
+ catch: Promise['catch']
+ finally: Promise['finally']
+}
+
+function makeSelectChain(rows: unknown[]): SelectChain {
+ const promise = Promise.resolve(rows)
+ const chain = {
+ from: vi.fn(() => chain),
+ innerJoin: vi.fn(() => chain),
+ where: vi.fn(() => chain),
+ then: promise.then.bind(promise),
+ catch: promise.catch.bind(promise),
+ finally: promise.finally.bind(promise),
+ } as SelectChain
+ return chain
+}
+
+const PRINCIPAL = 'principal_authz' as PrincipalId
+const TEAM_A = 'team_a' as TeamId
+const TEAM_B = 'team_b' as TeamId
+
+beforeEach(() => {
+ hoisted.mockTeamMembershipsFindMany.mockReset()
+ hoisted.mockPrincipalRoleAssignmentsFindMany.mockReset()
+ hoisted.mockPrincipalFindFirst.mockReset()
+ hoisted.mockSelect.mockReset()
+ hoisted.mockEq.mockReset()
+ hoisted.mockInArray.mockReset()
+ hoisted.mockEq.mockImplementation((...args: unknown[]) => ['eq', ...args])
+ hoisted.mockInArray.mockImplementation((...args: unknown[]) => ['inArray', ...args])
+ hoisted.mockTeamMembershipsFindMany.mockResolvedValue([])
+})
+
+describe('loadPermissionSet', () => {
+ it('builds workspace and team-scoped grants from role assignments', async () => {
+ hoisted.mockTeamMembershipsFindMany.mockResolvedValue([{ teamId: TEAM_A }, { teamId: TEAM_B }])
+ hoisted.mockPrincipalRoleAssignmentsFindMany.mockResolvedValue([
+ { roleId: 'role_workspace', teamId: null },
+ { roleId: 'role_team', teamId: TEAM_A },
+ { roleId: 'role_empty', teamId: TEAM_B },
+ { roleId: 'role_team', teamId: TEAM_A },
+ ])
+ hoisted.mockSelect.mockReturnValueOnce(
+ makeSelectChain([
+ { roleId: 'role_workspace', key: PERMISSIONS.TICKET_VIEW_ALL },
+ { roleId: 'role_team', key: PERMISSIONS.TICKET_REPLY_PUBLIC },
+ { roleId: 'role_team', key: PERMISSIONS.TICKET_COMMENT_INTERNAL },
+ ])
+ )
+
+ const set = await loadPermissionSet(PRINCIPAL)
+
+ expect(set.principalId).toBe(PRINCIPAL)
+ expect(set.teamIds).toEqual([TEAM_A, TEAM_B])
+ expect([...set.workspacePermissions]).toEqual([PERMISSIONS.TICKET_VIEW_ALL])
+ expect([...(set.teamPermissions.get(TEAM_A) ?? [])]).toEqual([
+ PERMISSIONS.TICKET_REPLY_PUBLIC,
+ PERMISSIONS.TICKET_COMMENT_INTERNAL,
+ ])
+ expect([...(set.teamPermissions.get(TEAM_B) ?? [])]).toEqual([])
+ expect(hoisted.mockInArray).toHaveBeenCalledWith('rolePermissions.roleId', [
+ 'role_workspace',
+ 'role_team',
+ 'role_empty',
+ ])
+ })
+
+ it('falls back to legacy admin, member, and customer role mappings when no assignments exist', async () => {
+ hoisted.mockPrincipalRoleAssignmentsFindMany.mockResolvedValue([])
+ hoisted.mockPrincipalFindFirst.mockResolvedValueOnce({ role: 'admin' })
+ let set = await loadPermissionSet(PRINCIPAL)
+ expect(set.workspacePermissions.has(PERMISSIONS.TICKET_VIEW_TEAM)).toBe(true)
+
+ hoisted.mockPrincipalFindFirst.mockResolvedValueOnce({ role: 'member' })
+ set = await loadPermissionSet(PRINCIPAL)
+ expect(set.workspacePermissions.has(PERMISSIONS.TICKET_REPLY_PUBLIC)).toBe(true)
+ expect(set.workspacePermissions.has(PERMISSIONS.TICKET_VIEW_ALL)).toBe(false)
+
+ hoisted.mockPrincipalFindFirst.mockResolvedValueOnce(undefined)
+ set = await loadPermissionSet(PRINCIPAL)
+ expect(set.workspacePermissions.size).toBe(0)
+ })
+})
+
+describe('assertPermission', () => {
+ it('allows matching permissions and throws a structured error when missing', () => {
+ const set = {
+ principalId: PRINCIPAL,
+ workspacePermissions: new Set([PERMISSIONS.TICKET_VIEW_ALL]),
+ teamPermissions: new Map([[TEAM_A, new Set([PERMISSIONS.TICKET_REPLY_PUBLIC])]]),
+ teamIds: [TEAM_A],
+ }
+
+ expect(() => assertPermission(set, PERMISSIONS.TICKET_VIEW_ALL)).not.toThrow()
+ expect(() =>
+ assertPermission(set, PERMISSIONS.TICKET_REPLY_PUBLIC, { primaryTeamId: TEAM_A })
+ ).not.toThrow()
+ expect(() =>
+ assertPermission(set, PERMISSIONS.TICKET_REPLY_PUBLIC, { primaryTeamId: TEAM_B })
+ ).toThrow(/Missing required permission/)
+ })
+})
diff --git a/apps/web/src/lib/server/domains/authz/__tests__/authz.test.ts b/apps/web/src/lib/server/domains/authz/__tests__/authz.test.ts
new file mode 100644
index 000000000..024cb9aaa
--- /dev/null
+++ b/apps/web/src/lib/server/domains/authz/__tests__/authz.test.ts
@@ -0,0 +1,221 @@
+/**
+ * Pure-logic tests for the authz scope evaluator and permission helpers.
+ *
+ * These tests do not touch the database — they exercise the pure functions
+ * exported from `authz.scopes.ts` and `authz.service.ts` so we can verify the
+ * full role × action × scope matrix without a live workspace.
+ */
+import { describe, it, expect } from 'vitest'
+import type { PermissionSet } from '../authz.service'
+import { evaluateTicketView, hasPermission, hasPermissionForResource } from '../authz.service'
+import { matchesAssignedScope, matchesSharedScope, matchesTeamScope } from '../authz.scopes'
+import { PERMISSIONS, SYSTEM_ROLE_PERMISSIONS, SYSTEM_ROLES } from '../authz.permissions'
+import type { PrincipalId, TeamId } from '@quackback/ids'
+
+const PRINCIPAL_A = 'principal_aaaaaaaaaaaaaaaaaaaaaaaaaa' as PrincipalId
+const PRINCIPAL_B = 'principal_bbbbbbbbbbbbbbbbbbbbbbbbbb' as PrincipalId
+const TEAM_X = 'team_xxxxxxxxxxxxxxxxxxxxxxxxxx' as TeamId
+const TEAM_Y = 'team_yyyyyyyyyyyyyyyyyyyyyyyyyy' as TeamId
+const TEAM_Z = 'team_zzzzzzzzzzzzzzzzzzzzzzzzzz' as TeamId
+
+function buildSet(args: {
+ principalId?: PrincipalId
+ workspace?: readonly (typeof PERMISSIONS)[keyof typeof PERMISSIONS][]
+ team?: Record
+ teamIds?: readonly TeamId[]
+}): PermissionSet {
+ const teamPermissions = new Map(
+ Object.entries(args.team ?? {}).map(([k, v]) => [k as TeamId, new Set(v)])
+ )
+ return {
+ principalId: args.principalId ?? PRINCIPAL_A,
+ workspacePermissions: new Set(args.workspace ?? []),
+ teamPermissions,
+ teamIds: args.teamIds ?? [],
+ }
+}
+
+describe('authz scope helpers', () => {
+ it('matches assigned scope when principal is the assignee', () => {
+ expect(
+ matchesAssignedScope(
+ { principalId: PRINCIPAL_A, teamIds: [] },
+ { assigneePrincipalId: PRINCIPAL_A }
+ ).inScope
+ ).toBe(true)
+ })
+
+ it('does not match assigned scope for a different principal', () => {
+ expect(
+ matchesAssignedScope(
+ { principalId: PRINCIPAL_A, teamIds: [] },
+ { assigneePrincipalId: PRINCIPAL_B }
+ ).inScope
+ ).toBe(false)
+ })
+
+ it('matches team scope by primary or assignee team', () => {
+ expect(
+ matchesTeamScope({ principalId: PRINCIPAL_A, teamIds: [TEAM_X] }, { primaryTeamId: TEAM_X })
+ .inScope
+ ).toBe(true)
+ expect(
+ matchesTeamScope({ principalId: PRINCIPAL_A, teamIds: [TEAM_X] }, { assigneeTeamId: TEAM_X })
+ .inScope
+ ).toBe(true)
+ expect(
+ matchesTeamScope({ principalId: PRINCIPAL_A, teamIds: [TEAM_X] }, { primaryTeamId: TEAM_Y })
+ .inScope
+ ).toBe(false)
+ })
+
+ it('matches shared scope when one of the actor teams is in shared list', () => {
+ expect(
+ matchesSharedScope(
+ { principalId: PRINCIPAL_A, teamIds: [TEAM_X, TEAM_Y] },
+ { sharedTeamIds: [TEAM_Y, TEAM_Z] }
+ ).inScope
+ ).toBe(true)
+ })
+
+ it('shared scope returns none when no overlap', () => {
+ expect(
+ matchesSharedScope(
+ { principalId: PRINCIPAL_A, teamIds: [TEAM_X] },
+ { sharedTeamIds: [TEAM_Y] }
+ ).inScope
+ ).toBe(false)
+ })
+})
+
+describe('hasPermission / hasPermissionForResource', () => {
+ it('workspace-wide grants always match regardless of resource', () => {
+ const set = buildSet({ workspace: [PERMISSIONS.TICKET_REPLY_PUBLIC] })
+ expect(hasPermission(set, PERMISSIONS.TICKET_REPLY_PUBLIC)).toBe(true)
+ expect(
+ hasPermissionForResource(set, PERMISSIONS.TICKET_REPLY_PUBLIC, {
+ primaryTeamId: TEAM_Y,
+ })
+ ).toBe(true)
+ })
+
+ it('team-scoped grants only match resources within the granted team', () => {
+ const set = buildSet({
+ team: { [TEAM_X]: [PERMISSIONS.TICKET_REPLY_PUBLIC] },
+ teamIds: [TEAM_X],
+ })
+ expect(
+ hasPermissionForResource(set, PERMISSIONS.TICKET_REPLY_PUBLIC, {
+ primaryTeamId: TEAM_X,
+ })
+ ).toBe(true)
+ expect(
+ hasPermissionForResource(set, PERMISSIONS.TICKET_REPLY_PUBLIC, {
+ primaryTeamId: TEAM_Y,
+ })
+ ).toBe(false)
+ })
+
+ it('team-scoped grants apply to shared-with team', () => {
+ const set = buildSet({
+ team: { [TEAM_X]: [PERMISSIONS.TICKET_REPLY_PUBLIC] },
+ })
+ expect(
+ hasPermissionForResource(set, PERMISSIONS.TICKET_REPLY_PUBLIC, {
+ sharedTeamIds: [TEAM_X],
+ })
+ ).toBe(true)
+ })
+
+ it('returns false when the permission is absent everywhere', () => {
+ const set = buildSet({})
+ expect(hasPermission(set, PERMISSIONS.TICKET_REPLY_PUBLIC)).toBe(false)
+ })
+})
+
+describe('evaluateTicketView (broadest → narrowest)', () => {
+ it('view_all wins regardless of resource', () => {
+ const set = buildSet({ workspace: [PERMISSIONS.TICKET_VIEW_ALL] })
+ const m = evaluateTicketView(set, { primaryTeamId: TEAM_Y })
+ expect(m.inScope).toBe(true)
+ expect(m.reason).toBe('all')
+ })
+
+ it('view_team matches when actor is on the owning team', () => {
+ const set = buildSet({
+ workspace: [PERMISSIONS.TICKET_VIEW_TEAM],
+ teamIds: [TEAM_X],
+ })
+ const m = evaluateTicketView(set, { primaryTeamId: TEAM_X })
+ expect(m).toEqual({ inScope: true, reason: 'team' })
+ })
+
+ it('view_team does not match a foreign team without view_shared', () => {
+ const set = buildSet({
+ workspace: [PERMISSIONS.TICKET_VIEW_TEAM],
+ teamIds: [TEAM_X],
+ })
+ const m = evaluateTicketView(set, { primaryTeamId: TEAM_Y })
+ expect(m.inScope).toBe(false)
+ })
+
+ it('view_shared matches when ticket is shared with one of actor teams', () => {
+ const set = buildSet({
+ workspace: [PERMISSIONS.TICKET_VIEW_SHARED],
+ teamIds: [TEAM_X],
+ })
+ const m = evaluateTicketView(set, {
+ primaryTeamId: TEAM_Y,
+ sharedTeamIds: [TEAM_X],
+ })
+ expect(m).toEqual({ inScope: true, reason: 'shared' })
+ })
+
+ it('view_assigned matches only when principal is the assignee', () => {
+ const set = buildSet({
+ workspace: [PERMISSIONS.TICKET_VIEW_ASSIGNED],
+ principalId: PRINCIPAL_A,
+ })
+ expect(evaluateTicketView(set, { assigneePrincipalId: PRINCIPAL_A })).toEqual({
+ inScope: true,
+ reason: 'assigned',
+ })
+ expect(evaluateTicketView(set, { assigneePrincipalId: PRINCIPAL_B })).toEqual({
+ inScope: false,
+ reason: 'none',
+ })
+ })
+
+ it('returns inScope=false with reason=none when no permission applies', () => {
+ const set = buildSet({})
+ const m = evaluateTicketView(set, { primaryTeamId: TEAM_X })
+ expect(m).toEqual({ inScope: false, reason: 'none' })
+ })
+})
+
+describe('system role bundles', () => {
+ it('owner holds every permission', () => {
+ const owner = SYSTEM_ROLE_PERMISSIONS[SYSTEM_ROLES.OWNER]
+ for (const p of Object.values(PERMISSIONS)) {
+ expect(owner).toContain(p)
+ }
+ })
+
+ it('agent does NOT have view_all or assign_any', () => {
+ const agent = SYSTEM_ROLE_PERMISSIONS[SYSTEM_ROLES.AGENT]
+ expect(agent).not.toContain(PERMISSIONS.TICKET_VIEW_ALL)
+ expect(agent).not.toContain(PERMISSIONS.TICKET_ASSIGN_ANY)
+ expect(agent).not.toContain(PERMISSIONS.TICKET_SHARE_CROSS_TEAM)
+ expect(agent).not.toContain(PERMISSIONS.AUDIT_VIEW)
+ })
+
+ it('collaborator has internal-comment but no public reply', () => {
+ const c = SYSTEM_ROLE_PERMISSIONS[SYSTEM_ROLES.COLLABORATOR]
+ expect(c).toContain(PERMISSIONS.TICKET_COMMENT_INTERNAL)
+ expect(c).not.toContain(PERMISSIONS.TICKET_REPLY_PUBLIC)
+ })
+
+ it('customer has no internal-side permissions', () => {
+ expect(SYSTEM_ROLE_PERMISSIONS[SYSTEM_ROLES.CUSTOMER]).toHaveLength(0)
+ })
+})
diff --git a/apps/web/src/lib/server/domains/authz/__tests__/role.service.test.ts b/apps/web/src/lib/server/domains/authz/__tests__/role.service.test.ts
new file mode 100644
index 000000000..afa713147
--- /dev/null
+++ b/apps/web/src/lib/server/domains/authz/__tests__/role.service.test.ts
@@ -0,0 +1,484 @@
+/**
+ * role.service — authorization guardrails for the custom-roles admin surface.
+ *
+ * These are security-critical invariants: built-in (system) roles must be
+ * tamper-proof, unknown permission keys must be rejected, roles in use must not
+ * be deletable out from under their assignments, and every mutation must emit
+ * an audit event. The DB layer is mocked (the lazy `db` Proxy is overridden);
+ * assertions focus on the decision/branch behaviour and the audit side-effects,
+ * not on SQL.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { ConflictError, ForbiddenError, NotFoundError } from '@/lib/shared/errors'
+
+// All state referenced by vi.mock factories must be hoisted (factories run at
+// import time, before plain top-level consts initialize).
+const h = vi.hoisted(() => {
+ const state = {
+ // FIFO queue of rows each `db.select(...)` chain resolves to, in call order.
+ selectResults: [] as unknown[][],
+ insertReturning: [] as unknown[],
+ }
+ const recordEventMock = vi.fn()
+ function makeSelectChain() {
+ const result = state.selectResults.shift() ?? []
+ const chain: Record = {}
+ const self = () => chain
+ chain.from = self
+ chain.where = self
+ chain.innerJoin = self
+ chain.leftJoin = self
+ chain.orderBy = self
+ chain.groupBy = () => Promise.resolve(result)
+ chain.limit = () => Promise.resolve(result)
+ // Awaiting the chain directly (no .limit/.groupBy) also resolves to result.
+ chain.then = (resolve: (v: unknown) => unknown) => resolve(result)
+ return chain
+ }
+ const writeOk = { where: () => Promise.resolve(undefined) }
+ const insertChain = {
+ values: () => ({ returning: () => Promise.resolve(state.insertReturning) }),
+ }
+ const dbMock = {
+ select: () => makeSelectChain(),
+ update: () => ({ set: () => writeOk }),
+ delete: () => writeOk,
+ insert: () => insertChain,
+ transaction: async (fn: (tx: unknown) => Promise) =>
+ fn({ insert: () => insertChain, delete: () => writeOk, select: () => makeSelectChain() }),
+ }
+ return { state, recordEventMock, dbMock }
+})
+
+vi.mock('@/lib/server/domains/audit/audit.service', () => ({
+ recordEvent: (...a: unknown[]) => h.recordEventMock(...a),
+}))
+
+vi.mock('@/lib/server/db', async (importOriginal) => ({
+ // Real module gives the schema tables + drizzle operators (pure, ignored by
+ // the mock chain); we override only the lazy `db` Proxy.
+ ...(await importOriginal()),
+ db: h.dbMock,
+}))
+
+const recordEventMock = h.recordEventMock
+
+import {
+ listRoles,
+ getRoleWithPermissions,
+ createRole,
+ updateRole,
+ deleteRole,
+ setRolePermissions,
+ listAssignmentsForPrincipal,
+ assignRole,
+ revokeRoleAssignment,
+} from '../role.service'
+import type { RoleId, PrincipalId, RoleAssignmentId } from '@quackback/ids'
+import { PERMISSIONS } from '../authz.permissions'
+
+const ACTOR = 'principal_admin' as PrincipalId
+const SYSTEM_ROLE = {
+ id: 'role_admin',
+ key: 'admin',
+ name: 'Administrator',
+ description: null,
+ isSystem: true,
+ createdAt: new Date('2026-01-01'),
+ updatedAt: new Date('2026-01-01'),
+}
+const CUSTOM_ROLE = {
+ ...SYSTEM_ROLE,
+ id: 'role_custom',
+ key: 'support',
+ name: 'Support',
+ isSystem: false,
+}
+
+beforeEach(() => {
+ h.state.selectResults = []
+ h.state.insertReturning = []
+ recordEventMock.mockReset()
+})
+
+describe('role.service — system-role protection (tamper-proof built-ins)', () => {
+ it('updateRole rejects a system role with ForbiddenError and writes no audit', async () => {
+ h.state.selectResults = [[SYSTEM_ROLE]] // role lookup
+ await expect(
+ updateRole({ id: 'role_admin' as RoleId, name: 'Hacked', actorPrincipalId: ACTOR })
+ ).rejects.toBeInstanceOf(ForbiddenError)
+ expect(recordEventMock).not.toHaveBeenCalled()
+ })
+
+ it('deleteRole rejects a system role with ForbiddenError', async () => {
+ h.state.selectResults = [[SYSTEM_ROLE]]
+ await expect(
+ deleteRole({ id: 'role_admin' as RoleId, actorPrincipalId: ACTOR })
+ ).rejects.toBeInstanceOf(ForbiddenError)
+ expect(recordEventMock).not.toHaveBeenCalled()
+ })
+
+ it('setRolePermissions rejects a system role with ForbiddenError', async () => {
+ h.state.selectResults = [[SYSTEM_ROLE]]
+ await expect(
+ setRolePermissions({
+ roleId: 'role_admin' as RoleId,
+ permissionKeys: [],
+ actorPrincipalId: ACTOR,
+ })
+ ).rejects.toBeInstanceOf(ForbiddenError)
+ expect(recordEventMock).not.toHaveBeenCalled()
+ })
+})
+
+describe('role.service — not-found handling (default-deny on missing rows)', () => {
+ it('getRoleWithPermissions throws NotFoundError when the role is absent', async () => {
+ h.state.selectResults = [[]]
+ await expect(getRoleWithPermissions('role_missing' as RoleId)).rejects.toBeInstanceOf(
+ NotFoundError
+ )
+ })
+
+ it('setRolePermissions throws NotFoundError when the role is absent', async () => {
+ h.state.selectResults = [[]]
+ await expect(
+ setRolePermissions({
+ roleId: 'role_missing' as RoleId,
+ permissionKeys: [],
+ actorPrincipalId: ACTOR,
+ })
+ ).rejects.toBeInstanceOf(NotFoundError)
+ })
+
+ it('updateRole throws NotFoundError when the role is absent', async () => {
+ h.state.selectResults = [[]]
+ await expect(
+ updateRole({ id: 'role_missing' as RoleId, name: 'X', actorPrincipalId: ACTOR })
+ ).rejects.toBeInstanceOf(NotFoundError)
+ })
+
+ it('revokeRoleAssignment throws NotFoundError when the assignment is absent', async () => {
+ h.state.selectResults = [[]]
+ await expect(
+ revokeRoleAssignment({
+ assignmentId: 'roleassign_missing' as RoleAssignmentId,
+ actorPrincipalId: ACTOR,
+ })
+ ).rejects.toBeInstanceOf(NotFoundError)
+ expect(recordEventMock).not.toHaveBeenCalled()
+ })
+})
+
+describe('role.service — conflict + validation guards', () => {
+ it('createRole rejects a duplicate key with ConflictError (no audit)', async () => {
+ h.state.selectResults = [[{ id: 'role_existing' }]] // key lookup finds an existing role
+ await expect(
+ createRole({
+ key: 'support',
+ name: 'Support',
+ permissionKeys: [],
+ actorPrincipalId: ACTOR,
+ })
+ ).rejects.toBeInstanceOf(ConflictError)
+ expect(recordEventMock).not.toHaveBeenCalled()
+ })
+
+ it('createRole rejects an unknown permission key before touching the DB', async () => {
+ await expect(
+ createRole({
+ key: 'support',
+ name: 'Support',
+ permissionKeys: ['totally.bogus.permission' as never],
+ actorPrincipalId: ACTOR,
+ })
+ ).rejects.toBeInstanceOf(ConflictError)
+ expect(recordEventMock).not.toHaveBeenCalled()
+ })
+
+ it('deleteRole refuses to delete a custom role that still has assignments', async () => {
+ h.state.selectResults = [[CUSTOM_ROLE], [{ n: 3 }]] // role lookup, then assignment count
+ await expect(
+ deleteRole({ id: 'role_custom' as RoleId, actorPrincipalId: ACTOR })
+ ).rejects.toBeInstanceOf(ConflictError)
+ expect(recordEventMock).not.toHaveBeenCalled()
+ })
+
+ it('assignRole refuses a duplicate assignment for the same scope', async () => {
+ h.state.selectResults = [[CUSTOM_ROLE], [{ id: 'roleassign_existing' }]] // role lookup, dup check
+ await expect(
+ assignRole({
+ principalId: 'principal_user' as PrincipalId,
+ roleId: 'role_custom' as RoleId,
+ actorPrincipalId: ACTOR,
+ })
+ ).rejects.toBeInstanceOf(ConflictError)
+ expect(recordEventMock).not.toHaveBeenCalled()
+ })
+})
+
+describe('role.service — successful mutations record an audit event', () => {
+ it('listRoles returns system roles first with permission and assignment counts', async () => {
+ h.state.selectResults = [
+ [SYSTEM_ROLE, CUSTOM_ROLE],
+ [{ roleId: 'role_admin', n: 4 }],
+ [{ roleId: 'role_custom', n: '2' }],
+ ]
+
+ const rows = await listRoles()
+
+ expect(rows).toEqual([
+ expect.objectContaining({
+ id: 'role_admin',
+ key: 'admin',
+ permissionCount: 4,
+ assignmentCount: 0,
+ }),
+ expect.objectContaining({
+ id: 'role_custom',
+ key: 'support',
+ permissionCount: 0,
+ assignmentCount: 2,
+ }),
+ ])
+ })
+
+ it('listRoles returns an empty list without count queries when no roles exist', async () => {
+ h.state.selectResults = [[]]
+
+ await expect(listRoles()).resolves.toEqual([])
+ expect(h.state.selectResults).toEqual([])
+ })
+
+ it('getRoleWithPermissions returns the role with permission keys', async () => {
+ h.state.selectResults = [[CUSTOM_ROLE], [{ key: PERMISSIONS.TICKET_VIEW_TEAM }]]
+
+ await expect(getRoleWithPermissions('role_custom' as RoleId)).resolves.toMatchObject({
+ id: 'role_custom',
+ key: 'support',
+ permissionKeys: [PERMISSIONS.TICKET_VIEW_TEAM],
+ })
+ })
+
+ it('createRole inserts and records role.created', async () => {
+ h.state.selectResults = [[]] // key lookup: no existing role
+ h.state.insertReturning = [{ id: 'role_new' }]
+ const id = await createRole({
+ key: 'triage',
+ name: 'Triage',
+ permissionKeys: [],
+ actorPrincipalId: ACTOR,
+ })
+ expect(id).toBe('role_new')
+ expect(recordEventMock).toHaveBeenCalledTimes(1)
+ expect(recordEventMock.mock.calls[0][0]).toMatchObject({
+ action: 'role.created',
+ targetType: 'role',
+ targetId: 'role_new',
+ principalId: ACTOR,
+ })
+ })
+
+ it('createRole stores permission grants when permission keys are provided', async () => {
+ h.state.selectResults = [
+ [],
+ [{ id: 'perm_ticket_view_team', key: PERMISSIONS.TICKET_VIEW_TEAM }],
+ ]
+ h.state.insertReturning = [{ id: 'role_new' }]
+
+ await expect(
+ createRole({
+ key: 'triage',
+ name: 'Triage',
+ description: 'Can triage tickets',
+ permissionKeys: [PERMISSIONS.TICKET_VIEW_TEAM],
+ actorPrincipalId: ACTOR,
+ })
+ ).resolves.toBe('role_new')
+
+ expect(recordEventMock.mock.calls[0][0]).toMatchObject({
+ action: 'role.created',
+ diff: { after: { permissions: [PERMISSIONS.TICKET_VIEW_TEAM] } },
+ })
+ })
+
+ it('createRole fails if the inserted role is not returned', async () => {
+ h.state.selectResults = [[]]
+ h.state.insertReturning = []
+
+ await expect(
+ createRole({
+ key: 'triage',
+ name: 'Triage',
+ permissionKeys: [],
+ actorPrincipalId: ACTOR,
+ })
+ ).rejects.toThrow('Failed to insert role')
+ })
+
+ it('updateRole on a custom role records role.updated with a before/after diff', async () => {
+ h.state.selectResults = [[CUSTOM_ROLE]]
+ await updateRole({ id: 'role_custom' as RoleId, name: 'Renamed', actorPrincipalId: ACTOR })
+ expect(recordEventMock).toHaveBeenCalledTimes(1)
+ const ev = recordEventMock.mock.calls[0][0]
+ expect(ev).toMatchObject({ action: 'role.updated', targetId: 'role_custom' })
+ expect(ev.diff.before.name).toBe('Support')
+ expect(ev.diff.after.name).toBe('Renamed')
+ })
+
+ it('setRolePermissions replaces grants and records before/after permissions', async () => {
+ h.state.selectResults = [
+ [CUSTOM_ROLE],
+ [{ key: PERMISSIONS.TICKET_VIEW_TEAM }],
+ [{ id: 'perm_reply', key: PERMISSIONS.TICKET_REPLY_PUBLIC }],
+ ]
+
+ await setRolePermissions({
+ roleId: 'role_custom' as RoleId,
+ permissionKeys: [PERMISSIONS.TICKET_REPLY_PUBLIC],
+ actorPrincipalId: ACTOR,
+ })
+
+ expect(recordEventMock.mock.calls[0][0]).toMatchObject({
+ action: 'role.permissions_set',
+ targetId: 'role_custom',
+ diff: {
+ before: { permissions: [PERMISSIONS.TICKET_VIEW_TEAM] },
+ after: { permissions: [PERMISSIONS.TICKET_REPLY_PUBLIC] },
+ },
+ })
+ })
+
+ it('setRolePermissions reports permissions missing from the DB', async () => {
+ h.state.selectResults = [[CUSTOM_ROLE], [], []]
+
+ await expect(
+ setRolePermissions({
+ roleId: 'role_custom' as RoleId,
+ permissionKeys: [PERMISSIONS.TICKET_REPLY_PUBLIC],
+ actorPrincipalId: ACTOR,
+ })
+ ).rejects.toMatchObject({ code: 'PERMISSIONS_MISSING' })
+ expect(recordEventMock).not.toHaveBeenCalled()
+ })
+
+ it('deleteRole removes an unassigned custom role and records role.deleted', async () => {
+ h.state.selectResults = [[CUSTOM_ROLE], [{ n: 0 }]] // role lookup, zero assignments
+ await deleteRole({ id: 'role_custom' as RoleId, actorPrincipalId: ACTOR })
+ expect(recordEventMock).toHaveBeenCalledTimes(1)
+ expect(recordEventMock.mock.calls[0][0]).toMatchObject({
+ action: 'role.deleted',
+ targetId: 'role_custom',
+ })
+ })
+
+ it('assignRole grants a new assignment and records role_assignment.granted', async () => {
+ h.state.selectResults = [[CUSTOM_ROLE], []] // role lookup, no existing dup
+ h.state.insertReturning = [{ id: 'roleassign_new' }]
+ const id = await assignRole({
+ principalId: 'principal_user' as PrincipalId,
+ roleId: 'role_custom' as RoleId,
+ actorPrincipalId: ACTOR,
+ })
+ expect(id).toBe('roleassign_new')
+ expect(recordEventMock.mock.calls[0][0]).toMatchObject({
+ action: 'role_assignment.granted',
+ targetType: 'principal',
+ targetId: 'principal_user',
+ })
+ })
+
+ it('assignRole grants team-scoped assignments and records the team scope', async () => {
+ h.state.selectResults = [[CUSTOM_ROLE], []]
+ h.state.insertReturning = [{ id: 'roleassign_team' }]
+
+ const id = await assignRole({
+ principalId: 'principal_user' as PrincipalId,
+ roleId: 'role_custom' as RoleId,
+ teamId: 'team_support' as never,
+ actorPrincipalId: ACTOR,
+ })
+
+ expect(id).toBe('roleassign_team')
+ expect(recordEventMock.mock.calls[0][0]).toMatchObject({
+ action: 'role_assignment.granted',
+ diff: { after: { role: 'support', teamId: 'team_support' } },
+ })
+ })
+
+ it('assignRole fails if the inserted assignment is not returned', async () => {
+ h.state.selectResults = [[CUSTOM_ROLE], []]
+ h.state.insertReturning = []
+
+ await expect(
+ assignRole({
+ principalId: 'principal_user' as PrincipalId,
+ roleId: 'role_custom' as RoleId,
+ actorPrincipalId: ACTOR,
+ })
+ ).rejects.toThrow('Failed to insert role assignment')
+ })
+
+ it('listAssignmentsForPrincipal hydrates role and optional team details', async () => {
+ const createdAt = new Date('2026-01-02T00:00:00.000Z')
+ h.state.selectResults = [
+ [
+ {
+ id: 'roleassign_1',
+ roleId: 'role_custom',
+ roleKey: 'support',
+ roleName: 'Support',
+ roleIsSystem: false,
+ teamId: 'team_support',
+ teamName: 'Support team',
+ grantedByPrincipalId: ACTOR,
+ createdAt,
+ },
+ {
+ id: 'roleassign_2',
+ roleId: 'role_admin',
+ roleKey: 'admin',
+ roleName: 'Admin',
+ roleIsSystem: true,
+ teamId: null,
+ teamName: null,
+ grantedByPrincipalId: null,
+ createdAt,
+ },
+ ],
+ ]
+
+ await expect(listAssignmentsForPrincipal('principal_user' as PrincipalId)).resolves.toEqual([
+ {
+ id: 'roleassign_1',
+ role: { id: 'role_custom', key: 'support', name: 'Support', isSystem: false },
+ teamId: 'team_support',
+ teamName: 'Support team',
+ grantedByPrincipalId: ACTOR,
+ createdAt,
+ },
+ {
+ id: 'roleassign_2',
+ role: { id: 'role_admin', key: 'admin', name: 'Admin', isSystem: true },
+ teamId: null,
+ teamName: null,
+ grantedByPrincipalId: null,
+ createdAt,
+ },
+ ])
+ })
+
+ it('revokeRoleAssignment deletes and records role_assignment.revoked', async () => {
+ h.state.selectResults = [
+ [{ principalId: 'principal_user', roleId: 'role_custom', teamId: null, roleKey: 'support' }],
+ ]
+ await revokeRoleAssignment({
+ assignmentId: 'roleassign_1' as RoleAssignmentId,
+ actorPrincipalId: ACTOR,
+ })
+ expect(recordEventMock.mock.calls[0][0]).toMatchObject({
+ action: 'role_assignment.revoked',
+ targetType: 'principal',
+ targetId: 'principal_user',
+ })
+ })
+})
diff --git a/apps/web/src/lib/server/domains/authz/authz.permissions.ts b/apps/web/src/lib/server/domains/authz/authz.permissions.ts
new file mode 100644
index 000000000..d4bdd0656
--- /dev/null
+++ b/apps/web/src/lib/server/domains/authz/authz.permissions.ts
@@ -0,0 +1,232 @@
+/**
+ * Permission catalogue for the ticketing module (and the broader workspace).
+ *
+ * Permissions are dotted machine names. A role bundle is a set of permissions.
+ * A grant (`principal_role_assignments`) attaches a role to a principal,
+ * optionally scoped to a team — see `authz.scopes.ts` for how that scope
+ * narrows action evaluation.
+ *
+ * Adding a permission here is purely a TypeScript change; the migration that
+ * seeds the system roles will reconcile the rows on next deploy.
+ */
+
+export const PERMISSIONS = {
+ // Ticket access (record-level)
+ TICKET_VIEW_ALL: 'ticket.view_all',
+ TICKET_VIEW_TEAM: 'ticket.view_team',
+ TICKET_VIEW_ASSIGNED: 'ticket.view_assigned',
+ TICKET_VIEW_SHARED: 'ticket.view_shared',
+
+ // Ticket actions
+ TICKET_REPLY_PUBLIC: 'ticket.reply_public',
+ TICKET_COMMENT_INTERNAL: 'ticket.comment_internal',
+ TICKET_EDIT_FIELDS: 'ticket.edit_fields',
+ TICKET_ASSIGN_SELF: 'ticket.assign_self',
+ TICKET_ASSIGN_ANY: 'ticket.assign_any',
+ TICKET_SHARE_CROSS_TEAM: 'ticket.share_cross_team',
+ TICKET_MANAGE_PARTICIPANTS: 'ticket.manage_participants',
+
+ // Organization & contact
+ ORG_VIEW: 'org.view',
+ ORG_MANAGE: 'org.manage',
+
+ // SLA
+ SLA_VIEW: 'sla.view',
+ SLA_MANAGE: 'sla.manage',
+ BUSINESS_HOURS_MANAGE: 'business_hours.manage',
+ ESCALATION_RULE_MANAGE: 'escalation.rule_manage',
+
+ // Audit
+ AUDIT_VIEW: 'audit.view',
+
+ // Inboxes & routing (Phase 4)
+ INBOX_VIEW: 'inbox.view',
+ INBOX_MANAGE: 'inbox.manage',
+ INBOX_CHANNEL_MANAGE: 'inbox.channel_manage',
+ ROUTING_RULE_MANAGE: 'routing.rule_manage',
+ TICKET_BULK_OPERATE: 'ticket.bulk_operate',
+
+ // Admin
+ ADMIN_MANAGE_USERS: 'admin.manage_users',
+ ADMIN_MANAGE_ROLES: 'admin.manage_roles',
+ ADMIN_MANAGE_API_KEYS: 'admin.manage_api_keys',
+ ADMIN_MANAGE_SETTINGS: 'admin.manage_settings',
+
+ // Teams (workspace structure)
+ TEAM_VIEW: 'team.view',
+ TEAM_MANAGE: 'team.manage',
+
+ // Audience & segmentation
+ SEGMENT_VIEW: 'segment.view',
+ SEGMENT_MANAGE: 'segment.manage',
+ USER_ATTRIBUTE_VIEW: 'user_attribute.view',
+ USER_ATTRIBUTE_MANAGE: 'user_attribute.manage',
+
+ // Portal & widget configuration
+ PORTAL_MANAGE: 'portal.manage',
+ WIDGET_VIEW: 'widget.view',
+ WIDGET_MANAGE: 'widget.manage',
+
+ // Conversations / live chat
+ CHAT_VIEW: 'chat.view',
+ CHAT_MANAGE: 'chat.manage',
+
+ // Content moderation
+ MODERATION_VIEW: 'moderation.view',
+ MODERATION_MANAGE: 'moderation.manage',
+} as const
+
+export type PermissionKey = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]
+
+export const ALL_PERMISSIONS: readonly PermissionKey[] = Object.values(PERMISSIONS)
+
+/**
+ * Permissions grouped by UI category. Used by the permissions admin page
+ * and by the seed migration to populate the `permissions.category` column.
+ */
+export const PERMISSION_CATEGORIES: Record = {
+ ticket: [
+ PERMISSIONS.TICKET_VIEW_ALL,
+ PERMISSIONS.TICKET_VIEW_TEAM,
+ PERMISSIONS.TICKET_VIEW_ASSIGNED,
+ PERMISSIONS.TICKET_VIEW_SHARED,
+ PERMISSIONS.TICKET_REPLY_PUBLIC,
+ PERMISSIONS.TICKET_COMMENT_INTERNAL,
+ PERMISSIONS.TICKET_EDIT_FIELDS,
+ PERMISSIONS.TICKET_ASSIGN_SELF,
+ PERMISSIONS.TICKET_ASSIGN_ANY,
+ PERMISSIONS.TICKET_SHARE_CROSS_TEAM,
+ PERMISSIONS.TICKET_MANAGE_PARTICIPANTS,
+ ],
+ org: [PERMISSIONS.ORG_VIEW, PERMISSIONS.ORG_MANAGE],
+ sla: [
+ PERMISSIONS.SLA_VIEW,
+ PERMISSIONS.SLA_MANAGE,
+ PERMISSIONS.BUSINESS_HOURS_MANAGE,
+ PERMISSIONS.ESCALATION_RULE_MANAGE,
+ ],
+ audit: [PERMISSIONS.AUDIT_VIEW],
+ inbox: [
+ PERMISSIONS.INBOX_VIEW,
+ PERMISSIONS.INBOX_MANAGE,
+ PERMISSIONS.INBOX_CHANNEL_MANAGE,
+ PERMISSIONS.ROUTING_RULE_MANAGE,
+ PERMISSIONS.TICKET_BULK_OPERATE,
+ ],
+ admin: [
+ PERMISSIONS.ADMIN_MANAGE_USERS,
+ PERMISSIONS.ADMIN_MANAGE_ROLES,
+ PERMISSIONS.ADMIN_MANAGE_API_KEYS,
+ PERMISSIONS.ADMIN_MANAGE_SETTINGS,
+ ],
+ team: [PERMISSIONS.TEAM_VIEW, PERMISSIONS.TEAM_MANAGE],
+ audience: [
+ PERMISSIONS.SEGMENT_VIEW,
+ PERMISSIONS.SEGMENT_MANAGE,
+ PERMISSIONS.USER_ATTRIBUTE_VIEW,
+ PERMISSIONS.USER_ATTRIBUTE_MANAGE,
+ ],
+ portal: [PERMISSIONS.PORTAL_MANAGE, PERMISSIONS.WIDGET_VIEW, PERMISSIONS.WIDGET_MANAGE],
+ chat: [PERMISSIONS.CHAT_VIEW, PERMISSIONS.CHAT_MANAGE],
+ moderation: [PERMISSIONS.MODERATION_VIEW, PERMISSIONS.MODERATION_MANAGE],
+}
+
+/**
+ * System role bundles. Seeded by migration; can be cloned/edited from the UI
+ * but not deleted (see `roles.is_system`).
+ *
+ * The legacy `principal.role` cache maps as follows:
+ * - principal.role === 'admin' → SYSTEM_ROLES.OWNER
+ * - principal.role === 'member' → SYSTEM_ROLES.AGENT (default landing role
+ * for existing team members; admins can
+ * promote to SUPERVISOR)
+ * - principal.role === 'user' → SYSTEM_ROLES.CUSTOMER
+ */
+export const SYSTEM_ROLES = {
+ OWNER: 'owner',
+ SUPERVISOR: 'supervisor',
+ AGENT: 'agent',
+ COLLABORATOR: 'collaborator',
+ CUSTOMER: 'customer',
+} as const
+
+export type SystemRoleKey = (typeof SYSTEM_ROLES)[keyof typeof SYSTEM_ROLES]
+
+/**
+ * Which permissions each system role bundle includes.
+ * Owner has every permission (computed dynamically to stay in sync with
+ * `PERMISSIONS`).
+ */
+export const SYSTEM_ROLE_PERMISSIONS: Record = {
+ [SYSTEM_ROLES.OWNER]: ALL_PERMISSIONS,
+
+ [SYSTEM_ROLES.SUPERVISOR]: [
+ PERMISSIONS.TICKET_VIEW_ALL,
+ PERMISSIONS.TICKET_VIEW_TEAM,
+ PERMISSIONS.TICKET_VIEW_ASSIGNED,
+ PERMISSIONS.TICKET_VIEW_SHARED,
+ PERMISSIONS.TICKET_REPLY_PUBLIC,
+ PERMISSIONS.TICKET_COMMENT_INTERNAL,
+ PERMISSIONS.TICKET_EDIT_FIELDS,
+ PERMISSIONS.TICKET_ASSIGN_SELF,
+ PERMISSIONS.TICKET_ASSIGN_ANY,
+ PERMISSIONS.TICKET_SHARE_CROSS_TEAM,
+ PERMISSIONS.TICKET_MANAGE_PARTICIPANTS,
+ PERMISSIONS.ORG_VIEW,
+ PERMISSIONS.ORG_MANAGE,
+ PERMISSIONS.SLA_VIEW,
+ PERMISSIONS.SLA_MANAGE,
+ PERMISSIONS.BUSINESS_HOURS_MANAGE,
+ PERMISSIONS.ESCALATION_RULE_MANAGE,
+ PERMISSIONS.AUDIT_VIEW,
+ PERMISSIONS.INBOX_VIEW,
+ PERMISSIONS.INBOX_MANAGE,
+ PERMISSIONS.INBOX_CHANNEL_MANAGE,
+ PERMISSIONS.ROUTING_RULE_MANAGE,
+ PERMISSIONS.TICKET_BULK_OPERATE,
+ // Workspace structure + operational config (admin.* stays owner-only)
+ PERMISSIONS.TEAM_VIEW,
+ PERMISSIONS.TEAM_MANAGE,
+ PERMISSIONS.SEGMENT_VIEW,
+ PERMISSIONS.SEGMENT_MANAGE,
+ PERMISSIONS.USER_ATTRIBUTE_VIEW,
+ PERMISSIONS.USER_ATTRIBUTE_MANAGE,
+ PERMISSIONS.PORTAL_MANAGE,
+ PERMISSIONS.WIDGET_VIEW,
+ PERMISSIONS.WIDGET_MANAGE,
+ PERMISSIONS.CHAT_VIEW,
+ PERMISSIONS.CHAT_MANAGE,
+ PERMISSIONS.MODERATION_VIEW,
+ PERMISSIONS.MODERATION_MANAGE,
+ ],
+
+ [SYSTEM_ROLES.AGENT]: [
+ PERMISSIONS.TICKET_VIEW_TEAM,
+ PERMISSIONS.TICKET_VIEW_ASSIGNED,
+ PERMISSIONS.TICKET_VIEW_SHARED,
+ PERMISSIONS.TICKET_REPLY_PUBLIC,
+ PERMISSIONS.TICKET_COMMENT_INTERNAL,
+ PERMISSIONS.TICKET_EDIT_FIELDS,
+ PERMISSIONS.TICKET_ASSIGN_SELF,
+ PERMISSIONS.ORG_VIEW,
+ PERMISSIONS.SLA_VIEW,
+ PERMISSIONS.INBOX_VIEW,
+ PERMISSIONS.TICKET_BULK_OPERATE,
+ // Front-line agents handle conversations and need read context
+ PERMISSIONS.CHAT_VIEW,
+ PERMISSIONS.CHAT_MANAGE,
+ PERMISSIONS.TEAM_VIEW,
+ PERMISSIONS.SEGMENT_VIEW,
+ ],
+
+ [SYSTEM_ROLES.COLLABORATOR]: [
+ PERMISSIONS.TICKET_VIEW_SHARED,
+ PERMISSIONS.TICKET_VIEW_ASSIGNED,
+ PERMISSIONS.TICKET_COMMENT_INTERNAL,
+ PERMISSIONS.ORG_VIEW,
+ ],
+
+ // Customer role intentionally has no internal-side permissions; portal-side
+ // access is handled by the existing portal session helpers.
+ [SYSTEM_ROLES.CUSTOMER]: [],
+}
diff --git a/apps/web/src/lib/server/domains/authz/authz.scopes.ts b/apps/web/src/lib/server/domains/authz/authz.scopes.ts
new file mode 100644
index 000000000..853c9a778
--- /dev/null
+++ b/apps/web/src/lib/server/domains/authz/authz.scopes.ts
@@ -0,0 +1,72 @@
+/**
+ * Scope evaluation helpers.
+ *
+ * Many ticketing permissions come in pairs (e.g. `ticket.view_all` vs
+ * `ticket.view_team`). The authz service first checks whether the principal
+ * holds *any* permission in the family; if they only hold a narrower variant,
+ * the scope evaluator decides whether the specific resource falls inside that
+ * narrower scope.
+ *
+ * A `ResourceScope` describes the resource being acted on. For tickets that's
+ * the owning team, the assignee, any teams the ticket is shared with, and any
+ * organization the requester belongs to.
+ */
+
+import type { PrincipalId, TeamId } from '@quackback/ids'
+
+export interface ResourceScope {
+ /** Team that primarily owns the resource (e.g. ticket.primary_team_id). */
+ primaryTeamId?: TeamId | null
+ /** Principal currently assigned (e.g. ticket.assignee_principal_id). */
+ assigneePrincipalId?: PrincipalId | null
+ /** Team currently assigned (e.g. ticket.assignee_team_id). */
+ assigneeTeamId?: TeamId | null
+ /** Teams the resource is explicitly shared with. */
+ sharedTeamIds?: readonly TeamId[]
+}
+
+export interface ActorScope {
+ principalId: PrincipalId
+ /** Teams the actor belongs to (any role). */
+ teamIds: readonly TeamId[]
+}
+
+/**
+ * Result of a single scope check: whether the actor's narrower permission
+ * applies to this resource.
+ */
+export interface ScopeMatch {
+ /** True if the resource is within the actor's allowed scope. */
+ inScope: boolean
+ /** Why — useful for debugging and for the redaction UX copy. */
+ reason: 'assigned' | 'team' | 'shared' | 'all' | 'none'
+}
+
+export function matchesAssignedScope(actor: ActorScope, resource: ResourceScope): ScopeMatch {
+ if (resource.assigneePrincipalId && resource.assigneePrincipalId === actor.principalId) {
+ return { inScope: true, reason: 'assigned' }
+ }
+ return { inScope: false, reason: 'none' }
+}
+
+export function matchesTeamScope(actor: ActorScope, resource: ResourceScope): ScopeMatch {
+ if (resource.primaryTeamId && actor.teamIds.includes(resource.primaryTeamId)) {
+ return { inScope: true, reason: 'team' }
+ }
+ if (resource.assigneeTeamId && actor.teamIds.includes(resource.assigneeTeamId)) {
+ return { inScope: true, reason: 'team' }
+ }
+ return { inScope: false, reason: 'none' }
+}
+
+export function matchesSharedScope(actor: ActorScope, resource: ResourceScope): ScopeMatch {
+ if (!resource.sharedTeamIds?.length) {
+ return { inScope: false, reason: 'none' }
+ }
+ for (const shared of resource.sharedTeamIds) {
+ if (actor.teamIds.includes(shared)) {
+ return { inScope: true, reason: 'shared' }
+ }
+ }
+ return { inScope: false, reason: 'none' }
+}
diff --git a/apps/web/src/lib/server/domains/authz/authz.service.ts b/apps/web/src/lib/server/domains/authz/authz.service.ts
new file mode 100644
index 000000000..677871f8b
--- /dev/null
+++ b/apps/web/src/lib/server/domains/authz/authz.service.ts
@@ -0,0 +1,230 @@
+/**
+ * AuthzService — load + evaluate permissions for a principal.
+ *
+ * Source of truth: `principal_role_assignments` → `role_permissions` →
+ * `permissions`. The legacy `principal.role` column remains a denormalised
+ * cache so existing call sites keep working.
+ *
+ * Per-request caching is intentionally NOT done here (the layer is stateless);
+ * `auth-helpers.ts` calls `loadPermissionSet` once when building `AuthContext`
+ * and stashes the result on the context object passed down the request.
+ */
+
+import {
+ db,
+ eq,
+ inArray,
+ principal,
+ principalRoleAssignments,
+ rolePermissions,
+ permissions as permissionsTable,
+ teamMemberships,
+} from '@/lib/server/db'
+import type { PrincipalId, TeamId } from '@quackback/ids'
+import { ForbiddenError } from '@/lib/shared/errors'
+import {
+ PERMISSIONS,
+ SYSTEM_ROLE_PERMISSIONS,
+ SYSTEM_ROLES,
+ type PermissionKey,
+} from './authz.permissions'
+import {
+ matchesAssignedScope,
+ matchesSharedScope,
+ matchesTeamScope,
+ type ActorScope,
+ type ResourceScope,
+ type ScopeMatch,
+} from './authz.scopes'
+
+/**
+ * Permission set held by a principal, indexed for fast lookup.
+ */
+export interface PermissionSet {
+ principalId: PrincipalId
+ /** All permissions held workspace-wide (team_id IS NULL grants). */
+ workspacePermissions: ReadonlySet
+ /** Map team_id → set of permissions granted by team-scoped role assignments. */
+ teamPermissions: ReadonlyMap>
+ /** Teams the principal is a member of (independent of permission grants). */
+ teamIds: readonly TeamId[]
+}
+
+/**
+ * Build the permission set for a principal.
+ *
+ * Falls back to the legacy `principal.role` column when no role assignments
+ * exist yet (i.e. before the seed migration has run on a given workspace).
+ * This keeps the system safe to deploy in any order: ship code first, run
+ * migration later.
+ */
+export async function loadPermissionSet(principalId: PrincipalId): Promise {
+ // Team memberships are needed regardless of the grant lookup outcome.
+ const memberships = await db.query.teamMemberships.findMany({
+ where: eq(teamMemberships.principalId, principalId),
+ columns: { teamId: true },
+ })
+ const teamIds = memberships.map((m) => m.teamId as TeamId)
+
+ // Load every grant for this principal plus the permissions attached to each
+ // role. We do this in two queries (assignments → role IDs → permissions) to
+ // keep the query plans simple and indexable.
+ const assignments = await db.query.principalRoleAssignments.findMany({
+ where: eq(principalRoleAssignments.principalId, principalId),
+ columns: { roleId: true, teamId: true },
+ })
+
+ if (assignments.length === 0) {
+ return legacyFallback(principalId, teamIds)
+ }
+
+ const roleIds = Array.from(new Set(assignments.map((a) => a.roleId)))
+ const grants = await db
+ .select({
+ roleId: rolePermissions.roleId,
+ key: permissionsTable.key,
+ })
+ .from(rolePermissions)
+ .innerJoin(permissionsTable, eq(rolePermissions.permissionId, permissionsTable.id))
+ .where(inArray(rolePermissions.roleId, roleIds))
+
+ const permissionsByRole = new Map()
+ for (const grant of grants) {
+ const existing = permissionsByRole.get(grant.roleId) ?? []
+ existing.push(grant.key as PermissionKey)
+ permissionsByRole.set(grant.roleId, existing)
+ }
+
+ const workspacePermissions = new Set()
+ const teamPermissions = new Map>()
+
+ for (const assignment of assignments) {
+ const perms = permissionsByRole.get(assignment.roleId) ?? []
+ if (assignment.teamId == null) {
+ for (const p of perms) workspacePermissions.add(p)
+ } else {
+ const teamId = assignment.teamId as TeamId
+ const bucket = teamPermissions.get(teamId) ?? new Set()
+ for (const p of perms) bucket.add(p)
+ teamPermissions.set(teamId, bucket)
+ }
+ }
+
+ return {
+ principalId,
+ workspacePermissions,
+ teamPermissions: new Map(
+ Array.from(teamPermissions.entries()).map(([k, v]) => [k, v as ReadonlySet])
+ ),
+ teamIds,
+ }
+}
+
+/**
+ * Pre-migration fallback: synthesise a permission set from `principal.role`.
+ */
+async function legacyFallback(
+ principalId: PrincipalId,
+ teamIds: readonly TeamId[]
+): Promise {
+ const record = await db.query.principal.findFirst({
+ where: eq(principal.id, principalId),
+ columns: { role: true },
+ })
+ const legacyRole = record?.role ?? 'user'
+ const mapped =
+ legacyRole === 'admin'
+ ? SYSTEM_ROLE_PERMISSIONS[SYSTEM_ROLES.OWNER]
+ : legacyRole === 'member'
+ ? SYSTEM_ROLE_PERMISSIONS[SYSTEM_ROLES.AGENT]
+ : SYSTEM_ROLE_PERMISSIONS[SYSTEM_ROLES.CUSTOMER]
+ return {
+ principalId,
+ workspacePermissions: new Set(mapped),
+ teamPermissions: new Map(),
+ teamIds,
+ }
+}
+
+/**
+ * Return true if the principal holds `permission` in *some* scope.
+ *
+ * Use `hasPermissionForResource` for the common ticketing case where the
+ * permission must apply to a specific resource.
+ */
+export function hasPermission(set: PermissionSet, permission: PermissionKey): boolean {
+ if (set.workspacePermissions.has(permission)) return true
+ for (const perms of set.teamPermissions.values()) {
+ if (perms.has(permission)) return true
+ }
+ return false
+}
+
+/**
+ * Return true if the principal holds `permission` *for the given resource*.
+ *
+ * Workspace-wide grants always match. Team-scoped grants only match when the
+ * resource lives in (or is shared with) one of the granted teams.
+ */
+export function hasPermissionForResource(
+ set: PermissionSet,
+ permission: PermissionKey,
+ resource: ResourceScope
+): boolean {
+ if (set.workspacePermissions.has(permission)) return true
+ for (const [teamId, perms] of set.teamPermissions.entries()) {
+ if (!perms.has(permission)) continue
+ if (resource.primaryTeamId === teamId) return true
+ if (resource.assigneeTeamId === teamId) return true
+ if (resource.sharedTeamIds?.includes(teamId)) return true
+ }
+ return false
+}
+
+/**
+ * Decide whether the principal can *view* a ticket-shaped resource, returning
+ * the matching scope so the UI can render an accurate visibility chip.
+ *
+ * Tries permissions from broadest to narrowest:
+ * 1. ticket.view_all → reason 'all'
+ * 2. ticket.view_team → reason 'team'
+ * 3. ticket.view_shared → reason 'shared'
+ * 4. ticket.view_assigned → reason 'assigned'
+ */
+export function evaluateTicketView(set: PermissionSet, resource: ResourceScope): ScopeMatch {
+ const actor: ActorScope = { principalId: set.principalId, teamIds: set.teamIds }
+
+ if (hasPermission(set, PERMISSIONS.TICKET_VIEW_ALL)) {
+ return { inScope: true, reason: 'all' }
+ }
+ if (hasPermissionForResource(set, PERMISSIONS.TICKET_VIEW_TEAM, resource)) {
+ const m = matchesTeamScope(actor, resource)
+ if (m.inScope) return m
+ }
+ if (hasPermissionForResource(set, PERMISSIONS.TICKET_VIEW_SHARED, resource)) {
+ const m = matchesSharedScope(actor, resource)
+ if (m.inScope) return m
+ }
+ if (hasPermissionForResource(set, PERMISSIONS.TICKET_VIEW_ASSIGNED, resource)) {
+ const m = matchesAssignedScope(actor, resource)
+ if (m.inScope) return m
+ }
+ return { inScope: false, reason: 'none' }
+}
+
+/**
+ * Throw a `ForbiddenError` if the principal lacks the permission.
+ * For resource-scoped checks, pass `resource`.
+ */
+export function assertPermission(
+ set: PermissionSet,
+ permission: PermissionKey,
+ resource?: ResourceScope
+): void {
+ const ok = resource
+ ? hasPermissionForResource(set, permission, resource)
+ : hasPermission(set, permission)
+ if (!ok) {
+ throw new ForbiddenError('PERMISSION_DENIED', `Missing required permission: ${permission}`)
+ }
+}
diff --git a/apps/web/src/lib/server/domains/authz/index.ts b/apps/web/src/lib/server/domains/authz/index.ts
new file mode 100644
index 000000000..dd154c839
--- /dev/null
+++ b/apps/web/src/lib/server/domains/authz/index.ts
@@ -0,0 +1,32 @@
+/**
+ * Ticketing / CRM RBAC authorization layer.
+ *
+ * Covers: tickets, teams, inboxes, SLA policies, contacts, organizations,
+ * and workspace admin operations.
+ *
+ * Design: 30+ dotted permission keys (e.g. `ticket.view_all`) organized into
+ * categories, assigned to 5 system roles (owner, supervisor, agent,
+ * collaborator, customer), with team-scope narrowing for view permissions.
+ *
+ * Legacy fallback: when no `principal_role_assignments` exist for a principal,
+ * the system maps `principal.role` → system role:
+ * admin → owner, member → agent, user → customer.
+ *
+ * For feedback portal authorization (boards, posts, comments, chat), use
+ * `@/lib/server/policy` instead — it uses a simpler tier-based access model.
+ *
+ * Service functions are server-only; types and the permission catalogue are
+ * safe to import from client code (e.g. for UI capability checks).
+ */
+
+export {
+ PERMISSIONS,
+ PERMISSION_CATEGORIES,
+ ALL_PERMISSIONS,
+ SYSTEM_ROLES,
+ SYSTEM_ROLE_PERMISSIONS,
+ type PermissionKey,
+ type SystemRoleKey,
+} from './authz.permissions'
+
+export type { ActorScope, ResourceScope, ScopeMatch } from './authz.scopes'
diff --git a/apps/web/src/lib/server/domains/authz/role.service.ts b/apps/web/src/lib/server/domains/authz/role.service.ts
new file mode 100644
index 000000000..d3ee8306f
--- /dev/null
+++ b/apps/web/src/lib/server/domains/authz/role.service.ts
@@ -0,0 +1,471 @@
+/**
+ * Role service — CRUD for role bundles + permission assignments + grants.
+ *
+ * System roles (`isSystem=true`) are seeded by migration and are read-only
+ * from the UI: they cannot be renamed, have their permissions changed, or be
+ * deleted. They CAN be granted/revoked freely.
+ *
+ * All write paths emit an audit event via `recordEvent`.
+ */
+import {
+ db,
+ eq,
+ and,
+ inArray,
+ count,
+ desc,
+ roles,
+ permissions as permissionsTable,
+ rolePermissions,
+ principalRoleAssignments,
+ teams,
+} from '@/lib/server/db'
+import type { PermissionId, PrincipalId, RoleId, RoleAssignmentId, TeamId } from '@quackback/ids'
+import { ConflictError, ForbiddenError, NotFoundError } from '@/lib/shared/errors'
+import { recordEvent } from '@/lib/server/domains/audit/audit.service'
+import {
+ dispatchRoleCreated,
+ dispatchRoleUpdated,
+ dispatchRoleDeleted,
+ dispatchRoleAssignmentCreated,
+ dispatchRoleAssignmentRevoked,
+} from '@/lib/server/events/dispatch'
+import { ALL_PERMISSIONS, type PermissionKey } from './authz.permissions'
+
+const ROLE_ACTOR = { type: 'service' as const, displayName: 'authz-system' }
+
+export interface RoleListItem {
+ id: RoleId
+ key: string
+ name: string
+ description: string | null
+ isSystem: boolean
+ permissionCount: number
+ assignmentCount: number
+}
+
+export interface RoleWithPermissions {
+ id: RoleId
+ key: string
+ name: string
+ description: string | null
+ isSystem: boolean
+ permissionKeys: PermissionKey[]
+ createdAt: Date
+ updatedAt: Date
+}
+
+export interface PrincipalRoleAssignmentRow {
+ id: RoleAssignmentId
+ role: { id: RoleId; key: string; name: string; isSystem: boolean }
+ teamId: TeamId | null
+ teamName: string | null
+ grantedByPrincipalId: PrincipalId | null
+ createdAt: Date
+}
+
+export async function listRoles(): Promise {
+ const rows = await db.select().from(roles).orderBy(desc(roles.isSystem), roles.name)
+ if (rows.length === 0) return []
+
+ const ids = rows.map((r) => r.id)
+ const permCounts = await db
+ .select({ roleId: rolePermissions.roleId, n: count(rolePermissions.id) })
+ .from(rolePermissions)
+ .where(inArray(rolePermissions.roleId, ids))
+ .groupBy(rolePermissions.roleId)
+ const asgnCounts = await db
+ .select({ roleId: principalRoleAssignments.roleId, n: count(principalRoleAssignments.id) })
+ .from(principalRoleAssignments)
+ .where(inArray(principalRoleAssignments.roleId, ids))
+ .groupBy(principalRoleAssignments.roleId)
+
+ const permMap = new Map(permCounts.map((r) => [r.roleId, Number(r.n)]))
+ const asgnMap = new Map(asgnCounts.map((r) => [r.roleId, Number(r.n)]))
+
+ return rows.map((r) => ({
+ id: r.id as RoleId,
+ key: r.key,
+ name: r.name,
+ description: r.description,
+ isSystem: r.isSystem,
+ permissionCount: permMap.get(r.id) ?? 0,
+ assignmentCount: asgnMap.get(r.id) ?? 0,
+ }))
+}
+
+export async function getRoleWithPermissions(roleId: RoleId): Promise {
+ const [role] = await db.select().from(roles).where(eq(roles.id, roleId)).limit(1)
+ if (!role) throw new NotFoundError('ROLE_NOT_FOUND', 'Role not found')
+
+ const grants = await db
+ .select({ key: permissionsTable.key })
+ .from(rolePermissions)
+ .innerJoin(permissionsTable, eq(rolePermissions.permissionId, permissionsTable.id))
+ .where(eq(rolePermissions.roleId, roleId))
+
+ return {
+ id: role.id as RoleId,
+ key: role.key,
+ name: role.name,
+ description: role.description,
+ isSystem: role.isSystem,
+ permissionKeys: grants.map((g) => g.key as PermissionKey),
+ createdAt: role.createdAt,
+ updatedAt: role.updatedAt,
+ }
+}
+
+interface CreateRoleInput {
+ key: string
+ name: string
+ description?: string | null
+ permissionKeys: PermissionKey[]
+ actorPrincipalId: PrincipalId
+}
+
+export async function createRole(input: CreateRoleInput): Promise {
+ validatePermissionKeys(input.permissionKeys)
+
+ const [existing] = await db
+ .select({ id: roles.id })
+ .from(roles)
+ .where(eq(roles.key, input.key))
+ .limit(1)
+ if (existing) throw new ConflictError('ROLE_KEY_EXISTS', 'Role key already exists')
+
+ const newId = await db.transaction(async (tx) => {
+ const [inserted] = await tx
+ .insert(roles)
+ .values({
+ key: input.key,
+ name: input.name,
+ description: input.description ?? null,
+ isSystem: false,
+ })
+ .returning({ id: roles.id })
+ if (!inserted) throw new Error('Failed to insert role')
+
+ if (input.permissionKeys.length > 0) {
+ const permIds = await loadPermissionIdsTx(tx, input.permissionKeys)
+ await tx.insert(rolePermissions).values(
+ permIds.map((permissionId) => ({
+ roleId: inserted.id as RoleId,
+ permissionId,
+ }))
+ )
+ }
+ return inserted.id as RoleId
+ })
+
+ await recordEvent({
+ principalId: input.actorPrincipalId,
+ action: 'role.created',
+ targetType: 'role',
+ targetId: newId,
+ diff: {
+ after: {
+ key: input.key,
+ name: input.name,
+ permissions: input.permissionKeys,
+ },
+ },
+ })
+
+ void dispatchRoleCreated(ROLE_ACTOR, {
+ id: newId,
+ key: input.key,
+ name: input.name,
+ isSystem: false,
+ }).catch(() => {})
+
+ return newId
+}
+
+interface UpdateRoleInput {
+ id: RoleId
+ name: string
+ description?: string | null
+ actorPrincipalId: PrincipalId
+}
+
+export async function updateRole(input: UpdateRoleInput): Promise {
+ const [role] = await db.select().from(roles).where(eq(roles.id, input.id)).limit(1)
+ if (!role) throw new NotFoundError('ROLE_NOT_FOUND', 'Role not found')
+ if (role.isSystem) throw new ForbiddenError('ROLE_SYSTEM', 'System roles cannot be edited')
+
+ await db
+ .update(roles)
+ .set({ name: input.name, description: input.description ?? null })
+ .where(eq(roles.id, input.id))
+
+ await recordEvent({
+ principalId: input.actorPrincipalId,
+ action: 'role.updated',
+ targetType: 'role',
+ targetId: input.id,
+ diff: {
+ before: { name: role.name, description: role.description },
+ after: { name: input.name, description: input.description ?? null },
+ },
+ })
+
+ const changedFields = ['name']
+ if (input.description !== undefined) changedFields.push('description')
+ void dispatchRoleUpdated(
+ ROLE_ACTOR,
+ {
+ id: role.id as RoleId,
+ key: role.key,
+ name: input.name,
+ isSystem: role.isSystem,
+ },
+ changedFields
+ ).catch(() => {})
+}
+
+export async function deleteRole(input: {
+ id: RoleId
+ actorPrincipalId: PrincipalId
+}): Promise {
+ const [role] = await db.select().from(roles).where(eq(roles.id, input.id)).limit(1)
+ if (!role) throw new NotFoundError('ROLE_NOT_FOUND', 'Role not found')
+ if (role.isSystem) throw new ForbiddenError('ROLE_SYSTEM', 'System roles cannot be deleted')
+
+ const [{ n }] = await db
+ .select({ n: count(principalRoleAssignments.id) })
+ .from(principalRoleAssignments)
+ .where(eq(principalRoleAssignments.roleId, input.id))
+ if (Number(n) > 0) {
+ throw new ConflictError(
+ 'ROLE_HAS_ASSIGNMENTS',
+ 'Revoke all assignments before deleting this role'
+ )
+ }
+
+ await db.delete(roles).where(eq(roles.id, input.id))
+
+ await recordEvent({
+ principalId: input.actorPrincipalId,
+ action: 'role.deleted',
+ targetType: 'role',
+ targetId: input.id,
+ diff: { before: { key: role.key, name: role.name } },
+ })
+
+ void dispatchRoleDeleted(ROLE_ACTOR, {
+ id: role.id as RoleId,
+ key: role.key,
+ name: role.name,
+ isSystem: role.isSystem,
+ }).catch(() => {})
+}
+
+interface SetRolePermissionsInput {
+ roleId: RoleId
+ permissionKeys: PermissionKey[]
+ actorPrincipalId: PrincipalId
+}
+
+export async function setRolePermissions(input: SetRolePermissionsInput): Promise {
+ validatePermissionKeys(input.permissionKeys)
+
+ const [role] = await db.select().from(roles).where(eq(roles.id, input.roleId)).limit(1)
+ if (!role) throw new NotFoundError('ROLE_NOT_FOUND', 'Role not found')
+ if (role.isSystem)
+ throw new ForbiddenError('ROLE_SYSTEM', 'System role permissions cannot be changed')
+
+ const existing = await db
+ .select({ key: permissionsTable.key })
+ .from(rolePermissions)
+ .innerJoin(permissionsTable, eq(rolePermissions.permissionId, permissionsTable.id))
+ .where(eq(rolePermissions.roleId, input.roleId))
+ const before = existing.map((g) => g.key as PermissionKey)
+
+ await db.transaction(async (tx) => {
+ await tx.delete(rolePermissions).where(eq(rolePermissions.roleId, input.roleId))
+ if (input.permissionKeys.length > 0) {
+ const permIds = await loadPermissionIdsTx(tx, input.permissionKeys)
+ await tx
+ .insert(rolePermissions)
+ .values(permIds.map((permissionId) => ({ roleId: input.roleId, permissionId })))
+ }
+ })
+
+ await recordEvent({
+ principalId: input.actorPrincipalId,
+ action: 'role.permissions_set',
+ targetType: 'role',
+ targetId: input.roleId,
+ diff: {
+ before: { permissions: before },
+ after: { permissions: input.permissionKeys },
+ },
+ })
+}
+
+export async function listAssignmentsForPrincipal(
+ principalId: PrincipalId
+): Promise {
+ const rows = await db
+ .select({
+ id: principalRoleAssignments.id,
+ teamId: principalRoleAssignments.teamId,
+ grantedByPrincipalId: principalRoleAssignments.grantedByPrincipalId,
+ createdAt: principalRoleAssignments.createdAt,
+ roleId: roles.id,
+ roleKey: roles.key,
+ roleName: roles.name,
+ roleIsSystem: roles.isSystem,
+ teamName: teams.name,
+ })
+ .from(principalRoleAssignments)
+ .innerJoin(roles, eq(principalRoleAssignments.roleId, roles.id))
+ .leftJoin(teams, eq(principalRoleAssignments.teamId, teams.id))
+ .where(eq(principalRoleAssignments.principalId, principalId))
+ .orderBy(desc(principalRoleAssignments.createdAt))
+
+ return rows.map((r) => ({
+ id: r.id as RoleAssignmentId,
+ role: {
+ id: r.roleId as RoleId,
+ key: r.roleKey,
+ name: r.roleName,
+ isSystem: r.roleIsSystem,
+ },
+ teamId: (r.teamId as TeamId | null) ?? null,
+ teamName: r.teamName ?? null,
+ grantedByPrincipalId: (r.grantedByPrincipalId as PrincipalId | null) ?? null,
+ createdAt: r.createdAt,
+ }))
+}
+
+interface AssignRoleInput {
+ principalId: PrincipalId
+ roleId: RoleId
+ teamId?: TeamId | null
+ actorPrincipalId: PrincipalId
+}
+
+export async function assignRole(input: AssignRoleInput): Promise {
+ const [role] = await db.select().from(roles).where(eq(roles.id, input.roleId)).limit(1)
+ if (!role) throw new NotFoundError('ROLE_NOT_FOUND', 'Role not found')
+
+ const teamId = input.teamId ?? null
+ const dupeWhere = teamId
+ ? and(
+ eq(principalRoleAssignments.principalId, input.principalId),
+ eq(principalRoleAssignments.roleId, input.roleId),
+ eq(principalRoleAssignments.teamId, teamId)
+ )
+ : and(
+ eq(principalRoleAssignments.principalId, input.principalId),
+ eq(principalRoleAssignments.roleId, input.roleId)
+ )
+ const [existing] = await db
+ .select({ id: principalRoleAssignments.id })
+ .from(principalRoleAssignments)
+ .where(dupeWhere)
+ .limit(1)
+ if (existing)
+ throw new ConflictError('ROLE_ALREADY_ASSIGNED', 'Role already assigned for this scope')
+
+ const [inserted] = await db
+ .insert(principalRoleAssignments)
+ .values({
+ principalId: input.principalId,
+ roleId: input.roleId,
+ teamId,
+ grantedByPrincipalId: input.actorPrincipalId,
+ })
+ .returning({ id: principalRoleAssignments.id })
+ if (!inserted) throw new Error('Failed to insert role assignment')
+
+ await recordEvent({
+ principalId: input.actorPrincipalId,
+ action: 'role_assignment.granted',
+ targetType: 'principal',
+ targetId: input.principalId,
+ diff: {
+ after: { role: role.key, teamId },
+ },
+ })
+
+ void dispatchRoleAssignmentCreated(ROLE_ACTOR, {
+ id: inserted.id as RoleAssignmentId,
+ roleId: input.roleId,
+ roleKey: role.key,
+ principalId: input.principalId,
+ teamId,
+ }).catch(() => {})
+
+ return inserted.id as RoleAssignmentId
+}
+
+export async function revokeRoleAssignment(input: {
+ assignmentId: RoleAssignmentId
+ actorPrincipalId: PrincipalId
+}): Promise {
+ const [row] = await db
+ .select({
+ principalId: principalRoleAssignments.principalId,
+ roleId: principalRoleAssignments.roleId,
+ teamId: principalRoleAssignments.teamId,
+ roleKey: roles.key,
+ })
+ .from(principalRoleAssignments)
+ .innerJoin(roles, eq(principalRoleAssignments.roleId, roles.id))
+ .where(eq(principalRoleAssignments.id, input.assignmentId))
+ .limit(1)
+ if (!row) throw new NotFoundError('ASSIGNMENT_NOT_FOUND', 'Role assignment not found')
+
+ await db
+ .delete(principalRoleAssignments)
+ .where(eq(principalRoleAssignments.id, input.assignmentId))
+
+ await recordEvent({
+ principalId: input.actorPrincipalId,
+ action: 'role_assignment.revoked',
+ targetType: 'principal',
+ targetId: row.principalId as string,
+ diff: {
+ before: { role: row.roleKey, teamId: row.teamId },
+ },
+ })
+
+ void dispatchRoleAssignmentRevoked(ROLE_ACTOR, {
+ id: input.assignmentId,
+ roleId: row.roleId as RoleId,
+ roleKey: row.roleKey,
+ principalId: row.principalId as PrincipalId,
+ teamId: (row.teamId as TeamId | null) ?? null,
+ }).catch(() => {})
+}
+
+// --- helpers --------------------------------------------------------------
+
+function validatePermissionKeys(keys: PermissionKey[]) {
+ const valid = new Set(ALL_PERMISSIONS)
+ for (const k of keys) {
+ if (!valid.has(k)) throw new ConflictError('UNKNOWN_PERMISSION', `Unknown permission: ${k}`)
+ }
+}
+
+async function loadPermissionIdsTx(
+ tx: Parameters[0]>[0],
+ keys: PermissionKey[]
+): Promise {
+ const rows = await tx
+ .select({ id: permissionsTable.id, key: permissionsTable.key })
+ .from(permissionsTable)
+ .where(inArray(permissionsTable.key, keys as string[]))
+ if (rows.length !== keys.length) {
+ const found = new Set(rows.map((r) => r.key))
+ const missing = keys.filter((k) => !found.has(k))
+ throw new ConflictError(
+ 'PERMISSIONS_MISSING',
+ `Permissions missing from DB (run migrations?): ${missing.join(', ')}`
+ )
+ }
+ return rows.map((r) => r.id as PermissionId)
+}
diff --git a/apps/web/src/lib/server/domains/customers/__tests__/customer-people.service.test.ts b/apps/web/src/lib/server/domains/customers/__tests__/customer-people.service.test.ts
new file mode 100644
index 000000000..136d262c0
--- /dev/null
+++ b/apps/web/src/lib/server/domains/customers/__tests__/customer-people.service.test.ts
@@ -0,0 +1,468 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { CustomerPersonListItem } from '../customer-people.service'
+
+const mocks = vi.hoisted(() => ({
+ dbSelect: vi.fn(),
+ searchContacts: vi.fn(),
+ eq: vi.fn(),
+ and: vi.fn(),
+ or: vi.fn(),
+ inArray: vi.fn(),
+ isNull: vi.fn(),
+ asc: vi.fn(),
+ desc: vi.fn(),
+ sql: vi.fn(),
+ realEmail: vi.fn(),
+}))
+
+vi.mock('@/lib/server/db', () => ({
+ db: {
+ select: (...args: unknown[]) => mocks.dbSelect(...args),
+ },
+ eq: (...args: unknown[]) => mocks.eq(...args),
+ and: (...args: unknown[]) => mocks.and(...args),
+ or: (...args: unknown[]) => mocks.or(...args),
+ inArray: (...args: unknown[]) => mocks.inArray(...args),
+ isNull: (...args: unknown[]) => mocks.isNull(...args),
+ asc: (...args: unknown[]) => mocks.asc(...args),
+ desc: (...args: unknown[]) => mocks.desc(...args),
+ sql: (...args: unknown[]) => mocks.sql(...args),
+ contacts: {
+ id: 'contacts.id',
+ archivedAt: 'contacts.archivedAt',
+ },
+ organizations: {
+ id: 'organizations.id',
+ name: 'organizations.name',
+ },
+ contactUserLinks: {
+ contactId: 'contactUserLinks.contactId',
+ userId: 'contactUserLinks.userId',
+ },
+ principal: {
+ id: 'principal.id',
+ userId: 'principal.userId',
+ role: 'principal.role',
+ type: 'principal.type',
+ createdAt: 'principal.createdAt',
+ },
+ user: {
+ id: 'user.id',
+ name: 'user.name',
+ email: 'user.email',
+ image: 'user.image',
+ emailVerified: 'user.emailVerified',
+ },
+ posts: {
+ id: 'posts.id',
+ principalId: 'posts.principalId',
+ deletedAt: 'posts.deletedAt',
+ },
+ comments: {
+ id: 'comments.id',
+ principalId: 'comments.principalId',
+ deletedAt: 'comments.deletedAt',
+ },
+ votes: {
+ id: 'votes.id',
+ principalId: 'votes.principalId',
+ },
+ tickets: {
+ id: 'tickets.id',
+ requesterContactId: 'tickets.requesterContactId',
+ requesterPrincipalId: 'tickets.requesterPrincipalId',
+ deletedAt: 'tickets.deletedAt',
+ },
+ userSegments: {
+ principalId: 'userSegments.principalId',
+ segmentId: 'userSegments.segmentId',
+ },
+ segments: {
+ id: 'segments.id',
+ name: 'segments.name',
+ color: 'segments.color',
+ type: 'segments.type',
+ deletedAt: 'segments.deletedAt',
+ },
+}))
+
+vi.mock('../../organizations/contact.service', () => ({
+ searchContacts: (...args: unknown[]) => mocks.searchContacts(...args),
+}))
+
+vi.mock('@/lib/shared/anonymous-email', () => ({
+ realEmail: (...args: unknown[]) => mocks.realEmail(...args),
+}))
+
+interface SelectChain {
+ from: ReturnType
+ innerJoin: ReturnType
+ leftJoin: ReturnType
+ where: ReturnType
+ orderBy: ReturnType
+ limit: ReturnType
+ offset: ReturnType
+ groupBy: ReturnType
+ as: ReturnType
+ then: Promise>>['then']
+ catch: Promise>>['catch']
+ finally: Promise>>['finally']
+}
+
+function selectRows(rows: ReadonlyArray>): SelectChain {
+ const promise = Promise.resolve(rows)
+ const chain = {
+ from: vi.fn(() => chain),
+ innerJoin: vi.fn(() => chain),
+ leftJoin: vi.fn(() => chain),
+ where: vi.fn(() => chain),
+ orderBy: vi.fn(() => chain),
+ limit: vi.fn(() => chain),
+ offset: vi.fn(() => chain),
+ groupBy: vi.fn(() => chain),
+ as: vi.fn((alias: string) => ({
+ principalId: `${alias}.principalId`,
+ postCount: `${alias}.postCount`,
+ commentCount: `${alias}.commentCount`,
+ voteCount: `${alias}.voteCount`,
+ })),
+ then: promise.then.bind(promise),
+ catch: promise.catch.bind(promise),
+ finally: promise.finally.bind(promise),
+ }
+ return chain
+}
+
+function queueSelects(...rowSets: Array>>) {
+ const chains = rowSets.map((rows) => selectRows(rows))
+ for (const chain of chains) {
+ mocks.dbSelect.mockReturnValueOnce(chain)
+ }
+ return chains
+}
+
+function portalRow(input: {
+ principalId: string
+ userId: string
+ name: string | null
+ email: string | null
+ image?: string | null
+ emailVerified?: boolean
+ postCount?: number
+ commentCount?: number
+ voteCount?: number
+}) {
+ return {
+ principalId: input.principalId,
+ userId: input.userId,
+ name: input.name,
+ email: input.email,
+ image: input.image ?? null,
+ emailVerified: input.emailVerified ?? false,
+ joinedAt: new Date('2026-06-01T00:00:00.000Z'),
+ postCount: input.postCount ?? 0,
+ commentCount: input.commentCount ?? 0,
+ voteCount: input.voteCount ?? 0,
+ }
+}
+
+function segmentRow(principalId: string, id: string, name: string) {
+ return {
+ principalId,
+ segmentId: id,
+ segmentName: name,
+ segmentColor: '#2563eb',
+ segmentType: 'manual',
+ }
+}
+
+function itemById(items: CustomerPersonListItem[], id: string) {
+ const item = items.find((candidate) => candidate.id === id)
+ expect(item).toBeDefined()
+ return item as CustomerPersonListItem
+}
+
+beforeEach(() => {
+ vi.resetAllMocks()
+ mocks.sql.mockReturnValue({ as: vi.fn((alias: string) => alias) })
+ mocks.and.mockImplementation((...args: unknown[]) => ({ and: args }))
+ mocks.or.mockImplementation((...args: unknown[]) => ({ or: args }))
+ mocks.eq.mockImplementation((...args: unknown[]) => ({ eq: args }))
+ mocks.inArray.mockImplementation((...args: unknown[]) => ({ inArray: args }))
+ mocks.isNull.mockImplementation((arg: unknown) => ({ isNull: arg }))
+ mocks.asc.mockImplementation((arg: unknown) => ({ asc: arg }))
+ mocks.desc.mockImplementation((arg: unknown) => ({ desc: arg }))
+ mocks.realEmail.mockImplementation((email: string | null | undefined) =>
+ email?.startsWith('temp-') ? null : (email ?? null)
+ )
+})
+
+describe('listCustomerPeople', () => {
+ it('lists portal-only people when CRM contacts are excluded', async () => {
+ queueSelects(
+ [{ userId: 'user_b' }, { userId: 'user_a' }],
+ [],
+ [],
+ [],
+ [
+ portalRow({
+ principalId: 'principal_b',
+ userId: 'user_b',
+ name: 'Beta User',
+ email: 'temp-user_b@anon.quackback.io',
+ postCount: 2,
+ commentCount: 1,
+ voteCount: 4,
+ }),
+ portalRow({
+ principalId: 'principal_a',
+ userId: 'user_a',
+ name: 'Alpha User',
+ email: 'alpha@example.com',
+ emailVerified: true,
+ postCount: 5,
+ }),
+ ],
+ [
+ segmentRow('principal_b', 'segment_beta', 'Beta'),
+ segmentRow('principal_a', 'segment_alpha', 'Alpha'),
+ ],
+ [
+ { principalId: 'principal_b', count: 3 },
+ { principalId: 'principal_a', count: 7 },
+ ]
+ )
+
+ const { listCustomerPeople } = await import('../customer-people.service')
+ const result = await listCustomerPeople({
+ includeCrm: false,
+ limit: 500,
+ offset: -10,
+ })
+
+ expect(mocks.searchContacts).not.toHaveBeenCalled()
+ expect(result.total).toBe(2)
+ expect(result.hasMore).toBe(false)
+ expect(result.items.map((item) => item.id)).toEqual(['user:principal_a', 'user:principal_b'])
+
+ const alpha = itemById(result.items, 'user:principal_a')
+ expect(alpha).toMatchObject({
+ kind: 'portal_user',
+ name: 'Alpha User',
+ email: 'alpha@example.com',
+ hasPortalUser: true,
+ emailVerified: true,
+ postCount: 5,
+ ticketCount: 7,
+ })
+ expect(alpha.segments).toEqual([
+ { id: 'segment_alpha', name: 'Alpha', color: '#2563eb', type: 'manual' },
+ ])
+
+ const beta = itemById(result.items, 'user:principal_b')
+ expect(beta.email).toBeNull()
+ expect(beta.ticketCount).toBe(3)
+ })
+
+ it('merges CRM contacts, linked portal users, organizations, segments, and ticket counts', async () => {
+ mocks.searchContacts.mockResolvedValue([
+ {
+ id: 'contact_search',
+ name: 'Search Contact',
+ email: 'search@example.com',
+ avatarUrl: null,
+ organizationId: null,
+ title: 'Champion',
+ phone: null,
+ externalId: null,
+ archivedAt: null,
+ },
+ ])
+
+ queueSelects(
+ [{ userId: 'user_linked' }, { userId: 'user_only' }],
+ [],
+ [],
+ [],
+ [
+ portalRow({
+ principalId: 'principal_linked',
+ userId: 'user_linked',
+ name: 'Linked Portal',
+ email: 'linked@example.com',
+ emailVerified: true,
+ postCount: 2,
+ }),
+ portalRow({
+ principalId: 'principal_only',
+ userId: 'user_only',
+ name: 'Portal Only',
+ email: 'portal@example.com',
+ commentCount: 3,
+ }),
+ ],
+ [
+ segmentRow('principal_linked', 'segment_enterprise', 'Enterprise'),
+ segmentRow('principal_only', 'segment_beta', 'Beta'),
+ ],
+ [{ contactId: 'contact_linked', userId: 'user_linked' }],
+ [
+ {
+ id: 'contact_linked',
+ name: 'Linked Contact',
+ email: null,
+ avatarUrl: null,
+ organizationId: 'org_1',
+ title: 'Buyer',
+ phone: '+1 555',
+ externalId: 'crm_1',
+ archivedAt: null,
+ },
+ ],
+ [{ contactId: 'contact_linked', userId: 'user_linked' }],
+ [],
+ [],
+ [],
+ [
+ portalRow({
+ principalId: 'principal_linked',
+ userId: 'user_linked',
+ name: 'Linked Portal',
+ email: 'linked@example.com',
+ emailVerified: true,
+ postCount: 2,
+ }),
+ ],
+ [segmentRow('principal_linked', 'segment_enterprise', 'Enterprise')],
+ [{ id: 'org_1', name: 'Acme Inc.' }],
+ [
+ { contactId: 'contact_linked', count: 2 },
+ { contactId: 'contact_search', count: 6 },
+ ],
+ [
+ { principalId: 'principal_linked', count: 5 },
+ { principalId: 'principal_only', count: 3 },
+ ]
+ )
+
+ const { listCustomerPeople } = await import('../customer-people.service')
+ const result = await listCustomerPeople({ search: 'portal' })
+
+ expect(mocks.searchContacts).toHaveBeenCalledWith({
+ query: 'portal',
+ includeArchived: undefined,
+ limit: 100,
+ offset: 0,
+ })
+ expect(result.items.map((item) => item.id)).toEqual([
+ 'contact:contact_linked',
+ 'user:principal_only',
+ 'contact:contact_search',
+ ])
+
+ const linked = itemById(result.items, 'contact:contact_linked')
+ expect(linked).toMatchObject({
+ kind: 'linked',
+ contactId: 'contact_linked',
+ name: 'Linked Contact',
+ email: 'linked@example.com',
+ organizationId: 'org_1',
+ organizationName: 'Acme Inc.',
+ title: 'Buyer',
+ phone: '+1 555',
+ externalId: 'crm_1',
+ hasPortalUser: true,
+ emailVerified: true,
+ postCount: 2,
+ ticketCount: 7,
+ })
+ expect(linked.principalIds).toEqual(['principal_linked'])
+ expect(linked.segments).toEqual([
+ { id: 'segment_enterprise', name: 'Enterprise', color: '#2563eb', type: 'manual' },
+ ])
+
+ const portalOnly = itemById(result.items, 'user:principal_only')
+ expect(portalOnly.kind).toBe('portal_user')
+ expect(portalOnly.ticketCount).toBe(3)
+
+ const crmOnly = itemById(result.items, 'contact:contact_search')
+ expect(crmOnly).toMatchObject({
+ kind: 'contact',
+ hasPortalUser: false,
+ ticketCount: 6,
+ })
+ })
+
+ it('filters contact rows by linked portal-user segments and skips ticket counts when disabled', async () => {
+ mocks.searchContacts.mockResolvedValue([
+ {
+ id: 'contact_linked',
+ name: 'Linked Contact',
+ email: 'linked-contact@example.com',
+ avatarUrl: null,
+ organizationId: null,
+ title: null,
+ phone: null,
+ externalId: null,
+ archivedAt: null,
+ },
+ {
+ id: 'contact_without_segment',
+ name: 'No Segment',
+ email: 'no-segment@example.com',
+ avatarUrl: null,
+ organizationId: null,
+ title: null,
+ phone: null,
+ externalId: null,
+ archivedAt: null,
+ },
+ ])
+
+ queueSelects(
+ [],
+ [{ userId: 'user_linked' }],
+ [],
+ [],
+ [],
+ [
+ portalRow({
+ principalId: 'principal_linked',
+ userId: 'user_linked',
+ name: 'Linked Portal',
+ email: 'linked@example.com',
+ }),
+ ],
+ [segmentRow('principal_linked', 'segment_enterprise', 'Enterprise')],
+ [{ contactId: 'contact_linked', userId: 'user_linked' }],
+ [{ contactId: 'contact_linked', userId: 'user_linked' }],
+ [],
+ [],
+ [],
+ [
+ portalRow({
+ principalId: 'principal_linked',
+ userId: 'user_linked',
+ name: 'Linked Portal',
+ email: 'linked@example.com',
+ }),
+ ],
+ [segmentRow('principal_linked', 'segment_enterprise', 'Enterprise')]
+ )
+
+ const { listCustomerPeople } = await import('../customer-people.service')
+ const result = await listCustomerPeople({
+ segmentIds: ['segment_enterprise' as never],
+ includeTicketCounts: false,
+ })
+
+ expect(result.items.map((item) => item.id)).toEqual(['contact:contact_linked'])
+ expect(result.items[0]).toMatchObject({
+ kind: 'linked',
+ ticketCount: 0,
+ segments: [
+ { id: 'segment_enterprise', name: 'Enterprise', color: '#2563eb', type: 'manual' },
+ ],
+ })
+ })
+})
diff --git a/apps/web/src/lib/server/domains/customers/customer-people.service.ts b/apps/web/src/lib/server/domains/customers/customer-people.service.ts
new file mode 100644
index 000000000..5d4d67e0a
--- /dev/null
+++ b/apps/web/src/lib/server/domains/customers/customer-people.service.ts
@@ -0,0 +1,507 @@
+import {
+ db,
+ eq,
+ and,
+ or,
+ inArray,
+ isNull,
+ sql,
+ asc,
+ desc,
+ contacts,
+ organizations,
+ contactUserLinks,
+ principal,
+ user,
+ posts,
+ comments,
+ votes,
+ tickets,
+ userSegments,
+ segments,
+} from '@/lib/server/db'
+import type { SQL } from 'drizzle-orm'
+import type { ContactId, OrganizationId, PrincipalId, SegmentId, UserId } from '@quackback/ids'
+import type { UserSegmentSummary } from '../users/user.types'
+import { searchContacts } from '../organizations/contact.service'
+import { realEmail } from '@/lib/shared/anonymous-email'
+
+export interface CustomerPersonListParams {
+ search?: string
+ includeArchived?: boolean
+ segmentIds?: SegmentId[]
+ includeCrm?: boolean
+ includeTicketCounts?: boolean
+ limit?: number
+ offset?: number
+}
+
+export type CustomerPersonKind = 'linked' | 'contact' | 'portal_user'
+
+export interface CustomerPersonLinkedUser {
+ principalId: PrincipalId
+ userId: UserId
+ name: string | null
+ email: string | null
+ image: string | null
+ emailVerified: boolean
+ joinedAt: Date
+}
+
+export interface CustomerPersonListItem {
+ id: string
+ kind: CustomerPersonKind
+ contactId: ContactId | null
+ principalIds: PrincipalId[]
+ userIds: UserId[]
+ name: string | null
+ email: string | null
+ avatarUrl: string | null
+ organizationId: OrganizationId | null
+ organizationName: string | null
+ title: string | null
+ phone: string | null
+ externalId: string | null
+ archivedAt: Date | null
+ hasPortalUser: boolean
+ emailVerified: boolean
+ linkedUsers: CustomerPersonLinkedUser[]
+ segments: UserSegmentSummary[]
+ postCount: number
+ commentCount: number
+ voteCount: number
+ ticketCount: number
+}
+
+export interface CustomerPersonListResult {
+ items: CustomerPersonListItem[]
+ total: number
+ hasMore: boolean
+}
+
+interface PortalSummary extends CustomerPersonLinkedUser {
+ postCount: number
+ commentCount: number
+ voteCount: number
+ segments: UserSegmentSummary[]
+}
+
+async function fetchSegmentsForPrincipals(
+ principalIds: PrincipalId[]
+): Promise> {
+ if (principalIds.length === 0) return new Map()
+
+ const rows = await db
+ .select({
+ principalId: userSegments.principalId,
+ segmentId: segments.id,
+ segmentName: segments.name,
+ segmentColor: segments.color,
+ segmentType: segments.type,
+ })
+ .from(userSegments)
+ .innerJoin(segments, eq(userSegments.segmentId, segments.id))
+ .where(and(inArray(userSegments.principalId, principalIds), isNull(segments.deletedAt)))
+ .orderBy(asc(segments.name))
+
+ const map = new Map()
+ for (const row of rows) {
+ const list = map.get(row.principalId) ?? []
+ list.push({
+ id: row.segmentId as SegmentId,
+ name: row.segmentName,
+ color: row.segmentColor,
+ type: row.segmentType as 'manual' | 'dynamic',
+ })
+ map.set(row.principalId, list)
+ }
+ return map
+}
+
+async function fetchPortalSummariesByUserIds(
+ userIds: UserId[]
+): Promise> {
+ if (userIds.length === 0) return new Map()
+
+ const postCounts = db
+ .select({
+ principalId: posts.principalId,
+ postCount: sql`count(*)::int`.as('post_count'),
+ })
+ .from(posts)
+ .where(isNull(posts.deletedAt))
+ .groupBy(posts.principalId)
+ .as('post_counts')
+
+ const commentCounts = db
+ .select({
+ principalId: comments.principalId,
+ commentCount: sql`count(*)::int`.as('comment_count'),
+ })
+ .from(comments)
+ .where(isNull(comments.deletedAt))
+ .groupBy(comments.principalId)
+ .as('comment_counts')
+
+ const voteCounts = db
+ .select({
+ principalId: votes.principalId,
+ voteCount: sql`count(*)::int`.as('vote_count'),
+ })
+ .from(votes)
+ .groupBy(votes.principalId)
+ .as('vote_counts')
+
+ const rows = await db
+ .select({
+ principalId: principal.id,
+ userId: user.id,
+ name: user.name,
+ email: user.email,
+ image: user.image,
+ emailVerified: user.emailVerified,
+ joinedAt: principal.createdAt,
+ postCount: sql`COALESCE(${postCounts.postCount}, 0)`,
+ commentCount: sql`COALESCE(${commentCounts.commentCount}, 0)`,
+ voteCount: sql`COALESCE(${voteCounts.voteCount}, 0)`,
+ })
+ .from(principal)
+ .innerJoin(user, eq(principal.userId, user.id))
+ .leftJoin(postCounts, eq(postCounts.principalId, principal.id))
+ .leftJoin(commentCounts, eq(commentCounts.principalId, principal.id))
+ .leftJoin(voteCounts, eq(voteCounts.principalId, principal.id))
+ .where(
+ and(
+ eq(principal.role, 'user'),
+ eq(principal.type, 'user'),
+ inArray(user.id, userIds as UserId[])
+ )
+ )
+
+ const segmentMap = await fetchSegmentsForPrincipals(
+ rows.map((row) => row.principalId as PrincipalId)
+ )
+ return new Map(
+ rows.map((row) => [
+ row.userId,
+ {
+ principalId: row.principalId as PrincipalId,
+ userId: row.userId as UserId,
+ name: row.name,
+ email: realEmail(row.email),
+ image: row.image,
+ emailVerified: row.emailVerified,
+ joinedAt: row.joinedAt,
+ postCount: Number(row.postCount),
+ commentCount: Number(row.commentCount),
+ voteCount: Number(row.voteCount),
+ segments: segmentMap.get(row.principalId) ?? [],
+ },
+ ])
+ )
+}
+
+async function fetchPortalMatches(params: CustomerPersonListParams): Promise {
+ const conditions: SQL[] = [eq(principal.role, 'user'), eq(principal.type, 'user')]
+ if (params.search?.trim()) {
+ const q = `%${params.search.trim()}%`
+ conditions.push(or(sql`${user.name} ILIKE ${q}`, sql`${user.email} ILIKE ${q}`)!)
+ }
+ if (params.segmentIds?.length) {
+ conditions.push(
+ inArray(
+ principal.id,
+ db
+ .select({ principalId: userSegments.principalId })
+ .from(userSegments)
+ .where(inArray(userSegments.segmentId, params.segmentIds))
+ )
+ )
+ }
+
+ const rows = await db
+ .select({ userId: user.id })
+ .from(principal)
+ .innerJoin(user, eq(principal.userId, user.id))
+ .where(and(...conditions))
+ .orderBy(desc(principal.createdAt))
+ .limit(Math.min(params.limit ?? 100, 200))
+ .offset(Math.max(params.offset ?? 0, 0))
+
+ const summaries = await fetchPortalSummariesByUserIds(rows.map((row) => row.userId as UserId))
+ return rows.map((row) => summaries.get(row.userId)).filter((row): row is PortalSummary => !!row)
+}
+
+async function fetchContactsByIds(contactIds: ContactId[]) {
+ if (contactIds.length === 0) return []
+ return db.select().from(contacts).where(inArray(contacts.id, contactIds))
+}
+
+async function fetchOrganizationNames(
+ organizationIds: OrganizationId[]
+): Promise> {
+ if (organizationIds.length === 0) return new Map()
+ const rows = await db
+ .select({ id: organizations.id, name: organizations.name })
+ .from(organizations)
+ .where(inArray(organizations.id, organizationIds))
+ return new Map(rows.map((row) => [row.id, row.name]))
+}
+
+async function fetchTicketCounts(input: {
+ contactIds: ContactId[]
+ principalIds: PrincipalId[]
+}): Promise<{ byContact: Map; byPrincipal: Map }> {
+ const byContact = new Map()
+ const byPrincipal = new Map()
+
+ if (input.contactIds.length > 0) {
+ const rows = await db
+ .select({
+ contactId: tickets.requesterContactId,
+ count: sql`count(distinct ${tickets.id})::int`,
+ })
+ .from(tickets)
+ .where(and(inArray(tickets.requesterContactId, input.contactIds), isNull(tickets.deletedAt)))
+ .groupBy(tickets.requesterContactId)
+ for (const row of rows) {
+ if (row.contactId) byContact.set(row.contactId, Number(row.count))
+ }
+ }
+
+ if (input.principalIds.length > 0) {
+ const principalTicketConditions: SQL[] = [
+ inArray(tickets.requesterPrincipalId, input.principalIds),
+ isNull(tickets.deletedAt),
+ ]
+ if (input.contactIds.length > 0) {
+ principalTicketConditions.push(isNull(tickets.requesterContactId))
+ }
+ const rows = await db
+ .select({
+ principalId: tickets.requesterPrincipalId,
+ count: sql`count(distinct ${tickets.id})::int`,
+ })
+ .from(tickets)
+ .where(and(...principalTicketConditions))
+ .groupBy(tickets.requesterPrincipalId)
+ for (const row of rows) {
+ if (row.principalId) byPrincipal.set(row.principalId, Number(row.count))
+ }
+ }
+
+ return { byContact, byPrincipal }
+}
+
+function uniqueSegments(users: PortalSummary[]): UserSegmentSummary[] {
+ const map = new Map()
+ for (const portalUser of users) {
+ for (const segment of portalUser.segments) map.set(segment.id, segment)
+ }
+ return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name))
+}
+
+function matchesSegmentFilter(users: PortalSummary[], segmentIds?: SegmentId[]): boolean {
+ if (!segmentIds?.length) return true
+ const wanted = new Set(segmentIds)
+ return users.some((portalUser) =>
+ portalUser.segments.some((segment) => wanted.has(segment.id as SegmentId))
+ )
+}
+
+export async function listCustomerPeople(
+ params: CustomerPersonListParams = {}
+): Promise {
+ const limit = Math.min(params.limit ?? 100, 200)
+ const offset = Math.max(params.offset ?? 0, 0)
+
+ if (params.includeCrm === false) {
+ const portalMatches = await fetchPortalMatches({ ...params, limit, offset })
+ const principalIds = portalMatches.map((portalUser) => portalUser.principalId as PrincipalId)
+ const ticketCounts =
+ params.includeTicketCounts === false
+ ? { byContact: new Map(), byPrincipal: new Map() }
+ : await fetchTicketCounts({ contactIds: [], principalIds })
+ const items = portalMatches
+ .map((row) => ({
+ id: `user:${row.principalId}`,
+ kind: 'portal_user' as const,
+ contactId: null,
+ principalIds: [row.principalId],
+ userIds: [row.userId],
+ name: row.name,
+ email: row.email,
+ avatarUrl: row.image,
+ organizationId: null,
+ organizationName: null,
+ title: null,
+ phone: null,
+ externalId: null,
+ archivedAt: null,
+ hasPortalUser: true,
+ emailVerified: row.emailVerified,
+ linkedUsers: [row],
+ segments: row.segments,
+ postCount: row.postCount,
+ commentCount: row.commentCount,
+ voteCount: row.voteCount,
+ ticketCount: ticketCounts.byPrincipal.get(row.principalId) ?? 0,
+ }))
+ .sort((a, b) => (a.name ?? a.email ?? a.id).localeCompare(b.name ?? b.email ?? b.id))
+
+ return {
+ items,
+ total: items.length,
+ hasMore: false,
+ }
+ }
+
+ const [contactMatches, portalMatches] = await Promise.all([
+ searchContacts({
+ query: params.search,
+ includeArchived: params.includeArchived,
+ limit,
+ offset,
+ }),
+ fetchPortalMatches({ ...params, limit, offset }),
+ ])
+
+ const portalUserIds = portalMatches.map((row) => row.userId)
+ const linksForPortalMatches =
+ portalUserIds.length > 0
+ ? await db
+ .select({ contactId: contactUserLinks.contactId, userId: contactUserLinks.userId })
+ .from(contactUserLinks)
+ .where(inArray(contactUserLinks.userId, portalUserIds))
+ : []
+
+ const contactIdSet = new Set