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:
parent
9758192377
commit
a060ee4496
@ -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<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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user