Merge pull request 'promote(decode): opaque candidate keys + slim decode response' (#122) from promote-decode into main

This commit was merged in pull request #122.
This commit is contained in:
2026-06-10 09:04:20 +00:00
6 changed files with 176 additions and 51 deletions

View File

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

View File

@@ -166,7 +166,11 @@ describe("VehiclesService", () => {
const { service } = createService(db);
const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
expect(result).toEqual(cached);
// Decode response is intentionally slim — id + display summary only;
// rawData/source never leave the API on this endpoint.
expect(result).toMatchObject({ id: "v1" });
expect(result).not.toHaveProperty("rawData");
expect(result).not.toHaveProperty("source");
});
it("should throw BadRequestException when corgi doesn't recognize VIN", async () => {
@@ -435,7 +439,8 @@ describe("VehiclesService", () => {
});
const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
expect(result).toEqual(savedVehicle);
expect(result).toMatchObject({ id: "v-emex" });
expect(result).not.toHaveProperty("source");
expect(partsCatalogsService.decodeVin).toHaveBeenCalled();
expect(emexService.decodeVinOrCandidates).toHaveBeenCalledWith("WBAPH5C55BA123456");
});
@@ -473,7 +478,7 @@ describe("VehiclesService", () => {
emexService.decodeVinOrCandidates.mockResolvedValue({ type: "notFound" });
const result = await service.decodeVin("WVWZZZ1JZ3W597935", "u1");
expect(result).toEqual(savedVehicle);
expect(result).toMatchObject({ id: "v-pcat" });
expect(pl24Service.decodeVin).not.toHaveBeenCalled();
});
@@ -488,7 +493,7 @@ describe("VehiclesService", () => {
});
const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
expect(result).toEqual(savedVehicle);
expect(result).toMatchObject({ id: "v-emex" });
expect(emexService.decodeVinOrCandidates).toHaveBeenCalled();
});
@@ -511,7 +516,7 @@ describe("VehiclesService", () => {
});
const result = await service.decodeVin("VR3EFYHZ3PJ674071", "u1");
expect(result).toEqual(savedVehicle);
expect(result).toMatchObject({ id: "v-pl24" });
expect(pl24Service.decodeVin).toHaveBeenCalledWith("VR3EFYHZ3PJ674071", "u1");
});
@@ -620,8 +625,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);
});
});

View File

@@ -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);
@@ -115,7 +154,7 @@ export class VehiclesService {
undefined,
ctx.timings,
);
return existing;
return this.toDecodeResponse(existing);
}
// 2. Resolve VIN via cached decode chain (Corgi → PartsCatalogs → PL24 → EMEX)
@@ -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
@@ -263,7 +302,22 @@ export class VehiclesService {
ctx.timings,
);
return savedVehicle;
return this.toDecodeResponse(savedVehicle);
}
/**
* Minimal decode response. Every consumer navigates by id and re-fetches the
* vehicle via GET /vehicles/:id; returning the full row shipped the raw
* upstream decode payload (rawData, p95 ~127KB) and the provider name to the
* client on every decode for nothing.
*/
private toDecodeResponse(v: {
id: string;
brandName: string | null;
model: string | null;
year: number | null;
}) {
return { id: v.id, brandName: v.brandName, model: v.model, year: v.year };
}
/**
@@ -340,14 +394,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))
@@ -372,7 +426,6 @@ export class VehiclesService {
model: resolved.model,
year: resolved.year,
engine: resolved.engine,
source: resolved.source,
};
}
@@ -392,6 +445,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
@@ -863,6 +920,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).
*/

View File

@@ -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}`;
}

View File

@@ -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,
});
@@ -301,7 +299,6 @@ function SearchPage() {
vin: cleanVin,
vehicle_id: data.id,
response_time_ms: responseTimeMs,
source: data.source ?? null,
query_source: querySourceRef.current,
});
navigate({ to: "/dashboard/vehicles/$id", params: { id: data.id } });
@@ -461,16 +458,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 +474,6 @@ function SearchPage() {
});
candidatesShownAtRef.current = null;
setCandidates(null);
setCandidateSource(null);
navigate({
to: "/dashboard/vehicles/$id",
params: { id: data.id },
@@ -488,7 +482,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);

View File

@@ -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) {