feat: catalog browser polish + design system refresh + translation pipeline

- catalog: P5 restriction selector flow (mainGroupsPath), grid/tree/columns view modes for brands and models with persisted user settings
- translations: bulk translateMany() path with 1d cache-miss TTL, expanded automotive dictionary; categories.service now drives EN→TR via TranslationsService instead of mapper-side strings
- pcat: migrate auth from v1 JWT to v3 widget tokens (TWS- api-key + supporting X-* headers, IP-bound via DataImpulse proxy)
- pl24: new fetchP5Restrictions() for restriction-level navigation
- subscriptions: trial extended 7 → 30 days
- design: oklch color tokens, brand semantic color, Geist + Instrument Serif fonts, tinted shadows, button "brand" variant with hover-lift, accessible focus rings, skip link, 404 NotFound page, auth layout polish
- nginx: dynamic resolver for Faro upstream
- config: OPENROUTER_API_KEY env (used by emex translate bootstrap script)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-05-09 15:54:30 +00:00
parent 14e43bc808
commit f35d64f2be
46 changed files with 2499 additions and 855 deletions

View File

@@ -52,6 +52,7 @@
"drizzle-orm": "^0.41.0",
"helmet": "^8.1.0",
"ioredis": "^5.4.0",
"openai": "^6.37.0",
"postgres": "^3.4.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",

View File

@@ -60,15 +60,25 @@ export class CatalogController {
return this.catalogService.getPsaGearboxes(id, body, engine, user.id);
}
@Get("vehicles/:id/p5-restrictions")
getP5Restrictions(
@Param("id") id: string,
@Query("path") path: string | undefined,
@CurrentUser() user: { id: string },
) {
return this.catalogService.getP5Restrictions(id, user.id, path);
}
@Get("vehicles/:id/categories")
getCategoryTree(
@Param("id") id: string,
@Query("body") body: string | undefined,
@Query("engine") engine: string | undefined,
@Query("gearbox") gearbox: string | undefined,
@Query("mgp") mainGroupsPath: string | undefined,
@CurrentUser() user: { id: string },
) {
return this.catalogService.getCategoryTree(id, user.id, body, engine, gearbox);
return this.catalogService.getCategoryTree(id, user.id, body, engine, gearbox, mainGroupsPath);
}
@Get("vehicles/:id/categories/:categoryId")

View File

@@ -240,6 +240,30 @@ export class CatalogService {
return vehicle;
}
/**
* Get P5 Modern restriction options for a catalog vehicle.
* Fetches the first restriction level (from vehicle.catalogPath) or a subsequent level (from `nextPath`).
*/
async getP5Restrictions(
catalogVehicleId: string,
userId: string,
nextPath?: string,
): Promise<{ options: Array<{ code: string; name: string; path: string }>; isFinal: boolean }> {
const [vehicle] = await this.db
.select()
.from(catalogVehicles)
.where(eq(catalogVehicles.id, catalogVehicleId))
.limit(1);
if (!vehicle) throw new NotFoundException("Katalog aracı bulunamadı");
if (vehicle.brandId) await this.checkBrandAccess(userId, vehicle.brandId);
const pathToFetch = nextPath || vehicle.catalogPath;
if (!pathToFetch) return { options: [], isFinal: false };
return this.pl24Service.fetchP5Restrictions(vehicle.serviceName, pathToFetch);
}
/**
* Get available body types for a PSA catalog vehicle (for variant selector UI).
*/
@@ -309,11 +333,14 @@ export class CatalogService {
body = "_all_",
engine = "_all_",
gearbox = "_all_",
mainGroupsPath?: string,
) {
const hasVariant = body !== "_all_" || engine !== "_all_" || gearbox !== "_all_";
const cacheKey = hasVariant
? `cat:catalog:tree:${catalogVehicleId}:b=${body}:e=${engine}:g=${gearbox}`
: `cat:catalog:tree:${catalogVehicleId}`;
const cacheKey = mainGroupsPath
? `cat:catalog:tree:${catalogVehicleId}:mgp=${Buffer.from(mainGroupsPath).toString("base64").slice(0, 40)}`
: hasVariant
? `cat:catalog:tree:${catalogVehicleId}:b=${body}:e=${engine}:g=${gearbox}`
: `cat:catalog:tree:${catalogVehicleId}`;
const cached = await this.redis.getJson<any[]>(cacheKey);
if (cached) return cached;
@@ -620,7 +647,10 @@ export class CatalogService {
.from(categories)
.where(eq(categories.catalogVehicleId, catalogVehicleId));
if (dbCategories.length === 0 && vehicle.catalogPath) {
// When mainGroupsPath is provided (P5 restriction flow), use it directly
const effectiveCatalogPath = mainGroupsPath || vehicle.catalogPath;
if (dbCategories.length === 0 && effectiveCatalogPath) {
try {
let pl24Categories;
@@ -637,7 +667,7 @@ export class CatalogService {
} else {
pl24Categories = await this.pl24Service.fetchMainGroups(
vehicle.serviceName,
vehicle.catalogPath,
effectiveCatalogPath,
);
}

View File

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

View File

@@ -25,8 +25,25 @@ function createService(db: any) {
const pl24FordLegacyService = {
fetchCategoriesForPsaVin: vi.fn().mockResolvedValue([]),
};
const service = new CategoriesService(db as any, redis as any, pl24Service as any, emexService as any, partsCatalogsService as any, storage as any, pl24FordLegacyService as any);
return { service, db, redis, pl24Service };
const translationsService = {
translate: vi.fn().mockImplementation((_key: string, sourceText: string) =>
Promise.resolve({ translatedText: sourceText, source: "none", isAutoTranslated: false }),
),
translateMany: vi.fn().mockImplementation((texts: string[]) =>
Promise.resolve(new Map<string, string>(texts.map((t) => [t, t]))),
),
};
const service = new CategoriesService(
db as any,
redis as any,
pl24Service as any,
emexService as any,
partsCatalogsService as any,
storage as any,
pl24FordLegacyService as any,
translationsService as any,
);
return { service, db, redis, pl24Service, translationsService };
}
/** Chainable mock where limit is terminal */

View File

@@ -9,6 +9,7 @@ import { EmexService } from "../integrations/emex/emex.service";
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
import type { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
import { StorageService } from "../storage/storage.service";
import { TranslationsService } from "../translations/translations.service";
@Injectable()
export class CategoriesService {
@@ -22,6 +23,7 @@ export class CategoriesService {
private partsCatalogsService: PartsCatalogsService,
private storage: StorageService,
private pl24FordLegacyService: PL24FordLegacyService,
private translationsService: TranslationsService,
) {}
async getCategoryTree(vehicleId: string) {
@@ -176,10 +178,13 @@ export class CategoriesService {
);
if (groups.length > 0) {
const trMap = await this.translationsService.translateMany(
groups.map((g) => g.name).filter(Boolean),
);
const insertData = groups.map((g) => ({
vehicleId,
catalogVehicleId: null as string | null,
name: g.name,
name: trMap.get(g.name) ?? g.name,
nameOriginal: g.name,
parentId: null as string | null,
externalId: g.id,
@@ -221,6 +226,20 @@ export class CategoriesService {
// Recursive tree insertion from QuickGroups.aspx
this.logger.log(`Inserting ${tree.length} EMEX top-level category groups recursively`);
// Phase 1: walk the tree, collect every unique English name so we
// can bulk-translate before touching the DB.
const uniqueNames = new Set<string>();
const collect = (
nodes: Array<{ name: string; children?: any[] }>,
) => {
for (const node of nodes) {
if (node.name) uniqueNames.add(node.name);
if (node.children?.length) collect(node.children);
}
};
collect(tree);
const trMap = await this.translationsService.translateMany([...uniqueNames]);
const insertNodes = async (
nodes: Array<{ name: string; gid: string | null; url: string | null; children?: any[] }>,
parentId: string | null,
@@ -236,7 +255,7 @@ export class CategoriesService {
.values({
vehicleId,
catalogVehicleId: null as string | null,
name: node.name,
name: trMap.get(node.name) ?? node.name,
nameOriginal: node.name,
parentId,
externalId: node.gid || null,
@@ -263,9 +282,15 @@ export class CategoriesService {
const emexCats = (rawData?.emexCategories as Array<{ gid: string; name: string; url: string | null }>) || [];
const urlMap = new Map(emexCats.map((c) => [c.gid, c.url]));
// Bulk translate English names before inserting; mapper no longer
// populates nameTr — TranslationsService is the single source.
const trMap = await this.translationsService.translateMany(
emexResult.categories.map((c) => c.nameEn).filter(Boolean),
);
const seenNames = new Set<string>();
const uniqueCategories = emexResult.categories.filter((c) => {
const name = c.nameTr || c.nameEn;
const name = trMap.get(c.nameEn) ?? c.nameEn;
if (seenNames.has(name)) return false;
seenNames.add(name);
return true;
@@ -274,7 +299,7 @@ export class CategoriesService {
const insertData = uniqueCategories.map((c) => ({
vehicleId,
catalogVehicleId: null as string | null,
name: c.nameTr || c.nameEn,
name: trMap.get(c.nameEn) ?? c.nameEn,
nameOriginal: c.nameEn,
parentId: null as string | null,
externalId: c.code,
@@ -380,10 +405,13 @@ export class CategoriesService {
return [];
}
const trMap = await this.translationsService.translateMany(
realSubGroups.map((g) => g.name).filter(Boolean),
);
const insertData = realSubGroups.map((g) => ({
vehicleId: category.vehicleId,
catalogVehicleId: category.catalogVehicleId,
name: g.name,
name: trMap.get(g.name) ?? g.name,
nameOriginal: g.name,
parentId: categoryId,
externalId: g.id,
@@ -588,31 +616,40 @@ export class CategoriesService {
if (partsResult) {
// Flatten part groups into parts
if (needParts) {
const allParts: Array<typeof parts.$inferInsert> = [];
const rawParts: Array<{ name: string; number: string; notice: string | null; positionNumber: string | null }> = [];
for (const pg of partsResult.partGroups) {
for (const p of pg.parts) {
if (!p.number) continue;
const posNum = p.positionNumber || pg.positionNumber || null;
allParts.push({
vehicleId: vehicle.id,
categoryId,
oemCode: p.number,
name: p.name || "Unknown",
nameOriginal: p.name || null,
description: p.notice || null,
quantity: null,
position: posNum,
hotspotIndex: posNum ? parseInt(posNum, 10) || null : null,
unavailable: false,
remark: null as string | null,
modelCodes: null as string | null,
presel: false,
source: "parts-catalogs" as const,
rawParts.push({
name: p.name || "",
number: p.number,
notice: p.notice || null,
positionNumber: p.positionNumber || pg.positionNumber || null,
});
}
}
const trMap = await this.translationsService.translateMany(
rawParts.map((p) => p.name).filter(Boolean),
);
const allParts: Array<typeof parts.$inferInsert> = rawParts.map((p) => ({
vehicleId: vehicle.id,
categoryId,
oemCode: p.number,
name: trMap.get(p.name) ?? p.name ?? "Unknown",
nameOriginal: p.name || null,
description: p.notice,
quantity: null,
position: p.positionNumber,
hotspotIndex: p.positionNumber ? parseInt(p.positionNumber, 10) || null : null,
unavailable: false,
remark: null as string | null,
modelCodes: null as string | null,
presel: false,
source: "parts-catalogs" as const,
}));
if (allParts.length > 0) {
dbParts = await this.db.insert(parts).values(allParts).returning();
this.logger.log(`Stored ${dbParts.length} PartsCatalogs parts for category ${categoryId}`);
@@ -702,18 +739,27 @@ export class CategoriesService {
}
if (needParts && emexResult.parts.length > 0) {
const insertData = emexResult.parts.map((p) => ({
vehicleId: vehicle.id,
categoryId,
oemCode: p.oemCode || "N/A",
name: p.nameEn || "Unknown",
nameOriginal: p.nameEn || null,
description: null as string | null,
quantity: null as number | null,
position: p.positionCode || null,
hotspotIndex: p.positionCode ? (posCodeToIndex.get(p.positionCode) ?? null) : null,
source: "emex" as const,
}));
// Bulk translate part names (cache + DB + dictionary fallback).
// After bootstrap, ~95%+ should be Redis hits.
const trMap = await this.translationsService.translateMany(
emexResult.parts.map((p) => p.nameEn || "").filter(Boolean),
);
const insertData = emexResult.parts.map((p) => {
const original = p.nameEn || "";
return {
vehicleId: vehicle.id,
categoryId,
oemCode: p.oemCode || "N/A",
name: trMap.get(original) ?? original ?? "Unknown",
nameOriginal: p.nameEn || null,
description: null as string | null,
quantity: null as number | null,
position: p.positionCode || null,
hotspotIndex: p.positionCode ? (posCodeToIndex.get(p.positionCode) ?? null) : null,
source: "emex" as const,
};
});
dbParts = await this.db.insert(parts).values(insertData).returning();
this.logger.log(`Stored ${dbParts.length} EMEX parts for category ${categoryId}`);

View File

@@ -1,24 +1,25 @@
/**
* EMEX Response Mapper
*
* Transforms raw EmexVinScraper responses into standardized DecodedVehicle format.
* Includes Turkish translation support for common automotive terms.
* Transforms raw EmexVinScraper responses into the standardized DecodedVehicle
* format. Vehicle attribute translations (body type, engine type, transmission,
* drive type) live here. Category and part name translations now live in
* TranslationsService (apps/api/src/translations/translations.service.ts) —
* mapCategories returns nameTr=null and the insertion path
* (categories.service.ts) calls translationsService.translateMany() before
* persisting.
*/
import {
EmexScraperResponse,
EmexCategory,
EmexCategoryTreeNode,
DecodedVehicle,
DecodedCategory,
CATALOG_MAP,
} from './emex.types';
// ==================== TURKISH TRANSLATIONS ====================
// ==================== VEHICLE ATTRIBUTE TRANSLATIONS ====================
/**
* Turkish translations for common automotive terms
*/
const TR_TRANSLATIONS = {
// Body types
bodyTypes: {
@@ -39,7 +40,6 @@ const TR_TRANSLATIONS = {
roadster: 'Roadster',
} as Record<string, string>,
// Engine types
engineTypes: {
gasoline: 'Benzin',
petrol: 'Benzin',
@@ -54,7 +54,6 @@ const TR_TRANSLATIONS = {
hydrogen: 'Hidrojen',
} as Record<string, string>,
// Transmission types
transmissions: {
automatic: 'Otomatik',
manual: 'Manuel',
@@ -69,7 +68,6 @@ const TR_TRANSLATIONS = {
mt: 'Manuel',
} as Record<string, string>,
// Drive types
driveTypes: {
fwd: 'Ondan Cekis',
rwd: 'Arkadan Itis',
@@ -83,211 +81,10 @@ const TR_TRANSLATIONS = {
xdrive: 'xDrive (Dort Ceker)',
'4matic': '4MATIC (Dort Ceker)',
} as Record<string, string>,
// Common part categories
categories: {
engine: 'Motor',
brake: 'Fren Sistemi',
brakes: 'Fren Sistemi',
suspension: 'Suspansiyon',
steering: 'Direksiyon',
transmission: 'Sanziman',
exhaust: 'Egzoz Sistemi',
cooling: 'Sogutma Sistemi',
electrical: 'Elektrik Sistemi',
interior: 'Ic Aksam',
exterior: 'Dis Aksam',
body: 'Kaporta',
lighting: 'Aydinlatma',
lights: 'Aydinlatma',
wheels: 'Jantlar',
tires: 'Lastikler',
fuel: 'Yakit Sistemi',
'fuel system': 'Yakit Sistemi',
air: 'Hava Sistemi',
'air conditioning': 'Klima',
climate: 'Klima',
filters: 'Filtreler',
oil: 'Yag',
battery: 'Akku',
alternator: 'Alternator',
starter: 'Mars Motoru',
clutch: 'Debriyaj',
gearbox: 'Vites Kutusu',
axle: 'Aks',
differential: 'Diferansiyel',
driveshaft: 'Saft',
'cv joint': 'Aks Kafasi',
'tie rod': 'Rot Kolu',
'ball joint': 'Rotil',
'control arm': 'Salincak',
shock: 'Amortisor',
'shock absorber': 'Amortisor',
spring: 'Yay',
strut: 'Makfersan',
'brake pad': 'Fren Balatasi',
'brake disc': 'Fren Diski',
'brake rotor': 'Fren Diski',
caliper: 'Fren Kaliperi',
'master cylinder': 'Ana Merkez',
'wheel bearing': 'Bilyali Rulman',
hub: 'Porya',
mirror: 'Ayna',
bumper: 'Tampon',
fender: 'Camurluk',
hood: 'Kaput',
bonnet: 'Kaput',
trunk: 'Bagaj',
boot: 'Bagaj',
door: 'Kapi',
window: 'Cam',
windshield: 'On Cam',
windscreen: 'On Cam',
wiper: 'Silecek',
headlight: 'Far',
taillight: 'Stop Lambasi',
'turn signal': 'Sinyal Lambasi',
indicator: 'Sinyal Lambasi',
seat: 'Koltuk',
dashboard: 'Gosterge Paneli',
'steering wheel': 'Direksiyon Simidi',
pedal: 'Pedal',
carpet: 'Hali',
mat: 'Paspas',
'water pump': 'Su Pompasi',
thermostat: 'Termostat',
radiator: 'Radyator',
fan: 'Fan',
hose: 'Hortum',
belt: 'Kayis',
'timing belt': 'Eksantrik Kayisi',
'timing chain': 'Eksantrik Zinciri',
'serpentine belt': 'V Kayis',
gasket: 'Conta',
seal: 'Simir',
'o-ring': 'O-Ring',
sensor: 'Sensor',
oxygen: 'Oksijen Sensoru',
abs: 'ABS Sensoru',
airbag: 'Hava Yastigi',
horn: 'Korna',
relay: 'Role',
fuse: 'Sigorta',
switch: 'Dugme',
motor: 'Motor',
pump: 'Pompa',
compressor: 'Kompresor',
condenser: 'Kondenser',
evaporator: 'Evaporator',
heater: 'Isitici',
blower: 'Ufleyici',
'spark plug': 'Buji',
'ignition coil': 'Atesleme Bobini',
injector: 'Enjktor',
'fuel pump': 'Yakit Pompasi',
'fuel filter': 'Yakit Filtresi',
'air filter': 'Hava Filtresi',
'oil filter': 'Yag Filtresi',
'cabin filter': 'Polen Filtresi',
'pollen filter': 'Polen Filtresi',
} as Record<string, string>,
// Common part names
parts: {
'oil filter': 'Yag Filtresi',
'air filter': 'Hava Filtresi',
'fuel filter': 'Yakit Filtresi',
'cabin filter': 'Polen Filtresi',
'spark plug': 'Buji',
'brake pad': 'Fren Balatasi',
'brake disc': 'Fren Diski',
'brake rotor': 'Fren Diski',
'timing belt': 'Eksantrik Kayisi',
'water pump': 'Su Pompasi',
thermostat: 'Termostat',
alternator: 'Alternator',
starter: 'Mars Motoru',
battery: 'Akku',
radiator: 'Radyator',
'shock absorber': 'Amortisor',
'control arm': 'Salincak',
'tie rod': 'Rot Kolu',
'ball joint': 'Rotil',
'cv joint': 'Aks Kafasi',
clutch: 'Debriyaj',
'clutch kit': 'Debriyaj Seti',
flywheel: 'Volan',
'wheel bearing': 'Bilyali Rulman',
'hub bearing': 'Porya Rulmani',
caliper: 'Fren Kaliperi',
'master cylinder': 'Ana Merkez',
'slave cylinder': 'Yardimci Merkez',
'brake hose': 'Fren Hortumu',
'brake line': 'Fren Borusu',
'abs sensor': 'ABS Sensoru',
'oxygen sensor': 'Oksijen Sensoru',
'crankshaft sensor': 'Krank Sensoru',
'camshaft sensor': 'Eksantrik Sensoru',
'coolant sensor': 'Su Isisi Sensoru',
'oil pressure sensor': 'Yag Basinci Sensoru',
'ignition coil': 'Atesleme Bobini',
injector: 'Enjktor',
'fuel pump': 'Yakit Pompasi',
'fuel injector': 'Yakit Enjektoru',
gasket: 'Conta',
'head gasket': 'Silindir Kapagi Contasi',
'valve cover gasket': 'Kulbtor Kapagi Contasi',
'oil pan gasket': 'Karter Contasi',
'intake manifold gasket': 'Emme Manifoldu Contasi',
'exhaust manifold gasket': 'Egzoz Manifoldu Contasi',
'serpentine belt': 'V Kayis',
'drive belt': 'Tahrik Kayisi',
tensioner: 'Gerdirici',
'belt tensioner': 'Kayis Gerdirici',
idler: 'Avare',
'idler pulley': 'Avare Kasnak',
pulley: 'Kasnak',
'crankshaft pulley': 'Krank Kasnagi',
'power steering pump': 'Hidrolik Direksiyon Pompasi',
'power steering hose': 'Hidrolik Direksiyon Hortumu',
'steering rack': 'Kremayer',
'steering gear': 'Direksiyon Kutusu',
'cv boot': 'Aks Korfezi',
'drive shaft': 'Saft',
'axle shaft': 'Aks Mili',
'wheel hub': 'Porya',
'wheel stud': 'Bijon',
'lug nut': 'Bijon Somunu',
headlight: 'Far',
'headlight bulb': 'Far Ampulu',
taillight: 'Stop Lambasi',
'turn signal': 'Sinyal Lambasi',
'fog light': 'Sis Lambasi',
mirror: 'Ayna',
'side mirror': 'Yan Ayna',
'rear view mirror': 'Ic Ayna',
wiper: 'Silecek',
'wiper blade': 'Silecek Lastigi',
'wiper motor': 'Silecek Motoru',
'window regulator': 'Cam Mekanizmasi',
'window motor': 'Cam Motoru',
'door handle': 'Kapi Kolu',
'door lock': 'Kapi Kilidi',
'door hinge': 'Kapi Mentesesi',
bumper: 'Tampon',
grille: 'Izgara',
hood: 'Kaput',
fender: 'Camurluk',
'splash guard': 'Paclama',
mudguard: 'Camurluk',
} as Record<string, string>,
};
// ==================== TRANSLATION HELPERS ====================
/**
* Translates a term to Turkish if available
*/
function translateToTurkish(
term: string | null | undefined,
dictionary: Record<string, string>,
@@ -297,52 +94,24 @@ function translateToTurkish(
return dictionary[normalized] || null;
}
/**
* Translates body type to Turkish
*/
export function translateBodyType(bodyType: string | null): string | null {
return translateToTurkish(bodyType, TR_TRANSLATIONS.bodyTypes);
}
/**
* Translates engine type to Turkish
*/
export function translateEngineType(engineType: string | null): string | null {
return translateToTurkish(engineType, TR_TRANSLATIONS.engineTypes);
}
/**
* Translates transmission type to Turkish
*/
export function translateTransmission(
transmission: string | null,
): string | null {
return translateToTurkish(transmission, TR_TRANSLATIONS.transmissions);
}
/**
* Translates drive type to Turkish
*/
export function translateDriveType(driveType: string | null): string | null {
return translateToTurkish(driveType, TR_TRANSLATIONS.driveTypes);
}
/**
* Translates category name to Turkish
*/
export function translateCategoryName(name: string): string {
const normalized = name.toLowerCase().trim();
return TR_TRANSLATIONS.categories[normalized] || name;
}
/**
* Translates part name to Turkish
*/
export function translatePartName(name: string): string {
const normalized = name.toLowerCase().trim();
return TR_TRANSLATIONS.parts[normalized] || name;
}
// ==================== MAPPER FUNCTIONS ====================
/**
@@ -444,8 +213,11 @@ function buildRawResponse(
}
/**
* Maps EMEX categories to standardized DecodedCategory format
* NOTE: Parts are NOT included here - they will be fetched on-demand when user clicks a category
* Maps EMEX categories to standardized DecodedCategory format.
* nameTr is left null on purpose the consumer (categories.service.ts)
* runs nameEn through TranslationsService.translateMany() before insert.
* Parts are NOT included here; they are fetched on-demand when the user
* clicks a category.
*/
function mapCategories(
categories?: EmexCategory[],
@@ -458,7 +230,7 @@ function mapCategories(
return {
code: cat.gid || `CAT_${index}`,
nameEn: cat.name,
nameTr: translateCategoryName(cat.name),
nameTr: undefined,
description: null,
iconName: deriveIconName(cat.name),
schemaImageUrl: null,

View File

@@ -1,17 +1,16 @@
/**
* Parts-Catalogs Auth Service — JWT warm pool via Playwright + DataImpulse proxy
* Parts-Catalogs Auth Service — v3 token warm pool via Playwright + DataImpulse proxy
*
* JWT is captured by navigating to partner sites and intercepting
* the Authorization header from requests to parts-catalogs.com.
* JWT is IP-bound (~10 min TTL), so the same proxy port must be used for both
* browser capture and subsequent API calls.
* Tokens are captured by navigating to partner sites that embed the v3 widget.
* The widget calls /v3/api/proxy/* with `x-api-key: TWS-{UUID}` and four other
* X-* headers (api-path, gui-version, user-id, origin, referer); we intercept
* all of them so backend requests can replay the exact header set.
*
* Tokens are IP-bound — same proxy port must be reused for the API calls.
*
* Warm pool behavior:
* 09:00-19:00 Istanbul → proactive: maintain >= 1 slot, auto-refresh before expiry
* 19:00-09:00 → on-demand only: capture only when needed
*
* Each slot manages its own refresh timer (no polling loop).
* Dynamic scaling: 1 JWT per 6 req/min, capped at 5 slots.
*/
import {
@@ -24,6 +23,7 @@ import { ConfigService } from "@nestjs/config";
import type { Browser, BrowserContext } from "playwright";
import type { PcatJwtToken, JwtSlot, PcatSession } from "./parts-catalogs.types";
const TOKEN_TTL = 600; // seconds — TWS- has no built-in expiry, refresh aggressively
const REFRESH_BUFFER = 90; // Refresh 90s before expiry
const CAPTURE_POLL_INTERVAL = 500; // ms
const CAPTURE_POLL_MAX = 40; // 40 × 500ms = 20s max wait
@@ -32,11 +32,11 @@ const CONTEXT_CLOSE_TIMEOUT = 5_000;
const SITE_COOLDOWN = 10 * 60 * 1000; // 10 min per site
const MAX_POOL_SIZE = 5;
const RPM_WINDOW = 60_000; // 1-minute rolling window
const RPM_PER_SLOT = 6; // 1 JWT per 6 req/min
const RPM_PER_SLOT = 6; // 1 token per 6 req/min
/**
* Sites that embed the parts-catalogs.com widget.
* Widget loads JS → calls /api/start → then calls /v1/catalogs/ with JWT.
* Sites embedding the parts-catalogs.com v3 widget.
* Widget loads JS → calls /v3/api/proxy/* with x-api-key + supporting X-* headers.
* Each site uses a different proxy port (IP) to avoid rate limiting.
*/
const JWT_SITES = [
@@ -50,7 +50,6 @@ const JWT_SITES = [
"https://www.autodo.kz/#/catalogs",
"https://avtoman124.ru/goodvin#/catalogs",
"https://flynestauto.com/auto-parts-oem-catalog",
"http://en.demo.tradesoft.hk.com/cats/#/catalogs",
];
// DataImpulse proxy defaults (port-based IP rotation)
@@ -58,7 +57,7 @@ const DI_HOST = "gw.dataimpulse.com";
const DI_PORT_MIN = 10000;
const DI_PORT_MAX = 10999;
const DI_DEFAULT_USER = "1726bbe361918676d44e";
const DI_DEFAULT_PASS = "f11c7b6128cc86c6";
const DI_DEFAULT_PASS = "78ebc3d881de6ec0";
/** Simple counting semaphore (same pattern as EmexBrowserService) */
class Semaphore {
@@ -234,7 +233,12 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
}
: null;
return {
authorization: slot.jwt.raw,
apiKey: slot.jwt.raw,
apiPath: slot.jwt.apiPath,
guiVersion: slot.jwt.guiVersion,
userId: slot.jwt.userId,
origin: slot.jwt.origin,
referer: slot.jwt.referer,
proxyUrl,
proxyConfig,
_slot: slot,
@@ -513,22 +517,28 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
context = await this.browser!.newContext(contextOptions);
const page = await context.newPage();
// Intercept requests to parts-catalogs.com
let capturedJwt: string | null = null;
// Intercept the v3 widget call to /v3/api/proxy/* — needs the full
// header set (x-api-key + x-api-path + x-gui-version + x-user-id +
// origin + referer) to replay against gui.parts-catalogs.com.
let capturedToken: PcatJwtToken | null = null;
page.on("request", (request) => {
if (capturedJwt) return;
if (capturedToken) return;
const url = request.url();
if (
url.includes("parts-catalogs.com") ||
url.includes("api.parts-catalogs.com")
) {
const auth = request.headers()["authorization"];
if (auth) {
capturedJwt = auth;
this.logger.debug("JWT intercepted from request");
}
}
if (!/\/v3\/api\/proxy\//i.test(url)) return;
const h = request.headers();
const apiKey = h["x-api-key"];
if (!apiKey || !apiKey.startsWith("TWS-")) return;
capturedToken = {
raw: apiKey,
exp: Math.floor(Date.now() / 1000) + TOKEN_TTL,
apiPath: h["x-api-path"] || "https://api.parts-catalogs.com/v1",
guiVersion: h["x-gui-version"] || "3",
userId: h["x-user-id"] || "",
origin: h["origin"] || "",
referer: h["referer"] || "",
};
this.logger.debug(`Token intercepted (key=${apiKey.slice(0, 16)}...)`);
});
// Block heavy resources to save proxy bandwidth
@@ -569,26 +579,25 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
);
}
// Poll for JWT
// Poll for token
for (let i = 0; i < CAPTURE_POLL_MAX; i++) {
if (capturedJwt) break;
if (capturedToken) break;
await new Promise((r) => setTimeout(r, CAPTURE_POLL_INTERVAL));
}
const elapsed = Date.now() - startTime;
if (capturedJwt) {
const token = this.parseJwt(capturedJwt);
if (capturedToken) {
this.logger.log(
`JWT captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`,
`Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`,
);
return token;
return capturedToken;
}
this.logger.debug(`No JWT after ${elapsed}ms from ${siteUrl}`);
this.logger.debug(`No token after ${elapsed}ms from ${siteUrl}`);
return null;
} catch (err) {
this.logger.warn(`JWT capture error: ${(err as Error).message}`);
this.logger.warn(`Token capture error: ${(err as Error).message}`);
return null;
} finally {
if (context) {
@@ -604,34 +613,6 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
}
}
private parseJwt(rawToken: string): PcatJwtToken {
const parts = rawToken.split(".");
if (parts.length !== 3) {
throw new Error("Invalid JWT format");
}
// Decode payload with proper base64url padding
let payloadB64 = parts[1];
const padding = 4 - (payloadB64.length % 4);
if (padding !== 4) {
payloadB64 += "=".repeat(padding);
}
const payload = JSON.parse(
Buffer.from(payloadB64, "base64url").toString("utf-8"),
);
return {
raw: rawToken,
exp: payload.exp || 0,
host: payload.host || "",
apiKey: payload.apiKey || "",
apiPath: payload.apiPath || "",
ip: payload.ip || "",
hash: payload.h || "",
};
}
// ─── Browser lifecycle ───────────────────────────────────
private async launchBrowser(): Promise<void> {

View File

@@ -1,8 +1,9 @@
/**
* Parts-Catalogs API Service — HTTP client for parts-catalogs.com
* Parts-Catalogs API Service — HTTP client for parts-catalogs.com v3
*
* All requests go through the same DataImpulse proxy as the JWT capture
* to ensure the JWT's IP-bound constraint is satisfied.
* Calls the v3 widget proxy (gui.parts-catalogs.com/v3/api/proxy/*) with the
* captured TWS- token + supporting X-* headers. Requests must go through the
* same DataImpulse proxy port that captured the token (IP-bound).
*/
import { Injectable, Logger } from "@nestjs/common";
@@ -16,7 +17,7 @@ import type {
PcatSession,
} from "./parts-catalogs.types";
const API_BASE = "https://api.parts-catalogs.com/v1";
const API_BASE = "https://gui.parts-catalogs.com/v3/api/proxy";
const REQUEST_TIMEOUT = 30_000;
@Injectable()
@@ -192,10 +193,15 @@ export class PartsCatalogsService {
const fetchOptions: RequestInit & { dispatcher?: any } = {
method: "GET",
headers: {
Authorization: session.authorization,
"x-api-key": session.apiKey,
"x-api-path": session.apiPath,
"x-gui-version": session.guiVersion,
"x-user-id": session.userId,
origin: session.origin,
referer: session.referer,
Accept: "application/json",
"User-Agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT),
};

View File

@@ -1,11 +1,16 @@
/**
* Captured from a parts-catalogs.com v3 widget request.
* Token + the supporting X-* headers the widget sends with every API call.
* IP-bound (must be reused with the same proxy port that captured it).
*/
export interface PcatJwtToken {
raw: string;
exp: number;
host: string;
apiKey: string;
apiPath: string;
ip: string;
hash: string;
raw: string; // x-api-key value, e.g. "TWS-016EA7BE-..."
exp: number; // unix epoch seconds (capturedAt + TTL_FALLBACK)
apiPath: string; // x-api-path (upstream PCAT API base URL)
guiVersion: string; // x-gui-version (e.g. "3")
userId: string; // x-user-id (per-session UUID minted by widget)
origin: string; // partner-site origin
referer: string; // partner-site referer
}
export interface JwtSlot {
@@ -17,7 +22,12 @@ export interface JwtSlot {
}
export interface PcatSession {
authorization: string;
apiKey: string; // x-api-key (TWS- token)
apiPath: string;
guiVersion: string;
userId: string;
origin: string;
referer: string;
proxyUrl: string | null;
proxyConfig: { server: string; username: string; password: string } | null;
_slot: JwtSlot;

View File

@@ -571,6 +571,73 @@ export class PL24Service {
/**
* Re-fetch main groups using a stored mainGroupsPath.
*/
/**
* Fetch P5 restriction options from a given path (restrictions1/2/3 endpoint).
* Returns an array of selectable options, each with a path to the next level.
*/
async fetchP5Restrictions(
serviceName: string,
restrictionPath: string,
): Promise<{
options: Array<{ code: string; name: string; path: string }>;
isFinal: boolean;
}> {
await this.touchActivity();
try {
await this.authService.authorizeService(serviceName);
const headers = await this.authService.buildAuthHeaders(serviceName);
const localizedPath = restrictionPath.replace(/lang=\w+/, `lang=${this.language}`);
const url = `${this.baseUrl}${localizedPath}`;
const response = await fetch(url, {
method: "GET",
headers,
signal: AbortSignal.timeout(this.timeout),
});
if (!response.ok) {
this.logger.warn(`P5 restrictions fetch failed: HTTP ${response.status}`);
return { options: [], isFinal: false };
}
const data = (await response.json()) as Record<string, any>;
let records: any[] = [];
if (Array.isArray(data?.data?.records)) {
records = data.data.records;
} else if (Array.isArray(data)) {
records = data;
}
const options = records
.filter((r) => r.id || r.values?.caption)
.map((r) => ({
code: String(r.id ?? ""),
name: String(r.values?.caption ?? r.id ?? ""),
path: String(r.link?.path ?? ""),
}));
// Detect if we've reached the mainGroups / parts level:
// - The fetched URL itself contains "/mainGroup" (we ARE at the mainGroups endpoint)
// - OR the records' link.wid indicates parts navigation (subGroup, partsList, etc.)
const fetchedMainGroups = restrictionPath.includes("/mainGroup");
const firstWid = String(records[0]?.link?.wid ?? "");
const partsWids = ["subGroupTable", "subGroupNodeTable", "partsListTable", "mainGroupNodeTable"];
const widsIndicateParts = partsWids.some((w) => firstWid.includes(w) || firstWid.includes("Group"));
const isFinal = fetchedMainGroups || widsIndicateParts;
if (isFinal) {
this.logger.log(
`P5 restrictions: reached final level (path=${restrictionPath.substring(0, 80)}, wid=${firstWid})`,
);
}
return { options, isFinal };
} catch (error) {
this.logger.warn(`fetchP5Restrictions failed: ${(error as Error).message}`);
return { options: [], isFinal: false };
}
}
async fetchMainGroups(
serviceName: string,
mainGroupsPath: string,

View File

@@ -284,7 +284,7 @@ export class SubscriptionsService {
const now = new Date();
const endDate = new Date(now);
endDate.setDate(endDate.getDate() + 7);
endDate.setDate(endDate.getDate() + 30);
// Create trial subscription
const [subscription] = await this.db

View File

@@ -118,10 +118,10 @@ describe("TranslationsService", () => {
it("should return original text when no dictionary match found", async () => {
const result = await service.translate(
"cat:unknown-part",
"xylophone bracket",
"xylophone harpsichord",
);
expect(result.translatedText).toBe("xylophone bracket");
expect(result.translatedText).toBe("xylophone harpsichord");
expect(result.source).toBe("none");
expect(result.isAutoTranslated).toBe(false);
});

View File

@@ -1,105 +1,247 @@
import { Inject, Injectable, Logger } from "@nestjs/common";
import { eq, ilike, or } from "drizzle-orm";
import { eq, ilike, inArray, or } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider";
import { emexCategoryTranslations } from "../database/schema/core";
import { RedisService } from "../redis/redis.service";
/** 30 days in seconds */
const CACHE_TTL = 30 * 24 * 60 * 60;
/** 1 day TTL for cache misses (allow re-check sooner) */
const CACHE_MISS_TTL = 24 * 60 * 60;
const CACHE_PREFIX = "tr:";
/** Common EN → TR automotive dictionary */
/**
* EN → TR automotive dictionary. Used as the last fallback before returning
* the original text. Keys are lowercased; values use Turkish diacritics.
*
* Covers the common terms most likely to appear in EMEX category/part names.
* Anything unmapped here is sent through the LLM bootstrap script
* (scripts/emex-translate-bootstrap.ts) and persisted in
* `emex_category_translations`.
*/
const AUTOMOTIVE_DICTIONARY: Record<string, string> = {
"engine": "Motor",
"brake": "Fren",
"steering": "Direksiyon",
"suspension": "Süspansiyon",
"exhaust": "Egzoz",
"transmission": "Şanzıman",
"radiator": "Radyatör",
"battery": "Akü",
"filter": "Filtre",
"clutch": "Debriyaj",
"shock absorber": "Amortisör",
"alternator": "Alternatör",
"starter": "Marş Motoru",
// Engine + powertrain
engine: "Motor",
motor: "Motor",
piston: "Piston",
cylinder: "Silindir",
crankshaft: "Krank Mili",
camshaft: "Eksantrik Mili",
valve: "Supap",
turbocharger: "Turbo",
turbo: "Turbo",
intercooler: "Intercooler",
manifold: "Manifold",
"intake manifold": "Emme Manifoldu",
"exhaust manifold": "Egzoz Manifoldu",
flywheel: "Volan",
"spark plug": "Buji",
"fuel pump": "Yakıt Pompası",
"water pump": "Su Pompası",
"oil pump": "Yağ Pompası",
"timing belt": "Triger Kayışı",
"fan belt": "Vantilatör Kayışı",
"gasket": "Conta",
"piston": "Piston",
"cylinder": "Silindir",
"crankshaft": "Krank Mili",
"camshaft": "Eksantrik Mili",
"valve": "Supap",
"turbocharger": "Turbo",
"intercooler": "Intercooler",
"catalytic converter": "Katalitik Konvertör",
"muffler": "Susturucu",
"bumper": "Tampon",
"fender": "Çamurluk",
"hood": "Kaput",
"trunk": "Bagaj",
"windshield": "Ön Cam",
"mirror": "Ayna",
"headlight": "Far",
"tail light": "Stop Lambası",
"wiper": "Silecek",
"door": "Kapı",
"wheel": "Jant",
"tire": "Lastik",
"axle": "Aks",
"bearing": "Rulman",
"caliper": "Kaliper",
"ignition coil": "Ateşleme Bobini",
injector: "Enjektör",
"fuel injector": "Yakıt Enjektörü",
distributor: "Distribütör",
"voltage regulator": "Voltaj Regülatörü",
"throttle body": "Gaz Kelebeği",
// Transmission
transmission: "Şanzıman",
gearbox: "Vites Kutusu",
clutch: "Debriyaj",
"clutch kit": "Debriyaj Seti",
"gear lever": "Vites Kolu",
differential: "Diferansiyel",
"drive shaft": "Şaft",
driveshaft: "Şaft",
"axle shaft": "Aks Mili",
axle: "Aks",
"cv joint": "Aks Kafası",
"cv boot": "Aks Körüğü",
// Brake
brake: "Fren",
brakes: "Fren Sistemi",
"brake pad": "Fren Balatası",
"brake disc": "Fren Diski",
"air filter": "Hava Filtresi",
"oil filter": "Yağ Filtresi",
"fuel filter": "Yakıt Filtresi",
"cabin filter": "Polen Filtresi",
"thermostat": "Termostat",
"sensor": "Sensör",
"relay": "Röle",
"fuse": "Sigorta",
"compressor": "Kompresör",
"condenser": "Kondenser",
"evaporator": "Evaporatör",
"hose": "Hortum",
"belt": "Kayış",
"spring": "Yay",
"strut": "Amortisör Bacağı",
"brake rotor": "Fren Diski",
"brake hose": "Fren Hortumu",
"brake line": "Fren Borusu",
"brake fluid": "Fren Hidroliği",
caliper: "Fren Kaliperi",
"master cylinder": "Ana Merkez",
"slave cylinder": "Yardımcı Merkez",
handbrake: "El Freni",
// Suspension + steering
suspension: "Süspansiyon",
steering: "Direksiyon",
"steering wheel": "Direksiyon Simidi",
"steering rack": "Kremayer",
"steering gear": "Direksiyon Kutusu",
"power steering": "Hidrolik Direksiyon",
"power steering pump": "Hidrolik Direksiyon Pompası",
"power steering hose": "Hidrolik Direksiyon Hortumu",
"shock absorber": "Amortisör",
shock: "Amortisör",
strut: "Makferson",
spring: "Yay",
"control arm": "Salıncak",
"tie rod": "Rot Başı",
"ball joint": "Rotil",
"cv joint": "Aks Kafası",
"drive shaft": "Şaft",
"differential": "Diferansiyel",
"gearbox": "Vites Kutusu",
"flywheel": "Volan",
"injector": "Enjektör",
"throttle body": "Gaz Kelebeği",
"manifold": "Manifold",
// Wheels + tires
wheel: "Tekerlek",
wheels: "Jantlar",
"wheel hub": "Poyra",
hub: "Poyra",
"wheel bearing": "Tekerlek Rulmanı",
"hub bearing": "Poyra Rulmanı",
bearing: "Rulman",
"wheel stud": "Bijon",
"lug nut": "Bijon Somunu",
tire: "Lastik",
tires: "Lastikler",
// Cooling + fuel + air
cooling: "Soğutma Sistemi",
radiator: "Radyatör",
"radiator hose": "Radyatör Hortumu",
thermostat: "Termostat",
"water pump": "Su Pompası",
"fan belt": "Vantilatör Kayışı",
fan: "Fan",
coolant: "Antifriz",
fuel: "Yakıt",
"fuel system": "Yakıt Sistemi",
"fuel pump": "Yakıt Pompası",
"fuel filter": "Yakıt Filtresi",
"fuel tank": "Yakıt Deposu",
air: "Hava",
"air filter": "Hava Filtresi",
"cabin filter": "Polen Filtresi",
"pollen filter": "Polen Filtresi",
"oil filter": "Yağ Filtresi",
"oil pump": "Yağ Pompası",
"oil pan": "Karter",
"engine oil": "Motor Yağı",
oil: "Yağ",
filter: "Filtre",
filters: "Filtreler",
// Exhaust
exhaust: "Egzoz Sistemi",
"catalytic converter": "Katalitik Konvertör",
muffler: "Susturucu",
// Electrical
electrical: "Elektrik Sistemi",
battery: "Akü",
alternator: "Alternatör",
starter: "Marş Motoru",
sensor: "Sensör",
"oxygen sensor": "Oksijen Sensörü",
"abs sensor": "ABS Sensörü",
"ignition coil": "Ateşleme Bobini",
"distributor": "Distribütör",
"voltage regulator": "Voltaj Regülatörü",
"window regulator": "Cam Krikosu",
"crankshaft sensor": "Krank Sensörü",
"camshaft sensor": "Eksantrik Sensörü",
"coolant sensor": "Su Isısı Sensörü",
"oil pressure sensor": "Yağ Basıncı Sensörü",
relay: "Röle",
fuse: "Sigorta",
switch: "Anahtar",
horn: "Korna",
airbag: "Hava Yastığı",
// HVAC
"air conditioning": "Klima",
climate: "Klima",
compressor: "Kompresör",
condenser: "Kondenser",
evaporator: "Evaporatör",
heater: "Isıtıcı",
blower: "Üfleyici",
// Belts, seals, gaskets
belt: "Kayış",
"timing belt": "Triger Kayışı",
"timing chain": "Triger Zinciri",
"serpentine belt": "V Kayışı",
"drive belt": "Tahrik Kayışı",
tensioner: "Gerdirici",
"belt tensioner": "Kayış Gerdirici",
idler: "Avare",
"idler pulley": "Avare Kasnak",
pulley: "Kasnak",
"crankshaft pulley": "Krank Kasnağı",
hose: "Hortum",
gasket: "Conta",
"head gasket": "Silindir Kapağı Contası",
"valve cover gasket": "Külbütör Kapağı Contası",
"oil pan gasket": "Karter Contası",
"intake manifold gasket": "Emme Manifoldu Contası",
"exhaust manifold gasket": "Egzoz Manifoldu Contası",
seal: "Keçe",
"o-ring": "O-Ring",
// Body + exterior
body: "Kaporta",
bumper: "Tampon",
"front bumper": "Ön Tampon",
"rear bumper": "Arka Tampon",
fender: "Çamurluk",
mudguard: "Çamurluk",
"splash guard": "Paçalık",
hood: "Kaput",
bonnet: "Kaput",
trunk: "Bagaj",
boot: "Bagaj",
grille: "Izgara",
exterior: "Dış Aksam",
// Doors + windows + mirrors
door: "Kapı",
"door handle": "Kapı Kolu",
"door lock": "Kapı Kilidi",
"seat": "Koltuk",
"dashboard": "Gösterge Paneli",
"steering wheel": "Direksiyon Simidi",
"gear lever": "Vites Kolu",
"handbrake": "El Freni",
"pedal": "Pedal",
"radiator hose": "Radyatör Hortumu",
"coolant": "Antifriz",
"brake fluid": "Fren Hidroliği",
"engine oil": "Motor Yağı",
"power steering": "Hidrolik Direksiyon",
"door hinge": "Kapı Menteşesi",
window: "Cam",
windshield: "Ön Cam",
windscreen: "Ön Cam",
"window regulator": "Cam Krikosu",
"window motor": "Cam Motoru",
mirror: "Ayna",
"side mirror": "Yan Ayna",
"rear view mirror": "İç Ayna",
// Wipers + lights
wiper: "Silecek",
"wiper blade": "Silecek Lastiği",
"wiper motor": "Silecek Motoru",
lighting: "Aydınlatma",
lights: "Aydınlatma",
headlight: "Far",
"headlight bulb": "Far Ampulü",
"tail light": "Stop Lambası",
taillight: "Stop Lambası",
"fog light": "Sis Lambası",
"turn signal": "Sinyal Lambası",
indicator: "Sinyal Lambası",
// Interior
interior: "İç Aksam",
seat: "Koltuk",
dashboard: "Gösterge Paneli",
pedal: "Pedal",
carpet: "Halı",
mat: "Paspas",
// Misc / fasteners
pump: "Pompa",
screw: "Vida",
bolt: "Cıvata",
nut: "Somun",
washer: "Pul",
clip: "Klips",
bracket: "Braket",
cover: "Kapak",
plug: "Tıpa",
};
export interface TranslationResult {
@@ -149,32 +291,9 @@ export class TranslationsService {
return result;
}
// 3. Try dictionary-based translation
const dictTranslation = this.translateWithDictionary(sourceText);
if (dictTranslation !== null) {
const result: TranslationResult = {
key,
sourceText,
translatedText: dictTranslation,
source: "dictionary",
isAutoTranslated: true,
};
// Persist to DB for future lookups
await this.db
.insert(emexCategoryTranslations)
.values({
originalName: sourceText,
translatedName: dictTranslation,
isManual: false,
})
.onConflictDoNothing();
await this.redis.setJson(cacheKey, result, CACHE_TTL);
return result;
}
// 4. No translation found — return original with flag
// 3. No DB hit — return original. Dictionary fallback removed: its
// word-by-word replacement produces half-translated strings and
// would poison the DB. Bootstrap script handles new terms via LLM.
const result: TranslationResult = {
key,
sourceText,
@@ -182,21 +301,122 @@ export class TranslationsService {
source: "none",
isAutoTranslated: false,
};
// Cache "miss" with shorter TTL (1 day) so it gets re-checked sooner
await this.redis.setJson(cacheKey, result, 24 * 60 * 60);
await this.redis.setJson(cacheKey, result, CACHE_MISS_TTL);
return result;
}
/**
* Batch translate multiple items
* Bulk translate many strings in one shot — used by EMEX category/part
* insertion paths (categories.service.ts) where a single fetch can yield
* dozens to hundreds of names.
*
* Pipeline: Redis MGET → DB IN(...) → dictionary fallback → bulk INSERT
* → Redis pipeline SET. Returns Map<originalText, translatedText>; entries
* for which no translation is available map to the original text.
*/
async translateMany(sourceTexts: string[]): Promise<Map<string, string>> {
const result = new Map<string, string>();
if (!sourceTexts.length) return result;
// Dedupe and drop empties — caller often has duplicates across rows
const unique = [...new Set(sourceTexts.filter((s) => s && s.trim().length > 0))];
if (!unique.length) return result;
// Phase 1: Redis MGET
const redis = this.redis.getClient();
const cacheKeys = unique.map((s) => `${CACHE_PREFIX}${s}`);
const cachedRaw = await redis.mget(...cacheKeys);
const missing: string[] = [];
cachedRaw.forEach((raw, idx) => {
const text = unique[idx];
if (raw) {
try {
const parsed = JSON.parse(raw) as TranslationResult;
result.set(text, parsed.translatedText);
} catch {
missing.push(text);
}
} else {
missing.push(text);
}
});
if (!missing.length) return result;
// Phase 2: DB IN(...) for cache misses
const dbRows = await this.db
.select({
originalName: emexCategoryTranslations.originalName,
translatedName: emexCategoryTranslations.translatedName,
isManual: emexCategoryTranslations.isManual,
})
.from(emexCategoryTranslations)
.where(inArray(emexCategoryTranslations.originalName, missing));
const dbMap = new Map<string, { translated: string; isManual: boolean }>();
for (const row of dbRows) {
dbMap.set(row.originalName, { translated: row.translatedName, isManual: row.isManual });
}
// Phase 3: Build result map + populate Redis pipeline.
// We deliberately do NOT use translateWithDictionary's word-by-word
// replacement here — it produces half-translated strings (e.g.
// "Body frame" → "Kaporta frame") and would persist them to the DB,
// poisoning future lookups. Anything missing falls through to a
// short-TTL cache miss; the next emex-translate-bootstrap.ts run
// picks it up and writes the proper LLM translation.
const pipeline = redis.pipeline();
for (const text of missing) {
const dbHit = dbMap.get(text);
if (dbHit) {
result.set(text, dbHit.translated);
const payload: TranslationResult = {
key: text,
sourceText: text,
translatedText: dbHit.translated,
source: "db",
isAutoTranslated: !dbHit.isManual,
};
pipeline.set(`${CACHE_PREFIX}${text}`, JSON.stringify(payload), "EX", CACHE_TTL);
} else {
result.set(text, text);
const payload: TranslationResult = {
key: text,
sourceText: text,
translatedText: text,
source: "none",
isAutoTranslated: false,
};
pipeline.set(`${CACHE_PREFIX}${text}`, JSON.stringify(payload), "EX", CACHE_MISS_TTL);
}
}
await pipeline.exec();
return result;
}
/**
* Batch translate — kept for backwards compatibility with the controller's
* /translations/batch endpoint. Internally uses translateMany.
*/
async translateBatch(
items: { key: string; sourceText: string }[],
): Promise<TranslationResult[]> {
const results = await Promise.all(
items.map((item) => this.translate(item.key, item.sourceText)),
);
return results;
if (!items.length) return [];
const trMap = await this.translateMany(items.map((i) => i.sourceText));
return items.map((i) => {
const translated = trMap.get(i.sourceText) ?? i.sourceText;
const same = translated === i.sourceText;
return {
key: i.key,
sourceText: i.sourceText,
translatedText: translated,
source: same ? "none" : "db",
isAutoTranslated: !same,
};
});
}
/**
@@ -225,9 +445,11 @@ export class TranslationsService {
})
.returning();
// Invalidate cache
const cacheKey = `${CACHE_PREFIX}${key}`;
await this.redis.del(cacheKey);
// Invalidate cache (key + sourceText so both lookup paths see the change)
await this.redis.del(`${CACHE_PREFIX}${key}`);
if (key !== sourceText) {
await this.redis.del(`${CACHE_PREFIX}${sourceText}`);
}
this.logger.log(`Translation set: "${sourceText}" → "${translatedText}"`);