From 55d5bf55e2f04297330f15a24e931002282e9594 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 06:30:55 +0000 Subject: [PATCH 1/9] fix(customer-operations): unified service-status model + E2E coverage Audit found API status was computed independently by the header, the dashboard, and every page (each calling its own useCocApi), so the header could show 'API Healthy' while a page showed Degraded/Unavailable, and direct (no-token) access produced inconsistent per-page error states. Root-cause fixes (frontend only): - coc-api.ts: extract one cocFetch() as the single request + status mapper (host/route/status/bearer never returned to the UI). - coc-status.tsx (new): CocServiceStatusProvider does ONE shared health probe and is token-aware. Exposes a unified status: healthy | degraded | unavailable | locked (locked = no SSO token / direct access). CocServiceBanner renders the one safe state used by every page. - CocHeader + useOpsData read the unified status; all pages render CocServiceBanner instead of per-fetch error banners. Header and content can no longer disagree; direct access shows a safe 'Sign-in required' locked state. Tests: - e2e/coc-e2e.spec.ts (Playwright): canonical lock, SSO token capture+scrub, Bearer attachment, locked direct-access, unified status (healthy/degraded), SPA-nav consistency, all 19 routes render with no raw URL/status exposure, and no horizontal overflow at mobile/tablet/desktop/4K. 10/10 pass. - domain-canonical.test.ts (vitest): canonical/OG/robots/manifest/redirect config locked to the custom domain (runs in CI). No backend/API/database/auth changes. No UI redesign. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MiPyhqoV9Pt7XN2tiFpS4A --- apps/customer-operations/.gitignore | 4 + apps/customer-operations/e2e/coc-e2e.spec.ts | 174 ++++++++++++++++++ apps/customer-operations/package.json | 4 +- apps/customer-operations/playwright.config.ts | 32 ++++ .../src/components/CocHeader.tsx | 17 +- .../src/layout/CocShell.tsx | 21 ++- .../lib/__tests__/domain-canonical.test.ts | 41 +++++ apps/customer-operations/src/lib/coc-api.ts | 129 +++++++------ .../src/lib/coc-status.tsx | 138 ++++++++++++++ .../src/lib/ops-metrics.ts | 28 +-- .../src/pages/AnalyticsPage.tsx | 7 +- .../src/pages/BillingOperationsPage.tsx | 5 +- .../src/pages/CustomerHealthPage.tsx | 5 +- .../src/pages/CustomerIntelligencePage.tsx | 7 +- .../src/pages/CustomerSuccessPage.tsx | 5 +- .../src/pages/HumanEscalationsPage.tsx | 5 +- .../src/pages/LiveConversationsPage.tsx | 5 +- .../src/pages/OmnichannelPage.tsx | 5 +- .../src/pages/OnboardingPage.tsx | 5 +- .../src/pages/OperationsDashboardPage.tsx | 4 +- .../src/pages/RenewalsPage.tsx | 7 +- .../src/pages/SlaMonitoringPage.tsx | 5 +- .../src/pages/SupportInboxPage.tsx | 5 +- .../src/pages/TicketCenterPage.tsx | 5 +- apps/customer-operations/vitest.config.ts | 13 ++ package-lock.json | 64 +++++++ 26 files changed, 614 insertions(+), 126 deletions(-) create mode 100644 apps/customer-operations/.gitignore create mode 100644 apps/customer-operations/e2e/coc-e2e.spec.ts create mode 100644 apps/customer-operations/playwright.config.ts create mode 100644 apps/customer-operations/src/lib/__tests__/domain-canonical.test.ts create mode 100644 apps/customer-operations/src/lib/coc-status.tsx create mode 100644 apps/customer-operations/vitest.config.ts diff --git a/apps/customer-operations/.gitignore b/apps/customer-operations/.gitignore new file mode 100644 index 00000000..6d250704 --- /dev/null +++ b/apps/customer-operations/.gitignore @@ -0,0 +1,4 @@ +# Playwright E2E artifacts +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/apps/customer-operations/e2e/coc-e2e.spec.ts b/apps/customer-operations/e2e/coc-e2e.spec.ts new file mode 100644 index 00000000..e86b739a --- /dev/null +++ b/apps/customer-operations/e2e/coc-e2e.spec.ts @@ -0,0 +1,174 @@ +import { test, expect, type Page, type Route } from '@playwright/test' + +/** + * Customer Operations Center — production-behavior E2E. + * + * Runs against the built bundle (vite preview). The platform API is mocked so + * we can deterministically drive healthy / degraded / unavailable / locked and + * assert that status is unified across header + content, the SSO bearer flow + * works, no raw infrastructure leaks, every route renders, and the layout has + * no horizontal overflow at any breakpoint. + */ + +// JWT-shaped token with a payload that has no `exp` (treated as non-expired). +const PAYLOAD = Buffer.from(JSON.stringify({ sub: 'admin', role: 'PLATFORM_ADMIN' })).toString('base64url') +const TOKEN = `eyJhbGciOiJIUzI1NiJ9.${PAYLOAD}.sig` + +const ROUTES: Array<{ path: string; label: string }> = [ + { path: '/', label: 'Operations Dashboard' }, + { path: '/ai-agents', label: 'AI Agents' }, + { path: '/inbox', label: 'Support Inbox' }, + { path: '/tickets', label: 'Ticket Center' }, + { path: '/conversations', label: 'Live Conversations' }, + { path: '/escalations', label: 'Human Escalations' }, + { path: '/customer-success', label: 'Customer Success' }, + { path: '/billing', label: 'Billing Operations' }, + { path: '/health', label: 'Customer Health' }, + { path: '/onboarding', label: 'Onboarding' }, + { path: '/renewals', label: 'Renewals' }, + { path: '/knowledge', label: 'Knowledge Base' }, + { path: '/sla', label: 'SLA Monitoring' }, + { path: '/analytics', label: 'Analytics' }, + { path: '/workflow', label: 'Workflow Studio' }, + { path: '/omnichannel', label: 'Omnichannel' }, + { path: '/intelligence', label: 'Customer Intelligence' }, + { path: '/governance', label: 'Governance' }, + { path: '/workforce', label: 'Workforce Management' }, +] + +const OVERVIEW = { + total: 248, + active: 230, + at_risk_count: 4, + critical_count: 1, + avg_health_score: 92, + renewing_soon: 6, + open_tickets: 128, + ai_resolution_rate: 0.94, +} + +type ApiMode = 'healthy' | 'degraded' | 'unavailable' + +/** Mock every platform API call; capture the Authorization header seen. */ +async function mockApi(page: Page, mode: ApiMode, seen: { auth?: string }) { + // Avoid external font fetches hanging in a sandboxed browser. + await page.route(/fonts\.(googleapis|gstatic)\.com/, (r: Route) => r.abort()) + await page.route('**/v1/**', (route: Route) => { + const auth = route.request().headers()['authorization'] + if (auth) seen.auth = auth + if (mode === 'unavailable') return route.abort('failed') + if (mode === 'degraded') return route.fulfill({ status: 503, contentType: 'application/json', body: '{}' }) + const url = route.request().url() + const body = url.includes('overview') ? OVERVIEW : { items: [] } + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }) + }) +} + +const FORBIDDEN = [ + 'api.zonforge.com', + '/v1/admin', + 'netlify.app', + 'Bearer', + 'HTTP 401', + 'HTTP 403', + 'HTTP 500', +] + +async function assertNoExposure(page: Page) { + const text = await page.evaluate(() => document.body.innerText) + for (const needle of FORBIDDEN) { + expect(text, `UI must not render "${needle}"`).not.toContain(needle) + } +} + +/** SSO sign-in: hit the launcher with a token, land on the dashboard. */ +async function ssoSignIn(page: Page) { + await page.goto(`/auth/sso?token=${TOKEN}`) + await page.waitForURL((u) => new URL(u).pathname === '/', { timeout: 15_000 }) +} + +test.describe('Customer Operations — production behavior', () => { + test('canonical metadata points only at the custom domain', async ({ page }) => { + await mockApi(page, 'healthy', {}) + await page.goto('/') + const canonical = await page.getAttribute('link[rel="canonical"]', 'href') + expect(canonical).toBe('https://customer-operations.zonforge.com/') + const ogUrl = await page.getAttribute('meta[property="og:url"]', 'content') + expect(ogUrl).toBe('https://customer-operations.zonforge.com/') + const html = await page.content() + expect(html).not.toContain('netlify.app') + await expect(page).toHaveTitle(/ZonForge Customer Operations/) + }) + + test('direct unauthenticated access shows a safe locked state (no raw errors)', async ({ page }) => { + const seen: { auth?: string } = {} + await mockApi(page, 'healthy', seen) + await page.goto('/') + await expect(page.getByText('Sign-in required')).toBeVisible() + await expect(page.getByText('API Unavailable')).toBeVisible() + // No probe should have been issued without a token. + expect(seen.auth).toBeUndefined() + await assertNoExposure(page) + }) + + test('SSO captures the token, scrubs the URL, attaches Bearer, shows API Healthy', async ({ page }) => { + const seen: { auth?: string } = {} + await mockApi(page, 'healthy', seen) + await ssoSignIn(page) + // URL scrubbed — no token left in the address bar. + expect(page.url()).not.toContain('token=') + await expect(page.getByText('API Healthy')).toBeVisible() + // Bearer header attached to the platform API call. + expect(seen.auth).toBe(`Bearer ${TOKEN}`) + // No service banner when healthy. + await expect(page.getByText('Sign-in required')).toHaveCount(0) + await expect(page.getByText('temporarily unavailable')).toHaveCount(0) + await assertNoExposure(page) + }) + + test('unified status: header and content agree when degraded', async ({ page }) => { + await mockApi(page, 'degraded', {}) + await ssoSignIn(page) + // Header badge degraded AND the page service banner present — they match. + await expect(page.getByText('API Degraded')).toBeVisible() + await expect(page.getByText('temporarily unavailable')).toBeVisible() + await assertNoExposure(page) + }) + + test('unified status persists across SPA navigation', async ({ page }) => { + await mockApi(page, 'healthy', {}) + await ssoSignIn(page) + for (const label of ['Ticket Center', 'Customer Intelligence']) { + await page.getByRole('link', { name: label }).first().click() + await expect(page.getByText('API Healthy')).toBeVisible() + await expect(page.getByText('temporarily unavailable')).toHaveCount(0) + await assertNoExposure(page) + } + }) + + test('every sidebar route renders safely on direct access (no crash, no leak)', async ({ page }) => { + await mockApi(page, 'degraded', {}) // direct access → locked regardless + for (const route of ROUTES) { + await page.goto(route.path) + await expect(page.locator('section.zf-cx-page')).toBeVisible() + await expect(page.locator('header.zf-cx-cmdhead')).toBeVisible() + await assertNoExposure(page) + } + }) + + for (const vp of [ + { name: 'mobile', width: 390, height: 844 }, + { name: 'tablet', width: 820, height: 1180 }, + { name: 'desktop', width: 1440, height: 900 }, + { name: '4k', width: 3840, height: 2160 }, + ]) { + test(`no horizontal overflow at ${vp.name} (${vp.width}px)`, async ({ page }) => { + await mockApi(page, 'healthy', {}) + await page.setViewportSize({ width: vp.width, height: vp.height }) + await ssoSignIn(page) + await expect(page.locator('header.zf-cx-cmdhead')).toBeVisible() + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + expect(overflow, 'no horizontal overflow').toBeLessThanOrEqual(1) + }) + } +}) diff --git a/apps/customer-operations/package.json b/apps/customer-operations/package.json index 5719f4bf..a03d0346 100644 --- a/apps/customer-operations/package.json +++ b/apps/customer-operations/package.json @@ -8,7 +8,8 @@ "build": "vite build", "preview": "vite preview", "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests" + "test": "vitest run --passWithNoTests", + "e2e": "playwright test" }, "dependencies": { "@sentry/react": "^8.0.0", @@ -19,6 +20,7 @@ "react-router-dom": "^6.30.4" }, "devDependencies": { + "@playwright/test": "^1.49.1", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.2.0", diff --git a/apps/customer-operations/playwright.config.ts b/apps/customer-operations/playwright.config.ts new file mode 100644 index 00000000..0510f9ef --- /dev/null +++ b/apps/customer-operations/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * E2E config for the Customer Operations Center. + * + * Runs against the production build served by `vite preview` so the tests + * exercise the same bundle that ships. The platform API is mocked per-test via + * page.route, so no backend is required. Uses the pre-installed Chromium. + */ +const PORT = 4173 +const EXECUTABLE = process.env.PW_CHROMIUM_PATH + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: 0, + reporter: [['list']], + timeout: 30_000, + use: { + baseURL: `http://localhost:${PORT}`, + headless: true, + ...(EXECUTABLE ? { launchOptions: { executablePath: EXECUTABLE } } : {}), + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: `npx vite preview --port ${PORT} --strictPort`, + url: `http://localhost:${PORT}/`, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, +}) diff --git a/apps/customer-operations/src/components/CocHeader.tsx b/apps/customer-operations/src/components/CocHeader.tsx index 1c1ea59d..c4b8edad 100644 --- a/apps/customer-operations/src/components/CocHeader.tsx +++ b/apps/customer-operations/src/components/CocHeader.tsx @@ -1,10 +1,8 @@ import { Bell, Command, Menu, Search, ShieldCheck, X } from 'lucide-react' import { resolveSuperAdminUrl } from '../lib/runtime-config' -import { useCocApi } from '../lib/coc-api' +import { useServiceStatus } from '../lib/coc-status' import { ZonForgeLogo } from './ZonForgeLogo' -type Overview = { total?: number; at_risk_count?: number; critical_count?: number } - function openPalette() { window.dispatchEvent(new CustomEvent('coc:cmdk')) } @@ -12,14 +10,17 @@ function openPalette() { /** * Enterprise command header — brand, command-palette search, and live * production / AI-target / API & SLA health status, notifications, Super Admin. + * + * API + SLA status come from the unified service-status model so the header can + * never contradict the page content. */ export function CocHeader({ onMenu, navOpen }: { onMenu?: () => void; navOpen?: boolean } = {}) { - // Reuse the overview call as a lightweight API-health + SLA-risk signal. - const { data, status, isLoading } = useCocApi('/v1/admin/customers/overview') - const slaRisk = (data?.at_risk_count ?? 0) + (data?.critical_count ?? 0) + const { badgeStatus, slaRisk, isLoading } = useServiceStatus() - const apiLabel = status === 'healthy' ? 'API Healthy' : status === 'degraded' ? 'API Degraded' : 'API Unavailable' - const apiColor = status === 'healthy' ? 'var(--success)' : status === 'degraded' ? 'var(--warning)' : 'var(--critical)' + const apiLabel = + badgeStatus === 'healthy' ? 'API Healthy' : badgeStatus === 'degraded' ? 'API Degraded' : 'API Unavailable' + const apiColor = + badgeStatus === 'healthy' ? 'var(--success)' : badgeStatus === 'degraded' ? 'var(--warning)' : 'var(--critical)' return (
diff --git a/apps/customer-operations/src/layout/CocShell.tsx b/apps/customer-operations/src/layout/CocShell.tsx index e2845e45..42c295d4 100644 --- a/apps/customer-operations/src/layout/CocShell.tsx +++ b/apps/customer-operations/src/layout/CocShell.tsx @@ -5,6 +5,7 @@ import { CocHeader } from '../components/CocHeader' import { CocSidebar } from '../components/CocSidebar' import { CommandPalette } from '../components/CommandPalette' import { AiCopilotPanel } from '../components/AiCopilotPanel' +import { CocServiceStatusProvider } from '../lib/coc-status' export function CocShell({ children }: { children: ReactNode }) { const [navOpen, setNavOpen] = useState(false) @@ -24,15 +25,17 @@ export function CocShell({ children }: { children: ReactNode }) { }, [navOpen]) return ( -
- -
setNavOpen(false)} aria-hidden="true" /> -
- setNavOpen((v) => !v)} navOpen={navOpen} /> -
{children}
+ +
+ +
setNavOpen(false)} aria-hidden="true" /> +
+ setNavOpen((v) => !v)} navOpen={navOpen} /> +
{children}
+
+ +
- - -
+
) } diff --git a/apps/customer-operations/src/lib/__tests__/domain-canonical.test.ts b/apps/customer-operations/src/lib/__tests__/domain-canonical.test.ts new file mode 100644 index 00000000..5bd8ac8b --- /dev/null +++ b/apps/customer-operations/src/lib/__tests__/domain-canonical.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, resolve } from 'node:path' + +/** + * Domain / canonical lock guards (source-level, so they run in CI without a + * build). They prove the official URL is the custom domain and that the raw + * Netlify host is force-redirected and never referenced in shipped metadata. + */ +const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..') +const read = (rel: string) => readFileSync(resolve(APP_ROOT, rel), 'utf8') + +const CUSTOM = 'https://customer-operations.zonforge.com' +const NETLIFY = 'zonforge-customer-operations.netlify.app' + +describe('Customer Operations — domain / canonical lock', () => { + it('index.html canonical + OpenGraph point at the custom domain only', () => { + const html = read('index.html') + expect(html).toMatch(/`) + // No raw Netlify host anywhere in the document head. + expect(html).not.toContain(NETLIFY) + }) + + it('robots + manifest never reference the Netlify host', () => { + expect(read('public/robots.txt')).not.toContain(NETLIFY) + const manifest = read('public/site.webmanifest') + expect(manifest).not.toContain(NETLIFY) + expect(manifest).toContain(CUSTOM) + }) + + it('netlify.toml force-redirects the Netlify host to the custom domain (301)', () => { + const toml = read('netlify.toml') + // https + http variants, forced 301, path preserved via :splat + expect(toml).toMatch( + /from = "https:\/\/zonforge-customer-operations\.netlify\.app\/\*"\s*\n\s*to = "https:\/\/customer-operations\.zonforge\.com\/:splat"\s*\n\s*status = 301\s*\n\s*force = true/, + ) + expect(toml).toMatch(/from = "http:\/\/zonforge-customer-operations\.netlify\.app\/\*"/) + }) +}) diff --git a/apps/customer-operations/src/lib/coc-api.ts b/apps/customer-operations/src/lib/coc-api.ts index c4c45d0d..6dd7b23b 100644 --- a/apps/customer-operations/src/lib/coc-api.ts +++ b/apps/customer-operations/src/lib/coc-api.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' import { resolveApiBaseUrl } from './runtime-config' import { cocTokenStorage } from './coc-auth' @@ -30,7 +30,7 @@ function normalizePath(path: string): string { } /** Opaque request id (no infrastructure detail) for operator-facing references. */ -function makeRequestId(): string { +export function makeRequestId(): string { try { return `zf-${crypto.randomUUID().split('-')[0]}` } catch { @@ -38,76 +38,83 @@ function makeRequestId(): string { } } +export type CocFetchResult = { + ok: boolean + status: ApiStatus + data: T | null + /** Opaque id on failure; null on success. */ + requestId: string | null +} + /** - * Live data hook for the Customer Operations Center. + * Single source of truth for every Customer Operations network call. * * The resolved endpoint is used ONLY to make the request — it is never returned - * to the UI. Failures are mapped to a safe status + generic message + a request - * id so raw URLs, routes, and HTTP status codes never reach the rendered output. + * to callers. Failures are mapped to a safe status + opaque request id so raw + * URLs, routes, bearer tokens, and HTTP status codes never reach the UI. Both + * the per-view data hook (`useCocApi`) and the app-wide service-status provider + * go through this function, so status is computed one way everywhere. + */ +export async function cocFetch(path: string, signal?: AbortSignal): Promise> { + const sourceUrl = `${resolveApiBaseUrl()}${normalizePath(path)}` + try { + const token = cocTokenStorage.get() + const headers: Record = { Accept: 'application/json' } + if (token) headers.Authorization = `Bearer ${token}` + + const response = await fetch(sourceUrl, { credentials: 'include', headers, signal: signal ?? null }) + + if (!response.ok) { + // Got a response but it failed (e.g. 401/403/5xx) — surface a safe + // "degraded" state only; the status code stays out of the UI. + return { ok: false, status: 'degraded', data: null, requestId: makeRequestId() } + } + + const payload = (await response.json()) as T | ApiEnvelope + if (payload && typeof payload === 'object' && 'data' in payload) { + return { ok: true, status: 'healthy', data: (payload as ApiEnvelope).data ?? null, requestId: null } + } + return { ok: true, status: 'healthy', data: payload as T, requestId: null } + } catch { + // Transport/network failure — never surface the underlying error (it can + // contain the request URL). + return { ok: false, status: 'unavailable', data: null, requestId: makeRequestId() } + } +} + +/** + * Live data hook for a single Customer Operations view. Delegates the request + + * status mapping to `cocFetch` so a view can never diverge from the app-wide + * service status. */ export function useCocApi(path: string): CocApiState { - const sourceUrl = useMemo(() => `${resolveApiBaseUrl()}${normalizePath(path)}`, [path]) - const [data, setData] = useState(null) - const [error, setError] = useState(null) - const [isLoading, setIsLoading] = useState(true) - const [status, setStatus] = useState('healthy') - const [requestId, setRequestId] = useState(null) + const [state, setState] = useState>({ + data: null, + error: null, + isLoading: true, + status: 'healthy', + requestId: null, + }) useEffect(() => { const controller = new AbortController() + setState((prev) => ({ ...prev, isLoading: true, error: null })) + + void cocFetch(path, controller.signal).then((result) => { + if (controller.signal.aborted) return + setState({ + data: result.data, + error: result.ok ? null : SERVICE_UNAVAILABLE_MESSAGE, + isLoading: false, + status: result.status, + requestId: result.requestId, + }) + }) - async function loadResource() { - setIsLoading(true) - setError(null) - - try { - const token = cocTokenStorage.get() - const headers: Record = { Accept: 'application/json' } - if (token) headers.Authorization = `Bearer ${token}` - - const response = await fetch(sourceUrl, { - credentials: 'include', - headers, - signal: controller.signal, - }) - - if (!response.ok) { - // Got a response but it failed (e.g. 401/403/5xx) — surface a safe - // "degraded" state only; the status code stays out of the UI. - setStatus('degraded') - setRequestId(makeRequestId()) - setData(null) - setError(SERVICE_UNAVAILABLE_MESSAGE) - return - } - - const payload = (await response.json()) as T | ApiEnvelope - setStatus('healthy') - setRequestId(null) - setError(null) - if (payload && typeof payload === 'object' && 'data' in payload) { - setData((payload as ApiEnvelope).data ?? null) - return - } - setData(payload as T) - } catch { - if (controller.signal.aborted) return - // Transport/network failure — never surface the underlying error (it can - // contain the request URL). - setStatus('unavailable') - setRequestId(makeRequestId()) - setData(null) - setError(SERVICE_UNAVAILABLE_MESSAGE) - } finally { - if (!controller.signal.aborted) setIsLoading(false) - } - } - - void loadResource() return () => controller.abort() - }, [sourceUrl]) + }, [path]) - return { data, error, isLoading, status, requestId } + return state } export function getCollection(payload: unknown): T[] { diff --git a/apps/customer-operations/src/lib/coc-status.tsx b/apps/customer-operations/src/lib/coc-status.tsx new file mode 100644 index 00000000..90002d7c --- /dev/null +++ b/apps/customer-operations/src/lib/coc-status.tsx @@ -0,0 +1,138 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' +import { ShieldAlert } from 'lucide-react' +import { cocFetch, type ApiStatus } from './coc-api' +import { hasCocToken } from './coc-auth' +import { resolveSuperAdminUrl } from './runtime-config' +import { CocError } from '../components/CocDataStates' + +/** + * Unified service-status model for the Customer Operations Center. + * + * Every status indicator in the app — the header API badge, the SLA chip, and + * every page's service banner — reads from ONE shared probe here instead of + * each view computing its own. This removes header-vs-content and page-to-page + * status divergence. Per-view data hooks still fetch their own rows; only the + * *status* is centralised. + * + * States: + * - locked → no SSO token (direct/unauthenticated access) + * - healthy → probe ok + * - degraded → probe returned an error response + * - unavailable → probe could not be reached + */ +export type ServiceStatus = ApiStatus | 'locked' + +type OverviewPayload = { + total?: number + active?: number + at_risk_count?: number + critical_count?: number + avg_health_score?: number + renewing_soon?: number + open_tickets?: number + ai_resolution_rate?: number | null +} + +type ServiceStatusValue = { + /** Full state incl. locked — drives page banners. */ + status: ServiceStatus + /** Three-state badge (locked collapses to unavailable) — drives the header. */ + badgeStatus: ApiStatus + isLoading: boolean + authenticated: boolean + requestId: string | null + overview: OverviewPayload | null + slaRisk: number +} + +const FALLBACK: ServiceStatusValue = { + status: 'healthy', + badgeStatus: 'healthy', + isLoading: true, + authenticated: false, + requestId: null, + overview: null, + slaRisk: 0, +} + +const ServiceStatusContext = createContext(FALLBACK) + +const HEALTH_PROBE_PATH = '/v1/admin/customers/overview' + +export function CocServiceStatusProvider({ children }: { children: ReactNode }) { + const [value, setValue] = useState(FALLBACK) + + useEffect(() => { + const controller = new AbortController() + const authenticated = hasCocToken() + + if (!authenticated) { + // Direct/unauthenticated access — present a safe locked state, never an + // error or a raw status code. No probe is issued without a token. + setValue({ + status: 'locked', + badgeStatus: 'unavailable', + isLoading: false, + authenticated: false, + requestId: null, + overview: null, + slaRisk: 0, + }) + return () => controller.abort() + } + + setValue((prev) => ({ ...prev, isLoading: true })) + void cocFetch(HEALTH_PROBE_PATH, controller.signal).then((result) => { + if (controller.signal.aborted) return + const overview = result.ok ? result.data : null + const slaRisk = (overview?.at_risk_count ?? 0) + (overview?.critical_count ?? 0) + setValue({ + status: result.status, + badgeStatus: result.status, + isLoading: false, + authenticated: true, + requestId: result.requestId, + overview, + slaRisk, + }) + }) + + return () => controller.abort() + }, []) + + return {children} +} + +export function useServiceStatus(): ServiceStatusValue { + return useContext(ServiceStatusContext) +} + +/** + * The single, app-wide service banner. Renders nothing when healthy; otherwise + * shows safe enterprise copy only (no URLs / routes / status codes / tokens). + * Because it reads the unified status, every page shows the same state and can + * never contradict the header badge. + */ +export function CocServiceBanner() { + const { status, requestId } = useServiceStatus() + + if (status === 'healthy') return null + + if (status === 'locked') { + return ( +
+

+ + Sign-in required +

+

Launch the Customer Operations Center from the Super Admin dashboard to continue.

+ + Go to Super Admin + +
+ ) + } + + // degraded / unavailable → generic safe failure copy + opaque reference id. + return +} diff --git a/apps/customer-operations/src/lib/ops-metrics.ts b/apps/customer-operations/src/lib/ops-metrics.ts index 8c515593..259a258b 100644 --- a/apps/customer-operations/src/lib/ops-metrics.ts +++ b/apps/customer-operations/src/lib/ops-metrics.ts @@ -10,8 +10,9 @@ * no backend, schema, or data flow. */ -import { getCollection, useCocApi } from './coc-api' +import { getCollection, useCocApi, SERVICE_UNAVAILABLE_MESSAGE } from './coc-api' import type { ApiStatus } from './coc-api' +import { useServiceStatus } from './coc-status' export type OverviewPayload = { total?: number @@ -160,24 +161,25 @@ export type OpsData = { requestId: string | null } -function worstStatus(a: ApiStatus, b: ApiStatus): ApiStatus { - const rank: Record = { healthy: 0, degraded: 1, unavailable: 2 } - return rank[a] >= rank[b] ? a : b -} - -/** Live command-center data hook — reuses the existing API surface only. */ +/** + * Live command-center data hook. + * + * Status comes from the unified service-status model (one shared overview probe) + * so the dashboard never contradicts the header or other pages. Only the + * escalation rows are fetched here for the queue tables. + */ export function useOpsData(): OpsData { - const overview = useCocApi('/v1/admin/customers/overview') + const service = useServiceStatus() const escalations = useCocApi<{ items?: EscalationRow[] }>('/v1/admin/escalations?status=open&limit=50') const rows = getCollection(escalations.data?.items ?? escalations.data) return { - metrics: deriveMetrics(overview.data, rows), + metrics: deriveMetrics(service.overview, rows), rows, - isLoading: overview.isLoading || escalations.isLoading, - error: overview.error ?? escalations.error, - status: worstStatus(overview.status, escalations.status), - requestId: overview.requestId ?? escalations.requestId, + isLoading: service.isLoading || escalations.isLoading, + error: service.status === 'healthy' ? null : SERVICE_UNAVAILABLE_MESSAGE, + status: service.badgeStatus, + requestId: service.requestId, } } diff --git a/apps/customer-operations/src/pages/AnalyticsPage.tsx b/apps/customer-operations/src/pages/AnalyticsPage.tsx index 24699b0a..9a3cc947 100644 --- a/apps/customer-operations/src/pages/AnalyticsPage.tsx +++ b/apps/customer-operations/src/pages/AnalyticsPage.tsx @@ -1,8 +1,9 @@ import { CocCard, CocMetric } from '../components/CocCard' -import { CocError, CocLoading, StatusBadge } from '../components/CocDataStates' +import { CocLoading, StatusBadge } from '../components/CocDataStates' import { CocPageFrame } from '../components/CocPageFrame' import { getCollection, useCocApi } from '../lib/coc-api' import { aiAgents, AI_RESOLUTION_TARGET } from '../data/agents' +import { CocServiceBanner } from '../lib/coc-status' type Overview = { total?: number @@ -32,9 +33,7 @@ export default function AnalyticsPage() { description="Operational analytics across the AI Agent Fabric and human operations." > {overview.isLoading ? : null} - {!overview.isLoading && overview.error ? ( - - ) : null} +
diff --git a/apps/customer-operations/src/pages/BillingOperationsPage.tsx b/apps/customer-operations/src/pages/BillingOperationsPage.tsx index b261894b..58a1c720 100644 --- a/apps/customer-operations/src/pages/BillingOperationsPage.tsx +++ b/apps/customer-operations/src/pages/BillingOperationsPage.tsx @@ -1,7 +1,8 @@ import { CocCard, CocMetric } from '../components/CocCard' -import { CocEmpty, CocError, CocLoading, StatusBadge } from '../components/CocDataStates' +import { CocEmpty, CocLoading, StatusBadge } from '../components/CocDataStates' import { CocPageFrame } from '../components/CocPageFrame' import { getCollection, useCocApi } from '../lib/coc-api' +import { CocServiceBanner } from '../lib/coc-status' type CustomerItem = { id?: string @@ -29,7 +30,7 @@ export default function BillingOperationsPage() { description="Billing support, dunning, and disputes. The Billing Support Agent resolves routine queries; Billing Specialists own approvals and refunds (RULE-004)." > {isLoading ? : null} - {!isLoading && error ? : null} +
diff --git a/apps/customer-operations/src/pages/CustomerHealthPage.tsx b/apps/customer-operations/src/pages/CustomerHealthPage.tsx index 6f982f8f..4e3929cc 100644 --- a/apps/customer-operations/src/pages/CustomerHealthPage.tsx +++ b/apps/customer-operations/src/pages/CustomerHealthPage.tsx @@ -1,7 +1,8 @@ import { CocCard } from '../components/CocCard' -import { CocEmpty, CocError, CocLoading, StatusBadge } from '../components/CocDataStates' +import { CocEmpty, CocLoading, StatusBadge } from '../components/CocDataStates' import { CocPageFrame } from '../components/CocPageFrame' import { getCollection, useCocApi } from '../lib/coc-api' +import { CocServiceBanner } from '../lib/coc-status' type CustomerHealth = { overallScore?: number @@ -45,7 +46,7 @@ export default function CustomerHealthPage() { description="Health scores, grades, and churn risk across the customer base, sorted by lowest score first." > {isLoading ? : null} - {!isLoading && error ? : null} + {!isLoading && !error && !items.length ? ( ) : null} diff --git a/apps/customer-operations/src/pages/CustomerIntelligencePage.tsx b/apps/customer-operations/src/pages/CustomerIntelligencePage.tsx index 4798e22d..1706bb38 100644 --- a/apps/customer-operations/src/pages/CustomerIntelligencePage.tsx +++ b/apps/customer-operations/src/pages/CustomerIntelligencePage.tsx @@ -1,10 +1,11 @@ import { Brain } from 'lucide-react' import { CocCard, CocMetric } from '../components/CocCard' -import { CocEmpty, CocError, CocLoading, StatusBadge } from '../components/CocDataStates' +import { CocEmpty, CocLoading, StatusBadge } from '../components/CocDataStates' import { CocFeatureGrid } from '../components/CocFeatureGrid' import { CocPageFrame } from '../components/CocPageFrame' import { getCollection, useCocApi } from '../lib/coc-api' import { intelligenceModules } from '../data/intelligence' +import { CocServiceBanner } from '../lib/coc-status' type CustomerItem = { id?: string @@ -46,9 +47,7 @@ export default function CustomerIntelligencePage() { {customers.isLoading ? : null} - {!customers.isLoading && customers.error ? ( - - ) : null} +
diff --git a/apps/customer-operations/src/pages/CustomerSuccessPage.tsx b/apps/customer-operations/src/pages/CustomerSuccessPage.tsx index 8a18d485..de4abb69 100644 --- a/apps/customer-operations/src/pages/CustomerSuccessPage.tsx +++ b/apps/customer-operations/src/pages/CustomerSuccessPage.tsx @@ -1,7 +1,8 @@ import { CocCard, CocMetric } from '../components/CocCard' -import { CocEmpty, CocError, CocLoading, StatusBadge } from '../components/CocDataStates' +import { CocEmpty, CocLoading, StatusBadge } from '../components/CocDataStates' import { CocPageFrame } from '../components/CocPageFrame' import { getCollection, useCocApi } from '../lib/coc-api' +import { CocServiceBanner } from '../lib/coc-status' type CustomerItem = { id?: string @@ -37,7 +38,7 @@ export default function CustomerSuccessPage() { description="Proactive success plays driven by the Customer Success Agent, with CSM ownership on high-risk accounts." > {isLoading ? : null} - {!isLoading && error ? : null} +
diff --git a/apps/customer-operations/src/pages/HumanEscalationsPage.tsx b/apps/customer-operations/src/pages/HumanEscalationsPage.tsx index cc4c5d36..a960c303 100644 --- a/apps/customer-operations/src/pages/HumanEscalationsPage.tsx +++ b/apps/customer-operations/src/pages/HumanEscalationsPage.tsx @@ -1,9 +1,10 @@ import { ArrowRight } from 'lucide-react' import { CocCard } from '../components/CocCard' -import { CocEmpty, CocError, CocLoading, StatusBadge, formatDate } from '../components/CocDataStates' +import { CocEmpty, CocLoading, StatusBadge, formatDate } from '../components/CocDataStates' import { CocPageFrame } from '../components/CocPageFrame' import { getCollection, useCocApi } from '../lib/coc-api' import { escalationPriorities, escalationWorkflow } from '../data/escalation' +import { CocServiceBanner } from '../lib/coc-status' type EscalationRow = { id?: string @@ -60,7 +61,7 @@ export default function HumanEscalationsPage() { {isLoading ? : null} - {!isLoading && error ? : null} + {!isLoading && !error && !rows.length ? ( ) : null} diff --git a/apps/customer-operations/src/pages/LiveConversationsPage.tsx b/apps/customer-operations/src/pages/LiveConversationsPage.tsx index fc542a17..fd9759d3 100644 --- a/apps/customer-operations/src/pages/LiveConversationsPage.tsx +++ b/apps/customer-operations/src/pages/LiveConversationsPage.tsx @@ -1,8 +1,9 @@ import { CocCard, CocMetric } from '../components/CocCard' -import { CocEmpty, CocError, CocLoading, StatusBadge, formatDate } from '../components/CocDataStates' +import { CocEmpty, CocLoading, StatusBadge, formatDate } from '../components/CocDataStates' import { CocPageFrame } from '../components/CocPageFrame' import { getCollection, useCocApi } from '../lib/coc-api' import { aiAgents } from '../data/agents' +import { CocServiceBanner } from '../lib/coc-status' type Conversation = { id?: string @@ -29,7 +30,7 @@ export default function LiveConversationsPage() { description="Active AI and human conversations in flight. AI agents lead; a human joins only on escalation." > {isLoading ? : null} - {!isLoading && error ? : null} +
diff --git a/apps/customer-operations/src/pages/OmnichannelPage.tsx b/apps/customer-operations/src/pages/OmnichannelPage.tsx index 496fad26..c5c8bd5f 100644 --- a/apps/customer-operations/src/pages/OmnichannelPage.tsx +++ b/apps/customer-operations/src/pages/OmnichannelPage.tsx @@ -1,10 +1,11 @@ import { useMemo, useState } from 'react' import { Radio } from 'lucide-react' import { CocCard, CocMetric } from '../components/CocCard' -import { CocEmpty, CocError, CocLoading, StatusBadge, formatDate } from '../components/CocDataStates' +import { CocEmpty, CocLoading, StatusBadge, formatDate } from '../components/CocDataStates' import { CocPageFrame } from '../components/CocPageFrame' import { getCollection, useCocApi } from '../lib/coc-api' import { channels, omnichannelCapabilities } from '../data/channels' +import { CocServiceBanner } from '../lib/coc-status' type Conversation = { id?: string @@ -93,7 +94,7 @@ export default function OmnichannelPage() { } > {isLoading ? : null} - {!isLoading && error ? : null} + {!isLoading && !error && !filtered.length ? ( ) : null} diff --git a/apps/customer-operations/src/pages/OnboardingPage.tsx b/apps/customer-operations/src/pages/OnboardingPage.tsx index db4e6404..11185e10 100644 --- a/apps/customer-operations/src/pages/OnboardingPage.tsx +++ b/apps/customer-operations/src/pages/OnboardingPage.tsx @@ -1,7 +1,8 @@ import { CocCard, CocMetric } from '../components/CocCard' -import { CocEmpty, CocError, CocLoading, StatusBadge, formatDate } from '../components/CocDataStates' +import { CocEmpty, CocLoading, StatusBadge, formatDate } from '../components/CocDataStates' import { CocPageFrame } from '../components/CocPageFrame' import { getCollection, useCocApi } from '../lib/coc-api' +import { CocServiceBanner } from '../lib/coc-status' type CustomerItem = { id?: string @@ -37,7 +38,7 @@ export default function OnboardingPage() { description="New customer activation journeys, guided by the Onboarding Agent toward first value and milestones." > {isLoading ? : null} - {!isLoading && error ? : null} +
diff --git a/apps/customer-operations/src/pages/OperationsDashboardPage.tsx b/apps/customer-operations/src/pages/OperationsDashboardPage.tsx index 4cbc59cf..38936f9e 100644 --- a/apps/customer-operations/src/pages/OperationsDashboardPage.tsx +++ b/apps/customer-operations/src/pages/OperationsDashboardPage.tsx @@ -2,7 +2,6 @@ import { Activity, Bot, Inbox, LifeBuoy, Mail, MessageCircle, Phone, ShieldAlert import type { LucideIcon } from 'lucide-react' import { CocCard } from '../components/CocCard' -import { CocError } from '../components/CocDataStates' import { CocPageFrame } from '../components/CocPageFrame' import { KpiCard } from '../components/KpiCard' import type { KpiStatus } from '../components/KpiCard' @@ -20,6 +19,7 @@ import { WorkforceCommand } from '../components/command-center/WorkforceCommand' import { AiAgentObservability } from '../components/command-center/AiAgentObservability' import { BusinessIntelligence } from '../components/command-center/BusinessIntelligence' import { EventStream } from '../components/command-center/EventStream' +import { CocServiceBanner } from '../lib/coc-status' const AI = 'var(--accent-ai)' const SUCCESS = 'var(--success)' @@ -69,7 +69,7 @@ export default function OperationsDashboardPage() { title="Operations Command Center" description="AI-first command surface across the Agent Fabric, omnichannel inbox, escalations, workforce, and customer intelligence." > - {error ? : null} + {/* Phase 17 — Executive Hero Section */} diff --git a/apps/customer-operations/src/pages/RenewalsPage.tsx b/apps/customer-operations/src/pages/RenewalsPage.tsx index 885973cc..0ce0dbdd 100644 --- a/apps/customer-operations/src/pages/RenewalsPage.tsx +++ b/apps/customer-operations/src/pages/RenewalsPage.tsx @@ -1,7 +1,8 @@ import { CocCard, CocMetric } from '../components/CocCard' -import { CocEmpty, CocError, CocLoading, StatusBadge } from '../components/CocDataStates' +import { CocEmpty, CocLoading, StatusBadge } from '../components/CocDataStates' import { CocPageFrame } from '../components/CocPageFrame' import { getCollection, useCocApi } from '../lib/coc-api' +import { CocServiceBanner } from '../lib/coc-status' type CustomerItem = { id?: string @@ -41,9 +42,7 @@ export default function RenewalsPage() { description="Upcoming renewals, expansion signals, and retention risk. The Renewal Agent drives reminders and quotes; AMs own at-risk contracts." > {customers.isLoading ? : null} - {!customers.isLoading && customers.error ? ( - - ) : null} +
diff --git a/apps/customer-operations/src/pages/SlaMonitoringPage.tsx b/apps/customer-operations/src/pages/SlaMonitoringPage.tsx index e8be78ce..c3914ec2 100644 --- a/apps/customer-operations/src/pages/SlaMonitoringPage.tsx +++ b/apps/customer-operations/src/pages/SlaMonitoringPage.tsx @@ -1,8 +1,9 @@ import { CocCard, CocMetric } from '../components/CocCard' -import { CocEmpty, CocError, CocLoading, StatusBadge, formatDate } from '../components/CocDataStates' +import { CocEmpty, CocLoading, StatusBadge, formatDate } from '../components/CocDataStates' import { CocPageFrame } from '../components/CocPageFrame' import { getCollection, useCocApi } from '../lib/coc-api' import { escalationPriorities } from '../data/escalation' +import { CocServiceBanner } from '../lib/coc-status' type EscalationRow = { id?: string @@ -54,7 +55,7 @@ export default function SlaMonitoringPage() { {isLoading ? : null} - {!isLoading && error ? : null} +
diff --git a/apps/customer-operations/src/pages/SupportInboxPage.tsx b/apps/customer-operations/src/pages/SupportInboxPage.tsx index 5ed4ff4e..cdc5204c 100644 --- a/apps/customer-operations/src/pages/SupportInboxPage.tsx +++ b/apps/customer-operations/src/pages/SupportInboxPage.tsx @@ -1,7 +1,8 @@ import { CocCard, CocMetric } from '../components/CocCard' -import { CocEmpty, CocError, CocLoading, StatusBadge, formatDate } from '../components/CocDataStates' +import { CocEmpty, CocLoading, StatusBadge, formatDate } from '../components/CocDataStates' import { CocPageFrame } from '../components/CocPageFrame' import { getCollection, useCocApi } from '../lib/coc-api' +import { CocServiceBanner } from '../lib/coc-status' type InboxItem = { id?: string @@ -29,7 +30,7 @@ export default function SupportInboxPage() { description="Unified omni-channel inbox. AI agents triage every item first; only exceptions surface for a human (RULE-003, RULE-004)." > {isLoading ? : null} - {!isLoading && error ? : null} +
diff --git a/apps/customer-operations/src/pages/TicketCenterPage.tsx b/apps/customer-operations/src/pages/TicketCenterPage.tsx index a076f3cf..92352ee4 100644 --- a/apps/customer-operations/src/pages/TicketCenterPage.tsx +++ b/apps/customer-operations/src/pages/TicketCenterPage.tsx @@ -1,8 +1,9 @@ import { useMemo, useState } from 'react' import { CocCard, CocMetric } from '../components/CocCard' -import { CocEmpty, CocError, CocLoading, StatusBadge, formatDate } from '../components/CocDataStates' +import { CocEmpty, CocLoading, StatusBadge, formatDate } from '../components/CocDataStates' import { CocPageFrame } from '../components/CocPageFrame' import { getCollection, useCocApi } from '../lib/coc-api' +import { CocServiceBanner } from '../lib/coc-status' type Ticket = { id?: string @@ -43,7 +44,7 @@ export default function TicketCenterPage() { description="Full lifecycle management for every customer ticket, from AI auto-resolution through human closure." > {isLoading ? : null} - {!isLoading && error ? : null} +
diff --git a/apps/customer-operations/vitest.config.ts b/apps/customer-operations/vitest.config.ts new file mode 100644 index 00000000..6632f781 --- /dev/null +++ b/apps/customer-operations/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config' + +/** + * Unit tests live under src/ and run in the default (node) environment. + * The Playwright E2E suite under e2e/ is excluded — it is run separately via + * `npm run e2e` (playwright test), not by vitest. + */ +export default defineConfig({ + test: { + include: ['src/**/*.{test,spec}.{ts,tsx}'], + exclude: ['e2e/**', 'node_modules/**', 'dist/**'], + }, +}) diff --git a/package-lock.json b/package-lock.json index c3d7b774..53951e13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -538,6 +538,7 @@ "react-router-dom": "^6.30.4" }, "devDependencies": { + "@playwright/test": "^1.49.1", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.2.0", @@ -7476,6 +7477,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz", + "integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.49.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@prisma/instrumentation": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-5.22.0.tgz", @@ -20652,6 +20669,53 @@ "node": ">=4" } }, + "node_modules/playwright": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", + "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.49.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", + "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", From dd793f6ec36a41bd3869b42cdac96b2a172a3513 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 06:36:02 +0000 Subject: [PATCH 2/9] test(customer-operations): add E2E for unavailable state + header/sidebar scroll fixity Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MiPyhqoV9Pt7XN2tiFpS4A --- apps/customer-operations/e2e/coc-e2e.spec.ts | 28 ++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/customer-operations/e2e/coc-e2e.spec.ts b/apps/customer-operations/e2e/coc-e2e.spec.ts index e86b739a..ea6b5bd8 100644 --- a/apps/customer-operations/e2e/coc-e2e.spec.ts +++ b/apps/customer-operations/e2e/coc-e2e.spec.ts @@ -135,6 +135,34 @@ test.describe('Customer Operations — production behavior', () => { await assertNoExposure(page) }) + test('unified status: header and content agree when unavailable', async ({ page }) => { + await mockApi(page, 'unavailable', {}) + await ssoSignIn(page) + await expect(page.getByText('API Unavailable')).toBeVisible() + await expect(page.getByText('temporarily unavailable')).toBeVisible() + await assertNoExposure(page) + }) + + test('header and sidebar are fixed; only content scrolls', async ({ page }) => { + await mockApi(page, 'healthy', {}) + await ssoSignIn(page) + const header = page.locator('header.zf-cx-cmdhead') + const sidebar = page.locator('aside.zf-cx-sidebar') + const headerBefore = await header.boundingBox() + const sidebarBefore = await sidebar.boundingBox() + await page.locator('main.zf-cx-content').evaluate((el) => { + el.scrollTop = el.scrollHeight + }) + await page.waitForTimeout(150) + const headerAfter = await header.boundingBox() + const sidebarAfter = await sidebar.boundingBox() + expect(Math.abs((headerAfter?.y ?? 0) - (headerBefore?.y ?? 0))).toBeLessThanOrEqual(1) + expect(Math.abs((sidebarAfter?.y ?? 0) - (sidebarBefore?.y ?? 0))).toBeLessThanOrEqual(1) + // The content container actually scrolled. + const scrolled = await page.locator('main.zf-cx-content').evaluate((el) => el.scrollTop) + expect(scrolled).toBeGreaterThan(0) + }) + test('unified status persists across SPA navigation', async ({ page }) => { await mockApi(page, 'healthy', {}) await ssoSignIn(page) From ba6521e9fd09c7b04186cefe6cfaaa41a766784c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 06:40:26 +0000 Subject: [PATCH 3/9] test(customer-operations): use glob route matchers (resolve CodeQL js/missing-regexp-anchor) Replace the unanchored hostname regex in page.route() with Playwright glob string matchers so no URL-context regex is introduced. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MiPyhqoV9Pt7XN2tiFpS4A --- apps/customer-operations/e2e/coc-e2e.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/customer-operations/e2e/coc-e2e.spec.ts b/apps/customer-operations/e2e/coc-e2e.spec.ts index ea6b5bd8..45d96a6b 100644 --- a/apps/customer-operations/e2e/coc-e2e.spec.ts +++ b/apps/customer-operations/e2e/coc-e2e.spec.ts @@ -51,8 +51,10 @@ type ApiMode = 'healthy' | 'degraded' | 'unavailable' /** Mock every platform API call; capture the Authorization header seen. */ async function mockApi(page: Page, mode: ApiMode, seen: { auth?: string }) { - // Avoid external font fetches hanging in a sandboxed browser. - await page.route(/fonts\.(googleapis|gstatic)\.com/, (r: Route) => r.abort()) + // Avoid external font fetches hanging in a sandboxed browser. Use glob + // matchers (not a hostname regex) so no unanchored URL regex is introduced. + await page.route('**/fonts.googleapis.com/**', (r: Route) => r.abort()) + await page.route('**/fonts.gstatic.com/**', (r: Route) => r.abort()) await page.route('**/v1/**', (route: Route) => { const auth = route.request().headers()['authorization'] if (auth) seen.auth = auth From e158c42889ae91ec7da2f515228dac98c0628f97 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 07:00:26 +0000 Subject: [PATCH 4/9] ci: live post-deploy edge verification for Customer Operations Runs curl assertions against the deployed production URLs from a GitHub-hosted runner (open egress) to verify the real 301 redirect, custom-domain 200, canonical lock, and absence of netlify.app in served HTML. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MiPyhqoV9Pt7XN2tiFpS4A --- .github/workflows/coc-edge-verify.yml | 53 +++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/workflows/coc-edge-verify.yml diff --git a/.github/workflows/coc-edge-verify.yml b/.github/workflows/coc-edge-verify.yml new file mode 100644 index 00000000..faa99708 --- /dev/null +++ b/.github/workflows/coc-edge-verify.yml @@ -0,0 +1,53 @@ +name: COC Edge Verify + +# Live post-deploy validation of the Customer Operations Center production edge. +# Runs on GitHub-hosted runners (open egress) so it hits the real deployed URLs, +# not just configuration. Triggers on push to this branch and via manual dispatch. +on: + push: + branches: [ci/coc-edge-verify] + workflow_dispatch: + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Verify deployed edge responses + run: | + set -uo pipefail + CUSTOM="https://customer-operations.zonforge.com" + NET="https://zonforge-customer-operations.netlify.app" + fail=0 + + echo "================ HTTP RESPONSE SUMMARY ================" + + net_code=$(curl -s -o /dev/null -w '%{http_code}' -I "$NET/") + net_loc=$(curl -s -o /dev/null -w '%{redirect_url}' -I "$NET/") + echo "netlify.app / -> $net_code Location: $net_loc" + + net_code2=$(curl -s -o /dev/null -w '%{http_code}' -I "$NET/tickets") + net_loc2=$(curl -s -o /dev/null -w '%{redirect_url}' -I "$NET/tickets") + echo "netlify.app /tickets -> $net_code2 Location: $net_loc2" + + cust_code=$(curl -sL -o /tmp/page.html -w '%{http_code}' "$CUSTOM/") + echo "custom / -> $cust_code" + + echo "================ CANONICAL / EXPOSURE =================" + grep -oi ']*>' /tmp/page.html || echo "(no canonical tag found)" + grep -oi ']*>' /tmp/page.html || true + if grep -qi 'netlify\.app' /tmp/page.html; then echo "netlify.app in body: YES"; else echo "netlify.app in body: NO"; fi + + echo "================ ASSERTIONS ===========================" + assert() { if eval "$2"; then echo "PASS $1"; else echo "FAIL $1"; fail=1; fi; } + + assert "netlify.app / returns 301" '[ "$net_code" = "301" ] || [ "$net_code" = "308" ]' + assert "redirect target is custom domain" 'echo "$net_loc" | grep -qi "customer-operations.zonforge.com"' + assert "netlify.app /tickets redirects" '[ "$net_code2" = "301" ] || [ "$net_code2" = "308" ]' + assert "deep redirect preserves /tickets" 'echo "$net_loc2" | grep -qi "/tickets"' + assert "custom domain returns 200" '[ "$cust_code" = "200" ]' + assert "canonical = custom domain" 'grep -qi "rel=\"canonical\"[^>]*customer-operations.zonforge.com" /tmp/page.html' + assert "no netlify.app in served HTML" '! grep -qi "netlify\.app" /tmp/page.html' + + echo "======================================================" + if [ "$fail" -ne 0 ]; then echo "EDGE VERIFICATION: FAILED"; exit 1; fi + echo "EDGE VERIFICATION: PASSED" From 777a6d63697855970d4d9e3cfaa5e35ac01a3d6e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 07:32:36 +0000 Subject: [PATCH 5/9] ci: re-run edge verification after redirect-order fix (011d618) From 6739c5f523c11d6ab119b064698d7c2144bf66f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 17:32:21 +0000 Subject: [PATCH 6/9] ci: verify production edge after header branding dedupe (178fb28) From 1398f34c649c83df8ede0b0345968012d309c34a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 18:11:37 +0000 Subject: [PATCH 7/9] ci: verify production edge after Conversation Workspace deploy (19aff19) From e3db09a80cd20811be62cef9a394dbcd2619121f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 18:41:38 +0000 Subject: [PATCH 8/9] ci: verify production edge after agent-ops deploy (b0330ac) From cb44431deefd38a5c81c176ef3d171e3e32d2032 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 18:52:19 +0000 Subject: [PATCH 9/9] ci: final edge verify after lighthouse-reporting deploy (fbe68f1)