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:
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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