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 + }) => ( + + ), +})) + +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 }) => ( + + ), +})) + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ + children, + }: { + children: ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void + }) =>
{children}
, + DialogContent: ({ children }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), + DialogDescription: ({ children }: { children: ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + 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 + }) => ( + + ), +})) + +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 + }) => ( + + ), +})) + +vi.mock('@/components/ui/avatar', () => ({ + Avatar: ({ children }: { children: ReactNode; className?: string }) => {children}, +})) + +vi.mock('@/components/ui/table', () => ({ + Table: ({ children }: { children: ReactNode }) => {children}
, + 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 }) => ( + + ), + AlertDialogCancel: ({ children }: { children: ReactNode }) => ( + + ), + AlertDialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogDescription: ({ children }: { children: ReactNode }) =>

{children}

, + AlertDialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + 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 + }) => ( + + ), +})) + +vi.mock('@heroicons/react/24/outline', () => ({ + TrashIcon: () => , +})) + +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 + }) => ( + + ), +})) + +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 }) => ( + + ), +})) + +vi.mock('@/components/ui/alert-dialog', () => ({ + AlertDialog: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogAction: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => ( + + ), + AlertDialogCancel: ({ children }: { children: ReactNode }) => ( + + ), + AlertDialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogDescription: ({ children }: { children: ReactNode }) =>

{children}

, + AlertDialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + 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 + }) => ( + + ), +})) + +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 + }) => ( + + ), +})) + +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 + }) =>