@@ -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 . e mexCandidates . length > 1 ) {
await thi s. 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 E mexCandidate[ ] ) ;
return { candidates : this.toPublicCandidate s( 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).
*/