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:
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user