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,
|
Plus,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Play,
|
Play,
|
||||||
|
Trash2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
Clock,
|
Clock,
|
||||||
@ -107,13 +108,11 @@ export default function AgentEvalsPage() {
|
|||||||
loadData();
|
loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO Q3: Backend has no DELETE /agent-evals/suites/:id endpoint.
|
async function handleDelete(id: string) {
|
||||||
// When suite deletion is implemented, uncomment this.
|
if (!confirm('Delete this evaluation suite?')) return;
|
||||||
// async function handleDelete(id: string) {
|
await apiFetch(`suites/${id}`, { method: 'DELETE' });
|
||||||
// if (!confirm('Delete this evaluation suite?')) return;
|
loadData();
|
||||||
// await apiFetch(`suites/${id}`, { method: 'DELETE' });
|
}
|
||||||
// loadData();
|
|
||||||
// }
|
|
||||||
|
|
||||||
const passedCount = suites.filter(s => s.lastRunStatus === 'passed').length;
|
const passedCount = suites.filter(s => s.lastRunStatus === 'passed').length;
|
||||||
const failedCount = suites.filter(s => s.lastRunStatus === 'failed').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" />
|
<Play className="mr-2 h-4 w-4" />
|
||||||
Run
|
Run
|
||||||
</DropdownMenuItem>
|
</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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@ -71,12 +71,10 @@ export default function DeliveryPage() {
|
|||||||
void loadData();
|
void loadData();
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
// TODO Q1: Backend has no retry endpoint for delivery log entries.
|
async function handleRetry(id: string) {
|
||||||
// When /delivery/logs/:id/retry is implemented, uncomment this.
|
await apiFetch(`logs/${id}/retry`, { method: 'POST' });
|
||||||
// async function handleRetry(id: string) {
|
loadData();
|
||||||
// await apiFetch(`logs/${id}/retry`, { method: 'POST' });
|
}
|
||||||
// loadData();
|
|
||||||
// }
|
|
||||||
|
|
||||||
const deliveredCount = entries.filter(e => e.status === 'delivered').length;
|
const deliveredCount = entries.filter(e => e.status === 'delivered').length;
|
||||||
const failedCount = entries.filter(e => e.status === 'failed').length;
|
const failedCount = entries.filter(e => e.status === 'failed').length;
|
||||||
@ -191,7 +189,14 @@ export default function DeliveryPage() {
|
|||||||
{formatDate(e.createdAt)}
|
{formatDate(e.createdAt)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -88,12 +88,10 @@ export default function ReviewsPage() {
|
|||||||
loadData();
|
loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO Q2: Backend has no /reviews/:id/flag endpoint.
|
async function handleFlag(id: string) {
|
||||||
// When flagging is implemented, uncomment this.
|
await apiFetch(`${id}/flag`, { method: 'POST' });
|
||||||
// async function handleFlag(id: string) {
|
loadData();
|
||||||
// await apiFetch(`${id}/flag`, { method: 'POST' });
|
}
|
||||||
// loadData();
|
|
||||||
// }
|
|
||||||
|
|
||||||
const pendingCount = reviews.filter(r => r.status === 'pending').length;
|
const pendingCount = reviews.filter(r => r.status === 'pending').length;
|
||||||
const flaggedCount = reviews.filter(r => r.flagged).length;
|
const flaggedCount = reviews.filter(r => r.flagged).length;
|
||||||
@ -253,7 +251,9 @@ export default function ReviewsPage() {
|
|||||||
Reject
|
Reject
|
||||||
</DropdownMenuItem>
|
</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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@ -74,12 +74,29 @@ export default function WebhooksPage() {
|
|||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const sData = await apiFetch('subscriptions');
|
const sData = await apiFetch('subscriptions');
|
||||||
setSubs(
|
const subsList: WebhookSub[] = Array.isArray(sData?.subscriptions)
|
||||||
Array.isArray(sData?.subscriptions) ? sData.subscriptions : Array.isArray(sData) ? sData : []
|
? sData.subscriptions
|
||||||
);
|
: Array.isArray(sData)
|
||||||
// TODO Q4: Backend has no top-level GET /webhooks/deliveries. Deliveries are per-subscription
|
? sData
|
||||||
// via GET /webhooks/subscriptions/:id/deliveries. Wire up per-subscription delivery loading.
|
: [];
|
||||||
setDeliveries([]);
|
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);
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -62,6 +62,10 @@ export async function updateSuite(
|
|||||||
return updated;
|
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> {
|
export async function createCase(doc: EvaluationCaseDoc): Promise<EvaluationCaseDoc> {
|
||||||
return caseCollection().create(doc);
|
return caseCollection().create(doc);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -88,6 +88,14 @@ export async function agentEvalRoutes(app: FastifyInstance) {
|
|||||||
return repo.updateSuite(id, access.productId, parsed.data);
|
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 => {
|
app.get('/agent-evals/suites/:id/cases', async req => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
|
|||||||
@ -14,6 +14,10 @@ export async function updateDeliveryLog(doc: DeliveryLogDoc): Promise<DeliveryLo
|
|||||||
return collection().upsert(doc);
|
return collection().upsert(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getDeliveryLog(id: string, pk: string): Promise<DeliveryLogDoc | null> {
|
||||||
|
return collection().findById(id, pk);
|
||||||
|
}
|
||||||
|
|
||||||
export async function listDeliveryLogs(
|
export async function listDeliveryLogs(
|
||||||
productId: string,
|
productId: string,
|
||||||
options?: { channel?: string; status?: string; limit?: number }
|
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
|
// Get delivery stats
|
||||||
app.get('/delivery/stats', async req => {
|
app.get('/delivery/stats', async req => {
|
||||||
await extractAuth(req);
|
await extractAuth(req);
|
||||||
|
|||||||
@ -167,6 +167,27 @@ export async function reviewRoutes(app: FastifyInstance) {
|
|||||||
return { succeeded, failed, total: body.ids.length };
|
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 ──────────────────────────────────────────
|
// ── Delegation ──────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/reviews/:id/delegate', async req => {
|
app.post('/reviews/:id/delegate', async req => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user