feat: sase.tr v2 full application implementation

Complete rewrite of sase.tr VIN lookup platform with modern stack:

Backend (NestJS 10 + Drizzle ORM + PostgreSQL + Redis + BullMQ):
- 34 DB models (core + PL24 + EMEX schemas)
- Auth via Better Auth (email/password + social)
- Brands, Plans, Subscriptions, Payments (iyzico + EFT)
- VIN decode orchestration (Corgi + PL24 + EMEX + NHTSA)
- Interactive schema viewer backend (MinIO storage)
- EMEX scraping integration (Puppeteer + BullMQ workers)
- Translation module (EN→TR automotive dictionary)
- Admin dashboard API (stats, user mgmt, payment approval)
- Rate limiting, Helmet security, file upload validation

Frontend (Next.js 15 + Tailwind v4 + shadcn/ui + TanStack Query + Zustand):
- 20 routes: auth, dashboard, VIN search, schema viewer, admin
- Interactive schema viewer with zoom/pan/hotspot highlighting
- Subscription management with brand selector
- Payment flow (iyzico 3D Secure + EFT with receipt upload)
- i18n support (TR/EN)
- Error boundaries, loading skeletons, 404 page

Infrastructure:
- 85 tests (52 backend + 33 frontend, Vitest)
- CI/CD (GitHub Actions: lint, typecheck, test, build, deploy)
- Zero-downtime deploy script (PM2)
- Env validation script

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-02-12 02:03:56 +00:00
parent 7fc47ce9cc
commit 56a3c8bfaa
215 changed files with 25043 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { CorgiService } from "./corgi.service";
@Module({
providers: [CorgiService],
exports: [CorgiService],
})
export class CorgiModule {}

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, beforeEach } from "vitest";
import { CorgiService } from "./corgi.service";
describe("CorgiService", () => {
let service: CorgiService;
beforeEach(() => {
service = new CorgiService();
});
describe("getBrandFromWmi", () => {
it("should return BMW for known WMI WBA", () => {
expect(service.getBrandFromWmi("WBA")).toBe("BMW");
});
it("should return Mercedes-Benz for WDB", () => {
expect(service.getBrandFromWmi("WDB")).toBe("Mercedes-Benz");
});
it("should return Audi for WAU", () => {
expect(service.getBrandFromWmi("WAU")).toBe("Audi");
});
it("should return null for unknown WMI", () => {
expect(service.getBrandFromWmi("ZZZ")).toBeNull();
});
it("should handle lowercase WMI input", () => {
expect(service.getBrandFromWmi("wba")).toBe("BMW");
});
});
describe("decodeVin", () => {
it("should decode a BMW VIN correctly", () => {
// WBA = BMW, position 10 (index 9) = 'K' = 2019
const result = service.decodeVin("WBAPH5C55BA123456");
expect(result).not.toBeNull();
expect(result!.brandName).toBe("BMW");
expect(result!.wmi).toBe("WBA");
expect(result!.isKnown).toBe(true);
expect(result!.modelYear).toBe(2011); // 'B' at position 10
});
it("should extract model year 'A' as 2010", () => {
// Position 10 (index 9) = 'A' = 2010
const result = service.decodeVin("WBAPH5C55AA123456");
expect(result).not.toBeNull();
expect(result!.modelYear).toBe(2010);
});
it("should extract model year 'J' as 2018", () => {
const result = service.decodeVin("WBAPH5C55JA123456");
expect(result).not.toBeNull();
expect(result!.modelYear).toBe(2018);
});
it("should extract model year '1' as 2001", () => {
const result = service.decodeVin("WBAPH5C5510123456");
expect(result).not.toBeNull();
expect(result!.modelYear).toBe(2001);
});
it("should extract model year '9' as 2009", () => {
const result = service.decodeVin("WBAPH5C5590123456");
expect(result).not.toBeNull();
expect(result!.modelYear).toBe(2009);
});
it("should return null modelYear for unrecognized year character", () => {
// Position 10 (index 9) = '0' is not in YEAR_MAP
const result = service.decodeVin("WBAPH5C550A123456");
expect(result).not.toBeNull();
expect(result!.modelYear).toBeNull();
});
it("should return isKnown=false for unknown WMI", () => {
const result = service.decodeVin("ZZZPH5C55KA123456");
expect(result).not.toBeNull();
expect(result!.brandName).toBe("Unknown");
expect(result!.isKnown).toBe(false);
});
it("should return null for VIN with wrong length", () => {
expect(service.decodeVin("WBA123")).toBeNull();
expect(service.decodeVin("")).toBeNull();
expect(service.decodeVin("WBAPH5C55KA12345678")).toBeNull();
});
it("should handle lowercase VIN input", () => {
const result = service.decodeVin("wbaph5c55ka123456");
expect(result).not.toBeNull();
expect(result!.brandName).toBe("BMW");
expect(result!.wmi).toBe("WBA");
});
it("should decode a Toyota VIN correctly", () => {
const result = service.decodeVin("JTDKN3DU5LA123456");
expect(result).not.toBeNull();
expect(result!.brandName).toBe("Toyota");
expect(result!.wmi).toBe("JTD");
expect(result!.isKnown).toBe(true);
expect(result!.modelYear).toBe(2020); // 'L' at position 10
});
it("should decode a Volkswagen VIN with numeric WMI prefix", () => {
const result = service.decodeVin("3VWFE21C55M123456");
expect(result).not.toBeNull();
expect(result!.brandName).toBe("Volkswagen");
expect(result!.wmi).toBe("3VW");
expect(result!.isKnown).toBe(true);
});
});
});

View File

@@ -0,0 +1,101 @@
import { Injectable, Logger } from "@nestjs/common";
interface CorgiDecodeResult {
brandName: string;
wmi: string;
modelYear: number | null;
isKnown: boolean;
}
const WMI_DATABASE: Record<string, string> = {
// BMW
WBA: "BMW", WBS: "BMW", WBY: "BMW", "5UX": "BMW",
// Mercedes-Benz
WDB: "Mercedes-Benz", WDC: "Mercedes-Benz", WDD: "Mercedes-Benz", WDF: "Mercedes-Benz",
// Audi
WAU: "Audi", WUA: "Audi",
// Volkswagen
WVW: "Volkswagen", WVG: "Volkswagen", "3VW": "Volkswagen",
// Toyota
JTD: "Toyota", JTE: "Toyota", JTN: "Toyota", "2T1": "Toyota", "4T1": "Toyota",
// Fiat
ZFA: "Fiat", ZFC: "Fiat",
// Renault
VF1: "Renault", VF2: "Renault",
// Peugeot
VF3: "Peugeot",
// Citroen
VF7: "Citroen",
// Honda
JHM: "Honda", SHH: "Honda", "1HG": "Honda",
// Hyundai
KMH: "Hyundai", "5NP": "Hyundai",
// Kia
KNA: "Kia", KND: "Kia",
// Ford
WF0: "Ford", "1FA": "Ford", "3FA": "Ford",
// Opel
W0L: "Opel",
// Skoda
TMB: "Skoda",
// Seat
VSS: "Seat",
// Volvo
YV1: "Volvo",
// Nissan
JN1: "Nissan", "1N4": "Nissan", "3N1": "Nissan",
// Mazda
JM1: "Mazda", JM3: "Mazda",
// Porsche
WP0: "Porsche", WP1: "Porsche",
// Land Rover
SAL: "Land Rover",
// Jaguar
SAJ: "Jaguar",
// Mini
WMW: "Mini",
// Dacia
UU1: "Dacia",
};
const YEAR_MAP: Record<string, number> = {
A: 2010, B: 2011, C: 2012, D: 2013, E: 2014, F: 2015, G: 2016, H: 2017,
J: 2018, K: 2019, L: 2020, M: 2021, N: 2022, P: 2023, R: 2024, S: 2025,
T: 2026, V: 2027, W: 2028, X: 2029, Y: 2030,
"1": 2001, "2": 2002, "3": 2003, "4": 2004, "5": 2005,
"6": 2006, "7": 2007, "8": 2008, "9": 2009,
};
@Injectable()
export class CorgiService {
private readonly logger = new Logger(CorgiService.name);
decodeVin(vin: string): CorgiDecodeResult | null {
const upper = vin.toUpperCase();
if (upper.length !== 17) return null;
const wmi = upper.substring(0, 3);
const brandName = WMI_DATABASE[wmi];
if (!brandName) {
this.logger.warn(`Unknown WMI: ${wmi}`);
return { brandName: "Unknown", wmi, modelYear: this.extractYear(upper), isKnown: false };
}
return {
brandName,
wmi,
modelYear: this.extractYear(upper),
isKnown: true,
};
}
private extractYear(vin: string): number | null {
const yearChar = vin[9];
return YEAR_MAP[yearChar] ?? null;
}
getBrandFromWmi(wmi: string): string | null {
return WMI_DATABASE[wmi.toUpperCase()] ?? null;
}
}

View File

@@ -0,0 +1,22 @@
/**
* Ambient type declarations for browser-context code used in Puppeteer evaluate().
* These functions are serialized and executed inside Chromium, not in Node.js.
* We declare minimal DOM types here to avoid adding "dom" to the global tsconfig lib.
*/
interface Element {
querySelector(selector: string): Element | null;
querySelectorAll(selector: string): NodeListOf<Element>;
getAttribute(name: string): string | null;
textContent: string | null;
}
interface NodeListOf<T> {
forEach(callback: (value: T, index: number) => void): void;
length: number;
}
declare const document: {
querySelector(selector: string): Element | null;
querySelectorAll(selector: string): NodeListOf<Element>;
};

View File

@@ -0,0 +1,178 @@
import { Injectable, Logger } from "@nestjs/common";
import type {
EmexVehicleData,
EmexCategoryData,
EmexPartData,
} from "./emex.types";
@Injectable()
export class EmexParserService {
private readonly logger = new Logger(EmexParserService.name);
parseVehicleData(data: Record<string, unknown>): EmexVehicleData | null {
try {
const vehicleId = this.extractString(data, "vehicleId", "id", "vehicle_id");
if (!vehicleId) {
this.logger.warn("No vehicleId found in EMEX vehicle data");
return null;
}
const brandName = this.extractString(data, "brandName", "brand", "make") || "";
const name = this.extractString(data, "name", "title", "vehicleName") || "";
const modelCode = this.extractString(data, "modelCode", "model", "model_code");
const engine = this.extractString(data, "engine", "engineCode", "engine_code");
const yearFrom = this.extractNumber(data, "yearFrom", "year_from", "startYear");
const yearTo = this.extractNumber(data, "yearTo", "year_to", "endYear");
const catalogId = this.extractString(data, "catalogId", "catalog_id", "catalogueId") || "";
return {
vehicleId,
catalogId,
brandName,
name,
modelCode,
engine,
yearFrom,
yearTo,
rawData: data,
};
} catch (error) {
this.logger.error("Failed to parse EMEX vehicle data", error);
return null;
}
}
parseCategoryTree(data: unknown[]): EmexCategoryData[] {
try {
if (!Array.isArray(data)) {
this.logger.warn("Invalid category data: expected array");
return [];
}
const categories: EmexCategoryData[] = [];
for (const item of data) {
if (typeof item !== "object" || item === null) continue;
const record = item as Record<string, unknown>;
const groupId = this.extractString(record, "groupId", "id", "group_id");
const name = this.extractString(record, "name", "title", "groupName");
if (!groupId || !name) continue;
const category: EmexCategoryData = {
groupId,
name,
nameOriginal: this.extractString(record, "nameOriginal", "name_original", "originalName"),
parentGroupId: this.extractString(record, "parentGroupId", "parent_group_id", "parentId"),
sortOrder: this.extractNumber(record, "sortOrder", "sort_order", "order"),
};
categories.push(category);
// Recursively parse children if present
const children = record.children || record.subGroups || record.sub_groups;
if (Array.isArray(children) && children.length > 0) {
const childCategories = this.parseCategoryTree(
children.map((child: unknown) => ({
...(child as Record<string, unknown>),
parentGroupId: groupId,
})),
);
categories.push(...childCategories);
}
}
return categories;
} catch (error) {
this.logger.error("Failed to parse EMEX category tree", error);
return [];
}
}
parsePartsTable(data: unknown[]): EmexPartData[] {
try {
if (!Array.isArray(data)) {
this.logger.warn("Invalid parts data: expected array");
return [];
}
const parts: EmexPartData[] = [];
for (const item of data) {
if (typeof item !== "object" || item === null) continue;
const record = item as Record<string, unknown>;
const name = this.extractString(record, "name", "title", "partName");
if (!name) continue;
// Extract OEM codes
const oemCodes: string[] = [];
const rawOem = record.oemCodes || record.oem_codes || record.partNumbers || record.codes;
if (Array.isArray(rawOem)) {
for (const code of rawOem) {
if (typeof code === "string" && code.trim()) {
oemCodes.push(code.trim());
} else if (typeof code === "object" && code !== null) {
const codeStr = (code as Record<string, unknown>).code || (code as Record<string, unknown>).value;
if (typeof codeStr === "string" && codeStr.trim()) {
oemCodes.push(codeStr.trim());
}
}
}
} else if (typeof rawOem === "string" && rawOem.trim()) {
oemCodes.push(rawOem.trim());
}
// Try to extract single OEM code field
const singleOem = this.extractString(record, "oemCode", "oem_code", "partNumber");
if (singleOem && !oemCodes.includes(singleOem)) {
oemCodes.unshift(singleOem);
}
parts.push({
partId: this.extractString(record, "partId", "id", "part_id"),
name,
nameOriginal: this.extractString(record, "nameOriginal", "name_original", "originalName"),
description: this.extractString(record, "description", "desc", "note"),
quantity: this.extractNumber(record, "quantity", "qty", "count"),
position: this.extractString(record, "position", "pos", "location"),
hotspotIndex: this.extractNumber(record, "hotspotIndex", "hotspot_index", "hotspot"),
oemCodes,
});
}
return parts;
} catch (error) {
this.logger.error("Failed to parse EMEX parts table", error);
return [];
}
}
private extractString(data: Record<string, unknown>, ...keys: string[]): string | null {
for (const key of keys) {
const value = data[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return null;
}
private extractNumber(data: Record<string, unknown>, ...keys: string[]): number | null {
for (const key of keys) {
const value = data[key];
if (typeof value === "number" && !isNaN(value)) {
return value;
}
if (typeof value === "string") {
const parsed = parseInt(value, 10);
if (!isNaN(parsed)) return parsed;
}
}
return null;
}
}

View File

@@ -0,0 +1,136 @@
import { Injectable, Logger, OnModuleDestroy } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Queue, type JobsOptions } from "bullmq";
import { RedisService } from "../../redis/redis.service";
import type { EmexScrapeJobData, EmexJobStatus } from "./emex.types";
const QUEUE_NAME = "emex-scrape";
const DEFAULT_JOB_OPTIONS: JobsOptions = {
attempts: 3,
backoff: {
type: "exponential",
delay: 5000,
},
removeOnComplete: {
age: 86400, // 24h
count: 1000,
},
removeOnFail: {
age: 604800, // 7 days
count: 5000,
},
};
@Injectable()
export class EmexQueueService implements OnModuleDestroy {
private readonly logger = new Logger(EmexQueueService.name);
private readonly queue: Queue<EmexScrapeJobData>;
constructor(
private configService: ConfigService,
private redis: RedisService,
) {
const redisHost = this.configService.get<string>("redis.host", "127.0.0.1");
const redisPort = this.configService.get<number>("redis.port", 6379);
const redisPassword = this.configService.get<string>("redis.password");
this.queue = new Queue<EmexScrapeJobData>(QUEUE_NAME, {
connection: {
host: redisHost,
port: redisPort,
password: redisPassword,
maxRetriesPerRequest: null,
},
defaultJobOptions: DEFAULT_JOB_OPTIONS,
});
this.logger.log(`EMEX scrape queue initialized: ${QUEUE_NAME}`);
}
async onModuleDestroy() {
await this.queue.close();
this.logger.log("EMEX scrape queue closed");
}
async addScrapeJob(vin: string, userId: string): Promise<string> {
const jobData: EmexScrapeJobData = {
vin,
userId,
type: "full-decode",
};
const job = await this.queue.add("decode-vin", jobData, {
jobId: `emex-decode:${vin}:${Date.now()}`,
priority: 1,
});
this.logger.log(`Added EMEX scrape job for VIN: ${vin}, jobId: ${job.id}`);
return job.id!;
}
async addCategoriesJob(
emexVehicleId: string,
vin: string,
userId: string,
): Promise<string> {
const jobData: EmexScrapeJobData = {
vin,
userId,
type: "categories",
emexVehicleId,
};
const job = await this.queue.add("scrape-categories", jobData, {
jobId: `emex-categories:${emexVehicleId}:${Date.now()}`,
priority: 2,
});
this.logger.log(`Added EMEX categories job for vehicleId: ${emexVehicleId}, jobId: ${job.id}`);
return job.id!;
}
async addPartsJob(
emexVehicleId: string,
groupId: string,
vin: string,
userId: string,
): Promise<string> {
const jobData: EmexScrapeJobData = {
vin,
userId,
type: "parts",
emexVehicleId,
groupId,
};
const job = await this.queue.add("scrape-parts", jobData, {
jobId: `emex-parts:${emexVehicleId}:${groupId}:${Date.now()}`,
priority: 3,
});
this.logger.log(
`Added EMEX parts job for vehicleId: ${emexVehicleId}, groupId: ${groupId}, jobId: ${job.id}`,
);
return job.id!;
}
async getJobStatus(jobId: string): Promise<EmexJobStatus | null> {
const job = await this.queue.getJob(jobId);
if (!job) return null;
const state = await job.getState();
return {
jobId: job.id!,
status: state as EmexJobStatus["status"],
progress: typeof job.progress === "number" ? job.progress : 0,
result: state === "completed" ? (job.returnvalue as EmexJobStatus["result"]) : null,
failedReason: job.failedReason || null,
};
}
getQueue(): Queue<EmexScrapeJobData> {
return this.queue;
}
}

View File

@@ -0,0 +1,419 @@
import { Injectable, Logger, OnModuleDestroy } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Browser, Page } from "puppeteer";
import puppeteer from "puppeteer";
import { EmexParserService } from "./emex-parser.service";
import {
EmexCaptchaError,
EmexScraperError,
type EmexVehicleData,
type EmexCategoryData,
type EmexPartData,
type EmexCredentials,
} from "./emex.types";
const MAX_CONCURRENT_PAGES = 3;
const MAX_RETRIES = 3;
const PAGE_TIMEOUT = 30_000;
const EMEX_BASE_URL = "https://emex.ru";
@Injectable()
export class EmexScraperService implements OnModuleDestroy {
private readonly logger = new Logger(EmexScraperService.name);
private browser: Browser | null = null;
private activePagesCount = 0;
private readonly pageQueue: Array<{
resolve: (page: Page) => void;
reject: (error: Error) => void;
}> = [];
private readonly credentials: EmexCredentials;
constructor(
private configService: ConfigService,
private parser: EmexParserService,
) {
this.credentials = {
username: this.configService.get<string>("emex.username") || "",
password: this.configService.get<string>("emex.password") || "",
};
}
async onModuleDestroy() {
await this.closeBrowser();
}
async scrapeVehicle(vin: string): Promise<EmexVehicleData | null> {
return this.withRetry(`scrapeVehicle(${vin})`, async () => {
const page = await this.acquirePage();
try {
await this.ensureLoggedIn(page);
await page.goto(`${EMEX_BASE_URL}/catalogs/decode?vin=${encodeURIComponent(vin)}`, {
waitUntil: "networkidle2",
timeout: PAGE_TIMEOUT,
});
this.detectCaptcha(page);
const vehicleData = await page.evaluate(this.extractVehicleFromPage);
if (!vehicleData) {
this.logger.warn(`No vehicle data found for VIN: ${vin}`);
return null;
}
return this.parser.parseVehicleData(vehicleData);
} finally {
await this.releasePage(page);
}
});
}
async scrapeCategories(emexVehicleId: string): Promise<EmexCategoryData[]> {
return this.withRetry(`scrapeCategories(${emexVehicleId})`, async () => {
const page = await this.acquirePage();
try {
await this.ensureLoggedIn(page);
await page.goto(
`${EMEX_BASE_URL}/catalogs/vehicle/${encodeURIComponent(emexVehicleId)}/groups`,
{
waitUntil: "networkidle2",
timeout: PAGE_TIMEOUT,
},
);
this.detectCaptcha(page);
const categoriesData = await page.evaluate(this.extractCategoriesFromPage);
return this.parser.parseCategoryTree(categoriesData);
} finally {
await this.releasePage(page);
}
});
}
async scrapeParts(emexVehicleId: string, groupId: string): Promise<EmexPartData[]> {
return this.withRetry(`scrapeParts(${emexVehicleId}, ${groupId})`, async () => {
const page = await this.acquirePage();
try {
await this.ensureLoggedIn(page);
await page.goto(
`${EMEX_BASE_URL}/catalogs/vehicle/${encodeURIComponent(emexVehicleId)}/groups/${encodeURIComponent(groupId)}/parts`,
{
waitUntil: "networkidle2",
timeout: PAGE_TIMEOUT,
},
);
this.detectCaptcha(page);
const partsData = await page.evaluate(this.extractPartsFromPage);
return this.parser.parsePartsTable(partsData);
} finally {
await this.releasePage(page);
}
});
}
/**
* Browser-context function: extracts vehicle data from the EMEX decode page.
* Serialized and sent to Puppeteer's evaluate — runs inside Chromium, not Node.
*/
private extractVehicleFromPage(): Record<string, unknown> | null {
const vehicleInfo = document.querySelector(
"[data-vehicle-info], .vehicle-info, .decode-result",
);
if (!vehicleInfo) return null;
const data: Record<string, unknown> = {};
data.vehicleId = vehicleInfo.getAttribute("data-vehicle-id") || "";
data.catalogId = vehicleInfo.getAttribute("data-catalog-id") || "";
const fields = vehicleInfo.querySelectorAll("[data-field], .info-row, tr");
fields.forEach((field: Element) => {
const label =
field.querySelector(".label, th, [data-label]")?.textContent?.trim().toLowerCase() || "";
const value =
field.querySelector(".value, td, [data-value]")?.textContent?.trim() || "";
if (label.includes("brand") || label.includes("marka")) data.brandName = value;
if (label.includes("model")) data.modelCode = value;
if (label.includes("name") || label.includes("ad")) data.name = value;
if (label.includes("engine") || label.includes("motor")) data.engine = value;
if (label.includes("year") || label.includes("yil") || label.includes("yıl")) {
const years = value.match(/(\d{4})/g);
if (years) {
data.yearFrom = parseInt(years[0], 10);
if (years.length > 1) data.yearTo = parseInt(years[1], 10);
}
}
});
return data;
}
/**
* Browser-context function: extracts category groups from the EMEX groups page.
*/
private extractCategoriesFromPage(): Record<string, unknown>[] {
const groups: Record<string, unknown>[] = [];
const groupElements = document.querySelectorAll(
"[data-group], .group-item, .category-item, .tree-node",
);
groupElements.forEach((el: Element, index: number) => {
const group: Record<string, unknown> = {};
group.groupId =
el.getAttribute("data-group-id") || el.getAttribute("data-id") || `group-${index}`;
group.name =
el.querySelector(".group-name, .name, .title")?.textContent?.trim() || "";
group.nameOriginal = el.getAttribute("data-original-name") || null;
group.parentGroupId = el.getAttribute("data-parent-id") || null;
group.sortOrder = index;
if (group.name) {
groups.push(group);
}
});
return groups;
}
/**
* Browser-context function: extracts parts from the EMEX parts page.
*/
private extractPartsFromPage(): Record<string, unknown>[] {
const parts: Record<string, unknown>[] = [];
const partRows = document.querySelectorAll(
"[data-part], .part-row, .parts-table tbody tr, .part-item",
);
partRows.forEach((row: Element) => {
const part: Record<string, unknown> = {};
part.partId =
row.getAttribute("data-part-id") || row.getAttribute("data-id") || null;
part.name =
row.querySelector(".part-name, .name, td:nth-child(2)")?.textContent?.trim() || "";
part.nameOriginal = row.getAttribute("data-original-name") || null;
part.description =
row.querySelector(".part-desc, .description, td:nth-child(3)")?.textContent?.trim() ||
null;
const qtyText = row
.querySelector(".part-qty, .quantity, td:nth-child(4)")
?.textContent?.trim();
part.quantity = qtyText ? parseInt(qtyText, 10) : null;
part.position =
row.querySelector(".part-position, .position")?.textContent?.trim() || null;
const hotspotAttr = row.getAttribute("data-hotspot");
part.hotspotIndex = hotspotAttr ? parseInt(hotspotAttr, 10) : null;
// Extract OEM codes
const oemElements = row.querySelectorAll(".oem-code, .part-number, [data-oem]");
const codes: string[] = [];
oemElements.forEach((el: Element) => {
const code = el.textContent?.trim();
if (code) codes.push(code);
});
part.oemCodes = codes;
// Fallback: single OEM code field
if (codes.length === 0) {
const singleOem = row.querySelector("td:first-child")?.textContent?.trim();
if (singleOem) part.oemCode = singleOem;
}
if (part.name) {
parts.push(part);
}
});
return parts;
}
private async ensureLoggedIn(page: Page): Promise<void> {
if (!this.credentials.username || !this.credentials.password) {
throw new EmexScraperError("EMEX credentials not configured", false);
}
// Check if already logged in by looking for session cookie
const cookies = await page.cookies(EMEX_BASE_URL);
const sessionCookie = cookies.find(
(c) => c.name === "session" || c.name === "PHPSESSID" || c.name === "auth_token",
);
if (sessionCookie) return;
await page.goto(`${EMEX_BASE_URL}/login`, {
waitUntil: "networkidle2",
timeout: PAGE_TIMEOUT,
});
this.detectCaptcha(page);
await page.type(
'input[name="username"], input[name="email"], input[name="login"], #username, #email',
this.credentials.username,
);
await page.type(
'input[name="password"], input[type="password"], #password',
this.credentials.password,
);
await Promise.all([
page.waitForNavigation({ waitUntil: "networkidle2", timeout: PAGE_TIMEOUT }),
page.click('button[type="submit"], input[type="submit"], .login-btn, #login-btn'),
]);
this.detectCaptcha(page);
this.logger.log("Successfully logged in to EMEX");
}
private detectCaptcha(page: Page): void {
// Synchronous check of page URL for captcha indicators
const url = page.url();
if (url.includes("captcha") || url.includes("challenge")) {
throw new EmexCaptchaError();
}
}
private async withRetry<T>(operation: string, fn: () => Promise<T>): Promise<T> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (error instanceof EmexCaptchaError) {
this.logger.error(`CAPTCHA detected during ${operation}, cannot retry`);
throw error;
}
if (error instanceof EmexScraperError && !error.retryable) {
throw error;
}
this.logger.warn(
`Attempt ${attempt}/${MAX_RETRIES} failed for ${operation}: ${lastError.message}`,
);
if (attempt < MAX_RETRIES) {
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10_000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
throw new EmexScraperError(
`${operation} failed after ${MAX_RETRIES} attempts: ${lastError?.message}`,
false,
);
}
private async getBrowser(): Promise<Browser> {
if (!this.browser || !this.browser.connected) {
this.browser = await puppeteer.launch({
headless: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-extensions",
"--single-process",
],
});
this.browser.on("disconnected", () => {
this.logger.warn("Browser disconnected");
this.browser = null;
this.activePagesCount = 0;
});
this.logger.log("Puppeteer browser launched");
}
return this.browser;
}
private async acquirePage(): Promise<Page> {
if (this.activePagesCount >= MAX_CONCURRENT_PAGES) {
return new Promise<Page>((resolve, reject) => {
this.pageQueue.push({ resolve, reject });
});
}
this.activePagesCount++;
try {
const browser = await this.getBrowser();
const page = await browser.newPage();
await page.setDefaultTimeout(PAGE_TIMEOUT);
await page.setDefaultNavigationTimeout(PAGE_TIMEOUT);
await page.setViewport({ width: 1280, height: 800 });
await page.setUserAgent(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
);
return page;
} catch (error) {
this.activePagesCount--;
this.processPageQueue();
throw error;
}
}
private async releasePage(page: Page): Promise<void> {
try {
if (!page.isClosed()) {
await page.close();
}
} catch {
// Page may already be closed
}
this.activePagesCount--;
this.processPageQueue();
}
private processPageQueue(): void {
if (this.pageQueue.length > 0 && this.activePagesCount < MAX_CONCURRENT_PAGES) {
const next = this.pageQueue.shift();
if (next) {
this.acquirePage().then(next.resolve).catch(next.reject);
}
}
}
private async closeBrowser(): Promise<void> {
// Reject queued page requests
for (const queued of this.pageQueue) {
queued.reject(new Error("Browser closing"));
}
this.pageQueue.length = 0;
if (this.browser) {
try {
await this.browser.close();
} catch {
// Browser may already be closed
}
this.browser = null;
this.activePagesCount = 0;
this.logger.log("Puppeteer browser closed");
}
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { EmexService } from "./emex.service";
import { EmexScraperService } from "./emex-scraper.service";
import { EmexParserService } from "./emex-parser.service";
import { EmexQueueService } from "./emex-queue.service";
@Module({
providers: [EmexService, EmexScraperService, EmexParserService, EmexQueueService],
exports: [EmexService],
})
export class EmexModule {}

View File

@@ -0,0 +1,371 @@
import { Inject, Injectable, Logger } from "@nestjs/common";
import { eq, and } from "drizzle-orm";
import { DATABASE, type Database } from "../../database/database.provider";
import { RedisService } from "../../redis/redis.service";
import { EmexScraperService } from "./emex-scraper.service";
import { EmexQueueService } from "./emex-queue.service";
import {
emexVehicles,
emexVehicleVins,
emexPartGroups,
emexParts,
emexPartNumbers,
emexCatalogs,
emexScrapeSessions,
} from "../../database/schema/emex";
import type {
EmexVehicleData,
EmexCategoryData,
EmexPartData,
EmexJobStatus,
} from "./emex.types";
const CACHE_PREFIX = "emex:";
const VEHICLE_CACHE_TTL = 86400; // 24h
const CATEGORY_CACHE_TTL = 3600; // 1h
const PARTS_CACHE_TTL = 3600; // 1h
@Injectable()
export class EmexService {
private readonly logger = new Logger(EmexService.name);
constructor(
@Inject(DATABASE) private db: Database,
private redis: RedisService,
private scraper: EmexScraperService,
private queue: EmexQueueService,
) {}
async decodeVin(vin: string, userId: string): Promise<{ jobId: string }> {
// Check if we already have a recent scrape session
const existingSession = await this.db
.select()
.from(emexScrapeSessions)
.where(and(eq(emexScrapeSessions.vin, vin), eq(emexScrapeSessions.status, "pending")))
.limit(1);
if (existingSession.length > 0 && existingSession[0].jobId) {
this.logger.log(`Reusing existing scrape session for VIN: ${vin}`);
return { jobId: existingSession[0].jobId };
}
const jobId = await this.queue.addScrapeJob(vin, userId);
// Create scrape session record
await this.db.insert(emexScrapeSessions).values({
vin,
status: "pending",
jobId,
startedAt: new Date(),
});
return { jobId };
}
async getJobStatus(jobId: string): Promise<EmexJobStatus | null> {
return this.queue.getJobStatus(jobId);
}
async getScrapedVehicle(vin: string): Promise<EmexVehicleData | null> {
// Redis cache check
const cacheKey = `${CACHE_PREFIX}vehicle:${vin}`;
const cached = await this.redis.getJson<EmexVehicleData>(cacheKey);
if (cached) return cached;
// Database check via VIN link
const vinRecord = await this.db
.select()
.from(emexVehicleVins)
.where(eq(emexVehicleVins.vin, vin))
.limit(1);
if (vinRecord.length === 0 || !vinRecord[0].emexVehicleId) return null;
const vehicleRecord = await this.db
.select()
.from(emexVehicles)
.where(eq(emexVehicles.id, vinRecord[0].emexVehicleId))
.limit(1);
if (vehicleRecord.length === 0) return null;
const vehicle = vehicleRecord[0];
const result: EmexVehicleData = {
vehicleId: vehicle.vehicleId,
catalogId: vehicle.catalogId || "",
brandName: "",
name: vehicle.name || "",
modelCode: vehicle.modelCode || null,
engine: vehicle.engine || null,
yearFrom: vehicle.yearFrom || null,
yearTo: vehicle.yearTo || null,
rawData: (vehicle.rawData as Record<string, unknown>) || null,
};
// Resolve brand name from catalog
if (vehicle.catalogId) {
const catalog = await this.db
.select()
.from(emexCatalogs)
.where(eq(emexCatalogs.id, vehicle.catalogId))
.limit(1);
if (catalog.length > 0) {
result.brandName = catalog[0].brandName;
}
}
await this.redis.setJson(cacheKey, result, VEHICLE_CACHE_TTL);
return result;
}
async getScrapedCategories(vehicleId: string): Promise<EmexCategoryData[]> {
const cacheKey = `${CACHE_PREFIX}categories:${vehicleId}`;
const cached = await this.redis.getJson<EmexCategoryData[]>(cacheKey);
if (cached) return cached;
// Look up the internal UUID from the emex vehicleId string
const vehicleRecord = await this.db
.select()
.from(emexVehicles)
.where(eq(emexVehicles.vehicleId, vehicleId))
.limit(1);
if (vehicleRecord.length === 0) return [];
const emexVehicleUuid = vehicleRecord[0].id;
const groups = await this.db
.select()
.from(emexPartGroups)
.where(eq(emexPartGroups.emexVehicleId, emexVehicleUuid));
const categories: EmexCategoryData[] = groups.map((g) => ({
groupId: g.groupId,
name: g.name,
nameOriginal: g.nameOriginal || null,
parentGroupId: g.parentGroupId || null,
sortOrder: g.sortOrder || null,
}));
if (categories.length > 0) {
await this.redis.setJson(cacheKey, categories, CATEGORY_CACHE_TTL);
}
return categories;
}
async getScrapedParts(vehicleId: string, groupId: string): Promise<EmexPartData[]> {
const cacheKey = `${CACHE_PREFIX}parts:${vehicleId}:${groupId}`;
const cached = await this.redis.getJson<EmexPartData[]>(cacheKey);
if (cached) return cached;
// Look up internal UUIDs
const vehicleRecord = await this.db
.select()
.from(emexVehicles)
.where(eq(emexVehicles.vehicleId, vehicleId))
.limit(1);
if (vehicleRecord.length === 0) return [];
const emexVehicleUuid = vehicleRecord[0].id;
const groupRecord = await this.db
.select()
.from(emexPartGroups)
.where(
and(
eq(emexPartGroups.emexVehicleId, emexVehicleUuid),
eq(emexPartGroups.groupId, groupId),
),
)
.limit(1);
if (groupRecord.length === 0) return [];
const groupUuid = groupRecord[0].id;
// Fetch parts for this group
const partsRecords = await this.db
.select()
.from(emexParts)
.where(
and(
eq(emexParts.emexVehicleId, emexVehicleUuid),
eq(emexParts.groupId, groupUuid),
),
);
// Fetch OEM codes for each part
const parts: EmexPartData[] = await Promise.all(
partsRecords.map(async (part) => {
const partNumbers = await this.db
.select()
.from(emexPartNumbers)
.where(eq(emexPartNumbers.emexPartId, part.id));
return {
partId: part.partId || null,
name: part.name,
nameOriginal: part.nameOriginal || null,
description: part.description || null,
quantity: part.quantity || null,
position: part.position || null,
hotspotIndex: part.hotspotIndex || null,
oemCodes: partNumbers.map((pn) => pn.oemCode),
};
}),
);
if (parts.length > 0) {
await this.redis.setJson(cacheKey, parts, PARTS_CACHE_TTL);
}
return parts;
}
async saveScrapedVehicle(vin: string, data: EmexVehicleData): Promise<string> {
// Upsert catalog
let catalogUuid: string | null = null;
if (data.catalogId) {
const existingCatalog = await this.db
.select()
.from(emexCatalogs)
.where(eq(emexCatalogs.catalogId, data.catalogId))
.limit(1);
if (existingCatalog.length > 0) {
catalogUuid = existingCatalog[0].id;
} else {
const [inserted] = await this.db
.insert(emexCatalogs)
.values({
catalogId: data.catalogId,
brandName: data.brandName,
})
.returning();
catalogUuid = inserted.id;
}
}
// Upsert vehicle
const existingVehicle = await this.db
.select()
.from(emexVehicles)
.where(eq(emexVehicles.vehicleId, data.vehicleId))
.limit(1);
let vehicleUuid: string;
if (existingVehicle.length > 0) {
vehicleUuid = existingVehicle[0].id;
await this.db
.update(emexVehicles)
.set({
catalogId: catalogUuid,
name: data.name,
modelCode: data.modelCode,
engine: data.engine,
yearFrom: data.yearFrom,
yearTo: data.yearTo,
rawData: data.rawData,
})
.where(eq(emexVehicles.id, vehicleUuid));
} else {
const [inserted] = await this.db
.insert(emexVehicles)
.values({
vehicleId: data.vehicleId,
catalogId: catalogUuid,
name: data.name,
modelCode: data.modelCode,
engine: data.engine,
yearFrom: data.yearFrom,
yearTo: data.yearTo,
rawData: data.rawData,
})
.returning();
vehicleUuid = inserted.id;
}
// Link VIN to vehicle
const existingVinLink = await this.db
.select()
.from(emexVehicleVins)
.where(eq(emexVehicleVins.vin, vin))
.limit(1);
if (existingVinLink.length === 0) {
await this.db.insert(emexVehicleVins).values({
emexVehicleId: vehicleUuid,
vin,
});
}
// Invalidate cache
await this.redis.del(`${CACHE_PREFIX}vehicle:${vin}`);
return vehicleUuid;
}
async saveScrapedCategories(
emexVehicleUuid: string,
categories: EmexCategoryData[],
): Promise<void> {
for (const category of categories) {
const existing = await this.db
.select()
.from(emexPartGroups)
.where(
and(
eq(emexPartGroups.emexVehicleId, emexVehicleUuid),
eq(emexPartGroups.groupId, category.groupId),
),
)
.limit(1);
if (existing.length === 0) {
await this.db.insert(emexPartGroups).values({
emexVehicleId: emexVehicleUuid,
groupId: category.groupId,
name: category.name,
nameOriginal: category.nameOriginal,
parentGroupId: category.parentGroupId,
sortOrder: category.sortOrder,
});
}
}
}
async saveScrapedParts(
emexVehicleUuid: string,
groupUuid: string,
parts: EmexPartData[],
): Promise<void> {
for (const part of parts) {
const [insertedPart] = await this.db
.insert(emexParts)
.values({
emexVehicleId: emexVehicleUuid,
groupId: groupUuid,
partId: part.partId,
name: part.name,
nameOriginal: part.nameOriginal,
description: part.description,
quantity: part.quantity,
position: part.position,
hotspotIndex: part.hotspotIndex,
})
.returning();
// Insert OEM codes
for (let i = 0; i < part.oemCodes.length; i++) {
await this.db.insert(emexPartNumbers).values({
emexPartId: insertedPart.id,
oemCode: part.oemCodes[i],
isMain: i === 0,
});
}
}
}
}

View File

@@ -0,0 +1,74 @@
export interface EmexVehicleData {
vehicleId: string;
catalogId: string;
brandName: string;
name: string;
modelCode: string | null;
engine: string | null;
yearFrom: number | null;
yearTo: number | null;
rawData: Record<string, unknown> | null;
}
export interface EmexCategoryData {
groupId: string;
name: string;
nameOriginal: string | null;
parentGroupId: string | null;
sortOrder: number | null;
}
export interface EmexPartData {
partId: string | null;
name: string;
nameOriginal: string | null;
description: string | null;
quantity: number | null;
position: string | null;
hotspotIndex: number | null;
oemCodes: string[];
}
export interface EmexScrapeJobData {
vin: string;
userId: string;
type: "full-decode" | "categories" | "parts";
emexVehicleId?: string;
groupId?: string;
}
export interface EmexScrapeResult {
vehicle: EmexVehicleData | null;
categories: EmexCategoryData[];
parts: EmexPartData[];
}
export interface EmexJobStatus {
jobId: string;
status: "waiting" | "active" | "completed" | "failed" | "delayed";
progress: number;
result: EmexScrapeResult | null;
failedReason: string | null;
}
export interface EmexCredentials {
username: string;
password: string;
}
export class EmexCaptchaError extends Error {
constructor(message = "CAPTCHA detected on EMEX page") {
super(message);
this.name = "EmexCaptchaError";
}
}
export class EmexScraperError extends Error {
constructor(
message: string,
public readonly retryable = true,
) {
super(message);
this.name = "EmexScraperError";
}
}

View File

@@ -0,0 +1,21 @@
import { ParsedVehicle, ParsedCategory, PL24PartResponse } from "../pl24.types";
export abstract class BasePL24Parser {
abstract readonly brandName: string;
abstract parseVehicle(raw: Record<string, unknown>): ParsedVehicle;
abstract parseCategories(raw: unknown[]): ParsedCategory[];
abstract parseParts(raw: unknown[]): PL24PartResponse[];
protected safeString(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
return String(value);
}
protected safeNumber(value: unknown): number {
if (typeof value === "number") return value;
const parsed = Number(value);
return isNaN(parsed) ? 0 : parsed;
}
}

View File

@@ -0,0 +1,17 @@
import { GenericPL24Parser } from "./generic-parser";
import { ParsedVehicle } from "../pl24.types";
export class BmwPL24Parser extends GenericPL24Parser {
constructor() {
super("BMW");
}
parseVehicle(raw: Record<string, unknown>): ParsedVehicle {
const base = super.parseVehicle(raw);
// BMW-specific: extract series from model code (E90, F30, G20, etc.)
if (base.modelCode && typeof raw.series === "string") {
base.name = `${raw.series} ${base.modelCode}`;
}
return base;
}
}

View File

@@ -0,0 +1,56 @@
import { BasePL24Parser } from "./base-parser";
import { ParsedVehicle, ParsedCategory, PL24PartResponse } from "../pl24.types";
export class GenericPL24Parser extends BasePL24Parser {
readonly brandName: string;
constructor(brandName: string) {
super();
this.brandName = brandName;
}
parseVehicle(raw: Record<string, unknown>): ParsedVehicle {
return {
vehicleId: this.safeString(raw.vehicleId || raw.id),
catalogId: this.safeString(raw.catalogId || raw.catalog_id),
name: this.safeString(raw.name || raw.description),
modelCode: this.safeString(raw.modelCode || raw.model_code || raw.model),
engine: this.safeString(raw.engine || raw.engineCode),
transmission: this.safeString(raw.transmission || raw.gearbox),
bodyType: this.safeString(raw.bodyType || raw.body_type || raw.body),
market: this.safeString(raw.market || raw.region),
yearFrom: this.safeNumber(raw.yearFrom || raw.year_from || raw.prodFrom),
yearTo: this.safeNumber(raw.yearTo || raw.year_to || raw.prodTo),
};
}
parseCategories(raw: unknown[]): ParsedCategory[] {
return raw.map((item: any, index: number) => ({
groupId: this.safeString(item.groupId || item.id || item.group_id),
name: this.safeString(item.name || item.description),
parentGroupId: item.parentGroupId || item.parent_group_id || null,
sortOrder: this.safeNumber(item.sortOrder || item.sort_order || index),
hasSchemaPic: !!item.hasSchemaPic || !!item.has_schema || !!item.imageUrl,
}));
}
parseParts(raw: unknown[]): PL24PartResponse[] {
return raw.map((item: any) => ({
partId: this.safeString(item.partId || item.id || item.part_id),
name: this.safeString(item.name || item.description),
description: this.safeString(item.description || item.additionalInfo || ""),
quantity: this.safeNumber(item.quantity || item.qty || 1),
position: this.safeString(item.position || item.pos || ""),
hotspotIndex: item.hotspotIndex ?? item.hotspot_index ?? item.callout ?? null,
oemCodes: this.extractOemCodes(item),
}));
}
private extractOemCodes(item: any): string[] {
if (Array.isArray(item.oemCodes)) return item.oemCodes;
if (Array.isArray(item.partNumbers)) return item.partNumbers.map((p: any) => p.code || p);
if (item.oemCode) return [item.oemCode];
if (item.partNumber) return [item.partNumber];
return [];
}
}

View File

@@ -0,0 +1,17 @@
import { GenericPL24Parser } from "./generic-parser";
import { ParsedVehicle } from "../pl24.types";
export class MercedesPL24Parser extends GenericPL24Parser {
constructor() {
super("Mercedes-Benz");
}
parseVehicle(raw: Record<string, unknown>): ParsedVehicle {
const base = super.parseVehicle(raw);
// Mercedes-specific: extract class (W205, W213, etc.)
if (typeof raw.baumuster === "string") {
base.modelCode = raw.baumuster;
}
return base;
}
}

View File

@@ -0,0 +1,15 @@
import { BasePL24Parser } from "./base-parser";
import { BmwPL24Parser } from "./bmw-parser";
import { MercedesPL24Parser } from "./mercedes-parser";
import { GenericPL24Parser } from "./generic-parser";
const PARSER_MAP: Record<string, () => BasePL24Parser> = {
"BMW": () => new BmwPL24Parser(),
"Mercedes-Benz": () => new MercedesPL24Parser(),
};
export function createParser(brandName: string): BasePL24Parser {
const factory = PARSER_MAP[brandName];
if (factory) return factory();
return new GenericPL24Parser(brandName);
}

View File

@@ -0,0 +1,61 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { RedisService } from "../../redis/redis.service";
import { PL24_DEFAULTS } from "./pl24.constants";
@Injectable()
export class PL24AuthService {
private readonly logger = new Logger(PL24AuthService.name);
private readonly cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}auth_token`;
constructor(
private configService: ConfigService,
private redis: RedisService,
) {}
async getToken(): Promise<string> {
// Check Redis cache
const cached = await this.redis.get(this.cacheKey);
if (cached) return cached;
// Authenticate with PL24
const token = await this.authenticate();
await this.redis.set(this.cacheKey, token, PL24_DEFAULTS.AUTH_TOKEN_TTL);
return token;
}
private async authenticate(): Promise<string> {
const apiUrl = this.configService.get<string>("pl24.apiUrl");
const username = this.configService.get<string>("pl24.username");
const password = this.configService.get<string>("pl24.password");
if (!apiUrl || !username || !password) {
this.logger.warn("PL24 credentials not configured");
throw new Error("PL24 credentials not configured");
}
try {
const response = await fetch(`${apiUrl}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
signal: AbortSignal.timeout(PL24_DEFAULTS.REQUEST_TIMEOUT),
});
if (!response.ok) {
throw new Error(`PL24 auth failed: ${response.status}`);
}
const data = (await response.json()) as { token: string };
this.logger.log("PL24 authenticated successfully");
return data.token;
} catch (error) {
this.logger.error("PL24 authentication failed", error);
throw error;
}
}
async invalidateToken(): Promise<void> {
await this.redis.del(this.cacheKey);
}
}

View File

@@ -0,0 +1,6 @@
export const PL24_DEFAULTS = {
AUTH_TOKEN_TTL: 3600, // 1 hour in seconds
CACHE_PREFIX: "pl24:",
REQUEST_TIMEOUT: 30000,
MAX_RETRIES: 3,
} as const;

View File

@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { PL24Service } from "./pl24.service";
import { PL24AuthService } from "./pl24-auth.service";
@Module({
providers: [PL24Service, PL24AuthService],
exports: [PL24Service],
})
export class PL24Module {}

View File

@@ -0,0 +1,189 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PL24AuthService } from "./pl24-auth.service";
import { RedisService } from "../../redis/redis.service";
import { StorageService } from "../../storage/storage.service";
import { createParser } from "./parsers/parser-factory";
import { PL24_DEFAULTS } from "./pl24.constants";
import type {
PL24VehicleResponse,
PL24CategoryResponse,
PL24PartResponse,
PL24SchemaPicResponse,
ParsedVehicle,
ParsedCategory,
} from "./pl24.types";
@Injectable()
export class PL24Service {
private readonly logger = new Logger(PL24Service.name);
private readonly apiUrl: string;
constructor(
private configService: ConfigService,
private authService: PL24AuthService,
private redis: RedisService,
private storage: StorageService,
) {
this.apiUrl = this.configService.get<string>("pl24.apiUrl") || "";
}
async decodeVin(vin: string, brandName: string): Promise<ParsedVehicle | null> {
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}vehicle:${vin}`;
const cached = await this.redis.getJson<ParsedVehicle>(cacheKey);
if (cached) return cached;
if (!this.apiUrl) {
this.logger.warn("PL24 API URL not configured");
return null;
}
try {
const token = await this.authService.getToken();
const response = await this.makeRequest<PL24VehicleResponse>(`/vehicles/decode/${vin}`, token);
if (!response) return null;
const parser = createParser(brandName);
const parsed = parser.parseVehicle(response as unknown as Record<string, unknown>);
await this.redis.setJson(cacheKey, parsed, 86400); // 24h cache
return parsed;
} catch (error) {
this.logger.error(`PL24 decode failed for ${vin}`, error);
return null;
}
}
async getCategories(vehicleId: string, brandName: string): Promise<ParsedCategory[]> {
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}categories:${vehicleId}`;
const cached = await this.redis.getJson<ParsedCategory[]>(cacheKey);
if (cached) return cached;
if (!this.apiUrl) return [];
try {
const token = await this.authService.getToken();
const response = await this.makeRequest<PL24CategoryResponse[]>(
`/vehicles/${vehicleId}/groups`,
token,
);
if (!response) return [];
const parser = createParser(brandName);
const parsed = parser.parseCategories(response as unknown as unknown[]);
await this.redis.setJson(cacheKey, parsed, 3600); // 1h cache
return parsed;
} catch (error) {
this.logger.error(`PL24 get categories failed for ${vehicleId}`, error);
return [];
}
}
async getParts(vehicleId: string, groupId: string, brandName: string): Promise<PL24PartResponse[]> {
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}parts:${vehicleId}:${groupId}`;
const cached = await this.redis.getJson<PL24PartResponse[]>(cacheKey);
if (cached) return cached;
if (!this.apiUrl) return [];
try {
const token = await this.authService.getToken();
const response = await this.makeRequest<PL24PartResponse[]>(
`/vehicles/${vehicleId}/groups/${groupId}/parts`,
token,
);
if (!response) return [];
const parser = createParser(brandName);
const parsed = parser.parseParts(response as unknown as unknown[]);
await this.redis.setJson(cacheKey, parsed, 3600);
return parsed;
} catch (error) {
this.logger.error(`PL24 get parts failed`, error);
return [];
}
}
async getSchemaImage(vehicleId: string, groupId: string): Promise<PL24SchemaPicResponse | null> {
// Check if already uploaded to MinIO
const minioKey = `schemas/${vehicleId}/${groupId}.png`;
const existingUrl = this.storage.getPublicUrl(minioKey);
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}schema:${vehicleId}:${groupId}`;
const cached = await this.redis.getJson<PL24SchemaPicResponse>(cacheKey);
if (cached) return cached;
if (!this.apiUrl) return null;
try {
const token = await this.authService.getToken();
const response = await this.makeRequest<PL24SchemaPicResponse>(
`/vehicles/${vehicleId}/groups/${groupId}/schema`,
token,
);
if (!response) return null;
// Download image and upload to MinIO
if (response.imageUrl) {
try {
const imageResponse = await fetch(response.imageUrl, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(PL24_DEFAULTS.REQUEST_TIMEOUT),
});
if (imageResponse.ok) {
const buffer = Buffer.from(await imageResponse.arrayBuffer());
const uploadedUrl = await this.storage.upload(minioKey, buffer, "image/png");
response.imageUrl = uploadedUrl;
}
} catch (imgError) {
this.logger.warn(`Failed to upload schema image to MinIO`, imgError);
}
}
await this.redis.setJson(cacheKey, response, 86400);
return response;
} catch (error) {
this.logger.error(`PL24 get schema failed`, error);
return null;
}
}
private async makeRequest<T>(path: string, token: string): Promise<T | null> {
try {
const response = await fetch(`${this.apiUrl}${path}`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
signal: AbortSignal.timeout(PL24_DEFAULTS.REQUEST_TIMEOUT),
});
if (response.status === 401) {
await this.authService.invalidateToken();
const newToken = await this.authService.getToken();
const retry = await fetch(`${this.apiUrl}${path}`, {
headers: {
Authorization: `Bearer ${newToken}`,
"Content-Type": "application/json",
},
signal: AbortSignal.timeout(PL24_DEFAULTS.REQUEST_TIMEOUT),
});
if (!retry.ok) return null;
return (await retry.json()) as T;
}
if (!response.ok) return null;
return (await response.json()) as T;
} catch (error) {
this.logger.error(`PL24 request failed: ${path}`, error);
return null;
}
}
}

View File

@@ -0,0 +1,74 @@
export interface PL24AuthResponse {
token: string;
expiresIn: number;
}
export interface PL24VehicleResponse {
vehicleId: string;
catalogId: string;
name: string;
modelCode: string;
engine: string;
transmission: string;
bodyType: string;
market: string;
yearFrom: number;
yearTo: number;
raw: Record<string, unknown>;
}
export interface PL24CategoryResponse {
groupId: string;
name: string;
parentGroupId: string | null;
sortOrder: number;
hasSchemaPic: boolean;
}
export interface PL24PartResponse {
partId: string;
name: string;
description: string;
quantity: number;
position: string;
hotspotIndex: number | null;
oemCodes: string[];
}
export interface PL24SchemaPicResponse {
imageUrl: string;
hotspots: PL24Hotspot[];
width: number;
height: number;
}
export interface PL24Hotspot {
index: number;
x: number;
y: number;
width: number;
height: number;
shape: "rect" | "circle" | "polygon";
points?: { x: number; y: number }[];
}
export interface ParsedVehicle {
vehicleId: string;
catalogId: string;
name: string;
modelCode: string;
engine: string;
transmission: string;
bodyType: string;
market: string;
yearFrom: number;
yearTo: number;
}
export interface ParsedCategory {
groupId: string;
name: string;
parentGroupId: string | null;
sortOrder: number;
hasSchemaPic: boolean;
}

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { VinApiService } from "./vin-api.service";
@Module({
providers: [VinApiService],
exports: [VinApiService],
})
export class VinApiModule {}

View File

@@ -0,0 +1,44 @@
import { Injectable, Logger } from "@nestjs/common";
interface NHTSAResult {
make: string;
model: string;
modelYear: string;
bodyClass: string;
engineModel: string;
transmissionStyle: string;
plantCountry: string;
}
@Injectable()
export class VinApiService {
private readonly logger = new Logger(VinApiService.name);
async decodeVin(vin: string): Promise<NHTSAResult | null> {
try {
const response = await fetch(
`https://vpic.nhtsa.dot.gov/api/vehicles/decodevinvalues/${vin}?format=json`,
{ signal: AbortSignal.timeout(10000) },
);
if (!response.ok) return null;
const data = (await response.json()) as { Results?: Record<string, string>[] };
const results = data.Results?.[0];
if (!results) return null;
return {
make: results.Make || "",
model: results.Model || "",
modelYear: results.ModelYear || "",
bodyClass: results.BodyClass || "",
engineModel: results.EngineModel || "",
transmissionStyle: results.TransmissionStyle || "",
plantCountry: results.PlantCountry || "",
};
} catch (error) {
this.logger.warn(`NHTSA decode failed for ${vin}`, error);
return null;
}
}
}