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 });
}
}

View File

@@ -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" })}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>
);
}

View File

@@ -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",
];

View 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;