feat(vin-decode): VIN row detail modal with timings + behaviour

Click a VIN in the list to open a modal with the full decode internals:
- Proxy times per provider (pcat/emex/pl24/vin-api) from query_logs.timings
- Race/lock status (lock_wait ms + cache_source='lock_wait' → "aynı VIN
  paralelde çözülüyordu"), candidate_pick, pl24 circuit, aborted
- Candidate counts (pcat_car_count, emex_candidate_count), result_kind, wmi
- User behaviour for that vehicle, correlated from PostHog custom events in
  the panel DB by user_id + vehicle_id: categories opened (parts_panel_viewed)
  and OEM copied (oem_code_copied + distinct codes)

New: getVinDecodeDetail/getVinUserBehavior (cross-DB: sase RO + panel),
GET /api/sase/query-log/[id] (auth-guarded), client VinDetailButton modal.
Both queries verified against prod data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-25 02:31:28 +03:00
parent 09438cf513
commit fb33692452
4 changed files with 507 additions and 2 deletions

View File

@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { getVinDecodeDetail, getVinUserBehavior } from "@/lib/sase/vin-detail";
export const dynamic = "force-dynamic";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
}
const { id } = await ctx.params;
if (!UUID_RE.test(id)) {
return NextResponse.json({ ok: false, error: "bad_id" }, { status: 400 });
}
const detail = await getVinDecodeDetail(id);
if (!detail) {
return NextResponse.json({ ok: false, error: "not_found" }, { status: 404 });
}
// Behaviour is best-effort: if the panel DB query fails, still return the
// decode detail rather than 500 the whole modal.
let behavior = null;
try {
behavior = await getVinUserBehavior(detail.userId, detail.vehicleId);
} catch {
behavior = null;
}
return NextResponse.json({ ok: true, detail, behavior });
}

View File

@@ -0,0 +1,307 @@
"use client";
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
type Timings = {
pcat?: number;
emex?: number;
pl24?: number;
vin_api?: number;
lock_wait?: number;
cache_source?: string;
cache_neg_hit?: boolean;
pcat_car_count?: number;
emex_candidate_count?: number;
candidate_pick?: string;
pl24_circuit_open?: boolean;
aborted?: boolean;
result_kind?: string;
wmi?: string;
[k: string]: unknown;
};
type Detail = {
id: string;
vin: string;
userId: string;
userEmail: string | null;
userName: string | null;
brandSlug: string | null;
brandName: string | null;
source: string | null;
success: boolean;
responseTimeMs: number | null;
errorMessage: string | null;
createdAt: string;
vehicleId: string | null;
timings: Timings | null;
};
type Behavior = {
oemCopiedCount: number;
oemCodes: string[];
categoriesOpened: number;
partsPanelViews: number;
firstAt: string | null;
lastAt: string | null;
} | null;
type ApiResponse =
| { ok: true; detail: Detail; behavior: Behavior }
| { ok: false; error: string };
function ms(v: number | null | undefined): string {
if (v == null) return "—";
if (v >= 1000) return `${(v / 1000).toFixed(2)}s`;
return `${v}ms`;
}
const PROVIDER_KEYS: Array<[keyof Timings, string]> = [
["pcat", "parts-catalogs"],
["emex", "emex"],
["pl24", "pl24"],
["vin_api", "vin-api"],
];
export function VinDetailButton({ id, vin }: { id: string; vin: string }) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<Detail | null>(null);
const [behavior, setBehavior] = useState<Behavior>(null);
useEffect(() => {
if (!open || detail || loading) return;
setLoading(true);
setError(null);
fetch(`/api/sase/query-log/${id}`, { cache: "no-store" })
.then(async (r) => (await r.json()) as ApiResponse)
.then((data) => {
if (data.ok) {
setDetail(data.detail);
setBehavior(data.behavior);
} else {
setError(data.error);
}
})
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setLoading(false));
}, [open, id, detail, loading]);
const t = detail?.timings ?? null;
const proxies = t ? PROVIDER_KEYS.filter(([k]) => typeof t[k] === "number") : [];
const raced = t ? (typeof t.lock_wait === "number" || t.cache_source === "lock_wait") : false;
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="cursor-pointer font-mono text-xs hover:underline"
title={`${vin} — detay`}
>
{vin}
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="font-mono text-base">{vin}</DialogTitle>
<DialogDescription>
{detail
? new Date(detail.createdAt).toISOString().slice(0, 19).replace("T", " ")
: "Decode detayı"}
</DialogDescription>
</DialogHeader>
{loading && <p className="text-sm text-muted-foreground">Yükleniyor</p>}
{error && (
<p className="text-sm text-destructive">Hata: {error}</p>
)}
{detail && (
<div className="space-y-4 text-sm">
{/* Decode özet */}
<Section title="Decode">
<Row label="Durum">
{detail.success ? (
<Badge variant="default">ok</Badge>
) : (
<Badge variant="destructive">fail</Badge>
)}
</Row>
<Row label="Kazanan provider">
{detail.source ? (
<Badge variant="outline" className="font-mono">{detail.source}</Badge>
) : (
"—"
)}
</Row>
<Row label="Toplam yanıt">{ms(detail.responseTimeMs)}</Row>
<Row label="Marka">
{detail.brandSlug ? (
<span className="font-mono text-xs">
{detail.brandSlug}
{detail.brandName && (
<span className="ml-1 text-muted-foreground">{detail.brandName}</span>
)}
</span>
) : (
"—"
)}
</Row>
<Row label="Kullanıcı">
<span className="font-mono text-xs">{detail.userEmail ?? detail.userId.slice(0, 8)}</span>
</Row>
{detail.errorMessage && (
<Row label="Hata mesajı">
<span className="text-xs text-destructive">{detail.errorMessage}</span>
</Row>
)}
</Section>
{/* Proxy süreleri */}
<Section title="Proxy süreleri">
{proxies.length === 0 ? (
<p className="text-xs text-muted-foreground">
Provider denemesi yok (cache&apos;ten döndü).
</p>
) : (
proxies.map(([k, label]) => (
<Row key={String(k)} label={label}>
<span className="tabular-nums">{ms(t?.[k] as number)}</span>
</Row>
))
)}
</Section>
{/* Race / kilit */}
<Section title="Race / kilit durumu">
{raced ? (
<Row label="Eşzamanlı istek">
<span className="text-yellow-600">
Aynı VIN paralelde çözülüyordu {ms(t?.lock_wait)} kilit beklendi
</span>
</Row>
) : (
<Row label="Eşzamanlı istek">
<span className="text-muted-foreground">Race yok (kilit beklenmedi)</span>
</Row>
)}
<Row label="cache_source">
<span className="font-mono text-xs">{t?.cache_source ?? "—"}</span>
</Row>
<Row label="candidate_pick">
<span className="font-mono text-xs">{t?.candidate_pick ?? "—"}</span>
</Row>
{t?.pl24_circuit_open && (
<Row label="PL24 circuit">
<Badge variant="destructive">ık (circuit open)</Badge>
</Row>
)}
{t?.aborted && (
<Row label="Abort">
<Badge variant="destructive">bütçe/timeout ile iptal</Badge>
</Row>
)}
</Section>
{/* Aday / sonuç */}
<Section title="Aday & sonuç">
<Row label="result_kind">
<span className="font-mono text-xs">{t?.result_kind ?? "—"}</span>
</Row>
<Row label="PCAT araç adayı">
<span className="tabular-nums">
{typeof t?.pcat_car_count === "number" ? t.pcat_car_count : "—"}
</span>
</Row>
<Row label="EMEX adayı">
<span className="tabular-nums">
{typeof t?.emex_candidate_count === "number" ? t.emex_candidate_count : "—"}
</span>
</Row>
<Row label="WMI">
<span className="font-mono text-xs">{t?.wmi ?? "—"}</span>
</Row>
{t?.cache_neg_hit && (
<Row label="Negatif cache">
<Badge variant="outline">negatif cache hit</Badge>
</Row>
)}
</Section>
{/* Kullanıcı davranışı (bu araç) */}
<Section title="Kullanıcı davranışı (bu araç)">
{!detail.vehicleId ? (
<p className="text-xs text-muted-foreground">
Decode bir araç kaydı üretmedi kategori/OEM davranışı yok.
</p>
) : behavior == null ? (
<p className="text-xs text-muted-foreground">Davranış verisi alınamadı.</p>
) : (
<>
<Row label="Kategori açıldı">
<span className="tabular-nums">{behavior.categoriesOpened}</span>
{behavior.partsPanelViews > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
({behavior.partsPanelViews} görüntüleme)
</span>
)}
</Row>
<Row label="OEM kopyalandı">
{behavior.oemCopiedCount > 0 ? (
<span className="text-emerald-600">
Evet · {behavior.oemCopiedCount} kez ({behavior.oemCodes.length} farklı kod)
</span>
) : (
<span className="text-muted-foreground">Hayır</span>
)}
</Row>
{behavior.oemCodes.length > 0 && (
<div className="flex flex-wrap gap-1 pt-1">
{behavior.oemCodes.map((c) => (
<Badge key={c} variant="secondary" className="font-mono text-xs">
{c}
</Badge>
))}
</div>
)}
</>
)}
</Section>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-md border p-3">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{title}
</p>
<div className="space-y-1">{children}</div>
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-3">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-right">{children}</span>
</div>
);
}

View File

@@ -13,6 +13,7 @@ import { listVinDecodes } from "@/lib/sase/vin-list";
import { saseAdminWired } from "@/lib/admin-sdk/sase";
import { VinsFilterBar } from "./_filter-bar";
import { VinActions } from "./_actions";
import { VinDetailButton } from "./_detail-modal";
import { Pager } from "./_pager";
import { buildHref, type VinsSearchParams } from "./_query";
@@ -115,8 +116,8 @@ export default async function VinsListPage({
<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 className="font-mono text-xs">
<VinDetailButton id={r.id} vin={r.vin} />
</TableCell>
<TableCell className="text-xs">
{r.brandSlug ? (

View File

@@ -0,0 +1,161 @@
import { saseDb } from "@/lib/db-sase";
import { prisma } from "@/lib/db";
// Single-decode drill-down for the VIN list modal. Pulls the full query_logs
// row + parsed timings from the Sase RO DB, resolves the vehicle_id (so we can
// correlate frontend behaviour), then joins PostHog custom events from the
// PANEL DB (cross-database — two clients, not one SQL join).
export type DecodeTimings = {
// provider proxy times (ms) — present only when that provider was attempted
pcat?: number;
emex?: number;
pl24?: number;
vin_api?: number;
// race / lock: ms waited because another request was decoding the same VIN
lock_wait?: number;
// 'miss' | 'db_hit' | 'redis_positive' | 'lock_wait' | 'redis_negative' ...
cache_source?: string;
cache_neg_hit?: boolean;
// candidate disambiguation counts returned by each provider
pcat_car_count?: number;
emex_candidate_count?: number;
candidate_pick?: string; // 'none' | 'auto' | 'single' ...
pl24_circuit_open?: boolean;
aborted?: boolean;
result_kind?: string; // 'vehicle' | 'unknown' ...
wmi?: string;
[k: string]: unknown;
};
export type VinDecodeDetail = {
id: string;
vin: string;
userId: string;
userEmail: string | null;
userName: string | null;
brandSlug: string | null;
brandName: string | null;
source: string | null;
success: boolean;
responseTimeMs: number | null;
errorMessage: string | null;
createdAt: Date;
vehicleId: string | null;
timings: DecodeTimings | null;
};
export async function getVinDecodeDetail(id: string): Promise<VinDecodeDetail | null> {
const rows = await saseDb.$queryRaw<
Array<{
id: string;
vin: string;
user_id: string;
user_email: string | null;
user_name: string | null;
brand_slug: string | null;
brand_name: string | null;
source: string | null;
success: boolean;
response_time_ms: number | null;
error_message: string | null;
timings: DecodeTimings | null;
created_at: Date;
vehicle_id: string | null;
}>
>`
SELECT
q.id, q.vin, q.user_id,
u.email AS user_email, u.name AS user_name,
b.slug AS brand_slug, b.name AS brand_name,
q.source, q.success, q.response_time_ms, q.error_message,
q.timings, q.created_at,
v.id AS vehicle_id
FROM query_logs q
LEFT JOIN users u ON u.id = q.user_id
LEFT JOIN brands b ON b.id = q.brand_id
LEFT JOIN vehicles v ON v.vin = q.vin
WHERE q.id = ${id}::uuid
LIMIT 1
`;
const r = rows[0];
if (!r) return null;
return {
id: r.id,
vin: r.vin,
userId: r.user_id,
userEmail: r.user_email,
userName: r.user_name,
brandSlug: r.brand_slug,
brandName: r.brand_name,
source: r.source,
success: r.success,
responseTimeMs: r.response_time_ms,
errorMessage: r.error_message,
createdAt: r.created_at,
vehicleId: r.vehicle_id,
timings: r.timings,
};
}
export type VinUserBehavior = {
oemCopiedCount: number;
oemCodes: string[];
categoriesOpened: number;
partsPanelViews: number;
firstAt: Date | null;
lastAt: Date | null;
};
// Correlate post-decode frontend behaviour for THIS vehicle. PostHog custom
// events live in the panel DB (session_custom_events). They carry the Sase
// user uuid (distinct_id / $user_id) and the vehicle_id, so we scope to this
// user + vehicle. Returns null when the decode produced no vehicle row.
export async function getVinUserBehavior(
userId: string,
vehicleId: string | null,
): Promise<VinUserBehavior | null> {
if (!vehicleId) return null;
const [agg] = await prisma.$queryRaw<
Array<{
oem_copied: bigint;
categories_opened: bigint;
parts_views: bigint;
first_at: Date | null;
last_at: Date | null;
}>
>`
SELECT
count(*) FILTER (WHERE "eventName" = 'oem_code_copied') AS oem_copied,
count(DISTINCT (properties->>'category_id'))
FILTER (WHERE "eventName" = 'parts_panel_viewed'
AND properties->>'category_id' IS NOT NULL) AS categories_opened,
count(*) FILTER (WHERE "eventName" = 'parts_panel_viewed') AS parts_views,
min("timestamp") AS first_at,
max("timestamp") AS last_at
FROM session_custom_events
WHERE properties->>'vehicle_id' = ${vehicleId}
AND (properties->>'distinct_id' = ${userId} OR properties->>'$user_id' = ${userId})
AND "eventName" IN ('oem_code_copied', 'parts_panel_viewed')
`;
const codeRows = await prisma.$queryRaw<Array<{ code: string }>>`
SELECT DISTINCT properties->>'oem_code' AS code
FROM session_custom_events
WHERE "eventName" = 'oem_code_copied'
AND properties->>'vehicle_id' = ${vehicleId}
AND (properties->>'distinct_id' = ${userId} OR properties->>'$user_id' = ${userId})
AND properties->>'oem_code' IS NOT NULL
LIMIT 20
`;
return {
oemCopiedCount: Number(agg?.oem_copied ?? 0n),
oemCodes: codeRows.map((r) => r.code),
categoriesOpened: Number(agg?.categories_opened ?? 0n),
partsPanelViews: Number(agg?.parts_views ?? 0n),
firstAt: agg?.first_at ?? null,
lastAt: agg?.last_at ?? null,
};
}