feat: Ford legacy support, EMEX browser pooling, collapsible sidebar

- Add PL24 Ford legacy service for fordt_parts architecture
- Refactor EMEX to use persistent browser pool instead of per-call instances
- Make vehicle decode resilient: fallback to PL24 when Corgi doesn't recognize VIN
- Add collapsible sidebar with persistent user preference
- Improve brand access guard and categories service
- Add debug/test scripts for VIN e2e testing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-02-14 22:25:06 +00:00
parent 7b024df4d5
commit 4a06ba5fdb
53 changed files with 17053 additions and 682 deletions

View File

@@ -49,7 +49,7 @@
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^3.2.4",
"drizzle-kit": "^0.31.4",
"puppeteer": "^23.0.0",
"playwright": "^1.50.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"

View File

@@ -81,7 +81,7 @@ export class CategoriesService {
}
// If still no categories, try EMEX fallback
if (dbCategories.length === 0 && vehicle.vin && this.emexService.isSupported(vehicle.vin)) {
if (dbCategories.length === 0 && vehicle.vin) {
this.logger.log(`No PL24 categories for ${vehicle.vin}, trying EMEX fallback`);
try {
const emexResult = await this.emexService.decodeVin(vehicle.vin);
@@ -105,6 +105,8 @@ export class CategoriesService {
for (const node of nodes) {
if (!node.name) continue;
const isLeaf = !node.children?.length;
// URL'si olmayan leaf node'lar dead-end — kaydetme
if (isLeaf && !node.url) continue;
const [inserted] = await this.db
.insert(categories)
@@ -221,8 +223,8 @@ export class CategoriesService {
return [];
}
// BOM links are leaf categories — they return parts, not subgroups
if (linkPath.includes("/bom/")) {
// BOM / servicepart item links are leaf categories — they return parts, not subgroups
if (linkPath.includes("/bom/") || linkPath.includes("/bomdetails") || linkPath.includes("/partinfo/") || linkPath.includes("/servicepart/vin_items")) {
return [];
}
@@ -315,8 +317,15 @@ export class CategoriesService {
.from(schemaPics)
.where(eq(schemaPics.categoryId, categoryId));
// If no parts in DB, fetch from source
if (dbParts.length === 0 && category.linkPath) {
// Fetch parts and/or schema image from source if missing
const needParts = dbParts.length === 0;
const needImage = pics.length === 0;
if ((needParts || needImage) && !category.linkPath && category.source === "emex") {
this.logger.warn(`EMEX leaf category ${categoryId} has no linkPath — dead-end node`);
}
if ((needParts || needImage) && category.linkPath) {
const [vehicle] = await this.db
.select()
.from(vehicles)
@@ -337,7 +346,7 @@ export class CategoriesService {
}
}
if (emexResult.parts.length > 0) {
if (needParts && emexResult.parts.length > 0) {
const insertData = emexResult.parts.map((p) => ({
vehicleId: vehicle.id,
categoryId,
@@ -356,7 +365,7 @@ export class CategoriesService {
}
// Download schema image from img.laximo.net and upload to MinIO
if (emexResult.schemaImageUrl && pics.length === 0) {
if (needImage && emexResult.schemaImageUrl) {
try {
const imgResp = await fetch(emexResult.schemaImageUrl, {
signal: AbortSignal.timeout(15000),
@@ -400,7 +409,7 @@ export class CategoriesService {
this.logger.error(`Failed to fetch EMEX parts for category ${categoryId}: ${(err as Error).message}`);
}
} else if (vehicle) {
// PL24: fetch parts via PL24 API
// PL24: fetch parts + schema image via PL24 API
const rawData = vehicle.rawData as any;
const catalogInfo = rawData?.catalogInfo;
@@ -412,7 +421,7 @@ export class CategoriesService {
);
// Store parts
if (pl24Result.parts.length > 0) {
if (needParts && pl24Result.parts.length > 0) {
const insertData = pl24Result.parts.map((p) => ({
vehicleId: vehicle.id,
categoryId,
@@ -422,7 +431,10 @@ export class CategoriesService {
description: p.description || null,
quantity: p.quantity || null,
position: p.positionCode || null,
hotspotIndex: p.hotspotId ? parseInt(p.hotspotId, 10) || null : null,
hotspotIndex: p.hotspotId ? (() => {
const val = parseInt(p.hotspotId!, 10);
return (val > 0 && val <= 2147483647) ? val : null;
})() : null,
source: "pl24" as const,
}));
@@ -430,7 +442,7 @@ export class CategoriesService {
}
// Store schema image if available
if (pl24Result.schemaImageUrl && pics.length === 0) {
if (needImage && pl24Result.schemaImageUrl) {
const imageResult = await this.pl24Service.getSchemaImage(
pl24Result.schemaImageUrl,
catalogInfo.serviceName,
@@ -566,8 +578,11 @@ export class CategoriesService {
return cats.map((c) => {
const dbChildCount = childCountMap.get(c.id) || 0;
// Leaf if: has BOM linkPath, OR has no linkPath and no DB children
const isLeaf = c.linkPath?.includes("/bom/") || (!c.linkPath && dbChildCount === 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
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));
return {
...c,
schemaImageUrl: picMap.get(c.id) || null,

View File

@@ -1,7 +1,7 @@
import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable } from "@nestjs/common";
import { eq, and } from "drizzle-orm";
import { DATABASE, Database } from "../../database/database.provider";
import { userSubscriptions, userBrands } from "../../database/schema/core";
import { userSubscriptions, userBrands, plans } from "../../database/schema/core";
@Injectable()
export class BrandAccessGuard implements CanActivate {
@@ -22,8 +22,12 @@ export class BrandAccessGuard implements CanActivate {
// Check if user has an active subscription with access to this brand
const activeSub = await this.db
.select()
.select({
id: userSubscriptions.id,
brandCount: plans.brandCount,
})
.from(userSubscriptions)
.innerJoin(plans, eq(userSubscriptions.planId, plans.id))
.where(and(eq(userSubscriptions.userId, user.id), eq(userSubscriptions.status, "active")))
.limit(1);
@@ -31,10 +35,12 @@ export class BrandAccessGuard implements CanActivate {
throw new ForbiddenException("No active subscription");
}
// Check if the subscription includes this brand (brandCount=0 means all brands)
const subscription = activeSub[0];
// Check userBrands junction
// brandCount === 0 means unlimited (Full Paket) — all brands accessible
if (subscription.brandCount === 0) return true;
// Check userBrands junction for limited plans
const brandAccess = await this.db
.select()
.from(userBrands)

View File

@@ -29,6 +29,8 @@ const BRANDS_DATA = [
{ name: "Mini", slug: "mini" },
{ name: "Dacia", slug: "dacia" },
{ name: "Subaru", slug: "subaru" },
{ name: "Suzuki", slug: "suzuki" },
{ name: "Mitsubishi", slug: "mitsubishi" },
];
const PLANS_DATA = [

View File

@@ -34,7 +34,7 @@ const WMI_DATABASE: Record<string, string> = {
// Kia
KNA: "Kia", KND: "Kia",
// Ford
WF0: "Ford", "1FA": "Ford", "3FA": "Ford",
WF0: "Ford", NM0: "Ford", "1FA": "Ford", "3FA": "Ford",
// Opel
W0L: "Opel",
// Skoda
@@ -46,7 +46,7 @@ const WMI_DATABASE: Record<string, string> = {
// Nissan
JN1: "Nissan", "1N4": "Nissan", "3N1": "Nissan",
// Mazda
JM1: "Mazda", JM3: "Mazda",
JMZ: "Mazda", JM1: "Mazda", JM3: "Mazda",
// Porsche
WP0: "Porsche", WP1: "Porsche",
// Land Rover
@@ -59,6 +59,10 @@ const WMI_DATABASE: Record<string, string> = {
UU1: "Dacia",
// Subaru
JF1: "Subaru", JF2: "Subaru",
// Suzuki
JS2: "Suzuki", JS3: "Suzuki", TSM: "Suzuki", MA3: "Suzuki", MBH: "Suzuki",
// Mitsubishi
JMB: "Mitsubishi", JMY: "Mitsubishi", MMB: "Mitsubishi", ML3: "Mitsubishi",
};
const YEAR_MAP: Record<string, number> = {

View File

@@ -0,0 +1,286 @@
/**
* EMEX Browser Service — Singleton Playwright browser manager
*
* Keeps a single Chromium instance alive for the app lifetime.
* Each scraping request gets its own Page (tab) via acquirePage().
* Session cookies are shared through a single BrowserContext.
*
* Features:
* - Semaphore limits concurrent pages (default 3)
* - Session cookie auto-refresh (25 min TTL)
* - Crash recovery (auto-relaunch if browser disconnects)
*/
import {
Injectable,
Logger,
OnModuleInit,
OnModuleDestroy,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { Browser, BrowserContext, Page } from 'playwright';
const SESSION_TTL_MS = 25 * 60 * 1000; // 25 minutes
const MAX_CONCURRENT_PAGES = 3;
const EMEX_BASE_URL = 'https://emexdwc.ae';
/** Simple counting semaphore */
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(); // hand slot to next waiter
} else {
this.current--;
}
}
}
export interface AcquiredPage {
page: Page;
release: () => Promise<void>;
}
@Injectable()
export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(EmexBrowserService.name);
private browser: Browser | null = null;
private context: BrowserContext | null = null;
private sessionExpiry = 0;
private readonly semaphore: Semaphore;
private launching: Promise<void> | null = null;
private readonly useProxy: boolean;
private readonly proxyHost: string;
private readonly proxyPortStart: number;
private readonly proxyPortEnd: number;
private readonly proxyUsername: string;
private readonly proxyPassword: string;
private startedAt = 0;
constructor(private configService: ConfigService) {
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
this.useProxy =
this.configService.get<string>('EMEX_USE_PROXY', 'false') === 'true';
this.proxyHost = this.configService.get<string>(
'EMEX_PROXY_HOST',
'74.81.81.81',
);
this.proxyPortStart = this.configService.get<number>(
'EMEX_PROXY_PORT_START',
10000,
);
this.proxyPortEnd = this.configService.get<number>(
'EMEX_PROXY_PORT_END',
10099,
);
this.proxyUsername = this.configService.get<string>(
'EMEX_PROXY_USER',
'1726bbe361918676d44e',
);
this.proxyPassword = this.configService.get<string>(
'EMEX_PROXY_PASS',
'f11c7b6128cc86c6',
);
}
async onModuleInit(): Promise<void> {
try {
await this.launchBrowser();
this.logger.log('Browser launched on module init');
} catch (err) {
const e = err as Error;
this.logger.error(
`Failed to launch browser on init: ${e.message}`,
e.stack,
);
// Non-fatal — will retry on first acquirePage()
}
}
async onModuleDestroy(): Promise<void> {
await this.closeBrowser();
this.logger.log('Browser closed on module destroy');
}
/**
* Acquire a new Page (tab) from the shared browser.
* Caller MUST call release() when done.
*/
async acquirePage(): Promise<AcquiredPage> {
await this.semaphore.acquire();
try {
await this.ensureBrowser();
await this.ensureSession();
const page = await this.context!.newPage();
let released = false;
const release = async () => {
if (released) return;
released = true;
try {
if (!page.isClosed()) {
await page.close();
}
} catch {
// page may already be closed
}
this.semaphore.release();
};
return { page, release };
} catch (err) {
this.semaphore.release();
throw err;
}
}
/**
* Health check for monitoring
*/
healthCheck(): {
browserConnected: boolean;
sessionValid: boolean;
uptimeMs: number;
} {
return {
browserConnected: this.browser?.isConnected() ?? false,
sessionValid: Date.now() < this.sessionExpiry,
uptimeMs: this.startedAt ? Date.now() - this.startedAt : 0,
};
}
// ─── Private ─────────────────────────────────────────────
private async launchBrowser(): Promise<void> {
// Prevent duplicate launches
if (this.launching) {
return this.launching;
}
this.launching = this._doLaunch();
try {
await this.launching;
} finally {
this.launching = null;
}
}
private async _doLaunch(): Promise<void> {
// Dynamic import — playwright is a devDependency
const { chromium } = await import('playwright');
const launchOptions: Record<string, unknown> = {
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--disable-gpu',
],
};
if (this.useProxy) {
const port = this.randomProxyPort();
launchOptions.proxy = {
server: `http://${this.proxyHost}:${port}`,
username: this.proxyUsername,
password: this.proxyPassword,
};
this.logger.log(`Using proxy: ${this.proxyHost}:${port}`);
}
this.browser = await chromium.launch(launchOptions);
this.context = await this.browser.newContext({
viewport: { width: 1920, height: 1080 },
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
});
this.startedAt = Date.now();
this.sessionExpiry = 0; // force session establish on first acquirePage
// Auto-recover on disconnect
this.browser.on('disconnected', () => {
this.logger.warn('Browser disconnected — will relaunch on next request');
this.browser = null;
this.context = null;
this.sessionExpiry = 0;
});
}
private async closeBrowser(): Promise<void> {
if (this.browser) {
try {
await this.browser.close();
} catch {
// ignore
}
this.browser = null;
this.context = null;
this.sessionExpiry = 0;
}
}
private async ensureBrowser(): Promise<void> {
if (this.browser?.isConnected()) return;
this.logger.log('Browser not connected — relaunching');
await this.launchBrowser();
}
/**
* Visit baseUrl to establish/refresh ASP.NET session cookie
*/
private async ensureSession(): Promise<void> {
if (Date.now() < this.sessionExpiry) return;
this.logger.log('Establishing EMEX session...');
const page = await this.context!.newPage();
try {
await page.goto(EMEX_BASE_URL, {
waitUntil: 'networkidle',
timeout: 30000,
});
const cookies = await this.context!.cookies();
const session = cookies.find((c) => c.name === 'ASP.NET_SessionId');
if (session) {
this.sessionExpiry = Date.now() + SESSION_TTL_MS;
this.logger.log('Session established, TTL 25 min');
} else {
this.logger.warn('No session cookie found after visiting baseUrl');
// Still set a short TTL to avoid hammering
this.sessionExpiry = Date.now() + 60_000;
}
} finally {
await page.close();
}
}
private randomProxyPort(): number {
return (
Math.floor(
Math.random() * (this.proxyPortEnd - this.proxyPortStart + 1),
) + this.proxyPortStart
);
}
}

View File

@@ -1,8 +1,9 @@
import { Module } from "@nestjs/common";
import { EmexBrowserService } from "./emex.browser";
import { EmexService } from "./emex.service";
@Module({
providers: [EmexService],
providers: [EmexBrowserService, EmexService],
exports: [EmexService],
})
export class EmexModule {}

View File

@@ -4,6 +4,9 @@
* NestJS service for emexdwc.ae VIN integration.
* Wraps the EmexVinScraper from scripts/emex-vin-scraper.js
* and provides standardized DecodedVehicle responses.
*
* Uses EmexBrowserService for a persistent singleton browser —
* each request gets a pre-created Page (tab) via acquirePage().
*/
import {
@@ -12,7 +15,6 @@ import {
BadRequestException,
ServiceUnavailableException,
InternalServerErrorException,
OnModuleDestroy,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as path from 'path';
@@ -25,10 +27,11 @@ import {
CATALOG_MAP,
} from './emex.types';
import { mapEmexResponse, createEmptyDecodedVehicle } from './emex.mapper';
import { EmexBrowserService } from './emex.browser';
// Type definition for the imported scraper module
interface EmexScraperModule {
EmexVinScraper: new () => EmexVinScraperInstance;
EmexVinScraper: new (options?: { page?: unknown }) => EmexVinScraperInstance;
getCatalogCode: (vin: string) => string | null;
getYearFromVIN: (vin: string) => number | null;
CONFIG: Record<string, unknown>;
@@ -50,7 +53,7 @@ interface EmexVinScraperInstance {
}
@Injectable()
export class EmexService implements OnModuleDestroy {
export class EmexService {
private readonly logger = new Logger(EmexService.name);
private scraperModule: EmexScraperModule | null = null;
private isInitialized = false;
@@ -60,7 +63,10 @@ export class EmexService implements OnModuleDestroy {
private readonly timeout: number;
private readonly debug: boolean;
constructor(private configService: ConfigService) {
constructor(
private configService: ConfigService,
private browserService: EmexBrowserService,
) {
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
const monorepoRoot = path.resolve(__dirname, '..', '..', '..', '..', '..');
@@ -76,10 +82,6 @@ export class EmexService implements OnModuleDestroy {
this.logger.log(`EMEX Service initialized with scraper path: ${this.scraperPath}`);
}
async onModuleDestroy(): Promise<void> {
// Nothing to clean up — each scraper instance is created and closed per-call
}
/**
* Lazily initialize the scraper module
*/
@@ -127,18 +129,24 @@ export class EmexService implements OnModuleDestroy {
}
/**
* Creates a new scraper instance and initializes browser
* Creates a scraper instance bound to a pre-created page from the browser pool.
* Returns { scraper, release } — caller MUST call release() in finally.
*/
private async createScraperInstance(): Promise<EmexVinScraperInstance> {
private async createScraperInstance(): Promise<{
scraper: EmexVinScraperInstance;
release: () => Promise<void>;
}> {
await this.initializeScraper();
if (!this.scraperModule) {
throw new InternalServerErrorException('EMEX scraper modulu yuklenemedi');
}
const instance = new this.scraperModule.EmexVinScraper();
await instance.init();
return instance;
const { page, release } = await this.browserService.acquirePage();
const scraper = new this.scraperModule.EmexVinScraper({ page });
// init() is a no-op in managed mode, but call it for consistency
await scraper.init();
return { scraper, release };
}
/**
@@ -165,19 +173,24 @@ export class EmexService implements OnModuleDestroy {
}
/**
* Decodes a VIN number using EMEX scraper
* Decodes a VIN number using EMEX scraper.
* Works with or without a known catalog code — the scraper
* falls back to VIN URL search which doesn't require one.
*/
async decodeVin(vin: string): Promise<DecodedVehicle> {
const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, '');
this.validateVin(cleanVin);
this.logger.log(`Decoding VIN: ${cleanVin}`);
const supported = this.isSupported(cleanVin);
this.logger.log(`Decoding VIN: ${cleanVin} (catalog supported: ${supported})`);
let scraper: EmexVinScraperInstance | null = null;
let release: (() => Promise<void>) | null = null;
try {
scraper = await this.createScraperInstance();
const instance = await this.createScraperInstance();
const scraper = instance.scraper;
release = instance.release;
const response = await this.executeWithTimeout(
scraper.searchByVIN(cleanVin),
@@ -270,7 +283,7 @@ export class EmexService implements OnModuleDestroy {
if (
err.message?.includes('browser') ||
err.message?.includes('puppeteer') ||
err.message?.includes('playwright') ||
err.message?.includes('navigation')
) {
this.logger.error(`Browser error: ${err.message}`, err.stack);
@@ -284,12 +297,12 @@ export class EmexService implements OnModuleDestroy {
'VIN sorgulama sirasinda bir hata olustu',
);
} finally {
if (scraper) {
if (release) {
try {
await scraper.close();
await release();
} catch (closeError) {
const err = closeError as Error;
this.logger.warn(`Error closing scraper: ${err.message}`);
this.logger.warn(`Error releasing page: ${err.message}`);
}
}
}
@@ -363,10 +376,12 @@ export class EmexService implements OnModuleDestroy {
this.logger.log(`Fetching parts from category URL: ${categoryUrl}`);
let scraper: EmexVinScraperInstance | null = null;
let release: (() => Promise<void>) | null = null;
try {
scraper = await this.createScraperInstance();
const instance = await this.createScraperInstance();
const scraper = instance.scraper;
release = instance.release;
const result = await this.executeWithTimeout(
scraper.getParts(categoryUrl),
@@ -387,12 +402,12 @@ export class EmexService implements OnModuleDestroy {
this.logger.error(`Failed to fetch category parts: ${err.message}`);
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
} finally {
if (scraper) {
if (release) {
try {
await scraper.close();
await release();
} catch (closeError) {
const err = closeError as Error;
this.logger.warn(`Error closing scraper: ${err.message}`);
this.logger.warn(`Error releasing page: ${err.message}`);
}
}
}

View File

@@ -247,6 +247,7 @@ export const CATALOG_MAP: Record<string, CatalogEntry> = {
ZFA: { code: 'CFIAT84', brand: 'Fiat' },
ZAR: { code: 'RFIAT84', brand: 'Alfa Romeo' },
WF0: { code: 'FORD202201', brand: 'Ford' },
NM0: { code: 'FORD202201', brand: 'Ford' },
JTD: { code: 'TOYOTA00', brand: 'Toyota' },
JTE: { code: 'TOYOTA00', brand: 'Toyota' },
SHH: { code: 'HONDA00', brand: 'Honda' },
@@ -256,4 +257,7 @@ export const CATALOG_MAP: Record<string, CatalogEntry> = {
WP1: { code: 'PO799', brand: 'Porsche' },
JF1: { code: 'SUBARU201802', brand: 'Subaru' },
JF2: { code: 'SUBARU201802', brand: 'Subaru' },
JMZ: { code: 'MAZDA2020', brand: 'Mazda' },
JM1: { code: 'MAZDA2020', brand: 'Mazda' },
JM3: { code: 'MAZDA2020', brand: 'Mazda' },
};

View File

@@ -276,6 +276,34 @@ export class PL24AuthService {
return headers;
}
/**
* Build headers for Ford legacy HTML page requests.
* Uses text/html Accept instead of application/json.
*/
async buildFordLegacyHeaders(
serviceName: string,
): Promise<Record<string, string>> {
const token = await this.authorizeService(serviceName);
const sessionCookie = await this.getSessionCookie();
return {
Authorization: `Bearer ${token}`,
Cookie: sessionCookie,
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
};
}
/**
* Get PL24TOKEN cookie value for Ford hintstoken parameter.
*/
getPL24TokenValue(): string | null {
if (!this.tokenData?.sessionCookie) return null;
const match = this.tokenData.sessionCookie.match(/PL24TOKEN=([^;]+)/);
return match?.[1] || null;
}
private isTokenValid(token: PL24TokenData): boolean {
const bufferMs = 60 * 1000;
return token.expiresAt.getTime() - bufferMs > Date.now();

View File

@@ -0,0 +1,692 @@
/**
* Ford Legacy PL24 Service
*
* Handles Ford VIN decode and parts catalog via PL24's legacy .action endpoints.
* Ford uses server-rendered HTML with embedded JavaScript variables instead of JSON APIs.
* HTML parsing via regex + string extraction (no external deps like cheerio).
*/
import { createHash } from "crypto";
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 { PL24_DEFAULTS } from "./pl24.constants";
import {
PL24DecodedVehicle,
PL24DecodedCategory,
PL24PartsResponse,
PL24Part,
PL24MainGroup,
SERVICE_TO_BRAND,
} from "./pl24.types";
import {
FORD_LEGACY_ENDPOINTS,
type FordPL24Support,
} from "./pl24-ford-legacy.types";
@Injectable()
export class PL24FordLegacyService {
private readonly logger = new Logger(PL24FordLegacyService.name);
private readonly baseUrl: string;
private readonly timeout: number;
private readonly language = "tr";
constructor(
private readonly authService: PL24AuthService,
private configService: ConfigService,
private redis: RedisService,
private storage: StorageService,
) {
this.baseUrl = this.configService.get<string>(
"pl24.baseUrl",
"https://www.partslink24.com",
);
this.timeout = 30000;
}
/**
* Decode a Ford VIN via legacy .action endpoints.
* Returns PL24DecodedVehicle or null (triggers EMEX fallback).
*/
async decodeVin(vin: string): Promise<PL24DecodedVehicle | null> {
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}vehicle:${vin}`;
const cached = await this.redis.getJson<PL24DecodedVehicle>(cacheKey);
if (cached) return cached;
this.logger.log(`Ford legacy: decoding VIN ${vin}`);
try {
// Step 1: Authorize for Ford service
await this.authService.authorizeService("fordt_parts");
// Step 2: Fetch VIN group page
const html = await this.fetchVinGroupPage(vin);
if (!html) return null;
// Step 3: Check if we got demo mode (not authenticated properly)
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
this.logger.warn("Ford legacy: got demo mode, retrying with fresh auth");
this.authService.clearTokens();
await this.authService.authorizeService("fordt_parts");
const retryHtml = await this.fetchVinGroupPage(vin);
if (!retryHtml) return null;
const retrySupport = this.extractScriptVariable<FordPL24Support>(retryHtml, "PL24_SUPPORT");
if (retrySupport?.demo || retrySupport?.role === "NOT_LOGGED_IN_DEMO") {
this.logger.warn("Ford legacy: still demo after retry, returning null");
return null;
}
return this.parseAndCacheVehicle(retryHtml, vin, cacheKey);
}
return this.parseAndCacheVehicle(html, vin, cacheKey);
} catch (error) {
const err = error as Error;
this.logger.error(`Ford legacy VIN decode error: ${err.message}`, err.stack);
return null;
}
}
/**
* Fetch sub-groups for a Ford legacy link path.
*/
async fetchSubGroupsByPath(
linkPath: string,
serviceName: string,
): Promise<PL24MainGroup[]> {
const pathHash = createHash("sha256").update(linkPath).digest("hex").substring(0, 16);
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}ford:subgroups:${pathHash}`;
const cached = await this.redis.getJson<PL24MainGroup[]>(cacheKey);
if (cached) return cached;
this.logger.log(`Ford legacy: fetching sub-groups from ${linkPath}`);
try {
const html = await this.fetchFordPage(linkPath, serviceName);
if (!html) return [];
const links = this.extractLinks(html, /\.action/);
const groups: PL24MainGroup[] = links.map((link, idx) => ({
id: String(idx),
code: this.extractCodeFromText(link.text) || String(idx),
name: link.text.replace(/^\d+\s+/, "").trim() || `Group ${idx}`,
linkPath: link.href,
}));
if (groups.length > 0) {
await this.redis.setJson(cacheKey, groups, 86400);
}
return groups;
} catch (error) {
const err = error as Error;
this.logger.error(`Ford legacy sub-groups error: ${err.message}`);
return [];
}
}
/**
* Fetch parts for a Ford legacy link path.
*/
async fetchPartsByPath(
linkPath: string,
serviceName: string,
): Promise<PL24PartsResponse> {
const pathHash = createHash("sha256").update(linkPath).digest("hex").substring(0, 16);
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}ford:parts:${pathHash}`;
const cached = await this.redis.getJson<PL24PartsResponse>(cacheKey);
if (cached) return cached;
this.logger.log(`Ford legacy: fetching parts from ${linkPath}`);
try {
const html = await this.fetchFordPage(linkPath, serviceName);
if (!html) {
return { success: false, groupId: "", groupName: "", parts: [] };
}
const parts = this.parsePartsFromHtml(html);
const groupName = this.extractPageTitle(html);
const schemaImageUrl = this.extractSchemaImageUrl(html);
const result: PL24PartsResponse = {
success: true,
groupId: pathHash,
groupName,
schemaImageUrl: schemaImageUrl || undefined,
parts,
};
if (parts.length > 0) {
await this.redis.setJson(cacheKey, result, 3600);
}
return result;
} catch (error) {
const err = error as Error;
this.logger.error(`Ford legacy parts error: ${err.message}`);
return { success: false, groupId: "", groupName: "", parts: [] };
}
}
// ==================== PRIVATE: Page fetching ====================
/**
* Fetch the VIN group page for a Ford VIN.
*/
private async fetchVinGroupPage(vin: string): Promise<string | null> {
const token = this.authService.getPL24TokenValue();
const params = new URLSearchParams({
vin,
lang: this.language,
...(token ? { hintstoken: token } : {}),
});
const url = `${this.baseUrl}${FORD_LEGACY_ENDPOINTS.VIN_GROUP}?${params}`;
return this.fetchFordPage(url, "fordt_parts", true);
}
/**
* Fetch a Ford legacy page (HTML). Handles 401 retry.
*/
private async fetchFordPage(
url: string,
serviceName: string,
isFullUrl = false,
): Promise<string | null> {
const fullUrl = isFullUrl ? url : `${this.baseUrl}${url}`;
const headers = await this.authService.buildFordLegacyHeaders(serviceName);
try {
let response = await fetch(fullUrl, {
method: "GET",
headers,
signal: AbortSignal.timeout(this.timeout),
redirect: "follow",
});
if (response.status === 401) {
this.logger.warn("Ford legacy: 401, refreshing auth");
this.authService.clearTokens();
await this.authService.authorizeService(serviceName);
const newHeaders = await this.authService.buildFordLegacyHeaders(serviceName);
response = await fetch(fullUrl, {
method: "GET",
headers: newHeaders,
signal: AbortSignal.timeout(this.timeout),
redirect: "follow",
});
}
if (!response.ok) {
this.logger.warn(`Ford legacy: HTTP ${response.status} for ${fullUrl}`);
return null;
}
const contentType = response.headers.get("content-type") || "";
// Some Ford endpoints may return JSON (e.g., model-config)
if (contentType.includes("application/json")) {
const json = await response.json();
return JSON.stringify(json);
}
return await response.text();
} catch (error) {
const err = error as Error;
this.logger.error(`Ford legacy fetch error: ${err.message}`);
return null;
}
}
// ==================== PRIVATE: HTML parsing helpers ====================
/**
* Extract a JavaScript variable from HTML <script> blocks.
* Matches: window.VAR_NAME = {...}; or var VAR_NAME = {...};
*/
extractScriptVariable<T = unknown>(html: string, varName: string): T | null {
// Match window.VAR = value; or var VAR = value;
const patterns = [
new RegExp(`window\\.${varName}\\s*=\\s*({[\\s\\S]*?});`, "m"),
new RegExp(`window\\.${varName}\\s*=\\s*(\\[[\\s\\S]*?\\]);`, "m"),
new RegExp(`var\\s+${varName}\\s*=\\s*({[\\s\\S]*?});`, "m"),
new RegExp(`var\\s+${varName}\\s*=\\s*(\\[[\\s\\S]*?\\]);`, "m"),
// Single-quoted string values
new RegExp(`window\\.${varName}\\s*=\\s*'([^']*)'`, "m"),
new RegExp(`window\\.${varName}\\s*=\\s*"([^"]*)"`, "m"),
];
for (const pattern of patterns) {
const match = html.match(pattern);
if (match?.[1]) {
try {
// Try JSON parse first (handles objects and arrays)
return JSON.parse(match[1]) as T;
} catch {
// For string values, return as-is
return match[1] as T;
}
}
}
return null;
}
/**
* Extract <a> links matching a pattern from HTML.
*/
extractLinks(html: string, pattern: RegExp): { href: string; text: string }[] {
const linkRegex = /<a\s+[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
const results: { href: string; text: string }[] = [];
let match: RegExpExecArray | null;
while ((match = linkRegex.exec(html)) !== null) {
const href = match[1];
const text = match[2].replace(/<[^>]+>/g, "").trim();
if (pattern.test(href) && text) {
results.push({ href, text });
}
}
return results;
}
/**
* Extract table rows from HTML <table> as objects.
* Uses first row as headers.
*/
extractTableRows(html: string): Record<string, string>[] {
// Find all tables
const tableRegex = /<table[^>]*>([\s\S]*?)<\/table>/gi;
const tableMatch = tableRegex.exec(html);
if (!tableMatch) return [];
const tableHtml = tableMatch[1];
// Extract header cells
const headerRegex = /<th[^>]*>([\s\S]*?)<\/th>/gi;
const headers: string[] = [];
let hMatch: RegExpExecArray | null;
while ((hMatch = headerRegex.exec(tableHtml)) !== null) {
headers.push(hMatch[1].replace(/<[^>]+>/g, "").trim());
}
// Extract rows
const rowRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
const rows: Record<string, string>[] = [];
let rMatch: RegExpExecArray | null;
let rowIndex = 0;
while ((rMatch = rowRegex.exec(tableHtml)) !== null) {
const cellRegex = /<td[^>]*>([\s\S]*?)<\/td>/gi;
const cells: string[] = [];
let cMatch: RegExpExecArray | null;
while ((cMatch = cellRegex.exec(rMatch[1])) !== null) {
cells.push(cMatch[1].replace(/<[^>]+>/g, "").trim());
}
if (cells.length > 0) {
const row: Record<string, string> = {};
cells.forEach((cell, idx) => {
const key = headers[idx] || `col${idx}`;
row[key] = cell;
});
rows.push(row);
}
rowIndex++;
}
return rows;
}
// ==================== PRIVATE: Ford-specific parsing ====================
/**
* Parse vehicle info + categories from VIN group HTML and cache result.
*/
private async parseAndCacheVehicle(
html: string,
vin: string,
cacheKey: string,
): Promise<PL24DecodedVehicle | null> {
const vehicle = this.parseFordVehicleResponse(html, vin);
if (!vehicle) return null;
const categories = this.parseFordCategories(html);
const result: PL24DecodedVehicle = {
...vehicle,
categories,
};
await this.redis.setJson(cacheKey, result, 86400);
this.logger.log(
`Ford legacy: decoded ${vin} - ${vehicle.brand} ${vehicle.model} ${vehicle.year}, ${categories.length} categories`,
);
return result;
}
/**
* Parse Ford vehicle info from VIN group HTML.
* Extracts from window.vehicles, data tables, or page content.
*/
private parseFordVehicleResponse(
html: string,
vin: string,
): Omit<PL24DecodedVehicle, "categories"> | null {
// Try extracting vehicle data from embedded JS
const vehicles = this.extractScriptVariable<Array<Record<string, string>>>(html, "vehicles");
const vehicleData = vehicles?.[0] || null;
// Try extracting from vehicle info table
const tableRows = this.extractTableRows(html);
// Build vehicle info from whatever we found
let model = "";
let year = 0;
let bodyType: string | null = null;
let engineCode: string | null = null;
let engineType: string | null = null;
if (vehicleData) {
model = vehicleData.model || vehicleData.modelName || vehicleData.description || "";
year = parseInt(vehicleData.year || vehicleData.modelYear || "", 10) || 0;
bodyType = vehicleData.bodyStyle || vehicleData.body || null;
engineCode = vehicleData.engineCode || vehicleData.engine || null;
engineType = vehicleData.engineDescription || vehicleData.engineType || null;
}
// Try to extract from page title or description
if (!model) {
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (titleMatch) {
const title = titleMatch[1].replace(/<[^>]+>/g, "").trim();
// Ford titles often have format: "Model Year - Parts"
const parts = title.split(/[-–—]/);
if (parts.length > 0) {
model = parts[0].trim().replace(/Ford\s*/i, "");
}
}
}
// Try extracting from breadcrumb or header
if (!model) {
const headerMatch = html.match(/<h[12][^>]*>([\s\S]*?)<\/h[12]>/i);
if (headerMatch) {
model = headerMatch[1].replace(/<[^>]+>/g, "").trim();
}
}
// Try extracting from vehicle info section
if (!model || !year) {
for (const row of tableRows) {
const values = Object.values(row);
const keys = Object.keys(row);
for (let i = 0; i < keys.length; i++) {
const key = keys[i].toLowerCase();
if (!model && (key.includes("model") || key.includes("arac"))) {
model = values[i] || model;
}
if (!year && (key.includes("year") || key.includes("yil") || key.includes("yıl"))) {
year = parseInt(values[i], 10) || year;
}
if (!engineCode && (key.includes("engine") || key.includes("motor"))) {
engineCode = values[i] || engineCode;
}
if (!bodyType && (key.includes("body") || key.includes("kasa"))) {
bodyType = values[i] || bodyType;
}
}
}
}
// Fallback: year from VIN position 10
if (!year) {
year = this.getYearFromVin(vin);
}
// If we still have nothing, at least return basic info
if (!model && !vehicleData && tableRows.length === 0) {
// Check if we got a valid page at all
if (!html.includes("ford") && !html.includes("Ford")) {
this.logger.warn("Ford legacy: page doesn't contain Ford data");
return null;
}
model = "Ford";
}
return {
brand: SERVICE_TO_BRAND["fordt_parts"] || "Ford",
model,
year,
series: null,
bodyType,
engineCode,
engineType,
engineVolume: null,
transmission: null,
driveType: null,
colorCode: null,
productionDate: null,
raw: { html_length: html.length, has_vehicles_var: !!vehicleData },
catalogInfo: {
serviceName: "fordt_parts",
vehicleId: vin,
catalogPath: "/ford/fordt_parts",
baseUrl: this.baseUrl,
},
};
}
/**
* Parse categories from Ford VIN group HTML.
* Categories appear as links to .action endpoints in the page.
*/
private parseFordCategories(html: string): PL24DecodedCategory[] {
// Try to extract category links from the page
// Ford categories are typically in a navigation list or table
const categoryLinks = this.extractLinks(html, /\.action/);
// Filter to only category-like links (exclude navigation/auth links)
const filtered = categoryLinks.filter((link) => {
const href = link.href.toLowerCase();
// Include group/category navigation links
if (
href.includes("group") ||
href.includes("category") ||
href.includes("maingroup") ||
href.includes("parts-group")
) {
return true;
}
// Exclude login, keep-alive, etc.
if (
href.includes("login") ||
href.includes("keep-alive") ||
href.includes("logout") ||
href.includes("json-")
) {
return false;
}
// Include if it looks like a content link with text
return link.text.length > 2 && href.includes(".action");
});
// Deduplicate by href
const seen = new Set<string>();
const unique = filtered.filter((link) => {
if (seen.has(link.href)) return false;
seen.add(link.href);
return true;
});
return unique.map((link, idx) => {
const code = this.extractCodeFromText(link.text) || String(idx + 1);
const name = link.text.replace(/^\d+\s+/, "").trim();
return {
code,
nameEn: name || code,
nameTr: name || code,
description: null,
iconUrl: null,
subGroups: [],
linkPath: link.href,
};
});
}
/**
* Parse parts from Ford HTML table.
*/
private parsePartsFromHtml(html: string): PL24Part[] {
const rows = this.extractTableRows(html);
if (rows.length === 0) return [];
const parts: PL24Part[] = [];
for (const row of rows) {
// Try to find OEM code column (various possible names)
const oemCode = this.findColumnValue(row, [
"partno", "part_no", "part number", "parca no", "parça no",
"oem", "oemcode", "code", "kod", "no",
]);
if (!oemCode) continue;
const cleanOem = oemCode.replace(/\s+/g, "");
const name = this.findColumnValue(row, [
"description", "name", "aciklama", "açıklama", "tanim", "tanım",
"descr", "part name", "parca adi", "parça adı",
]) || "";
const positionCode = this.findColumnValue(row, [
"pos", "position", "pozisyon", "no", "sira",
]) || "";
const qtyStr = this.findColumnValue(row, [
"qty", "quantity", "miktar", "adet", "count",
]) || "";
const quantity = parseInt(qtyStr, 10) || undefined;
const remark = this.findColumnValue(row, [
"remark", "remarks", "note", "notes", "not", "aciklama2",
]) || undefined;
parts.push({
id: cleanOem,
oemCode: cleanOem,
formattedPartNo: oemCode,
name,
description: name,
positionCode: positionCode || undefined,
quantity,
remark,
});
}
return parts;
}
/**
* Extract schema/illustration image URL from HTML.
*/
private extractSchemaImageUrl(html: string): string | null {
// Look for illustration images
const imgPatterns = [
/<img[^>]+src=["']([^"']*(?:illustration|schema|diagram|exploded)[^"']*)["']/i,
/<img[^>]+src=["']([^"']*(?:\.png|\.jpg|\.gif|\.svg)[^"']*)["'][^>]*class=["'][^"']*(?:schema|illus|diagram)/i,
/<img[^>]+id=["'](?:schema|illustration|diagram)["'][^>]*src=["']([^"']+)["']/i,
];
for (const pattern of imgPatterns) {
const match = html.match(pattern);
if (match?.[1]) {
const src = match[1];
return src.startsWith("http") ? src : `${this.baseUrl}${src}`;
}
}
return null;
}
/**
* Extract page title from HTML.
*/
private extractPageTitle(html: string): string {
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (titleMatch) {
return titleMatch[1].replace(/<[^>]+>/g, "").trim();
}
const h1Match = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
if (h1Match) {
return h1Match[1].replace(/<[^>]+>/g, "").trim();
}
return "";
}
// ==================== PRIVATE: Utility ====================
/**
* Find a column value in a row by trying multiple possible column names.
*/
private findColumnValue(
row: Record<string, string>,
possibleKeys: string[],
): string | null {
// Try exact match first
for (const key of possibleKeys) {
if (row[key]) return row[key];
}
// Try case-insensitive match
const rowKeys = Object.keys(row);
for (const key of possibleKeys) {
const found = rowKeys.find(
(k) => k.toLowerCase() === key.toLowerCase(),
);
if (found && row[found]) return row[found];
}
// Try partial match
for (const key of possibleKeys) {
const found = rowKeys.find(
(k) => k.toLowerCase().includes(key.toLowerCase()),
);
if (found && row[found]) return row[found];
}
return null;
}
/**
* Extract numeric code from category text (e.g., "01 Engine" → "01").
*/
private extractCodeFromText(text: string): string {
const match = text.match(/^(\d+)\s/);
return match?.[1] || "";
}
/**
* Get year from VIN position 10.
*/
private getYearFromVin(vin: string): number {
if (!vin || vin.length < 10) return 0;
const yearChar = vin.charAt(9).toUpperCase();
const yearMap: Record<string, number> = {
"1": 2001, "2": 2002, "3": 2003, "4": 2004, "5": 2005,
"6": 2006, "7": 2007, "8": 2008, "9": 2009,
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,
};
return yearMap[yearChar] || 0;
}
}

View File

@@ -0,0 +1,38 @@
/**
* Ford Legacy PL24 Types
*
* Ford uses a legacy JSP/Struts architecture on PL24 with .action endpoints
* and server-rendered HTML containing embedded JavaScript variables.
*/
export interface FordPL24Support {
role: string; // "NOT_LOGGED_IN_DEMO" or authenticated role
locale: string; // "tr"
mode: string; // e.g. "A0LW0TRTR"
action: string;
contextPath: string; // "/ford"
demo: boolean;
}
export interface FordVehicleInfo {
vin: string;
brand: string;
model: string;
year: number;
bodyType: string | null;
engineCode: string | null;
engineType: string | null;
}
export interface FordCategoryLink {
href: string;
text: string;
code: string;
}
export const FORD_LEGACY_ENDPOINTS = {
VIN_GROUP: "/ford/fordt_parts/vin-group.action",
VEHICLE: "/ford/vehicle.action",
MODEL_CONFIG: "/ford/json-model-config.action",
KEEP_ALIVE: "/ford/json-keep-alive.action",
} as const;

View File

@@ -15,4 +15,10 @@ export const PL24_ENDPOINTS = {
// Image server
IMAGESERVER: "/imageserver/ext/api/images",
// Ford Legacy (.action endpoints)
FORD_VIN_GROUP: "/ford/fordt_parts/vin-group.action",
FORD_VEHICLE: "/ford/vehicle.action",
FORD_MODEL_CONFIG: "/ford/json-model-config.action",
FORD_KEEP_ALIVE: "/ford/json-keep-alive.action",
} as const;

View File

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

View File

@@ -15,6 +15,7 @@ import {
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PL24AuthService } from "./pl24-auth.service";
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
import { RedisService } from "../../redis/redis.service";
import { StorageService } from "../../storage/storage.service";
import { PL24_DEFAULTS } from "./pl24.constants";
@@ -41,6 +42,7 @@ export class PL24Service {
constructor(
private readonly authService: PL24AuthService,
private readonly fordLegacyService: PL24FordLegacyService,
private configService: ConfigService,
private redis: RedisService,
private storage: StorageService,
@@ -74,8 +76,11 @@ export class PL24Service {
);
}
// Only P5 Modern is supported for now
// Dispatch legacy architectures to their dedicated services
if (!isP5Modern(serviceName)) {
if (serviceName === "fordt_parts") {
return this.fordLegacyService.decodeVin(cleanVin);
}
this.logger.warn(
`Legacy architecture not supported yet: ${serviceName}`,
);
@@ -251,6 +256,11 @@ export class PL24Service {
linkPath: string,
serviceName: string,
): Promise<PL24PartsResponse> {
// Ford legacy dispatch
if (this.isFordLegacyPath(linkPath)) {
return this.fordLegacyService.fetchPartsByPath(linkPath, serviceName);
}
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);
@@ -368,6 +378,11 @@ export class PL24Service {
linkPath: string,
serviceName: string,
): Promise<PL24MainGroup[]> {
// Ford legacy dispatch
if (this.isFordLegacyPath(linkPath)) {
return this.fordLegacyService.fetchSubGroupsByPath(linkPath, serviceName);
}
this.logger.log(`Fetching sub-groups by path: ${linkPath}`);
try {
@@ -530,7 +545,8 @@ export class PL24Service {
isSupported(vin: string): boolean {
if (!vin || vin.length < 3) return false;
const serviceName = this.getServiceName(vin);
return !!serviceName && isP5Modern(serviceName);
if (!serviceName) return false;
return isP5Modern(serviceName) || serviceName === "fordt_parts";
}
/**
@@ -540,13 +556,22 @@ export class PL24Service {
const brands = new Set<string>();
for (const service of Object.values(PL24_WMI_SERVICE_MAP)) {
const brand = SERVICE_TO_BRAND[service];
if (brand && isP5Modern(service)) {
if (brand && (isP5Modern(service) || service === "fordt_parts")) {
brands.add(brand);
}
}
return Array.from(brands).sort();
}
/**
* Get brand display name from VIN's WMI.
*/
getBrandName(vin: string): string | null {
const serviceName = this.getServiceName(vin);
if (!serviceName) return null;
return SERVICE_TO_BRAND[serviceName] || null;
}
// ==================== PRIVATE: Request helpers ====================
/**
@@ -861,6 +886,7 @@ export class PL24Service {
const responseData = response as Record<string, unknown>;
let records: Array<Record<string, unknown>> = [];
let bomBasePath: string | undefined;
if (Array.isArray(response)) {
records = response;
@@ -871,6 +897,11 @@ export class PL24Service {
} else if (Array.isArray(innerData)) {
records = innerData as unknown as Array<Record<string, unknown>>;
}
// Servicepart: extract bomBaseLink for constructing child paths
const bomBaseLink = innerData.bomBaseLink as Record<string, unknown> | undefined;
if (bomBaseLink?.path) {
bomBasePath = bomBaseLink.path as string;
}
} else if (responseData.subGroups) {
records = responseData.subGroups as Array<Record<string, unknown>>;
} else if (responseData.groups) {
@@ -879,9 +910,11 @@ export class PL24Service {
const availableRecords = records.filter((record) => {
if (record.unavailable) return false;
// Filter out illustration headers that have no navigable link
const link = (record.link as Record<string, unknown>) || {};
if (!link.path) return false;
// Servicepart records have no link.path but can use bomBaseLink
if (!link.path && !bomBasePath) return false;
// Skip the "all" pseudo-record in servicepart responses
if (record.id === "all") return false;
return true;
});
@@ -924,6 +957,12 @@ export class PL24Service {
if (!name) name = code;
// Construct linkPath: use record's own link.path, or bomBaseLink + record id
const recordLinkPath = (link.path as string) || undefined;
const constructedPath = !recordLinkPath && bomBasePath
? `${bomBasePath}${record.id}`
: recordLinkPath;
return {
id: String(record.id || ""),
code,
@@ -931,8 +970,8 @@ export class PL24Service {
description: values.modelDescriptions || undefined,
imageUrl: undefined,
partCount: undefined,
linkPath: (link.path as string) || undefined,
linkWid: (link.wid as string) || undefined,
linkPath: constructedPath,
linkWid: (link.wid as string) || (bomBasePath ? "servicePartsItemsTable" : undefined),
};
});
}
@@ -1081,7 +1120,7 @@ export class PL24Service {
if (standardMatch) return standardMatch[1];
const tiffMatch = imageUrl.match(
/\/tiffimages\/[^/]+\/[^/]+\/([a-zA-Z0-9]+)\.\w+/,
/\/tiffimages\/(?:[^/]+\/)+([a-zA-Z0-9_-]+)\.\w+/,
);
if (tiffMatch) return tiffMatch[1];
@@ -1094,21 +1133,35 @@ export class PL24Service {
// ==================== PRIVATE: Brand-specific flows ====================
/**
* Convert partinfo links to bom links.
* Convert partinfo links to bom/bomdetails links.
* partinfo returns single part detail; bom returns full illustration + all parts.
*
* Standard brands: /extern/partinfo/vin → /extern/bom/vin
* Suzuki-style: /extern/partinfo/vin → /extern/details/vin/bomdetails
*/
private convertPartInfoToBom(linkPath: string): string {
if (!linkPath.includes("/partinfo/")) return linkPath;
const bomPath = linkPath.replace("/partinfo/", "/bom/");
// Suzuki (and similar) uses /details/vin/bomdetails instead of /bom/
const isSuzukiStyle = linkPath.includes("/p5suzuki/");
const bomPath = isSuzukiStyle
? linkPath.replace("/partinfo/vin", "/details/vin/bomdetails")
: linkPath.replace("/partinfo/", "/bom/");
const url = new URL(bomPath, "http://placeholder");
// Remove partinfo-specific params, but keep illustration info for correct BOM context
// Remove partinfo-specific params
url.searchParams.delete("fiValidity");
url.searchParams.delete("position");
url.searchParams.delete("positionId");
url.searchParams.delete("partno");
url.searchParams.delete("pos");
return `${url.pathname}?${url.searchParams.toString()}`;
}
private isFordLegacyPath(linkPath: string): boolean {
return linkPath.includes("/ford/") && linkPath.includes(".action");
}
private isDaimlerService(serviceName: string): boolean {
return (
serviceName.startsWith("mercedes") || serviceName === "smart_parts"

View File

@@ -286,6 +286,13 @@ export const PL24_SERVICE_CATALOGS: Record<string, PL24CatalogConfig> = {
apiPath: "/p5suzuki",
architecture: "P5_MODERN",
},
// Ford
fordt_parts: {
basePath: "/ford/fordt_parts",
apiPath: "/ford/fordt_parts",
architecture: "LEGACY_FORD",
},
};
// ==================== HELPER FUNCTIONS ====================
@@ -428,6 +435,12 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
TSM: "suzuki_parts",
MA3: "suzuki_parts",
MBH: "suzuki_parts",
// Ford
NM0: "fordt_parts",
WF0: "fordt_parts",
"1FA": "fordt_parts",
"3FA": "fordt_parts",
};
// ==================== VEHICLE TYPES ====================
@@ -604,4 +617,5 @@ export const SERVICE_TO_BRAND: Record<string, string> = {
man_parts: "MAN",
mmc_parts: "Mitsubishi",
suzuki_parts: "Suzuki",
fordt_parts: "Ford",
};

View File

@@ -63,7 +63,10 @@ export class PartsService {
description: p.description || null,
quantity: p.quantity || null,
position: p.positionCode || null,
hotspotIndex: p.hotspotId ? parseInt(p.hotspotId, 10) || null : null,
hotspotIndex: p.hotspotId ? (() => {
const val = parseInt(p.hotspotId!, 10);
return (val > 0 && val <= 2147483647) ? val : null;
})() : null,
source: "pl24" as const,
}));

View File

@@ -8,7 +8,7 @@ import {
} from "@nestjs/common";
import { eq, and, desc } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider";
import { vehicles, queryLogs, brands, userBrands, userSubscriptions } from "../database/schema/core";
import { vehicles, queryLogs, brands, userBrands, userSubscriptions, plans } 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";
@@ -53,32 +53,50 @@ export class VehiclesService {
// 2. Corgi decode (offline)
const corgiResult = this.corgiService.decodeVin(vin);
if (!corgiResult || !corgiResult.isKnown) {
await this.logQuery(userId, vin, null, "corgi", false, Date.now() - startTime, "Unknown VIN/brand");
throw new BadRequestException("VIN not recognized. Brand not supported.");
const corgiKnown = corgiResult && corgiResult.isKnown;
// 3. Brand access check (only if Corgi recognized the brand)
let brandId: string | null = null;
let brandName: string | null = null;
if (corgiKnown) {
const brand = await this.db
.select()
.from(brands)
.where(eq(brands.name, corgiResult.brandName))
.limit(1);
if (brand.length > 0) {
brandId = brand[0].id;
brandName = corgiResult.brandName;
await this.checkBrandAccess(userId, brandId);
}
}
// 3. Brand access check
const brand = await this.db
.select()
.from(brands)
.where(eq(brands.name, corgiResult.brandName))
.limit(1);
if (brand.length === 0) {
throw new BadRequestException(`Brand not supported: ${corgiResult.brandName}`);
}
const brandId = brand[0].id;
await this.checkBrandAccess(userId, brandId);
// 4. PL24 decode (real API)
// 4. PL24 decode (real API) — always attempt, PL24 has its own WMI map
let source = "corgi";
let pl24Vehicle = null;
try {
pl24Vehicle = await this.pl24Service.decodeVin(vin);
} catch (err) {
this.logger.warn(`PL24 decode failed for ${vin}: ${(err as Error).message}`);
if (this.pl24Service.isSupported(vin)) {
try {
pl24Vehicle = await this.pl24Service.decodeVin(vin);
// If Corgi didn't know the brand, resolve it from PL24's WMI map
if (!brandId && pl24Vehicle) {
const pl24Brand = this.pl24Service.getBrandName(vin);
if (pl24Brand) {
const brand = await this.db
.select()
.from(brands)
.where(eq(brands.name, pl24Brand))
.limit(1);
if (brand.length > 0) {
brandId = brand[0].id;
brandName = pl24Brand;
await this.checkBrandAccess(userId, brandId);
}
}
}
} catch (err) {
this.logger.warn(`PL24 decode failed for ${vin}: ${(err as Error).message}`);
}
}
// 5. Fallback to EMEX if PL24 not available
@@ -86,10 +104,19 @@ export class VehiclesService {
if (!pl24Vehicle) {
this.logger.log(`PL24 returned no data for ${vin}, trying EMEX fallback`);
try {
if (this.emexService.isSupported(vin)) {
const emexResult = await this.emexService.decodeVin(vin);
if (emexResult && emexResult.brand !== 'UNKNOWN') {
emexVehicle = emexResult;
const emexResult = await this.emexService.decodeVin(vin);
if (emexResult && emexResult.brand !== 'UNKNOWN') {
emexVehicle = emexResult;
if (!brandId && emexResult.brand) {
const emexBrand = await this.db
.select()
.from(brands)
.where(eq(brands.name, emexResult.brand))
.limit(1);
if (emexBrand.length > 0) {
brandId = emexBrand[0].id;
brandName = emexResult.brand;
}
}
}
} catch (emexError) {
@@ -97,6 +124,12 @@ export class VehiclesService {
}
}
// If nothing recognized this VIN at all, give up
if (!pl24Vehicle && !emexVehicle && !corgiKnown) {
await this.logQuery(userId, vin, null, "corgi", false, Date.now() - startTime, "Unknown VIN/brand");
throw new BadRequestException("VIN not recognized. Brand not supported.");
}
// 6. Fallback to VIN API if PL24 and EMEX not available
let vinApiData: any = null;
if (!pl24Vehicle && !emexVehicle) {
@@ -113,9 +146,9 @@ export class VehiclesService {
userId,
vin,
brandId,
brandName: corgiResult.brandName,
brandName: brandName || corgiResult?.brandName || emexVehicle?.brand || null,
model: pl24Vehicle?.model || emexVehicle?.model || vinApiData?.model || null,
year: pl24Vehicle?.year || emexVehicle?.year || corgiResult.modelYear || (vinApiData ? parseInt(vinApiData.modelYear) : null),
year: pl24Vehicle?.year || emexVehicle?.year || corgiResult?.modelYear || (vinApiData ? parseInt(vinApiData.modelYear) : null),
engine: pl24Vehicle?.engineType || pl24Vehicle?.engineCode || emexVehicle?.engineCode || emexVehicle?.engineType || vinApiData?.engineModel || null,
transmission: pl24Vehicle?.transmission || emexVehicle?.transmission || vinApiData?.transmissionStyle || null,
bodyType: pl24Vehicle?.bodyType || emexVehicle?.bodyType || vinApiData?.bodyClass || null,
@@ -177,8 +210,12 @@ export class VehiclesService {
private async checkBrandAccess(userId: string, brandId: string) {
const [sub] = await this.db
.select()
.select({
id: userSubscriptions.id,
brandCount: plans.brandCount,
})
.from(userSubscriptions)
.innerJoin(plans, eq(userSubscriptions.planId, plans.id))
.where(and(eq(userSubscriptions.userId, userId), eq(userSubscriptions.status, "active")))
.limit(1);
@@ -186,6 +223,9 @@ export class VehiclesService {
throw new ForbiddenException("No active subscription. Please subscribe to access vehicle data.");
}
// brandCount === 0 means unlimited (Full Paket) — skip per-brand check
if (sub.brandCount === 0) return;
const [access] = await this.db
.select()
.from(userBrands)

View File

@@ -2,10 +2,12 @@ const STORAGE_KEY = "userSettings";
interface UserSettings {
categoryViewMode?: "grid" | "tree";
sidebarCollapsed?: boolean;
}
const defaults: UserSettings = {
categoryViewMode: "grid",
sidebarCollapsed: false,
};
export function getUserSettings(): UserSettings {

View File

@@ -17,8 +17,11 @@ import {
Menu,
X,
LogOut,
PanelLeftClose,
PanelLeftOpen,
} from "lucide-react";
import { useState } from "react";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
export const Route = createFileRoute("/dashboard")({
component: DashboardLayout,
@@ -45,6 +48,13 @@ function DashboardLayout() {
const { user, isLoading, signOut, isAdmin } = useAuth();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const [collapsed, setCollapsed] = useState(() => getUserSettings().sidebarCollapsed ?? false);
const toggleCollapsed = () => {
const next = !collapsed;
setCollapsed(next);
setUserSetting("sidebarCollapsed", next);
};
if (isLoading) {
return (
@@ -70,25 +80,41 @@ function DashboardLayout() {
return (
<div className="flex min-h-screen">
{/* Desktop Sidebar */}
<aside className="hidden w-64 flex-shrink-0 border-r lg:block">
<aside
className={`hidden flex-shrink-0 border-r transition-[width] duration-200 lg:block ${collapsed ? "w-16" : "w-64"}`}
>
<div className="flex h-full flex-col">
<div className="flex h-16 items-center border-b px-6">
<Link to="/" className="text-xl font-bold">
Sase.tr
</Link>
<div className={`flex h-16 items-center border-b ${collapsed ? "justify-center px-2" : "justify-between px-4"}`}>
{!collapsed && (
<Link to="/" className="text-xl font-bold">
Sase.tr
</Link>
)}
<button
type="button"
onClick={toggleCollapsed}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
{collapsed ? (
<PanelLeftOpen className="h-5 w-5" />
) : (
<PanelLeftClose className="h-5 w-5" />
)}
</button>
</div>
<nav className="flex-1 space-y-1 p-4">
<nav className={`flex-1 space-y-1 overflow-y-auto ${collapsed ? "p-2" : "p-4"}`}>
{navItems.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.to}
to={item.to}
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent [&.active]:text-accent-foreground"
title={collapsed ? t(item.label) : undefined}
className={`flex items-center rounded-lg text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent [&.active]:text-accent-foreground ${collapsed ? "justify-center p-2" : "gap-3 px-3 py-2"}`}
activeProps={{ className: "active" }}
>
<Icon className="h-4 w-4" />
{t(item.label)}
<Icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{t(item.label)}</span>}
</Link>
);
})}
@@ -99,37 +125,52 @@ function DashboardLayout() {
<Link
key={item.to}
to={item.to}
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent [&.active]:text-accent-foreground"
title={collapsed ? item.label : undefined}
className={`flex items-center rounded-lg text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent [&.active]:text-accent-foreground ${collapsed ? "justify-center p-2" : "gap-3 px-3 py-2"}`}
activeProps={{ className: "active" }}
>
<Icon className="h-4 w-4" />
{item.label}
<Icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{item.label}</span>}
</Link>
);
})}
</nav>
{/* User section - bottom */}
<div className={`border-t ${collapsed ? "p-2" : "p-3"}`}>
<button
type="button"
onClick={() => signOut()}
title={collapsed ? user.name ?? ıkış" : undefined}
className={`flex w-full items-center rounded-lg text-left transition-colors hover:bg-accent ${collapsed ? "justify-center p-2" : "gap-3 px-3 py-2"}`}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
{user.name?.charAt(0).toUpperCase() ?? "?"}
</div>
{!collapsed && (
<>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{user.name}</p>
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
</div>
<LogOut className="h-4 w-4 shrink-0 text-muted-foreground" />
</>
)}
</button>
</div>
</div>
</aside>
{/* Main Content */}
<div className="flex flex-1 flex-col">
{/* Header */}
<header className="flex h-16 items-center justify-between border-b px-6">
<header className="flex h-16 items-center border-b px-6 lg:hidden">
<Button
variant="ghost"
size="icon"
className="lg:hidden"
onClick={() => setMobileOpen(true)}
>
<Menu className="h-5 w-5" />
</Button>
<div className="flex-1" />
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">{user.name}</span>
<Button variant="ghost" size="icon" onClick={() => signOut()}>
<LogOut className="h-4 w-4" />
</Button>
</div>
</header>
{/* Page Content */}
@@ -148,14 +189,14 @@ function DashboardLayout() {
role="button"
tabIndex={-1}
/>
<aside className="absolute left-0 top-0 h-full w-64 bg-background shadow-lg">
<aside className="absolute left-0 top-0 flex h-full w-64 flex-col bg-background shadow-lg">
<div className="flex h-16 items-center justify-between border-b px-6">
<span className="text-xl font-bold">Sase.tr</span>
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(false)}>
<X className="h-4 w-4" />
</Button>
</div>
<nav className="space-y-1 p-4">
<nav className="flex-1 space-y-1 overflow-y-auto p-4">
{navItems.map((item) => {
const Icon = item.icon;
return (
@@ -188,6 +229,26 @@ function DashboardLayout() {
);
})}
</nav>
{/* User section - bottom */}
<div className="border-t p-3">
<button
type="button"
onClick={() => {
signOut();
setMobileOpen(false);
}}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-accent"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
{user.name?.charAt(0).toUpperCase() ?? "?"}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{user.name}</p>
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
</div>
<LogOut className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
</div>
</aside>
</div>
)}

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Card, CardContent } from "@sase/ui";
@@ -22,6 +23,12 @@ function SearchPage() {
const [vin, setVin] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showHistory, setShowHistory] = useState(false);
const { data: history } = useQuery({
queryKey: ["vehicles", "history"],
queryFn: () => api.get<any[]>("/vehicles/history?limit=20"),
});
async function handleSearch(e: React.FormEvent) {
e.preventDefault();
@@ -61,16 +68,40 @@ function SearchPage() {
<Card>
<CardContent className="pt-6">
<form onSubmit={handleSearch} className="flex gap-3">
<Input
placeholder="VIN numarasını girin (17 karakter)"
value={vin}
onChange={(e) => {
setVin(e.target.value.toUpperCase());
setError(null);
}}
maxLength={17}
className="font-mono text-lg tracking-wider"
/>
<div className="relative flex-1">
<Input
placeholder="VIN numarasını girin (17 karakter)"
value={vin}
onChange={(e) => {
setVin(e.target.value.toUpperCase());
setError(null);
}}
onFocus={() => setShowHistory(true)}
onBlur={() => setShowHistory(false)}
maxLength={17}
className="font-mono text-lg tracking-wider"
/>
{showHistory && vin.length === 0 && history && history.length > 0 && (
<div className="absolute z-50 mt-1 max-h-80 w-full overflow-y-auto rounded-md border bg-popover shadow-md">
{history.map((v: any) => (
<div
key={v.id}
className="flex cursor-pointer items-center justify-between px-3 py-2 hover:bg-accent"
onMouseDown={(e) => {
e.preventDefault();
setVin(v.vin);
setShowHistory(false);
}}
>
<span className="font-mono text-sm">{v.vin}</span>
<span className="text-xs text-muted-foreground">
{v.brandName} {v.model} {v.year}
</span>
</div>
))}
</div>
)}
</div>
<Button type="submit" disabled={loading || vin.length !== 17}>
{loading ? (
<span className="animate-spin">...</span>

File diff suppressed because one or more lines are too long