From e4bff5a2fe84bb92a047794229b7ada5c7061ddd Mon Sep 17 00:00:00 2001 From: root Date: Sun, 15 Mar 2026 09:39:20 +0000 Subject: [PATCH] feat(platform-service): add support case management foundation --- .../platform-service/src/lib/cosmos-init.ts | 4 + .../modules/support-cases/repository.test.ts | 57 +++++++ .../src/modules/support-cases/repository.ts | 86 +++++++++++ .../src/modules/support-cases/routes.test.ts | 110 ++++++++++++++ .../src/modules/support-cases/routes.ts | 143 ++++++++++++++++++ .../src/modules/support-cases/types.ts | 118 +++++++++++++++ services/platform-service/src/server.ts | 2 + 7 files changed, 520 insertions(+) create mode 100644 services/platform-service/src/modules/support-cases/repository.test.ts create mode 100644 services/platform-service/src/modules/support-cases/repository.ts create mode 100644 services/platform-service/src/modules/support-cases/routes.test.ts create mode 100644 services/platform-service/src/modules/support-cases/routes.ts create mode 100644 services/platform-service/src/modules/support-cases/types.ts diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts index 0b59f119..9dffd5a7 100644 --- a/services/platform-service/src/lib/cosmos-init.ts +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -93,6 +93,10 @@ const CONTAINER_DEFS: Record = { scim_user_sync: { partitionKeyPath: '/connectorId' }, scim_group_sync: { partitionKeyPath: '/connectorId' }, scim_events: { partitionKeyPath: '/connectorId', defaultTtl: 90 * 86400 }, + // Support case management + support_cases: { partitionKeyPath: '/productId' }, + support_case_notes: { partitionKeyPath: '/caseId' }, + support_case_escalations: { partitionKeyPath: '/caseId', defaultTtl: 90 * 86400 }, // Telemetry (client diagnostics — see docs/WINDSURF/CLIENT_TELEMETRY_DESIGN.md) telemetry_events: { partitionKeyPath: '/pk', defaultTtl: 30 * 86400 }, telemetry_error_clusters: { partitionKeyPath: '/pk', defaultTtl: 90 * 86400 }, diff --git a/services/platform-service/src/modules/support-cases/repository.test.ts b/services/platform-service/src/modules/support-cases/repository.test.ts new file mode 100644 index 00000000..a3c9110b --- /dev/null +++ b/services/platform-service/src/modules/support-cases/repository.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { MemoryDatastoreProvider } from '@bytelyst/datastore'; +import { _resetDatastoreProvider, setProvider } from '../../lib/datastore.js'; +import * as repo from './repository.js'; + +describe('support cases repository', () => { + beforeEach(() => { + setProvider(new MemoryDatastoreProvider()); + }); + + afterEach(() => { + _resetDatastoreProvider(); + }); + + it('stores support cases, notes, and escalations', async () => { + await repo.createCase({ + id: 'sup_1', + productId: 'lysnrai', + title: 'Agent escalated an incident', + status: 'open', + priority: 'high', + source: 'agent', + tags: ['incident'], + createdAt: '2026-03-15T00:00:00.000Z', + updatedAt: '2026-03-15T00:00:00.000Z', + }); + + await repo.createNote({ + id: 'sup_1:note:1', + caseId: 'sup_1', + productId: 'lysnrai', + authorType: 'user', + authorId: 'admin_1', + visibility: 'internal', + body: 'Initial triage completed.', + createdAt: '2026-03-15T00:01:00.000Z', + }); + + await repo.createEscalation({ + id: 'sup_1:esc:1', + caseId: 'sup_1', + productId: 'lysnrai', + escalatedTo: 'tier2', + reason: 'Customer impact confirmed', + triggeredBy: 'admin_1', + createdAt: '2026-03-15T00:02:00.000Z', + }); + + const cases = await repo.listCases('lysnrai', { limit: 20 }); + const notes = await repo.listNotes('sup_1'); + const escalations = await repo.listEscalations('sup_1'); + + expect(cases).toHaveLength(1); + expect(notes[0].body).toContain('triage'); + expect(escalations[0].escalatedTo).toBe('tier2'); + }); +}); diff --git a/services/platform-service/src/modules/support-cases/repository.ts b/services/platform-service/src/modules/support-cases/repository.ts new file mode 100644 index 00000000..ef98b5f3 --- /dev/null +++ b/services/platform-service/src/modules/support-cases/repository.ts @@ -0,0 +1,86 @@ +import { NotFoundError } from '../../lib/errors.js'; +import { getCollection } from '../../lib/datastore.js'; +import type { + ListSupportCasesQuery, + SupportCaseDoc, + SupportCaseNoteDoc, + SupportEscalationEventDoc, +} from './types.js'; + +function caseCollection() { + return getCollection('support_cases', '/productId'); +} + +function noteCollection() { + return getCollection('support_case_notes', '/caseId'); +} + +function escalationCollection() { + return getCollection('support_case_escalations', '/caseId'); +} + +export async function createCase(doc: SupportCaseDoc): Promise { + return caseCollection().create(doc); +} + +export async function listCases( + productId: string, + query: ListSupportCasesQuery +): Promise { + return caseCollection().findMany({ + filter: { + productId, + ...(query.status ? { status: query.status } : {}), + ...(query.priority ? { priority: query.priority } : {}), + ...(query.source ? { source: query.source } : {}), + ...(query.assignedTo ? { assignedTo: query.assignedTo } : {}), + }, + sort: { createdAt: -1 }, + limit: query.limit, + }); +} + +export async function getCase(id: string, productId: string): Promise { + const doc = await caseCollection().findById(id, productId); + if (!doc) throw new NotFoundError(`Support case '${id}' not found`); + return doc; +} + +export async function updateCase( + id: string, + productId: string, + updates: Partial +): Promise { + const updated = await caseCollection().update(id, productId, { + ...updates, + updatedAt: new Date().toISOString(), + }); + if (!updated) throw new NotFoundError(`Support case '${id}' not found`); + return updated; +} + +export async function createNote(doc: SupportCaseNoteDoc): Promise { + return noteCollection().create(doc); +} + +export async function listNotes(caseId: string): Promise { + return noteCollection().findMany({ + filter: { caseId }, + sort: { createdAt: 1 }, + limit: 500, + }); +} + +export async function createEscalation( + doc: SupportEscalationEventDoc +): Promise { + return escalationCollection().create(doc); +} + +export async function listEscalations(caseId: string): Promise { + return escalationCollection().findMany({ + filter: { caseId }, + sort: { createdAt: -1 }, + limit: 100, + }); +} diff --git a/services/platform-service/src/modules/support-cases/routes.test.ts b/services/platform-service/src/modules/support-cases/routes.test.ts new file mode 100644 index 00000000..eb7d295b --- /dev/null +++ b/services/platform-service/src/modules/support-cases/routes.test.ts @@ -0,0 +1,110 @@ +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const repoMock = { + listCases: vi.fn(), + createCase: vi.fn(), + getCase: vi.fn(), + updateCase: vi.fn(), + createNote: vi.fn(), + listNotes: vi.fn(), + createEscalation: vi.fn(), + listEscalations: vi.fn(), +}; + +vi.mock('./repository.js', () => repoMock); + +async function buildApp(payload?: { sub: string; productId: string; role?: string }) { + const { supportCaseRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + if (payload) { + app.addHook('onRequest', async req => { + req.jwtPayload = payload; + }); + } + await app.register(supportCaseRoutes, { prefix: '/api' }); + return app; +} + +describe('supportCaseRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('POST /support/cases creates an agent-linked case', async () => { + repoMock.createCase.mockResolvedValue({ 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', + payload: { + title: 'Incident escalation', + source: 'agent', + runId: 'run_1', + reviewId: 'rev_1', + }, + }); + + expect(res.statusCode).toBe(200); + expect(repoMock.createCase).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'agent', + status: 'triaged', + runId: 'run_1', + }) + ); + }); + + it('POST /support/cases/:id/notes adds a note to an existing case', async () => { + repoMock.getCase.mockResolvedValue({ id: 'sup_1', productId: 'lysnrai' }); + repoMock.createNote.mockResolvedValue({ id: 'sup_1:note: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/notes', + payload: { + authorId: 'admin_1', + body: 'Reached out to the customer.', + }, + }); + + expect(res.statusCode).toBe(200); + expect(repoMock.createNote).toHaveBeenCalledWith( + expect.objectContaining({ + caseId: 'sup_1', + authorId: 'admin_1', + }) + ); + }); + + it('POST /support/cases/:id/escalations updates the case and records the event', async () => { + repoMock.getCase.mockResolvedValue({ id: 'sup_1', productId: 'lysnrai' }); + repoMock.updateCase.mockResolvedValue({ id: 'sup_1', status: 'escalated' }); + repoMock.createEscalation.mockResolvedValue({ id: 'sup_1:esc: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/escalations', + payload: { + escalatedTo: 'tier2', + reason: 'Customer impact confirmed', + }, + }); + + expect(res.statusCode).toBe(200); + expect(repoMock.updateCase).toHaveBeenCalledWith('sup_1', 'lysnrai', { status: 'escalated' }); + expect(repoMock.createEscalation).toHaveBeenCalledWith( + expect.objectContaining({ + caseId: 'sup_1', + escalatedTo: 'tier2', + }) + ); + }); +}); diff --git a/services/platform-service/src/modules/support-cases/routes.ts b/services/platform-service/src/modules/support-cases/routes.ts new file mode 100644 index 00000000..ccb4cc2b --- /dev/null +++ b/services/platform-service/src/modules/support-cases/routes.ts @@ -0,0 +1,143 @@ +import { randomUUID } from 'node:crypto'; +import type { FastifyInstance } from 'fastify'; +import { BadRequestError, ForbiddenError } from '../../lib/errors.js'; +import { + CreateSupportCaseSchema, + CreateSupportEscalationSchema, + CreateSupportNoteSchema, + ListSupportCasesQuerySchema, + SupportCaseDoc, + SupportCaseNoteDoc, + SupportEscalationEventDoc, + UpdateSupportCaseSchema, +} from './types.js'; +import * as repo from './repository.js'; + +function requireAdmin(req: { jwtPayload?: { sub?: string; role?: string; productId?: string } }): { + userId: string; + productId: string; +} { + const payload = req.jwtPayload; + if (!payload?.sub) throw new ForbiddenError('Authentication required'); + if (!payload.role || !['super_admin', 'admin'].includes(payload.role)) { + throw new ForbiddenError('Admin access required'); + } + return { + userId: payload.sub, + productId: payload.productId ?? process.env.DEFAULT_PRODUCT_ID ?? 'lysnrai', + }; +} + +function validationError(message: string): never { + throw new BadRequestError(message); +} + +export async function supportCaseRoutes(app: FastifyInstance) { + app.get('/support/cases', async req => { + const access = requireAdmin(req); + const parsed = ListSupportCasesQuerySchema.safeParse(req.query); + if (!parsed.success) { + validationError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + return repo.listCases(access.productId, parsed.data); + }); + + app.post('/support/cases', async req => { + const access = requireAdmin(req); + const parsed = CreateSupportCaseSchema.safeParse(req.body); + if (!parsed.success) { + validationError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + const now = new Date().toISOString(); + const doc: SupportCaseDoc = { + id: `sup_${randomUUID()}`, + productId: access.productId, + orgId: parsed.data.orgId, + workspaceId: parsed.data.workspaceId, + requesterUserId: parsed.data.requesterUserId, + assignedTo: parsed.data.assignedTo, + title: parsed.data.title, + description: parsed.data.description, + status: parsed.data.source === 'agent' ? 'triaged' : 'open', + priority: parsed.data.priority, + source: parsed.data.source, + runId: parsed.data.runId, + reviewId: parsed.data.reviewId, + knowledgeBaseId: parsed.data.knowledgeBaseId, + tags: parsed.data.tags, + createdAt: now, + updatedAt: now, + }; + return repo.createCase(doc); + }); + + app.get('/support/cases/:id', async req => { + const access = requireAdmin(req); + const { id } = req.params as { id: string }; + return repo.getCase(id, access.productId); + }); + + app.patch('/support/cases/:id', async req => { + const access = requireAdmin(req); + const { id } = req.params as { id: string }; + const parsed = UpdateSupportCaseSchema.safeParse(req.body); + if (!parsed.success) { + validationError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + return repo.updateCase(id, access.productId, parsed.data); + }); + + app.get('/support/cases/:id/notes', async req => { + requireAdmin(req); + const { id } = req.params as { id: string }; + return repo.listNotes(id); + }); + + app.post('/support/cases/:id/notes', async req => { + const access = requireAdmin(req); + const { id } = req.params as { id: string }; + await repo.getCase(id, access.productId); + const parsed = CreateSupportNoteSchema.safeParse(req.body); + if (!parsed.success) { + validationError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + const doc: SupportCaseNoteDoc = { + id: `${id}:note:${randomUUID()}`, + caseId: id, + productId: access.productId, + authorType: parsed.data.authorType, + authorId: parsed.data.authorId, + visibility: parsed.data.visibility, + body: parsed.data.body, + createdAt: new Date().toISOString(), + }; + return repo.createNote(doc); + }); + + app.get('/support/cases/:id/escalations', async req => { + requireAdmin(req); + const { id } = req.params as { id: string }; + return repo.listEscalations(id); + }); + + app.post('/support/cases/:id/escalations', async req => { + const access = requireAdmin(req); + const { id } = req.params as { id: string }; + await repo.getCase(id, access.productId); + const parsed = CreateSupportEscalationSchema.safeParse(req.body); + if (!parsed.success) { + validationError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + const event: SupportEscalationEventDoc = { + id: `${id}:esc:${randomUUID()}`, + caseId: id, + productId: access.productId, + escalatedTo: parsed.data.escalatedTo, + reason: parsed.data.reason, + triggeredBy: access.userId, + createdAt: new Date().toISOString(), + }; + await repo.updateCase(id, access.productId, { status: 'escalated' }); + return repo.createEscalation(event); + }); +} diff --git a/services/platform-service/src/modules/support-cases/types.ts b/services/platform-service/src/modules/support-cases/types.ts new file mode 100644 index 00000000..5d4d6426 --- /dev/null +++ b/services/platform-service/src/modules/support-cases/types.ts @@ -0,0 +1,118 @@ +import { z } from 'zod'; + +export const SupportCaseStatusSchema = z.enum([ + 'open', + 'triaged', + 'in_progress', + 'waiting_customer', + 'resolved', + 'closed', + 'escalated', +]); +export const SupportCasePrioritySchema = z.enum(['critical', 'high', 'medium', 'low']); +export const SupportCaseSourceSchema = z.enum(['manual', 'agent', 'telemetry', 'customer']); +export const SupportNoteVisibilitySchema = z.enum(['internal', 'customer']); +export const SupportAuthorTypeSchema = z.enum(['user', 'agent', 'system']); + +export const SupportCaseSchema = z.object({ + id: z.string().min(1), + productId: z.string().min(1), + orgId: z.string().optional(), + workspaceId: z.string().optional(), + requesterUserId: z.string().optional(), + assignedTo: z.string().optional(), + title: z.string().min(1), + description: z.string().optional(), + status: SupportCaseStatusSchema, + priority: SupportCasePrioritySchema, + source: SupportCaseSourceSchema, + runId: z.string().optional(), + reviewId: z.string().optional(), + knowledgeBaseId: z.string().optional(), + tags: z.array(z.string()).default([]), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export type SupportCaseDoc = z.infer & { + _ts?: number; + _etag?: string; +}; + +export const SupportCaseNoteSchema = z.object({ + id: z.string().min(1), + caseId: z.string().min(1), + productId: z.string().min(1), + authorType: SupportAuthorTypeSchema, + authorId: z.string().min(1), + visibility: SupportNoteVisibilitySchema, + body: z.string().min(1), + createdAt: z.string(), +}); + +export type SupportCaseNoteDoc = z.infer & { + _ts?: number; + _etag?: string; +}; + +export const SupportEscalationEventSchema = z.object({ + id: z.string().min(1), + caseId: z.string().min(1), + productId: z.string().min(1), + escalatedTo: z.string().min(1), + reason: z.string().min(1), + triggeredBy: z.string().min(1), + createdAt: z.string(), +}); + +export type SupportEscalationEventDoc = z.infer & { + _ts?: number; + _etag?: string; +}; + +export const CreateSupportCaseSchema = z.object({ + orgId: z.string().optional(), + workspaceId: z.string().optional(), + requesterUserId: z.string().optional(), + assignedTo: z.string().optional(), + title: z.string().min(1), + description: z.string().optional(), + priority: SupportCasePrioritySchema.default('medium'), + source: SupportCaseSourceSchema.default('manual'), + runId: z.string().optional(), + reviewId: z.string().optional(), + knowledgeBaseId: z.string().optional(), + tags: z.array(z.string()).default([]), +}); + +export const UpdateSupportCaseSchema = z.object({ + assignedTo: z.string().optional(), + title: z.string().min(1).optional(), + description: z.string().optional(), + status: SupportCaseStatusSchema.optional(), + priority: SupportCasePrioritySchema.optional(), + knowledgeBaseId: z.string().optional(), + tags: z.array(z.string()).optional(), +}); + +export const CreateSupportNoteSchema = z.object({ + authorType: SupportAuthorTypeSchema.default('user'), + authorId: z.string().min(1), + visibility: SupportNoteVisibilitySchema.default('internal'), + body: z.string().min(1), +}); + +export const CreateSupportEscalationSchema = z.object({ + escalatedTo: z.string().min(1), + reason: z.string().min(1), +}); + +export const ListSupportCasesQuerySchema = z.object({ + status: SupportCaseStatusSchema.optional(), + priority: SupportCasePrioritySchema.optional(), + source: SupportCaseSourceSchema.optional(), + assignedTo: z.string().optional(), + limit: z.coerce.number().min(1).max(100).default(20), +}); + +export type ListSupportCasesQuery = z.infer; diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 30f7ce6d..88bab011 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -40,6 +40,7 @@ import { agentEvalRoutes } from './modules/agent-evals/routes.js'; import { aiBudgetRoutes } from './modules/ai-budgets/routes.js'; import { knowledgeRoutes } from './modules/knowledge/routes.js'; import { scimRoutes } from './modules/scim/routes.js'; +import { supportCaseRoutes } from './modules/support-cases/routes.js'; import { notificationRoutes } from './modules/notifications/routes.js'; import { flagRoutes } from './modules/flags/routes.js'; import { rateLimitRoutes } from './modules/ratelimit/routes.js'; @@ -149,6 +150,7 @@ await app.register(agentEvalRoutes, { prefix: '/api' }); await app.register(aiBudgetRoutes, { prefix: '/api' }); await app.register(knowledgeRoutes, { prefix: '/api' }); await app.register(scimRoutes, { prefix: '/api' }); +await app.register(supportCaseRoutes, { prefix: '/api' }); await app.register(notificationRoutes, { prefix: '/api' }); await app.register(flagRoutes, { prefix: '/api' }); await app.register(rateLimitRoutes, { prefix: '/api' });