diff --git a/apps/api/src/catalog/catalog-browse-heal.spec.ts b/apps/api/src/catalog/catalog-browse-heal.spec.ts index 57f1be2..2abf261 100644 --- a/apps/api/src/catalog/catalog-browse-heal.spec.ts +++ b/apps/api/src/catalog/catalog-browse-heal.spec.ts @@ -81,6 +81,7 @@ function makeService(opts: { redis as never, pl24Service as never, {} as never, + { isEnabled: () => false, fetchAndStoreParts: vi.fn().mockResolvedValue(null) } as any, ) as never as { healStaleBrowseRows: (brand: string, rows: Row[], where: unknown[]) => Promise; }; diff --git a/apps/api/src/catalog/catalog.module.ts b/apps/api/src/catalog/catalog.module.ts index 185e54b..04567a2 100644 --- a/apps/api/src/catalog/catalog.module.ts +++ b/apps/api/src/catalog/catalog.module.ts @@ -1,7 +1,9 @@ import { Module } from "@nestjs/common"; +import { CarcatonlineCatalogService } from "../integrations/carcatonline/carcatonline-catalog.service"; import { PL24Module } from "../integrations/pl24/pl24.module"; import { StorageModule } from "../storage/storage.module"; import { SubscriptionsModule } from "../subscriptions/subscriptions.module"; +import { TranslationsModule } from "../translations/translations.module"; import { CatalogController } from "./catalog.controller"; import { CatalogService } from "./catalog.service"; import { EmexCatalogController } from "./emex-catalog.controller"; @@ -10,9 +12,9 @@ import { PcatCatalogController } from "./pcat-catalog.controller"; import { PcatCatalogService } from "./pcat-catalog.service"; @Module({ - imports: [PL24Module, SubscriptionsModule, StorageModule], + imports: [PL24Module, SubscriptionsModule, StorageModule, TranslationsModule], controllers: [CatalogController, EmexCatalogController, PcatCatalogController], - providers: [CatalogService, EmexCatalogService, PcatCatalogService], + providers: [CatalogService, EmexCatalogService, PcatCatalogService, CarcatonlineCatalogService], exports: [CatalogService, EmexCatalogService, PcatCatalogService], }) export class CatalogModule {} diff --git a/apps/api/src/catalog/catalog.service.ts b/apps/api/src/catalog/catalog.service.ts index 35e3ebb..0908b22 100644 --- a/apps/api/src/catalog/catalog.service.ts +++ b/apps/api/src/catalog/catalog.service.ts @@ -19,6 +19,7 @@ import { userBrands, userSubscriptions, } from "../database/schema/core"; +import { CarcatonlineCatalogService } from "../integrations/carcatonline/carcatonline-catalog.service"; import { isPl24LeafNode, isStaleBrowseArchitecture } from "../integrations/pl24/pl24-tree"; import { PL24Service } from "../integrations/pl24/pl24.service"; import { @@ -40,6 +41,7 @@ export class CatalogService { private redis: RedisService, private pl24Service: PL24Service, private storage: StorageService, + private carcatonline: CarcatonlineCatalogService, ) {} /** @@ -1021,7 +1023,9 @@ export class CatalogService { }; } - if (linkPath && !this.isLeafPath(linkPath)) { + // carcatonline trees are inserted in full by the night backfill; a node + // without DB children is a leaf (parts below) — never send its link to PL24. + if (linkPath && category.source !== "carcatonline" && !this.isLeafPath(linkPath)) { // Try to fetch subgroups const subGroups = await this.pl24Service.fetchSubGroupsByPath( linkPath, @@ -1208,6 +1212,18 @@ export class CatalogService { `Failed to fetch parts for category ${categoryId}: ${(err as Error).message}`, ); } + } else if ((needParts || needImage) && category.source === "carcatonline") { + // Leaf seeded by the carcatonline backfill: fetch its parts + plate image on + // demand (one paced upstream call, shared lockout/budget with the worker). + const fetched = await this.carcatonline.fetchAndStoreParts({ + id: categoryId, + catalogVehicleId, + linkPath, + }); + if (fetched) { + if (needParts && fetched.parts.length > 0) dbParts = fetched.parts; + if (needImage && fetched.pic) pics.push(fetched.pic); + } } // Parse hotspots diff --git a/apps/api/src/integrations/carcatonline/carcatonline-catalog.service.spec.ts b/apps/api/src/integrations/carcatonline/carcatonline-catalog.service.spec.ts new file mode 100644 index 0000000..86cd919 --- /dev/null +++ b/apps/api/src/integrations/carcatonline/carcatonline-catalog.service.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { mapCarcatParts, parseCarcatLinkPath } from "./carcatonline-catalog.service"; +import type { CarcatPartsResponse } from "./carcatonline.client"; + +describe("parseCarcatLinkPath", () => { + it("parses the carcat::: link path and rejects PL24 paths", () => { + expect(parseCarcatLinkPath("carcat:pl_renault:pl_0bb9:pl_3f0b")).toEqual({ + catalogId: "pl_renault", + carId: "pl_0bb9", + groupId: "pl_3f0b", + }); + expect(parseCarcatLinkPath("/p5renault/extern/details/bomDetails?catalog=XFE")).toBeNull(); + expect(parseCarcatLinkPath(null)).toBeNull(); + }); +}); + +describe("mapCarcatParts", () => { + const payload: CarcatPartsResponse = { + img: "static/images/renault/01063629.jpeg", + partGroups: [ + { + name: "Pedals", + parts: [ + { + id: "1", + number: "03L 100 032 T", + name: "COVER-PEDAL", + positionNumber: "1", + description: { + qty: "\r\n\r\n 1", + remark: "1.6ltr.", + restriction: "+ FAMILY = KADJAR\n", + }, + }, + { id: "2", number: "253003RA0A", name: "SW ASSY-ASCD CANCEL", positionNumber: "10" }, + { id: "3", number: "", name: "", positionNumber: "99" }, + ], + }, + ], + positions: [ + { number: "1", coordinates: [342, 415, 15, 23] }, + { number: "10", coordinates: [100, 50, 12, 12] }, + { number: "bad", coordinates: [1] }, + ], + }; + + it("normalises OE codes, keeps originals, translates names and builds hotspots", () => { + const tr = (raw: string) => (raw === "COVER-PEDAL" ? "PEDAL KAPAĞI" : raw); + const out = mapCarcatParts(payload, { catalogVehicleId: "cv", categoryId: "cat" }, tr); + expect(out.rows).toHaveLength(2); // the empty part is dropped + expect(out.rows[0]).toMatchObject({ + catalogVehicleId: "cv", + categoryId: "cat", + oemCode: "03L100032T", + name: "PEDAL KAPAĞI", + nameOriginal: "COVER-PEDAL", + quantity: 1, + position: "1", + hotspotIndex: 1, + remark: "1.6ltr. | + FAMILY = KADJAR", + source: "carcatonline", + }); + expect(out.rows[1]).toMatchObject({ + oemCode: "253003RA0A", + name: "SW ASSY-ASCD CANCEL", + hotspotIndex: 10, + }); + expect(out.imageUrl).toBe("https://api.carcatonline.com/static/images/renault/01063629.jpeg"); + expect(out.hotspots.items).toEqual([ + { key: "1", areas: [{ left: 342, top: 415, width: 15, height: 23 }] }, + { key: "10", areas: [{ left: 100, top: 50, width: 12, height: 12 }] }, + ]); + }); +}); diff --git a/apps/api/src/integrations/carcatonline/carcatonline-catalog.service.ts b/apps/api/src/integrations/carcatonline/carcatonline-catalog.service.ts new file mode 100644 index 0000000..cc60633 --- /dev/null +++ b/apps/api/src/integrations/carcatonline/carcatonline-catalog.service.ts @@ -0,0 +1,188 @@ +import { Inject, Injectable, Logger } from "@nestjs/common"; +import { DATABASE, type Database } from "../../database/database.provider"; +import { parts, schemaPics } from "../../database/schema/core"; +import { RedisService } from "../../redis/redis.service"; +import { TranslationsService } from "../../translations/translations.service"; +import { + type CarcatPartsResponse, + CarcatonlineClient, + CarcatonlineRateLimitError, + carcatImageUrl, +} from "./carcatonline.client"; +import { + CarcatonlineThrottle, + type RedisLike, + carcatConfigFromEnv, + redisTokenStore, +} from "./carcatonline.pacing"; + +/** `categories.link_path` for carcatonline nodes: `carcat:::`. */ +export function parseCarcatLinkPath(linkPath: string | null | undefined): { + catalogId: string; + carId: string; + groupId: string; +} | null { + if (!linkPath) return null; + const m = linkPath.match(/^carcat:([^:]+):([^:]+):([^:]+)$/); + return m ? { catalogId: m[1], carId: m[2], groupId: m[3] } : null; +} + +/** Map a parts2 payload to our `parts` rows and `schema_pics` hotspots. */ +export function mapCarcatParts( + payload: CarcatPartsResponse, + ctx: { catalogVehicleId: string; categoryId: string }, + translate: (raw: string) => string, +) { + const rows: (typeof parts.$inferInsert)[] = []; + for (const group of payload.partGroups ?? []) { + for (const p of group.parts ?? []) { + if (!p.number && !p.name) continue; + const clean = (s: string | undefined | null): string => + (s ?? "") + .replace(/\r\n|\r|\n/g, " ") + .replace(/\s+/g, " ") + .trim(); + const rawName = clean(p.name) || clean(group.name) || "—"; + const remarkBits = [clean(p.description?.remark), clean(p.description?.restriction)].filter( + Boolean, + ); + const qtyText = clean(p.description?.qty); + const qty = qtyText ? Number.parseInt(qtyText, 10) : Number.NaN; + const hotspot = p.positionNumber ? Number.parseInt(p.positionNumber, 10) : Number.NaN; + rows.push({ + catalogVehicleId: ctx.catalogVehicleId, + vehicleId: null, + categoryId: ctx.categoryId, + oemCode: (p.number || "N/A").replace(/\s+/g, "").toUpperCase(), + name: translate(rawName), + nameOriginal: rawName, + description: clean(p.description?.modelDescription) || clean(p.notice) || null, + quantity: Number.isFinite(qty) && qty > 0 ? qty : null, + position: p.positionNumber || null, + hotspotIndex: + Number.isFinite(hotspot) && hotspot > 0 && hotspot <= 2147483647 ? hotspot : null, + unavailable: false, + remark: remarkBits.length ? remarkBits.join(" | ").slice(0, 2000) : null, + modelCodes: null, + presel: false, + price: null, + currency: null, + source: "carcatonline", + }); + } + } + const hotspots = { + width: null as number | null, + height: null as number | null, + items: (payload.positions ?? []) + .filter((pos) => Array.isArray(pos.coordinates) && pos.coordinates.length >= 4) + .map((pos) => ({ + key: pos.number, + areas: [ + { + left: pos.coordinates[0], + top: pos.coordinates[1], + width: pos.coordinates[2], + height: pos.coordinates[3], + }, + ], + })), + }; + return { rows, imageUrl: carcatImageUrl(payload.img), hotspots }; +} + +/** + * API-side carcatonline access: on-demand parts for a category that the night + * backfill seeded. Shares the Redis token / lockout / daily-budget / pacing with + * the worker, so a user click never bursts the upstream API. + */ +@Injectable() +export class CarcatonlineCatalogService { + private readonly logger = new Logger(CarcatonlineCatalogService.name); + private client: CarcatonlineClient | null = null; + + constructor( + @Inject(DATABASE) private readonly db: Database, + private readonly redis: RedisService, + private readonly translations: TranslationsService, + ) {} + + isEnabled(): boolean { + return process.env.CARCATONLINE_ENABLED === "true" && !!process.env.CARCATONLINE_EMAIL; + } + + private getClient(): CarcatonlineClient { + if (!this.client) { + const email = process.env.CARCATONLINE_EMAIL; + const password = process.env.CARCATONLINE_PASSWORD; + if (!email || !password) throw new Error("carcatonline credentials are not configured"); + this.client = new CarcatonlineClient( + { + email, + password, + logger: { log: (m) => this.logger.log(m), warn: (m) => this.logger.warn(m) }, + }, + redisTokenStore(this.redis.getClient() as unknown as RedisLike), + ); + } + return this.client; + } + + /** + * Fetch + persist parts and the schema image for one carcatonline leaf. + * Returns null (and logs) on any upstream problem so the catalog page still renders. + */ + async fetchAndStoreParts(category: { + id: string; + catalogVehicleId: string | null; + linkPath: string | null; + }): Promise<{ + parts: (typeof parts.$inferSelect)[]; + pic: typeof schemaPics.$inferSelect | null; + } | null> { + if (!this.isEnabled() || !category.catalogVehicleId) return null; + const ref = parseCarcatLinkPath(category.linkPath); + if (!ref) return null; + const redis = this.redis.getClient() as unknown as RedisLike; + const throttle = new CarcatonlineThrottle(redis, carcatConfigFromEnv()); + try { + await throttle.beforeCall(); + const payload = await this.getClient().parts(ref.catalogId, ref.carId, ref.groupId); + const rawNames = [ + ...new Set((payload.partGroups ?? []).flatMap((g) => g.parts.map((p) => p.name || g.name))), + ]; + const trMap = await this.translations.translateMany(rawNames.filter(Boolean)); + const mapped = mapCarcatParts( + payload, + { catalogVehicleId: category.catalogVehicleId, categoryId: category.id }, + (raw) => trMap.get(raw) ?? raw, + ); + const insertedParts = mapped.rows.length + ? await this.db.insert(parts).values(mapped.rows).onConflictDoNothing().returning() + : []; + let pic: typeof schemaPics.$inferSelect | null = null; + if (mapped.imageUrl) { + [pic] = await this.db + .insert(schemaPics) + .values({ + categoryId: category.id, + imageUrl: mapped.imageUrl, + hotspots: JSON.stringify(mapped.hotspots), + source: "carcatonline", + }) + .returning(); + } + return { parts: insertedParts, pic }; + } catch (err) { + if (err instanceof CarcatonlineRateLimitError) { + await throttle.markLockout(); + this.logger.warn(`carcatonline rate-limited on ${ref.groupId} — lockout marked`); + return null; + } + this.logger.warn( + `carcatonline parts fetch failed for ${category.id}: ${(err as Error).message}`, + ); + return null; + } + } +} diff --git a/apps/api/src/integrations/carcatonline/carcatonline.client.spec.ts b/apps/api/src/integrations/carcatonline/carcatonline.client.spec.ts new file mode 100644 index 0000000..56caf0a --- /dev/null +++ b/apps/api/src/integrations/carcatonline/carcatonline.client.spec.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from "vitest"; +import { + CarcatonlineAuthError, + CarcatonlineClient, + CarcatonlineHttpError, + CarcatonlineRateLimitError, + carcatImageUrl, + loginCarcatonline, +} from "./carcatonline.client"; + +const b64url = (s: string) => Buffer.from(s).toString("base64url"); +const jwt = (exp: number) => + `${b64url('{"alg":"HS256"}')}.${b64url(JSON.stringify({ sub: "e@x", exp }))}.sig`; + +describe("loginCarcatonline", () => { + it("posts the htmx login form, replays the session cookie and extracts the widget token", async () => { + const exp = Math.floor(Date.now() / 1000) + 180 * 86400; + const calls: { url: string; init?: RequestInit }[] = []; + const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => { + calls.push({ url: String(url), init }); + if (String(url).endsWith("/login")) { + return new Response("
ok
", { + status: 200, + headers: { "set-cookie": 'session="abc"; HttpOnly; Path=/' }, + }); + } + return new Response( + `