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:
parent
a060ee4496
commit
0bbae1f14e
@ -84,3 +84,11 @@ export async function listEscalations(caseId: string): Promise<SupportEscalation
|
||||
limit: 100,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAllCases(productId: string, limit = 500): Promise<SupportCaseDoc[]> {
|
||||
return caseCollection().findMany({
|
||||
filter: { productId },
|
||||
sort: { createdAt: -1 },
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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<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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user