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:
parent
1935b39525
commit
ead9457345
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user