Files
sase.tr/apps/api/src/catalog/catalog.service.ts
Semih Yesilyurt 28720c7d7d
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
fix(canonical): kova → doğal alt-kategori seviyesi (entry-children, subtree count)
Önceki hibrit sadece derin parça-yapraklarını + kök/dal girişlerini gösteriyordu;
kısmen drill edilmiş kovada ("Fren sistemi" subtree'de 39 parça) sistem-kökü
skip ediliyor + 0-parçalı kardeşleri düşüyordu → Fren'de 14 yerine 2. Yeni model
(buildCanonicalInput): her kovanın "entry container" köklerinin AYNI-KOVA
çocuklarını SUBTREE parça sayısıyla göster (drilled olsun olmasın). Audi Fren →
11 alt-kategori (Disk frenler/Fren Kaliyeri/El fren…), orijinal katalog gibi.
On-the-fly bucketing (materialize edilmemiş taze kategoriler için).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:15:52 +03:00

1473 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { and, eq, gte, inArray, isNotNull, isNull, or, sql } from "drizzle-orm";
import {
buildCanonicalInput,
groupCategoriesByCanonical,
overlayTemplate,
} from "../categories/canonical-grouping";
import { DATABASE, type Database } from "../database/database.provider";
import {
brands,
canonicalCategories,
canonicalStructureTemplate,
catalogVehicles,
categories,
parts,
plans,
schemaPics,
userBrands,
userSubscriptions,
} from "../database/schema/core";
import { PL24Service } from "../integrations/pl24/pl24.service";
import {
type PL24DecodedCategory,
PL24_SERVICE_CATALOGS,
SERVICE_DISPLAY_NAMES,
SERVICE_TO_BRAND,
isP5Modern,
} from "../integrations/pl24/pl24.types";
import { RedisService } from "../redis/redis.service";
import { StorageService } from "../storage/storage.service";
@Injectable()
export class CatalogService {
private readonly logger = new Logger(CatalogService.name);
constructor(
@Inject(DATABASE) private db: Database,
private redis: RedisService,
private pl24Service: PL24Service,
private storage: StorageService,
) {}
/**
* Get all brands available in PL24 catalogs, with access flags.
* Returns static brand list from SERVICE_TO_BRAND, enriched with DB brand info.
*/
async getBrands(userId: string) {
// Get unique brand names from service catalog
const brandNames = new Set(Object.values(SERVICE_TO_BRAND));
// Fetch DB brands for logo URLs
const dbBrands = await this.db
.select({ id: brands.id, name: brands.name, slug: brands.slug, logoUrl: brands.logoUrl })
.from(brands)
.where(eq(brands.isActive, true));
// Case-insensitive name lookup to handle mismatches (e.g. "SEAT" vs "Seat", "MINI" vs "Mini")
const brandByName = new Map(dbBrands.map((b) => [b.name.toLowerCase(), b]));
// Check if user has full plan (brandCount=0 → unlimited brand access)
const [sub] = await this.db
.select({ brandCount: plans.brandCount })
.from(userSubscriptions)
.innerJoin(plans, eq(userSubscriptions.planId, plans.id))
.where(
and(
eq(userSubscriptions.userId, userId),
or(eq(userSubscriptions.status, "active"), eq(userSubscriptions.status, "trial")),
),
)
.limit(1);
const isFullAccess = sub?.brandCount === 0;
const accessibleBrandIds = isFullAccess ? null : await this.getAccessibleBrandIds(userId);
// Build response
const result: Array<{
brandName: string;
brandId: string | null;
logoUrl: string | null;
serviceNames: string[];
hasAccess: boolean;
}> = [];
// Group services by brand
const brandServices = new Map<string, string[]>();
for (const [serviceName, brandName] of Object.entries(SERVICE_TO_BRAND)) {
if (!brandServices.has(brandName)) brandServices.set(brandName, []);
brandServices.get(brandName)?.push(serviceName);
}
for (const brandName of Array.from(brandNames).sort()) {
const dbBrand = brandByName.get(brandName.toLowerCase());
const hasAccess = isFullAccess
? true
: dbBrand
? (accessibleBrandIds?.has(dbBrand.id) ?? false)
: false;
result.push({
brandName,
brandId: dbBrand?.id || null,
logoUrl: dbBrand?.logoUrl || null,
serviceNames: brandServices.get(brandName) || [],
hasAccess,
});
}
return result;
}
/**
* Get the list of sub-catalogs (services) available for a brand.
* Brands with a single service return a 1-item array; multi-service brands return all services.
*/
async getCatalogs(brandName: string, userId: string) {
const brandId = await this.getBrandIdByName(brandName);
if (brandId) {
await this.checkBrandAccess(userId, brandId);
}
const serviceNames = Object.entries(SERVICE_TO_BRAND)
.filter(([, b]) => b === brandName)
.map(([s]) => s);
if (serviceNames.length === 0) {
throw new NotFoundException(`Marka kataloğu bulunamadı: ${brandName}`);
}
return serviceNames.map((s) => ({
serviceName: s,
displayName: SERVICE_DISPLAY_NAMES[s] ?? s,
}));
}
/**
* Get models for a brand. Fetches from DB first, then PL24 if empty.
* Optional serviceName filters to a specific sub-catalog.
*/
async getModels(brandName: string, userId: string, serviceName?: string) {
// Check brand access
const brandId = await this.getBrandIdByName(brandName);
if (brandId) {
await this.checkBrandAccess(userId, brandId);
}
// All service names for this brand, or just the requested one
const allServiceNames = Object.entries(SERVICE_TO_BRAND)
.filter(([, b]) => b === brandName)
.map(([s]) => s);
if (allServiceNames.length === 0) {
throw new NotFoundException(`Marka kataloğu bulunamadı: ${brandName}`);
}
const serviceNames = serviceName ? [serviceName] : allServiceNames;
// Check DB first
const whereConditions = [
eq(catalogVehicles.brandName, brandName),
eq(catalogVehicles.source, "pl24"),
...(serviceName ? [eq(catalogVehicles.serviceName, serviceName)] : []),
];
const dbVehicles = await this.db
.select()
.from(catalogVehicles)
.where(and(...whereConditions));
if (dbVehicles.length > 0) {
return dbVehicles;
}
// Fetch from PL24 for each service
const allVehicles: (typeof catalogVehicles.$inferSelect)[] = [];
for (const svc of serviceNames) {
try {
const fetched = await this.pl24Service.fetchVehicleList(svc);
if (fetched.length === 0) continue;
const config = PL24_SERVICE_CATALOGS[svc];
const insertData = fetched.map((v) => ({
source: "pl24" as const,
serviceName: svc,
brandName,
brandId: brandId || null,
model: v.model,
year: v.year || null,
engine: v.engine || null,
bodyType: v.bodyType || null,
transmission: v.transmission || null,
market: v.market || null,
serviceVehicleId: v.vehicleId,
catalogPath: v.catalogPath || null,
architecture: config?.architecture || "P5_MODERN",
metadata: v.metadata || null,
categoriesFetched: false,
updatedAt: new Date(),
}));
const inserted = await this.db
.insert(catalogVehicles)
.values(insertData)
.onConflictDoNothing()
.returning();
allVehicles.push(...inserted);
} catch (err) {
this.logger.warn(`Failed to fetch vehicle list for ${svc}: ${(err as Error).message}`);
}
}
// Re-fetch from DB to include any previously inserted (onConflictDoNothing may skip)
if (allVehicles.length === 0) {
const refetched = await this.db
.select()
.from(catalogVehicles)
.where(and(...whereConditions));
return refetched;
}
return allVehicles;
}
/**
* Get a single catalog vehicle by ID.
*/
async getVehicle(id: string, userId: string) {
const [vehicle] = await this.db
.select()
.from(catalogVehicles)
.where(eq(catalogVehicles.id, id))
.limit(1);
if (!vehicle) throw new NotFoundException("Katalog aracı bulunamadı");
if (vehicle.brandId) {
await this.checkBrandAccess(userId, vehicle.brandId);
}
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).
*/
async getPsaBodies(
catalogVehicleId: string,
userId: string,
): Promise<{ code: string; name: string }[]> {
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);
if (vehicle.architecture !== "LEGACY_PSA" || !vehicle.catalogPath) return [];
const { familyId, salesTypeId, mode, upds } = this.extractPsaMeta(vehicle);
if (!familyId || !salesTypeId) return [];
return this.pl24Service.fetchPsaBodies(vehicle.serviceName, familyId, salesTypeId, mode, upds);
}
/**
* Get available engines for a PSA catalog vehicle given a selected body code.
*/
async getPsaEngines(
catalogVehicleId: string,
body: string,
userId: string,
): Promise<{ code: string; name: string }[]> {
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);
if (vehicle.architecture !== "LEGACY_PSA" || !vehicle.catalogPath) return [];
const { familyId, salesTypeId, mode, upds } = this.extractPsaMeta(vehicle);
if (!familyId || !salesTypeId) return [];
return this.pl24Service.fetchPsaEnginesForBody(
vehicle.serviceName,
familyId,
salesTypeId,
body,
mode,
upds,
);
}
/**
* Get available gearboxes for a PSA catalog vehicle given selected body + engine codes.
*/
async getPsaGearboxes(
catalogVehicleId: string,
body: string,
engine: string,
userId: string,
): Promise<{ code: string; name: string }[]> {
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);
if (vehicle.architecture !== "LEGACY_PSA" || !vehicle.catalogPath) return [];
const { familyId, salesTypeId, mode, upds } = this.extractPsaMeta(vehicle);
if (!familyId || !salesTypeId) return [];
return this.pl24Service.fetchPsaGearboxes(
vehicle.serviceName,
familyId,
salesTypeId,
body,
engine,
mode,
upds,
);
}
/**
* Get category tree for a catalog vehicle (on-demand fetch from PL24).
*/
/**
* Unified canonical browse for a catalog vehicle (pl24 flow) — the
* catalog_vehicle_id counterpart of CategoriesService.getCanonicalTree.
* Folds the vehicle's part-holding leaf categories into the 20 canonical
* buckets so pl24 brands (VW, Opel, Renault…) navigate through the same
* headings as the emex/pcat vehicle_id flow. Part counts are joined by
* category_id (pl24 parts often carry vehicle_id, not catalog_vehicle_id).
*/
async getCanonicalTree(
catalogVehicleId: string,
userId: string,
body = "_all_",
engine = "_all_",
gearbox = "_all_",
mainGroupsPath?: string,
) {
// Populate the raw source tree (lazy fetch + cache) and enforce brand access.
await this.getCategoryTree(catalogVehicleId, userId, body, engine, gearbox, mainGroupsPath);
const buckets = await this.db
.select({
id: canonicalCategories.id,
slug: canonicalCategories.slug,
name: canonicalCategories.name,
})
.from(canonicalCategories)
.where(isNull(canonicalCategories.parentId))
.orderBy(canonicalCategories.sortOrder);
const subLeaves = await this.db
.select({
id: canonicalCategories.id,
parentId: canonicalCategories.parentId,
slug: canonicalCategories.slug,
name: canonicalCategories.name,
})
.from(canonicalCategories)
.where(isNotNull(canonicalCategories.parentId))
.orderBy(canonicalCategories.sortOrder);
const cats = await this.db
.select({
id: categories.id,
name: categories.name,
source: categories.source,
canonicalId: categories.canonicalCategoryId,
subId: categories.canonicalSubcategoryId,
parentId: categories.parentId,
})
.from(categories)
.where(eq(categories.catalogVehicleId, catalogVehicleId));
const partCounts = new Map<string, number>();
const catIds = cats.map((c) => c.id);
if (catIds.length > 0) {
const rows = await this.db
.select({ categoryId: parts.categoryId, count: sql<number>`count(*)::int` })
.from(parts)
.where(inArray(parts.categoryId, catIds))
.groupBy(parts.categoryId);
for (const r of rows) partCounts.set(r.categoryId, r.count);
}
// Show each bucket's natural sub-category level (see buildCanonicalInput).
const {
input: shown,
counts: shownCounts,
hasChildren,
} = buildCanonicalInput(cats, partCounts, buckets);
const groups = groupCategoriesByCanonical(shown, shownCounts, buckets, {
requireParts: false,
subLeaves,
hasChildren,
});
// Overlay the per-brand structure template (see CategoriesService.getCanonicalTree).
const cv = await this.db
.select({ brand: catalogVehicles.brandName, source: catalogVehicles.source })
.from(catalogVehicles)
.where(eq(catalogVehicles.id, catalogVehicleId))
.limit(1);
const brand = cv[0]?.brand;
const source = cats[0]?.source ?? cv[0]?.source;
if (!brand || !source) return groups;
const template = await this.db
.select({
bucketId: canonicalStructureTemplate.canonicalCategoryId,
subId: canonicalStructureTemplate.canonicalSubcategoryId,
})
.from(canonicalStructureTemplate)
.where(
and(
eq(canonicalStructureTemplate.source, source),
eq(canonicalStructureTemplate.brand, brand),
gte(canonicalStructureTemplate.vehicleCount, 2),
),
);
return overlayTemplate(groups, template, buckets, subLeaves);
}
async getCategoryTree(
catalogVehicleId: string,
userId: string,
body = "_all_",
engine = "_all_",
gearbox = "_all_",
mainGroupsPath?: string,
) {
const hasVariant = body !== "_all_" || engine !== "_all_" || gearbox !== "_all_";
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;
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);
}
// For LEGACY_PSA with specific variants, fetch directly from PL24.
// We also ensure the _all_ categories are persisted to DB so that
// getCategoryWithParts can resolve PSA codes to DB UUIDs via externalId.
if (vehicle.architecture === "LEGACY_PSA" && hasVariant && vehicle.catalogPath) {
try {
const { familyId, salesTypeId, mode, upds } = this.extractPsaMeta(vehicle);
// Fetch variant-specific main groups
const pl24Categories = await this.pl24Service.fetchMainGroupsForPsa(
vehicle.serviceName,
familyId,
salesTypeId,
mode,
upds,
body,
engine,
gearbox,
);
// Ensure _all_ categories are in DB so getCategoryWithParts can find UUIDs by externalId
let dbCategories = await this.db
.select()
.from(categories)
.where(eq(categories.catalogVehicleId, catalogVehicleId));
if (dbCategories.length === 0) {
// Fetch and persist the _all_ variant (once, as the canonical DB records)
const allCats = await this.pl24Service.fetchMainGroupsForPsa(
vehicle.serviceName,
familyId,
salesTypeId,
mode,
upds,
);
if (allCats.length > 0) {
const seenNames = new Set<string>();
const unique = allCats.filter((c) => {
const name = c.nameTr || c.nameEn;
if (seenNames.has(name)) return false;
seenNames.add(name);
return true;
});
const insertData = unique.map((c) => ({
catalogVehicleId,
vehicleId: null as string | null,
name: c.nameTr || c.nameEn,
nameOriginal: c.nameEn,
parentId: null as string | null,
externalId: c.code,
linkPath: c.linkPath || null,
linkWid: c.linkWid || null,
source: "pl24" as const,
}));
const inserted = await this.db
.insert(categories)
.values(insertData)
.onConflictDoNothing()
.returning();
dbCategories =
inserted.length > 0
? inserted
: await this.db
.select()
.from(categories)
.where(eq(categories.catalogVehicleId, catalogVehicleId));
await this.db
.update(catalogVehicles)
.set({ categoriesFetched: true, updatedAt: new Date() })
.where(eq(catalogVehicles.id, catalogVehicleId));
}
}
// Map PSA external codes → DB UUIDs so navigation uses real UUIDs
const externalIdToUuid = new Map(dbCategories.map((c) => [c.externalId, c.id]));
const tree = this.buildTree(
pl24Categories.map((c) => ({
id: externalIdToUuid.get(c.code) ?? c.code,
catalogVehicleId,
vehicleId: null,
name: c.nameTr || c.nameEn,
nameOriginal: c.nameEn,
parentId: null,
externalId: c.code,
linkPath: c.linkPath || null,
linkWid: c.linkWid || null,
source: "pl24",
})),
);
await this.redis.setJson(cacheKey, tree, 7200);
return tree;
} catch (err) {
this.logger.warn(
`PSA variant category fetch failed for ${catalogVehicleId}: ${(err as Error).message}`,
);
return [];
}
}
// LEGACY_HYUNDAI_KIA / LEGACY_NISSAN / LEGACY_OPEL: no variant selector needed.
// Fetch main groups directly via fetchFordMainGroups (dispatches URL per architecture).
if (
vehicle.architecture === "LEGACY_HYUNDAI_KIA" ||
vehicle.architecture === "LEGACY_NISSAN" ||
vehicle.architecture === "LEGACY_OPEL"
) {
try {
const { familyId, mode, upds } = this.extractFordMeta(vehicle);
if (familyId) {
const pl24Categories = await this.pl24Service.fetchFordMainGroups(
vehicle.serviceName,
familyId,
"_all_",
"_all_",
"_all_",
mode,
upds,
);
if (pl24Categories.length > 0) {
const tree = await this.persistAndBuildLegacyTree(
catalogVehicleId,
pl24Categories,
cacheKey,
);
if (tree.length > 0) return tree;
}
}
} catch (err) {
this.logger.warn(
`P4 direct category fetch failed for ${catalogVehicleId}: ${(err as Error).message}`,
);
}
// DB fallback: return cached categories if PL24 failed or returned empty
return this.buildCategoryTreeFromDb(catalogVehicleId, cacheKey);
}
// LEGACY_FORD + LEGACY_VOLVO: fetch categories via group.action only when variant params are provided.
// Without params the FordVariantSelector on the frontend handles selection first.
// For LEGACY_FORD: body param = catCode (e.g. "CB7" for C-MAX Grand C-MAX).
// For LEGACY_VOLVO: body param = model year (e.g. "1638").
if (this.isP4FordLikeArch(vehicle.architecture)) {
if (!hasVariant) return []; // no variant selected yet → let frontend show variant selector
try {
const { familyId, mode, upds } = this.extractFordMeta(vehicle);
if (familyId) {
let pl24Categories: Awaited<ReturnType<typeof this.pl24Service.fetchFordMainGroups>>;
if (vehicle.architecture === "LEGACY_FORD") {
// For Ford, body = catCode (generation identifier required by group.action).
// catCode is selected by the user via FordVariantSelector showing window.vehicles sub-models.
const fordCatCode = body && body !== "_nor_" && body !== "_all_" ? body : undefined;
pl24Categories = await this.pl24Service.fetchFordMainGroups(
vehicle.serviceName,
familyId,
"_all_", // modelYear not used for Ford catCode-based lookup
"_all_", // engine not used
"_all_", // gearbox not used
mode,
upds,
fordCatCode,
);
} else {
// LEGACY_VOLVO: body = year, engine/gearbox as-is
const normFord = (v: string) => (v === "_nor_" ? "_all_" : v || "_all_");
pl24Categories = await this.pl24Service.fetchFordMainGroups(
vehicle.serviceName,
familyId,
normFord(body ?? ""),
normFord(engine ?? ""),
normFord(gearbox ?? ""),
mode,
upds,
);
}
if (pl24Categories.length > 0) {
const tree = await this.persistAndBuildLegacyTree(
catalogVehicleId,
pl24Categories,
cacheKey,
);
if (tree.length > 0) return tree;
}
}
} catch (err) {
this.logger.warn(
`Ford variant category fetch failed for ${catalogVehicleId}: ${(err as Error).message}`,
);
}
// DB fallback: return cached categories if PL24 failed or returned empty
return this.buildCategoryTreeFromDb(catalogVehicleId, cacheKey);
}
// Check DB first
let dbCategories = await this.db
.select()
.from(categories)
.where(eq(categories.catalogVehicleId, catalogVehicleId));
// When mainGroupsPath is provided (P5 restriction flow), use it directly
const effectiveCatalogPath = mainGroupsPath || vehicle.catalogPath;
if (dbCategories.length === 0 && effectiveCatalogPath) {
try {
let pl24Categories: Awaited<ReturnType<PL24Service["fetchMainGroups"]>>;
if (vehicle.architecture === "LEGACY_PSA") {
// PSA vehicles: parse family/salesType from catalogPath, mode/upds from metadata
const { familyId, salesTypeId, mode, upds } = this.extractPsaMeta(vehicle);
pl24Categories = await this.pl24Service.fetchMainGroupsForPsa(
vehicle.serviceName,
familyId,
salesTypeId,
mode,
upds,
);
} else {
pl24Categories = await this.pl24Service.fetchMainGroups(
vehicle.serviceName,
effectiveCatalogPath,
);
}
if (pl24Categories.length > 0) {
const seenNames = new Set<string>();
const unique = pl24Categories.filter((c) => {
const name = c.nameTr || c.nameEn;
if (seenNames.has(name)) return false;
seenNames.add(name);
return true;
});
const insertData = unique.map((c) => ({
catalogVehicleId,
vehicleId: null as string | null,
name: c.nameTr || c.nameEn,
nameOriginal: c.nameEn,
parentId: null as string | null,
externalId: c.code,
linkPath: c.linkPath || null,
linkWid: c.linkWid || null,
source: "pl24" as const,
}));
dbCategories = await this.db
.insert(categories)
.values(insertData)
.onConflictDoNothing()
.returning();
if (dbCategories.length < insertData.length) {
dbCategories = await this.db
.select()
.from(categories)
.where(eq(categories.catalogVehicleId, catalogVehicleId));
}
// Mark categories as fetched
await this.db
.update(catalogVehicles)
.set({ categoriesFetched: true, updatedAt: new Date() })
.where(eq(catalogVehicles.id, catalogVehicleId));
}
} catch (err) {
this.logger.warn(
`PL24 category fetch failed for catalog vehicle ${catalogVehicleId}: ${(err as Error).message}`,
);
}
}
const tree = this.buildTree(dbCategories);
await this.redis.setJson(cacheKey, tree, 7200); // 2h TTL
return tree;
}
/**
* Build a category tree from DB for a catalog vehicle (used as fallback when PL24 is unreachable).
* Only returns root-level categories (parentId IS NULL).
*/
private async buildCategoryTreeFromDb(catalogVehicleId: string, cacheKey: string) {
const dbCategories = await this.db
.select()
.from(categories)
.where(
and(eq(categories.catalogVehicleId, catalogVehicleId), sql`${categories.parentId} IS NULL`),
);
if (dbCategories.length === 0) return [];
// Same nav-crumb read filter as persistAndBuildLegacyTree (self-heals
// previously-persisted Hyundai "Portal" / Nissan eu.nissan.biz junk rows).
const tree = this.buildTree(dbCategories.filter((c) => !this.isNavCrumbLink(c.linkPath)));
await this.redis.setJson(cacheKey, tree, 3600); // 1h cache for DB fallback
return tree;
}
/**
* Get sub-categories or parts for a category in a catalog vehicle.
* Adapts CategoriesService.getCategoryWithParts logic for catalogVehicleId.
*/
async getCategoryWithParts(
catalogVehicleId: string,
categoryIdInput: string,
userId: string,
body = "_all_",
engine = "_all_",
gearbox = "_all_",
) {
const [catalogVehicle] = await this.db
.select()
.from(catalogVehicles)
.where(eq(catalogVehicles.id, catalogVehicleId))
.limit(1);
if (!catalogVehicle) throw new NotFoundException("Katalog aracı bulunamadı");
if (catalogVehicle.brandId) {
await this.checkBrandAccess(userId, catalogVehicle.brandId);
}
// PSA variant trees return PL24 codes (e.g. "_FCT0100") as IDs instead of UUIDs.
// Detect and resolve to DB UUID via externalId lookup.
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
categoryIdInput,
);
let categoryRow: typeof categories.$inferSelect | undefined;
if (isUuid) {
[categoryRow] = await this.db
.select()
.from(categories)
.where(eq(categories.id, categoryIdInput))
.limit(1);
} else {
// PSA external code — look up by externalId within this catalog vehicle
[categoryRow] = await this.db
.select()
.from(categories)
.where(
and(
eq(categories.externalId, categoryIdInput),
eq(categories.catalogVehicleId, catalogVehicleId),
),
)
.limit(1);
}
if (!categoryRow) throw new NotFoundException("Kategori bulunamadı");
// Normalize to DB UUID for all downstream queries
const category = categoryRow;
const categoryId = category.id;
const linkPath = category.linkPath;
// Check if this category has children
let children = await this.db
.select()
.from(categories)
.where(eq(categories.parentId, categoryId));
// Self-healing: drop nav-crumb junk children (e.g. "Portal"/eu.nissan.biz rows
// persisted by the old parser) and delete them so this node re-fetches the real
// subgroups instead of returning a junk-only child set.
const navCrumbChildren = children.filter((c) => this.isNavCrumbLink(c.linkPath));
if (navCrumbChildren.length > 0) {
await this.db.delete(categories).where(
inArray(
categories.id,
navCrumbChildren.map((c) => c.id),
),
);
children = children.filter((c) => !this.isNavCrumbLink(c.linkPath));
}
// Self-healing: if the linkPath is a leaf path but DB has children, those are
// stale records created by the previous case-insensitive bug. Delete and re-fetch.
if (children.length > 0 && linkPath && this.isLeafPath(linkPath)) {
this.logger.warn(
`Stale children detected on leaf category ${categoryId} (linkPath: ${linkPath}), cleaning up`,
);
await this.db.delete(categories).where(eq(categories.parentId, categoryId));
children = [];
}
if (children.length > 0) {
return {
id: category.id,
name: category.name,
description: category.nameOriginal || null,
parentId: category.parentId || null,
parts: [],
schemaPics: [],
hotspots: [],
children: await this.enrichWithSchemaImages(children),
};
}
if (linkPath && !this.isLeafPath(linkPath)) {
// Try to fetch subgroups
const subGroups = await this.pl24Service.fetchSubGroupsByPath(
linkPath,
catalogVehicle.serviceName,
body,
engine,
gearbox,
);
// After dropping nav-crumb junk the subgroup set can be empty (upstream
// returned only a "Portal"/external stub for this node). Guard the insert —
// drizzle .values([]) throws — and fall through to the leaf/parts path,
// which yields a clean empty node instead of a 500.
const seenNames = new Set<string>();
const unique = subGroups.filter((sg) => {
if (this.isNavCrumbLink(sg.linkPath)) return false;
if (seenNames.has(sg.name)) return false;
seenNames.add(sg.name);
return true;
});
if (unique.length > 0) {
const insertData = unique.map((sg) => ({
catalogVehicleId,
vehicleId: null as string | null,
name: sg.name,
nameOriginal: sg.name,
parentId: categoryId,
externalId: sg.code,
linkPath: sg.linkPath || null,
linkWid: sg.linkWid || null,
unavailable: sg.unavailable || false,
source: "pl24" as const,
}));
children = await this.db
.insert(categories)
.values(insertData)
.onConflictDoNothing()
.returning();
if (children.length < insertData.length) {
children = await this.db
.select()
.from(categories)
.where(eq(categories.parentId, categoryId));
}
await this.redis.del(`cat:catalog:tree:${catalogVehicleId}`);
if (children.length > 0) {
return {
id: category.id,
name: category.name,
description: category.nameOriginal || null,
parentId: category.parentId || null,
parts: [],
schemaPics: [],
hotspots: [],
children: await this.enrichWithSchemaImages(children),
};
}
}
}
// Leaf category — get or fetch parts
let dbParts = await this.db.select().from(parts).where(eq(parts.categoryId, categoryId));
const pics = await this.db
.select()
.from(schemaPics)
.where(eq(schemaPics.categoryId, categoryId));
const needParts = dbParts.length === 0;
const needImage = pics.length === 0;
if ((needParts || needImage) && linkPath && category.source === "pl24") {
try {
const pl24Result = await this.pl24Service.fetchPartsByPath(
linkPath,
catalogVehicle.serviceName,
body,
engine,
gearbox,
);
if (needParts && pl24Result.parts.length > 0) {
const insertData = pl24Result.parts.map((p) => ({
catalogVehicleId,
vehicleId: null as string | null,
categoryId,
oemCode: p.oemCode || "N/A",
name: p.name,
nameOriginal: p.name,
description: p.description || null,
quantity: p.quantity ? Number.parseInt(String(p.quantity), 10) || null : null,
position: p.positionCode || null,
hotspotIndex: ((): number | null => {
if (!p.hotspotId) return null;
const val = Number.parseInt(p.hotspotId, 10);
return val > 0 && val <= 2147483647 ? val : null;
})(),
unavailable: p.unavailable || false,
remark: p.remark || null,
modelCodes: p.modelCodes || null,
presel: p.presel || false,
price: p.price != null ? String(p.price) : null,
currency: p.price != null ? (p.currency ?? "EUR") : null,
source: "pl24" as const,
}));
dbParts = await this.db
.insert(parts)
.values(insertData)
.onConflictDoNothing()
.returning();
}
if (needImage) {
// PSA provides a pre-downloaded buffer (ticket URLs expire immediately)
if (pl24Result.schemaImageBuffer) {
try {
const ext = (pl24Result.schemaImageContentType || "image/png").includes("jpeg")
? "jpg"
: "png";
const fileName = `catalog/${catalogVehicleId}/${categoryId}.${ext}`;
const uploadedUrl = await this.storage.upload(
fileName,
pl24Result.schemaImageBuffer,
pl24Result.schemaImageContentType || "image/png",
);
const hotspotsData = {
width: pl24Result.schemaWidth || null,
height: pl24Result.schemaHeight || null,
items: pl24Result.hotspots || [],
};
const [inserted] = await this.db
.insert(schemaPics)
.values({
categoryId,
imageUrl: uploadedUrl,
hotspots: JSON.stringify(hotspotsData),
source: "pl24",
})
.returning();
pics.push(inserted);
} catch (imgErr) {
this.logger.warn(
`PSA image upload failed for ${categoryId}: ${(imgErr as Error).message}`,
);
}
} else if (pl24Result.schemaImageUrl) {
const imageResult = await this.pl24Service.getSchemaImage(
pl24Result.schemaImageUrl,
catalogVehicle.serviceName,
);
if (imageResult) {
const hotspotsData = {
width: imageResult.width || pl24Result.schemaWidth || null,
height: imageResult.height || pl24Result.schemaHeight || null,
items:
imageResult.hotspots.length > 0
? imageResult.hotspots
: pl24Result.hotspots || [],
};
const [inserted] = await this.db
.insert(schemaPics)
.values({
categoryId,
imageUrl: imageResult.imageUrl,
hotspots: JSON.stringify(hotspotsData),
source: "pl24",
})
.returning();
pics.push(inserted);
}
}
}
} catch (err) {
this.logger.error(
`Failed to fetch parts for category ${categoryId}: ${(err as Error).message}`,
);
}
}
// Parse hotspots
let hotspots: any[] = [];
let schemaWidth = 0;
let schemaHeight = 0;
if (pics.length > 0) {
const rawHotspots = pics[0].hotspots;
let parsed: any = rawHotspots;
if (typeof rawHotspots === "string") {
try {
parsed = JSON.parse(rawHotspots);
} catch {
parsed = [];
}
}
if (parsed && !Array.isArray(parsed) && parsed.items) {
schemaWidth = parsed.width || 0;
schemaHeight = parsed.height || 0;
hotspots = parsed.items || [];
} else if (Array.isArray(parsed)) {
hotspots = parsed;
}
}
const mappedHotspots = hotspots.flatMap(
(hs: {
key: string;
label?: string;
areas?: Array<{ left: number; top: number; width: number; height: number }>;
}) =>
(hs.areas || []).map((area, areaIdx) => ({
id: `hs-${hs.key}-${areaIdx}`,
key: hs.key,
group: Number.parseInt(hs.key, 10) || 0,
shape: "rect" as const,
coordinates: [area.left, area.top, area.width, area.height],
label: hs.label || hs.key,
})),
);
const mappedPics = pics.map((pic) => ({
id: pic.id,
url: pic.imageUrl,
width: schemaWidth,
height: schemaHeight,
label: category.name,
}));
return {
id: category.id,
name: category.name,
description: category.nameOriginal || null,
parentId: category.parentId || null,
parts: dbParts,
schemaPics: mappedPics,
hotspots: mappedHotspots,
};
}
/**
* Admin: explore a PL24 service to discover catalog structure.
* Tries various endpoint patterns and returns raw results.
*/
async exploreService(serviceName: string) {
const config = PL24_SERVICE_CATALOGS[serviceName];
if (!config) {
throw new NotFoundException(`Servis bulunamadı: ${serviceName}`);
}
this.logger.log(`Exploring PL24 service: ${serviceName} (${config.architecture})`);
const results: Record<string, any> = {
serviceName,
architecture: config.architecture,
basePath: config.basePath,
apiPath: config.apiPath,
endpoints: {},
};
if (isP5Modern(serviceName)) {
results.endpoints = await this.pl24Service.exploreP5Service(serviceName);
} else {
results.note = "Legacy architecture — manual exploration required";
}
return results;
}
// ─── Private helpers ────────────────────────────────────────────────────────
private extractPsaMeta(vehicle: typeof catalogVehicles.$inferSelect) {
const familyMatch = vehicle.catalogPath?.match(/::family=([^:]+)/);
const salesTypeMatch = vehicle.catalogPath?.match(/::salesType=([^:]+)/);
const meta = (vehicle.metadata || {}) as Record<string, string>;
return {
familyId: familyMatch?.[1] || "",
salesTypeId: salesTypeMatch?.[1] || "",
mode: meta.mode || "A0LW0TRTR",
upds: meta.upds || "",
};
}
private isP4FordLikeArch(arch: string | null): boolean {
return ["LEGACY_FORD", "LEGACY_VOLVO"].includes(arch ?? "");
}
private extractFordMeta(vehicle: typeof catalogVehicles.$inferSelect): {
familyId: string;
mode: string;
upds: string;
} {
const meta = (vehicle.metadata || {}) as Record<string, unknown>;
return {
familyId: (meta.familyId as string) || "",
mode: (meta.mode as string) || "",
upds: (meta.upds as string) || "",
};
}
/**
* Get model config (variant options) for a Ford LEGACY catalog vehicle.
* Returns { modelYears, engines, gearboxes } for the Ford variant selector UI.
*/
async getFordModelConfig(
vehicleId: string,
userId: string,
): Promise<{
modelYears: { code: string; name: string }[];
engines: { code: string; name: string }[];
gearboxes: { code: string; name: string }[];
}> {
const [vehicle] = await this.db
.select()
.from(catalogVehicles)
.where(eq(catalogVehicles.id, vehicleId))
.limit(1);
if (!vehicle) throw new NotFoundException("Katalog aracı bulunamadı");
if (vehicle.brandId) await this.checkBrandAccess(userId, vehicle.brandId);
if (!this.isP4FordLikeArch(vehicle.architecture))
return { modelYears: [], engines: [], gearboxes: [] };
const { familyId, mode, upds } = this.extractFordMeta(vehicle);
if (!familyId) return { modelYears: [], engines: [], gearboxes: [] };
if (vehicle.architecture === "LEGACY_VOLVO") {
return this.pl24Service.fetchVolvoModelConfig(vehicle.serviceName, familyId, mode, upds);
}
return this.pl24Service.fetchFordModelConfig(vehicle.serviceName, familyId, mode, upds);
}
private isLeafPath(linkPath: string): boolean {
const lower = linkPath.toLowerCase();
return (
lower.includes("/bom/") ||
lower.includes("/bomdetails") ||
lower.includes("/partinfo/") ||
// PL24 P5 leaf items endpoints — chemicals, servicepart, accessories,
// any /extern/<kind>/(vin|mdl)_items combination. These return parts,
// not subgroups, so they must short-circuit drill-down.
/\/extern\/[^/]+\/(vin_items|mdl_items)\b/.test(lower) ||
lower.includes("image-board.action") || // PSA illustration leaf
lower.includes("json-vin-bom-detail.action")
);
}
private async getBrandIdByName(brandName: string): Promise<string | null> {
const [brand] = await this.db
.select({ id: brands.id })
.from(brands)
.where(eq(brands.name, brandName))
.limit(1);
return brand?.id || null;
}
private async getAccessibleBrandIds(userId: string): Promise<Set<string>> {
const [sub] = await this.db
.select({ id: userSubscriptions.id, brandCount: plans.brandCount })
.from(userSubscriptions)
.innerJoin(plans, eq(userSubscriptions.planId, plans.id))
.where(
and(
eq(userSubscriptions.userId, userId),
or(eq(userSubscriptions.status, "active"), eq(userSubscriptions.status, "trial")),
),
)
.limit(1);
if (!sub) return new Set();
// Full plan (brandCount=0) → access to all brands
if (sub.brandCount === 0) {
const allBrands = await this.db
.select({ id: brands.id })
.from(brands)
.where(eq(brands.isActive, true));
return new Set(allBrands.map((b) => b.id));
}
const subBrands = await this.db
.select({ brandId: userBrands.brandId })
.from(userBrands)
.where(and(eq(userBrands.userId, userId), eq(userBrands.subscriptionId, sub.id)));
return new Set(subBrands.map((b) => b.brandId));
}
private async checkBrandAccess(userId: string, brandId: string): Promise<void> {
const [sub] = await this.db
.select({ id: userSubscriptions.id, brandCount: plans.brandCount })
.from(userSubscriptions)
.innerJoin(plans, eq(userSubscriptions.planId, plans.id))
.where(
and(
eq(userSubscriptions.userId, userId),
or(eq(userSubscriptions.status, "active"), eq(userSubscriptions.status, "trial")),
),
)
.limit(1);
if (!sub) {
throw new ForbiddenException(
"Aktif aboneliğiniz yok. Katalog verilerine erişmek için abone olun.",
);
}
if (sub.brandCount === 0) return;
const [access] = await this.db
.select()
.from(userBrands)
.where(
and(
eq(userBrands.userId, userId),
eq(userBrands.subscriptionId, sub.id),
eq(userBrands.brandId, brandId),
),
)
.limit(1);
if (!access) {
throw new ForbiddenException("Bu markaya erişim yok. Aboneliğinizi güncelleyin.");
}
}
private async enrichWithSchemaImages(cats: (typeof categories.$inferSelect)[]) {
const catIds = cats.map((c) => c.id);
if (catIds.length === 0) return cats;
const pics = await this.db
.select({ categoryId: schemaPics.categoryId, imageUrl: schemaPics.imageUrl })
.from(schemaPics)
.where(inArray(schemaPics.categoryId, catIds));
const picMap = new Map(pics.map((p) => [p.categoryId, p.imageUrl]));
const childCounts = await this.db
.select({
parentId: categories.parentId,
count: sql<number>`count(*)::int`,
})
.from(categories)
.where(inArray(categories.parentId, catIds))
.groupBy(categories.parentId);
const childCountMap = new Map(childCounts.map((r) => [r.parentId, r.count]));
return cats.map((c) => {
const dbChildCount = childCountMap.get(c.id) || 0;
const isLeaf =
c.linkPath?.includes("/bom/") ||
c.linkPath?.includes("/bomdetails") ||
c.linkPath?.includes("/partinfo/") ||
c.linkPath?.includes("/servicepart/vin_items") ||
c.linkPath?.includes("image-board.action") || // PSA illustration leaf
(!c.linkPath && dbChildCount === 0);
return {
...c,
schemaImageUrl: picMap.get(c.id) || null,
children: isLeaf ? [] : undefined,
};
});
}
/**
* Header/breadcrumb nav links that PL24's group.action HTML leaks into the
* category table as pseudo-categories. They are not real part groups and drill
* to nothing. Mirrors the filter in categories.service (VIN-decode path).
* Keeps Volvo's `vin-group.action?...group1=...` real categories.
*/
private isNavCrumbLink(linkPath?: string | null): boolean {
if (!linkPath) return false;
if (/(portal|logout)\.action/i.test(linkPath)) return true;
if (/vehicle\.action/i.test(linkPath)) return true;
if (linkPath.includes("vin-group.action") && !linkPath.includes("group1=")) return true;
// External absolute URL embedded anywhere that is not a partslink .action
// endpoint. Nissan's "Repair & Maintenance Information" comes back as
// `/nissan/nissan_parts/https://eu.nissan.biz/` — the basePath prefix means an
// anchored ^https check misses it, so match `://` anywhere. Real PL24 linkPaths
// are relative (group.action / json-*.action / pcat: / emex:) and never contain `://`.
if (linkPath.includes("://") && !/\.action(\?|$)/i.test(linkPath)) return true;
return false;
}
/**
* Persist freshly-fetched PL24 legacy main groups (Ford/Volvo/Opel/Hyundai-Kia/
* Nissan) and return the tree built FROM the DB rows — so every node id is a real
* UUID, never a raw upstream linkPath (which would 404 on drill). Filters nav-crumb
* junk and dedupes by name first (matching the PSA block's discipline), so
* onConflictDoNothing can't silently drop a colliding row and leak a raw-id node.
*/
private async persistAndBuildLegacyTree(
catalogVehicleId: string,
pl24Categories: PL24DecodedCategory[],
cacheKey: string,
): Promise<any[]> {
const seenNames = new Set<string>();
const clean = pl24Categories.filter((c) => {
if (this.isNavCrumbLink(c.linkPath)) return false;
const name = c.nameTr || c.nameEn;
if (!name || seenNames.has(name)) return false;
seenNames.add(name);
return true;
});
if (clean.length === 0) return [];
let dbCategories = await this.db
.select()
.from(categories)
.where(eq(categories.catalogVehicleId, catalogVehicleId))
.orderBy(categories.createdAt, categories.id);
if (dbCategories.length === 0) {
await this.db
.insert(categories)
.values(
clean.map((c) => ({
catalogVehicleId,
vehicleId: null as string | null,
name: c.nameTr || c.nameEn,
nameOriginal: c.nameEn,
parentId: null as string | null,
externalId: c.code,
linkPath: c.linkPath || null,
linkWid: c.linkWid || null,
source: "pl24" as const,
})),
)
.onConflictDoNothing();
// Always re-select (never trust .returning(): onConflictDoNothing drops
// name-collision rows, which would otherwise leave gaps that fall back to
// raw-path ids). Deterministic order so the tree is stable across rebuilds.
dbCategories = await this.db
.select()
.from(categories)
.where(eq(categories.catalogVehicleId, catalogVehicleId))
.orderBy(categories.createdAt, categories.id);
await this.db
.update(catalogVehicles)
.set({ categoriesFetched: true, updatedAt: new Date() })
.where(eq(catalogVehicles.id, catalogVehicleId));
}
// Filter on read too — drops nav-crumb junk that was persisted before this
// fix (e.g. existing Hyundai "Portal" / Nissan eu.nissan.biz rows), so the
// response self-heals without a DB migration.
const tree = this.buildTree(dbCategories.filter((c) => !this.isNavCrumbLink(c.linkPath)));
await this.redis.setJson(cacheKey, tree, 7200);
return tree;
}
private buildTree(items: any[]): any[] {
const map = new Map<string, any>();
const roots: any[] = [];
for (const item of items) {
map.set(item.id, { ...item, children: [] });
}
for (const item of items) {
const node = map.get(item.id);
if (!node) continue;
if (item.parentId && map.has(item.parentId)) {
map.get(item.parentId)?.children.push(node);
} else {
roots.push(node);
}
}
for (const node of map.values()) {
if (node.children.length === 0) {
node.children = undefined;
}
}
return roots;
}
}