@@ -6,13 +6,27 @@ import {
Logger ,
NotFoundException ,
} from "@nestjs/common" ;
import { Queue } from "bullmq" ;
import { eq , and , desc , or } from "drizzle-orm" ;
import { DATABASE , Database } from "../database/database.provider" ;
import { vehicles , queryLogs , brands , userBrands , userSubscriptions , plans } from "../database/schema/cor e";
import { CATALOG_PREFETCH_QUEUE } from "../jobs/queues/catalog-prefetch.queu e";
import type { PrefetchSource } from "../jobs/prefetch.types" ;
import {
vehicles ,
userVehicles ,
queryLogs ,
brands ,
userBrands ,
userSubscriptions ,
plans ,
parts ,
} from "../database/schema/core" ;
import { CorgiService } from "../integrations/corgi/corgi.service" ;
import { PL24Service } from "../integrations/pl24/pl24.service" ;
import { VinApiService } from "../integrations/vin-api/vin-api.service" ;
import { EmexService } from "../integrations/emex/emex.service" ;
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service" ;
import type { PcatCar } from "../integrations/parts-catalogs/parts-catalogs.types" ;
import { RedisService } from "../redis/redis.service" ;
import { isValidVin } from "@sase/shared" ;
@@ -27,6 +41,8 @@ interface VinResolveResult {
source : string ;
corgiKnown : boolean ;
corgiResult : any ;
/** When source is "parts-catalogs" and multiple cars found, these are the candidates */
pcatCandidates? : PcatCar [ ] ;
}
@Injectable ( )
@@ -35,57 +51,68 @@ export class VehiclesService {
constructor (
@Inject ( DATABASE ) private db : Database ,
@Inject ( CATALOG_PREFETCH_QUEUE ) private prefetchQueue : Queue ,
private corgiService : CorgiService ,
private pl24Service : PL24Service ,
private vinApiService : VinApiService ,
private emexService : EmexService ,
private partsCatalogsService : PartsCatalogsService ,
private redis : RedisService ,
) { }
async decodeVin ( vin : string , userId : string ) {
async decodeVin ( vin : string , userId : string , pcatCarId? : string ) {
const startTime = Date . now ( ) ;
if ( ! isValidVin ( vin ) ) {
throw new BadRequestException ( "Geçersiz şase numarası " ) ;
}
// 1. Cache check
const cached = await this . db
// 1. Check for shared vehicle config by VIN (no userId filter)
const [ existing ] = await this . db
. select ( )
. from ( vehicles )
. where ( and ( eq ( vehicles . userId , userId ) , eq( vehicles . vin , vin ) ) )
. where ( eq ( vehicles . vin , vin ) )
. limit ( 1 ) ;
if ( cached . length > 0 ) {
const vehicl e = cached [ 0 ] ;
const age = Date . now ( ) - new Date ( vehicle . updatedAt ) . getTime ( ) ;
if ( existing && ! pcatCarId ) {
const ag e = Date . now ( ) - new Date ( existing . updatedAt ) . getTime ( ) ;
if ( age < 24 * 60 * 60 * 1000 ) {
// Fresh cache (<24h)
await this . logQuery ( userId , vin , vehicle . brandId , "cache" , true , Date . now ( ) - startTime ) ;
return vehicle ;
// Fresh cache (<24h) — check brand access, link user, return
if ( existing . brandId ) {
await this . checkBrandAccess ( userId , existing . brandId ) ;
}
await this . ensureUserVehicleLink ( userId , existing . id ) ;
await this . logQuery ( userId , vin , existing . brandId , "cache" , true , Date . now ( ) - startTime ) ;
return existing ;
}
}
// 2. Resolve VIN via cached decode chain (Corgi → PL24 → EMEX)
const resolved = await this . resolveVin ( vin ) ;
// 2. Resolve VIN via cached decode chain (Corgi → PartsCatalogs → PL24 → EMEX)
const resolved = await this . resolveVin ( vin , pcatCarId );
if ( ! resolved ) {
await this . logQuery ( userId , vin , null , "corgi" , false , Date . now ( ) - startTime , "Unknown VIN/brand" ) ;
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 ) {
await this . logQuery ( userId , vin , null , "parts-catalogs" , true , Date . now ( ) - startTime ) ;
return { candidates : resolved.pcatCandidates , vin , source : "parts-catalogs" } ;
}
// 3. Brand access check
let brandId : string | null = null ;
le t brandName = resolved . brandName ;
cons t brandName = resolved . brandName ;
if ( brandName ) {
const brand = await this . db
const [ brand ] = await this . db
. select ( )
. from ( brands )
. where ( eq ( brands . name , brandName ) )
. limit ( 1 ) ;
if ( brand . length > 0 ) {
brandId = brand [ 0 ] .id ;
if ( brand ) {
brandId = brand . id ;
await this . checkBrandAccess ( userId , brandId ) ;
}
}
@@ -98,9 +125,8 @@ export class VehiclesService {
source = vinApiData ? "vin-api" : "corgi" ;
}
// 5. Save to DB
// 5. Upsert shared vehicle config (ON CONFLICT vin → UPDATE)
const vehicleData = {
userId ,
vin ,
brandId ,
brandName ,
@@ -115,17 +141,21 @@ export class VehiclesService {
updatedAt : new Date ( ) ,
} ;
let savedVehicle ;
if ( cached . length > 0 ) {
const [ updated ] = await this . db
. update ( vehicles )
. set ( vehicleData )
. where ( eq ( vehicles . id , cached [ 0 ] . id ) )
. returning ( ) ;
savedVehicle = updated ;
} else {
const [ inserted ] = await this . db . insert ( vehicles ) . values ( vehicleData ) . returning ( ) ;
savedVehicle = inserted ;
const [ savedVehicle ] = await this . db
. insert ( vehicles )
. values ( vehicleData )
. onConflictDoUpdate ( {
target : vehicles.vin ,
set : vehicleData ,
} )
. returning ( ) ;
// 6. Link user to this shared vehicle
await this . ensureUserVehicleLink ( userId , savedVehicle . id ) ;
// 7. Schedule background catalog prefetch
if ( source === "pl24" || source === "emex" || source === "parts-catalogs" ) {
await this . schedulePrefetch ( savedVehicle . id , source as PrefetchSource ) ;
}
await this . logQuery ( userId , vin , brandId , source , true , Date . now ( ) - startTime ) ;
@@ -158,9 +188,16 @@ export class VehiclesService {
/**
* Shared VIN decode chain with 5-minute Redis cache.
* Corgi (offline) → PL24 → EMEX fallback.
* Corgi (offline) → PartsCatalogs → PL24 fallback → EMEX fallback.
*
* @param pcatCarId If provided, skip resolve chain and use this specific PC car
*/
private async resolveVin ( vin : string ) : Promise < VinResolveResult | null > {
private async resolveVin ( vin : string , pcatCarId? : string ): Promise < VinResolveResult | null > {
// If user selected a specific PC car from candidates, resolve directly
if ( pcatCarId ) {
return this . resolvePcatCarById ( vin , pcatCarId ) ;
}
const cacheKey = ` vin:resolve: ${ vin } ` ;
const cached = await this . redis . getJson < VinResolveResult > ( cacheKey ) ;
if ( cached ) {
@@ -173,7 +210,44 @@ export class VehiclesService {
const corgiKnown = ! ! ( corgiResult && corgiResult . isKnown ) ;
let brandName = corgiKnown ? corgiResult.brandName : null ;
// 2. PL24 decode
// 2. PartsCatalogs (first external source)
let pcatCandidates : PcatCar [ ] | null = null ;
try {
const pcatResult = await this . partsCatalogsService . decodeVin ( vin ) ;
if ( pcatResult ? . cars ? . length === 1 ) {
// Single car → use directly
const car = pcatResult . cars [ 0 ] ;
if ( ! brandName ) brandName = this . extractBrandFromPcatCar ( car ) || null ;
const result : VinResolveResult = {
brandName ,
model : car.name || null ,
year : this.extractYearFromPcatCar ( car ) || corgiResult ? . modelYear || null ,
engine : this.extractParamFromPcatCar ( car , "engine" ) || null ,
transmission : this.extractParamFromPcatCar ( car , "transmission" ) || null ,
bodyType : this.extractParamFromPcatCar ( car , "body" ) || null ,
rawData : {
source : "parts-catalogs" ,
catalogId : car.catalogId ,
carId : car.id ,
parameters : car.parameters || [ ] ,
pcatCar : car ,
} ,
source : "parts-catalogs" ,
corgiKnown ,
corgiResult : corgiResult || null ,
} ;
await this . redis . setJson ( cacheKey , result , 300 ) ;
return result ;
}
if ( pcatResult ? . cars && pcatResult . cars . length > 1 ) {
pcatCandidates = pcatResult . cars ;
this . logger . log ( ` PartsCatalogs returned ${ pcatCandidates . length } candidates for ${ vin } ` ) ;
}
} catch ( err ) {
this . logger . warn ( ` PartsCatalogs decode failed for ${ vin } : ${ ( err as Error ) . message } ` ) ;
}
// 3. PL24 (if PC had multiple results, or PC failed entirely)
let pl24Vehicle : any = null ;
if ( this . pl24Service . isSupported ( vin ) ) {
try {
@@ -186,7 +260,42 @@ export class VehiclesService {
}
}
// 3. EMEX fallback
// If PL24 succeeded, use PL24 regardless of PC candidates
if ( pl24Vehicle ) {
const result : VinResolveResult = {
brandName : brandName || corgiResult ? . brandName || null ,
model : pl24Vehicle.model || null ,
year : pl24Vehicle.year || corgiResult ? . modelYear || null ,
engine : pl24Vehicle.engineType || pl24Vehicle . engineCode || null ,
transmission : pl24Vehicle.transmission || null ,
bodyType : pl24Vehicle.bodyType || null ,
rawData : pl24Vehicle ,
source : "pl24" ,
corgiKnown ,
corgiResult : corgiResult || null ,
} ;
await this . redis . setJson ( cacheKey , result , 300 ) ;
return result ;
}
// 3b. PL24 failed + PC had multiple candidates → return candidates for user selection
if ( pcatCandidates && pcatCandidates . length > 1 ) {
return {
brandName ,
model : null ,
year : null ,
engine : null ,
transmission : null ,
bodyType : null ,
rawData : null ,
source : "parts-catalogs" ,
corgiKnown ,
corgiResult : corgiResult || null ,
pcatCandidates ,
} ;
}
// 4. EMEX fallback (slowest, browser-based)
let emexVehicle : import ( "../integrations/emex/emex.types" ) . DecodedVehicle | null = null ;
if ( ! pl24Vehicle ) {
try {
@@ -201,25 +310,20 @@ export class VehiclesService {
}
// Nothing recognized this VIN
if ( ! pl24Vehicle && ! emexVehicle && ! corgiKnown ) {
if ( ! emexVehicle && ! corgiKnown ) {
return null ;
}
const source = pl24Vehicle ? "pl24" : emexVehicle ? "emex" : "corgi" ;
const resolvedSource = emexVehicle ? "emex" : "corgi" ;
const result : VinResolveResult = {
brandName : brandName || corgiResult ? . brandName || emexVehicle ? . brand || null ,
model : pl24Vehicle?.model || emexVehicle? . model || null ,
year : pl24Vehicle?.year || emexVehicle? . year || corgiResult ? . modelYear || null ,
engine :
pl24Vehicle?.engineType ||
pl24Vehicle ? . engineCode ||
emexVehicle ? . engineCode ||
emexVehicle ? . engineType ||
null ,
transmission : pl24Vehicle?.transmission || emexVehicle ? . transmission || null ,
bodyType : pl24Vehicle?.bodyType || emexVehicle ? . bodyType || null ,
rawData : pl24Vehicle || emexVehicle ? . raw || null ,
source ,
model : emexVehicle?. model || null ,
year : emexVehicle?. year || corgiResult ? . modelYear || null ,
engine : emexVehicle?.engineCode || emexVehicle ? . engineType || null ,
transmission : emexVehicle?.transmission || null ,
bodyType : emexVehicle?.bodyType || null ,
rawData : emexVehicle?.raw || null ,
source : resolvedSource ,
corgiKnown ,
corgiResult : corgiResult || null ,
} ;
@@ -228,38 +332,176 @@ export class VehiclesService {
return result ;
}
/**
* Resolve a specific PartsCatalogs car by ID (after user selects from candidates).
*/
private async resolvePcatCarById ( vin : string , pcatCarId : string ) : Promise < VinResolveResult | null > {
const corgiResult = this . corgiService . decodeVin ( vin ) ;
const corgiKnown = ! ! ( corgiResult && corgiResult . isKnown ) ;
let brandName = corgiKnown ? corgiResult.brandName : null ;
// Re-decode VIN to get fresh car list, then find the selected car
const pcatResult = await this . partsCatalogsService . decodeVin ( vin ) ;
const car = pcatResult ? . cars ? . find ( ( c ) = > c . id === pcatCarId ) ;
if ( ! car ) {
this . logger . warn ( ` PartsCatalogs car ${ pcatCarId } not found for ${ vin } ` ) ;
return null ;
}
if ( ! brandName ) brandName = this . extractBrandFromPcatCar ( car ) || null ;
return {
brandName ,
model : car.name || null ,
year : this.extractYearFromPcatCar ( car ) || corgiResult ? . modelYear || null ,
engine : this.extractParamFromPcatCar ( car , "engine" ) || null ,
transmission : this.extractParamFromPcatCar ( car , "transmission" ) || null ,
bodyType : this.extractParamFromPcatCar ( car , "body" ) || null ,
rawData : {
source : "parts-catalogs" ,
catalogId : car.catalogId ,
carId : car.id ,
parameters : car.parameters || [ ] ,
pcatCar : car ,
} ,
source : "parts-catalogs" ,
corgiKnown ,
corgiResult : corgiResult || null ,
} ;
}
// ─── PartsCatalogs helpers ─────────────────────────────
/** Map common parts-catalogs catalog IDs to brand display names */
private static readonly PCAT_CATALOG_BRAND_MAP : Record < string , string > = {
vw : "Volkswagen" , volkswagen : "Volkswagen" ,
bmw : "BMW" , mercedes : "Mercedes-Benz" , "mercedes-benz" : "Mercedes-Benz" ,
audi : "Audi" , porsche : "Porsche" , skoda : "Skoda" ,
seat : "Seat" , ford : "Ford" , opel : "Opel" ,
renault : "Renault" , peugeot : "Peugeot" , citroen : "Citroen" ,
fiat : "Fiat" , toyota : "Toyota" , honda : "Honda" ,
hyundai : "Hyundai" , kia : "Kia" , nissan : "Nissan" ,
mazda : "Mazda" , subaru : "Subaru" , volvo : "Volvo" ,
jaguar : "Jaguar" , "land-rover" : "Land Rover" , landrover : "Land Rover" ,
mini : "Mini" , dacia : "Dacia" , suzuki : "Suzuki" ,
mitsubishi : "Mitsubishi" , chevrolet : "Chevrolet" ,
} ;
private extractBrandFromPcatCar ( car : PcatCar ) : string | null {
if ( ! car . catalogId ) return null ;
const key = car . catalogId . toLowerCase ( ) ;
return VehiclesService . PCAT_CATALOG_BRAND_MAP [ key ] || null ;
}
private extractYearFromPcatCar ( car : PcatCar ) : number | null {
if ( ! car . parameters ) return null ;
const yearParam = car . parameters . find (
( p ) = > p . key . toLowerCase ( ) . includes ( "year" ) || p . key . toLowerCase ( ) . includes ( "model_year" ) ,
) ;
if ( yearParam ? . value ) {
const num = parseInt ( yearParam . value , 10 ) ;
if ( num > 1900 && num < 2100 ) return num ;
}
return null ;
}
private extractParamFromPcatCar ( car : PcatCar , keyword : string ) : string | null {
if ( ! car . parameters ) return null ;
const param = car . parameters . find ( ( p ) = > p . key . toLowerCase ( ) . includes ( keyword ) ) ;
return param ? . value || null ;
}
/**
* Get user's vehicle history via junction table.
* Ordered by lastAccessedAt (most recent first).
*/
async getHistory ( userId : string , page = 1 , limit = 20 ) {
const offset = ( page - 1 ) * limit ;
return this . db
. select ( )
. from ( vehicles)
. where ( eq ( vehicles . userId , userId ) )
. orderBy ( desc ( vehicles . updatedAt ) )
. select ( {
id : vehicles.id ,
vin : vehicles.vin ,
brandId : vehicles.brandId ,
brandName : vehicles.brandName ,
model : vehicles.model ,
year : vehicles.year ,
engine : vehicles.engine ,
transmission : vehicles.transmission ,
bodyType : vehicles.bodyType ,
market : vehicles.market ,
rawData : vehicles.rawData ,
source : vehicles.source ,
createdAt : vehicles.createdAt ,
updatedAt : vehicles.updatedAt ,
lastAccessedAt : userVehicles.lastAccessedAt ,
} )
. from ( userVehicles )
. innerJoin ( vehicles , eq ( userVehicles . vehicleId , vehicles . id ) )
. where ( eq ( userVehicles . userId , userId ) )
. orderBy ( desc ( userVehicles . lastAccessedAt ) )
. limit ( limit )
. offset ( offset ) ;
}
/**
* Get vehicle by ID — verify user has access via junction table.
*/
async getById ( id : string , userId : string ) {
const result = await this . db
. select ( )
const [ result ] = await this . db
. select ( {
id : vehicles.id ,
vin : vehicles.vin ,
brandId : vehicles.brandId ,
brandName : vehicles.brandName ,
model : vehicles.model ,
year : vehicles.year ,
engine : vehicles.engine ,
transmission : vehicles.transmission ,
bodyType : vehicles.bodyType ,
market : vehicles.market ,
rawData : vehicles.rawData ,
source : vehicles.source ,
createdAt : vehicles.createdAt ,
updatedAt : vehicles.updatedAt ,
} )
. from ( vehicles )
. where ( and ( eq ( vehicles . id , id ) , eq ( v ehicles. user Id, userId ) ) )
. innerJoin ( userVehicles , eq ( userV ehicles. vehicle Id, vehicles . id ) )
. where ( and ( eq ( vehicles . id , id ) , eq ( userVehicles . userId , userId ) ) )
. limit ( 1 ) ;
if ( result . length === 0 ) throw new NotFoundException ( "Araç bulunamadı " ) ;
return result [ 0 ] ;
if ( ! result ) throw new NotFoundException ( "Araç bulunamadı " ) ;
return result ;
}
/**
* Delete user's link to a vehicle (junction record only).
* The shared vehicle config and its categories/parts remain intact.
*/
async deleteVehicle ( id : string , userId : string ) {
const result = await this . db
. delete ( v ehicles)
. where ( and ( eq ( v ehicles. i d, id ) , eq ( v ehicles. userId , userId ) ) )
. delete ( userV ehicles)
. where ( and ( eq ( userV ehicles. vehicleI d, id ) , eq ( userV ehicles. userId , userId ) ) )
. returning ( ) ;
if ( result . length === 0 ) throw new NotFoundException ( "Araç bulunamadı " ) ;
return { deleted : true } ;
}
/**
* Ensure a user ↔ vehicle link exists in the junction table.
* Uses ON CONFLICT to update lastAccessedAt if already linked.
*/
private async ensureUserVehicleLink ( userId : string , vehicleId : string ) {
await this . db
. insert ( userVehicles )
. values ( { userId , vehicleId , lastAccessedAt : new Date ( ) } )
. onConflictDoUpdate ( {
target : [ userVehicles . userId , userVehicles . vehicleId ] ,
set : { lastAccessedAt : new Date ( ) } ,
} ) ;
}
private async checkBrandAccess ( userId : string , brandId : string ) {
const [ sub ] = await this . db
. select ( {
@@ -295,6 +537,50 @@ export class VehiclesService {
}
}
/**
* Schedule background catalog prefetch for a vehicle.
* Skips if vehicle already has parts in DB (= already prefetched).
* Uses a short-lived Redis key to prevent duplicate scheduling within the delay window.
*/
private async schedulePrefetch ( vehicleId : string , source : PrefetchSource ) {
// Skip if already queued (5min TTL covers the initial delay window)
const key = ` prefetch:scheduled: ${ vehicleId } ` ;
const queued = await this . redis . exists ( key ) ;
if ( queued ) return ;
// Skip if vehicle already has parts in DB (= previously prefetched)
const [ partCheck ] = await this . db
. select ( { id : parts.id } )
. from ( parts )
. where ( eq ( parts . vehicleId , vehicleId ) )
. limit ( 1 ) ;
if ( partCheck ) return ;
try {
await this . prefetchQueue . add (
"prefetch-init" ,
{ vehicleId , source } ,
{
jobId : ` prefetch: ${ vehicleId } ` ,
delay : 5 * 60 * 1000 , // 5 min initial delay
} ,
) ;
await this . redis . set ( key , "1" , 600 ) ; // 10min TTL — just to prevent double-scheduling
this . logger . log ( ` [prefetch] Scheduled for vehicle= ${ vehicleId } , source= ${ source } ` ) ;
} catch ( err ) {
this . logger . warn ( ` [prefetch] Failed to schedule: ${ ( err as Error ) . message } ` ) ;
}
}
/**
* Get prefetch progress for a vehicle (Redis hash).
*/
async getPrefetchStatus ( vehicleId : string ) {
const { getProgress } = await import ( "../jobs/prefetch-utils" ) ;
return getProgress ( this . redis , vehicleId ) ;
}
private async logQuery (
userId : string ,
vin : string ,