fix(catalog): build legacy category tree from DB rows (kill raw-path ids)

LEGACY_OPEL/HYUNDAI_KIA/NISSAN/FORD/VOLVO getCategoryTree built the
returned tree from the freshly-parsed PL24 list with
`externalIdToUuid.get(c.code) ?? c.code`, falling back to the RAW upstream
linkPath as the node id whenever a row wasn't in the map. That happened
because the two legacy blocks (unlike the PSA block) did not dedupe by
name, so onConflictDoNothing silently dropped name-colliding rows; those
nodes then leaked a raw `group.action?catId=…` id, which the frontend
turned into `/categories/group.action?...` → 404 "Bir hata oluştu".

Extract the duplicated build/persist into persistAndBuildLegacyTree which:
filters nav-crumb junk + dedupes by name, always re-selects after insert
(never trusts .returning()), and builds the tree FROM the DB rows so every
node id is a real UUID. Deterministic ORDER BY for stable tree order. PSA
block left untouched (it intentionally supports non-UUID PSA codes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 14:24:21 +03:00
parent f83973c628
commit 8e804394a3

View File

@@ -13,6 +13,7 @@ import {
} from "../database/schema/core";
import { PL24Service } from "../integrations/pl24/pl24.service";
import {
type PL24DecodedCategory,
PL24_SERVICE_CATALOGS,
SERVICE_DISPLAY_NAMES,
SERVICE_TO_BRAND,
@@ -497,58 +498,12 @@ export class CatalogService {
);
if (pl24Categories.length > 0) {
let dbCategories = await this.db
.select()
.from(categories)
.where(eq(categories.catalogVehicleId, catalogVehicleId));
if (dbCategories.length === 0) {
const insertData = pl24Categories.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));
}
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",
})),
const tree = await this.persistAndBuildLegacyTree(
catalogVehicleId,
pl24Categories,
cacheKey,
);
await this.redis.setJson(cacheKey, tree, 7200);
return tree;
if (tree.length > 0) return tree;
}
}
} catch (err) {
@@ -600,59 +555,12 @@ export class CatalogService {
}
if (pl24Categories.length > 0) {
// Persist _all_ variant to DB as canonical records
let dbCategories = await this.db
.select()
.from(categories)
.where(eq(categories.catalogVehicleId, catalogVehicleId));
if (dbCategories.length === 0) {
const insertData = pl24Categories.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));
}
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",
})),
const tree = await this.persistAndBuildLegacyTree(
catalogVehicleId,
pl24Categories,
cacheKey,
);
await this.redis.setJson(cacheKey, tree, 7200);
return tree;
if (tree.length > 0) return tree;
}
}
} catch (err) {
@@ -1321,6 +1229,87 @@ export class CatalogService {
});
}
/**
* 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 that is not a partslink .action endpoint
// (e.g. Nissan "Repair & Maintenance Information" → https://eu.nissan.biz/).
if (/^https?:\/\//i.test(linkPath) && !/\.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));
}
const tree = this.buildTree(dbCategories);
await this.redis.setJson(cacheKey, tree, 7200);
return tree;
}
private buildTree(items: any[]): any[] {
const map = new Map<string, any>();
const roots: any[] = [];