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

@@ -69,6 +69,7 @@
"playwright": "^1.50.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"undici": "^7.22.0",
"vitest": "^3.0.0"
}
}

View File

@@ -3,9 +3,10 @@ import { CategoriesController } from "./categories.controller";
import { CategoriesService } from "./categories.service";
import { PL24Module } from "../integrations/pl24/pl24.module";
import { EmexModule } from "../integrations/emex/emex.module";
import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module";
@Module({
imports: [PL24Module, EmexModule],
imports: [PL24Module, EmexModule, PartsCatalogsModule],
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService],

View File

@@ -18,7 +18,11 @@ function createService(db: any) {
const storage = {
upload: vi.fn().mockResolvedValue("https://storage.sase.tr/sase-schemas/test.png"),
};
const service = new CategoriesService(db as any, redis as any, pl24Service as any, emexService as any, storage as any);
const partsCatalogsService = {
fetchGroups: vi.fn().mockResolvedValue([]),
fetchParts: vi.fn().mockResolvedValue(null),
};
const service = new CategoriesService(db as any, redis as any, pl24Service as any, emexService as any, partsCatalogsService as any, storage as any);
return { service, db, redis, pl24Service };
}

View File

@@ -5,6 +5,7 @@ import { categories, vehicles, schemaPics, parts } from "../database/schema/core
import { RedisService } from "../redis/redis.service";
import { PL24Service } from "../integrations/pl24/pl24.service";
import { EmexService } from "../integrations/emex/emex.service";
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
import { StorageService } from "../storage/storage.service";
@Injectable()
@@ -16,6 +17,7 @@ export class CategoriesService {
private redis: RedisService,
private pl24Service: PL24Service,
private emexService: EmexService,
private partsCatalogsService: PartsCatalogsService,
private storage: StorageService,
) {}
@@ -80,6 +82,46 @@ export class CategoriesService {
}
}
// If still no categories, try PartsCatalogs
if (dbCategories.length === 0 && vehicle.rawData) {
const rawData = vehicle.rawData as any;
if (rawData?.source === "parts-catalogs" && rawData.catalogId && rawData.carId) {
try {
const carParams = this.buildPcatCarParams(rawData.parameters);
const groups = await this.partsCatalogsService.fetchGroups(
rawData.catalogId,
rawData.carId,
undefined,
carParams,
);
if (groups.length > 0) {
const insertData = groups.map((g) => ({
vehicleId,
name: g.name,
nameOriginal: g.name,
parentId: null as string | null,
externalId: g.id,
linkPath: `pcat:${rawData.catalogId}:${rawData.carId}:${g.id}`,
linkWid: null as string | null,
source: "parts-catalogs" as const,
}));
dbCategories = await this.db.insert(categories).values(insertData).onConflictDoNothing().returning();
if (dbCategories.length < insertData.length) {
dbCategories = await this.db
.select()
.from(categories)
.where(eq(categories.vehicleId, vehicleId));
}
this.logger.log(`Stored ${dbCategories.length} PartsCatalogs categories for ${vehicle.vin}`);
}
} catch (err) {
this.logger.warn(`PartsCatalogs category fetch failed for ${vehicleId}: ${(err as Error).message}`);
}
}
}
// If still no categories, try EMEX fallback
if (dbCategories.length === 0 && vehicle.vin) {
this.logger.log(`No PL24 categories for ${vehicle.vin}, trying EMEX fallback`);
@@ -219,6 +261,51 @@ export class CategoriesService {
const catalogInfo = rawData?.catalogInfo;
const linkPath = category.linkPath;
// PartsCatalogs subgroups (on-demand drill-down)
if (category.source === "parts-catalogs" && rawData?.source === "parts-catalogs" && category.externalId) {
try {
const carParams = this.buildPcatCarParams(rawData.parameters);
const subGroups = await this.partsCatalogsService.fetchGroups(
rawData.catalogId,
rawData.carId,
category.externalId,
carParams,
);
if (subGroups.length > 0) {
const insertData = subGroups.map((g) => ({
vehicleId: category.vehicleId,
name: g.name,
nameOriginal: g.name,
parentId: categoryId,
externalId: g.id,
linkPath: `pcat:${rawData.catalogId}:${rawData.carId}:${g.id}`,
linkWid: null as string | null,
source: "parts-catalogs" as const,
}));
children = await this.db
.insert(categories)
.values(insertData)
.onConflictDoNothing()
.returning();
if (children.length < insertData.length) {
children = await this.db
.select()
.from(categories)
.where(eq(categories.parentId, categoryId));
}
await this.redis.del(`cat:tree:${category.vehicleId}`);
}
} catch (err) {
this.logger.error(`PartsCatalogs subgroup fetch failed for ${categoryId} (externalId=${category.externalId}): ${(err as Error).message}`);
}
return children.length > 0 ? this.enrichWithSchemaImages(children) : children;
}
if (!catalogInfo?.serviceName || !linkPath) {
return [];
}
@@ -290,11 +377,16 @@ export class CategoriesService {
if (!category) throw new NotFoundException("Kategori bulunamadı");
// Check if this category has children
const children = await this.db
let children = await this.db
.select()
.from(categories)
.where(eq(categories.parentId, categoryId));
// For parts-catalogs categories with no DB children, fetch from API first
if (children.length === 0 && category.source === "parts-catalogs" && category.linkPath?.startsWith("pcat:")) {
children = await this.getChildren(categoryId);
}
if (children.length > 0) {
return {
id: category.id,
@@ -333,7 +425,117 @@ export class CategoriesService {
.where(eq(vehicles.id, category.vehicleId))
.limit(1);
if (vehicle && category.source === "emex") {
if (vehicle && category.source === "parts-catalogs" && category.linkPath?.startsWith("pcat:")) {
// PartsCatalogs: fetch parts + schema image via API
try {
// Format: pcat:{catalogId}:{carId}:{groupId} — split only on first 3 colons
const withoutPrefix = category.linkPath.slice("pcat:".length);
const firstColon = withoutPrefix.indexOf(":");
const secondColon = withoutPrefix.indexOf(":", firstColon + 1);
const catalogId = withoutPrefix.slice(0, firstColon);
const carId = withoutPrefix.slice(firstColon + 1, secondColon);
const groupId = withoutPrefix.slice(secondColon + 1);
const vehicleRawData = vehicle.rawData as any;
const carParams = this.buildPcatCarParams(vehicleRawData?.parameters);
const partsResult = await this.partsCatalogsService.fetchParts(
catalogId,
carId,
groupId,
carParams,
);
if (partsResult) {
// Flatten part groups into parts
if (needParts) {
const allParts: Array<typeof parts.$inferInsert> = [];
for (const pg of partsResult.partGroups) {
for (const p of pg.parts) {
if (!p.number) continue;
const posNum = p.positionNumber || pg.positionNumber || null;
allParts.push({
vehicleId: vehicle.id,
categoryId,
oemCode: p.number,
name: p.name || "Unknown",
nameOriginal: p.name || null,
description: p.notice || null,
quantity: null,
position: posNum,
hotspotIndex: posNum ? parseInt(posNum, 10) || null : null,
unavailable: false,
remark: null as string | null,
modelCodes: null as string | null,
presel: false,
source: "parts-catalogs" as const,
});
}
}
if (allParts.length > 0) {
dbParts = await this.db.insert(parts).values(allParts).returning();
this.logger.log(`Stored ${dbParts.length} PartsCatalogs parts for category ${categoryId}`);
}
}
// Download schema image and store hotspots
if (needImage && partsResult.img) {
try {
const imgUrl = partsResult.img.startsWith("//") ? `https:${partsResult.img}` : partsResult.img;
const imgResp = await fetch(imgUrl, {
signal: AbortSignal.timeout(15000),
});
if (imgResp.ok) {
const buf = Buffer.from(await imgResp.arrayBuffer());
const ext = partsResult.img.includes(".gif") ? "gif" : "png";
const key = `schemas/pcat-${categoryId}.${ext}`;
const minioUrl = await this.storage.upload(key, buf, `image/${ext}`);
const dims = this.getImageDimensions(buf, ext);
// Convert positions to hotspot format
const hotspotItems = partsResult.positions.map((pos) => ({
key: pos.number,
label: pos.number,
areas: [
{
left: pos.coordinates[0],
top: pos.coordinates[1],
width: pos.coordinates[2],
height: pos.coordinates[3],
},
],
}));
const [inserted] = await this.db
.insert(schemaPics)
.values({
categoryId,
imageUrl: minioUrl,
hotspots: JSON.stringify({
width: dims.width,
height: dims.height,
items: hotspotItems,
}),
source: "parts-catalogs",
})
.returning();
pics.push(inserted);
this.logger.log(
`Stored PartsCatalogs schema image for category ${categoryId}: ${minioUrl}`,
);
}
} catch (imgErr) {
this.logger.warn(`Failed to download PC schema image: ${(imgErr as Error).message}`);
}
}
}
} catch (err) {
this.logger.error(`Failed to fetch PartsCatalogs parts for category ${categoryId}: ${(err as Error).message}`);
}
} else if (vehicle && category.source === "emex") {
// EMEX: fetch parts + schema image via Puppeteer from QuickDetails URL
try {
const emexResult = await this.emexService.fetchCategoryParts(category.linkPath);
@@ -585,9 +787,12 @@ export class CategoriesService {
const dbChildCount = childCountMap.get(c.id) || 0;
// EMEX: leaf only if linkPath exists and no DB children
// PL24: leaf if BOM/servicepart-items linkPath, or no linkPath and no DB children
// PartsCatalogs: leaf if linkPath starts with "pcat:" and no DB children
const isLeaf = c.source === "emex"
? (!!c.linkPath && dbChildCount === 0)
: (c.linkPath?.includes("/bom/") || c.linkPath?.includes("/bomdetails") || c.linkPath?.includes("/partinfo/") || c.linkPath?.includes("/servicepart/vin_items") || (!c.linkPath && dbChildCount === 0));
: c.source === "parts-catalogs"
? (!!c.linkPath?.startsWith("pcat:") && dbChildCount === 0)
: (c.linkPath?.includes("/bom/") || c.linkPath?.includes("/bomdetails") || c.linkPath?.includes("/partinfo/") || c.linkPath?.includes("/servicepart/vin_items") || (!c.linkPath && dbChildCount === 0));
return {
...c,
schemaImageUrl: picMap.get(c.id) || null,
@@ -596,6 +801,21 @@ export class CategoriesService {
});
}
/**
* Build query params from PartsCatalogs car parameters.
* Parameters are [{key, idx, value}] — API expects {key: idx} as query params.
*/
private buildPcatCarParams(parameters?: Array<{ key: string; idx: string; value: string }>): Record<string, string> {
if (!parameters || !Array.isArray(parameters)) return {};
const params: Record<string, string> = {};
for (const p of parameters) {
if (p.key && p.idx) {
params[p.key] = p.idx;
}
}
return params;
}
private getImageDimensions(buf: Buffer, ext: string): { width: number; height: number } {
try {
if (ext === 'gif' && buf.length >= 10) {

View File

@@ -7,17 +7,6 @@ export interface TransformedResponse<T> {
meta?: Record<string, unknown>;
}
function stripSource(data: unknown): unknown {
if (Array.isArray(data)) return data.map(stripSource);
if (data && typeof data === "object" && !(data instanceof Date)) {
const { source, ...rest } = data as Record<string, unknown>;
return Object.fromEntries(
Object.entries(rest).map(([k, v]) => [k, stripSource(v)]),
);
}
return data;
}
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, TransformedResponse<T>> {
intercept(
@@ -28,21 +17,21 @@ export class TransformInterceptor<T> implements NestInterceptor<T, TransformedRe
map((data) => {
// If already wrapped, pass through
if (data && typeof data === "object" && "success" in data) {
return stripSource(data) as TransformedResponse<T>;
return data as TransformedResponse<T>;
}
// Handle pagination response
if (data && typeof data === "object" && "items" in data && "meta" in data) {
return {
success: true,
data: stripSource(data.items),
data: data.items,
meta: data.meta,
} as TransformedResponse<T>;
}
return {
success: true,
data: stripSource(data),
data,
} as TransformedResponse<T>;
}),
);

View File

@@ -231,14 +231,11 @@ export const oemCodeCopies = pgTable(
],
);
// ─── Vehicles ───────────────────────────────────────
// ─── Vehicles (shared config — one record per VIN) ──
export const vehicles = pgTable(
"vehicles",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
vin: varchar("vin", { length: 17 }).notNull(),
brandId: uuid("brand_id").references(() => brands.id),
brandName: varchar("brand_name", { length: 100 }),
@@ -254,9 +251,27 @@ export const vehicles = pgTable(
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("vehicles_user_id_idx").on(table.userId),
index("vehicles_vin_idx").on(table.vin),
uniqueIndex("vehicles_user_vin_idx").on(table.userId, table.vin),
uniqueIndex("vehicles_vin_unique_idx").on(table.vin),
],
);
// ─── User Vehicles (junction — user ↔ shared vehicle) ─
export const userVehicles = pgTable(
"user_vehicles",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
vehicleId: uuid("vehicle_id")
.notNull()
.references(() => vehicles.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
lastAccessedAt: timestamp("last_accessed_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("user_vehicles_user_vehicle_idx").on(table.userId, table.vehicleId),
index("user_vehicles_user_id_idx").on(table.userId),
],
);
@@ -271,7 +286,7 @@ export const categories = pgTable(
name: varchar("name", { length: 500 }).notNull(),
nameOriginal: varchar("name_original", { length: 500 }),
parentId: uuid("parent_id"),
externalId: varchar("external_id", { length: 100 }),
externalId: varchar("external_id", { length: 500 }),
linkPath: text("link_path"),
linkWid: varchar("link_wid", { length: 100 }),
unavailable: boolean("unavailable").default(false).notNull(),
@@ -285,24 +300,6 @@ export const categories = pgTable(
],
);
// ─── Vehicle Categories (junction) ──────────────────
export const vehicleCategories = pgTable(
"vehicle_categories",
{
id: uuid("id").primaryKey().defaultRandom(),
vehicleId: uuid("vehicle_id")
.notNull()
.references(() => vehicles.id, { onDelete: "cascade" }),
categoryId: uuid("category_id")
.notNull()
.references(() => categories.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("vehicle_categories_unique_idx").on(table.vehicleId, table.categoryId),
],
);
// ─── Parts ──────────────────────────────────────────
export const parts = pgTable(
"parts",

View File

@@ -0,0 +1,82 @@
import {
pgTable,
uuid,
varchar,
text,
boolean,
serial,
timestamp,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
// ─── PCAT Vehicles — VIN decode results ────────────
export const pcatVehicles = pgTable(
"pcat_vehicles",
{
id: text("id").primaryKey(), // parts-catalogs car ID
catalogId: text("catalog_id").notNull(),
vin: varchar("vin", { length: 17 }),
name: text("name").notNull(),
description: text("description"),
parameters: jsonb("parameters"), // [{key, idx, value}]
rawData: jsonb("raw_data"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("pcat_vehicles_vin_idx").on(table.vin),
index("pcat_vehicles_catalog_id_idx").on(table.catalogId),
],
);
// ─── PCAT Part Groups — Hierarchical categories ────
export const pcatPartGroups = pgTable(
"pcat_part_groups",
{
id: text("id").primaryKey(), // parts-catalogs group ID
catalogId: text("catalog_id").notNull(),
parentId: text("parent_id"),
name: text("name").notNull(),
imgUrl: text("img_url"),
hasSubgroups: boolean("has_subgroups").default(false).notNull(),
hasParts: boolean("has_parts").default(false).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("pcat_part_groups_catalog_id_idx").on(table.catalogId),
index("pcat_part_groups_parent_id_idx").on(table.parentId),
],
);
// ─── PCAT Parts — OEM parts ────────────────────────
export const pcatParts = pgTable(
"pcat_parts",
{
id: text("id").primaryKey(), // OEM number as PK
name: text("name").notNull(),
notice: text("notice"),
description: text("description"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("pcat_parts_name_idx").on(table.name),
],
);
// ─── PCAT Schema Pics — Schema images + hotspots ───
export const pcatSchemaPics = pgTable(
"pcat_schema_pics",
{
id: serial("id").primaryKey(),
groupId: text("group_id").notNull(),
carId: text("car_id").notNull(),
imgUrl: text("img_url").notNull(),
imgDescription: text("img_description"),
hotspots: jsonb("hotspots"), // positions array from API
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("pcat_schema_pics_group_car_idx").on(table.groupId, table.carId),
],
);

View File

@@ -10,6 +10,7 @@ import {
payments,
queryLogs,
vehicles,
userVehicles,
categories,
parts,
schemaPics,
@@ -23,7 +24,7 @@ export const usersRelations = relations(users, ({ many }) => ({
userBrands: many(userBrands),
payments: many(payments),
queryLogs: many(queryLogs),
vehicles: many(vehicles),
userVehicles: many(userVehicles),
referralsGiven: many(referrals, { relationName: "referrer" }),
referralsReceived: many(referrals, { relationName: "referred" }),
}));
@@ -74,12 +75,17 @@ export const queryLogsRelations = relations(queryLogs, ({ one }) => ({
}));
export const vehiclesRelations = relations(vehicles, ({ one, many }) => ({
user: one(users, { fields: [vehicles.userId], references: [users.id] }),
brand: one(brands, { fields: [vehicles.brandId], references: [brands.id] }),
userVehicles: many(userVehicles),
categories: many(categories),
parts: many(parts),
}));
export const userVehiclesRelations = relations(userVehicles, ({ one }) => ({
user: one(users, { fields: [userVehicles.userId], references: [users.id] }),
vehicle: one(vehicles, { fields: [userVehicles.vehicleId], references: [vehicles.id] }),
}));
export const categoriesRelations = relations(categories, ({ one, many }) => ({
vehicle: one(vehicles, { fields: [categories.vehicleId], references: [vehicles.id] }),
parent: one(categories, {

View File

@@ -28,6 +28,7 @@ import {
} from './emex.types';
import { mapEmexResponse, createEmptyDecodedVehicle } from './emex.mapper';
import { EmexBrowserService } from './emex.browser';
import { RedisService } from '../../redis/redis.service';
// Type definition for the imported scraper module
interface EmexScraperModule {
@@ -66,6 +67,7 @@ export class EmexService {
constructor(
private configService: ConfigService,
private browserService: EmexBrowserService,
private redis: RedisService,
) {
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
@@ -82,6 +84,15 @@ export class EmexService {
this.logger.log(`EMEX Service initialized with scraper path: ${this.scraperPath}`);
}
/** Mark EMEX as actively used (5min TTL) to defer prefetch worker */
private async touchActivity(): Promise<void> {
try {
await this.redis.set("prefetch:activity:emex", String(Date.now()), 300);
} catch {
// Non-critical — don't break the request
}
}
/**
* Lazily initialize the scraper module
*/
@@ -184,6 +195,7 @@ export class EmexService {
const supported = this.isSupported(cleanVin);
this.logger.log(`Decoding VIN: ${cleanVin} (catalog supported: ${supported})`);
await this.touchActivity();
let release: (() => Promise<void>) | null = null;
@@ -375,6 +387,7 @@ export class EmexService {
}
this.logger.log(`Fetching parts from category URL: ${categoryUrl}`);
await this.touchActivity();
let release: (() => Promise<void>) | null = null;

View File

@@ -0,0 +1,685 @@
/**
* Parts-Catalogs Auth Service — JWT warm pool via Playwright + DataImpulse proxy
*
* JWT is captured by navigating to partner sites and intercepting
* the Authorization header from requests to parts-catalogs.com.
* JWT is IP-bound (~10 min TTL), so the same proxy port must be used for both
* browser capture and subsequent API calls.
*
* Warm pool behavior:
* 09:00-19:00 Istanbul → proactive: maintain >= 1 slot, auto-refresh before expiry
* 19:00-09:00 → on-demand only: capture only when needed
*
* Each slot manages its own refresh timer (no polling loop).
* Dynamic scaling: 1 JWT per 6 req/min, capped at 5 slots.
*/
import {
Injectable,
Logger,
OnModuleInit,
OnModuleDestroy,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Browser, BrowserContext } from "playwright";
import type { PcatJwtToken, JwtSlot, PcatSession } from "./parts-catalogs.types";
const REFRESH_BUFFER = 90; // Refresh 90s before expiry
const CAPTURE_POLL_INTERVAL = 500; // ms
const CAPTURE_POLL_MAX = 40; // 40 × 500ms = 20s max wait
const PAGE_TIMEOUT = 30_000; // 30s navigation timeout
const CONTEXT_CLOSE_TIMEOUT = 5_000;
const SITE_COOLDOWN = 10 * 60 * 1000; // 10 min per site
const MAX_POOL_SIZE = 5;
const RPM_WINDOW = 60_000; // 1-minute rolling window
const RPM_PER_SLOT = 6; // 1 JWT per 6 req/min
/**
* Sites that embed the parts-catalogs.com widget.
* Widget loads JS → calls /api/start → then calls /v1/catalogs/ with JWT.
* Each site uses a different proxy port (IP) to avoid rate limiting.
*/
const JWT_SITES = [
"https://www.e-acca.com/cats/#/catalogs",
"https://www.alkatalog.com/cats/#/catalogs",
"https://auto-komplekt.ru/goodvin#/catalogs",
"https://www.autotrade.md/cats/#/catalogs",
"https://www.e-trak.ru/cats/#/catalogs",
"https://www.autopolyus.ru/cats/#/catalogs",
"https://knkauto.ru/goodvin#/catalogs",
"https://www.autodo.kz/#/catalogs",
"https://avtoman124.ru/goodvin#/catalogs",
"https://flynestauto.com/auto-parts-oem-catalog",
"http://en.demo.tradesoft.hk.com/cats/#/catalogs",
];
// DataImpulse proxy defaults (port-based IP rotation)
const DI_HOST = "gw.dataimpulse.com";
const DI_PORT_MIN = 10000;
const DI_PORT_MAX = 10999;
const DI_DEFAULT_USER = "1726bbe361918676d44e";
const DI_DEFAULT_PASS = "f11c7b6128cc86c6";
/** Simple counting semaphore (same pattern as EmexBrowserService) */
class Semaphore {
private current = 0;
private queue: Array<() => void> = [];
constructor(private readonly max: number) {}
acquire(): Promise<void> {
if (this.current < this.max) {
this.current++;
return Promise.resolve();
}
return new Promise<void>((resolve) => {
this.queue.push(resolve);
});
}
release(): void {
const next = this.queue.shift();
if (next) {
next();
} else {
this.current--;
}
}
}
@Injectable()
export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PartsCatalogsAuthService.name);
private browser: Browser | null = null;
private launching: Promise<void> | null = null;
private readonly semaphore = new Semaphore(1); // Max 1 concurrent JWT capture
// Pool state
private pool: JwtSlot[] = [];
private siteLastUsedAt = new Map<string, number>();
private siteIndex = 0; // round-robin across JWT_SITES
private requestRoundRobin = 0; // round-robin across pool slots
// Business hours scheduling
private businessHoursTimer: ReturnType<typeof setTimeout> | null = null;
// RPM tracking
private requestTimestamps: number[] = [];
// Config
private readonly useProxy: boolean;
private readonly proxyHost: string;
private readonly proxyUser: string;
private readonly proxyPass: string;
constructor(private configService: ConfigService) {
this.useProxy =
this.configService.get<string>("PCAT_USE_PROXY", "true") === "true";
this.proxyHost = this.configService.get<string>("PCAT_PROXY_HOST", DI_HOST);
this.proxyUser = this.configService.get<string>(
"PCAT_PROXY_USER",
DI_DEFAULT_USER,
);
this.proxyPass = this.configService.get<string>(
"PCAT_PROXY_PASS",
DI_DEFAULT_PASS,
);
}
async onModuleInit(): Promise<void> {
try {
await this.launchBrowser();
this.logger.log("Browser launched for JWT capture");
} catch (err) {
this.logger.error(
`Failed to launch browser on init: ${(err as Error).message}`,
);
}
// Start business hours scheduling
if (this.isBusinessHours()) {
this.captureToPool().catch((err) => {
this.logger.error(`Initial pool capture failed: ${(err as Error).message}`);
});
}
this.scheduleBusinessHours();
}
async onModuleDestroy(): Promise<void> {
this.clearAllRefreshTimers();
if (this.businessHoursTimer) {
clearTimeout(this.businessHoursTimer);
this.businessHoursTimer = null;
}
await this.closeBrowser();
this.logger.log("Browser closed on module destroy");
}
// ─── Public API ───────────────────────────────────────────
/**
* Acquire a matched JWT + proxy session from the pool.
* Returns a valid slot (round-robin) or captures on-demand if pool is empty.
*/
async acquireSession(): Promise<PcatSession> {
this.trackRequest();
// Try to find a valid slot in the pool
const slot = this.pickValidSlot();
if (slot) {
// Check if we should scale up in the background
this.maybeScaleUp();
return this.slotToSession(slot);
}
// No valid slot — capture on-demand
this.logger.log("JWT pool empty — capturing on-demand...");
const newSlot = await this.captureToPool();
return this.slotToSession(newSlot);
}
/**
* Invalidate a session after 401/403, remove the slot, and capture a replacement.
*/
async invalidateSession(session: PcatSession): Promise<void> {
const idx = this.pool.indexOf(session._slot);
if (idx !== -1) {
this.clearSlotTimer(this.pool[idx]);
this.pool.splice(idx, 1);
this.logger.log(
`JWT pool: slot invalidated (port ${session._slot.proxyPort}), ${this.pool.length} remaining`,
);
}
// Capture replacement in background (don't block the caller's retry)
if (this.isBusinessHours() || this.pool.length === 0) {
this.captureToPool().catch((err) => {
this.logger.error(`Replacement capture failed: ${(err as Error).message}`);
});
}
}
// ─── Pool management ──────────────────────────────────────
private pickValidSlot(): JwtSlot | null {
const now = Math.floor(Date.now() / 1000);
// Remove expired slots
this.pool = this.pool.filter((s) => {
if (s.jwt.exp - now < 30) {
this.clearSlotTimer(s);
return false;
}
return true;
});
if (this.pool.length === 0) return null;
// Round-robin across valid slots
this.requestRoundRobin = this.requestRoundRobin % this.pool.length;
const slot = this.pool[this.requestRoundRobin];
this.requestRoundRobin++;
return slot;
}
private slotToSession(slot: JwtSlot): PcatSession {
const proxyUrl = this.useProxy
? `http://${this.proxyUser}:${this.proxyPass}@${this.proxyHost}:${slot.proxyPort}`
: null;
const proxyConfig = this.useProxy
? {
server: `http://${this.proxyHost}:${slot.proxyPort}`,
username: this.proxyUser,
password: this.proxyPass,
}
: null;
return {
authorization: slot.jwt.raw,
proxyUrl,
proxyConfig,
_slot: slot,
};
}
// ─── Core capture + pool add ──────────────────────────────
private async captureToPool(): Promise<JwtSlot> {
await this.semaphore.acquire();
try {
await this.ensureBrowser();
const maxRetries = 4;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const port = this.allocatePort();
const siteUrl = this.getAvailableSite();
const jwt = await this.attemptCapture(siteUrl, port);
if (jwt) {
const slot: JwtSlot = {
jwt,
proxyPort: port,
siteUsed: siteUrl,
capturedAt: Date.now(),
refreshTimer: null,
};
// Mark site as used
this.siteLastUsedAt.set(siteUrl, Date.now());
// Add to pool
this.pool.push(slot);
// Schedule refresh if in business hours
if (this.isBusinessHours()) {
this.scheduleSlotRefresh(slot);
}
const ttl = jwt.exp - Math.floor(Date.now() / 1000);
const refreshIn = this.isBusinessHours()
? Math.max(ttl - REFRESH_BUFFER, 30)
: null;
this.logger.log(
`JWT pool: slot captured, TTL: ${ttl}s${refreshIn ? `, refresh in ${refreshIn}s` : ""}, pool size: ${this.pool.length}`,
);
return slot;
}
this.logger.warn(
`JWT capture attempt ${attempt + 1}/${maxRetries} failed (${new URL(siteUrl).hostname}), trying next site...`,
);
}
throw new Error("Failed to capture JWT after all retries");
} finally {
this.semaphore.release();
}
}
// ─── Timer-based refresh per slot ─────────────────────────
private scheduleSlotRefresh(slot: JwtSlot): void {
this.clearSlotTimer(slot);
const now = Math.floor(Date.now() / 1000);
const delayMs = Math.max((slot.jwt.exp - now - REFRESH_BUFFER) * 1000, 30_000);
slot.refreshTimer = setTimeout(async () => {
// Don't refresh outside business hours
if (!this.isBusinessHours()) {
this.logger.debug("JWT pool: refresh timer fired outside business hours, skipping");
return;
}
try {
const newSlot = await this.captureToPool();
// Remove old slot
const idx = this.pool.indexOf(slot);
if (idx !== -1) {
this.pool.splice(idx, 1);
}
this.logger.log(
`JWT pool: slot refreshed, TTL: ${newSlot.jwt.exp - Math.floor(Date.now() / 1000)}s`,
);
} catch (err) {
this.logger.error(`JWT pool: refresh failed: ${(err as Error).message}`);
// Schedule a retry in 30s if still in business hours
if (this.isBusinessHours()) {
slot.refreshTimer = setTimeout(() => {
this.scheduleSlotRefresh(slot);
}, 30_000);
}
}
}, delayMs);
}
private clearSlotTimer(slot: JwtSlot): void {
if (slot.refreshTimer) {
clearTimeout(slot.refreshTimer);
slot.refreshTimer = null;
}
}
private clearAllRefreshTimers(): void {
for (const slot of this.pool) {
this.clearSlotTimer(slot);
}
this.logger.log("JWT pool: all refresh timers cleared");
}
// ─── Business hours scheduling ────────────────────────────
/**
* Get current Istanbul hour and minute using Intl.DateTimeFormat.
* This works correctly regardless of the server's local timezone.
*/
private getIstanbulTime(): { hour: number; minute: number } {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Istanbul",
hour: "numeric",
minute: "numeric",
hour12: false,
}).formatToParts(new Date());
const hour = parseInt(parts.find((p) => p.type === "hour")!.value, 10);
const minute = parseInt(parts.find((p) => p.type === "minute")!.value, 10);
return { hour, minute };
}
private isBusinessHours(): boolean {
const { hour } = this.getIstanbulTime();
return hour >= 9 && hour < 19;
}
private scheduleBusinessHours(): void {
if (this.businessHoursTimer) {
clearTimeout(this.businessHoursTimer);
this.businessHoursTimer = null;
}
const { hour, minute } = this.getIstanbulTime();
let delayMs: number;
let nextEvent: string;
if (this.isBusinessHours()) {
// Schedule 19:00 stop
const minsUntil19 = (19 - hour - 1) * 60 + (60 - minute);
delayMs = minsUntil19 * 60 * 1000;
nextEvent = "stop (19:00)";
this.businessHoursTimer = setTimeout(() => {
this.logger.log("JWT pool: business hours ended, timers cleared");
this.clearAllRefreshTimers();
this.scheduleBusinessHours(); // schedule next 09:00 start
}, delayMs);
} else {
// Schedule 09:00 start
let minsUntil9: number;
if (hour >= 19) {
// Same day evening → next day 09:00
minsUntil9 = (24 - hour + 9 - 1) * 60 + (60 - minute);
} else {
// Before 09:00
minsUntil9 = (9 - hour - 1) * 60 + (60 - minute);
}
delayMs = minsUntil9 * 60 * 1000;
nextEvent = "start (09:00)";
this.businessHoursTimer = setTimeout(() => {
this.logger.log("JWT pool: business hours started, capturing initial slot");
this.captureToPool().catch((err) => {
this.logger.error(`Business hours initial capture failed: ${(err as Error).message}`);
});
this.scheduleBusinessHours(); // schedule 19:00 stop
}, delayMs);
}
this.logger.log(
`JWT pool: next ${nextEvent} in ${Math.round(delayMs / 60_000)}min (Istanbul: ${hour}:${String(minute).padStart(2, "0")})`,
);
}
// ─── RPM tracking & dynamic scaling ───────────────────────
private trackRequest(): void {
const now = Date.now();
this.requestTimestamps.push(now);
// Prune old timestamps outside the window
const cutoff = now - RPM_WINDOW;
while (this.requestTimestamps.length > 0 && this.requestTimestamps[0] < cutoff) {
this.requestTimestamps.shift();
}
}
private getRPM(): number {
const now = Date.now();
const cutoff = now - RPM_WINDOW;
return this.requestTimestamps.filter((t) => t >= cutoff).length;
}
private getDesiredPoolSize(): number {
const rpm = this.getRPM();
return Math.min(Math.max(1, Math.ceil(rpm / RPM_PER_SLOT)), MAX_POOL_SIZE);
}
private maybeScaleUp(): void {
if (!this.isBusinessHours()) return;
const desired = this.getDesiredPoolSize();
if (this.pool.length < desired) {
this.logger.log(
`JWT pool: scaling up, ${this.pool.length}/${desired} slots (RPM: ${this.getRPM()})`,
);
this.captureToPool().catch((err) => {
this.logger.warn(`JWT pool: scale-up capture failed: ${(err as Error).message}`);
});
}
}
// ─── Site cooldown & port allocation ──────────────────────
private getAvailableSite(): string {
const now = Date.now();
// Try round-robin, preferring sites not on cooldown
for (let i = 0; i < JWT_SITES.length; i++) {
const idx = (this.siteIndex + i) % JWT_SITES.length;
const site = JWT_SITES[idx];
const lastUsed = this.siteLastUsedAt.get(site) || 0;
if (now - lastUsed >= SITE_COOLDOWN) {
this.siteIndex = (idx + 1) % JWT_SITES.length;
return site;
}
}
// All on cooldown — pick the one with oldest usage
let oldestIdx = 0;
let oldestTime = Infinity;
for (let i = 0; i < JWT_SITES.length; i++) {
const lastUsed = this.siteLastUsedAt.get(JWT_SITES[i]) || 0;
if (lastUsed < oldestTime) {
oldestTime = lastUsed;
oldestIdx = i;
}
}
this.siteIndex = (oldestIdx + 1) % JWT_SITES.length;
return JWT_SITES[oldestIdx];
}
private allocatePort(): number {
return DI_PORT_MIN + Math.floor(Math.random() * (DI_PORT_MAX - DI_PORT_MIN + 1));
}
// ─── JWT capture via Playwright ───────────────────────────
private async attemptCapture(
siteUrl: string,
port: number,
): Promise<PcatJwtToken | null> {
let context: BrowserContext | null = null;
const startTime = Date.now();
try {
// Build context options with proxy
const contextOptions: Record<string, unknown> = {};
if (this.useProxy) {
contextOptions.proxy = {
server: `http://${this.proxyHost}:${port}`,
username: this.proxyUser,
password: this.proxyPass,
};
}
context = await this.browser!.newContext(contextOptions);
const page = await context.newPage();
// Intercept requests to parts-catalogs.com
let capturedJwt: string | null = null;
page.on("request", (request) => {
if (capturedJwt) return;
const url = request.url();
if (
url.includes("parts-catalogs.com") ||
url.includes("api.parts-catalogs.com")
) {
const auth = request.headers()["authorization"];
if (auth) {
capturedJwt = auth;
this.logger.debug("JWT intercepted from request");
}
}
});
// Block heavy resources to save proxy bandwidth
await page.route("**/*", (route) => {
const url = route.request().url();
const type = route.request().resourceType();
// Block images, fonts, media
if (["image", "font", "media"].includes(type)) {
return route.abort();
}
// Block known trackers/analytics
if (
url.includes("google-analytics.com") ||
url.includes("googletagmanager.com") ||
url.includes("mc.yandex.ru") ||
url.includes("facebook.net") ||
url.includes("doubleclick.net") ||
url.includes("hotjar.com")
) {
return route.abort();
}
return route.continue();
});
// Navigate — networkidle waits for widget JS to load + make API calls
try {
await page.goto(siteUrl, {
timeout: PAGE_TIMEOUT,
waitUntil: "networkidle",
});
} catch (navErr) {
// Navigation may timeout but JWT could still be captured
this.logger.debug(
`Navigation ended: ${(navErr as Error).message?.slice(0, 80)}`,
);
}
// Poll for JWT
for (let i = 0; i < CAPTURE_POLL_MAX; i++) {
if (capturedJwt) break;
await new Promise((r) => setTimeout(r, CAPTURE_POLL_INTERVAL));
}
const elapsed = Date.now() - startTime;
if (capturedJwt) {
const token = this.parseJwt(capturedJwt);
this.logger.log(
`JWT captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`,
);
return token;
}
this.logger.debug(`No JWT after ${elapsed}ms from ${siteUrl}`);
return null;
} catch (err) {
this.logger.warn(`JWT capture error: ${(err as Error).message}`);
return null;
} finally {
if (context) {
try {
await Promise.race([
context.close(),
new Promise((r) => setTimeout(r, CONTEXT_CLOSE_TIMEOUT)),
]);
} catch {
// ignore
}
}
}
}
private parseJwt(rawToken: string): PcatJwtToken {
const parts = rawToken.split(".");
if (parts.length !== 3) {
throw new Error("Invalid JWT format");
}
// Decode payload with proper base64url padding
let payloadB64 = parts[1];
const padding = 4 - (payloadB64.length % 4);
if (padding !== 4) {
payloadB64 += "=".repeat(padding);
}
const payload = JSON.parse(
Buffer.from(payloadB64, "base64url").toString("utf-8"),
);
return {
raw: rawToken,
exp: payload.exp || 0,
host: payload.host || "",
apiKey: payload.apiKey || "",
apiPath: payload.apiPath || "",
ip: payload.ip || "",
hash: payload.h || "",
};
}
// ─── Browser lifecycle ───────────────────────────────────
private async launchBrowser(): Promise<void> {
if (this.launching) {
return this.launching;
}
this.launching = this._doLaunch();
try {
await this.launching;
} finally {
this.launching = null;
}
}
private async _doLaunch(): Promise<void> {
const { chromium } = await import("playwright");
this.browser = await chromium.launch({
headless: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-accelerated-2d-canvas",
"--disable-gpu",
],
});
this.browser.on("disconnected", () => {
this.logger.warn("Browser disconnected — will relaunch on next request");
this.browser = null;
});
}
private async closeBrowser(): Promise<void> {
if (this.browser) {
try {
await this.browser.close();
} catch {
// ignore
}
this.browser = null;
}
}
private async ensureBrowser(): Promise<void> {
if (this.browser?.isConnected()) return;
this.logger.log("Browser not connected — relaunching");
await this.launchBrowser();
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
import { PartsCatalogsService } from "./parts-catalogs.service";
@Module({
providers: [PartsCatalogsAuthService, PartsCatalogsService],
exports: [PartsCatalogsService],
})
export class PartsCatalogsModule {}

View File

@@ -0,0 +1,240 @@
/**
* Parts-Catalogs API Service — HTTP client for parts-catalogs.com
*
* All requests go through the same DataImpulse proxy as the JWT capture
* to ensure the JWT's IP-bound constraint is satisfied.
*/
import { Injectable, Logger } from "@nestjs/common";
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
import { RedisService } from "../../redis/redis.service";
import type {
PcatVinResult,
PcatCar,
PcatGroup,
PcatPartsResult,
PcatSession,
} from "./parts-catalogs.types";
const API_BASE = "https://api.parts-catalogs.com/v1";
const REQUEST_TIMEOUT = 30_000;
@Injectable()
export class PartsCatalogsService {
private readonly logger = new Logger(PartsCatalogsService.name);
constructor(
private authService: PartsCatalogsAuthService,
private redis: RedisService,
) {}
/** Mark parts-catalogs as actively used (5min TTL) to defer prefetch worker */
private async touchActivity(): Promise<void> {
try {
await this.redis.set("prefetch:activity:parts-catalogs", String(Date.now()), 300);
} catch {
// Non-critical
}
}
/**
* VIN decode — returns one or more car matches.
*/
async decodeVin(vin: string): Promise<PcatVinResult | null> {
try {
const data = await this.fetchWithAuth("/car/info", { q: vin });
if (!data || typeof data !== "object") {
return null;
}
// Response can be a single car or array of cars depending on VIN
const rawCars = Array.isArray(data) ? data : [data];
const cars: PcatCar[] = [];
for (const item of rawCars) {
if (!item) continue;
// Each car result may have nested catalog info
const catalogId = item.catalogId || item.catalog?.id || "";
const carId = item.id || item.carId || "";
if (!carId) continue;
cars.push({
id: String(carId),
name: item.name || item.title || "",
description: item.description || item.modelName || undefined,
parameters: Array.isArray(item.parameters) ? item.parameters : undefined,
catalogId: String(catalogId),
});
}
if (cars.length === 0) return null;
return { cars };
} catch (err) {
this.logger.warn(`VIN decode failed for ${vin}: ${(err as Error).message}`);
return null;
}
}
/**
* Get category groups for a car.
* Pass groupId for subgroups.
*/
async fetchGroups(
catalogId: string,
carId: string,
groupId?: string,
carParams?: Record<string, string>,
): Promise<PcatGroup[]> {
await this.touchActivity();
const params: Record<string, string> = { carId };
if (groupId) params.groupId = groupId;
if (carParams) Object.assign(params, carParams);
const data = await this.fetchWithAuth(
`/catalogs/${catalogId}/groups2/`,
params,
);
if (!Array.isArray(data)) return [];
return data.map((g: any) => ({
id: String(g.id),
parentId: g.parentId ? String(g.parentId) : undefined,
name: g.name || "",
img: g.img || undefined,
hasSubgroups: !!g.hasSubgroups,
hasParts: !!g.hasParts,
}));
}
/**
* Get parts + schema image + hotspot positions for a group.
*/
async fetchParts(
catalogId: string,
carId: string,
groupId: string,
carParams?: Record<string, string>,
): Promise<PcatPartsResult | null> {
await this.touchActivity();
const params: Record<string, string> = { carId, groupId };
if (carParams) Object.assign(params, carParams);
const data = await this.fetchWithAuth(
`/catalogs/${catalogId}/parts2`,
params,
);
if (!data || typeof data !== "object") return null;
return {
img: data.img || "",
imgDescription: data.imgDescription || undefined,
partGroups: Array.isArray(data.partGroups)
? data.partGroups.map((pg: any) => ({
name: pg.name || undefined,
number: pg.number || undefined,
positionNumber: pg.positionNumber || undefined,
parts: Array.isArray(pg.parts)
? pg.parts.map((p: any) => ({
id: p.id ? String(p.id) : undefined,
number: p.number || "",
name: p.name || "",
nameId: p.nameId || undefined,
notice: p.notice || undefined,
positionNumber: p.positionNumber || undefined,
}))
: [],
}))
: [],
positions: Array.isArray(data.positions)
? data.positions.map((pos: any) => ({
number: String(pos.number),
coordinates: pos.coordinates || [0, 0, 0, 0],
}))
: [],
};
}
/**
* Check if a VIN is potentially supported.
* Parts-catalogs.com covers most brands, so this is broadly true.
*/
isSupported(_vin: string): boolean {
return true;
}
// ─── Private ─────────────────────────────────────────────
private async fetchWithAuth(
endpoint: string,
params?: Record<string, string>,
): Promise<any> {
const maxRetries = 2;
let session: PcatSession | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
session = await this.authService.acquireSession();
const url = new URL(`${API_BASE}${endpoint}`);
if (params) {
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
}
try {
const fetchOptions: RequestInit & { dispatcher?: any } = {
method: "GET",
headers: {
Authorization: session.authorization,
Accept: "application/json",
"User-Agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT),
};
// Use undici ProxyAgent if proxy is configured
if (session.proxyUrl) {
const { ProxyAgent } = await import("undici");
fetchOptions.dispatcher = new ProxyAgent(session.proxyUrl);
}
const response = await fetch(url.toString(), fetchOptions);
if (response.ok) {
return await response.json();
}
if (response.status === 401 || response.status === 403) {
this.logger.warn(
`Auth error (${response.status}) on ${endpoint}, attempt ${attempt + 1}/${maxRetries + 1}`,
);
if (attempt < maxRetries) {
await this.authService.invalidateSession(session);
continue;
}
}
const text = await response.text().catch(() => "");
throw new Error(
`HTTP ${response.status} from ${endpoint}: ${text.slice(0, 200)}`,
);
} catch (err) {
if ((err as Error).name === "TimeoutError") {
this.logger.warn(`Timeout on ${endpoint}, attempt ${attempt + 1}`);
if (attempt < maxRetries) continue;
}
throw err;
}
}
throw new Error(`Max retries exceeded for ${endpoint}`);
}
}

View File

@@ -0,0 +1,79 @@
export interface PcatJwtToken {
raw: string;
exp: number;
host: string;
apiKey: string;
apiPath: string;
ip: string;
hash: string;
}
export interface JwtSlot {
jwt: PcatJwtToken;
proxyPort: number;
siteUsed: string;
capturedAt: number;
refreshTimer: ReturnType<typeof setTimeout> | null;
}
export interface PcatSession {
authorization: string;
proxyUrl: string | null;
proxyConfig: { server: string; username: string; password: string } | null;
_slot: JwtSlot;
}
export interface PcatCarParameter {
key: string;
idx: string;
value: string;
}
export interface PcatCar {
id: string;
name: string;
description?: string;
parameters?: PcatCarParameter[];
catalogId: string;
}
export interface PcatVinResult {
cars: PcatCar[];
}
export interface PcatGroup {
id: string;
parentId?: string;
name: string;
img?: string;
hasSubgroups: boolean;
hasParts: boolean;
}
export interface PcatPart {
id?: string;
number: string; // OEM code
name: string;
nameId?: number;
notice?: string;
positionNumber?: string;
}
export interface PcatPartGroup {
name?: string;
number?: string;
positionNumber?: string;
parts: PcatPart[];
}
export interface PcatPosition {
number: string;
coordinates: [number, number, number, number]; // x, y, w, h
}
export interface PcatPartsResult {
img: string;
imgDescription?: string;
partGroups: PcatPartGroup[];
positions: PcatPosition[];
}

View File

@@ -261,6 +261,7 @@ export class PL24Service {
return this.fordLegacyService.fetchPartsByPath(linkPath, serviceName);
}
await this.touchActivity();
const pathHash = createHash("sha256").update(linkPath).digest("hex").substring(0, 16);
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}parts:path:${pathHash}`;
const cached = await this.redis.getJson<PL24PartsResponse>(cacheKey);
@@ -384,6 +385,7 @@ export class PL24Service {
}
this.logger.log(`Fetching sub-groups by path: ${linkPath}`);
await this.touchActivity();
try {
await this.authService.authorizeService(serviceName);
@@ -411,6 +413,7 @@ export class PL24Service {
serviceName: string,
mainGroupsPath: string,
): Promise<PL24DecodedCategory[]> {
await this.touchActivity();
try {
await this.authService.authorizeService(serviceName);
const headers = await this.authService.buildAuthHeaders(serviceName);
@@ -435,6 +438,7 @@ export class PL24Service {
hotspots: PL24Hotspot[];
} | null> {
if (!imageUrl) return null;
await this.touchActivity();
// Extract image ID for dedup
const imageId = this.extractImageIdFromUrl(imageUrl);
@@ -572,6 +576,15 @@ export class PL24Service {
return SERVICE_TO_BRAND[serviceName] || null;
}
/** Mark PL24 as actively used (5min TTL) to defer prefetch worker */
private async touchActivity(): Promise<void> {
try {
await this.redis.set("prefetch:activity:pl24", String(Date.now()), 300);
} catch {
// Non-critical — don't break the request
}
}
// ==================== PRIVATE: Request helpers ====================
/**

View File

@@ -24,4 +24,5 @@ export const QUEUE_NAMES = {
EMEX_SCRAPE: "emex-scrape",
SUBSCRIPTION_EXPIRY: "subscription-expiry",
QUERY_CLEANUP: "query-cleanup",
CATALOG_PREFETCH: "catalog-prefetch",
} as const;

View File

@@ -6,19 +6,29 @@ import {
SUBSCRIPTION_EXPIRY_QUEUE,
} from "./queues/subscription-expiry.queue";
import { QueryCleanupQueueProvider, QUERY_CLEANUP_QUEUE } from "./queues/query-cleanup.queue";
import {
CatalogPrefetchQueueProvider,
CATALOG_PREFETCH_QUEUE,
} from "./queues/catalog-prefetch.queue";
import { PrefetchWorkerService } from "./prefetch-worker.service";
import { CategoriesModule } from "../categories/categories.module";
@Module({
imports: [CategoriesModule],
providers: [
EmexScrapeQueueProvider,
SubscriptionExpiryQueueProvider,
QueryCleanupQueueProvider,
CatalogPrefetchQueueProvider,
PrefetchWorkerService,
],
exports: [EMEX_SCRAPE_QUEUE, SUBSCRIPTION_EXPIRY_QUEUE, QUERY_CLEANUP_QUEUE],
exports: [EMEX_SCRAPE_QUEUE, SUBSCRIPTION_EXPIRY_QUEUE, QUERY_CLEANUP_QUEUE, CATALOG_PREFETCH_QUEUE],
})
export class JobsModule implements OnModuleInit, OnModuleDestroy {
constructor(
@Inject(SUBSCRIPTION_EXPIRY_QUEUE) private subscriptionExpiryQueue: Queue,
@Inject(QUERY_CLEANUP_QUEUE) private queryCleanupQueue: Queue,
@Inject(CATALOG_PREFETCH_QUEUE) private catalogPrefetchQueue: Queue,
) {}
async onModuleInit() {
@@ -59,6 +69,7 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
await Promise.all([
this.subscriptionExpiryQueue.close(),
this.queryCleanupQueue.close(),
this.catalogPrefetchQueue.close(),
]);
}
}

View File

@@ -0,0 +1,144 @@
import { RedisService } from "../redis/redis.service";
/**
* Custom error that tells BullMQ to retry after a delay.
* The worker catches this and re-queues the job with the specified delay.
*/
export class RateLimitError extends Error {
constructor(public readonly retryAfterMs: number) {
super(`Rate limited — retry after ${retryAfterMs}ms`);
this.name = "RateLimitError";
}
}
/**
* Check if a user is actively using the source.
* Throws RateLimitError (1min retry) if cooldown key exists.
*/
export async function checkCooldown(
redis: RedisService,
source: string,
): Promise<void> {
const key = `prefetch:activity:${source}`;
const exists = await redis.exists(key);
if (exists) {
const retryMs = source === "parts-catalogs" ? 120_000 : 60_000;
throw new RateLimitError(retryMs);
}
}
/**
* Check PL24 business hours (09:0018:00 Europe/Istanbul).
* Throws RateLimitError with delay until next 09:00 if outside window.
*/
export function checkTimeWindow(source: string): void {
if (source !== "pl24" && source !== "parts-catalogs") return;
const hourStr = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Istanbul",
hour: "numeric",
hour12: false,
}).format(new Date());
const h = parseInt(hourStr, 10);
const endHour = source === "parts-catalogs" ? 19 : 18;
if (h < 9 || h >= endHour) {
throw new RateLimitError(msUntilNext9AM());
}
}
/**
* Milliseconds until the next 09:00 Europe/Istanbul.
*/
export function msUntilNext9AM(): number {
const now = new Date();
// Get current Istanbul time components
const istParts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Istanbul",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(now);
const get = (type: string) =>
parseInt(istParts.find((p) => p.type === type)?.value || "0", 10);
const hour = get("hour");
const minute = get("minute");
const second = get("second");
// If before 09:00 → wait until 09:00 today
// If 18:00+ → wait until 09:00 tomorrow
let hoursToWait: number;
if (hour < 9) {
hoursToWait = 9 - hour;
} else {
hoursToWait = 24 - hour + 9;
}
const ms =
hoursToWait * 3600_000 -
minute * 60_000 -
second * 1000;
// At least 1 minute, at most 15 hours
return Math.max(60_000, Math.min(ms, 15 * 3600_000));
}
/** Redis hash key for prefetch progress */
export function progressKey(vehicleId: string): string {
return `prefetch:progress:${vehicleId}`;
}
export interface PrefetchProgress {
status: "running" | "completed" | "error";
total: number;
completed: number;
errors: number;
startedAt: string;
updatedAt: string;
}
export async function initProgress(
redis: RedisService,
vehicleId: string,
): Promise<void> {
const now = new Date().toISOString();
await redis.setJson(progressKey(vehicleId), {
status: "running",
total: 0,
completed: 0,
errors: 0,
startedAt: now,
updatedAt: now,
} satisfies PrefetchProgress, 86400);
}
export async function updateProgress(
redis: RedisService,
vehicleId: string,
update: Partial<PrefetchProgress>,
): Promise<void> {
const key = progressKey(vehicleId);
const current = await redis.getJson<PrefetchProgress>(key);
if (!current) return;
const updated: PrefetchProgress = {
...current,
...update,
updatedAt: new Date().toISOString(),
};
await redis.setJson(key, updated, 86400);
}
export async function getProgress(
redis: RedisService,
vehicleId: string,
): Promise<PrefetchProgress | null> {
return redis.getJson<PrefetchProgress>(progressKey(vehicleId));
}

View File

@@ -0,0 +1,395 @@
import {
Inject,
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit,
} from "@nestjs/common";
import { Job, Queue, Worker } from "bullmq";
import { eq, and, isNull } from "drizzle-orm";
import { CATALOG_PREFETCH_QUEUE } from "./queues/catalog-prefetch.queue";
import { getBullConnection, QUEUE_NAMES } from "./bull.config";
import {
PrefetchInitJobData,
PrefetchCategoryJobData,
} from "./prefetch.types";
import {
RateLimitError,
checkCooldown,
checkTimeWindow,
initProgress,
updateProgress,
} from "./prefetch-utils";
import { CategoriesService } from "../categories/categories.service";
import { RedisService } from "../redis/redis.service";
import { DATABASE, Database } from "../database/database.provider";
import { categories, parts, vehicles } from "../database/schema/core";
const MAX_DEPTH = 5;
@Injectable()
export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrefetchWorkerService.name);
private worker: Worker | null = null;
private pcatJobIndex = 0;
constructor(
@Inject(CATALOG_PREFETCH_QUEUE) private queue: Queue,
private categoriesService: CategoriesService,
private redis: RedisService,
@Inject(DATABASE) private db: Database,
) {}
onModuleInit() {
this.worker = new Worker(
QUEUE_NAMES.CATALOG_PREFETCH,
(job) => this.process(job),
{
connection: getBullConnection(),
concurrency: 1,
limiter: { max: 5, duration: 60_000 },
},
);
this.worker.on("failed", (job, err) => {
if (err instanceof RateLimitError) {
this.logger.debug(
`[prefetch] Job ${job?.name} rate-limited, will retry in ${err.retryAfterMs}ms`,
);
} else {
this.logger.warn(
`[prefetch] Job ${job?.name} failed: ${err.message}`,
);
}
});
this.worker.on("error", (err) => {
this.logger.error(`[prefetch] Worker error: ${err.message}`);
});
this.logger.log("[prefetch] Worker started (concurrency=1, 5 jobs/min)");
}
async onModuleDestroy() {
if (this.worker) {
await this.worker.close();
this.worker = null;
}
}
private async process(job: Job): Promise<void> {
const data = job.data as { source?: string };
if (data.source === "parts-catalogs") {
await new Promise((r) => setTimeout(r, 15_000));
}
if (job.name === "prefetch-init") {
return this.processInit(job as Job<PrefetchInitJobData>);
}
if (job.name === "prefetch-children") {
return this.processChildren(job as Job<PrefetchCategoryJobData>);
}
if (job.name === "prefetch-parts") {
return this.processParts(job as Job<PrefetchCategoryJobData>);
}
this.logger.warn(`[prefetch] Unknown job name: ${job.name}`);
}
/**
* Init job: walk the category tree for a vehicle and queue sub-jobs.
*/
private async processInit(job: Job<PrefetchInitJobData>): Promise<void> {
const { vehicleId, source } = job.data;
this.logger.log(`[prefetch] Init for vehicle=${vehicleId}, source=${source}`);
this.pcatJobIndex = 0;
await checkCooldown(this.redis, source);
checkTimeWindow(source);
// Verify vehicle still exists
const [vehicle] = await this.db
.select({ id: vehicles.id })
.from(vehicles)
.where(eq(vehicles.id, vehicleId))
.limit(1);
if (!vehicle) {
this.logger.warn(`[prefetch] Vehicle ${vehicleId} not found, skipping`);
return;
}
await initProgress(this.redis, vehicleId);
// Get all top-level categories for this vehicle
const topCategories = await this.db
.select()
.from(categories)
.where(
and(
eq(categories.vehicleId, vehicleId),
isNull(categories.parentId),
),
);
if (topCategories.length === 0) {
this.logger.log(`[prefetch] No categories for vehicle=${vehicleId}`);
await updateProgress(this.redis, vehicleId, {
status: "completed",
total: 0,
});
return;
}
let queued = 0;
for (const cat of topCategories) {
if (cat.unavailable) continue;
// Check if this category already has children in DB
const [childCheck] = await this.db
.select({ id: categories.id })
.from(categories)
.where(eq(categories.parentId, cat.id))
.limit(1);
if (childCheck) {
// Has children — queue recursive exploration of children
const children = await this.db
.select()
.from(categories)
.where(eq(categories.parentId, cat.id));
for (const child of children) {
if (child.unavailable) continue;
await this.queueCategoryJob(child, vehicleId, source, 1);
queued++;
}
} else if (this.isLeafLinkPath(cat.linkPath, cat.source)) {
// Leaf — check if parts already fetched
const [partCheck] = await this.db
.select({ id: parts.id })
.from(parts)
.where(eq(parts.categoryId, cat.id))
.limit(1);
if (!partCheck && cat.linkPath) {
await this.addJob("prefetch-parts", {
vehicleId,
categoryId: cat.id,
source,
action: "parts" as const,
depth: 0,
});
queued++;
}
} else if (cat.linkPath) {
// Non-leaf without children — needs children fetch
await this.addJob("prefetch-children", {
vehicleId,
categoryId: cat.id,
source,
action: "children" as const,
depth: 0,
});
queued++;
}
}
await updateProgress(this.redis, vehicleId, { total: queued });
this.logger.log(`[prefetch] Queued ${queued} sub-jobs for vehicle=${vehicleId}`);
}
/**
* Fetch children (sub-categories) for a category.
*/
private async processChildren(
job: Job<PrefetchCategoryJobData>,
): Promise<void> {
const { vehicleId, categoryId, source, depth } = job.data;
this.logger.log(
`[prefetch] Children for category=${categoryId}, depth=${depth}`,
);
await checkCooldown(this.redis, source);
checkTimeWindow(source);
if (depth >= MAX_DEPTH) {
this.logger.warn(
`[prefetch] Max depth reached for category=${categoryId}`,
);
return;
}
try {
const children = await this.categoriesService.getChildren(categoryId);
let queued = 0;
for (const child of children) {
if (child.unavailable) continue;
await this.queueCategoryJob(child, vehicleId, source, depth + 1);
queued++;
}
if (queued > 0) {
await updateProgress(this.redis, vehicleId, {
total:
((await this.redis.getJson<{ total: number }>(
`prefetch:progress:${vehicleId}`,
))?.total || 0) + queued,
});
}
await this.incrementCompleted(vehicleId);
} catch (err) {
if (err instanceof RateLimitError) throw err;
this.logger.error(
`[prefetch] Children fetch failed for ${categoryId}: ${(err as Error).message}`,
);
await this.incrementErrors(vehicleId);
}
}
/**
* Fetch parts + schema for a leaf category.
*/
private async processParts(
job: Job<PrefetchCategoryJobData>,
): Promise<void> {
const { vehicleId, categoryId, source } = job.data;
this.logger.log(`[prefetch] Parts for category=${categoryId}`);
await checkCooldown(this.redis, source);
checkTimeWindow(source);
try {
await this.categoriesService.getCategoryWithParts(categoryId);
await this.incrementCompleted(vehicleId);
} catch (err) {
if (err instanceof RateLimitError) throw err;
this.logger.error(
`[prefetch] Parts fetch failed for ${categoryId}: ${(err as Error).message}`,
);
await this.incrementErrors(vehicleId);
}
}
// ==================== Helpers ====================
private async queueCategoryJob(
cat: { id: string; linkPath: string | null; source: string; unavailable: boolean },
vehicleId: string,
source: string,
depth: number,
): Promise<void> {
if (cat.unavailable) return;
if (this.isLeafLinkPath(cat.linkPath, cat.source)) {
// Leaf — check if already has parts
const [partCheck] = await this.db
.select({ id: parts.id })
.from(parts)
.where(eq(parts.categoryId, cat.id))
.limit(1);
if (!partCheck && cat.linkPath) {
await this.addJob("prefetch-parts", {
vehicleId,
categoryId: cat.id,
source: source as "pl24" | "emex",
action: "parts" as const,
depth,
});
}
} else if (cat.linkPath) {
// Check if children already exist
const [childCheck] = await this.db
.select({ id: categories.id })
.from(categories)
.where(eq(categories.parentId, cat.id))
.limit(1);
if (childCheck) {
// Already has children — explore them recursively
const children = await this.db
.select()
.from(categories)
.where(eq(categories.parentId, cat.id));
for (const child of children) {
if (child.unavailable) continue;
await this.queueCategoryJob(child, vehicleId, source, depth + 1);
}
} else {
await this.addJob("prefetch-children", {
vehicleId,
categoryId: cat.id,
source: source as "pl24" | "emex",
action: "children" as const,
depth,
});
}
}
}
private isLeafLinkPath(linkPath: string | null, source: string): boolean {
if (!linkPath) return false;
if (source === "emex") return true; // EMEX leaves always have a URL
if (source === "parts-catalogs") return linkPath.startsWith("pcat:"); // pcat: prefix = leaf
// PL24 leaf indicators
return (
linkPath.includes("/bom/") ||
linkPath.includes("/bomdetails") ||
linkPath.includes("/partinfo/") ||
linkPath.includes("/servicepart/vin_items")
);
}
private async addJob(
name: string,
data: PrefetchCategoryJobData,
): Promise<void> {
const opts: Record<string, unknown> = {
jobId: `prefetch:${data.vehicleId}:${data.categoryId}:${data.action}`,
};
if (data.source === "parts-catalogs") {
opts.delay = ++this.pcatJobIndex * 20_000;
}
await this.queue.add(name, data, opts);
}
private async incrementCompleted(vehicleId: string): Promise<void> {
const progress = await this.redis.getJson<{
completed: number;
total: number;
}>(`prefetch:progress:${vehicleId}`);
if (!progress) return;
const completed = (progress.completed || 0) + 1;
const isFinished = completed >= progress.total;
await updateProgress(this.redis, vehicleId, {
completed,
...(isFinished ? { status: "completed" } : {}),
});
if (isFinished) {
this.logger.log(`[prefetch] Completed all jobs for vehicle=${vehicleId}`);
// Clean up Redis keys — data is in PostgreSQL now
await this.redis.del(`prefetch:scheduled:${vehicleId}`);
await this.redis.del(`prefetch:progress:${vehicleId}`);
}
}
private async incrementErrors(vehicleId: string): Promise<void> {
const progress = await this.redis.getJson<{ errors: number }>(
`prefetch:progress:${vehicleId}`,
);
if (!progress) return;
await updateProgress(this.redis, vehicleId, {
errors: (progress.errors || 0) + 1,
});
}
}

View File

@@ -0,0 +1,16 @@
export type PrefetchSource = "pl24" | "emex" | "parts-catalogs";
/** Initial job: walks the category tree and queues sub-jobs */
export interface PrefetchInitJobData {
vehicleId: string;
source: PrefetchSource;
}
/** Per-category job: fetches children OR parts */
export interface PrefetchCategoryJobData {
vehicleId: string;
categoryId: string;
source: PrefetchSource;
action: "children" | "parts";
depth: number;
}

View File

@@ -0,0 +1,25 @@
import { Provider } from "@nestjs/common";
import { Queue } from "bullmq";
import { getBullConnection, getBullTelemetry, QUEUE_NAMES } from "../bull.config";
export const CATALOG_PREFETCH_QUEUE = "CATALOG_PREFETCH_QUEUE";
export const CatalogPrefetchQueueProvider: Provider = {
provide: CATALOG_PREFETCH_QUEUE,
useFactory: () => {
const telemetry = getBullTelemetry();
return new Queue(QUEUE_NAMES.CATALOG_PREFETCH, {
connection: getBullConnection(),
...(telemetry ? { telemetry } : {}),
defaultJobOptions: {
attempts: 3,
backoff: {
type: "exponential",
delay: 30000,
},
removeOnComplete: { count: 1000 },
removeOnFail: { count: 5000 },
},
});
},
};

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,

View File

@@ -14,6 +14,7 @@ interface Category {
schemaImageUrl?: string | null;
parentId?: string | null;
unavailable?: boolean;
source?: string;
}
interface CategoryGridProps {
@@ -46,7 +47,8 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
(c) =>
c.children !== undefined &&
c.children.length === 0 &&
!c.schemaImageUrl,
!c.schemaImageUrl &&
c.source !== "parts-catalogs",
);
if (leafsWithoutImage.length === 0) {

View File

@@ -14,6 +14,7 @@ interface Category {
schemaImageUrl?: string | null;
parentId?: string | null;
unavailable?: boolean;
source?: string;
}
export function CategoryTree({ categories, vehicleId }: { categories: Category[]; vehicleId: string }) {
@@ -68,7 +69,7 @@ function CategoryNode({ category, vehicleId, level, parentPrefetching }: {
useEffect(() => {
if (!expanded || prefetchedRef.current) return;
const leafs = children.filter(
(c) => c.children !== undefined && c.children.length === 0 && !c.schemaImageUrl,
(c) => c.children !== undefined && c.children.length === 0 && !c.schemaImageUrl && c.source !== "parts-catalogs",
);
if (leafs.length === 0) return;
prefetchedRef.current = true;

View File

@@ -0,0 +1,154 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Button,
Badge,
Separator,
} from "@sase/ui";
import { Car, Loader2, ChevronRight } from "lucide-react";
interface PcatCandidate {
id: string;
name: string;
description?: string;
parameters?: Array<{ key: string; idx: string; value: string }>;
catalogId: string;
}
interface VehicleSelectModalProps {
open: boolean;
onClose: () => void;
candidates: PcatCandidate[];
vin: string;
onSelect: (carId: string) => void;
loading?: boolean;
}
export function VehicleSelectModal({
open,
onClose,
candidates,
vin,
onSelect,
loading,
}: VehicleSelectModalProps) {
const [selectedId, setSelectedId] = useState<string | null>(null);
function getParamValue(
params: PcatCandidate["parameters"],
keyword: string,
): string | null {
if (!params) return null;
const p = params.find((param) =>
param.key.toLowerCase().includes(keyword),
);
return p?.value || null;
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle className="font-[family-name:var(--font-display)]">
Araç Seçimi
</DialogTitle>
<DialogDescription>
<span className="font-mono text-xs">{vin}</span> için birden fazla
araç bulundu. Lütfen aracınızı seçin.
</DialogDescription>
</DialogHeader>
<Separator className="my-2" />
<div className="space-y-2">
{candidates.map((car) => {
const year = getParamValue(car.parameters, "year");
const engine = getParamValue(car.parameters, "engine");
const body = getParamValue(car.parameters, "body");
const isSelected = selectedId === car.id;
return (
<button
key={car.id}
type="button"
onClick={() => setSelectedId(car.id)}
className={`group flex w-full items-center gap-4 rounded-xl border p-4 text-left transition-colors ${
isSelected
? "border-primary bg-primary/5"
: "border-border bg-background hover:bg-accent"
}`}
>
<div
className={`flex size-10 shrink-0 items-center justify-center rounded-xl ${
isSelected
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground"
}`}
>
<Car className="size-5" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{car.name}</p>
{car.description && (
<p className="truncate text-xs text-muted-foreground">
{car.description}
</p>
)}
<div className="mt-1.5 flex flex-wrap gap-1.5">
{year && (
<Badge variant="secondary" className="text-[10px]">
{year}
</Badge>
)}
{engine && (
<Badge variant="secondary" className="text-[10px]">
{engine}
</Badge>
)}
{body && (
<Badge variant="secondary" className="text-[10px]">
{body}
</Badge>
)}
<Badge variant="outline" className="text-[10px]">
{car.catalogId}
</Badge>
</div>
</div>
<ChevronRight
className={`size-4 shrink-0 transition-colors ${
isSelected
? "text-primary"
: "text-muted-foreground/50 group-hover:text-muted-foreground"
}`}
/>
</button>
);
})}
</div>
<Separator className="my-2" />
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose} className="rounded-xl">
Vazgeç
</Button>
<Button
disabled={!selectedId || loading}
onClick={() => selectedId && onSelect(selectedId)}
className="rounded-xl"
>
{loading ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
Seç ve Devam Et
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -14,6 +14,7 @@ import { api, ApiError } from "@/lib/api-client";
import { startAction } from "@/lib/faro";
import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import { VehicleSelectModal } from "@/components/vehicles/vehicle-select-modal";
// ─── HELPERS ──────────────────────────────────────────────────────────────────
@@ -51,6 +52,11 @@ function SearchPage() {
const [reportSending, setReportSending] = useState(false);
const [reportSent, setReportSent] = useState(false);
// Vehicle candidate selection (PartsCatalogs multi-result)
const [candidates, setCandidates] = useState<any[] | null>(null);
const [candidateVin, setCandidateVin] = useState("");
const [selectLoading, setSelectLoading] = useState(false);
// Live preview state
const [preview, setPreview] = useState<{
brandName: string;
@@ -142,6 +148,19 @@ function SearchPage() {
setLoading(true);
try {
const data = await api.post<any>("/vehicles/decode", { vin: cleanVin });
// Handle multiple vehicle candidates (PartsCatalogs)
if (data.candidates && Array.isArray(data.candidates)) {
setCandidates(data.candidates);
setCandidateVin(cleanVin);
capture("vin_decode_candidates", {
vin: cleanVin,
count: data.candidates.length,
});
setLoading(false);
return;
}
capture("vin_decode_success", { vin: cleanVin, vehicle_id: data.id });
navigate({
to: "/dashboard/vehicles/$id",
@@ -190,6 +209,36 @@ function SearchPage() {
}
}
async function handleCandidateSelect(pcatCarId: string) {
setSelectLoading(true);
try {
const data = await api.post<any>("/vehicles/decode", {
vin: candidateVin,
pcatCarId,
});
capture("vin_decode_candidate_selected", {
vin: candidateVin,
pcatCarId,
vehicle_id: data.id,
});
setCandidates(null);
navigate({
to: "/dashboard/vehicles/$id",
params: { id: data.id },
});
} catch (err) {
const message =
err instanceof ApiError
? err.message
: "Bir hata oluştu. Lütfen tekrar deneyin.";
setError(message);
setCandidates(null);
toast.error("Araç seçimi başarısız");
} finally {
setSelectLoading(false);
}
}
function fillExampleVin() {
setVin("WVWZZZ1JZ3W597935");
inputRef.current?.focus();
@@ -424,6 +473,18 @@ function SearchPage() {
</p>
</div>
)}
{/* ─── Vehicle Selection Modal (PartsCatalogs multi-result) ──── */}
{candidates && (
<VehicleSelectModal
open={!!candidates}
onClose={() => setCandidates(null)}
candidates={candidates}
vin={candidateVin}
onSelect={handleCandidateSelect}
loading={selectLoading}
/>
)}
</div>
);
}

File diff suppressed because one or more lines are too long