feat(platform+admin-web): implement 4 missing backend endpoints + re-enable frontend

Backend endpoints added:
- POST /delivery/logs/:id/retry — re-dispatches failed email deliveries (Q1)
- POST /reviews/:id/flag — flags review item with reason + admin metadata (Q2)
- DELETE /agent-evals/suites/:id — deletes evaluation suite with 204 response (Q3)

Frontend re-enabled:
- delivery: retry button for failed entries
- reviews: flag dropdown menu item
- agent-evals: delete dropdown menu item + Trash2 icon

Frontend fixed:
- webhooks: per-subscription delivery loading via GET /subscriptions/:id/deliveries (Q4)
This commit is contained in:
saravanakumardb1 2026-03-21 23:11:38 -07:00
parent 1935b39525
commit ead9457345
9 changed files with 126 additions and 28 deletions

View File

@ -8,6 +8,7 @@ import {
Plus,
MoreHorizontal,
Play,
Trash2,
CheckCircle2,
XCircle,
Clock,
@ -107,13 +108,11 @@ export default function AgentEvalsPage() {
loadData();
}
// TODO Q3: Backend has no DELETE /agent-evals/suites/:id endpoint.
// When suite deletion is implemented, uncomment this.
// async function handleDelete(id: string) {
// if (!confirm('Delete this evaluation suite?')) return;
// await apiFetch(`suites/${id}`, { method: 'DELETE' });
// loadData();
// }
async function handleDelete(id: string) {
if (!confirm('Delete this evaluation suite?')) return;
await apiFetch(`suites/${id}`, { method: 'DELETE' });
loadData();
}
const passedCount = suites.filter(s => s.lastRunStatus === 'passed').length;
const failedCount = suites.filter(s => s.lastRunStatus === 'failed').length;
@ -245,7 +244,13 @@ export default function AgentEvalsPage() {
<Play className="mr-2 h-4 w-4" />
Run
</DropdownMenuItem>
{/* TODO Q3: Delete disabled — no backend endpoint yet */}
<DropdownMenuItem
onClick={() => handleDelete(s.id)}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>

View File

@ -71,12 +71,10 @@ export default function DeliveryPage() {
void loadData();
}, [loadData]);
// TODO Q1: Backend has no retry endpoint for delivery log entries.
// When /delivery/logs/:id/retry is implemented, uncomment this.
// async function handleRetry(id: string) {
// await apiFetch(`logs/${id}/retry`, { method: 'POST' });
// loadData();
// }
async function handleRetry(id: string) {
await apiFetch(`logs/${id}/retry`, { method: 'POST' });
loadData();
}
const deliveredCount = entries.filter(e => e.status === 'delivered').length;
const failedCount = entries.filter(e => e.status === 'failed').length;
@ -191,7 +189,14 @@ export default function DeliveryPage() {
{formatDate(e.createdAt)}
</TableCell>
<TableCell>
{/* TODO Q1: Retry button disabled — backend has no retry endpoint yet */}
{e.status === 'failed' && (
<button
onClick={() => handleRetry(e.id)}
className="text-xs text-blue-600 hover:underline"
>
Retry
</button>
)}
</TableCell>
</TableRow>
);

View File

@ -88,12 +88,10 @@ export default function ReviewsPage() {
loadData();
}
// TODO Q2: Backend has no /reviews/:id/flag endpoint.
// When flagging is implemented, uncomment this.
// async function handleFlag(id: string) {
// await apiFetch(`${id}/flag`, { method: 'POST' });
// loadData();
// }
async function handleFlag(id: string) {
await apiFetch(`${id}/flag`, { method: 'POST' });
loadData();
}
const pendingCount = reviews.filter(r => r.status === 'pending').length;
const flaggedCount = reviews.filter(r => r.flagged).length;
@ -253,7 +251,9 @@ export default function ReviewsPage() {
Reject
</DropdownMenuItem>
)}
{/* TODO Q2: Flag button disabled — no backend endpoint yet */}
<DropdownMenuItem onClick={() => handleFlag(r.id)}>
<Flag className="mr-2 h-4 w-4" /> Flag
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>

View File

@ -74,12 +74,29 @@ export default function WebhooksPage() {
const loadData = useCallback(async () => {
setLoading(true);
const sData = await apiFetch('subscriptions');
setSubs(
Array.isArray(sData?.subscriptions) ? sData.subscriptions : Array.isArray(sData) ? sData : []
);
// TODO Q4: Backend has no top-level GET /webhooks/deliveries. Deliveries are per-subscription
// via GET /webhooks/subscriptions/:id/deliveries. Wire up per-subscription delivery loading.
setDeliveries([]);
const subsList: WebhookSub[] = Array.isArray(sData?.subscriptions)
? sData.subscriptions
: Array.isArray(sData)
? sData
: [];
setSubs(subsList);
// Load deliveries per-subscription (backend only has GET /webhooks/subscriptions/:id/deliveries)
const allDeliveries: Delivery[] = [];
for (const sub of subsList.slice(0, 10)) {
try {
const dData = await apiFetch(`subscriptions/${sub.id}/deliveries`);
const items: Delivery[] = Array.isArray(dData?.deliveries)
? dData.deliveries
: Array.isArray(dData)
? dData
: [];
allDeliveries.push(...items);
} catch {
// best-effort
}
}
setDeliveries(allDeliveries.sort((a, b) => b.createdAt.localeCompare(a.createdAt)));
setLoading(false);
}, []);

View File

@ -62,6 +62,10 @@ export async function updateSuite(
return updated;
}
export async function deleteSuite(id: string, productId: string): Promise<void> {
await suiteCollection().delete(id, productId);
}
export async function createCase(doc: EvaluationCaseDoc): Promise<EvaluationCaseDoc> {
return caseCollection().create(doc);
}

View File

@ -88,6 +88,14 @@ export async function agentEvalRoutes(app: FastifyInstance) {
return repo.updateSuite(id, access.productId, parsed.data);
});
app.delete('/agent-evals/suites/:id', async (req, reply) => {
const access = requireAdmin(req);
const { id } = req.params as { id: string };
await repo.getSuite(id, access.productId); // throws NotFoundError if missing
await repo.deleteSuite(id, access.productId);
return reply.code(204).send();
});
app.get('/agent-evals/suites/:id/cases', async req => {
requireAdmin(req);
const { id } = req.params as { id: string };

View File

@ -14,6 +14,10 @@ export async function updateDeliveryLog(doc: DeliveryLogDoc): Promise<DeliveryLo
return collection().upsert(doc);
}
export async function getDeliveryLog(id: string, pk: string): Promise<DeliveryLogDoc | null> {
return collection().findById(id, pk);
}
export async function listDeliveryLogs(
productId: string,
options?: { channel?: string; status?: string; limit?: number }

View File

@ -121,6 +121,40 @@ export async function deliveryRoutes(app: FastifyInstance) {
});
});
// Retry a failed delivery
app.post('/delivery/logs/:id/retry', async req => {
await extractAuth(req);
const { id } = req.params as { id: string };
const query = req.query as Record<string, string>;
const pk = query.pk || '';
// Look up the original log entry
const original = await repo.getDeliveryLog(id, pk);
if (!original) throw new BadRequestError(`Delivery log '${id}' not found`);
if (original.status !== 'failed') {
throw new BadRequestError(
`Can only retry failed deliveries (current status: ${original.status})`
);
}
// Re-dispatch based on channel
if (original.channel === 'email' && original.templateId) {
const result = await dispatchEmail(
{
to: original.to,
templateId: original.templateId,
variables: (original.metadata as Record<string, string>) ?? {},
productId: original.productId,
userId: original.userId,
},
req.log
);
return { success: result.success, messageId: result.messageId, error: result.error };
}
throw new BadRequestError(`Retry not supported for channel '${original.channel}'`);
});
// Get delivery stats
app.get('/delivery/stats', async req => {
await extractAuth(req);

View File

@ -167,6 +167,27 @@ export async function reviewRoutes(app: FastifyInstance) {
return { succeeded, failed, total: body.ids.length };
});
// ── Flag ────────────────────────────────────────────────
app.post('/reviews/:id/flag', async req => {
const access = requireAdmin(req);
const { id } = req.params as { id: string };
const body = req.body as { reason?: string };
const review = await repo.getById(id, access.productId);
const updated = await repo.update(id, access.productId, {
metadata: {
...(review.metadata ?? {}),
flagged: true,
flaggedBy: access.userId,
flagReason: body.reason ?? 'Flagged by admin',
flaggedAt: new Date().toISOString(),
},
});
return updated;
});
// ── Delegation ──────────────────────────────────────────
app.post('/reviews/:id/delegate', async req => {