From a060ee4496e216bf554ae948030e7f3e1f753c96 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 20 Mar 2026 03:33:55 -0700 Subject: [PATCH] =?UTF-8?q?feat(platform):=20Phase=205=20=E2=80=94=20Human?= =?UTF-8?q?=20Review=20Queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Batch decisions: POST /reviews/batch-decision (up to 50 items) - Parallel execution with allSettled, reports succeeded/failed counts - Delegation: POST /reviews/:id/delegate - Reassigns review with delegation metadata tracking - Triggers notification to new assignee - Auto-expiry: POST /reviews/expire - Scans pending/assigned reviews past dueAt, marks expired - Review stats: GET /reviews/stats - Aggregates by status/priority/category, avg resolution time - Computes pendingCount, overdueCount, avgResolutionHours - New repo functions: listExpired, listAll - 1,331 tests passing (7 new) --- .../src/modules/reviews/repository.ts | 22 +++ .../src/modules/reviews/routes.test.ts | 133 ++++++++++++++++ .../src/modules/reviews/routes.ts | 142 ++++++++++++++++++ 3 files changed, 297 insertions(+) diff --git a/services/platform-service/src/modules/reviews/repository.ts b/services/platform-service/src/modules/reviews/repository.ts index 82cbdd3e..c3ddb52b 100644 --- a/services/platform-service/src/modules/reviews/repository.ts +++ b/services/platform-service/src/modules/reviews/repository.ts @@ -45,3 +45,25 @@ export async function update( if (!updated) throw new NotFoundError(`Review '${id}' not found`); return updated; } + +export async function listExpired(productId: string, now: string): Promise { + const pending = await reviewCollection().findMany({ + filter: { productId, status: 'pending' }, + sort: { createdAt: -1 }, + limit: 500, + }); + const assigned = await reviewCollection().findMany({ + filter: { productId, status: 'assigned' }, + sort: { createdAt: -1 }, + limit: 500, + }); + return [...pending, ...assigned].filter(r => r.dueAt && r.dueAt < now); +} + +export async function listAll(productId: string, limit = 500): Promise { + return reviewCollection().findMany({ + filter: { productId }, + sort: { createdAt: -1 }, + limit, + }); +} diff --git a/services/platform-service/src/modules/reviews/routes.test.ts b/services/platform-service/src/modules/reviews/routes.test.ts index 10e8c15c..4677dbb3 100644 --- a/services/platform-service/src/modules/reviews/routes.test.ts +++ b/services/platform-service/src/modules/reviews/routes.test.ts @@ -6,6 +6,8 @@ const repoMock = { create: vi.fn(), getById: vi.fn(), update: vi.fn(), + listExpired: vi.fn(), + listAll: vi.fn(), }; const notifyMock = { @@ -85,4 +87,135 @@ describe('reviewRoutes', () => { }) ); }); + + // ── Batch Decisions ──────────────────────────────────── + + it('POST /reviews/batch-decision processes batch approvals', async () => { + repoMock.update.mockResolvedValue({ status: 'approved' }); + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + + const res = await app.inject({ + method: 'POST', + url: '/api/reviews/batch-decision', + payload: { ids: ['rev_1', 'rev_2'], decision: 'approved', reason: 'Batch OK' }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.succeeded).toBe(2); + expect(body.total).toBe(2); + }); + + it('POST /reviews/batch-decision rejects empty ids', async () => { + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + const res = await app.inject({ + method: 'POST', + url: '/api/reviews/batch-decision', + payload: { ids: [], decision: 'approved' }, + }); + expect(res.statusCode).toBe(400); + }); + + it('POST /reviews/batch-decision rejects > 50 items', async () => { + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + const ids = Array.from({ length: 51 }, (_, i) => `rev_${i}`); + const res = await app.inject({ + method: 'POST', + url: '/api/reviews/batch-decision', + payload: { ids, decision: 'approved' }, + }); + expect(res.statusCode).toBe(400); + }); + + // ── Delegation ───────────────────────────────────────── + + it('POST /reviews/:id/delegate reassigns and notifies', async () => { + repoMock.getById.mockResolvedValue({ id: 'rev_1', assignedTo: 'user_1', metadata: {} }); + repoMock.update.mockResolvedValue({ id: 'rev_1', assignedTo: 'user_2', status: 'assigned' }); + notifyMock.notifyReviewAssigned.mockResolvedValue(undefined); + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + + const res = await app.inject({ + method: 'POST', + url: '/api/reviews/rev_1/delegate', + payload: { delegateTo: 'user_2', reason: 'SME needed' }, + }); + + expect(res.statusCode).toBe(200); + expect(repoMock.update).toHaveBeenCalledWith( + 'rev_1', + 'lysnrai', + expect.objectContaining({ + assignedTo: 'user_2', + status: 'assigned', + }) + ); + expect(notifyMock.notifyReviewAssigned).toHaveBeenCalled(); + }); + + it('POST /reviews/:id/delegate requires delegateTo', async () => { + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + const res = await app.inject({ + method: 'POST', + url: '/api/reviews/rev_1/delegate', + payload: {}, + }); + expect(res.statusCode).toBe(400); + }); + + // ── Auto-Expiry ──────────────────────────────────────── + + it('POST /reviews/expire expires overdue reviews', async () => { + repoMock.listExpired.mockResolvedValue([ + { id: 'rev_1', dueAt: '2020-01-01' }, + { id: 'rev_2', dueAt: '2020-01-02' }, + ]); + repoMock.update.mockResolvedValue({ status: 'expired' }); + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + + const res = await app.inject({ method: 'POST', url: '/api/reviews/expire' }); + expect(res.statusCode).toBe(200); + expect(res.json().expired).toBe(2); + }); + + // ── Stats ───────────────────────────────────────────── + + it('GET /reviews/stats returns review queue metrics', async () => { + repoMock.listAll.mockResolvedValue([ + { + id: 'rev_1', + status: 'pending', + priority: 'high', + category: 'agent_action', + createdAt: '2026-03-15T00:00:00Z', + dueAt: '2020-01-01', + }, + { + id: 'rev_2', + status: 'approved', + priority: 'normal', + category: 'agent_action', + createdAt: '2026-03-14T00:00:00Z', + resolution: { actedAt: '2026-03-15T00:00:00Z' }, + }, + { + id: 'rev_3', + status: 'rejected', + priority: 'low', + category: 'model_change', + createdAt: '2026-03-13T00:00:00Z', + resolution: { actedAt: '2026-03-14T00:00:00Z' }, + }, + ]); + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + + const res = await app.inject({ method: 'GET', url: '/api/reviews/stats' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.total).toBe(3); + expect(body.byStatus.pending).toBe(1); + expect(body.byStatus.approved).toBe(1); + expect(body.pendingCount).toBe(1); + expect(body.overdueCount).toBe(1); + }); }); diff --git a/services/platform-service/src/modules/reviews/routes.ts b/services/platform-service/src/modules/reviews/routes.ts index d9cdfa1c..2373b3d0 100644 --- a/services/platform-service/src/modules/reviews/routes.ts +++ b/services/platform-service/src/modules/reviews/routes.ts @@ -125,4 +125,146 @@ export async function reviewRoutes(app: FastifyInstance) { }, }); }); + + // ── Batch Decisions ───────────────────────────────────── + + app.post('/reviews/batch-decision', async req => { + const access = requireAdmin(req); + const body = req.body as { + ids: string[]; + decision: 'approved' | 'rejected' | 'cancelled'; + reason?: string; + }; + + if (!body.ids || !Array.isArray(body.ids) || body.ids.length === 0) { + throw new BadRequestError('ids array is required'); + } + if (body.ids.length > 50) { + throw new BadRequestError('Maximum 50 items per batch'); + } + if (!body.decision || !['approved', 'rejected', 'cancelled'].includes(body.decision)) { + throw new BadRequestError('Valid decision is required'); + } + + const now = new Date().toISOString(); + const results = await Promise.allSettled( + body.ids.map(id => + repo.update(id, access.productId, { + status: body.decision, + resolution: { + decision: body.decision, + reason: body.reason, + actedBy: access.userId, + actedAt: now, + }, + }) + ) + ); + + const succeeded = results.filter(r => r.status === 'fulfilled').length; + const failed = results.filter(r => r.status === 'rejected').length; + + return { succeeded, failed, total: body.ids.length }; + }); + + // ── Delegation ────────────────────────────────────────── + + app.post('/reviews/:id/delegate', async req => { + const access = requireAdmin(req); + const { id } = req.params as { id: string }; + const body = req.body as { delegateTo: string; reason?: string }; + + if (!body.delegateTo) { + throw new BadRequestError('delegateTo is required'); + } + + const review = await repo.getById(id, access.productId); + const updated = await repo.update(id, access.productId, { + assignedTo: body.delegateTo, + status: 'assigned', + metadata: { + ...(review.metadata ?? {}), + delegatedFrom: review.assignedTo ?? access.userId, + delegationReason: body.reason, + delegatedAt: new Date().toISOString(), + }, + }); + + await notifyReviewAssigned(updated, req.log); + return updated; + }); + + // ── Auto-Expiry ───────────────────────────────────────── + + app.post('/reviews/expire', async req => { + const access = requireAdmin(req); + const now = new Date().toISOString(); + const expired = await repo.listExpired(access.productId, now); + + let expiredCount = 0; + for (const review of expired) { + try { + await repo.update(review.id, access.productId, { + status: 'expired', + resolution: { + decision: 'expired', + reason: 'Auto-expired: past due date', + actedBy: 'system', + actedAt: now, + }, + }); + expiredCount++; + } catch { + // best-effort + } + } + + return { expired: expiredCount, checked: expired.length }; + }); + + // ── Review Stats ──────────────────────────────────────── + + app.get('/reviews/stats', async req => { + const access = requireAdmin(req); + const allReviews = await repo.listAll(access.productId); + + const byStatus: Record = {}; + const byPriority: Record = {}; + const byCategory: Record = {}; + let totalDurationMs = 0; + let resolvedCount = 0; + + for (const review of allReviews) { + byStatus[review.status] = (byStatus[review.status] ?? 0) + 1; + byPriority[review.priority] = (byPriority[review.priority] ?? 0) + 1; + byCategory[review.category] = (byCategory[review.category] ?? 0) + 1; + + if (review.resolution?.actedAt) { + const created = new Date(review.createdAt).getTime(); + const resolved = new Date(review.resolution.actedAt).getTime(); + if (resolved > created) { + totalDurationMs += resolved - created; + resolvedCount++; + } + } + } + + const avgResolutionMs = resolvedCount > 0 ? Math.round(totalDurationMs / resolvedCount) : 0; + + return { + total: allReviews.length, + byStatus, + byPriority, + byCategory, + avgResolutionMs, + avgResolutionHours: Math.round((avgResolutionMs / 3600000) * 10) / 10, + pendingCount: (byStatus['pending'] ?? 0) + (byStatus['assigned'] ?? 0), + overdueCount: allReviews.filter( + r => + r.dueAt && + r.dueAt < new Date().toISOString() && + ['pending', 'assigned'].includes(r.status) + ).length, + }; + }); }