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:
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}"`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user