feat: add parts catalogs integration, catalog prefetch worker, and vehicle select modal

Integrate external parts catalogs API with auth service, add BullMQ-based
catalog prefetch worker for background data caching, expand vehicles service
with shared vehicle support, and add vehicle select modal to frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-02-20 18:14:36 +00:00
parent f3f3f57756
commit f1a27810db
32 changed files with 2591 additions and 118 deletions

View File

@@ -24,8 +24,9 @@ export class VehiclesController {
async decode(
@CurrentUser("id") userId: string,
@Body("vin", VinValidationPipe) vin: string,
@Body("pcatCarId") pcatCarId?: string,
) {
return this.vehiclesService.decodeVin(vin, userId);
return this.vehiclesService.decodeVin(vin, userId, pcatCarId);
}
@Get("history")
@@ -63,6 +64,15 @@ export class VehiclesController {
return { sent: true };
}
@Get(":vehicleId/prefetch-status")
async prefetchStatus(
@Param("vehicleId") vehicleId: string,
@CurrentUser("id") userId: string,
) {
await this.vehiclesService.getById(vehicleId, userId);
return this.vehiclesService.getPrefetchStatus(vehicleId);
}
@Get(":vehicleId/categories/:categoryId")
async getCategoryParts(
@Param("vehicleId") vehicleId: string,

View File

@@ -5,11 +5,13 @@ import { CorgiModule } from "../integrations/corgi/corgi.module";
import { PL24Module } from "../integrations/pl24/pl24.module";
import { VinApiModule } from "../integrations/vin-api/vin-api.module";
import { EmexModule } from "../integrations/emex/emex.module";
import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module";
import { BrandsModule } from "../brands/brands.module";
import { CategoriesModule } from "../categories/categories.module";
import { JobsModule } from "../jobs/jobs.module";
@Module({
imports: [CorgiModule, PL24Module, VinApiModule, EmexModule, BrandsModule, CategoriesModule],
imports: [CorgiModule, PL24Module, VinApiModule, EmexModule, PartsCatalogsModule, BrandsModule, CategoriesModule, JobsModule],
controllers: [VehiclesController],
providers: [VehiclesService],
exports: [VehiclesService],

View File

@@ -57,16 +57,27 @@ function createService(dbOrOverrides: any = {}) {
del: vi.fn().mockResolvedValue(undefined),
};
const partsCatalogsService = {
decodeVin: vi.fn().mockResolvedValue(null),
isSupported: vi.fn().mockReturnValue(true),
};
const prefetchQueue = {
add: vi.fn().mockResolvedValue(undefined),
};
const service = new VehiclesService(
db as any,
prefetchQueue as any,
corgiService as any,
pl24Service as any,
vinApiService as any,
emexService as any,
partsCatalogsService as any,
redisService as any,
);
return { service, db, corgiService, pl24Service, vinApiService, emexService, redisService };
return { service, db, corgiService, pl24Service, vinApiService, emexService, partsCatalogsService, redisService };
}
describe("VehiclesService", () => {

View File

@@ -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/core";
import { CATALOG_PREFETCH_QUEUE } from "../jobs/queues/catalog-prefetch.queue";
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 vehicle = cached[0];
const age = Date.now() - new Date(vehicle.updatedAt).getTime();
if (existing && !pcatCarId) {
const age = 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;
let brandName = resolved.brandName;
const 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(vehicles.userId, userId)))
.innerJoin(userVehicles, eq(userVehicles.vehicleId, 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(vehicles)
.where(and(eq(vehicles.id, id), eq(vehicles.userId, userId)))
.delete(userVehicles)
.where(and(eq(userVehicles.vehicleId, id), eq(userVehicles.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,