From eac1fbe3a6dbce184ba65175c692a40f2546282a Mon Sep 17 00:00:00 2001 From: Cihan Alagoz Date: Mon, 17 Aug 2026 11:04:10 +0300 Subject: [PATCH] fix(support): anchor support identity to the tenant id and send diagnostics Console sent its tenant slug as the CRM organization id. The CRM derives the support organization key from that value, and ticket visibility is scoped to the organization, so renaming a slug would have created a second organization and hidden every earlier ticket from the customer. The key is also not namespaced per issuer, so a human-readable slug risked colliding with another product's organization id. A Console tenant is both the customer and the workspace, so it now sends a null organization id and lets the CRM derive an issuer-scoped key from the tenant id. That also fixes the organization name never following a company rename, which the CRM skips whenever a product supplies its own organization id. Studio and Pulse both send a person's name; Console sent the email address, so support staff and every notification greeted an address. It now reads the real name from the tenant database and falls back to the email if that lookup fails, which must never block a handoff. The Help entry point used reachability rather than configurability, so on SaaS with only SUPPORT_BASE_URL set the button appeared and every click ended in a 503. It now uses the same predicate Studio and Pulse already used. Diagnostics never left the browser: openSupport sent only a locale, so the diagnostic-draft path on the server was unreachable. The dashboard error boundary can now report to Support with the error attached, and /support/status, which nothing called, is what tells it whether to offer that. docker-compose passes the support variables through. Console ships as a self-hosted image rather than a chart, and there was no way to enable Support from that path. --- .env.example | 7 +- docker-compose.yml | 8 + src/__tests__/api/support.test.ts | 175 +++++++++++++++++++++ src/__tests__/unit/support-handoff.test.ts | 168 ++++++++++++++++++++ src/app/dashboard/error.tsx | 62 +++++++- src/app/dashboard/layout.tsx | 4 +- src/lib/services/support/supportHandoff.ts | 43 ++++- src/lib/support/openSupport.ts | 44 +++++- src/server/api/plugins/support.ts | 4 +- 9 files changed, 498 insertions(+), 17 deletions(-) create mode 100644 src/__tests__/api/support.test.ts create mode 100644 src/__tests__/unit/support-handoff.test.ts diff --git a/.env.example b/.env.example index 1b692c77..62462cdb 100644 --- a/.env.example +++ b/.env.example @@ -44,11 +44,12 @@ PROVIDER_ENCRYPTION_SECRET= # (required) Independent encr # DEPLOYMENT_MODE= # ---- Cognipeer Support --------------------------------------- -# Help & Support entry points stay hidden until SUPPORT_BASE_URL is set. +# On SaaS the Help & Support entry points appear only when all three values are +# set, so a click can never end in a 503. # SUPPORT_HANDOFF_SECRET must be at least 32 characters and must equal CRM's # SUPPORT_HANDOFF_SECRET_CONSOLE. It is server-only — never use NEXT_PUBLIC_. -# On-prem: when SUPPORT_BASE_URL is set but the CRM values are not, Console -# sends the user to the Support login page instead of failing. +# On-prem: SUPPORT_BASE_URL alone is enough; Console then sends the user to the +# Support login page instead of failing. # SUPPORT_BASE_URL=https://support.cognipeer.com # SUPPORT_CRM_API_URL= # SUPPORT_HANDOFF_SECRET= diff --git a/docker-compose.yml b/docker-compose.yml index 939cc6e8..47addca3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,14 @@ services: - JWT_SECRET=${JWT_SECRET:?Set JWT_SECRET in .env.local or environment} - CACHE_PROVIDER=memory - RATE_LIMIT_PROVIDER=memory + # Self-hosted installs are on-prem: with SUPPORT_BASE_URL alone the Help + # action lands on the Support login instead of failing. + - DEPLOYMENT_MODE=${DEPLOYMENT_MODE:-onprem} + # Cognipeer Support. Leave empty to hide every Support entry point. + # SUPPORT_HANDOFF_SECRET must equal CRM's SUPPORT_HANDOFF_SECRET_CONSOLE. + - SUPPORT_BASE_URL=${SUPPORT_BASE_URL:-} + - SUPPORT_CRM_API_URL=${SUPPORT_CRM_API_URL:-} + - SUPPORT_HANDOFF_SECRET=${SUPPORT_HANDOFF_SECRET:-} volumes: - app-data:/app/data restart: unless-stopped diff --git a/src/__tests__/api/support.test.ts b/src/__tests__/api/support.test.ts new file mode 100644 index 00000000..d6b03ffe --- /dev/null +++ b/src/__tests__/api/support.test.ts @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/services/support/supportHandoff', () => ({ + createSupportHandoff: vi.fn(), + isSupportEntryPointEnabled: vi.fn(), +})); + +import { + createSupportHandoff, + isSupportEntryPointEnabled, +} from '@/lib/services/support/supportHandoff'; +import { supportApiPlugin } from '@/server/api/plugins/support'; +import { createFastifyApiTestApp, parseJsonBody } from '../helpers/fastify-api'; + +const SESSION_HEADERS = { + 'x-tenant-db-name': 'tenant_acme', + 'x-tenant-id': 'tenant-1', + 'x-tenant-slug': 'acme', + 'x-user-email': 'ada@example.com', + 'x-user-id': 'user-1', + 'x-user-role': 'owner', + 'x-license-type': 'FREE', +}; + +const mockedHandoff = vi.mocked(createSupportHandoff); +const mockedEnabled = vi.mocked(isSupportEntryPointEnabled); + +async function app() { + return createFastifyApiTestApp(supportApiPlugin); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockedEnabled.mockReturnValue(true); + mockedHandoff.mockResolvedValue({ ok: true, url: 'https://support.example.com/api/auth/handoff?code=shc_x' }); +}); + +describe('GET /api/support/status', () => { + it('reports whether an entry point should be shown', async () => { + const response = await (await app()).inject({ + method: 'GET', + url: '/api/support/status', + headers: SESSION_HEADERS, + }); + + expect(response.statusCode).toBe(200); + expect(parseJsonBody<{ enabled: boolean }>(response.body).enabled).toBe(true); + }); + + it('rejects an unauthenticated caller', async () => { + const response = await (await app()).inject({ method: 'GET', url: '/api/support/status' }); + expect(response.statusCode).toBe(401); + }); +}); + +describe('POST /api/support/handoff', () => { + it('passes the session identity to the handoff service', async () => { + await (await app()).inject({ + method: 'POST', + url: '/api/support/handoff', + headers: SESSION_HEADERS, + payload: { locale: 'en' }, + }); + + expect(mockedHandoff).toHaveBeenCalledWith( + expect.objectContaining({ + tenantId: 'tenant-1', + userId: 'user-1', + userEmail: 'ada@example.com', + locale: 'en', + }), + ); + }); + + it('forwards diagnostics collected by an error surface', async () => { + await (await app()).inject({ + method: 'POST', + url: '/api/support/handoff', + headers: SESSION_HEADERS, + payload: { + locale: 'tr', + summary: 'boom', + diagnostics: { category: 'dashboard_error', page: '/dashboard' }, + }, + }); + + expect(mockedHandoff).toHaveBeenCalledWith( + expect.objectContaining({ + summary: 'boom', + diagnostics: { category: 'dashboard_error', page: '/dashboard' }, + }), + ); + }); + + it('defaults to Turkish when no locale is sent', async () => { + await (await app()).inject({ + method: 'POST', + url: '/api/support/handoff', + headers: SESSION_HEADERS, + payload: {}, + }); + + expect(mockedHandoff).toHaveBeenCalledWith(expect.objectContaining({ locale: 'tr' })); + }); + + it('rejects an unknown locale instead of guessing', async () => { + const response = await (await app()).inject({ + method: 'POST', + url: '/api/support/handoff', + headers: SESSION_HEADERS, + payload: { locale: 'de' }, + }); + + expect(response.statusCode).toBe(400); + expect(mockedHandoff).not.toHaveBeenCalled(); + }); + + it('rejects diagnostics that are not an object', async () => { + const response = await (await app()).inject({ + method: 'POST', + url: '/api/support/handoff', + headers: SESSION_HEADERS, + payload: { diagnostics: ['not', 'an', 'object'] }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('rejects an unauthenticated caller', async () => { + const response = await (await app()).inject({ + method: 'POST', + url: '/api/support/handoff', + payload: { locale: 'tr' }, + }); + + expect(response.statusCode).toBe(401); + expect(mockedHandoff).not.toHaveBeenCalled(); + }); + + it('surfaces the service status and keeps the response uncacheable', async () => { + mockedHandoff.mockResolvedValue({ + ok: false, + status: 503, + message: 'Support integration is not configured.', + }); + + const response = await (await app()).inject({ + method: 'POST', + url: '/api/support/handoff', + headers: SESSION_HEADERS, + payload: { locale: 'tr' }, + }); + + expect(response.statusCode).toBe(503); + }); + + it('marks a login fallback so the caller can explain the missing diagnostics', async () => { + mockedHandoff.mockResolvedValue({ + ok: true, + url: 'https://support.example.com/tr/login?diagnostics=unavailable', + diagnosticsUnavailable: true, + }); + + const response = await (await app()).inject({ + method: 'POST', + url: '/api/support/handoff', + headers: SESSION_HEADERS, + payload: { locale: 'tr' }, + }); + + expect(response.headers['cache-control']).toBe('no-store'); + expect(parseJsonBody<{ diagnosticsUnavailable?: boolean }>(response.body).diagnosticsUnavailable) + .toBe(true); + }); +}); diff --git a/src/__tests__/unit/support-handoff.test.ts b/src/__tests__/unit/support-handoff.test.ts new file mode 100644 index 00000000..f6e95486 --- /dev/null +++ b/src/__tests__/unit/support-handoff.test.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const db = { + findTenantById: vi.fn(), + switchToTenant: vi.fn().mockResolvedValue(undefined), + assertTenantContext: vi.fn(), + findUserById: vi.fn(), +}; + +vi.mock('@/lib/database', () => ({ + getDatabase: vi.fn(async () => db), +})); + +const config = { + support: { + baseUrl: 'https://support.example.com', + crmApiUrl: 'https://crm.example.com', + handoffSecret: 'a'.repeat(32), + }, + deployment: { mode: 'saas' as 'saas' | 'onprem', isOnPrem: false }, +}; + +vi.mock('@/lib/core/config', () => ({ + getConfig: () => config, +})); + +import { + createSupportHandoff, + isSupportEntryPointEnabled, +} from '@/lib/services/support/supportHandoff'; + +const input = { + tenantId: '65f1c0ffee0000000000abcd', + userId: 'user-1', + userEmail: ' Ada@Example.COM ', + locale: 'tr' as const, +}; + +function crmResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as Response; +} + +beforeEach(() => { + vi.clearAllMocks(); + config.support = { + baseUrl: 'https://support.example.com', + crmApiUrl: 'https://crm.example.com', + handoffSecret: 'a'.repeat(32), + }; + config.deployment = { mode: 'saas', isOnPrem: false }; + db.findTenantById.mockResolvedValue({ + _id: input.tenantId, + companyName: 'Acme Inc', + slug: 'acme', + dbName: 'tenant_acme', + }); + db.findUserById.mockResolvedValue({ name: 'Ada Lovelace', email: 'ada@example.com' }); +}); + +describe('createSupportHandoff', () => { + it('anchors support identity to the tenant id, not the renameable slug', async () => { + const fetchMock = vi.fn().mockResolvedValue(crmResponse({ code: 'shc_x' })); + vi.stubGlobal('fetch', fetchMock); + + const result = await createSupportHandoff(input); + + expect(result.ok).toBe(true); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.issuer).toBe('console'); + expect(body.externalInstallationId).toBe(input.tenantId); + // A null organizationId makes the CRM derive an issuer-scoped key, so a + // slug rename can never orphan the customer's ticket history. + expect(body.context.organizationId).toBeNull(); + expect(body.context.workspaceName).toBe('Acme Inc'); + }); + + it('sends a display name so support does not address an email address', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(crmResponse({ code: 'shc_x' }))); + + await createSupportHandoff(input); + + const body = JSON.parse((globalThis.fetch as ReturnType).mock.calls[0][1].body); + expect(body.context.userName).toBe('Ada Lovelace'); + expect(body.email).toBe('ada@example.com'); + }); + + it('falls back to the email when the user record cannot be read', async () => { + db.findUserById.mockRejectedValue(new Error('tenant db unavailable')); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(crmResponse({ code: 'shc_x' }))); + + const result = await createSupportHandoff(input); + + expect(result.ok).toBe(true); + const body = JSON.parse((globalThis.fetch as ReturnType).mock.calls[0][1].body); + expect(body.context.userName).toBe('ada@example.com'); + }); + + it('returns the Support callback URL carrying the single-use code', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(crmResponse({ code: 'shc_abc' }))); + + const result = await createSupportHandoff(input); + + expect(result).toMatchObject({ + ok: true, + url: 'https://support.example.com/api/auth/handoff?code=shc_abc&locale=tr', + }); + }); + + it('creates a diagnostic draft first and links it to the handoff', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(crmResponse({ id: 'draft-1' })) + .mockResolvedValueOnce(crmResponse({ code: 'shc_abc' })); + vi.stubGlobal('fetch', fetchMock); + + await createSupportHandoff({ ...input, summary: 'boom', diagnostics: { category: 'x' } }); + + expect(fetchMock.mock.calls[0][0]).toContain('/api/internal-support/v1/diagnostic-drafts'); + const handoffBody = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(handoffBody.diagnosticDraftId).toBe('draft-1'); + }); + + it('never leaks the CRM reason to the caller', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(crmResponse({ error: 'secret internal reason' }, 403)), + ); + + const result = await createSupportHandoff(input); + + expect(result).toEqual({ ok: false, status: 403, message: 'Support access denied.' }); + }); + + it('sends an on-prem install to the Support login when CRM is unreachable', async () => { + config.deployment = { mode: 'onprem', isOnPrem: true }; + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))); + + const result = await createSupportHandoff(input); + + expect(result).toMatchObject({ + ok: true, + url: 'https://support.example.com/tr/login?diagnostics=unavailable', + diagnosticsUnavailable: true, + }); + }); +}); + +describe('isSupportEntryPointEnabled', () => { + it('hides the entry point when no handoff can be issued on SaaS', () => { + config.support = { baseUrl: 'https://support.example.com', crmApiUrl: '', handoffSecret: '' }; + expect(isSupportEntryPointEnabled()).toBe(false); + }); + + it('keeps it on-prem, where the login fallback still helps', () => { + config.support = { baseUrl: 'https://support.example.com', crmApiUrl: '', handoffSecret: '' }; + config.deployment = { mode: 'onprem', isOnPrem: true }; + expect(isSupportEntryPointEnabled()).toBe(true); + }); + + it('stays hidden without a Support URL', () => { + config.support = { baseUrl: '', crmApiUrl: 'https://crm', handoffSecret: 'a'.repeat(32) }; + config.deployment = { mode: 'onprem', isOnPrem: true }; + expect(isSupportEntryPointEnabled()).toBe(false); + }); +}); diff --git a/src/app/dashboard/error.tsx b/src/app/dashboard/error.tsx index 1b32aecb..8871fc24 100644 --- a/src/app/dashboard/error.tsx +++ b/src/app/dashboard/error.tsx @@ -1,9 +1,47 @@ 'use client'; -import { Button, Center, Stack, Text, ThemeIcon } from '@mantine/core'; -import { IconAlertTriangle, IconRefresh } from '@tabler/icons-react'; +import { useEffect, useState } from 'react'; +import { Button, Center, Group, Stack, Text, ThemeIcon } from '@mantine/core'; +import { IconAlertTriangle, IconLifebuoy, IconRefresh } from '@tabler/icons-react'; +import { isSupportAvailable, openSupport } from '@/lib/support/openSupport'; + +type DashboardErrorProps = { + error: Error & { digest?: string }; + reset: () => void; +}; + +export default function DashboardError({ error, reset }: DashboardErrorProps) { + const [supportEnabled, setSupportEnabled] = useState(false); + const [reporting, setReporting] = useState(false); + + useEffect(() => { + let active = true; + void isSupportAvailable().then((enabled) => { + if (active) setSupportEnabled(enabled); + }); + return () => { + active = false; + }; + }, []); + + const reportToSupport = async () => { + setReporting(true); + try { + await openSupport(document.documentElement.lang === 'en' ? 'en' : 'tr', { + summary: error.message || 'Console dashboard error', + category: 'dashboard_error', + error: { + name: error.name, + message: error.message, + stack: error.stack, + digest: error.digest, + }, + }); + } finally { + setReporting(false); + } + }; -export default function DashboardError({ reset }: { reset: () => void }) { return (
@@ -16,9 +54,21 @@ export default function DashboardError({ reset }: { reset: () => void }) { Retry the request. If it fails again, the route-level API response should be checked. - + + + {supportEnabled ? ( + + ) : null} +
); diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index e837610e..3b3459cf 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -4,7 +4,7 @@ import { redirect } from 'next/navigation'; import DashboardLayout from '@/components/layout/DashboardLayout'; import { getDatabase } from '@/lib/database'; import { normalizeServicePermissions } from '@/lib/security/rbac'; -import { isSupportReachable } from '@/lib/services/support/supportHandoff'; +import { isSupportEntryPointEnabled } from '@/lib/services/support/supportHandoff'; interface DashboardRouteLayoutProps { children: ReactNode; @@ -54,7 +54,7 @@ export default async function DashboardRouteLayout({ children }: DashboardRouteL return ( { + try { + const db = await getDatabase(); + await db.switchToTenant(tenantDbName); + db.assertTenantContext?.(tenantDbName); + const user = await db.findUserById(input.userId); + return user?.name?.trim() || email; + } catch (error) { + logger.warn('Support handoff could not resolve the user name', { + error: error instanceof Error ? error.message : String(error), + }); + return email; + } +} + export async function createSupportHandoff( input: SupportHandoffInput, ): Promise { @@ -114,6 +148,8 @@ export async function createSupportHandoff( return { ok: false, status: 403, message: 'Support access denied.' }; } + const userName = await resolveUserName(input, tenant.dbName, email); + try { let diagnosticDraftId: string | undefined; if (input.diagnostics) { @@ -134,9 +170,12 @@ export async function createSupportHandoff( diagnosticDraftId, context: { workspaceName: tenant.companyName, - organizationId: tenant.slug || null, + // A Console tenant is both the customer and the workspace, so the CRM + // derives an issuer-scoped organization key from the tenant id. Sending + // the slug here would anchor support history to a renameable value. + organizationId: null, userId: input.userId, - userName: email, + userName, }, }); diff --git a/src/lib/support/openSupport.ts b/src/lib/support/openSupport.ts index 1e0623a8..1c1c029d 100644 --- a/src/lib/support/openSupport.ts +++ b/src/lib/support/openSupport.ts @@ -4,11 +4,33 @@ import { apiRequest } from '@/lib/api/client'; type HandoffResponse = { url: string; diagnosticsUnavailable?: boolean }; +export type SupportDiagnostic = { + summary: string; + category?: string; + error?: { name?: string; message?: string; stack?: string; digest?: string }; + requestId?: string; +}; + +/** Everything the CRM needs to reproduce the problem; it redacts on its side. */ +function buildDiagnostics(diagnostic: SupportDiagnostic) { + return { + category: diagnostic.category ?? 'application_error', + error: diagnostic.error, + requestId: diagnostic.requestId, + page: window.location.pathname, + environment: process.env.NEXT_PUBLIC_ENV || process.env.NODE_ENV, + occurredAt: new Date().toISOString(), + }; +} + /** * Opens Support in a new tab. The tab is opened before the request so the * browser attributes it to the click and does not block it as a popup. */ -export async function openSupport(locale: 'tr' | 'en' = 'tr'): Promise { +export async function openSupport( + locale: 'tr' | 'en' = 'tr', + diagnostic?: SupportDiagnostic, +): Promise { const supportWindow = window.open('about:blank', '_blank'); if (!supportWindow) return false; supportWindow.opener = null; @@ -16,7 +38,15 @@ export async function openSupport(locale: 'tr' | 'en' = 'tr'): Promise try { const response = await apiRequest('/api/support/handoff', { method: 'POST', - body: JSON.stringify({ locale }), + body: JSON.stringify({ + locale, + ...(diagnostic + ? { + summary: diagnostic.summary.slice(0, 500), + diagnostics: buildDiagnostics(diagnostic), + } + : {}), + }), }); supportWindow.location.replace(response.url); return true; @@ -26,3 +56,13 @@ export async function openSupport(locale: 'tr' | 'en' = 'tr'): Promise return false; } } + +/** Lets a client surface hide its Support action when no handoff can be issued. */ +export async function isSupportAvailable(): Promise { + try { + const response = await apiRequest<{ enabled: boolean }>('/api/support/status'); + return response.enabled === true; + } catch { + return false; + } +} diff --git a/src/server/api/plugins/support.ts b/src/server/api/plugins/support.ts index 90d0fed4..7891af55 100644 --- a/src/server/api/plugins/support.ts +++ b/src/server/api/plugins/support.ts @@ -2,7 +2,7 @@ import type { FastifyPluginAsync } from 'fastify'; import { createLogger } from '@/lib/core/logger'; import { createSupportHandoff, - isSupportConfigured, + isSupportEntryPointEnabled, type SupportLocale, } from '@/lib/services/support/supportHandoff'; import { readJsonBody, requireSessionContext, withApiRequestContext } from '../fastify-utils'; @@ -23,7 +23,7 @@ export const supportApiPlugin: FastifyPluginAsync = async (app) => { app.get('/support/status', withApiRequestContext(async (request, reply) => { try { requireSessionContext(request); - return reply.code(200).send({ enabled: isSupportConfigured() }); + return reply.code(200).send({ enabled: isSupportEntryPointEnabled() }); } catch { return reply.code(401).send({ error: 'Unauthorized' }); }