feat(sase): VIN list management view

A user-list-style table at /projects/sase/vin-decode/vins for inspecting
and managing decoded VINs. Pairs with sase.tr#34 (cache-clear + delete
endpoints).

Repo (lib/sase/vin-list.ts)
- listVinDecodes(filter, sort, page) — raw SQL with dynamic WHERE for
  search (VIN/email), provider IN, success boolean, brand IDs, date
  range. Joins users + brands, computes EXISTS(vehicles) per row so the
  action menu knows whether DB delete is meaningful.
- KNOWN_PROVIDERS export.

Admin SDK
- clearVinCache({ vin, reason, founderId }) — POST cache-clear
- deleteVehicleByVin({ vin, reason, founderId }) — DELETE vehicle
- Result types: VinCacheClearResult, VinDeleteResult.
- SASE_ADMIN_ENDPOINTS list updated.

Panel routes
- POST /api/sase/vins/[vin]/cache-clear — auth + spoke-wired + reason
  ≥ 5 chars. Audit on both paths.
- DELETE /api/sase/vins/[vin] — same shape.

UI (/projects/sase/vin-decode/vins)
- URL-driven filters (VIN/email search, provider multi-select pills,
  success/fail toggle) — sharable links.
- Sortable columns: Tarih (createdAt) and RT (responseTimeMs).
- Per-row cells: timestamp · VIN (mono) · brand slug+name · user email
  (link to user detail) · provider badge (link to provider drill-down)
  · ok/fail badge (fail title = errorMessage) · responseTimeMs ·
  cacheSource from timings jsonb · action menu.
- Per-row action menu (gated on saseAdminWired):
    [Cache] — opens reason modal, POSTs cache-clear
    [DB]    — only when vehicles row exists; opens reason modal,
              destructive variant, DELETE
- Pager with prefix/postfix range + ←/→ links preserving filters.

Dashboard header gets a "VIN list →" pill next to Business/Trends.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-19 07:59:22 +03:00
parent 230ef63433
commit 39c26bd4d4
10 changed files with 934 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { createSaseAdmin, saseAdminWired } from "@/lib/admin-sdk/sase";
import { writeAudit } from "@/lib/audit";
export const dynamic = "force-dynamic";
export async function POST(
req: Request,
ctx: { params: Promise<{ vin: string }> },
) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
}
if (!saseAdminWired()) {
return NextResponse.json({ ok: false, error: "spoke_not_wired" }, { status: 503 });
}
const { vin } = await ctx.params;
const body = (await req.json().catch(() => ({}))) as { reason?: string };
const reason = (body.reason ?? "").trim();
if (reason.length < 5) {
return NextResponse.json({ ok: false, error: "reason_required" }, { status: 400 });
}
const endpoint = `/api/sase/vins/${vin}/cache-clear`;
try {
const sdk = createSaseAdmin();
const result = await sdk.clearVinCache({
vin,
reason,
founderId: session.user.id,
});
await writeAudit({
projectKey: "sase",
endpoint,
method: "POST",
requestPayload: { vin, reasonLen: reason.length },
responseStatus: 200,
});
return NextResponse.json({ ok: true, result });
} catch (err) {
const status = (err as { status?: number } | undefined)?.status ?? 500;
const message = err instanceof Error ? err.message : "unknown";
await writeAudit({
projectKey: "sase",
endpoint,
method: "POST",
requestPayload: { vin, reasonLen: reason.length },
responseStatus: status,
});
return NextResponse.json({ ok: false, error: message }, { status });
}
}

View File

@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { createSaseAdmin, saseAdminWired } from "@/lib/admin-sdk/sase";
import { writeAudit } from "@/lib/audit";
export const dynamic = "force-dynamic";
// Hard-delete the shared vehicle row for this VIN.
export async function DELETE(
req: Request,
ctx: { params: Promise<{ vin: string }> },
) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
}
if (!saseAdminWired()) {
return NextResponse.json({ ok: false, error: "spoke_not_wired" }, { status: 503 });
}
const { vin } = await ctx.params;
const body = (await req.json().catch(() => ({}))) as { reason?: string };
const reason = (body.reason ?? "").trim();
if (reason.length < 5) {
return NextResponse.json({ ok: false, error: "reason_required" }, { status: 400 });
}
const endpoint = `/api/sase/vins/${vin}`;
try {
const sdk = createSaseAdmin();
const result = await sdk.deleteVehicleByVin({
vin,
reason,
founderId: session.user.id,
});
await writeAudit({
projectKey: "sase",
endpoint,
method: "DELETE",
requestPayload: { vin, reasonLen: reason.length },
responseStatus: 200,
});
return NextResponse.json({ ok: true, result });
} catch (err) {
const status = (err as { status?: number } | undefined)?.status ?? 500;
const message = err instanceof Error ? err.message : "unknown";
await writeAudit({
projectKey: "sase",
endpoint,
method: "DELETE",
requestPayload: { vin, reasonLen: reason.length },
responseStatus: status,
});
return NextResponse.json({ ok: false, error: message }, { status });
}
}