feat(platform): Phase 5 — Human Review Queue

- 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)
This commit is contained in:
saravanakumardb1 2026-03-20 03:33:55 -07:00
parent 9758192377
commit a060ee4496
3 changed files with 297 additions and 0 deletions

View File

@ -45,3 +45,25 @@ export async function update(
if (!updated) throw new NotFoundError(`Review '${id}' not found`); if (!updated) throw new NotFoundError(`Review '${id}' not found`);
return updated; return updated;
} }
export async function listExpired(productId: string, now: string): Promise<ReviewItemDoc[]> {
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<ReviewItemDoc[]> {
return reviewCollection().findMany({
filter: { productId },
sort: { createdAt: -1 },
limit,
});
}

View File

@ -6,6 +6,8 @@ const repoMock = {
create: vi.fn(), create: vi.fn(),
getById: vi.fn(), getById: vi.fn(),
update: vi.fn(), update: vi.fn(),
listExpired: vi.fn(),
listAll: vi.fn(),
}; };
const notifyMock = { 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);
});
}); });

View File

@ -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<string, number> = {};
const byPriority: Record<string, number> = {};
const byCategory: Record<string, number> = {};
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,
};
});
} }