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:
56
apps/web/src/app/api/sase/vins/[vin]/cache-clear/route.ts
Normal file
56
apps/web/src/app/api/sase/vins/[vin]/cache-clear/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
57
apps/web/src/app/api/sase/vins/[vin]/route.ts
Normal file
57
apps/web/src/app/api/sase/vins/[vin]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,12 @@ export default async function VinDecodePage({
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href="/projects/sase/vin-decode/vins"
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
>
|
||||
VIN list →
|
||||
</Link>
|
||||
<Link
|
||||
href="/projects/sase/vin-decode/business"
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
|
||||
161
apps/web/src/app/projects/sase/vin-decode/vins/_actions.tsx
Normal file
161
apps/web/src/app/projects/sase/vin-decode/vins/_actions.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type Action = "cache-clear" | "delete";
|
||||
|
||||
export function VinActions({
|
||||
vin,
|
||||
hasVehicleRow,
|
||||
}: {
|
||||
vin: string;
|
||||
hasVehicleRow: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState<Action | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function close() {
|
||||
setOpen(null);
|
||||
setReason("");
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!open) return;
|
||||
setError(null);
|
||||
if (reason.trim().length < 5) {
|
||||
setError("Sebep en az 5 karakter olmalı.");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const url =
|
||||
open === "cache-clear"
|
||||
? `/api/sase/vins/${encodeURIComponent(vin)}/cache-clear`
|
||||
: `/api/sase/vins/${encodeURIComponent(vin)}`;
|
||||
const res = await fetch(url, {
|
||||
method: open === "cache-clear" ? "POST" : "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: reason.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
setError(data.error ?? `HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as {
|
||||
result?: { clearedKeys?: string[]; cascadedUserLinks?: number };
|
||||
};
|
||||
if (open === "cache-clear") {
|
||||
setInfo(
|
||||
`Temizlendi (${data.result?.clearedKeys?.length ?? 0} key).`,
|
||||
);
|
||||
} else {
|
||||
setInfo(
|
||||
`Silindi (${data.result?.cascadedUserLinks ?? 0} user link cascade).`,
|
||||
);
|
||||
}
|
||||
setReason("");
|
||||
setTimeout(() => {
|
||||
close();
|
||||
router.refresh();
|
||||
}, 800);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => setOpen("cache-clear")}
|
||||
title="Redis cache (vin:resolve / vin:resolve:neg / vin:lock) sil"
|
||||
>
|
||||
Cache
|
||||
</Button>
|
||||
{hasVehicleRow && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => setOpen("delete")}
|
||||
title="vehicles tablosundan sil (cascade user_vehicles)"
|
||||
>
|
||||
DB
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={open !== null} onOpenChange={(v) => !v && close()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{open === "cache-clear"
|
||||
? "Redis cache temizle"
|
||||
: "Vehicle kaydını sil"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
VIN: <code className="font-mono">{vin}</code>
|
||||
{open === "cache-clear" ? (
|
||||
<>
|
||||
<br />
|
||||
Sase.tr'deki <code>vin:resolve:*</code> ve{" "}
|
||||
<code>vin:lock:</code> Redis key'leri silinir. Sonraki decode
|
||||
isteğinde upstream provider'lardan yeniden çekilir.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<br />
|
||||
<code>vehicles</code> tablosundan satır silinir;{" "}
|
||||
<code>user_vehicles</code> cascade ile temizlenir.{" "}
|
||||
<code>query_logs</code> dokunulmaz (audit). Redis cache de
|
||||
temizlenir.
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Sebep (zorunlu, audit'e geçer)"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{info && <p className="text-sm text-emerald-600">{info}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={close} disabled={pending}>
|
||||
İptal
|
||||
</Button>
|
||||
<Button
|
||||
variant={open === "delete" ? "destructive" : "default"}
|
||||
onClick={submit}
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? "..." : open === "cache-clear" ? "Cache temizle" : "DB'den sil"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
117
apps/web/src/app/projects/sase/vin-decode/vins/_filter-bar.tsx
Normal file
117
apps/web/src/app/projects/sase/vin-decode/vins/_filter-bar.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buildHref, toggleArray, type VinsSearchParams } from "./_query";
|
||||
|
||||
const PROVIDERS = [
|
||||
"cache",
|
||||
"parts-catalogs",
|
||||
"pl24",
|
||||
"emex",
|
||||
"vin-api",
|
||||
"corgi",
|
||||
"none",
|
||||
"aborted",
|
||||
];
|
||||
|
||||
export function VinsFilterBar({ initial }: { initial: VinsSearchParams }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const sp = useSearchParams();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const current: VinsSearchParams = {
|
||||
q: sp.get("q") ?? undefined,
|
||||
provider: sp.getAll("provider").length ? sp.getAll("provider") : undefined,
|
||||
success: sp.get("success") ?? undefined,
|
||||
from: sp.get("from") ?? undefined,
|
||||
to: sp.get("to") ?? undefined,
|
||||
sort: sp.get("sort") ?? undefined,
|
||||
};
|
||||
|
||||
function nav(patch: Partial<VinsSearchParams>) {
|
||||
const href = `${pathname}${buildHref(current, { page: undefined, ...patch })}`;
|
||||
startTransition(() => router.push(href));
|
||||
}
|
||||
|
||||
function isProviderActive(p: string) {
|
||||
const v = current.provider;
|
||||
if (!v) return false;
|
||||
return Array.isArray(v) ? v.includes(p) : v === p;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<form
|
||||
action={(fd) => {
|
||||
const q = String(fd.get("q") ?? "").trim();
|
||||
nav({ q: q || undefined });
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Input
|
||||
name="q"
|
||||
defaultValue={initial.q ?? ""}
|
||||
placeholder="VIN veya email ile ara…"
|
||||
className="max-w-md"
|
||||
/>
|
||||
<Button type="submit" disabled={pending} size="sm">
|
||||
Ara
|
||||
</Button>
|
||||
{Object.values(current).some((v) => v !== undefined && v !== "") && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push(pathname)}
|
||||
>
|
||||
Temizle
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-xs text-muted-foreground">Provider:</span>
|
||||
{PROVIDERS.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => nav({ provider: toggleArray(current.provider, p) })}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Badge
|
||||
variant={isProviderActive(p) ? "default" : "outline"}
|
||||
className="font-mono"
|
||||
>
|
||||
{p}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-xs text-muted-foreground">Durum:</span>
|
||||
{[
|
||||
{ v: "true", label: "Başarılı" },
|
||||
{ v: "false", label: "Hata" },
|
||||
].map((s) => (
|
||||
<button
|
||||
key={s.v}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
nav({ success: current.success === s.v ? undefined : s.v })
|
||||
}
|
||||
>
|
||||
<Badge variant={current.success === s.v ? "default" : "outline"}>
|
||||
{s.label}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
apps/web/src/app/projects/sase/vin-decode/vins/_pager.tsx
Normal file
64
apps/web/src/app/projects/sase/vin-decode/vins/_pager.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams, usePathname } from "next/navigation";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { buildHref, type VinsSearchParams } from "./_query";
|
||||
|
||||
export function Pager({
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
}: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}) {
|
||||
const sp = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const current: VinsSearchParams = Object.fromEntries(sp.entries()) as VinsSearchParams;
|
||||
const providerAll = sp.getAll("provider");
|
||||
if (providerAll.length > 1) current.provider = providerAll;
|
||||
|
||||
const lastPage = Math.max(0, Math.ceil(total / pageSize) - 1);
|
||||
const prevPage = Math.max(0, page - 1);
|
||||
const nextPage = Math.min(lastPage, page + 1);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{total === 0
|
||||
? "Kayıt yok"
|
||||
: `${page * pageSize + 1}–${Math.min(total, (page + 1) * pageSize)} / ${total}`}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{page === 0 ? (
|
||||
<span className={buttonVariants({ variant: "outline", size: "sm" }) + " pointer-events-none opacity-50"}>
|
||||
← Önceki
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href={`${pathname}${buildHref(current, { page: prevPage === 0 ? undefined : String(prevPage) })}`}
|
||||
scroll={false}
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
>
|
||||
← Önceki
|
||||
</Link>
|
||||
)}
|
||||
{page >= lastPage ? (
|
||||
<span className={buttonVariants({ variant: "outline", size: "sm" }) + " pointer-events-none opacity-50"}>
|
||||
Sonraki →
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href={`${pathname}${buildHref(current, { page: String(nextPage) })}`}
|
||||
scroll={false}
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
>
|
||||
Sonraki →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
apps/web/src/app/projects/sase/vin-decode/vins/_query.ts
Normal file
38
apps/web/src/app/projects/sase/vin-decode/vins/_query.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type VinsSearchParams = {
|
||||
q?: string;
|
||||
provider?: string | string[];
|
||||
success?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
sort?: string;
|
||||
page?: string;
|
||||
};
|
||||
|
||||
export function buildHref(
|
||||
current: VinsSearchParams,
|
||||
patch: Partial<VinsSearchParams>,
|
||||
): string {
|
||||
const merged: Record<string, string | string[] | undefined> = {
|
||||
...current,
|
||||
...patch,
|
||||
} as Record<string, string | string[] | undefined>;
|
||||
const usp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(merged)) {
|
||||
if (v === undefined || v === null || v === "") continue;
|
||||
if (Array.isArray(v)) for (const item of v) if (item) usp.append(k, item);
|
||||
else usp.set(k, String(v));
|
||||
}
|
||||
const qs = usp.toString();
|
||||
return qs ? `?${qs}` : "?";
|
||||
}
|
||||
|
||||
export function toggleArray(
|
||||
current: string | string[] | undefined,
|
||||
value: string,
|
||||
): string[] {
|
||||
const arr = !current ? [] : Array.isArray(current) ? [...current] : current.split(",");
|
||||
const i = arr.indexOf(value);
|
||||
if (i >= 0) arr.splice(i, 1);
|
||||
else arr.push(value);
|
||||
return arr;
|
||||
}
|
||||
215
apps/web/src/app/projects/sase/vin-decode/vins/page.tsx
Normal file
215
apps/web/src/app/projects/sase/vin-decode/vins/page.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import Link from "next/link";
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { listVinDecodes } from "@/lib/sase/vin-list";
|
||||
import { saseAdminWired } from "@/lib/admin-sdk/sase";
|
||||
import { VinsFilterBar } from "./_filter-bar";
|
||||
import { VinActions } from "./_actions";
|
||||
import { Pager } from "./_pager";
|
||||
import { buildHref, type VinsSearchParams } from "./_query";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function parseSort(s: string | undefined): {
|
||||
field: "createdAt" | "responseTimeMs";
|
||||
dir: "asc" | "desc";
|
||||
} {
|
||||
const [f, d] = (s ?? "createdAt:desc").split(":");
|
||||
const field = f === "responseTimeMs" ? "responseTimeMs" : "createdAt";
|
||||
const dir = d === "asc" ? "asc" : "desc";
|
||||
return { field, dir };
|
||||
}
|
||||
|
||||
function toArray(v: string | string[] | undefined): string[] {
|
||||
if (!v) return [];
|
||||
return Array.isArray(v) ? v : v.split(",").filter(Boolean);
|
||||
}
|
||||
|
||||
export default async function VinsListPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<VinsSearchParams>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const sort = parseSort(sp.sort);
|
||||
const page = Math.max(0, Number(sp.page ?? "0") || 0);
|
||||
const pageSize = 50;
|
||||
|
||||
const providers = toArray(sp.provider);
|
||||
const success = sp.success === "true" || sp.success === "false" ? sp.success : undefined;
|
||||
const from = sp.from ? new Date(sp.from) : undefined;
|
||||
const to = sp.to ? new Date(sp.to) : undefined;
|
||||
|
||||
const { rows, total } = await listVinDecodes(
|
||||
{
|
||||
search: sp.q,
|
||||
providers: providers.length ? providers : undefined,
|
||||
success: success as "true" | "false" | undefined,
|
||||
from,
|
||||
to,
|
||||
},
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
);
|
||||
|
||||
return (
|
||||
<PanelShell title="Sase · VIN List">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Link href="/projects/sase/vin-decode" className="hover:underline">
|
||||
← VIN Decode dashboard
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Decode'lar</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{total.toLocaleString("tr-TR")} kayıt · sayfa {page + 1}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VinsFilterBar initial={sp} />
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<SortableHeader label="Tarih" field="createdAt" sp={sp} />
|
||||
</TableHead>
|
||||
<TableHead>VIN</TableHead>
|
||||
<TableHead>Marka</TableHead>
|
||||
<TableHead>Kullanıcı</TableHead>
|
||||
<TableHead>Provider</TableHead>
|
||||
<TableHead>Durum</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<SortableHeader label="RT" field="responseTimeMs" sp={sp} />
|
||||
</TableHead>
|
||||
<TableHead>Cache</TableHead>
|
||||
{saseAdminWired() && <TableHead className="text-right">İşlem</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={saseAdminWired() ? 9 : 8}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
Eşleşen decode yok.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{r.createdAt.toISOString().slice(5, 16).replace("T", " ")}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs" title={r.vin}>
|
||||
{r.vin}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{r.brandSlug ? (
|
||||
<>
|
||||
<span className="font-mono">{r.brandSlug}</span>
|
||||
{r.brandName && (
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
{r.brandName}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<Link
|
||||
href={`/projects/sase/users/${r.userId}`}
|
||||
className="hover:underline"
|
||||
title={r.userName ?? undefined}
|
||||
>
|
||||
{r.userEmail ?? r.userId.slice(0, 8)}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{r.source ? (
|
||||
<Link
|
||||
href={`/projects/sase/vin-decode/providers/${encodeURIComponent(r.source)}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{r.source}
|
||||
</Badge>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{r.success ? (
|
||||
<Badge variant="default">ok</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
title={r.errorMessage ?? undefined}
|
||||
>
|
||||
fail
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-xs">
|
||||
{r.responseTimeMs ? `${r.responseTimeMs}ms` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{r.cacheSource ?? "—"}
|
||||
</TableCell>
|
||||
{saseAdminWired() && (
|
||||
<TableCell>
|
||||
<VinActions vin={r.vin} hasVehicleRow={r.hasVehicleRow} />
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Pager page={page} pageSize={pageSize} total={total} />
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableHeader({
|
||||
label,
|
||||
field,
|
||||
sp,
|
||||
}: {
|
||||
label: string;
|
||||
field: string;
|
||||
sp: VinsSearchParams;
|
||||
}) {
|
||||
const [curField, curDir] = (sp.sort ?? "createdAt:desc").split(":");
|
||||
const nextDir = curField === field && curDir === "desc" ? "asc" : "desc";
|
||||
const indicator = curField === field ? (curDir === "asc" ? " ↑" : " ↓") : "";
|
||||
return (
|
||||
<Link
|
||||
href={buildHref(sp, { sort: `${field}:${nextDir}`, page: undefined })}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
scroll={false}
|
||||
>
|
||||
{label}
|
||||
{indicator}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -109,6 +109,37 @@ export type SaseAdmin = {
|
||||
reason: string;
|
||||
founderId: string;
|
||||
}): Promise<BrandSetResult>;
|
||||
|
||||
/** Clear Sase.tr Redis caches for a VIN so the next decode re-fetches. */
|
||||
clearVinCache(input: {
|
||||
vin: string;
|
||||
reason: string;
|
||||
founderId: string;
|
||||
}): Promise<VinCacheClearResult>;
|
||||
|
||||
/** Hard-delete the shared `vehicles` row for a VIN (cascades user_vehicles). */
|
||||
deleteVehicleByVin(input: {
|
||||
vin: string;
|
||||
reason: string;
|
||||
founderId: string;
|
||||
}): Promise<VinDeleteResult>;
|
||||
};
|
||||
|
||||
export type VinCacheClearResult = {
|
||||
success: boolean;
|
||||
vin: string;
|
||||
clearedKeys: string[];
|
||||
totalKeysChecked: number;
|
||||
};
|
||||
|
||||
export type VinDeleteResult = {
|
||||
success: boolean;
|
||||
vin: string;
|
||||
vehicleId: string;
|
||||
brandName: string | null;
|
||||
model: string | null;
|
||||
source: string | null;
|
||||
cascadedUserLinks: number;
|
||||
};
|
||||
|
||||
export type BrandSetResult = {
|
||||
@@ -256,6 +287,18 @@ export function createSaseAdmin(): SaseAdmin {
|
||||
reason: input.reason,
|
||||
founderId: input.founderId,
|
||||
}),
|
||||
clearVinCache: (input) =>
|
||||
client.call(
|
||||
"POST",
|
||||
`/internal/admin/vehicles/${encodeURIComponent(input.vin)}/cache-clear`,
|
||||
{ reason: input.reason, founderId: input.founderId },
|
||||
),
|
||||
deleteVehicleByVin: (input) =>
|
||||
client.call(
|
||||
"DELETE",
|
||||
`/internal/admin/vehicles/${encodeURIComponent(input.vin)}`,
|
||||
{ reason: input.reason, founderId: input.founderId },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -280,6 +323,8 @@ function notWiredSdk(projectKey: string): SaseAdmin {
|
||||
resumeSubscription: () => Promise.reject(reject()),
|
||||
refundPayment: () => Promise.reject(reject()),
|
||||
setSubscriptionBrands: () => Promise.reject(reject()),
|
||||
clearVinCache: () => Promise.reject(reject()),
|
||||
deleteVehicleByVin: () => Promise.reject(reject()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -302,4 +347,6 @@ export const SASE_ADMIN_ENDPOINTS = [
|
||||
"POST /internal/admin/subscriptions/:id/resume",
|
||||
"POST /internal/admin/payments/:id/refund",
|
||||
"POST /internal/admin/subscriptions/:id/brands",
|
||||
"POST /internal/admin/vehicles/:vin/cache-clear",
|
||||
"DELETE /internal/admin/vehicles/:vin",
|
||||
];
|
||||
|
||||
173
apps/web/src/lib/sase/vin-list.ts
Normal file
173
apps/web/src/lib/sase/vin-list.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { saseDb } from "@/lib/db-sase";
|
||||
|
||||
export type VinListFilter = {
|
||||
search?: string;
|
||||
providers?: string[]; // source values: 'cache','parts-catalogs','pl24','emex','vin-api','corgi','none','aborted'
|
||||
success?: "true" | "false"; // string flag from URL
|
||||
brandIds?: string[];
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
};
|
||||
|
||||
export type VinListSort = {
|
||||
field: "createdAt" | "responseTimeMs";
|
||||
dir: "asc" | "desc";
|
||||
};
|
||||
|
||||
export type VinRow = {
|
||||
id: string;
|
||||
vin: string;
|
||||
userId: string;
|
||||
userEmail: string | null;
|
||||
userName: string | null;
|
||||
brandId: string | null;
|
||||
brandSlug: string | null;
|
||||
brandName: string | null;
|
||||
source: string | null;
|
||||
success: boolean;
|
||||
responseTimeMs: number | null;
|
||||
errorMessage: string | null;
|
||||
cacheSource: string | null;
|
||||
hasVehicleRow: boolean;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
const PAGE_DEFAULT = 50;
|
||||
const PAGE_MAX = 200;
|
||||
|
||||
export async function listVinDecodes(
|
||||
filter: VinListFilter,
|
||||
sort: VinListSort,
|
||||
page = 0,
|
||||
pageSize = PAGE_DEFAULT,
|
||||
): Promise<{ rows: VinRow[]; total: number }> {
|
||||
const take = Math.min(Math.max(pageSize, 1), PAGE_MAX);
|
||||
const skip = Math.max(page, 0) * take;
|
||||
const sortColumn = sort.field === "responseTimeMs" ? "response_time_ms" : "created_at";
|
||||
const sortDir = sort.dir === "asc" ? "ASC" : "DESC";
|
||||
|
||||
// Build dynamic where conditions as parameterized tagged templates.
|
||||
const conds: string[] = ["1 = 1"];
|
||||
const params: unknown[] = [];
|
||||
let p = 1;
|
||||
|
||||
if (filter.search?.trim()) {
|
||||
const q = filter.search.trim().toUpperCase();
|
||||
conds.push(`(q.vin ILIKE $${p} OR u.email ILIKE $${p + 1})`);
|
||||
params.push(`%${q}%`, `%${q.toLowerCase()}%`);
|
||||
p += 2;
|
||||
}
|
||||
if (filter.providers?.length) {
|
||||
conds.push(`q.source = ANY($${p}::text[])`);
|
||||
params.push(filter.providers);
|
||||
p += 1;
|
||||
}
|
||||
if (filter.success === "true") {
|
||||
conds.push(`q.success = true`);
|
||||
} else if (filter.success === "false") {
|
||||
conds.push(`q.success = false`);
|
||||
}
|
||||
if (filter.brandIds?.length) {
|
||||
conds.push(`q.brand_id = ANY($${p}::uuid[])`);
|
||||
params.push(filter.brandIds);
|
||||
p += 1;
|
||||
}
|
||||
if (filter.from) {
|
||||
conds.push(`q.created_at >= $${p}`);
|
||||
params.push(filter.from);
|
||||
p += 1;
|
||||
}
|
||||
if (filter.to) {
|
||||
conds.push(`q.created_at < $${p}`);
|
||||
params.push(filter.to);
|
||||
p += 1;
|
||||
}
|
||||
|
||||
const where = conds.join(" AND ");
|
||||
|
||||
const dataSql = `
|
||||
SELECT
|
||||
q.id,
|
||||
q.vin,
|
||||
q.user_id,
|
||||
u.email AS user_email,
|
||||
u.name AS user_name,
|
||||
q.brand_id,
|
||||
b.slug AS brand_slug,
|
||||
b.name AS brand_name,
|
||||
q.source,
|
||||
q.success,
|
||||
q.response_time_ms,
|
||||
q.error_message,
|
||||
(q.timings->>'cache_source') AS cache_source,
|
||||
EXISTS (SELECT 1 FROM vehicles v WHERE v.vin = q.vin) AS has_vehicle_row,
|
||||
q.created_at
|
||||
FROM query_logs q
|
||||
LEFT JOIN users u ON u.id = q.user_id
|
||||
LEFT JOIN brands b ON b.id = q.brand_id
|
||||
WHERE ${where}
|
||||
ORDER BY q.${sortColumn} ${sortDir} NULLS LAST
|
||||
LIMIT $${p} OFFSET $${p + 1}
|
||||
`;
|
||||
const countSql = `
|
||||
SELECT count(*) AS total
|
||||
FROM query_logs q
|
||||
LEFT JOIN users u ON u.id = q.user_id
|
||||
WHERE ${where}
|
||||
`;
|
||||
|
||||
const [rawRows, countRows] = await Promise.all([
|
||||
saseDb.$queryRawUnsafe<
|
||||
Array<{
|
||||
id: string;
|
||||
vin: string;
|
||||
user_id: string;
|
||||
user_email: string | null;
|
||||
user_name: string | null;
|
||||
brand_id: string | null;
|
||||
brand_slug: string | null;
|
||||
brand_name: string | null;
|
||||
source: string | null;
|
||||
success: boolean;
|
||||
response_time_ms: number | null;
|
||||
error_message: string | null;
|
||||
cache_source: string | null;
|
||||
has_vehicle_row: boolean;
|
||||
created_at: Date;
|
||||
}>
|
||||
>(dataSql, ...params, take, skip),
|
||||
saseDb.$queryRawUnsafe<Array<{ total: bigint }>>(countSql, ...params),
|
||||
]);
|
||||
|
||||
return {
|
||||
rows: rawRows.map((r) => ({
|
||||
id: r.id,
|
||||
vin: r.vin,
|
||||
userId: r.user_id,
|
||||
userEmail: r.user_email,
|
||||
userName: r.user_name,
|
||||
brandId: r.brand_id,
|
||||
brandSlug: r.brand_slug,
|
||||
brandName: r.brand_name,
|
||||
source: r.source,
|
||||
success: r.success,
|
||||
responseTimeMs: r.response_time_ms,
|
||||
errorMessage: r.error_message,
|
||||
cacheSource: r.cache_source,
|
||||
hasVehicleRow: r.has_vehicle_row,
|
||||
createdAt: r.created_at,
|
||||
})),
|
||||
total: Number(countRows[0]?.total ?? 0n),
|
||||
};
|
||||
}
|
||||
|
||||
export const KNOWN_PROVIDERS = [
|
||||
"cache",
|
||||
"parts-catalogs",
|
||||
"pl24",
|
||||
"emex",
|
||||
"vin-api",
|
||||
"corgi",
|
||||
"none",
|
||||
"aborted",
|
||||
] as const;
|
||||
Reference in New Issue
Block a user