feat(platform): Phase 6 — Support Case Management

- 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)
This commit is contained in:
saravanakumardb1 2026-03-20 03:38:08 -07:00
parent a060ee4496
commit 0bbae1f14e
3 changed files with 375 additions and 0 deletions

View File

@ -84,3 +84,11 @@ export async function listEscalations(caseId: string): Promise<SupportEscalation
limit: 100, limit: 100,
}); });
} }
export async function listAllCases(productId: string, limit = 500): Promise<SupportCaseDoc[]> {
return caseCollection().findMany({
filter: { productId },
sort: { createdAt: -1 },
limit,
});
}

View File

@ -10,6 +10,7 @@ const repoMock = {
listNotes: vi.fn(), listNotes: vi.fn(),
createEscalation: vi.fn(), createEscalation: vi.fn(),
listEscalations: vi.fn(), listEscalations: vi.fn(),
listAllCases: vi.fn(),
}; };
vi.mock('./repository.js', () => repoMock); 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<string, unknown>) => ({
...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<string, unknown>) => ({
...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);
});
}); });

View File

@ -140,4 +140,213 @@ export async function supportCaseRoutes(app: FastifyInstance) {
await repo.updateCase(id, access.productId, { status: 'escalated' }); await repo.updateCase(id, access.productId, { status: 'escalated' });
return repo.createEscalation(event); 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<string, unknown>;
}> = [];
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<string, { responseHours: number; resolutionHours: number }> = {
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<string, number> = {};
const byPriority: Record<string, number> = {};
const bySource: Record<string, number> = {};
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,
};
});
} }