feat(rectification): anonymous aggregate telemetry + admin summary page

One row per rectification Case, written once when the range card is first
delivered (GET /api/rectification/cases/[caseId], fire-and-forget after the
response is built). Numbers and closed enums only: no user / case / session
id, birth data, names, text or timestamps finer than the ISO week. Dedupe via
a separate case_id ledger that cascades with the Case (and account deletion).

Migration 20260926010000 is additive: two RLS tables with no runtime table
grants, SECURITY DEFINER write (service_role), purge (service_role) and
aggregate-only summary (admin_runtime) functions; 180-day retention.

Admin: 「校正统计」 page + GET /api/admin/rectification-telemetry
(admin.customers.read), aggregates only, no per-row view or export.

TASK-rectification-telemetry-20260926. test:db not run locally (no Docker).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-26 15:36:29 +08:00
co-authored by Claude Opus 5.5
parent f74825a27c
commit d0bfc1fc3d
27 changed files with 2422 additions and 6 deletions
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { requirePermission } from "@/lib/admin/auth";
import { queryAdminRows } from "@/lib/admin/database";
import { adminErrorResponse } from "@/lib/admin/http";
import {
parseRectificationTelemetrySummary,
parseTelemetrySummaryWeeks,
} from "@/lib/admin/rectification-telemetry-summary";
export const runtime = "nodejs";
/**
* GET /api/admin/rectification-telemetry?weeks=4|12|26
*
* Aggregates only (TASK-rectification-telemetry-20260926, D3). The admin role
* has no table privilege on `rectification_telemetry`; the only read path is
* the SECURITY DEFINER summary function, which returns medians, distributions
* and a weekly trend. There is no per-row list and no export.
*/
export async function GET(request: Request) {
try {
await requirePermission("admin.customers.read");
const weeks = parseTelemetrySummaryWeeks(new URL(request.url).searchParams.get("weeks"));
const rows = await queryAdminRows<{ summary: unknown }>(
"select public.rectification_telemetry_summary($1::integer) as summary",
[weeks],
);
return NextResponse.json({ data: parseRectificationTelemetrySummary(rows[0]?.summary ?? null) });
} catch (error) {
return adminErrorResponse(error);
}
}
@@ -14,6 +14,7 @@ import {
safeToolErrorCode,
} from "@/lib/rectification-agentic/v9/tool-service";
import { dossierResponseWithIdentity } from "@/lib/rectification-agentic/v9/case-dossier-response";
import { scheduleRectificationTelemetry } from "@/lib/rectification-agentic/v9/telemetry";
export const runtime = "nodejs";
@@ -96,7 +97,22 @@ export async function GET(request: Request, context: RouteContext) {
}),
);
const listed = await listV10ConversationFocuses(accounting, user.id, caseId);
return NextResponse.json(await dossierResponseWithIdentity(dossier, receipts, skillIdentity, { fullDetail, listed }));
return NextResponse.json(await dossierResponseWithIdentity(dossier, receipts, skillIdentity, {
fullDetail,
listed,
// Anonymous aggregate telemetry: fire-and-forget, never awaited here.
onProjected: ({ decision, rangeDelivery, readOnly }) => {
void scheduleRectificationTelemetry({
accounting,
userId: user.id,
caseId,
readOnly,
build: () => listed.available
? { dossier, focuses: listed.focuses, decision, rangeDelivery, now: Date.now() }
: null,
});
},
}));
} catch (error) {
if (error instanceof RectificationToolServiceError) {
const message = error.message;