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`);
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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