From 0bbae1f14e125924197bb84dc2cc610fe065be52 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 20 Mar 2026 03:38:08 -0700 Subject: [PATCH] =?UTF-8?q?feat(platform):=20Phase=206=20=E2=80=94=20Suppo?= =?UTF-8?q?rt=20Case=20Management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Case timeline: GET /support/cases/:id/timeline - Merges case creation, notes, escalations chronologically - SLA engine: GET /support/cases/:id/sla - Priority-based SLA targets (critical=1h/4h, high=4h/24h, etc.) - First-response and resolution breach detection - Auto-triage: POST /support/cases/:id/auto-triage - Keyword-based priority heuristics (outage→critical, error→high, etc.) - Category detection (auth, billing, api) - Deduped tag application - Case metrics: GET /support/metrics - Aggregates by status/priority/source - Avg resolution hours, SLA breach count, compliance rate - New repo function: listAllCases - 1,336 tests passing (5 new) --- .../src/modules/support-cases/repository.ts | 8 + .../src/modules/support-cases/routes.test.ts | 158 +++++++++++++ .../src/modules/support-cases/routes.ts | 209 ++++++++++++++++++ 3 files changed, 375 insertions(+) diff --git a/services/platform-service/src/modules/support-cases/repository.ts b/services/platform-service/src/modules/support-cases/repository.ts index ef98b5f3..791d1069 100644 --- a/services/platform-service/src/modules/support-cases/repository.ts +++ b/services/platform-service/src/modules/support-cases/repository.ts @@ -84,3 +84,11 @@ export async function listEscalations(caseId: string): Promise { + return caseCollection().findMany({ + filter: { productId }, + sort: { createdAt: -1 }, + limit, + }); +} diff --git a/services/platform-service/src/modules/support-cases/routes.test.ts b/services/platform-service/src/modules/support-cases/routes.test.ts index eb7d295b..ddba2d94 100644 --- a/services/platform-service/src/modules/support-cases/routes.test.ts +++ b/services/platform-service/src/modules/support-cases/routes.test.ts @@ -10,6 +10,7 @@ const repoMock = { listNotes: vi.fn(), createEscalation: vi.fn(), listEscalations: vi.fn(), + listAllCases: vi.fn(), }; vi.mock('./repository.js', () => repoMock); @@ -107,4 +108,161 @@ describe('supportCaseRoutes', () => { }) ); }); + + // ── Case Timeline ─────────────────────────────────────── + + it('GET /support/cases/:id/timeline returns chronological events', async () => { + repoMock.getCase.mockResolvedValue({ + id: 'sup_1', + productId: 'lysnrai', + title: 'Test', + priority: 'high', + source: 'manual', + requesterUserId: 'user_1', + createdAt: '2026-03-15T00:00:00Z', + }); + repoMock.listNotes.mockResolvedValue([ + { + authorId: 'admin_1', + authorType: 'user', + visibility: 'internal', + body: 'Investigating', + createdAt: '2026-03-15T01:00:00Z', + }, + ]); + repoMock.listEscalations.mockResolvedValue([ + { + triggeredBy: 'admin_1', + escalatedTo: 'tier2', + reason: 'Critical', + createdAt: '2026-03-15T02:00:00Z', + }, + ]); + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + + const res = await app.inject({ method: 'GET', url: '/api/support/cases/sup_1/timeline' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.events).toHaveLength(3); + expect(body.events[0].type).toBe('case_created'); + expect(body.events[1].type).toBe('internal_note'); + expect(body.events[2].type).toBe('escalation'); + }); + + // ── SLA Engine ───────────────────────────────────────── + + it('GET /support/cases/:id/sla returns SLA status with breach detection', async () => { + repoMock.getCase.mockResolvedValue({ + id: 'sup_1', + productId: 'lysnrai', + priority: 'critical', + status: 'open', + createdAt: '2026-03-15T00:00:00Z', + updatedAt: '2026-03-15T00:00:00Z', + }); + repoMock.listNotes.mockResolvedValue([ + { authorType: 'user', createdAt: '2026-03-15T00:30:00Z' }, + ]); + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + + const res = await app.inject({ method: 'GET', url: '/api/support/cases/sup_1/sla' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.priority).toBe('critical'); + expect(body.response.hasResponse).toBe(true); + expect(body.target.responseHours).toBe(1); + }); + + // ── Auto-Triage ──────────────────────────────────────── + + it('POST /support/cases/:id/auto-triage upgrades priority for outage keywords', async () => { + repoMock.getCase.mockResolvedValue({ + id: 'sup_1', + productId: 'lysnrai', + title: 'Production outage affecting all users', + description: 'Login service is down', + priority: 'medium', + tags: [], + }); + repoMock.updateCase.mockImplementation( + async (_id: string, _pid: string, updates: Record) => ({ + ...updates, + id: 'sup_1', + }) + ); + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + + const res = await app.inject({ method: 'POST', url: '/api/support/cases/sup_1/auto-triage' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.triaged).toBe(true); + expect(body.newPriority).toBe('critical'); + expect(body.tagsAdded).toContain('auto-triaged:critical'); + expect(body.tagsAdded).toContain('category:auth'); + }); + + it('POST /support/cases/:id/auto-triage detects billing category', async () => { + repoMock.getCase.mockResolvedValue({ + id: 'sup_2', + productId: 'lysnrai', + title: 'Question about billing', + description: 'How do I update payment method?', + priority: 'medium', + tags: [], + }); + repoMock.updateCase.mockImplementation( + async (_id: string, _pid: string, updates: Record) => ({ + ...updates, + id: 'sup_2', + }) + ); + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + + const res = await app.inject({ method: 'POST', url: '/api/support/cases/sup_2/auto-triage' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.newPriority).toBe('low'); + expect(body.tagsAdded).toContain('category:billing'); + }); + + // ── Case Metrics ─────────────────────────────────────── + + it('GET /support/metrics returns case metrics with SLA compliance', async () => { + repoMock.listAllCases.mockResolvedValue([ + { + id: 'sup_1', + status: 'open', + priority: 'high', + source: 'manual', + createdAt: '2026-03-15T00:00:00Z', + updatedAt: '2026-03-15T00:00:00Z', + }, + { + id: 'sup_2', + status: 'resolved', + priority: 'medium', + source: 'agent', + createdAt: '2026-03-14T00:00:00Z', + updatedAt: '2026-03-14T02:00:00Z', + }, + { + id: 'sup_3', + status: 'closed', + priority: 'low', + source: 'customer', + createdAt: '2026-03-13T00:00:00Z', + updatedAt: '2026-03-13T12:00:00Z', + }, + ]); + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + + const res = await app.inject({ method: 'GET', url: '/api/support/metrics' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.total).toBe(3); + expect(body.open).toBe(1); + expect(body.resolved).toBe(2); + expect(body.byStatus.open).toBe(1); + expect(body.slaComplianceRate).toBe(100); + }); }); diff --git a/services/platform-service/src/modules/support-cases/routes.ts b/services/platform-service/src/modules/support-cases/routes.ts index ccb4cc2b..4aae81f0 100644 --- a/services/platform-service/src/modules/support-cases/routes.ts +++ b/services/platform-service/src/modules/support-cases/routes.ts @@ -140,4 +140,213 @@ export async function supportCaseRoutes(app: FastifyInstance) { await repo.updateCase(id, access.productId, { status: 'escalated' }); return repo.createEscalation(event); }); + + // ── Case Timeline ─────────────────────────────────────── + + app.get('/support/cases/:id/timeline', async req => { + const access = requireAdmin(req); + const { id } = req.params as { id: string }; + const supportCase = await repo.getCase(id, access.productId); + + const [notes, escalations] = await Promise.all([repo.listNotes(id), repo.listEscalations(id)]); + + const events: Array<{ + type: string; + timestamp: string; + actor?: string; + details: Record; + }> = []; + + events.push({ + type: 'case_created', + timestamp: supportCase.createdAt, + actor: supportCase.requesterUserId, + details: { + title: supportCase.title, + priority: supportCase.priority, + source: supportCase.source, + }, + }); + + for (const note of notes) { + events.push({ + type: note.visibility === 'internal' ? 'internal_note' : 'customer_note', + timestamp: note.createdAt, + actor: note.authorId, + details: { body: note.body, authorType: note.authorType }, + }); + } + + for (const esc of escalations) { + events.push({ + type: 'escalation', + timestamp: esc.createdAt, + actor: esc.triggeredBy, + details: { escalatedTo: esc.escalatedTo, reason: esc.reason }, + }); + } + + events.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + + return { caseId: id, events }; + }); + + // ── SLA Engine ────────────────────────────────────────── + + const SLA_TARGETS_HOURS: Record = { + critical: { responseHours: 1, resolutionHours: 4 }, + high: { responseHours: 4, resolutionHours: 24 }, + medium: { responseHours: 8, resolutionHours: 72 }, + low: { responseHours: 24, resolutionHours: 168 }, + }; + + app.get('/support/cases/:id/sla', async req => { + const access = requireAdmin(req); + const { id } = req.params as { id: string }; + const supportCase = await repo.getCase(id, access.productId); + const notes = await repo.listNotes(id); + + const target = SLA_TARGETS_HOURS[supportCase.priority] ?? SLA_TARGETS_HOURS['medium']; + const createdMs = new Date(supportCase.createdAt).getTime(); + const nowMs = Date.now(); + + // First response = first note by a user (not system) + const firstResponse = notes.find(n => n.authorType === 'user'); + const responseMs = firstResponse + ? new Date(firstResponse.createdAt).getTime() - createdMs + : nowMs - createdMs; + + const isResolved = ['resolved', 'closed'].includes(supportCase.status); + const resolutionMs = isResolved + ? new Date(supportCase.updatedAt).getTime() - createdMs + : nowMs - createdMs; + + const responseBreached = responseMs > target.responseHours * 3600000; + const resolutionBreached = !isResolved && resolutionMs > target.resolutionHours * 3600000; + + return { + caseId: id, + priority: supportCase.priority, + target, + response: { + elapsedHours: Math.round((responseMs / 3600000) * 10) / 10, + targetHours: target.responseHours, + breached: responseBreached, + hasResponse: !!firstResponse, + }, + resolution: { + elapsedHours: Math.round((resolutionMs / 3600000) * 10) / 10, + targetHours: target.resolutionHours, + breached: resolutionBreached, + isResolved, + }, + }; + }); + + // ── Auto-Triage ───────────────────────────────────────── + + app.post('/support/cases/:id/auto-triage', async req => { + const access = requireAdmin(req); + const { id } = req.params as { id: string }; + const supportCase = await repo.getCase(id, access.productId); + + // Simple keyword-based auto-triage + const text = `${supportCase.title} ${supportCase.description ?? ''}`.toLowerCase(); + + let suggestedPriority: string = supportCase.priority; + const tags = [...supportCase.tags]; + + // Priority heuristics + if (text.includes('outage') || text.includes('production down') || text.includes('data loss')) { + suggestedPriority = 'critical'; + tags.push('auto-triaged:critical'); + } else if (text.includes('error') || text.includes('broken') || text.includes('failing')) { + suggestedPriority = 'high'; + tags.push('auto-triaged:high'); + } else if (text.includes('slow') || text.includes('performance') || text.includes('timeout')) { + suggestedPriority = 'medium'; + tags.push('auto-triaged:medium'); + } else if (text.includes('feature') || text.includes('request') || text.includes('question')) { + suggestedPriority = 'low'; + tags.push('auto-triaged:low'); + } + + // Category detection + if (text.includes('auth') || text.includes('login') || text.includes('password')) { + tags.push('category:auth'); + } + if (text.includes('billing') || text.includes('payment') || text.includes('invoice')) { + tags.push('category:billing'); + } + if (text.includes('api') || text.includes('endpoint') || text.includes('webhook')) { + tags.push('category:api'); + } + + const dedupedTags = [...new Set(tags)]; + const updated = await repo.updateCase(id, access.productId, { + priority: suggestedPriority as SupportCaseDoc['priority'], + status: 'triaged', + tags: dedupedTags, + }); + + return { + triaged: true, + previousPriority: supportCase.priority, + newPriority: suggestedPriority, + tagsAdded: dedupedTags.filter(t => !supportCase.tags.includes(t)), + case: updated, + }; + }); + + // ── Case Metrics ──────────────────────────────────────── + + app.get('/support/metrics', async req => { + const access = requireAdmin(req); + const allCases = await repo.listAllCases(access.productId); + + const byStatus: Record = {}; + const byPriority: Record = {}; + const bySource: Record = {}; + let totalResolutionMs = 0; + let resolvedCount = 0; + let slaBreachCount = 0; + + for (const c of allCases) { + byStatus[c.status] = (byStatus[c.status] ?? 0) + 1; + byPriority[c.priority] = (byPriority[c.priority] ?? 0) + 1; + bySource[c.source] = (bySource[c.source] ?? 0) + 1; + + if (['resolved', 'closed'].includes(c.status)) { + const created = new Date(c.createdAt).getTime(); + const resolved = new Date(c.updatedAt).getTime(); + if (resolved > created) { + totalResolutionMs += resolved - created; + resolvedCount++; + + const target = SLA_TARGETS_HOURS[c.priority] ?? SLA_TARGETS_HOURS['medium']; + if (resolved - created > target.resolutionHours * 3600000) { + slaBreachCount++; + } + } + } + } + + const avgResolutionMs = resolvedCount > 0 ? Math.round(totalResolutionMs / resolvedCount) : 0; + const openCount = allCases.filter(c => !['resolved', 'closed'].includes(c.status)).length; + + return { + total: allCases.length, + open: openCount, + resolved: resolvedCount, + byStatus, + byPriority, + bySource, + avgResolutionHours: Math.round((avgResolutionMs / 3600000) * 10) / 10, + slaBreachCount, + slaComplianceRate: + resolvedCount > 0 + ? Math.round(((resolvedCount - slaBreachCount) / resolvedCount) * 100) + : 100, + }; + }); }