refactor(decode): keep decode-source details server-side, opaque candidate keys
The multi-candidate decode response leaked provider internals (source name,
pcat car ids, EMEX _ssd/_vid/_quickGroupsUrl/catalogId) and made the client
carry them between requests: the frontend stored candidateSource and echoed
pcatCarId/emexCarIndex back on selection.
Now the candidate list returned to the client carries only display fields
(name, description, parameters) plus an opaque key, and the provider mapping
is stashed in Redis (vin:candidates:*, 30m TTL, resolve-cache fallback). The
pick request sends just { vin, candidate }. Legacy pcatCarId/emexCarIndex
body params still work for already-loaded bundles.
Also drops `source` from the public /vehicles/preview response — no consumer
used it, and provider names must never be public (same policy as
teaser-stats).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,10 +37,14 @@ export class VehiclesController {
|
||||
async decode(
|
||||
@CurrentUser("id") userId: string,
|
||||
@Body("vin", VinValidationPipe) vin: string,
|
||||
// Deprecated: provider-specific picks from pre-stash frontend bundles.
|
||||
// New clients send only the opaque `candidate` key; the provider mapping
|
||||
// lives in the server-side stash.
|
||||
@Body("pcatCarId") pcatCarId?: string,
|
||||
@Body("emexCarIndex") emexCarIndex?: number,
|
||||
@Body("candidate") candidate?: string,
|
||||
) {
|
||||
return this.vehiclesService.decodeVin(vin, userId, pcatCarId, emexCarIndex);
|
||||
return this.vehiclesService.decodeVin(vin, userId, pcatCarId, emexCarIndex, candidate);
|
||||
}
|
||||
|
||||
@Get("history")
|
||||
|
||||
@@ -620,8 +620,9 @@ describe("VehiclesService", () => {
|
||||
|
||||
const res = await service.previewVin("NM435600006H43436");
|
||||
|
||||
expect(res.source).toBe("parts-catalogs");
|
||||
expect(res.brandName).toBe("Fiat"); // was null before the fix
|
||||
// Provider/source adı public preview cevabına asla sızmamalı.
|
||||
expect("source" in res).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import { VinApiService } from "../integrations/vin-api/vin-api.service";
|
||||
import { PrefetchSource } from "../jobs/prefetch.types";
|
||||
import { CATALOG_PREFETCH_QUEUE } from "../jobs/queues/catalog-prefetch.queue";
|
||||
import { RedisService } from "../redis/redis.service";
|
||||
import { vinResolveCacheKeys } from "./vin-cache-keys";
|
||||
import { vinCandidateStashKey, vinResolveCacheKeys } from "./vin-cache-keys";
|
||||
|
||||
/**
|
||||
* Per-decode metadata, persisted to `query_logs.timings` jsonb. Despite the
|
||||
@@ -66,6 +66,26 @@ interface VinResolveResult {
|
||||
emexCandidates?: EmexCandidate[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side record of a pending multi-candidate selection. The client only
|
||||
* ever sees the opaque per-candidate key (the index, as a string); which decode
|
||||
* source produced the list and how to re-address the chosen car never leave
|
||||
* the API.
|
||||
*/
|
||||
interface CandidateStash {
|
||||
source: "parts-catalogs" | "emex";
|
||||
/** Indexed by candidate key — provider-specific selector for each entry. */
|
||||
picks: Array<{ pcatCarId?: string; emexCarIndex?: number }>;
|
||||
}
|
||||
|
||||
/** Display-only candidate shape returned to the client. */
|
||||
export interface PublicCandidate {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: PcatCar["parameters"];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VehiclesService {
|
||||
private readonly logger = new Logger(VehiclesService.name);
|
||||
@@ -81,8 +101,31 @@ export class VehiclesService {
|
||||
private redis: RedisService,
|
||||
) {}
|
||||
|
||||
async decodeVin(vin: string, userId: string, pcatCarId?: string, emexCarIndex?: number) {
|
||||
async decodeVin(
|
||||
vin: string,
|
||||
userId: string,
|
||||
legacyPcatCarId?: string,
|
||||
legacyEmexCarIndex?: number,
|
||||
candidateKey?: string,
|
||||
) {
|
||||
const startTime = Date.now();
|
||||
|
||||
if (!isValidVin(vin)) {
|
||||
throw new BadRequestException("Geçersiz şase numarası");
|
||||
}
|
||||
|
||||
// Opaque candidate pick → provider-specific selection, restored from the
|
||||
// server-side stash so the client never round-trips decode-source details
|
||||
// (source name, pcat car ids, EMEX indexes) between requests. The legacy
|
||||
// pcatCarId/emexCarIndex body params still work for old bundles.
|
||||
let pcatCarId = legacyPcatCarId;
|
||||
let emexCarIndex = legacyEmexCarIndex;
|
||||
if (candidateKey !== undefined && pcatCarId === undefined && emexCarIndex === undefined) {
|
||||
const pick = await this.resolveCandidateKey(vin, candidateKey);
|
||||
pcatCarId = pick.pcatCarId;
|
||||
emexCarIndex = pick.emexCarIndex;
|
||||
}
|
||||
|
||||
const ctx: ResolveContext = {
|
||||
timings: {
|
||||
wmi: vin.length >= 3 ? vin.substring(0, 3).toUpperCase() : "",
|
||||
@@ -90,10 +133,6 @@ export class VehiclesService {
|
||||
},
|
||||
};
|
||||
|
||||
if (!isValidVin(vin)) {
|
||||
throw new BadRequestException("Geçersiz şase numarası");
|
||||
}
|
||||
|
||||
// 1. Check for shared vehicle config by VIN (no userId filter)
|
||||
const [existing] = await this.db.select().from(vehicles).where(eq(vehicles.vin, vin)).limit(1);
|
||||
|
||||
@@ -163,32 +202,32 @@ export class VehiclesService {
|
||||
throw new BadRequestException("Şase numarası tanınamadı. Marka desteklenmiyor.");
|
||||
}
|
||||
|
||||
// 2b. If resolveVin returned multiple candidates, return them for frontend selection
|
||||
if (resolved.pcatCandidates && resolved.pcatCandidates.length > 1) {
|
||||
// 2b. If resolveVin returned multiple candidates, return them for frontend
|
||||
// selection. Provider details (source name, car ids, EMEX internals) are
|
||||
// stashed server-side; the client only echoes back the opaque candidate key.
|
||||
const hasPcatCandidates = !!resolved.pcatCandidates && resolved.pcatCandidates.length > 1;
|
||||
const hasEmexCandidates = !!resolved.emexCandidates && resolved.emexCandidates.length > 1;
|
||||
if (hasPcatCandidates || hasEmexCandidates) {
|
||||
const stash = this.buildCandidateStash(resolved);
|
||||
await this.redis.setJson(
|
||||
vinCandidateStashKey(vin),
|
||||
stash,
|
||||
VehiclesService.CANDIDATE_STASH_TTL_S,
|
||||
);
|
||||
await this.logQuery(
|
||||
userId,
|
||||
vin,
|
||||
null,
|
||||
"parts-catalogs",
|
||||
stash.source,
|
||||
true,
|
||||
Date.now() - startTime,
|
||||
undefined,
|
||||
ctx.timings,
|
||||
);
|
||||
return { candidates: resolved.pcatCandidates, vin, source: "parts-catalogs" };
|
||||
}
|
||||
if (resolved.emexCandidates && resolved.emexCandidates.length > 1) {
|
||||
await this.logQuery(
|
||||
userId,
|
||||
vin,
|
||||
null,
|
||||
"emex",
|
||||
true,
|
||||
Date.now() - startTime,
|
||||
undefined,
|
||||
ctx.timings,
|
||||
);
|
||||
return { candidates: resolved.emexCandidates, vin, source: "emex" };
|
||||
const raw = hasPcatCandidates
|
||||
? (resolved.pcatCandidates as PcatCar[])
|
||||
: (resolved.emexCandidates as EmexCandidate[]);
|
||||
return { candidates: this.toPublicCandidates(raw), vin };
|
||||
}
|
||||
|
||||
// 3. Brand access check
|
||||
@@ -346,14 +385,14 @@ export class VehiclesService {
|
||||
throw new BadRequestException("Geçersiz şase numarası");
|
||||
}
|
||||
|
||||
// DB'de varsa direkt dön — dış API çağrısına gerek yok
|
||||
// DB'de varsa direkt dön — dış API çağrısına gerek yok.
|
||||
// Source/provider adı public preview'a asla dahil edilmez.
|
||||
const [existing] = await this.db
|
||||
.select({
|
||||
brandName: vehicles.brandName,
|
||||
model: vehicles.model,
|
||||
year: vehicles.year,
|
||||
engine: vehicles.engine,
|
||||
source: vehicles.source,
|
||||
})
|
||||
.from(vehicles)
|
||||
.where(eq(vehicles.vin, vin))
|
||||
@@ -378,7 +417,6 @@ export class VehiclesService {
|
||||
model: resolved.model,
|
||||
year: resolved.year,
|
||||
engine: resolved.engine,
|
||||
source: resolved.source,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -398,6 +436,10 @@ export class VehiclesService {
|
||||
// PL24 decode (responseTimeMs=1006123) that ran long after the request was
|
||||
// already aborted by TimeoutInterceptor. Anything past this is wasted work.
|
||||
private static readonly RESOLVE_BUDGET_MS = 25_000;
|
||||
// How long a multi-candidate selection stays valid. Picks normally happen
|
||||
// within seconds; if the stash expires we fall back to the resolve cache
|
||||
// (24h TTL) before giving up.
|
||||
private static readonly CANDIDATE_STASH_TTL_S = 1_800; // 30m
|
||||
|
||||
// ─── PL24 circuit breaker ──────────────────────────────
|
||||
// PL24 had p95=12min in production. When it goes bad it stays bad — every
|
||||
@@ -869,6 +911,60 @@ export class VehiclesService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Candidate stash (opaque pick keys) ────────────────
|
||||
|
||||
/** Build the server-side selector list for a multi-candidate resolve. */
|
||||
private buildCandidateStash(resolved: VinResolveResult): CandidateStash {
|
||||
if (resolved.pcatCandidates && resolved.pcatCandidates.length > 0) {
|
||||
return {
|
||||
source: "parts-catalogs",
|
||||
picks: resolved.pcatCandidates.map((c) => ({ pcatCarId: c.id })),
|
||||
};
|
||||
}
|
||||
return {
|
||||
source: "emex",
|
||||
picks: (resolved.emexCandidates ?? []).map((c) => ({
|
||||
emexCarIndex: Number.isInteger(c._index) ? c._index : Number.parseInt(c.id, 10),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Strip provider internals before the candidate list leaves the API. */
|
||||
private toPublicCandidates(candidates: Array<PcatCar | EmexCandidate>): PublicCandidate[] {
|
||||
return candidates.map((c, i) => ({
|
||||
id: String(i),
|
||||
name: c.name,
|
||||
description: "description" in c ? c.description : undefined,
|
||||
parameters: c.parameters,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an opaque candidate key back to the provider-specific selection.
|
||||
* Falls back to re-resolving the VIN (normally a Redis resolve-cache hit)
|
||||
* when the stash has expired.
|
||||
*/
|
||||
private async resolveCandidateKey(
|
||||
vin: string,
|
||||
candidateKey: string,
|
||||
): Promise<{ pcatCarId?: string; emexCarIndex?: number }> {
|
||||
let stash = await this.redis.getJson<CandidateStash>(vinCandidateStashKey(vin));
|
||||
if (!stash) {
|
||||
const resolved = await this.resolveVin(vin);
|
||||
if (resolved?.pcatCandidates?.length || resolved?.emexCandidates?.length) {
|
||||
stash = this.buildCandidateStash(resolved);
|
||||
}
|
||||
}
|
||||
const idx = Number(candidateKey);
|
||||
const pick = stash && Number.isInteger(idx) ? stash.picks[idx] : undefined;
|
||||
if (!pick) {
|
||||
throw new BadRequestException(
|
||||
"Araç seçimi zaman aşımına uğradı. Lütfen şase numarasını tekrar sorgulayın.",
|
||||
);
|
||||
}
|
||||
return pick;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a specific PartsCatalogs car by ID (after user selects from candidates).
|
||||
*/
|
||||
|
||||
@@ -29,3 +29,14 @@ export function vinResolveCacheKeys(vin: string): {
|
||||
lockKey: `vin:lock:${vin}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side stash for a multi-candidate decode: maps the opaque candidate
|
||||
* keys returned to the client back to the provider-specific selection
|
||||
* (parts-catalogs car id / EMEX list index). Exists so the client never has to
|
||||
* carry decode-source details between the decode and the candidate-pick
|
||||
* requests.
|
||||
*/
|
||||
export function vinCandidateStashKey(vin: string): string {
|
||||
return `vin:candidates:${DECODE_CHAIN_VERSION}:${vin}`;
|
||||
}
|
||||
|
||||
@@ -142,10 +142,10 @@ function SearchPage() {
|
||||
const pendingVinRef = useRef<string | null>(null);
|
||||
const didInitFromUrlRef = useRef(false);
|
||||
|
||||
// Vehicle candidate selection (PartsCatalogs or EMEX multi-result)
|
||||
// Vehicle candidate selection (multi-result decode). Candidates carry only
|
||||
// display fields + an opaque key; the decode source stays server-side.
|
||||
const [candidates, setCandidates] = useState<any[] | null>(null);
|
||||
const [candidateVin, setCandidateVin] = useState("");
|
||||
const [candidateSource, setCandidateSource] = useState<"parts-catalogs" | "emex" | null>(null);
|
||||
const [selectLoading, setSelectLoading] = useState(false);
|
||||
|
||||
// Live preview state
|
||||
@@ -285,12 +285,10 @@ function SearchPage() {
|
||||
if (data.candidates && Array.isArray(data.candidates)) {
|
||||
setCandidates(data.candidates);
|
||||
setCandidateVin(cleanVin);
|
||||
setCandidateSource(data.source ?? "parts-catalogs");
|
||||
candidatesShownAtRef.current = performance.now();
|
||||
capture("vin_decode_candidates", {
|
||||
vin: cleanVin,
|
||||
count: data.candidates.length,
|
||||
source: data.source,
|
||||
response_time_ms: responseTimeMs,
|
||||
query_source: querySourceRef.current,
|
||||
});
|
||||
@@ -461,16 +459,14 @@ function SearchPage() {
|
||||
? Math.round(performance.now() - candidatesShownAtRef.current)
|
||||
: null;
|
||||
try {
|
||||
const payload: Record<string, unknown> = { vin: candidateVin };
|
||||
if (candidateSource === "emex") {
|
||||
payload.emexCarIndex = Number.parseInt(carId, 10);
|
||||
} else {
|
||||
payload.pcatCarId = carId;
|
||||
}
|
||||
const data = await api.post<any>("/vehicles/decode", payload);
|
||||
// The carId is the opaque candidate key from the decode response — the
|
||||
// server maps it back to the provider-specific selection.
|
||||
const data = await api.post<any>("/vehicles/decode", {
|
||||
vin: candidateVin,
|
||||
candidate: carId,
|
||||
});
|
||||
capture("vin_decode_candidate_selected", {
|
||||
vin: candidateVin,
|
||||
source: candidateSource,
|
||||
carId,
|
||||
vehicle_id: data.id,
|
||||
selected_index: selectedIndex,
|
||||
@@ -479,7 +475,6 @@ function SearchPage() {
|
||||
});
|
||||
candidatesShownAtRef.current = null;
|
||||
setCandidates(null);
|
||||
setCandidateSource(null);
|
||||
navigate({
|
||||
to: "/dashboard/vehicles/$id",
|
||||
params: { id: data.id },
|
||||
@@ -488,7 +483,6 @@ function SearchPage() {
|
||||
const message = err instanceof ApiError ? err.message : t("search.errorGeneric");
|
||||
setError(message);
|
||||
setCandidates(null);
|
||||
setCandidateSource(null);
|
||||
toast.error(t("search.candidateSelectFailed"));
|
||||
} finally {
|
||||
setSelectLoading(false);
|
||||
|
||||
@@ -160,7 +160,7 @@ function ServiceTestPage() {
|
||||
try {
|
||||
const data = await api.post<any>("/vehicles/decode", {
|
||||
vin: candidateVin,
|
||||
emexCarIndex: Number.parseInt(carId, 10),
|
||||
candidate: carId,
|
||||
});
|
||||
setCandidates(null);
|
||||
if (data.id) {
|
||||
|
||||
Reference in New Issue
Block a user