style: apply biome safe auto-fixes

Run 'biome check --fix' on apps/api/src and apps/web/src to clear
the safe-fixable lint backlog (151 files: parseInt → Number.parseInt,
isNaN → Number.isNaN, organize imports, etc.). 769 errors remain
that require manual changes (mostly noExplicitAny).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-05-09 16:13:56 +00:00
parent ca2798b5d1
commit 3184e4c619
181 changed files with 3739 additions and 3713 deletions

View File

@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common"; import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
import { AdminService } from "./admin.service";
import { Roles } from "../common/decorators/roles.decorator"; import { Roles } from "../common/decorators/roles.decorator";
import type { AdminService } from "./admin.service";
@Controller("admin") @Controller("admin")
@Roles("admin") @Roles("admin")
@@ -27,8 +27,8 @@ export class AdminController {
) { ) {
return this.adminService.getUsers( return this.adminService.getUsers(
search, search,
page ? parseInt(page, 10) : 1, page ? Number.parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20, limit ? Number.parseInt(limit, 10) : 20,
); );
} }
@@ -49,8 +49,8 @@ export class AdminController {
@Query("userId") userId?: string, @Query("userId") userId?: string,
) { ) {
return this.adminService.getQueryLogs( return this.adminService.getQueryLogs(
page ? parseInt(page, 10) : 1, page ? Number.parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 50, limit ? Number.parseInt(limit, 10) : 50,
userId, userId,
); );
} }
@@ -63,8 +63,8 @@ export class AdminController {
) { ) {
return this.adminService.getReferrals( return this.adminService.getReferrals(
search, search,
page ? parseInt(page, 10) : 1, page ? Number.parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20, limit ? Number.parseInt(limit, 10) : 20,
); );
} }
@@ -80,8 +80,8 @@ export class AdminController {
@Query("userId") userId?: string, @Query("userId") userId?: string,
) { ) {
return this.adminService.getCopyLogs( return this.adminService.getCopyLogs(
page ? parseInt(page, 10) : 1, page ? Number.parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 50, limit ? Number.parseInt(limit, 10) : 50,
userId, userId,
); );
} }
@@ -92,8 +92,8 @@ export class AdminController {
@Query("limit") limit?: string, @Query("limit") limit?: string,
) { ) {
return this.adminService.getTopCopiedCodes( return this.adminService.getTopCopiedCodes(
days ? parseInt(days, 10) : 30, days ? Number.parseInt(days, 10) : 30,
limit ? parseInt(limit, 10) : 20, limit ? Number.parseInt(limit, 10) : 20,
); );
} }
} }

View File

@@ -1,7 +1,7 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { AnalyticsModule } from "../analytics/analytics.module";
import { AdminController } from "./admin.controller"; import { AdminController } from "./admin.controller";
import { AdminService } from "./admin.service"; import { AdminService } from "./admin.service";
import { AnalyticsModule } from "../analytics/analytics.module";
@Module({ @Module({
imports: [AnalyticsModule], imports: [AnalyticsModule],

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NotFoundException } from "@nestjs/common"; import { NotFoundException } from "@nestjs/common";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AdminService } from "./admin.service"; import { AdminService } from "./admin.service";
describe("AdminService", () => { describe("AdminService", () => {
@@ -174,7 +174,9 @@ describe("AdminService", () => {
describe("getPendingPayments", () => { describe("getPendingPayments", () => {
it("should return pending EFT payments with user info", async () => { it("should return pending EFT payments with user info", async () => {
const pending = [{ id: "pay-1", userId: "u1", userName: "Ali", method: "eft", status: "pending" }]; const pending = [
{ id: "pay-1", userId: "u1", userName: "Ali", method: "eft", status: "pending" },
];
// select().from().innerJoin().where().orderBy() — orderBy terminal // select().from().innerJoin().where().orderBy() — orderBy terminal
const c: Record<string, any> = {}; const c: Record<string, any> = {};
c.from = vi.fn().mockReturnValue(c); c.from = vi.fn().mockReturnValue(c);

View File

@@ -5,20 +5,20 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { generateReferralCode } from "@sase/shared";
import { hashPassword } from "better-auth/crypto";
import { and, count, desc, eq, gte, ilike, inArray, or, sql } from "drizzle-orm"; import { and, count, desc, eq, gte, ilike, inArray, or, sql } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider"; import type { AnalyticsService } from "../analytics/analytics.service";
import { DATABASE, type Database } from "../database/database.provider";
import { import {
users,
accounts, accounts,
userSubscriptions, brands,
payments, payments,
queryLogs, queryLogs,
brands,
referrals, referrals,
userSubscriptions,
users,
} from "../database/schema/core"; } from "../database/schema/core";
import { hashPassword } from "better-auth/crypto";
import { generateReferralCode } from "@sase/shared";
import { AnalyticsService } from "../analytics/analytics.service";
@Injectable() @Injectable()
export class AdminService { export class AdminService {

View File

@@ -1,6 +1,6 @@
import { Body, Controller, Post } from "@nestjs/common"; import { Body, Controller, Post } from "@nestjs/common";
import { AnalyticsService } from "./analytics.service";
import { CurrentUser } from "../common/decorators/current-user.decorator"; import { CurrentUser } from "../common/decorators/current-user.decorator";
import type { AnalyticsService } from "./analytics.service";
@Controller("analytics") @Controller("analytics")
export class AnalyticsController { export class AnalyticsController {

View File

@@ -1,6 +1,6 @@
import { Inject, Injectable, Logger } from "@nestjs/common"; import { Inject, Injectable, Logger } from "@nestjs/common";
import { count, desc, eq, gte, sql } from "drizzle-orm"; import { count, desc, eq, gte, sql } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider"; import { DATABASE, type Database } from "../database/database.provider";
import { oemCodeCopies, users } from "../database/schema/core"; import { oemCodeCopies, users } from "../database/schema/core";
@Injectable() @Injectable()

View File

@@ -1,37 +1,37 @@
import { resolve } from "path";
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config"; import { ConfigModule } from "@nestjs/config";
import { resolve } from "path";
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core"; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler"; import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
import { AdminModule } from "./admin/admin.module";
import { AnalyticsModule } from "./analytics/analytics.module";
import { AuthModule } from "./auth/auth.module";
import { BrandsModule } from "./brands/brands.module";
import { CatalogModule } from "./catalog/catalog.module";
import { CategoriesModule } from "./categories/categories.module";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
import { AuthGuard } from "./common/guards/auth.guard";
import { RolesGuard } from "./common/guards/roles.guard";
import { LoggingInterceptor } from "./common/interceptors/logging.interceptor";
import { TimeoutInterceptor } from "./common/interceptors/timeout.interceptor";
import { TransformInterceptor } from "./common/interceptors/transform.interceptor";
import configuration from "./config/configuration"; import configuration from "./config/configuration";
import { validate } from "./config/env.validation"; import { validate } from "./config/env.validation";
import { DatabaseModule } from "./database/database.module"; import { DatabaseModule } from "./database/database.module";
import { RedisModule } from "./redis/redis.module";
import { AuthModule } from "./auth/auth.module";
import { UsersModule } from "./users/users.module";
import { EmailModule } from "./email/email.module"; import { EmailModule } from "./email/email.module";
import { BrandsModule } from "./brands/brands.module";
import { PlansModule } from "./plans/plans.module";
import { SubscriptionsModule } from "./subscriptions/subscriptions.module";
import { PaymentsModule } from "./payments/payments.module";
import { StorageModule } from "./storage/storage.module";
import { ReferralsModule } from "./referrals/referrals.module";
import { VehiclesModule } from "./vehicles/vehicles.module";
import { CategoriesModule } from "./categories/categories.module";
import { PartsModule } from "./parts/parts.module";
import { JobsModule } from "./jobs/jobs.module";
import { EmexModule } from "./integrations/emex/emex.module";
import { TranslationsModule } from "./translations/translations.module";
import { AdminModule } from "./admin/admin.module";
import { AnalyticsModule } from "./analytics/analytics.module";
import { CatalogModule } from "./catalog/catalog.module";
import { HealthController } from "./health.controller"; import { HealthController } from "./health.controller";
import { AuthGuard } from "./common/guards/auth.guard"; import { EmexModule } from "./integrations/emex/emex.module";
import { RolesGuard } from "./common/guards/roles.guard"; import { JobsModule } from "./jobs/jobs.module";
import { TransformInterceptor } from "./common/interceptors/transform.interceptor"; import { PartsModule } from "./parts/parts.module";
import { LoggingInterceptor } from "./common/interceptors/logging.interceptor"; import { PaymentsModule } from "./payments/payments.module";
import { TimeoutInterceptor } from "./common/interceptors/timeout.interceptor"; import { PlansModule } from "./plans/plans.module";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter"; import { RedisModule } from "./redis/redis.module";
import { ReferralsModule } from "./referrals/referrals.module";
import { StorageModule } from "./storage/storage.module";
import { SubscriptionsModule } from "./subscriptions/subscriptions.module";
import { TranslationsModule } from "./translations/translations.module";
import { UsersModule } from "./users/users.module";
import { VehiclesModule } from "./vehicles/vehicles.module";
@Module({ @Module({
imports: [ imports: [

View File

@@ -1,8 +1,8 @@
import { All, Controller, Req, Res } from "@nestjs/common"; import { All, Controller, Req, Res } from "@nestjs/common";
import { Request, Response } from "express";
import { getAuth } from "./auth";
import { toNodeHandler } from "better-auth/node"; import { toNodeHandler } from "better-auth/node";
import type { Request, Response } from "express";
import { Public } from "../common/decorators/public.decorator"; import { Public } from "../common/decorators/public.decorator";
import { getAuth } from "./auth";
@Controller("auth") @Controller("auth")
export class AuthController { export class AuthController {

View File

@@ -1,9 +1,9 @@
import { Module, OnModuleInit } from "@nestjs/common"; import { Module, type OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import type { ConfigService } from "@nestjs/config";
import type { EmailService } from "../email/email.service";
import { createAuth } from "./auth";
import { AuthController } from "./auth.controller"; import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { EmailService } from "../email/email.service";
import { createAuth } from "./auth";
@Module({ @Module({
controllers: [AuthController], controllers: [AuthController],

View File

@@ -1,11 +1,11 @@
import { randomUUID } from "crypto";
import { generateReferralCode } from "@sase/shared";
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { drizzle } from "drizzle-orm/postgres-js"; import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres"; import postgres from "postgres";
import { randomUUID } from "crypto";
import * as schema from "../database/schema/core"; import * as schema from "../database/schema/core";
import type { EmailService } from "../email/email.service"; import type { EmailService } from "../email/email.service";
import { generateReferralCode } from "@sase/shared";
let authInstance: ReturnType<typeof betterAuth> | null = null; let authInstance: ReturnType<typeof betterAuth> | null = null;
@@ -19,7 +19,12 @@ interface AuthOptions {
emailService?: EmailService; emailService?: EmailService;
} }
export function createAuth(databaseUrl: string, secret: string, baseUrl: string, options?: AuthOptions) { export function createAuth(
databaseUrl: string,
secret: string,
baseUrl: string,
options?: AuthOptions,
) {
if (authInstance) return authInstance; if (authInstance) return authInstance;
const client = postgres(databaseUrl, { max: 5 }); const client = postgres(databaseUrl, { max: 5 });

View File

@@ -1,8 +1,8 @@
import { Controller, Get, Post, Patch, Param, Body, UseGuards } from "@nestjs/common"; import { Body, Controller, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
import { BrandsService } from "./brands.service";
import { Public } from "../common/decorators/public.decorator"; import { Public } from "../common/decorators/public.decorator";
import { Roles } from "../common/decorators/roles.decorator"; import { Roles } from "../common/decorators/roles.decorator";
import { RolesGuard } from "../common/guards/roles.guard"; import { RolesGuard } from "../common/guards/roles.guard";
import type { BrandsService } from "./brands.service";
@Controller("brands") @Controller("brands")
export class BrandsController { export class BrandsController {

View File

@@ -1,13 +1,22 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NotFoundException } from "@nestjs/common"; import { NotFoundException } from "@nestjs/common";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BrandsService } from "./brands.service"; import { BrandsService } from "./brands.service";
function createMockDb(overrides: Record<string, unknown> = {}) { function createMockDb(overrides: Record<string, unknown> = {}) {
function chainable(terminalValue: unknown) { function chainable(terminalValue: unknown) {
const chain: Record<string, unknown> = {}; const chain: Record<string, unknown> = {};
const methods = [ const methods = [
"select", "from", "where", "orderBy", "limit", "offset", "select",
"insert", "values", "update", "set", "returning", "from",
"where",
"orderBy",
"limit",
"offset",
"insert",
"values",
"update",
"set",
"returning",
]; ];
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain); for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
chain.limit = vi.fn().mockReturnValue(terminalValue); chain.limit = vi.fn().mockReturnValue(terminalValue);
@@ -41,7 +50,10 @@ describe("BrandsService", () => {
}); });
it("should return all brands when activeOnly is false", async () => { it("should return all brands when activeOnly is false", async () => {
const brands = [{ id: "b1", name: "BMW" }, { id: "b2", name: "Audi" }]; const brands = [
{ id: "b1", name: "BMW" },
{ id: "b2", name: "Audi" },
];
// When activeOnly=false, chain is: select().from().orderBy() — no where // When activeOnly=false, chain is: select().from().orderBy() — no where
const chain: Record<string, any> = { const chain: Record<string, any> = {
from: vi.fn().mockReturnThis(), from: vi.fn().mockReturnThis(),

View File

@@ -1,6 +1,6 @@
import { Inject, Injectable, NotFoundException } from "@nestjs/common"; import { Inject, Injectable, NotFoundException } from "@nestjs/common";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider"; import { DATABASE, type Database } from "../database/database.provider";
import { brands } from "../database/schema/core"; import { brands } from "../database/schema/core";
@Injectable() @Injectable()

View File

@@ -1,7 +1,7 @@
import { Controller, Get, Param, Post, Query } from "@nestjs/common"; import { Controller, Get, Param, Post, Query } from "@nestjs/common";
import { CurrentUser } from "../common/decorators/current-user.decorator"; import { CurrentUser } from "../common/decorators/current-user.decorator";
import { Roles } from "../common/decorators/roles.decorator"; import { Roles } from "../common/decorators/roles.decorator";
import { CatalogService } from "./catalog.service"; import type { CatalogService } from "./catalog.service";
@Controller("catalog") @Controller("catalog")
export class CatalogController { export class CatalogController {

View File

@@ -1,9 +1,9 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { PL24Module } from "../integrations/pl24/pl24.module";
import { StorageModule } from "../storage/storage.module";
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
import { CatalogController } from "./catalog.controller"; import { CatalogController } from "./catalog.controller";
import { CatalogService } from "./catalog.service"; import { CatalogService } from "./catalog.service";
import { PL24Module } from "../integrations/pl24/pl24.module";
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
import { StorageModule } from "../storage/storage.module";
@Module({ @Module({
imports: [PL24Module, SubscriptionsModule, StorageModule], imports: [PL24Module, SubscriptionsModule, StorageModule],

View File

@@ -5,27 +5,27 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { eq, and, or, inArray, sql } from "drizzle-orm"; import { and, eq, inArray, or, sql } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider"; import { DATABASE, type Database } from "../database/database.provider";
import { import {
brands,
catalogVehicles, catalogVehicles,
categories, categories,
parts, parts,
schemaPics,
brands,
userSubscriptions,
userBrands,
plans, plans,
schemaPics,
userBrands,
userSubscriptions,
} from "../database/schema/core"; } from "../database/schema/core";
import { RedisService } from "../redis/redis.service"; import type { PL24Service } from "../integrations/pl24/pl24.service";
import { PL24Service } from "../integrations/pl24/pl24.service";
import { StorageService } from "../storage/storage.service";
import { import {
PL24_SERVICE_CATALOGS, PL24_SERVICE_CATALOGS,
SERVICE_TO_BRAND,
SERVICE_DISPLAY_NAMES, SERVICE_DISPLAY_NAMES,
SERVICE_TO_BRAND,
isP5Modern, isP5Modern,
} from "../integrations/pl24/pl24.types"; } from "../integrations/pl24/pl24.types";
import type { RedisService } from "../redis/redis.service";
import type { StorageService } from "../storage/storage.service";
@Injectable() @Injectable()
export class CatalogService { export class CatalogService {
@@ -909,11 +909,11 @@ export class CatalogService {
name: p.name, name: p.name,
nameOriginal: p.name, nameOriginal: p.name,
description: p.description || null, description: p.description || null,
quantity: p.quantity ? (parseInt(String(p.quantity), 10) || null) : null, quantity: p.quantity ? (Number.parseInt(String(p.quantity), 10) || null) : null,
position: p.positionCode || null, position: p.positionCode || null,
hotspotIndex: p.hotspotId hotspotIndex: p.hotspotId
? (() => { ? (() => {
const val = parseInt(p.hotspotId!, 10); const val = Number.parseInt(p.hotspotId!, 10);
return val > 0 && val <= 2147483647 ? val : null; return val > 0 && val <= 2147483647 ? val : null;
})() })()
: null, : null,
@@ -1027,7 +1027,7 @@ export class CatalogService {
(hs.areas || []).map((area, areaIdx) => ({ (hs.areas || []).map((area, areaIdx) => ({
id: `hs-${hs.key}-${areaIdx}`, id: `hs-${hs.key}-${areaIdx}`,
key: hs.key, key: hs.key,
group: parseInt(hs.key, 10) || 0, group: Number.parseInt(hs.key, 10) || 0,
shape: "rect" as const, shape: "rect" as const,
coordinates: [area.left, area.top, area.width, area.height], coordinates: [area.left, area.top, area.width, area.height],
label: hs.label || hs.key, label: hs.label || hs.key,

View File

@@ -1,5 +1,5 @@
import { Controller, Get, Param } from "@nestjs/common"; import { Controller, Get, Param } from "@nestjs/common";
import { CategoriesService } from "./categories.service"; import type { CategoriesService } from "./categories.service";
@Controller("categories") @Controller("categories")
export class CategoriesController { export class CategoriesController {

View File

@@ -1,10 +1,10 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { CategoriesController } from "./categories.controller";
import { CategoriesService } from "./categories.service";
import { PL24Module } from "../integrations/pl24/pl24.module";
import { EmexModule } from "../integrations/emex/emex.module"; import { EmexModule } from "../integrations/emex/emex.module";
import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module"; import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module";
import { PL24Module } from "../integrations/pl24/pl24.module";
import { TranslationsModule } from "../translations/translations.module"; import { TranslationsModule } from "../translations/translations.module";
import { CategoriesController } from "./categories.controller";
import { CategoriesService } from "./categories.service";
@Module({ @Module({
imports: [PL24Module, EmexModule, PartsCatalogsModule, TranslationsModule], imports: [PL24Module, EmexModule, PartsCatalogsModule, TranslationsModule],

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NotFoundException } from "@nestjs/common"; import { NotFoundException } from "@nestjs/common";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CategoriesService } from "./categories.service"; import { CategoriesService } from "./categories.service";
function createService(db: any) { function createService(db: any) {
@@ -26,10 +26,14 @@ function createService(db: any) {
fetchCategoriesForPsaVin: vi.fn().mockResolvedValue([]), fetchCategoriesForPsaVin: vi.fn().mockResolvedValue([]),
}; };
const translationsService = { const translationsService = {
translate: vi.fn().mockImplementation((_key: string, sourceText: string) => translate: vi
.fn()
.mockImplementation((_key: string, sourceText: string) =>
Promise.resolve({ translatedText: sourceText, source: "none", isAutoTranslated: false }), Promise.resolve({ translatedText: sourceText, source: "none", isAutoTranslated: false }),
), ),
translateMany: vi.fn().mockImplementation((texts: string[]) => translateMany: vi
.fn()
.mockImplementation((texts: string[]) =>
Promise.resolve(new Map<string, string>(texts.map((t) => [t, t]))), Promise.resolve(new Map<string, string>(texts.map((t) => [t, t]))),
), ),
}; };

View File

@@ -1,15 +1,15 @@
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { eq, inArray, isNull, sql } from "drizzle-orm"; import { eq, inArray, isNull, sql } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider"; import { DATABASE, type Database } from "../database/database.provider";
import { categories, vehicles, schemaPics, parts } from "../database/schema/core"; import { categories, parts, schemaPics, vehicles } from "../database/schema/core";
import { RedisService } from "../redis/redis.service"; import type { EmexService } from "../integrations/emex/emex.service";
import { PL24Service } from "../integrations/pl24/pl24.service"; import type { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
import { PL24FordLegacyService } from "../integrations/pl24/pl24-ford-legacy.service";
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 type { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
import { StorageService } from "../storage/storage.service"; import type { PL24FordLegacyService } from "../integrations/pl24/pl24-ford-legacy.service";
import { TranslationsService } from "../translations/translations.service"; import type { PL24Service } from "../integrations/pl24/pl24.service";
import type { RedisService } from "../redis/redis.service";
import type { StorageService } from "../storage/storage.service";
import type { TranslationsService } from "../translations/translations.service";
@Injectable() @Injectable()
export class CategoriesService { export class CategoriesService {
@@ -642,7 +642,7 @@ export class CategoriesService {
description: p.notice, description: p.notice,
quantity: null, quantity: null,
position: p.positionNumber, position: p.positionNumber,
hotspotIndex: p.positionNumber ? parseInt(p.positionNumber, 10) || null : null, hotspotIndex: p.positionNumber ? Number.parseInt(p.positionNumber, 10) || null : null,
unavailable: false, unavailable: false,
remark: null as string | null, remark: null as string | null,
modelCodes: null as string | null, modelCodes: null as string | null,
@@ -830,10 +830,10 @@ export class CategoriesService {
name: p.name, name: p.name,
nameOriginal: p.name, nameOriginal: p.name,
description: p.description || null, description: p.description || null,
quantity: p.quantity ? (parseInt(String(p.quantity), 10) || null) : null, quantity: p.quantity ? (Number.parseInt(String(p.quantity), 10) || null) : null,
position: p.positionCode || null, position: p.positionCode || null,
hotspotIndex: p.hotspotId ? (() => { hotspotIndex: p.hotspotId ? (() => {
const val = parseInt(p.hotspotId!, 10); const val = Number.parseInt(p.hotspotId!, 10);
return (val > 0 && val <= 2147483647) ? val : null; return (val > 0 && val <= 2147483647) ? val : null;
})() : null, })() : null,
unavailable: p.unavailable || false, unavailable: p.unavailable || false,
@@ -977,7 +977,7 @@ export class CategoriesService {
(hs.areas || []).map((area, areaIdx) => ({ (hs.areas || []).map((area, areaIdx) => ({
id: `hs-${hs.key}-${areaIdx}`, id: `hs-${hs.key}-${areaIdx}`,
key: hs.key, key: hs.key,
group: parseInt(hs.key, 10) || 0, group: Number.parseInt(hs.key, 10) || 0,
shape: "rect" as const, shape: "rect" as const,
coordinates: [area.left, area.top, area.width, area.height], coordinates: [area.left, area.top, area.width, area.height],
label: hs.label || hs.key, label: hs.label || hs.key,

View File

@@ -1,4 +1,4 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common"; import { type ExecutionContext, createParamDecorator } from "@nestjs/common";
export const CurrentUser = createParamDecorator((data: string, ctx: ExecutionContext) => { export const CurrentUser = createParamDecorator((data: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest(); const request = ctx.switchToHttp().getRequest();

View File

@@ -5,14 +5,14 @@ export const paginationSchema = z.object({
.string() .string()
.optional() .optional()
.transform((val) => { .transform((val) => {
const parsed = val ? parseInt(val, 10) : 1; const parsed = val ? Number.parseInt(val, 10) : 1;
return Number.isNaN(parsed) || parsed < 1 ? 1 : parsed; return Number.isNaN(parsed) || parsed < 1 ? 1 : parsed;
}), }),
limit: z limit: z
.string() .string()
.optional() .optional()
.transform((val) => { .transform((val) => {
const parsed = val ? parseInt(val, 10) : 20; const parsed = val ? Number.parseInt(val, 10) : 20;
if (Number.isNaN(parsed) || parsed < 1) return 20; if (Number.isNaN(parsed) || parsed < 1) return 20;
return Math.min(parsed, 100); return Math.min(parsed, 100);
}), }),

View File

@@ -1,5 +1,11 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus, Logger } from "@nestjs/common"; import {
import { Response } from "express"; type ArgumentsHost,
Catch,
type ExceptionFilter,
HttpStatus,
Logger,
} from "@nestjs/common";
import type { Response } from "express";
// Drizzle/postgres unique violation error // Drizzle/postgres unique violation error
@Catch() @Catch()

View File

@@ -1,6 +1,13 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from "@nestjs/common"; import {
import { Response } from "express"; type ArgumentsHost,
import { trace, SpanStatusCode } from "@opentelemetry/api"; Catch,
type ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from "@nestjs/common";
import { SpanStatusCode, trace } from "@opentelemetry/api";
import type { Response } from "express";
@Catch() @Catch()
export class HttpExceptionFilter implements ExceptionFilter { export class HttpExceptionFilter implements ExceptionFilter {
@@ -54,13 +61,20 @@ export class HttpExceptionFilter implements ExceptionFilter {
private getCodeFromStatus(status: number): string { private getCodeFromStatus(status: number): string {
switch (status) { switch (status) {
case 400: return "GEN_002"; case 400:
case 401: return "AUTH_004"; return "GEN_002";
case 403: return "AUTH_005"; case 401:
case 404: return "GEN_001"; return "AUTH_004";
case 409: return "GEN_005"; case 403:
case 429: return "GEN_004"; return "AUTH_005";
default: return "GEN_003"; case 404:
return "GEN_001";
case 409:
return "GEN_005";
case 429:
return "GEN_004";
default:
return "GEN_003";
} }
} }
} }

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { UnauthorizedException } from "@nestjs/common"; import { UnauthorizedException } from "@nestjs/common";
import { Reflector } from "@nestjs/core"; import type { Reflector } from "@nestjs/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AuthGuard } from "./auth.guard"; import { AuthGuard } from "./auth.guard";
vi.mock("../../auth/auth", () => ({ vi.mock("../../auth/auth", () => ({
@@ -67,9 +67,7 @@ describe("AuthGuard", () => {
headers: { authorization: "Bearer token123" }, headers: { authorization: "Bearer token123" },
}); });
await expect(guard.canActivate(context as any)).rejects.toThrow( await expect(guard.canActivate(context as any)).rejects.toThrow(UnauthorizedException);
UnauthorizedException,
);
}); });
it("should throw UnauthorizedException when session has no user", async () => { it("should throw UnauthorizedException when session has no user", async () => {
@@ -85,9 +83,7 @@ describe("AuthGuard", () => {
headers: { authorization: "Bearer token123" }, headers: { authorization: "Bearer token123" },
}); });
await expect(guard.canActivate(context as any)).rejects.toThrow( await expect(guard.canActivate(context as any)).rejects.toThrow(UnauthorizedException);
UnauthorizedException,
);
}); });
it("should set user and session on request for valid session", async () => { it("should set user and session on request for valid session", async () => {
@@ -127,9 +123,7 @@ describe("AuthGuard", () => {
headers: {}, headers: {},
}); });
await expect(guard.canActivate(context as any)).rejects.toThrow( await expect(guard.canActivate(context as any)).rejects.toThrow(UnauthorizedException);
UnauthorizedException,
);
}); });
it("should re-throw UnauthorizedException from inner code", async () => { it("should re-throw UnauthorizedException from inner code", async () => {
@@ -137,9 +131,7 @@ describe("AuthGuard", () => {
mockedGetAuth.mockReturnValue({ mockedGetAuth.mockReturnValue({
api: { api: {
getSession: vi.fn().mockRejectedValue( getSession: vi.fn().mockRejectedValue(new UnauthorizedException("Custom auth error")),
new UnauthorizedException("Custom auth error"),
),
}, },
} as any); } as any);
@@ -147,8 +139,6 @@ describe("AuthGuard", () => {
headers: { authorization: "Bearer token" }, headers: { authorization: "Bearer token" },
}); });
await expect(guard.canActivate(context as any)).rejects.toThrow( await expect(guard.canActivate(context as any)).rejects.toThrow(UnauthorizedException);
UnauthorizedException,
);
}); });
}); });

View File

@@ -1,7 +1,12 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common"; import {
import { Reflector } from "@nestjs/core"; type CanActivate,
import { IS_PUBLIC_KEY } from "../decorators/public.decorator"; type ExecutionContext,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import type { Reflector } from "@nestjs/core";
import { getAuth } from "../../auth/auth"; import { getAuth } from "../../auth/auth";
import { IS_PUBLIC_KEY } from "../decorators/public.decorator";
@Injectable() @Injectable()
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {

View File

@@ -1,11 +1,24 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ForbiddenException } from "@nestjs/common"; import { ForbiddenException } from "@nestjs/common";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BrandAccessGuard } from "./brand-access.guard"; import { BrandAccessGuard } from "./brand-access.guard";
function createMockDb(overrides: Record<string, unknown> = {}) { function createMockDb(overrides: Record<string, unknown> = {}) {
function chainable(terminalValue: unknown) { function chainable(terminalValue: unknown) {
const chain: Record<string, unknown> = {}; const chain: Record<string, unknown> = {};
const methods = ["select", "from", "where", "orderBy", "limit", "offset", "innerJoin", "insert", "values", "update", "set", "returning"]; const methods = [
"select",
"from",
"where",
"orderBy",
"limit",
"offset",
"innerJoin",
"insert",
"values",
"update",
"set",
"returning",
];
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain); for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
chain.limit = vi.fn().mockReturnValue(terminalValue); chain.limit = vi.fn().mockReturnValue(terminalValue);
return chain; return chain;

View File

@@ -1,7 +1,7 @@
import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable } from "@nestjs/common"; import { type CanActivate, type ExecutionContext, ForbiddenException, Inject, Injectable } from "@nestjs/common";
import { eq, and, or } from "drizzle-orm"; import { and, eq, or } from "drizzle-orm";
import { DATABASE, Database } from "../../database/database.provider"; import { DATABASE, type Database } from "../../database/database.provider";
import { userSubscriptions, userBrands, plans } from "../../database/schema/core"; import { plans, userBrands, userSubscriptions } from "../../database/schema/core";
@Injectable() @Injectable()
export class BrandAccessGuard implements CanActivate { export class BrandAccessGuard implements CanActivate {

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ForbiddenException } from "@nestjs/common"; import { ForbiddenException } from "@nestjs/common";
import { Reflector } from "@nestjs/core"; import type { Reflector } from "@nestjs/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { RolesGuard } from "./roles.guard"; import { RolesGuard } from "./roles.guard";
function createMockExecutionContext(user?: { role: string }) { function createMockExecutionContext(user?: { role: string }) {

View File

@@ -1,5 +1,10 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common"; import {
import { Reflector } from "@nestjs/core"; type CanActivate,
type ExecutionContext,
ForbiddenException,
Injectable,
} from "@nestjs/common";
import type { Reflector } from "@nestjs/core";
import { ROLES_KEY } from "../decorators/roles.decorator"; import { ROLES_KEY } from "../decorators/roles.decorator";
@Injectable() @Injectable()

View File

@@ -1,6 +1,12 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from "@nestjs/common"; import {
import { Observable, tap } from "rxjs"; type CallHandler,
type ExecutionContext,
Injectable,
Logger,
type NestInterceptor,
} from "@nestjs/common";
import { trace } from "@opentelemetry/api"; import { trace } from "@opentelemetry/api";
import { type Observable, tap } from "rxjs";
@Injectable() @Injectable()
export class LoggingInterceptor implements NestInterceptor { export class LoggingInterceptor implements NestInterceptor {

View File

@@ -1,11 +1,11 @@
import { import {
type CallHandler,
type ExecutionContext,
Injectable, Injectable,
NestInterceptor, type NestInterceptor,
ExecutionContext,
CallHandler,
RequestTimeoutException, RequestTimeoutException,
} from "@nestjs/common"; } from "@nestjs/common";
import { Observable, throwError, timeout, catchError, TimeoutError } from "rxjs"; import { type Observable, TimeoutError, catchError, throwError, timeout } from "rxjs";
@Injectable() @Injectable()
export class TimeoutInterceptor implements NestInterceptor { export class TimeoutInterceptor implements NestInterceptor {

View File

@@ -1,5 +1,10 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from "@nestjs/common"; import {
import { Observable, map } from "rxjs"; type CallHandler,
type ExecutionContext,
Injectable,
type NestInterceptor,
} from "@nestjs/common";
import { type Observable, map } from "rxjs";
export interface TransformedResponse<T> { export interface TransformedResponse<T> {
success: boolean; success: boolean;
@@ -9,10 +14,7 @@ export interface TransformedResponse<T> {
@Injectable() @Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, TransformedResponse<T>> { export class TransformInterceptor<T> implements NestInterceptor<T, TransformedResponse<T>> {
intercept( intercept(context: ExecutionContext, next: CallHandler): Observable<TransformedResponse<T>> {
context: ExecutionContext,
next: CallHandler,
): Observable<TransformedResponse<T>> {
return next.handle().pipe( return next.handle().pipe(
map((data) => { map((data) => {
// If already wrapped, pass through // If already wrapped, pass through

View File

@@ -1,11 +1,6 @@
import type { Request, Response, NextFunction } from "express"; import type { NextFunction, Request, Response } from "express";
const ALLOWED_MIME_TYPES = [ const ALLOWED_MIME_TYPES = ["image/png", "image/jpeg", "image/jpg", "application/pdf"];
"image/png",
"image/jpeg",
"image/jpg",
"application/pdf",
];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from "vitest";
import { BadRequestException } from "@nestjs/common"; import { BadRequestException } from "@nestjs/common";
import { beforeEach, describe, expect, it } from "vitest";
import { VinValidationPipe } from "./vin-validation.pipe"; import { VinValidationPipe } from "./vin-validation.pipe";
describe("VinValidationPipe", () => { describe("VinValidationPipe", () => {
@@ -32,27 +32,19 @@ describe("VinValidationPipe", () => {
}); });
it("should throw BadRequestException for VIN longer than 17 characters", () => { it("should throw BadRequestException for VIN longer than 17 characters", () => {
expect(() => pipe.transform("WBAPH5C55BA12345678")).toThrow( expect(() => pipe.transform("WBAPH5C55BA12345678")).toThrow(BadRequestException);
BadRequestException,
);
}); });
it("should throw BadRequestException for VIN containing letter I", () => { it("should throw BadRequestException for VIN containing letter I", () => {
expect(() => pipe.transform("WBAPH5C55IA123456")).toThrow( expect(() => pipe.transform("WBAPH5C55IA123456")).toThrow(BadRequestException);
BadRequestException,
);
}); });
it("should throw BadRequestException for VIN containing letter O", () => { it("should throw BadRequestException for VIN containing letter O", () => {
expect(() => pipe.transform("WBAPH5C55OA123456")).toThrow( expect(() => pipe.transform("WBAPH5C55OA123456")).toThrow(BadRequestException);
BadRequestException,
);
}); });
it("should throw BadRequestException for VIN containing letter Q", () => { it("should throw BadRequestException for VIN containing letter Q", () => {
expect(() => pipe.transform("WBAPH5C55QA123456")).toThrow( expect(() => pipe.transform("WBAPH5C55QA123456")).toThrow(BadRequestException);
BadRequestException,
);
}); });
it("should throw BadRequestException for empty string", () => { it("should throw BadRequestException for empty string", () => {
@@ -61,17 +53,11 @@ describe("VinValidationPipe", () => {
}); });
it("should throw BadRequestException for null/undefined value", () => { it("should throw BadRequestException for null/undefined value", () => {
expect(() => pipe.transform(null as unknown as string)).toThrow( expect(() => pipe.transform(null as unknown as string)).toThrow(BadRequestException);
BadRequestException, expect(() => pipe.transform(undefined as unknown as string)).toThrow(BadRequestException);
);
expect(() => pipe.transform(undefined as unknown as string)).toThrow(
BadRequestException,
);
}); });
it("should throw BadRequestException for non-string value", () => { it("should throw BadRequestException for non-string value", () => {
expect(() => pipe.transform(12345 as unknown as string)).toThrow( expect(() => pipe.transform(12345 as unknown as string)).toThrow(BadRequestException);
BadRequestException,
);
}); });
}); });

View File

@@ -1,4 +1,4 @@
import { PipeTransform, Injectable, BadRequestException } from "@nestjs/common"; import { BadRequestException, Injectable, type PipeTransform } from "@nestjs/common";
import { isValidVin } from "@sase/shared"; import { isValidVin } from "@sase/shared";
@Injectable() @Injectable()

View File

@@ -1,11 +1,11 @@
export default () => ({ export default () => ({
port: parseInt(process.env.PORT || "4000", 10), port: Number.parseInt(process.env.PORT || "4000", 10),
database: { database: {
url: process.env.DATABASE_URL, url: process.env.DATABASE_URL,
}, },
redis: { redis: {
host: process.env.REDIS_HOST || "127.0.0.1", host: process.env.REDIS_HOST || "127.0.0.1",
port: parseInt(process.env.REDIS_PORT || "6379", 10), port: Number.parseInt(process.env.REDIS_PORT || "6379", 10),
password: process.env.REDIS_PASSWORD, password: process.env.REDIS_PASSWORD,
}, },
auth: { auth: {
@@ -54,6 +54,6 @@ export default () => ({
enabled: process.env.OTEL_ENABLED === "true", enabled: process.env.OTEL_ENABLED === "true",
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
serviceName: process.env.OTEL_SERVICE_NAME || "sase-api", serviceName: process.env.OTEL_SERVICE_NAME || "sase-api",
sampleRate: parseFloat(process.env.OTEL_TRACE_SAMPLE_RATE || "1.0"), sampleRate: Number.parseFloat(process.env.OTEL_TRACE_SAMPLE_RATE || "1.0"),
}, },
}); });

View File

@@ -1,12 +1,12 @@
import { Provider } from "@nestjs/common"; import type { Provider } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { drizzle, PostgresJsDatabase } from "drizzle-orm/postgres-js"; import { type PostgresJsDatabase, drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres"; import postgres from "postgres";
import * as core from "./schema/core";
import * as pl24 from "./schema/pl24";
import * as emex from "./schema/emex";
import * as relations from "./schema/relations";
import { isOtelEnabled } from "../telemetry"; import { isOtelEnabled } from "../telemetry";
import * as core from "./schema/core";
import * as emex from "./schema/emex";
import * as pl24 from "./schema/pl24";
import * as relations from "./schema/relations";
export const DATABASE = "DATABASE"; export const DATABASE = "DATABASE";

View File

@@ -1,15 +1,15 @@
import { import {
boolean,
index,
integer,
jsonb,
numeric,
pgTable, pgTable,
text,
timestamp,
uniqueIndex,
uuid, uuid,
varchar, varchar,
text,
boolean,
integer,
numeric,
timestamp,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
// ─── Users ─────────────────────────────────────────── // ─── Users ───────────────────────────────────────────
@@ -256,7 +256,11 @@ export const catalogVehicles = pgTable(
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
}, },
(table) => [ (table) => [
uniqueIndex("catalog_vehicles_source_svc_vid_idx").on(table.source, table.serviceName, table.serviceVehicleId), uniqueIndex("catalog_vehicles_source_svc_vid_idx").on(
table.source,
table.serviceName,
table.serviceVehicleId,
),
index("catalog_vehicles_brand_name_idx").on(table.brandName), index("catalog_vehicles_brand_name_idx").on(table.brandName),
index("catalog_vehicles_service_name_idx").on(table.serviceName), index("catalog_vehicles_service_name_idx").on(table.serviceName),
], ],
@@ -281,9 +285,7 @@ export const vehicles = pgTable(
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
}, },
(table) => [ (table) => [uniqueIndex("vehicles_vin_unique_idx").on(table.vin)],
uniqueIndex("vehicles_vin_unique_idx").on(table.vin),
],
); );
// ─── User Vehicles (junction — user ↔ shared vehicle) ─ // ─── User Vehicles (junction — user ↔ shared vehicle) ─
@@ -312,7 +314,9 @@ export const categories = pgTable(
{ {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
vehicleId: uuid("vehicle_id").references(() => vehicles.id, { onDelete: "cascade" }), vehicleId: uuid("vehicle_id").references(() => vehicles.id, { onDelete: "cascade" }),
catalogVehicleId: uuid("catalog_vehicle_id").references(() => catalogVehicles.id, { onDelete: "cascade" }), catalogVehicleId: uuid("catalog_vehicle_id").references(() => catalogVehicles.id, {
onDelete: "cascade",
}),
name: varchar("name", { length: 500 }).notNull(), name: varchar("name", { length: 500 }).notNull(),
nameOriginal: varchar("name_original", { length: 500 }), nameOriginal: varchar("name_original", { length: 500 }),
parentId: uuid("parent_id"), parentId: uuid("parent_id"),
@@ -327,7 +331,12 @@ export const categories = pgTable(
index("categories_vehicle_id_idx").on(table.vehicleId), index("categories_vehicle_id_idx").on(table.vehicleId),
index("categories_catalog_vehicle_id_idx").on(table.catalogVehicleId), index("categories_catalog_vehicle_id_idx").on(table.catalogVehicleId),
index("categories_parent_id_idx").on(table.parentId), index("categories_parent_id_idx").on(table.parentId),
uniqueIndex("categories_vehicle_name_source_idx").on(table.vehicleId, table.catalogVehicleId, table.name, table.source), uniqueIndex("categories_vehicle_name_source_idx").on(
table.vehicleId,
table.catalogVehicleId,
table.name,
table.source,
),
], ],
); );
@@ -337,7 +346,9 @@ export const parts = pgTable(
{ {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
vehicleId: uuid("vehicle_id").references(() => vehicles.id, { onDelete: "cascade" }), vehicleId: uuid("vehicle_id").references(() => vehicles.id, { onDelete: "cascade" }),
catalogVehicleId: uuid("catalog_vehicle_id").references(() => catalogVehicles.id, { onDelete: "cascade" }), catalogVehicleId: uuid("catalog_vehicle_id").references(() => catalogVehicles.id, {
onDelete: "cascade",
}),
categoryId: uuid("category_id") categoryId: uuid("category_id")
.notNull() .notNull()
.references(() => categories.id, { onDelete: "cascade" }), .references(() => categories.id, { onDelete: "cascade" }),

View File

@@ -1,14 +1,14 @@
import { import {
boolean,
index,
integer,
jsonb,
pgTable, pgTable,
text,
timestamp,
uniqueIndex,
uuid, uuid,
varchar, varchar,
text,
boolean,
integer,
timestamp,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
// ─── EMEX Catalog ─────────────────────────────────── // ─── EMEX Catalog ───────────────────────────────────

View File

@@ -1,14 +1,14 @@
import { import {
boolean,
index,
jsonb,
pgTable, pgTable,
serial,
text,
timestamp,
uniqueIndex,
uuid, uuid,
varchar, varchar,
text,
boolean,
serial,
timestamp,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
// ─── PCAT Vehicles — VIN decode results ──────────── // ─── PCAT Vehicles — VIN decode results ────────────
@@ -59,9 +59,7 @@ export const pcatParts = pgTable(
description: text("description"), description: text("description"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
}, },
(table) => [ (table) => [index("pcat_parts_name_idx").on(table.name)],
index("pcat_parts_name_idx").on(table.name),
],
); );
// ─── PCAT Schema Pics — Schema images + hotspots ─── // ─── PCAT Schema Pics — Schema images + hotspots ───
@@ -76,7 +74,5 @@ export const pcatSchemaPics = pgTable(
hotspots: jsonb("hotspots"), // positions array from API hotspots: jsonb("hotspots"), // positions array from API
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
}, },
(table) => [ (table) => [index("pcat_schema_pics_group_car_idx").on(table.groupId, table.carId)],
index("pcat_schema_pics_group_car_idx").on(table.groupId, table.carId),
],
); );

View File

@@ -1,14 +1,14 @@
import { import {
boolean,
index,
integer,
jsonb,
pgTable, pgTable,
text,
timestamp,
uniqueIndex,
uuid, uuid,
varchar, varchar,
text,
boolean,
integer,
timestamp,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
// ─── PL24 Catalog ─────────────────────────────────── // ─── PL24 Catalog ───────────────────────────────────

View File

@@ -1,21 +1,21 @@
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { import {
users,
sessions,
accounts, accounts,
brands, brands,
plans, catalogVehicles,
userSubscriptions,
userBrands,
payments,
queryLogs,
vehicles,
userVehicles,
categories, categories,
parts, parts,
schemaPics, payments,
plans,
queryLogs,
referrals, referrals,
catalogVehicles, schemaPics,
sessions,
userBrands,
userSubscriptions,
userVehicles,
users,
vehicles,
} from "./core"; } from "./core";
export const usersRelations = relations(users, ({ many }) => ({ export const usersRelations = relations(users, ({ many }) => ({
@@ -96,7 +96,10 @@ export const userVehiclesRelations = relations(userVehicles, ({ one }) => ({
export const categoriesRelations = relations(categories, ({ one, many }) => ({ export const categoriesRelations = relations(categories, ({ one, many }) => ({
vehicle: one(vehicles, { fields: [categories.vehicleId], references: [vehicles.id] }), vehicle: one(vehicles, { fields: [categories.vehicleId], references: [vehicles.id] }),
catalogVehicle: one(catalogVehicles, { fields: [categories.catalogVehicleId], references: [catalogVehicles.id] }), catalogVehicle: one(catalogVehicles, {
fields: [categories.catalogVehicleId],
references: [catalogVehicles.id],
}),
parent: one(categories, { parent: one(categories, {
fields: [categories.parentId], fields: [categories.parentId],
references: [categories.id], references: [categories.id],
@@ -109,7 +112,10 @@ export const categoriesRelations = relations(categories, ({ one, many }) => ({
export const partsRelations = relations(parts, ({ one }) => ({ export const partsRelations = relations(parts, ({ one }) => ({
vehicle: one(vehicles, { fields: [parts.vehicleId], references: [vehicles.id] }), vehicle: one(vehicles, { fields: [parts.vehicleId], references: [vehicles.id] }),
catalogVehicle: one(catalogVehicles, { fields: [parts.catalogVehicleId], references: [catalogVehicles.id] }), catalogVehicle: one(catalogVehicles, {
fields: [parts.catalogVehicleId],
references: [catalogVehicles.id],
}),
category: one(categories, { fields: [parts.categoryId], references: [categories.id] }), category: one(categories, { fields: [parts.categoryId], references: [categories.id] }),
})); }));

View File

@@ -1,7 +1,7 @@
import "dotenv/config"; import "dotenv/config";
import { drizzle } from "drizzle-orm/postgres-js"; import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres"; import postgres from "postgres";
import { brands, plans, users, accounts } from "./schema/core"; import { accounts, brands, plans, users } from "./schema/core";
const BRANDS_DATA = [ const BRANDS_DATA = [
// Mevcut markalar // Mevcut markalar
@@ -76,7 +76,10 @@ async function seed() {
...p, ...p,
isActive: false, isActive: false,
})); }));
await db.insert(plans).values([...activePlans, ...trialPlans]).onConflictDoNothing(); await db
.insert(plans)
.values([...activePlans, ...trialPlans])
.onConflictDoNothing();
// Seed admin user // Seed admin user
console.log("Seeding admin user..."); console.log("Seeding admin user...");

View File

@@ -1,5 +1,5 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import type { ConfigService } from "@nestjs/config";
export interface SendEmailOptions { export interface SendEmailOptions {
to: string; to: string;

View File

@@ -1,9 +1,9 @@
import { Controller, Get, Inject } from "@nestjs/common"; import { Controller, Get, Inject } from "@nestjs/common";
import { Public } from "./common/decorators/public.decorator";
import { DATABASE, Database } from "./database/database.provider";
import { RedisService } from "./redis/redis.service";
import { isOtelEnabled } from "./telemetry";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { Public } from "./common/decorators/public.decorator";
import { DATABASE, type Database } from "./database/database.provider";
import type { RedisService } from "./redis/redis.service";
import { isOtelEnabled } from "./telemetry";
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> { function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([ return Promise.race([

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { CorgiService } from "./corgi.service"; import { CorgiService } from "./corgi.service";
describe("CorgiService", () => { describe("CorgiService", () => {

View File

@@ -9,32 +9,56 @@ interface CorgiDecodeResult {
const WMI_DATABASE: Record<string, string> = { const WMI_DATABASE: Record<string, string> = {
// BMW // BMW
WBA: "BMW", WBS: "BMW", WBY: "BMW", "5UX": "BMW", WBA: "BMW",
WBS: "BMW",
WBY: "BMW",
"5UX": "BMW",
// Mercedes-Benz // Mercedes-Benz
WDB: "Mercedes-Benz", WDC: "Mercedes-Benz", WDD: "Mercedes-Benz", WDF: "Mercedes-Benz", WDB: "Mercedes-Benz",
WDC: "Mercedes-Benz",
WDD: "Mercedes-Benz",
WDF: "Mercedes-Benz",
// Audi // Audi
WAU: "Audi", WUA: "Audi", WAU: "Audi",
WUA: "Audi",
// Volkswagen // Volkswagen
WVW: "Volkswagen", WVG: "Volkswagen", "3VW": "Volkswagen", WVW: "Volkswagen",
WV1: "Volkswagen", WV2: "Volkswagen", WV3: "Volkswagen", WVG: "Volkswagen",
"3VW": "Volkswagen",
WV1: "Volkswagen",
WV2: "Volkswagen",
WV3: "Volkswagen",
// Toyota // Toyota
JTD: "Toyota", JTE: "Toyota", JTN: "Toyota", "2T1": "Toyota", "4T1": "Toyota", JTD: "Toyota",
JTE: "Toyota",
JTN: "Toyota",
"2T1": "Toyota",
"4T1": "Toyota",
// Fiat // Fiat
ZFA: "Fiat", ZFC: "Fiat", ZFA: "Fiat",
ZFC: "Fiat",
// Renault // Renault
VF1: "Renault", VF2: "Renault", VF1: "Renault",
VF2: "Renault",
// Peugeot // Peugeot
VF3: "Peugeot", VF3: "Peugeot",
// Citroen // Citroen
VF7: "Citroen", VF7: "Citroen",
// Honda // Honda
JHM: "Honda", SHH: "Honda", "1HG": "Honda", JHM: "Honda",
SHH: "Honda",
"1HG": "Honda",
// Hyundai // Hyundai
KMH: "Hyundai", "5NP": "Hyundai", KMH: "Hyundai",
"5NP": "Hyundai",
// Kia // Kia
KNA: "Kia", KND: "Kia", KNA: "Kia",
KND: "Kia",
// Ford // Ford
WF0: "Ford", NM0: "Ford", "1FA": "Ford", "3FA": "Ford", WF0: "Ford",
NM0: "Ford",
"1FA": "Ford",
"3FA": "Ford",
// Opel // Opel
W0L: "Opel", W0L: "Opel",
// Skoda // Skoda
@@ -44,11 +68,16 @@ const WMI_DATABASE: Record<string, string> = {
// Volvo // Volvo
YV1: "Volvo", YV1: "Volvo",
// Nissan // Nissan
JN1: "Nissan", "1N4": "Nissan", "3N1": "Nissan", JN1: "Nissan",
"1N4": "Nissan",
"3N1": "Nissan",
// Mazda // Mazda
JMZ: "Mazda", JM1: "Mazda", JM3: "Mazda", JMZ: "Mazda",
JM1: "Mazda",
JM3: "Mazda",
// Porsche // Porsche
WP0: "Porsche", WP1: "Porsche", WP0: "Porsche",
WP1: "Porsche",
// Land Rover // Land Rover
SAL: "Land Rover", SAL: "Land Rover",
// Jaguar // Jaguar
@@ -58,19 +87,52 @@ const WMI_DATABASE: Record<string, string> = {
// Dacia // Dacia
UU1: "Dacia", UU1: "Dacia",
// Subaru // Subaru
JF1: "Subaru", JF2: "Subaru", JF1: "Subaru",
JF2: "Subaru",
// Suzuki // Suzuki
JS2: "Suzuki", JS3: "Suzuki", TSM: "Suzuki", MA3: "Suzuki", MBH: "Suzuki", JS2: "Suzuki",
JS3: "Suzuki",
TSM: "Suzuki",
MA3: "Suzuki",
MBH: "Suzuki",
// Mitsubishi // Mitsubishi
JMB: "Mitsubishi", JMY: "Mitsubishi", MMB: "Mitsubishi", ML3: "Mitsubishi", JMB: "Mitsubishi",
JMY: "Mitsubishi",
MMB: "Mitsubishi",
ML3: "Mitsubishi",
}; };
const YEAR_MAP: Record<string, number> = { const YEAR_MAP: Record<string, number> = {
A: 2010, B: 2011, C: 2012, D: 2013, E: 2014, F: 2015, G: 2016, H: 2017, A: 2010,
J: 2018, K: 2019, L: 2020, M: 2021, N: 2022, P: 2023, R: 2024, S: 2025, B: 2011,
T: 2026, V: 2027, W: 2028, X: 2029, Y: 2030, C: 2012,
"1": 2001, "2": 2002, "3": 2003, "4": 2004, "5": 2005, D: 2013,
"6": 2006, "7": 2007, "8": 2008, "9": 2009, E: 2014,
F: 2015,
G: 2016,
H: 2017,
J: 2018,
K: 2019,
L: 2020,
M: 2021,
N: 2022,
P: 2023,
R: 2024,
S: 2025,
T: 2026,
V: 2027,
W: 2028,
X: 2029,
Y: 2030,
"1": 2001,
"2": 2002,
"3": 2003,
"4": 2004,
"5": 2005,
"6": 2006,
"7": 2007,
"8": 2008,
"9": 2009,
}; };
@Injectable() @Injectable()

View File

@@ -11,18 +11,13 @@
* - Crash recovery (auto-relaunch if browser disconnects) * - Crash recovery (auto-relaunch if browser disconnects)
*/ */
import { import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
Injectable, import type { ConfigService } from "@nestjs/config";
Logger, import type { Browser, BrowserContext, Page } from "playwright";
OnModuleInit,
OnModuleDestroy,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { Browser, BrowserContext, Page } from 'playwright';
const SESSION_TTL_MS = 25 * 60 * 1000; // 25 minutes const SESSION_TTL_MS = 25 * 60 * 1000; // 25 minutes
const MAX_CONCURRENT_PAGES = 3; const MAX_CONCURRENT_PAGES = 3;
const EMEX_BASE_URL = 'https://emexdwc.ae'; const EMEX_BASE_URL = "https://emexdwc.ae";
/** Simple counting semaphore */ /** Simple counting semaphore */
class Semaphore { class Semaphore {
@@ -76,47 +71,28 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
constructor(private configService: ConfigService) { constructor(private configService: ConfigService) {
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES); this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
this.useProxy = this.useProxy = this.configService.get<string>("EMEX_USE_PROXY", "true") === "true";
this.configService.get<string>('EMEX_USE_PROXY', 'true') === 'true'; this.proxyHost = this.configService.get<string>("EMEX_PROXY_HOST", "74.81.81.81");
this.proxyHost = this.configService.get<string>( this.proxyPortStart = this.configService.get<number>("EMEX_PROXY_PORT_START", 10001);
'EMEX_PROXY_HOST', this.proxyPortEnd = this.configService.get<number>("EMEX_PROXY_PORT_END", 10099);
'74.81.81.81', this.proxyUsername = this.configService.get<string>("EMEX_PROXY_USER", "1726bbe361918676d44e");
); this.proxyPassword = this.configService.get<string>("EMEX_PROXY_PASS", "f11c7b6128cc86c6");
this.proxyPortStart = this.configService.get<number>(
'EMEX_PROXY_PORT_START',
10001,
);
this.proxyPortEnd = this.configService.get<number>(
'EMEX_PROXY_PORT_END',
10099,
);
this.proxyUsername = this.configService.get<string>(
'EMEX_PROXY_USER',
'1726bbe361918676d44e',
);
this.proxyPassword = this.configService.get<string>(
'EMEX_PROXY_PASS',
'f11c7b6128cc86c6',
);
} }
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
try { try {
await this.launchBrowser(); await this.launchBrowser();
this.logger.log('Browser launched on module init'); this.logger.log("Browser launched on module init");
} catch (err) { } catch (err) {
const e = err as Error; const e = err as Error;
this.logger.error( this.logger.error(`Failed to launch browser on init: ${e.message}`, e.stack);
`Failed to launch browser on init: ${e.message}`,
e.stack,
);
// Non-fatal — will retry on first acquirePage() // Non-fatal — will retry on first acquirePage()
} }
} }
async onModuleDestroy(): Promise<void> { async onModuleDestroy(): Promise<void> {
await this.closeBrowser(); await this.closeBrowser();
this.logger.log('Browser closed on module destroy'); this.logger.log("Browser closed on module destroy");
} }
/** /**
@@ -186,16 +162,16 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
private async _doLaunch(): Promise<void> { private async _doLaunch(): Promise<void> {
// Dynamic import — playwright is a devDependency // Dynamic import — playwright is a devDependency
const { chromium } = await import('playwright'); const { chromium } = await import("playwright");
const launchOptions: Record<string, unknown> = { const launchOptions: Record<string, unknown> = {
headless: true, headless: true,
args: [ args: [
'--no-sandbox', "--no-sandbox",
'--disable-setuid-sandbox', "--disable-setuid-sandbox",
'--disable-dev-shm-usage', "--disable-dev-shm-usage",
'--disable-accelerated-2d-canvas', "--disable-accelerated-2d-canvas",
'--disable-gpu', "--disable-gpu",
], ],
}; };
@@ -213,15 +189,15 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
this.context = await this.browser.newContext({ this.context = await this.browser.newContext({
viewport: { width: 1920, height: 1080 }, viewport: { width: 1920, height: 1080 },
userAgent: userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
}); });
this.startedAt = Date.now(); this.startedAt = Date.now();
this.sessionExpiry = 0; // force session establish on first acquirePage this.sessionExpiry = 0; // force session establish on first acquirePage
// Auto-recover on disconnect // Auto-recover on disconnect
this.browser.on('disconnected', () => { this.browser.on("disconnected", () => {
this.logger.warn('Browser disconnected — will relaunch on next request'); this.logger.warn("Browser disconnected — will relaunch on next request");
this.browser = null; this.browser = null;
this.context = null; this.context = null;
this.sessionExpiry = 0; this.sessionExpiry = 0;
@@ -243,7 +219,7 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
private async ensureBrowser(): Promise<void> { private async ensureBrowser(): Promise<void> {
if (this.browser?.isConnected()) return; if (this.browser?.isConnected()) return;
this.logger.log('Browser not connected — relaunching'); this.logger.log("Browser not connected — relaunching");
await this.launchBrowser(); await this.launchBrowser();
} }
@@ -253,21 +229,21 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
private async ensureSession(): Promise<void> { private async ensureSession(): Promise<void> {
if (Date.now() < this.sessionExpiry) return; if (Date.now() < this.sessionExpiry) return;
this.logger.log('Establishing EMEX session...'); this.logger.log("Establishing EMEX session...");
const page = await this.context!.newPage(); const page = await this.context!.newPage();
try { try {
await page.goto(EMEX_BASE_URL, { await page.goto(EMEX_BASE_URL, {
waitUntil: 'networkidle', waitUntil: "networkidle",
timeout: 30000, timeout: 30000,
}); });
const cookies = await this.context!.cookies(); const cookies = await this.context!.cookies();
const session = cookies.find((c) => c.name === 'ASP.NET_SessionId'); const session = cookies.find((c) => c.name === "ASP.NET_SessionId");
if (session) { if (session) {
this.sessionExpiry = Date.now() + SESSION_TTL_MS; this.sessionExpiry = Date.now() + SESSION_TTL_MS;
this.logger.log('Session established, TTL 25 min'); this.logger.log("Session established, TTL 25 min");
} else { } else {
this.logger.warn('No session cookie found after visiting baseUrl'); this.logger.warn("No session cookie found after visiting baseUrl");
// Still set a short TTL to avoid hammering // Still set a short TTL to avoid hammering
this.sessionExpiry = Date.now() + 60_000; this.sessionExpiry = Date.now() + 60_000;
} }
@@ -278,9 +254,8 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
private randomProxyPort(): number { private randomProxyPort(): number {
return ( return (
Math.floor( Math.floor(Math.random() * (this.proxyPortEnd - this.proxyPortStart + 1)) +
Math.random() * (this.proxyPortEnd - this.proxyPortStart + 1), this.proxyPortStart
) + this.proxyPortStart
); );
} }
} }

View File

@@ -11,75 +11,75 @@
*/ */
import { import {
EmexScraperResponse,
EmexCategory,
DecodedVehicle,
DecodedCategory,
CATALOG_MAP, CATALOG_MAP,
} from './emex.types'; type DecodedCategory,
type DecodedVehicle,
type EmexCategory,
type EmexScraperResponse,
} from "./emex.types";
// ==================== VEHICLE ATTRIBUTE TRANSLATIONS ==================== // ==================== VEHICLE ATTRIBUTE TRANSLATIONS ====================
const TR_TRANSLATIONS = { const TR_TRANSLATIONS = {
// Body types // Body types
bodyTypes: { bodyTypes: {
sedan: 'Sedan', sedan: "Sedan",
coupe: 'Coupe', coupe: "Coupe",
hatchback: 'Hatchback', hatchback: "Hatchback",
wagon: 'Station Wagon', wagon: "Station Wagon",
'station wagon': 'Station Wagon', "station wagon": "Station Wagon",
estate: 'Station Wagon', estate: "Station Wagon",
convertible: 'Ustu Acik', convertible: "Ustu Acik",
cabriolet: 'Kabriyole', cabriolet: "Kabriyole",
suv: 'SUV', suv: "SUV",
crossover: 'Crossover', crossover: "Crossover",
pickup: 'Pikap', pickup: "Pikap",
van: 'Minivan', van: "Minivan",
minivan: 'Minivan', minivan: "Minivan",
mpv: 'Cok Amacli Arac', mpv: "Cok Amacli Arac",
roadster: 'Roadster', roadster: "Roadster",
} as Record<string, string>, } as Record<string, string>,
engineTypes: { engineTypes: {
gasoline: 'Benzin', gasoline: "Benzin",
petrol: 'Benzin', petrol: "Benzin",
benzin: 'Benzin', benzin: "Benzin",
diesel: 'Dizel', diesel: "Dizel",
electric: 'Elektrik', electric: "Elektrik",
hybrid: 'Hibrit', hybrid: "Hibrit",
'plug-in hybrid': 'Sarjli Hibrit', "plug-in hybrid": "Sarjli Hibrit",
phev: 'Sarjli Hibrit', phev: "Sarjli Hibrit",
lpg: 'LPG', lpg: "LPG",
cng: 'CNG', cng: "CNG",
hydrogen: 'Hidrojen', hydrogen: "Hidrojen",
} as Record<string, string>, } as Record<string, string>,
transmissions: { transmissions: {
automatic: 'Otomatik', automatic: "Otomatik",
manual: 'Manuel', manual: "Manuel",
'semi-automatic': 'Yari Otomatik', "semi-automatic": "Yari Otomatik",
dct: 'Cift Kavramali', dct: "Cift Kavramali",
cvt: 'CVT', cvt: "CVT",
'dual clutch': 'Cift Kavramali', "dual clutch": "Cift Kavramali",
dsg: 'DSG', dsg: "DSG",
tiptronic: 'Tiptronic', tiptronic: "Tiptronic",
steptronic: 'Steptronic', steptronic: "Steptronic",
at: 'Otomatik', at: "Otomatik",
mt: 'Manuel', mt: "Manuel",
} as Record<string, string>, } as Record<string, string>,
driveTypes: { driveTypes: {
fwd: 'Ondan Cekis', fwd: "Ondan Cekis",
rwd: 'Arkadan Itis', rwd: "Arkadan Itis",
awd: 'Dort Ceker', awd: "Dort Ceker",
'4wd': 'Dort Ceker', "4wd": "Dort Ceker",
'4x4': 'Dort Ceker', "4x4": "Dort Ceker",
'front-wheel drive': 'Ondan Cekis', "front-wheel drive": "Ondan Cekis",
'rear-wheel drive': 'Arkadan Itis', "rear-wheel drive": "Arkadan Itis",
'all-wheel drive': 'Dort Ceker', "all-wheel drive": "Dort Ceker",
quattro: 'Quattro (Dort Ceker)', quattro: "Quattro (Dort Ceker)",
xdrive: 'xDrive (Dort Ceker)', xdrive: "xDrive (Dort Ceker)",
'4matic': '4MATIC (Dort Ceker)', "4matic": "4MATIC (Dort Ceker)",
} as Record<string, string>, } as Record<string, string>,
}; };
@@ -102,9 +102,7 @@ export function translateEngineType(engineType: string | null): string | null {
return translateToTurkish(engineType, TR_TRANSLATIONS.engineTypes); return translateToTurkish(engineType, TR_TRANSLATIONS.engineTypes);
} }
export function translateTransmission( export function translateTransmission(transmission: string | null): string | null {
transmission: string | null,
): string | null {
return translateToTurkish(transmission, TR_TRANSLATIONS.transmissions); return translateToTurkish(transmission, TR_TRANSLATIONS.transmissions);
} }
@@ -123,11 +121,11 @@ export function mapEmexResponse(response: EmexScraperResponse): DecodedVehicle {
// Get brand from catalog map or use the one from response // Get brand from catalog map or use the one from response
const wmi = response.vin.substring(0, 3); const wmi = response.vin.substring(0, 3);
const catalogEntry = CATALOG_MAP[wmi]; const catalogEntry = CATALOG_MAP[wmi];
const brand = catalogEntry?.brand || vehicle.brand || 'Unknown'; const brand = catalogEntry?.brand || vehicle.brand || "Unknown";
return { return {
brand: brand.toUpperCase(), brand: brand.toUpperCase(),
model: vehicle.model || 'Unknown', model: vehicle.model || "Unknown",
year: vehicle.year || extractYearFromVin(response.vin), year: vehicle.year || extractYearFromVin(response.vin),
series: vehicle.series || null, series: vehicle.series || null,
bodyType: vehicle.bodyType || null, bodyType: vehicle.bodyType || null,
@@ -148,15 +146,15 @@ export function mapEmexResponse(response: EmexScraperResponse): DecodedVehicle {
function extractYearFromVin(vin: string): number { function extractYearFromVin(vin: string): number {
const yearChar = vin.charAt(9).toUpperCase(); const yearChar = vin.charAt(9).toUpperCase();
const yearMap: Record<string, number> = { const yearMap: Record<string, number> = {
'1': 2001, "1": 2001,
'2': 2002, "2": 2002,
'3': 2003, "3": 2003,
'4': 2004, "4": 2004,
'5': 2005, "5": 2005,
'6': 2006, "6": 2006,
'7': 2007, "7": 2007,
'8': 2008, "8": 2008,
'9': 2009, "9": 2009,
A: 2010, A: 2010,
B: 2011, B: 2011,
C: 2012, C: 2012,
@@ -186,11 +184,9 @@ function extractYearFromVin(vin: string): number {
* Builds the raw response object for storage * Builds the raw response object for storage
* Includes category URLs for on-demand parts fetching * Includes category URLs for on-demand parts fetching
*/ */
function buildRawResponse( function buildRawResponse(response: EmexScraperResponse): Record<string, unknown> {
response: EmexScraperResponse,
): Record<string, unknown> {
return { return {
source: 'emex', // Explicit source identifier for on-demand loading source: "emex", // Explicit source identifier for on-demand loading
method: response.method, method: response.method,
vin: response.vin, vin: response.vin,
catalogCode: response.catalogCode, catalogCode: response.catalogCode,
@@ -204,7 +200,8 @@ function buildRawResponse(
// Store category tree for hierarchical insertion (QuickGroups.aspx) // Store category tree for hierarchical insertion (QuickGroups.aspx)
emexCategoryTree: response.categoryTree || [], emexCategoryTree: response.categoryTree || [],
// Store flat category URLs for on-demand parts fetching (fallback) // Store flat category URLs for on-demand parts fetching (fallback)
emexCategories: response.categories?.map((cat) => ({ emexCategories:
response.categories?.map((cat) => ({
gid: cat.gid, gid: cat.gid,
name: cat.name, name: cat.name,
url: cat.url, url: cat.url,
@@ -219,9 +216,7 @@ function buildRawResponse(
* Parts are NOT included here; they are fetched on-demand when the user * Parts are NOT included here; they are fetched on-demand when the user
* clicks a category. * clicks a category.
*/ */
function mapCategories( function mapCategories(categories?: EmexCategory[]): DecodedCategory[] {
categories?: EmexCategory[],
): DecodedCategory[] {
if (!categories || categories.length === 0) { if (!categories || categories.length === 0) {
return []; return [];
} }
@@ -246,27 +241,27 @@ function deriveIconName(categoryName: string): string | null {
const normalized = categoryName.toLowerCase(); const normalized = categoryName.toLowerCase();
const iconMap: Record<string, string> = { const iconMap: Record<string, string> = {
engine: 'engine', engine: "engine",
motor: 'engine', motor: "engine",
brake: 'brake', brake: "brake",
brakes: 'brake', brakes: "brake",
suspension: 'suspension', suspension: "suspension",
steering: 'steering', steering: "steering",
transmission: 'transmission', transmission: "transmission",
gearbox: 'transmission', gearbox: "transmission",
exhaust: 'exhaust', exhaust: "exhaust",
cooling: 'cooling', cooling: "cooling",
electrical: 'electrical', electrical: "electrical",
interior: 'interior', interior: "interior",
exterior: 'exterior', exterior: "exterior",
body: 'body', body: "body",
lighting: 'lighting', lighting: "lighting",
lights: 'lighting', lights: "lighting",
wheels: 'wheels', wheels: "wheels",
fuel: 'fuel', fuel: "fuel",
air: 'air', air: "air",
climate: 'climate', climate: "climate",
filters: 'filters', filters: "filters",
}; };
for (const [key, icon] of Object.entries(iconMap)) { for (const [key, icon] of Object.entries(iconMap)) {
@@ -281,16 +276,13 @@ function deriveIconName(categoryName: string): string | null {
/** /**
* Creates an empty/default DecodedVehicle for error cases * Creates an empty/default DecodedVehicle for error cases
*/ */
export function createEmptyDecodedVehicle( export function createEmptyDecodedVehicle(vin: string, errorMessage?: string): DecodedVehicle {
vin: string,
errorMessage?: string,
): DecodedVehicle {
const wmi = vin.substring(0, 3); const wmi = vin.substring(0, 3);
const catalogEntry = CATALOG_MAP[wmi]; const catalogEntry = CATALOG_MAP[wmi];
return { return {
brand: catalogEntry?.brand?.toUpperCase() || 'UNKNOWN', brand: catalogEntry?.brand?.toUpperCase() || "UNKNOWN",
model: 'Unknown', model: "Unknown",
year: extractYearFromVin(vin), year: extractYearFromVin(vin),
series: null, series: null,
bodyType: null, bodyType: null,
@@ -302,8 +294,8 @@ export function createEmptyDecodedVehicle(
colorCode: null, colorCode: null,
raw: { raw: {
vin, vin,
error: errorMessage || 'Vehicle data not found', error: errorMessage || "Vehicle data not found",
source: 'emexdwc.ae', source: "emexdwc.ae",
}, },
categories: [], categories: [],
}; };

View File

@@ -9,30 +9,31 @@
* QuickGroups.aspx, or QuickDetails.aspx — plain HTTP GET works. * QuickGroups.aspx, or QuickDetails.aspx — plain HTTP GET works.
*/ */
import * as path from "path";
import { import {
Injectable,
Logger,
BadRequestException, BadRequestException,
ServiceUnavailableException, Injectable,
InternalServerErrorException, InternalServerErrorException,
} from '@nestjs/common'; Logger,
import { ConfigService } from '@nestjs/config'; ServiceUnavailableException,
import * as path from 'path'; } from "@nestjs/common";
import type { ConfigService } from "@nestjs/config";
import { ProxyAgent } from 'undici'; import { ProxyAgent } from "undici";
import type { RedisService } from "../../redis/redis.service";
import type { EmexBrowserService } from "./emex.browser";
import { createEmptyDecodedVehicle, mapEmexResponse } from "./emex.mapper";
import { import {
EmexScraperResponse,
EmexCategoryTreeNode,
EmexPartsResult,
DecodedVehicle,
CATALOG_MAP, CATALOG_MAP,
} from './emex.types'; type DecodedVehicle,
import { mapEmexResponse, createEmptyDecodedVehicle } from './emex.mapper'; type EmexCategoryTreeNode,
import { EmexBrowserService } from './emex.browser'; type EmexPartsResult,
import { RedisService } from '../../redis/redis.service'; type EmexScraperResponse,
} from "./emex.types";
const EMEX_BASE_URL = 'https://emexdwc.ae'; const EMEX_BASE_URL = "https://emexdwc.ae";
const EMEX_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; const EMEX_UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
interface EmexHttpVehicle { interface EmexHttpVehicle {
label: string; label: string;
@@ -109,23 +110,20 @@ export class EmexService {
) { ) {
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/ // __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js // Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
const monorepoRoot = path.resolve(__dirname, '..', '..', '..', '..', '..'); const monorepoRoot = path.resolve(__dirname, "..", "..", "..", "..", "..");
const defaultPath = path.resolve(monorepoRoot, 'scripts/emex-vin-scraper.js'); const defaultPath = path.resolve(monorepoRoot, "scripts/emex-vin-scraper.js");
this.scraperPath = this.configService.get<string>( this.scraperPath = this.configService.get<string>("EMEX_SCRAPER_PATH", defaultPath);
'EMEX_SCRAPER_PATH',
defaultPath,
);
this.timeout = this.configService.get<number>('EMEX_TIMEOUT', 60000); this.timeout = this.configService.get<number>("EMEX_TIMEOUT", 60000);
this.debug = this.configService.get<boolean>('EMEX_DEBUG', false); this.debug = this.configService.get<boolean>("EMEX_DEBUG", false);
const useProxy = this.configService.get<string>('EMEX_USE_PROXY', 'true') === 'true'; const useProxy = this.configService.get<string>("EMEX_USE_PROXY", "true") === "true";
if (useProxy) { if (useProxy) {
const host = this.configService.get<string>('EMEX_PROXY_HOST', '74.81.81.81'); const host = this.configService.get<string>("EMEX_PROXY_HOST", "74.81.81.81");
const portStart = this.configService.get<number>('EMEX_PROXY_PORT_START', 10001); const portStart = this.configService.get<number>("EMEX_PROXY_PORT_START", 10001);
const portEnd = this.configService.get<number>('EMEX_PROXY_PORT_END', 10099); const portEnd = this.configService.get<number>("EMEX_PROXY_PORT_END", 10099);
const user = this.configService.get<string>('EMEX_PROXY_USER', '1726bbe361918676d44e'); const user = this.configService.get<string>("EMEX_PROXY_USER", "1726bbe361918676d44e");
const pass = this.configService.get<string>('EMEX_PROXY_PASS', 'f11c7b6128cc86c6'); const pass = this.configService.get<string>("EMEX_PROXY_PASS", "f11c7b6128cc86c6");
const port = Math.floor(Math.random() * (portEnd - portStart + 1)) + portStart; const port = Math.floor(Math.random() * (portEnd - portStart + 1)) + portStart;
this.proxyAgent = new ProxyAgent({ this.proxyAgent = new ProxyAgent({
uri: `http://${user}:${pass}@${host}:${port}`, uri: `http://${user}:${pass}@${host}:${port}`,
@@ -169,7 +167,7 @@ export class EmexService {
try { try {
this.logger.log(`Loading EMEX scraper module from: ${this.scraperPath}`); this.logger.log(`Loading EMEX scraper module from: ${this.scraperPath}`);
const fs = require('fs'); const fs = require("fs");
if (!fs.existsSync(this.scraperPath)) { if (!fs.existsSync(this.scraperPath)) {
this.logger.error(`Scraper file not found at: ${this.scraperPath}`); this.logger.error(`Scraper file not found at: ${this.scraperPath}`);
this.logger.error(`Current working directory: ${process.cwd()}`); this.logger.error(`Current working directory: ${process.cwd()}`);
@@ -181,17 +179,12 @@ export class EmexService {
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
this.scraperModule = require(this.scraperPath) as EmexScraperModule; this.scraperModule = require(this.scraperPath) as EmexScraperModule;
this.logger.log('EMEX scraper module loaded successfully'); this.logger.log("EMEX scraper module loaded successfully");
this.isInitialized = true; this.isInitialized = true;
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;
this.logger.error( this.logger.error(`Failed to load EMEX scraper module: ${err.message}`, err.stack);
`Failed to load EMEX scraper module: ${err.message}`, throw new InternalServerErrorException("EMEX servis modulu yuklenemedi");
err.stack,
);
throw new InternalServerErrorException(
'EMEX servis modulu yuklenemedi',
);
} }
} }
@@ -206,7 +199,7 @@ export class EmexService {
await this.initializeScraper(); await this.initializeScraper();
if (!this.scraperModule) { if (!this.scraperModule) {
throw new InternalServerErrorException('EMEX scraper modulu yuklenemedi'); throw new InternalServerErrorException("EMEX scraper modulu yuklenemedi");
} }
const { page, release } = await this.browserService.acquirePage(); const { page, release } = await this.browserService.acquirePage();
@@ -221,20 +214,18 @@ export class EmexService {
*/ */
private validateVin(vin: string): void { private validateVin(vin: string): void {
if (!vin) { if (!vin) {
throw new BadRequestException('VIN numarasi gereklidir'); throw new BadRequestException("VIN numarasi gereklidir");
} }
const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, ''); const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, "");
if (cleanVin.length !== 17) { if (cleanVin.length !== 17) {
throw new BadRequestException( throw new BadRequestException("VIN numarasi 17 karakter olmalidir");
'VIN numarasi 17 karakter olmalidir',
);
} }
if (/[IOQ]/i.test(cleanVin)) { if (/[IOQ]/i.test(cleanVin)) {
throw new BadRequestException( throw new BadRequestException(
'VIN numarasi gecersiz karakterler iceriyor (I, O, Q kullanilamaz)', "VIN numarasi gecersiz karakterler iceriyor (I, O, Q kullanilamaz)",
); );
} }
} }
@@ -248,7 +239,7 @@ export class EmexService {
*/ */
private async fetchEmexHtml(url: string): Promise<string> { private async fetchEmexHtml(url: string): Promise<string> {
const res = await fetch(url, { const res = await fetch(url, {
headers: { 'User-Agent': EMEX_UA, 'Accept': 'text/html,application/xhtml+xml' }, headers: { "User-Agent": EMEX_UA, Accept: "text/html,application/xhtml+xml" },
signal: AbortSignal.timeout(this.timeout), signal: AbortSignal.timeout(this.timeout),
...(this.proxyAgent ? { dispatcher: this.proxyAgent } : {}), ...(this.proxyAgent ? { dispatcher: this.proxyAgent } : {}),
} as RequestInit); } as RequestInit);
@@ -267,24 +258,25 @@ export class EmexService {
const vehicles: EmexHttpVehicle[] = []; const vehicles: EmexHttpVehicle[] = [];
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
while ((m = linkRx.exec(html)) !== null) { while ((m = linkRx.exec(html)) !== null) {
const href = m[1].replace(/&amp;/g, '&'); const href = m[1].replace(/&amp;/g, "&");
if (seen.has(href)) continue; if (seen.has(href)) continue;
seen.add(href); seen.add(href);
const label = m[2].trim(); const label = m[2].trim();
const params = new URLSearchParams(href.replace('Vehicle.aspx?', '')); const params = new URLSearchParams(href.replace("Vehicle.aspx?", ""));
const c = params.get('c'); const c = params.get("c");
const vid = params.get('vid'); const vid = params.get("vid");
const ssd = params.get('ssd'); const ssd = params.get("ssd");
const modelMatch = label.match(/^([^\[]+)/); const modelMatch = label.match(/^([^\[]+)/);
const yearMatch = label.match(/\((\d{4})/); const yearMatch = label.match(/\((\d{4})/);
vehicles.push({ vehicles.push({
label, label,
model: modelMatch ? modelMatch[1].trim() : label, model: modelMatch ? modelMatch[1].trim() : label,
yearFrom: yearMatch ? parseInt(yearMatch[1], 10) : null, yearFrom: yearMatch ? Number.parseInt(yearMatch[1], 10) : null,
catalogCode: c, catalogCode: c,
vid, vid,
ssd, ssd,
quickGroupsUrl: c && vid != null && ssd quickGroupsUrl:
c && vid != null && ssd
? `${EMEX_BASE_URL}/QuickGroups.aspx?c=${c}&vid=${vid}&ssd=${encodeURIComponent(ssd)}` ? `${EMEX_BASE_URL}/QuickGroups.aspx?c=${c}&vid=${vid}&ssd=${encodeURIComponent(ssd)}`
: null, : null,
}); });
@@ -301,12 +293,12 @@ export class EmexService {
const cats: EmexHttpCategory[] = []; const cats: EmexHttpCategory[] = [];
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
while ((m = catRx.exec(html)) !== null) { while ((m = catRx.exec(html)) !== null) {
const href = m[1].replace(/&amp;/g, '&'); const href = m[1].replace(/&amp;/g, "&");
const name = m[2].trim(); const name = m[2].trim();
if (name.length < 2 || seen.has(href)) continue; if (name.length < 2 || seen.has(href)) continue;
seen.add(href); seen.add(href);
const params = new URLSearchParams(href.replace('QuickDetails.aspx?', '')); const params = new URLSearchParams(href.replace("QuickDetails.aspx?", ""));
cats.push({ gid: params.get('gid'), name, url: `${EMEX_BASE_URL}/${href}` }); cats.push({ gid: params.get("gid"), name, url: `${EMEX_BASE_URL}/${href}` });
} }
return cats; return cats;
} }
@@ -318,13 +310,25 @@ export class EmexService {
if (!c) return null; if (!c) return null;
const upper = c.toUpperCase(); const upper = c.toUpperCase();
const prefixes: [string, string][] = [ const prefixes: [string, string][] = [
['BMW', 'BMW'], ['MB', 'Mercedes-Benz'], ['AU', 'Audi'], ["BMW", "BMW"],
['VW', 'Volkswagen'], ['FFIAT', 'Fiat'], ['RFIAT', 'Alfa Romeo'], ["MB", "Mercedes-Benz"],
['FORD', 'Ford'], ['RENAULT', 'Renault'], ['TOYOTA', 'Toyota'], ["AU", "Audi"],
['HONDA', 'Honda'], ['KIA', 'Kia'], ['HYUNDAI', 'Hyundai'], ["VW", "Volkswagen"],
['PORSCHE', 'Porsche'], ['SUBARU', 'Subaru'], ['MAZDA', 'Mazda'], ["FFIAT", "Fiat"],
['CPSA', 'Citroën/Peugeot'], ['VOLVO', 'Volvo'], ['NISSAN', 'Nissan'], ["RFIAT", "Alfa Romeo"],
['OPEL', 'Opel'], ["FORD", "Ford"],
["RENAULT", "Renault"],
["TOYOTA", "Toyota"],
["HONDA", "Honda"],
["KIA", "Kia"],
["HYUNDAI", "Hyundai"],
["PORSCHE", "Porsche"],
["SUBARU", "Subaru"],
["MAZDA", "Mazda"],
["CPSA", "Citroën/Peugeot"],
["VOLVO", "Volvo"],
["NISSAN", "Nissan"],
["OPEL", "Opel"],
]; ];
for (const [prefix, brand] of prefixes) { for (const [prefix, brand] of prefixes) {
if (upper.startsWith(prefix)) return brand; if (upper.startsWith(prefix)) return brand;
@@ -354,7 +358,7 @@ export class EmexService {
// Determine brand: prefer CATALOG_MAP lookup, then catalog code heuristic // Determine brand: prefer CATALOG_MAP lookup, then catalog code heuristic
const wmi = vin.substring(0, 3).toUpperCase(); const wmi = vin.substring(0, 3).toUpperCase();
const catalogEntry = CATALOG_MAP[wmi]; const catalogEntry = CATALOG_MAP[wmi];
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || 'Unknown'; const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || "Unknown";
// Fetch categories from QuickGroups.aspx (fast HTTP, no browser) // Fetch categories from QuickGroups.aspx (fast HTTP, no browser)
let categories: EmexHttpCategory[] = []; let categories: EmexHttpCategory[] = [];
@@ -371,10 +375,10 @@ export class EmexService {
// Build a response compatible with mapEmexResponse // Build a response compatible with mapEmexResponse
const response: EmexScraperResponse = { const response: EmexScraperResponse = {
success: true, success: true,
source: 'emexdwc.ae', source: "emexdwc.ae",
method: 'vin_url', method: "vin_url",
vin, vin,
catalogCode: v.catalogCode || '', catalogCode: v.catalogCode || "",
ssd: v.ssd || undefined, ssd: v.ssd || undefined,
vehicle: { vehicle: {
brand, brand,
@@ -388,7 +392,7 @@ export class EmexService {
driveType: null, driveType: null,
}, },
quickGroupsUrl: v.quickGroupsUrl || null, quickGroupsUrl: v.quickGroupsUrl || null,
categories: categories.map((c) => ({ gid: c.gid || '', name: c.name, url: c.url })), categories: categories.map((c) => ({ gid: c.gid || "", name: c.name, url: c.url })),
categoryTree: [], categoryTree: [],
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
@@ -403,43 +407,45 @@ export class EmexService {
* - `{ type: 'notFound' }` — VIN not in EMEX * - `{ type: 'notFound' }` — VIN not in EMEX
* - `{ type: 'error' }` — fetch failed * - `{ type: 'error' }` — fetch failed
*/ */
async decodeVinOrCandidates(vin: string): Promise< async decodeVinOrCandidates(
| { type: 'vehicle'; vehicle: DecodedVehicle } vin: string,
| { type: 'candidates'; candidates: EmexCandidate[] } ): Promise<
| { type: 'notFound' } | { type: "vehicle"; vehicle: DecodedVehicle }
| { type: 'error' } | { type: "candidates"; candidates: EmexCandidate[] }
| { type: "notFound" }
| { type: "error" }
> { > {
try { try {
const vinUrl = `${EMEX_BASE_URL}/Vehicles.aspx?ft=findByVIN&c=&ssd=&vin=${vin}`; const vinUrl = `${EMEX_BASE_URL}/Vehicles.aspx?ft=findByVIN&c=&ssd=&vin=${vin}`;
const html = await this.fetchEmexHtml(vinUrl); const html = await this.fetchEmexHtml(vinUrl);
const vehicleList = this.parseVehiclesList(html); const vehicleList = this.parseVehiclesList(html);
if (vehicleList.length === 0) return { type: 'notFound' }; if (vehicleList.length === 0) return { type: "notFound" };
if (vehicleList.length > 1) { if (vehicleList.length > 1) {
const candidates: EmexCandidate[] = vehicleList.map((v, i) => { const candidates: EmexCandidate[] = vehicleList.map((v, i) => {
const params: Array<{ key: string; idx: string; value: string }> = []; const params: Array<{ key: string; idx: string; value: string }> = [];
if (v.yearFrom) params.push({ key: 'year', idx: '0', value: String(v.yearFrom) }); if (v.yearFrom) params.push({ key: "year", idx: "0", value: String(v.yearFrom) });
if (v.catalogCode) params.push({ key: 'catalog', idx: '1', value: v.catalogCode }); if (v.catalogCode) params.push({ key: "catalog", idx: "1", value: v.catalogCode });
return { return {
id: String(i), id: String(i),
name: v.label, name: v.label,
parameters: params, parameters: params,
catalogId: v.catalogCode || '', catalogId: v.catalogCode || "",
_index: i, _index: i,
_quickGroupsUrl: v.quickGroupsUrl, _quickGroupsUrl: v.quickGroupsUrl,
_ssd: v.ssd, _ssd: v.ssd,
_vid: v.vid, _vid: v.vid,
}; };
}); });
return { type: 'candidates', candidates }; return { type: "candidates", candidates };
} }
// Single result — decode directly // Single result — decode directly
const v = vehicleList[0]; const v = vehicleList[0];
const wmi = vin.substring(0, 3).toUpperCase(); const wmi = vin.substring(0, 3).toUpperCase();
const catalogEntry = CATALOG_MAP[wmi]; const catalogEntry = CATALOG_MAP[wmi];
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || 'Unknown'; const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || "Unknown";
let categories: EmexHttpCategory[] = []; let categories: EmexHttpCategory[] = [];
if (v.quickGroupsUrl) { if (v.quickGroupsUrl) {
@@ -454,10 +460,10 @@ export class EmexService {
const response: EmexScraperResponse = { const response: EmexScraperResponse = {
success: true, success: true,
source: 'emexdwc.ae', source: "emexdwc.ae",
method: 'vin_url', method: "vin_url",
vin, vin,
catalogCode: v.catalogCode || '', catalogCode: v.catalogCode || "",
ssd: v.ssd || undefined, ssd: v.ssd || undefined,
vehicle: { vehicle: {
brand, brand,
@@ -471,15 +477,15 @@ export class EmexService {
driveType: null, driveType: null,
}, },
quickGroupsUrl: v.quickGroupsUrl || null, quickGroupsUrl: v.quickGroupsUrl || null,
categories: categories.map((c) => ({ gid: c.gid || '', name: c.name, url: c.url })), categories: categories.map((c) => ({ gid: c.gid || "", name: c.name, url: c.url })),
categoryTree: [], categoryTree: [],
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
return { type: 'vehicle', vehicle: mapEmexResponse(response) }; return { type: "vehicle", vehicle: mapEmexResponse(response) };
} catch (err) { } catch (err) {
this.logger.warn(`decodeVinOrCandidates failed: ${(err as Error).message}`); this.logger.warn(`decodeVinOrCandidates failed: ${(err as Error).message}`);
return { type: 'error' }; return { type: "error" };
} }
} }
@@ -494,7 +500,9 @@ export class EmexService {
const vehicleList = this.parseVehiclesList(vinHtml); const vehicleList = this.parseVehiclesList(vinHtml);
if (index < 0 || index >= vehicleList.length) { if (index < 0 || index >= vehicleList.length) {
this.logger.warn(`EMEX decodeVinByIndex: index ${index} out of range (${vehicleList.length} vehicles)`); this.logger.warn(
`EMEX decodeVinByIndex: index ${index} out of range (${vehicleList.length} vehicles)`,
);
return null; return null;
} }
@@ -503,7 +511,7 @@ export class EmexService {
const wmi = vin.substring(0, 3).toUpperCase(); const wmi = vin.substring(0, 3).toUpperCase();
const catalogEntry = CATALOG_MAP[wmi]; const catalogEntry = CATALOG_MAP[wmi];
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || 'Unknown'; const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || "Unknown";
let categories: EmexHttpCategory[] = []; let categories: EmexHttpCategory[] = [];
if (v.quickGroupsUrl) { if (v.quickGroupsUrl) {
@@ -511,16 +519,18 @@ export class EmexService {
const qgHtml = await this.fetchEmexHtml(v.quickGroupsUrl); const qgHtml = await this.fetchEmexHtml(v.quickGroupsUrl);
categories = this.parseCategoryList(qgHtml); categories = this.parseCategoryList(qgHtml);
} catch (err) { } catch (err) {
this.logger.warn(`EMEX decodeVinByIndex category fetch failed: ${(err as Error).message}`); this.logger.warn(
`EMEX decodeVinByIndex category fetch failed: ${(err as Error).message}`,
);
} }
} }
const response: EmexScraperResponse = { const response: EmexScraperResponse = {
success: true, success: true,
source: 'emexdwc.ae', source: "emexdwc.ae",
method: 'vin_url', method: "vin_url",
vin, vin,
catalogCode: v.catalogCode || '', catalogCode: v.catalogCode || "",
ssd: v.ssd || undefined, ssd: v.ssd || undefined,
vehicle: { vehicle: {
brand, brand,
@@ -534,7 +544,7 @@ export class EmexService {
driveType: null, driveType: null,
}, },
quickGroupsUrl: v.quickGroupsUrl || null, quickGroupsUrl: v.quickGroupsUrl || null,
categories: categories.map((c) => ({ gid: c.gid || '', name: c.name, url: c.url })), categories: categories.map((c) => ({ gid: c.gid || "", name: c.name, url: c.url })),
categoryTree: [], categoryTree: [],
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
@@ -552,7 +562,7 @@ export class EmexService {
* Fallback: Playwright browser scraper (slower, used if HTTP fails). * Fallback: Playwright browser scraper (slower, used if HTTP fails).
*/ */
async decodeVin(vin: string): Promise<DecodedVehicle> { async decodeVin(vin: string): Promise<DecodedVehicle> {
const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, ''); const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, "");
this.validateVin(cleanVin); this.validateVin(cleanVin);
@@ -564,13 +574,11 @@ export class EmexService {
try { try {
const result = await this.decodeVinHttp(cleanVin); const result = await this.decodeVinHttp(cleanVin);
if (result) { if (result) {
this.logger.log( this.logger.log(`EMEX HTTP decode OK: ${result.brand} ${result.model} (${result.year})`);
`EMEX HTTP decode OK: ${result.brand} ${result.model} (${result.year})`,
);
return result; return result;
} }
// VIN not in EMEX — return empty rather than hitting browser // VIN not in EMEX — return empty rather than hitting browser
return createEmptyDecodedVehicle(cleanVin, 'Vehicle not found in EMEX database'); return createEmptyDecodedVehicle(cleanVin, "Vehicle not found in EMEX database");
} catch (httpErr) { } catch (httpErr) {
const err = httpErr as Error; const err = httpErr as Error;
this.logger.warn(`EMEX HTTP decode failed (${err.message}), falling back to browser`); this.logger.warn(`EMEX HTTP decode failed (${err.message}), falling back to browser`);
@@ -584,28 +592,18 @@ export class EmexService {
const scraper = instance.scraper; const scraper = instance.scraper;
release = instance.release; release = instance.release;
const response = await this.executeWithTimeout( const response = await this.executeWithTimeout(scraper.searchByVIN(cleanVin), this.timeout);
scraper.searchByVIN(cleanVin),
this.timeout,
);
if (this.debug) { if (this.debug) {
this.logger.debug( this.logger.debug(`EMEX browser raw response: ${JSON.stringify(response, null, 2)}`);
`EMEX browser raw response: ${JSON.stringify(response, null, 2)}`,
);
} }
if (!response.success) { if (!response.success) {
this.logger.warn( this.logger.warn(`EMEX browser search unsuccessful: ${response.message || response.error}`);
`EMEX browser search unsuccessful: ${response.message || response.error}`,
);
if (response.vehicle && response.vehicle.brand) { if (response.vehicle && response.vehicle.brand) {
return mapEmexResponse(response); return mapEmexResponse(response);
} }
return createEmptyDecodedVehicle( return createEmptyDecodedVehicle(cleanVin, response.message || response.error);
cleanVin,
response.message || response.error,
);
} }
const decodedVehicle = mapEmexResponse(response); const decodedVehicle = mapEmexResponse(response);
@@ -624,17 +622,15 @@ export class EmexService {
throw err; throw err;
} }
if (err.message?.includes('timeout') || err.name === 'TimeoutError') { if (err.message?.includes("timeout") || err.name === "TimeoutError") {
this.logger.error(`VIN decode timeout for: ${cleanVin}`); this.logger.error(`VIN decode timeout for: ${cleanVin}`);
throw new ServiceUnavailableException( throw new ServiceUnavailableException(
'EMEX servisi zaman asimina ugradi. Lutfen tekrar deneyin.', "EMEX servisi zaman asimina ugradi. Lutfen tekrar deneyin.",
); );
} }
this.logger.error(`VIN decode error: ${err.message}`, err.stack); this.logger.error(`VIN decode error: ${err.message}`, err.stack);
throw new ServiceUnavailableException( throw new ServiceUnavailableException("VIN sorgulama sirasinda bir hata olustu");
'VIN sorgulama sirasinda bir hata olustu',
);
} finally { } finally {
if (release) { if (release) {
try { try {
@@ -650,16 +646,13 @@ export class EmexService {
/** /**
* Executes a promise with timeout * Executes a promise with timeout
*/ */
private async executeWithTimeout<T>( private async executeWithTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
promise: Promise<T>,
timeoutMs: number,
): Promise<T> {
let timeoutId: NodeJS.Timeout; let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => { const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
const error = new Error(`Operation timed out after ${timeoutMs}ms`); const error = new Error(`Operation timed out after ${timeoutMs}ms`);
error.name = 'TimeoutError'; error.name = "TimeoutError";
reject(error); reject(error);
}, timeoutMs); }, timeoutMs);
}); });
@@ -709,7 +702,7 @@ export class EmexService {
*/ */
async fetchCategoryParts(categoryUrl: string): Promise<EmexPartsResult> { async fetchCategoryParts(categoryUrl: string): Promise<EmexPartsResult> {
if (!categoryUrl) { if (!categoryUrl) {
this.logger.warn('fetchCategoryParts called with empty URL'); this.logger.warn("fetchCategoryParts called with empty URL");
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 }; return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
} }
@@ -723,10 +716,7 @@ export class EmexService {
const scraper = instance.scraper; const scraper = instance.scraper;
release = instance.release; release = instance.release;
const result = await this.executeWithTimeout( const result = await this.executeWithTimeout(scraper.getParts(categoryUrl), this.timeout);
scraper.getParts(categoryUrl),
this.timeout,
);
if (result && result.parts.length > 0) { if (result && result.parts.length > 0) {
this.logger.log(`Fetched ${result.parts.length} parts from category`); this.logger.log(`Fetched ${result.parts.length} parts from category`);
@@ -763,15 +753,15 @@ export class EmexService {
const yearChar = vin.charAt(9).toUpperCase(); const yearChar = vin.charAt(9).toUpperCase();
const yearMap: Record<string, number> = { const yearMap: Record<string, number> = {
'1': 2001, "1": 2001,
'2': 2002, "2": 2002,
'3': 2003, "3": 2003,
'4': 2004, "4": 2004,
'5': 2005, "5": 2005,
'6': 2006, "6": 2006,
'7': 2007, "7": 2007,
'8': 2008, "8": 2008,
'9': 2009, "9": 2009,
A: 2010, A: 2010,
B: 2011, B: 2011,
C: 2012, C: 2012,

View File

@@ -126,7 +126,7 @@ export interface EmexWizardOption {
export interface EmexScraperResponse { export interface EmexScraperResponse {
success: boolean; success: boolean;
source: string; source: string;
method: 'api' | 'vin_url' | 'wizard' | 'html_parse' | 'fallback'; method: "api" | "vin_url" | "wizard" | "html_parse" | "fallback";
vin: string; vin: string;
catalogCode: string; catalogCode: string;
ssd?: string; ssd?: string;
@@ -231,33 +231,33 @@ export interface CatalogEntry {
* WMI (World Manufacturer Identifier) to catalog mapping * WMI (World Manufacturer Identifier) to catalog mapping
*/ */
export const CATALOG_MAP: Record<string, CatalogEntry> = { export const CATALOG_MAP: Record<string, CatalogEntry> = {
WBA: { code: 'BMW202501', brand: 'BMW' }, WBA: { code: "BMW202501", brand: "BMW" },
WBS: { code: 'BMW202501', brand: 'BMW' }, WBS: { code: "BMW202501", brand: "BMW" },
WBY: { code: 'BMW202501', brand: 'BMW' }, WBY: { code: "BMW202501", brand: "BMW" },
WDB: { code: 'MB201810', brand: 'Mercedes-Benz' }, WDB: { code: "MB201810", brand: "Mercedes-Benz" },
WDD: { code: 'MB201810', brand: 'Mercedes-Benz' }, WDD: { code: "MB201810", brand: "Mercedes-Benz" },
WDC: { code: 'MB201810', brand: 'Mercedes-Benz' }, WDC: { code: "MB201810", brand: "Mercedes-Benz" },
WDF: { code: 'MB201810', brand: 'Mercedes-Benz' }, WDF: { code: "MB201810", brand: "Mercedes-Benz" },
WAU: { code: 'AU1587', brand: 'Audi' }, WAU: { code: "AU1587", brand: "Audi" },
WVW: { code: 'VW1587', brand: 'Volkswagen' }, WVW: { code: "VW1587", brand: "Volkswagen" },
WVG: { code: 'VW1587', brand: 'Volkswagen' }, WVG: { code: "VW1587", brand: "Volkswagen" },
VF1: { code: 'RENAULT201910', brand: 'Renault' }, VF1: { code: "RENAULT201910", brand: "Renault" },
VF7: { code: 'CPSA01', brand: 'Peugeot' }, VF7: { code: "CPSA01", brand: "Peugeot" },
VF3: { code: 'CPSA01', brand: 'Peugeot' }, VF3: { code: "CPSA01", brand: "Peugeot" },
ZFA: { code: 'CFIAT84', brand: 'Fiat' }, ZFA: { code: "CFIAT84", brand: "Fiat" },
ZAR: { code: 'RFIAT84', brand: 'Alfa Romeo' }, ZAR: { code: "RFIAT84", brand: "Alfa Romeo" },
WF0: { code: 'FORD202201', brand: 'Ford' }, WF0: { code: "FORD202201", brand: "Ford" },
NM0: { code: 'FORD202201', brand: 'Ford' }, NM0: { code: "FORD202201", brand: "Ford" },
JTD: { code: 'TOYOTA00', brand: 'Toyota' }, JTD: { code: "TOYOTA00", brand: "Toyota" },
JTE: { code: 'TOYOTA00', brand: 'Toyota' }, JTE: { code: "TOYOTA00", brand: "Toyota" },
SHH: { code: 'HONDA00', brand: 'Honda' }, SHH: { code: "HONDA00", brand: "Honda" },
KNM: { code: 'HYUNDAI00', brand: 'Hyundai' }, KNM: { code: "HYUNDAI00", brand: "Hyundai" },
KNA: { code: 'KIA00', brand: 'Kia' }, KNA: { code: "KIA00", brand: "Kia" },
WP0: { code: 'PO799', brand: 'Porsche' }, WP0: { code: "PO799", brand: "Porsche" },
WP1: { code: 'PO799', brand: 'Porsche' }, WP1: { code: "PO799", brand: "Porsche" },
JF1: { code: 'SUBARU201802', brand: 'Subaru' }, JF1: { code: "SUBARU201802", brand: "Subaru" },
JF2: { code: 'SUBARU201802', brand: 'Subaru' }, JF2: { code: "SUBARU201802", brand: "Subaru" },
JMZ: { code: 'MAZDA2020', brand: 'Mazda' }, JMZ: { code: "MAZDA2020", brand: "Mazda" },
JM1: { code: 'MAZDA2020', brand: 'Mazda' }, JM1: { code: "MAZDA2020", brand: "Mazda" },
JM3: { code: 'MAZDA2020', brand: 'Mazda' }, JM3: { code: "MAZDA2020", brand: "Mazda" },
}; };

View File

@@ -13,15 +13,10 @@
* 19:00-09:00 → on-demand only: capture only when needed * 19:00-09:00 → on-demand only: capture only when needed
*/ */
import { import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
Injectable, import type { ConfigService } from "@nestjs/config";
Logger,
OnModuleInit,
OnModuleDestroy,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Browser, BrowserContext } from "playwright"; import type { Browser, BrowserContext } from "playwright";
import type { PcatJwtToken, JwtSlot, PcatSession } from "./parts-catalogs.types"; import type { JwtSlot, PcatJwtToken, PcatSession } from "./parts-catalogs.types";
const TOKEN_TTL = 600; // seconds — TWS- has no built-in expiry, refresh aggressively const TOKEN_TTL = 600; // seconds — TWS- has no built-in expiry, refresh aggressively
const REFRESH_BUFFER = 90; // Refresh 90s before expiry const REFRESH_BUFFER = 90; // Refresh 90s before expiry
@@ -113,17 +108,10 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
private readonly proxyPass: string; private readonly proxyPass: string;
constructor(private configService: ConfigService) { constructor(private configService: ConfigService) {
this.useProxy = this.useProxy = this.configService.get<string>("PCAT_USE_PROXY", "true") === "true";
this.configService.get<string>("PCAT_USE_PROXY", "true") === "true";
this.proxyHost = this.configService.get<string>("PCAT_PROXY_HOST", DI_HOST); this.proxyHost = this.configService.get<string>("PCAT_PROXY_HOST", DI_HOST);
this.proxyUser = this.configService.get<string>( this.proxyUser = this.configService.get<string>("PCAT_PROXY_USER", DI_DEFAULT_USER);
"PCAT_PROXY_USER", this.proxyPass = this.configService.get<string>("PCAT_PROXY_PASS", DI_DEFAULT_PASS);
DI_DEFAULT_USER,
);
this.proxyPass = this.configService.get<string>(
"PCAT_PROXY_PASS",
DI_DEFAULT_PASS,
);
} }
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
@@ -131,9 +119,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
await this.launchBrowser(); await this.launchBrowser();
this.logger.log("Browser launched for JWT capture"); this.logger.log("Browser launched for JWT capture");
} catch (err) { } catch (err) {
this.logger.error( this.logger.error(`Failed to launch browser on init: ${(err as Error).message}`);
`Failed to launch browser on init: ${(err as Error).message}`,
);
} }
// Start business hours scheduling // Start business hours scheduling
@@ -279,9 +265,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
} }
const ttl = jwt.exp - Math.floor(Date.now() / 1000); const ttl = jwt.exp - Math.floor(Date.now() / 1000);
const refreshIn = this.isBusinessHours() const refreshIn = this.isBusinessHours() ? Math.max(ttl - REFRESH_BUFFER, 30) : null;
? Math.max(ttl - REFRESH_BUFFER, 30)
: null;
this.logger.log( this.logger.log(
`JWT pool: slot captured, TTL: ${ttl}s${refreshIn ? `, refresh in ${refreshIn}s` : ""}, pool size: ${this.pool.length}`, `JWT pool: slot captured, TTL: ${ttl}s${refreshIn ? `, refresh in ${refreshIn}s` : ""}, pool size: ${this.pool.length}`,
); );
@@ -363,8 +347,8 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
hour12: false, hour12: false,
}).formatToParts(new Date()); }).formatToParts(new Date());
const hour = parseInt(parts.find((p) => p.type === "hour")!.value, 10); const hour = Number.parseInt(parts.find((p) => p.type === "hour")!.value, 10);
const minute = parseInt(parts.find((p) => p.type === "minute")!.value, 10); const minute = Number.parseInt(parts.find((p) => p.type === "minute")!.value, 10);
return { hour, minute }; return { hour, minute };
} }
@@ -478,7 +462,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
// All on cooldown — pick the one with oldest usage // All on cooldown — pick the one with oldest usage
let oldestIdx = 0; let oldestIdx = 0;
let oldestTime = Infinity; let oldestTime = Number.POSITIVE_INFINITY;
for (let i = 0; i < JWT_SITES.length; i++) { for (let i = 0; i < JWT_SITES.length; i++) {
const lastUsed = this.siteLastUsedAt.get(JWT_SITES[i]) || 0; const lastUsed = this.siteLastUsedAt.get(JWT_SITES[i]) || 0;
if (lastUsed < oldestTime) { if (lastUsed < oldestTime) {
@@ -496,10 +480,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
// ─── JWT capture via Playwright ─────────────────────────── // ─── JWT capture via Playwright ───────────────────────────
private async attemptCapture( private async attemptCapture(siteUrl: string, port: number): Promise<PcatJwtToken | null> {
siteUrl: string,
port: number,
): Promise<PcatJwtToken | null> {
let context: BrowserContext | null = null; let context: BrowserContext | null = null;
const startTime = Date.now(); const startTime = Date.now();
@@ -574,9 +555,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
}); });
} catch (navErr) { } catch (navErr) {
// Navigation may timeout but JWT could still be captured // Navigation may timeout but JWT could still be captured
this.logger.debug( this.logger.debug(`Navigation ended: ${(navErr as Error).message?.slice(0, 80)}`);
`Navigation ended: ${(navErr as Error).message?.slice(0, 80)}`,
);
} }
// Poll for token // Poll for token
@@ -588,9 +567,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
if (capturedToken) { if (capturedToken) {
this.logger.log( this.logger.log(`Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`);
`Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`,
);
return capturedToken; return capturedToken;
} }

View File

@@ -7,14 +7,14 @@
*/ */
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service"; import type { RedisService } from "../../redis/redis.service";
import { RedisService } from "../../redis/redis.service"; import type { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
import type { import type {
PcatVinResult,
PcatCar, PcatCar,
PcatGroup, PcatGroup,
PcatPartsResult, PcatPartsResult,
PcatSession, PcatSession,
PcatVinResult,
} from "./parts-catalogs.types"; } from "./parts-catalogs.types";
const API_BASE = "https://gui.parts-catalogs.com/v3/api/proxy"; const API_BASE = "https://gui.parts-catalogs.com/v3/api/proxy";
@@ -95,10 +95,7 @@ export class PartsCatalogsService {
if (groupId) params.groupId = groupId; if (groupId) params.groupId = groupId;
if (carParams) Object.assign(params, carParams); if (carParams) Object.assign(params, carParams);
const data = await this.fetchWithAuth( const data = await this.fetchWithAuth(`/catalogs/${catalogId}/groups2/`, params);
`/catalogs/${catalogId}/groups2/`,
params,
);
if (!Array.isArray(data)) return []; if (!Array.isArray(data)) return [];
@@ -125,10 +122,7 @@ export class PartsCatalogsService {
const params: Record<string, string> = { carId, groupId }; const params: Record<string, string> = { carId, groupId };
if (carParams) Object.assign(params, carParams); if (carParams) Object.assign(params, carParams);
const data = await this.fetchWithAuth( const data = await this.fetchWithAuth(`/catalogs/${catalogId}/parts2`, params);
`/catalogs/${catalogId}/parts2`,
params,
);
if (!data || typeof data !== "object") return null; if (!data || typeof data !== "object") return null;
@@ -171,10 +165,7 @@ export class PartsCatalogsService {
// ─── Private ───────────────────────────────────────────── // ─── Private ─────────────────────────────────────────────
private async fetchWithAuth( private async fetchWithAuth(endpoint: string, params?: Record<string, string>): Promise<any> {
endpoint: string,
params?: Record<string, string>,
): Promise<any> {
const maxRetries = 2; const maxRetries = 2;
let session: PcatSession | null = null; let session: PcatSession | null = null;
@@ -229,9 +220,7 @@ export class PartsCatalogsService {
} }
const text = await response.text().catch(() => ""); const text = await response.text().catch(() => "");
throw new Error( throw new Error(`HTTP ${response.status} from ${endpoint}: ${text.slice(0, 200)}`);
`HTTP ${response.status} from ${endpoint}: ${text.slice(0, 200)}`,
);
} catch (err) { } catch (err) {
if ((err as Error).name === "TimeoutError") { if ((err as Error).name === "TimeoutError") {
this.logger.warn(`Timeout on ${endpoint}, attempt ${attempt + 1}`); this.logger.warn(`Timeout on ${endpoint}, attempt ${attempt + 1}`);

View File

@@ -6,7 +6,7 @@
* response normalization. * response normalization.
*/ */
import type { PL24DecodedVehicle, PL24DecodedCategory, PL24Part } from "../pl24.types"; import type { PL24DecodedCategory, PL24DecodedVehicle, PL24Part } from "../pl24.types";
export abstract class BasePL24Parser { export abstract class BasePL24Parser {
abstract readonly brandName: string; abstract readonly brandName: string;

View File

@@ -5,8 +5,8 @@
* parsing is done directly in PL24Service using the actual API response format. * parsing is done directly in PL24Service using the actual API response format.
*/ */
import type { PL24DecodedCategory, PL24DecodedVehicle, PL24Part } from "../pl24.types";
import { BasePL24Parser } from "./base-parser"; import { BasePL24Parser } from "./base-parser";
import type { PL24DecodedVehicle, PL24DecodedCategory, PL24Part } from "../pl24.types";
export class GenericPL24Parser extends BasePL24Parser { export class GenericPL24Parser extends BasePL24Parser {
readonly brandName: string; readonly brandName: string;

View File

@@ -1,8 +1,8 @@
import { BasePL24Parser } from "./base-parser";
import { BmwPL24Parser } from "./bmw-parser";
import { MercedesPL24Parser } from "./mercedes-parser";
import { GenericPL24Parser } from "./generic-parser";
import { PL24_WMI_SERVICE_MAP, isP5Modern } from "../pl24.types"; import { PL24_WMI_SERVICE_MAP, isP5Modern } from "../pl24.types";
import type { BasePL24Parser } from "./base-parser";
import { BmwPL24Parser } from "./bmw-parser";
import { GenericPL24Parser } from "./generic-parser";
import { MercedesPL24Parser } from "./mercedes-parser";
const PARSER_MAP: Record<string, () => BasePL24Parser> = { const PARSER_MAP: Record<string, () => BasePL24Parser> = {
BMW: () => new BmwPL24Parser(), BMW: () => new BmwPL24Parser(),

View File

@@ -8,15 +8,15 @@
*/ */
import { Injectable, Logger, UnauthorizedException } from "@nestjs/common"; import { Injectable, Logger, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import type { ConfigService } from "@nestjs/config";
import { PL24_ENDPOINTS } from "./pl24.constants"; import { PL24_ENDPOINTS } from "./pl24.constants";
import type { import type {
PL24AuthorizeRequest,
PL24AuthorizeResponse,
PL24JWTPayload,
PL24LoginRequest, PL24LoginRequest,
PL24LoginResponse, PL24LoginResponse,
PL24TokenData, PL24TokenData,
PL24JWTPayload,
PL24AuthorizeRequest,
PL24AuthorizeResponse,
} from "./pl24.types"; } from "./pl24.types";
@Injectable() @Injectable()
@@ -282,7 +282,9 @@ export class PL24AuthService {
} }
if (!data.token?.access_token) { if (!data.token?.access_token) {
this.logger.error(`PL24 login failed: ${data.status} - ${data.message || "No token returned"}`); this.logger.error(
`PL24 login failed: ${data.status} - ${data.message || "No token returned"}`,
);
throw new UnauthorizedException( throw new UnauthorizedException(
`PL24 giris basarisiz: ${data.message || data.status || "Token alinamadi"}`, `PL24 giris basarisiz: ${data.message || data.status || "Token alinamadi"}`,
); );
@@ -300,7 +302,9 @@ export class PL24AuthService {
services: payload.services || [], services: payload.services || [],
}; };
this.logger.log(`PL24 login (tr) successful. Token expires at ${this.tokenData.expiresAt.toISOString()}`); this.logger.log(
`PL24 login (tr) successful. Token expires at ${this.tokenData.expiresAt.toISOString()}`,
);
return this.tokenData; return this.tokenData;
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;
@@ -419,7 +423,9 @@ export class PL24AuthService {
services: payload.services || [], services: payload.services || [],
}; };
this.logger.log(`PL24 login (de) successful. Token expires at ${this.tokenData2.expiresAt.toISOString()}`); this.logger.log(
`PL24 login (de) successful. Token expires at ${this.tokenData2.expiresAt.toISOString()}`,
);
return this.tokenData2; return this.tokenData2;
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;

View File

@@ -8,24 +8,21 @@
import { createHash } from "crypto"; import { createHash } from "crypto";
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import type { ConfigService } from "@nestjs/config";
import { PL24AuthService } from "./pl24-auth.service"; import type { RedisService } from "../../redis/redis.service";
import { RedisService } from "../../redis/redis.service"; import type { StorageService } from "../../storage/storage.service";
import { StorageService } from "../../storage/storage.service"; import type { PL24AuthService } from "./pl24-auth.service";
import { FORD_LEGACY_ENDPOINTS, type FordPL24Support } from "./pl24-ford-legacy.types";
import { PL24_DEFAULTS } from "./pl24.constants"; import { PL24_DEFAULTS } from "./pl24.constants";
import { import {
PL24DecodedVehicle, type PL24DecodedCategory,
PL24DecodedCategory, type PL24DecodedVehicle,
PL24PartsResponse, type PL24MainGroup,
PL24Part, type PL24Part,
PL24MainGroup, type PL24PartsResponse,
SERVICE_TO_BRAND, SERVICE_TO_BRAND,
getServiceConfig, getServiceConfig,
} from "./pl24.types"; } from "./pl24.types";
import {
FORD_LEGACY_ENDPOINTS,
type FordPL24Support,
} from "./pl24-ford-legacy.types";
@Injectable() @Injectable()
export class PL24FordLegacyService { export class PL24FordLegacyService {
@@ -40,10 +37,7 @@ export class PL24FordLegacyService {
private redis: RedisService, private redis: RedisService,
private storage: StorageService, private storage: StorageService,
) { ) {
this.baseUrl = this.configService.get<string>( this.baseUrl = this.configService.get<string>("pl24.baseUrl", "https://www.partslink24.com");
"pl24.baseUrl",
"https://www.partslink24.com",
);
this.timeout = 30000; this.timeout = 30000;
} }
@@ -178,7 +172,9 @@ export class PL24FordLegacyService {
} }
if (groups.length === 0) { if (groups.length === 0) {
// Debug: log the response // Debug: log the response
this.logger.log(`json-sub-group unknown response[0:1000]: ${html.substring(0, 1000).replace(/\s+/g, " ")}`); this.logger.log(
`json-sub-group unknown response[0:1000]: ${html.substring(0, 1000).replace(/\s+/g, " ")}`,
);
} }
if (groups.length > 0) { if (groups.length > 0) {
await this.redis.setJson(cacheKey, groups, 86400); await this.redis.setJson(cacheKey, groups, 86400);
@@ -247,7 +243,9 @@ export class PL24FordLegacyService {
// Fallback: extract any .action links from the page // Fallback: extract any .action links from the page
const links = this.extractLinks(html, /\.action/); const links = this.extractLinks(html, /\.action/);
const groups: PL24MainGroup[] = links const groups: PL24MainGroup[] = links
.filter((link) => !link.href.includes("portal.action") && !link.href.includes("logout.action")) .filter(
(link) => !link.href.includes("portal.action") && !link.href.includes("logout.action"),
)
.map((link, idx) => ({ .map((link, idx) => ({
id: String(idx), id: String(idx),
code: this.extractCodeFromText(link.text) || String(idx), code: this.extractCodeFromText(link.text) || String(idx),
@@ -518,12 +516,11 @@ export class PL24FordLegacyService {
for (const item of items) { for (const item of items) {
// Avoid duplicating family name prefix (e.g. family="BERLINGO", salesType="BERLINGO VP" → "BERLINGO VP") // Avoid duplicating family name prefix (e.g. family="BERLINGO", salesType="BERLINGO VP" → "BERLINGO VP")
const alreadyPrefixed = const alreadyPrefixed =
item.name && item.name && item.name.toUpperCase().startsWith(family.name.toUpperCase());
item.name.toUpperCase().startsWith(family.name.toUpperCase());
const modelLabel = const modelLabel =
item.name && !alreadyPrefixed && item.name !== family.name item.name && !alreadyPrefixed && item.name !== family.name
? `${family.name} ${item.name}` ? `${family.name} ${item.name}`
: (item.name || family.name); : item.name || family.name;
vehicles.push({ vehicles.push({
vehicleId: `${serviceName}::${family.id}::${item.code}`, vehicleId: `${serviceName}::${family.id}::${item.code}`,
@@ -690,7 +687,9 @@ export class PL24FordLegacyService {
if (!html) return []; if (!html) return [];
const scopes = this.parsePsaScopesFromHtml(html, serviceName, familyId, salesTypeId); const scopes = this.parsePsaScopesFromHtml(html, serviceName, familyId, salesTypeId);
this.logger.log(`PSA: ${scopes.length} scopes for ${serviceName}/${familyId}/${salesTypeId} body=${body} engine=${engine} gearbox=${gearbox}`); this.logger.log(
`PSA: ${scopes.length} scopes for ${serviceName}/${familyId}/${salesTypeId} body=${body} engine=${engine} gearbox=${gearbox}`,
);
if (scopes.length > 0) { if (scopes.length > 0) {
await this.redis.setJson(cacheKey, scopes, 7200); await this.redis.setJson(cacheKey, scopes, 7200);
@@ -712,7 +711,10 @@ export class PL24FordLegacyService {
body !== "_all_" || engine !== "_all_" || gearbox !== "_all_" body !== "_all_" || engine !== "_all_" || gearbox !== "_all_"
? `:b=${body}:e=${engine}:g=${gearbox}` ? `:b=${body}:e=${engine}:g=${gearbox}`
: ""; : "";
const pathHash = createHash("sha256").update(linkPath + variantSuffix).digest("hex").substring(0, 16); const pathHash = createHash("sha256")
.update(linkPath + variantSuffix)
.digest("hex")
.substring(0, 16);
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}psa:maingroups:${pathHash}`; const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}psa:maingroups:${pathHash}`;
const cached = await this.redis.getJson<PL24MainGroup[]>(cacheKey); const cached = await this.redis.getJson<PL24MainGroup[]>(cacheKey);
if (cached) return cached; if (cached) return cached;
@@ -781,7 +783,10 @@ export class PL24FordLegacyService {
body !== "_all_" || engine !== "_all_" || gearbox !== "_all_" body !== "_all_" || engine !== "_all_" || gearbox !== "_all_"
? `:b=${body}:e=${engine}:g=${gearbox}` ? `:b=${body}:e=${engine}:g=${gearbox}`
: ""; : "";
const pathHash = createHash("sha256").update(effectivePath + variantSuffix).digest("hex").substring(0, 16); const pathHash = createHash("sha256")
.update(effectivePath + variantSuffix)
.digest("hex")
.substring(0, 16);
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}psa:illus:${pathHash}`; const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}psa:illus:${pathHash}`;
const cached = await this.redis.getJson<PL24MainGroup[]>(cacheKey); const cached = await this.redis.getJson<PL24MainGroup[]>(cacheKey);
if (cached) return cached; if (cached) return cached;
@@ -822,7 +827,9 @@ export class PL24FordLegacyService {
seenPaths.add(key); seenPaths.add(key);
const boardUrl = item.url const boardUrl = item.url
? item.url.startsWith("/") ? item.url : `/psa/${svcFromPath}/${item.url}` ? item.url.startsWith("/")
? item.url
: `/psa/${svcFromPath}/${item.url}`
: undefined; : undefined;
groups.push({ groups.push({
@@ -859,7 +866,10 @@ export class PL24FordLegacyService {
body !== "_all_" || engine !== "_all_" || gearbox !== "_all_" body !== "_all_" || engine !== "_all_" || gearbox !== "_all_"
? `:b=${body}:e=${engine}:g=${gearbox}` ? `:b=${body}:e=${engine}:g=${gearbox}`
: ""; : "";
const pathHash = createHash("sha256").update(effectivePath + variantSuffix).digest("hex").substring(0, 16); const pathHash = createHash("sha256")
.update(effectivePath + variantSuffix)
.digest("hex")
.substring(0, 16);
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}psa:parts:${pathHash}`; const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}psa:parts:${pathHash}`;
if (!bypassCache) { if (!bypassCache) {
const cached = await this.redis.getJson<PL24PartsResponse>(cacheKey); const cached = await this.redis.getJson<PL24PartsResponse>(cacheKey);
@@ -1022,7 +1032,13 @@ export class PL24FordLegacyService {
// Parse modelFamilyToModelList from window.vehicles = new Vehicles({...}) JS variable. // Parse modelFamilyToModelList from window.vehicles = new Vehicles({...}) JS variable.
// Each family maps to one or more catCode sub-models (year ranges like CAK=1998-2005). // Each family maps to one or more catCode sub-models (year ranges like CAK=1998-2005).
// The catCode is the true unique key for group.action — modelFamily alone returns 500. // The catCode is the true unique key for group.action — modelFamily alone returns 500.
const catCodeVehicles = this.parseFordCatCodeVehicles(html, serviceName, basePath, mode, upds); const catCodeVehicles = this.parseFordCatCodeVehicles(
html,
serviceName,
basePath,
mode,
upds,
);
if (catCodeVehicles.length > 0) { if (catCodeVehicles.length > 0) {
this.logger.log(`Ford: ${catCodeVehicles.length} catCode vehicles for ${serviceName}`); this.logger.log(`Ford: ${catCodeVehicles.length} catCode vehicles for ${serviceName}`);
@@ -1090,7 +1106,9 @@ export class PL24FordLegacyService {
await this.authService.authorizeService(serviceName); await this.authService.authorizeService(serviceName);
const config = getServiceConfig(serviceName); const config = getServiceConfig(serviceName);
const basePath = config ? `${config.basePath}/${serviceName}` : `/hyundai-kia-automotive-group/${serviceName}`; const basePath = config
? `${config.basePath}/${serviceName}`
: `/hyundai-kia-automotive-group/${serviceName}`;
// Step 1: group.action to get session mode/upds // Step 1: group.action to get session mode/upds
const groupUrl = `${this.baseUrl}${basePath}/group.action?lang=tr`; const groupUrl = `${this.baseUrl}${basePath}/group.action?lang=tr`;
@@ -1138,7 +1156,10 @@ export class PL24FordLegacyService {
return vehicles; return vehicles;
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;
this.logger.error(`HyundaiKia fetchVehicleList error (${serviceName}): ${err.message}`, err.stack); this.logger.error(
`HyundaiKia fetchVehicleList error (${serviceName}): ${err.message}`,
err.stack,
);
return []; return [];
} }
} }
@@ -1205,7 +1226,10 @@ export class PL24FordLegacyService {
return vehicles; return vehicles;
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;
this.logger.error(`Nissan fetchVehicleList error (${serviceName}): ${err.message}`, err.stack); this.logger.error(
`Nissan fetchVehicleList error (${serviceName}): ${err.message}`,
err.stack,
);
return []; return [];
} }
} }
@@ -1318,7 +1342,9 @@ export class PL24FordLegacyService {
// Step 1: group.action?lang=tr WITH auth → get mode/upds for authenticated subsequent calls // Step 1: group.action?lang=tr WITH auth → get mode/upds for authenticated subsequent calls
const groupUrl = `${this.baseUrl}${basePath}/group.action?lang=tr`; const groupUrl = `${this.baseUrl}${basePath}/group.action?lang=tr`;
const groupHtml = await this.fetchP4Page(groupUrl, serviceName, true); const groupHtml = await this.fetchP4Page(groupUrl, serviceName, true);
let { mode, upds } = groupHtml ? this.extractModeUpdsFromHtml(groupHtml) : { mode: "", upds: "" }; let { mode, upds } = groupHtml
? this.extractModeUpdsFromHtml(groupHtml)
: { mode: "", upds: "" };
// Step 2: Try vin-group.action?lang=tr WITHOUT auth (demo mode returns 200 with model names) // Step 2: Try vin-group.action?lang=tr WITHOUT auth (demo mode returns 200 with model names)
// Our subscription doesn't support Volvo VIN browsing, so auth'd request returns 500. // Our subscription doesn't support Volvo VIN browsing, so auth'd request returns 500.
@@ -1328,7 +1354,10 @@ export class PL24FordLegacyService {
try { try {
const resp = await fetch(vinGroupUrl, { const resp = await fetch(vinGroupUrl, {
method: "GET", method: "GET",
headers: { "Accept": "text/html,application/xhtml+xml,*/*", "Accept-Language": "tr-TR,tr;q=0.9,en;q=0.8" }, headers: {
Accept: "text/html,application/xhtml+xml,*/*",
"Accept-Language": "tr-TR,tr;q=0.9,en;q=0.8",
},
signal: AbortSignal.timeout(this.timeout), signal: AbortSignal.timeout(this.timeout),
redirect: "follow", redirect: "follow",
}); });
@@ -1424,12 +1453,14 @@ export class PL24FordLegacyService {
let modelYears: { code: string; name: string }[] = []; let modelYears: { code: string; name: string }[] = [];
try { try {
const parsed = JSON.parse(raw) as { modelyears?: Array<{ caption?: string; url?: string; gray?: boolean }> }; const parsed = JSON.parse(raw) as {
modelyears?: Array<{ caption?: string; url?: string; gray?: boolean }>;
};
const items = (parsed.modelyears || []).filter((it) => !it.gray); const items = (parsed.modelyears || []).filter((it) => !it.gray);
modelYears = items modelYears = items
.map((it) => { .map((it) => {
const yearMatch = it.url?.match(/[?&]year=([^&"]+)/); const yearMatch = it.url?.match(/[?&]year=([^&"]+)/);
const code = yearMatch ? decodeURIComponent(yearMatch[1]) : (it.caption || ""); const code = yearMatch ? decodeURIComponent(yearMatch[1]) : it.caption || "";
return { code, name: it.caption || code }; return { code, name: it.caption || code };
}) })
.filter((it) => it.code); .filter((it) => it.code);
@@ -1536,7 +1567,8 @@ export class PL24FordLegacyService {
return []; return [];
} }
const familyMap = vehiclesData.modelFamilyToModelList as Record< const familyMap = vehiclesData.modelFamilyToModelList as
| Record<
string, string,
Array<{ Array<{
url?: string; url?: string;
@@ -1546,7 +1578,8 @@ export class PL24FordLegacyService {
year?: string; year?: string;
gray?: boolean; gray?: boolean;
}> }>
> | undefined; >
| undefined;
if (!familyMap) return []; if (!familyMap) return [];
@@ -1554,7 +1587,9 @@ export class PL24FordLegacyService {
const familyKey = const familyKey =
Object.keys(familyMap).find((k) => k === familyId) || Object.keys(familyMap).find((k) => k === familyId) ||
Object.keys(familyMap).find((k) => k.toLowerCase() === familyId.toLowerCase()) || Object.keys(familyMap).find((k) => k.toLowerCase() === familyId.toLowerCase()) ||
Object.keys(familyMap).find((k) => k.replace(/\s+/g, "-").toLowerCase() === familyId.replace(/\s+/g, "-").toLowerCase()); Object.keys(familyMap).find(
(k) => k.replace(/\s+/g, "-").toLowerCase() === familyId.replace(/\s+/g, "-").toLowerCase(),
);
const models = familyKey ? familyMap[familyKey] : undefined; const models = familyKey ? familyMap[familyKey] : undefined;
if (!Array.isArray(models) || models.length === 0) return []; if (!Array.isArray(models) || models.length === 0) return [];
@@ -1564,8 +1599,8 @@ export class PL24FordLegacyService {
if (m2.gray === true) continue; // Skip unavailable sub-models if (m2.gray === true) continue; // Skip unavailable sub-models
// catCode may be in identifier OR embedded in the URL // catCode may be in identifier OR embedded in the URL
const catCode = (m2.identifier?.trim() || "") || const catCode =
(m2.url?.match(/[?&]catCode=([^&"]+)/)?.[1] || ""); m2.identifier?.trim() || "" || m2.url?.match(/[?&]catCode=([^&"]+)/)?.[1] || "";
if (!catCode) continue; if (!catCode) continue;
// Build display name: caption if available, else family+year range // Build display name: caption if available, else family+year range
@@ -1738,7 +1773,9 @@ export class PL24FordLegacyService {
let firstSalesTypeId = firstFamily.id; let firstSalesTypeId = firstFamily.id;
if (salesRaw) { if (salesRaw) {
try { try {
const parsed = JSON.parse(salesRaw) as { items?: Array<{ code: string; name: string; subheader?: boolean }> }; const parsed = JSON.parse(salesRaw) as {
items?: Array<{ code: string; name: string; subheader?: boolean }>;
};
const items = (parsed.items || []).filter((it) => it.code && !it.subheader); const items = (parsed.items || []).filter((it) => it.code && !it.subheader);
if (items.length > 0) firstSalesTypeId = items[0].code; if (items.length > 0) firstSalesTypeId = items[0].code;
} catch { } catch {
@@ -1877,10 +1914,14 @@ export class PL24FordLegacyService {
const salesRaw = await this.fetchPsaPage(salesUrl, serviceName, jsessionId, true); const salesRaw = await this.fetchPsaPage(salesUrl, serviceName, jsessionId, true);
if (salesRaw) { if (salesRaw) {
try { try {
const parsed = JSON.parse(salesRaw) as { items?: Array<{ code: string; name: string; subheader?: boolean }> }; const parsed = JSON.parse(salesRaw) as {
items?: Array<{ code: string; name: string; subheader?: boolean }>;
};
const items = (parsed.items || []).filter((it) => it.code && !it.subheader); const items = (parsed.items || []).filter((it) => it.code && !it.subheader);
if (items.length > 0) firstSalesTypeId = items[0].code; if (items.length > 0) firstSalesTypeId = items[0].code;
} catch { /* keep firstFamily.id */ } } catch {
/* keep firstFamily.id */
}
} }
const groupUrl = const groupUrl =
`${this.baseUrl}/psa/${serviceName}/group.action` + `${this.baseUrl}/psa/${serviceName}/group.action` +
@@ -1889,7 +1930,10 @@ export class PL24FordLegacyService {
`&startup=false&mode=${mode}&upds=${upds}`; `&startup=false&mode=${mode}&upds=${upds}`;
const groupHtml = await this.fetchPsaPage(groupUrl, serviceName, jsessionId); const groupHtml = await this.fetchPsaPage(groupUrl, serviceName, jsessionId);
if (groupHtml) { if (groupHtml) {
({ scopes, familyId, salesTypeId } = this.parsePsaScopesAndParams(groupHtml, serviceName)); ({ scopes, familyId, salesTypeId } = this.parsePsaScopesAndParams(
groupHtml,
serviceName,
));
} }
} }
} }
@@ -1987,7 +2031,8 @@ export class PL24FordLegacyService {
return []; return [];
} }
const familyMap = vehiclesData.modelFamilyToModelList as Record< const familyMap = vehiclesData.modelFamilyToModelList as
| Record<
string, string,
Array<{ Array<{
url?: string; url?: string;
@@ -1996,7 +2041,8 @@ export class PL24FordLegacyService {
caption?: string; caption?: string;
year?: string; year?: string;
}> }>
> | undefined; >
| undefined;
if (!familyMap || typeof familyMap !== "object") return []; if (!familyMap || typeof familyMap !== "object") return [];
@@ -2011,8 +2057,8 @@ export class PL24FordLegacyService {
if (!Array.isArray(models)) continue; if (!Array.isArray(models)) continue;
for (const m2 of models) { for (const m2 of models) {
// catCode may be in identifier field OR embedded in the url (e.g. "vehicle.action?catCode=CB7&...") // catCode may be in identifier field OR embedded in the url (e.g. "vehicle.action?catCode=CB7&...")
const catCode = (m2.identifier?.trim() || "") || const catCode =
(m2.url?.match(/[?&]catCode=([^&"]+)/)?.[1] || ""); m2.identifier?.trim() || "" || m2.url?.match(/[?&]catCode=([^&"]+)/)?.[1] || "";
if (!catCode) continue; if (!catCode) continue;
// Build display name: "Focus (1998-2005)" or just "Focus" if no year // Build display name: "Focus (1998-2005)" or just "Focus" if no year
@@ -2092,7 +2138,6 @@ export class PL24FordLegacyService {
return families; return families;
} }
/** /**
* Parse items array from PSA JSON variant endpoints (bodies/engines/gearboxes). * Parse items array from PSA JSON variant endpoints (bodies/engines/gearboxes).
* PL24 PSA uses variant-specific field names: * PL24 PSA uses variant-specific field names:
@@ -2105,7 +2150,8 @@ export class PL24FordLegacyService {
raw: string, raw: string,
type: "body" | "engine" | "gearbox", type: "body" | "engine" | "gearbox",
): { code: string; name: string }[] { ): { code: string; name: string }[] {
const codeKey = type === "body" ? "bodyCodeTec" : type === "engine" ? "engineCodeTec" : "gearboxCodeTec"; const codeKey =
type === "body" ? "bodyCodeTec" : type === "engine" ? "engineCodeTec" : "gearboxCodeTec";
const nameKey = type === "body" ? "bodyName" : type === "engine" ? "engineName" : "gearboxName"; const nameKey = type === "body" ? "bodyName" : type === "engine" ? "engineName" : "gearboxName";
try { try {
const parsed = JSON.parse(raw) as { const parsed = JSON.parse(raw) as {
@@ -2244,7 +2290,12 @@ export class PL24FordLegacyService {
const textTdMatch = const textTdMatch =
segment.match(/class="text[^"]*"[^>]*>([\s\S]*?)<\/td>/) || segment.match(/class="text[^"]*"[^>]*>([\s\S]*?)<\/td>/) ||
segment.match(/class="caption[^"]*"[^>]*>([\s\S]*?)<\/td>/); segment.match(/class="caption[^"]*"[^>]*>([\s\S]*?)<\/td>/);
const text = textTdMatch ? textTdMatch[1].replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " ").trim() : ""; const text = textTdMatch
? textTdMatch[1]
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.trim()
: "";
rows.push({ rows.push({
pnc, pnc,
@@ -2331,14 +2382,16 @@ export class PL24FordLegacyService {
if (!oemCode) continue; if (!oemCode) continue;
const captionMatch = segment.match(/\bcaption="([^"]*)"/); const captionMatch = segment.match(/\bcaption="([^"]*)"/);
const name = captionMatch ? captionMatch[1].replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").trim() : ""; const name = captionMatch
? captionMatch[1].replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").trim()
: "";
const hotspotMatch = segment.match(/\bhotspot="([^"]*?)"/); const hotspotMatch = segment.match(/\bhotspot="([^"]*?)"/);
const hotspot = hotspotMatch?.[1]?.trim() ?? ""; const hotspot = hotspotMatch?.[1]?.trim() ?? "";
const qtyTdMatch = segment.match(/class="quantity"[^>]*>\s*(\d+)\s*</); const qtyTdMatch = segment.match(/class="quantity"[^>]*>\s*(\d+)\s*</);
const qtyUrlMatch = segment.match(/[?&]quantity=(\d+)/); const qtyUrlMatch = segment.match(/[?&]quantity=(\d+)/);
const quantity = parseInt(qtyTdMatch?.[1] ?? qtyUrlMatch?.[1] ?? "1", 10) || 1; const quantity = Number.parseInt(qtyTdMatch?.[1] ?? qtyUrlMatch?.[1] ?? "1", 10) || 1;
const bomDetailIdMatch = segment.match(/\bbomDetailId="([^"]+)"/); const bomDetailIdMatch = segment.match(/\bbomDetailId="([^"]+)"/);
const positionCode = hotspot || bomDetailIdMatch?.[1] || oemCode; const positionCode = hotspot || bomDetailIdMatch?.[1] || oemCode;
@@ -2407,12 +2460,15 @@ export class PL24FordLegacyService {
.filter((item) => !item.subheader && !item.gray && !!(item.jsonUrl || item.url)) .filter((item) => !item.subheader && !item.gray && !!(item.jsonUrl || item.url))
.map((item) => { .map((item) => {
// Strip HTML restriction block divs from caption (Ford embeds them in JSON too) // Strip HTML restriction block divs from caption (Ford embeds them in JSON too)
const cleanName = (item.caption || "") const cleanName =
(item.caption || "")
.replace(/<div[^>]*restriction[^>]*>[\s\S]*/i, "") .replace(/<div[^>]*restriction[^>]*>[\s\S]*/i, "")
.replace(/<[^>]+>/g, " ") .replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ") .replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim() || item.databaseKey || item.identifier; .trim() ||
item.databaseKey ||
item.identifier;
return { return {
id: item.databaseKey || item.identifier, id: item.databaseKey || item.identifier,
code: item.databaseKey || item.identifier, code: item.databaseKey || item.identifier,
@@ -2454,9 +2510,7 @@ export class PL24FordLegacyService {
id: String(item.id || idx), id: String(item.id || idx),
code: String(item.id || idx), code: String(item.id || idx),
name: (item.caption ?? item.name ?? "").replace(/^\d+\s+/, "").trim(), name: (item.caption ?? item.name ?? "").replace(/^\d+\s+/, "").trim(),
linkPath: item.url linkPath: item.url ? `${basePath}${item.url}` : `${basePath}${item.jsonUrl}`,
? `${basePath}${item.url}`
: `${basePath}${item.jsonUrl}`,
})); }));
} catch { } catch {
return []; return [];
@@ -2496,16 +2550,20 @@ export class PL24FordLegacyService {
try { try {
// 1. entry.action → 302 with JSESSIONID // 1. entry.action → 302 with JSESSIONID
const entryRes = await fetch( const entryRes = await fetch(`${this.baseUrl}/psa/pl24-entry.action?service=${serviceName}`, {
`${this.baseUrl}/psa/pl24-entry.action?service=${serviceName}`, method: "GET",
{ method: "GET", headers, redirect: "manual", signal: AbortSignal.timeout(this.timeout) }, headers,
); redirect: "manual",
signal: AbortSignal.timeout(this.timeout),
});
const jsessionMatch = entryRes.headers.get("set-cookie")?.match(/JSESSIONID=([^;]+)/); const jsessionMatch = entryRes.headers.get("set-cookie")?.match(/JSESSIONID=([^;]+)/);
const jsessionId = jsessionMatch?.[1] || ""; const jsessionId = jsessionMatch?.[1] || "";
const loc1 = entryRes.headers.get("location"); const loc1 = entryRes.headers.get("location");
if (!loc1) return null; if (!loc1) return null;
const hdrs2 = jsessionId ? { ...headers, Cookie: `${headers.Cookie}; JSESSIONID=${jsessionId}` } : headers; const hdrs2 = jsessionId
? { ...headers, Cookie: `${headers.Cookie}; JSESSIONID=${jsessionId}` }
: headers;
// 2. startup=true → 302 with mode + upds in Location // 2. startup=true → 302 with mode + upds in Location
const startup1Url = loc1.startsWith("http") ? loc1 : `${this.baseUrl}${loc1}`; const startup1Url = loc1.startsWith("http") ? loc1 : `${this.baseUrl}${loc1}`;
@@ -2527,7 +2585,9 @@ export class PL24FordLegacyService {
if (!mode) return null; if (!mode) return null;
this.logger.log(`PSA session init: service=${serviceName} mode=${mode} upds=${upds} jsessionId=${jsessionId.substring(0, 10)}...`); this.logger.log(
`PSA session init: service=${serviceName} mode=${mode} upds=${upds} jsessionId=${jsessionId.substring(0, 10)}...`,
);
return { jsessionId, mode, upds }; return { jsessionId, mode, upds };
} catch (error) { } catch (error) {
this.logger.error(`PSA session init error: ${(error as Error).message}`); this.logger.error(`PSA session init error: ${(error as Error).message}`);
@@ -2567,9 +2627,7 @@ export class PL24FordLegacyService {
const headers = { const headers = {
...baseHeaders, ...baseHeaders,
Cookie: cookieStr, Cookie: cookieStr,
Accept: isJson Accept: isJson ? "application/json,*/*" : "text/html,application/xhtml+xml,*/*;q=0.9",
? "application/json,*/*"
: "text/html,application/xhtml+xml,*/*;q=0.9",
}; };
try { try {
@@ -2637,7 +2695,9 @@ export class PL24FordLegacyService {
signal: AbortSignal.timeout(this.timeout), signal: AbortSignal.timeout(this.timeout),
}); });
if (!infoRes.ok) { if (!infoRes.ok) {
this.logger.warn(`PSA GetImageInfo: HTTP ${infoRes.status} for ${infoUrl.substring(0, 120)}`); this.logger.warn(
`PSA GetImageInfo: HTTP ${infoRes.status} for ${infoUrl.substring(0, 120)}`,
);
return null; return null;
} }
const info = (await infoRes.json()) as { const info = (await infoRes.json()) as {
@@ -2788,7 +2848,9 @@ export class PL24FordLegacyService {
fromHtmlAttrs.push({ id: m[1], name: m[2] }); fromHtmlAttrs.push({ id: m[1], name: m[2] });
} }
if (fromHtmlAttrs.length > 0) { if (fromHtmlAttrs.length > 0) {
this.logger.log(`PSA HTML: found ${fromHtmlAttrs.length} families via modelFamily attributes`); this.logger.log(
`PSA HTML: found ${fromHtmlAttrs.length} families via modelFamily attributes`,
);
return fromHtmlAttrs; return fromHtmlAttrs;
} }
@@ -2807,7 +2869,8 @@ export class PL24FordLegacyService {
} }
// FALLBACK: Try <select> elements with model/family options // FALLBACK: Try <select> elements with model/family options
const selectRegex = /<select[^>]*name=["'](?:modelFamily|famille|modele|model)[^"']*["'][^>]*>([\s\S]*?)<\/select>/i; const selectRegex =
/<select[^>]*name=["'](?:modelFamily|famille|modele|model)[^"']*["'][^>]*>([\s\S]*?)<\/select>/i;
const selectMatch = html.match(selectRegex); const selectMatch = html.match(selectRegex);
if (selectMatch) { if (selectMatch) {
const optionRegex = /<option[^>]*value=["']([^"']+)["'][^>]*>([\s\S]*?)<\/option>/gi; const optionRegex = /<option[^>]*value=["']([^"']+)["'][^>]*>([\s\S]*?)<\/option>/gi;
@@ -2851,12 +2914,24 @@ export class PL24FordLegacyService {
// Log select/option elements for model selection hints // Log select/option elements for model selection hints
const selectMatches = html.match(/<select[^>]*>[\s\S]*?<\/select>/gi); const selectMatches = html.match(/<select[^>]*>[\s\S]*?<\/select>/gi);
if (selectMatches) { if (selectMatches) {
this.logger.log(`PSA HTML selects: ${selectMatches.slice(0, 3).map(s => s.substring(0, 200)).join(" | ")}`); this.logger.log(
`PSA HTML selects: ${selectMatches
.slice(0, 3)
.map((s) => s.substring(0, 200))
.join(" | ")}`,
);
} }
// Log any li/div elements with model names in the navigation // Log any li/div elements with model names in the navigation
const liLinks = html.match(/<(?:li|div)[^>]*class=["'][^"']*(?:family|model|brand|vehicle|catalog)[^"']*["'][^>]*>[\s\S]*?<\/(?:li|div)>/gi); const liLinks = html.match(
/<(?:li|div)[^>]*class=["'][^"']*(?:family|model|brand|vehicle|catalog)[^"']*["'][^>]*>[\s\S]*?<\/(?:li|div)>/gi,
);
if (liLinks) { if (liLinks) {
this.logger.log(`PSA HTML nav items: ${liLinks.slice(0, 5).map(s => s.substring(0, 150)).join(" | ")}`); this.logger.log(
`PSA HTML nav items: ${liLinks
.slice(0, 5)
.map((s) => s.substring(0, 150))
.join(" | ")}`,
);
} }
return []; return [];
} }
@@ -2920,7 +2995,10 @@ export class PL24FordLegacyService {
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`); this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
this.authService.clearTokensForAccount(account); this.authService.clearTokensForAccount(account);
await this.authService.authorizeServiceForAccount(serviceName, account); await this.authService.authorizeServiceForAccount(serviceName, account);
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(serviceName, account); const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(
serviceName,
account,
);
response = await fetch(fullUrl, buildOpts(newHeaders)); response = await fetch(fullUrl, buildOpts(newHeaders));
} }
@@ -2939,7 +3017,9 @@ export class PL24FordLegacyService {
return await response.text(); return await response.text();
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;
this.logger.error(`Ford legacy fetch error: ${err.message} [url=${fullUrl.substring(0, 100)}] [cause=${(err as any).cause?.message ?? "none"}]`); this.logger.error(
`Ford legacy fetch error: ${err.message} [url=${fullUrl.substring(0, 100)}] [cause=${(err as any).cause?.message ?? "none"}]`,
);
return null; return null;
} }
} }
@@ -2977,14 +3057,15 @@ export class PL24FordLegacyService {
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`); this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
this.authService.clearTokensForAccount(account); this.authService.clearTokensForAccount(account);
await this.authService.authorizeServiceForAccount(serviceName, account); await this.authService.authorizeServiceForAccount(serviceName, account);
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(serviceName, account); const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(
serviceName,
account,
);
response = await doFetch(newHeaders); response = await doFetch(newHeaders);
} }
const jsessionId = const jsessionId =
response.headers response.headers.get("set-cookie")?.match(/JSESSIONID=([^;,\s]+)/)?.[1] ?? "";
.get("set-cookie")
?.match(/JSESSIONID=([^;,\s]+)/)?.[1] ?? "";
if (!response.ok) { if (!response.ok) {
this.logger.warn(`Ford legacy: HTTP ${response.status} for ${fullUrl}`); this.logger.warn(`Ford legacy: HTTP ${response.status} for ${fullUrl}`);
@@ -3207,7 +3288,10 @@ export class PL24FordLegacyService {
.replace(/\xa0/g, " ") .replace(/\xa0/g, " ")
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim(); .trim();
if (text.length >= 3) { tdName = text; break; } if (text.length >= 3) {
tdName = text;
break;
}
} }
// Caption may contain HTML-encoded content (e.g. &lt;div class="restrictionBlock"...&gt;) // Caption may contain HTML-encoded content (e.g. &lt;div class="restrictionBlock"...&gt;)
@@ -3215,8 +3299,12 @@ export class PL24FordLegacyService {
const rawCaption = captionMatch?.[1] || ""; const rawCaption = captionMatch?.[1] || "";
const cleanCaption = rawCaption const cleanCaption = rawCaption
// Decode HTML entities first (so we can then strip decoded HTML tags) // Decode HTML entities first (so we can then strip decoded HTML tags)
.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&") .replace(/&lt;/g, "<")
.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ") .replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
// Trim restriction block divs — everything from <div class="restriction to end // Trim restriction block divs — everything from <div class="restriction to end
.replace(/<div[^>]*restriction[^>]*>[\s\S]*/i, "") .replace(/<div[^>]*restriction[^>]*>[\s\S]*/i, "")
// Strip remaining HTML tags // Strip remaining HTML tags
@@ -3294,14 +3382,17 @@ export class PL24FordLegacyService {
const trParts = html.split(/<tr\s/); const trParts = html.split(/<tr\s/);
for (const segment of trParts) { for (const segment of trParts) {
if (!segment.includes("tc-data-row")) continue; if (!segment.includes("tc-data-row")) continue;
if (!segment.includes('catalog=')) continue; if (!segment.includes("catalog=")) continue;
const catalogMatch = segment.match(/\bcatalog="([^"]+)"/); const catalogMatch = segment.match(/\bcatalog="([^"]+)"/);
const captionMatch = segment.match(/\bcaption="([^"]+)"/); const captionMatch = segment.match(/\bcaption="([^"]+)"/);
if (!catalogMatch || !captionMatch) continue; if (!catalogMatch || !captionMatch) continue;
const id = catalogMatch[1].trim(); const id = catalogMatch[1].trim();
const name = captionMatch[1].replace(/&amp;/g, "&").replace(/&nbsp;/g, " ").trim(); const name = captionMatch[1]
.replace(/&amp;/g, "&")
.replace(/&nbsp;/g, " ")
.trim();
if (!id || seen.has(id)) continue; if (!id || seen.has(id)) continue;
seen.add(id); seen.add(id);
@@ -3331,7 +3422,10 @@ export class PL24FordLegacyService {
if (!identMatch || !captionMatch) continue; if (!identMatch || !captionMatch) continue;
const id = identMatch[1].trim(); const id = identMatch[1].trim();
const name = captionMatch[1].replace(/&amp;/g, "&").replace(/&nbsp;/g, " ").trim(); const name = captionMatch[1]
.replace(/&amp;/g, "&")
.replace(/&nbsp;/g, " ")
.trim();
if (!id || seen.has(id)) continue; if (!id || seen.has(id)) continue;
seen.add(id); seen.add(id);
@@ -3406,7 +3500,11 @@ export class PL24FordLegacyService {
const captionMatch = segment.match(/\bcaption="([^"]+)"/); const captionMatch = segment.match(/\bcaption="([^"]+)"/);
let name = ""; let name = "";
if (captionMatch) { if (captionMatch) {
name = captionMatch[1].replace(/&amp;/g, "&").replace(/&nbsp;/g, " ").replace(/\xa0/g, " ").trim(); name = captionMatch[1]
.replace(/&amp;/g, "&")
.replace(/&nbsp;/g, " ")
.replace(/\xa0/g, " ")
.trim();
} else { } else {
// Extract text from first non-empty <td> (strip inner tags) // Extract text from first non-empty <td> (strip inner tags)
const tdPattern = /<td[^>]*>([\s\S]*?)<\/td>/g; const tdPattern = /<td[^>]*>([\s\S]*?)<\/td>/g;
@@ -3585,7 +3683,7 @@ export class PL24FordLegacyService {
if (vehicleData) { if (vehicleData) {
model = vehicleData.model || vehicleData.modelName || vehicleData.description || ""; model = vehicleData.model || vehicleData.modelName || vehicleData.description || "";
year = parseInt(vehicleData.year || vehicleData.modelYear || "", 10) || 0; year = Number.parseInt(vehicleData.year || vehicleData.modelYear || "", 10) || 0;
bodyType = vehicleData.bodyStyle || vehicleData.body || null; bodyType = vehicleData.bodyStyle || vehicleData.body || null;
engineCode = vehicleData.engineCode || vehicleData.engine || null; engineCode = vehicleData.engineCode || vehicleData.engine || null;
engineType = vehicleData.engineDescription || vehicleData.engineType || null; engineType = vehicleData.engineDescription || vehicleData.engineType || null;
@@ -3623,7 +3721,7 @@ export class PL24FordLegacyService {
model = values[i] || model; model = values[i] || model;
} }
if (!year && (key.includes("year") || key.includes("yil") || key.includes("yıl"))) { if (!year && (key.includes("year") || key.includes("yil") || key.includes("yıl"))) {
year = parseInt(values[i], 10) || year; year = Number.parseInt(values[i], 10) || year;
} }
if (!engineCode && (key.includes("engine") || key.includes("motor"))) { if (!engineCode && (key.includes("engine") || key.includes("motor"))) {
engineCode = values[i] || engineCode; engineCode = values[i] || engineCode;
@@ -3744,30 +3842,45 @@ export class PL24FordLegacyService {
for (const row of rows) { for (const row of rows) {
// Try to find OEM code column (various possible names) // Try to find OEM code column (various possible names)
const oemCode = this.findColumnValue(row, [ const oemCode = this.findColumnValue(row, [
"partno", "part_no", "part number", "parca no", "parça no", "partno",
"oem", "oemcode", "code", "kod", "no", "part_no",
"part number",
"parca no",
"parça no",
"oem",
"oemcode",
"code",
"kod",
"no",
]); ]);
if (!oemCode) continue; if (!oemCode) continue;
const cleanOem = oemCode.replace(/\s+/g, ""); const cleanOem = oemCode.replace(/\s+/g, "");
const name = this.findColumnValue(row, [ const name =
"description", "name", "aciklama", "açıklama", "tanim", "tanım", this.findColumnValue(row, [
"descr", "part name", "parca adi", "parça adı", "description",
"name",
"aciklama",
"açıklama",
"tanim",
"tanım",
"descr",
"part name",
"parca adi",
"parça adı",
]) || ""; ]) || "";
const positionCode = this.findColumnValue(row, [ const positionCode =
"pos", "position", "pozisyon", "no", "sira", this.findColumnValue(row, ["pos", "position", "pozisyon", "no", "sira"]) || "";
]) || "";
const qtyStr = this.findColumnValue(row, [ const qtyStr =
"qty", "quantity", "miktar", "adet", "count", this.findColumnValue(row, ["qty", "quantity", "miktar", "adet", "count"]) || "";
]) || ""; const quantity = Number.parseInt(qtyStr, 10) || undefined;
const quantity = parseInt(qtyStr, 10) || undefined;
const remark = this.findColumnValue(row, [ const remark =
"remark", "remarks", "note", "notes", "not", "aciklama2", this.findColumnValue(row, ["remark", "remarks", "note", "notes", "not", "aciklama2"]) ||
]) || undefined; undefined;
parts.push({ parts.push({
id: cleanOem, id: cleanOem,
@@ -3828,10 +3941,7 @@ export class PL24FordLegacyService {
/** /**
* Find a column value in a row by trying multiple possible column names. * Find a column value in a row by trying multiple possible column names.
*/ */
private findColumnValue( private findColumnValue(row: Record<string, string>, possibleKeys: string[]): string | null {
row: Record<string, string>,
possibleKeys: string[],
): string | null {
// Try exact match first // Try exact match first
for (const key of possibleKeys) { for (const key of possibleKeys) {
if (row[key]) return row[key]; if (row[key]) return row[key];
@@ -3840,17 +3950,13 @@ export class PL24FordLegacyService {
// Try case-insensitive match // Try case-insensitive match
const rowKeys = Object.keys(row); const rowKeys = Object.keys(row);
for (const key of possibleKeys) { for (const key of possibleKeys) {
const found = rowKeys.find( const found = rowKeys.find((k) => k.toLowerCase() === key.toLowerCase());
(k) => k.toLowerCase() === key.toLowerCase(),
);
if (found && row[found]) return row[found]; if (found && row[found]) return row[found];
} }
// Try partial match // Try partial match
for (const key of possibleKeys) { for (const key of possibleKeys) {
const found = rowKeys.find( const found = rowKeys.find((k) => k.toLowerCase().includes(key.toLowerCase()));
(k) => k.toLowerCase().includes(key.toLowerCase()),
);
if (found && row[found]) return row[found]; if (found && row[found]) return row[found];
} }
@@ -3872,12 +3978,36 @@ export class PL24FordLegacyService {
if (!vin || vin.length < 10) return 0; if (!vin || vin.length < 10) return 0;
const yearChar = vin.charAt(9).toUpperCase(); const yearChar = vin.charAt(9).toUpperCase();
const yearMap: Record<string, number> = { const yearMap: Record<string, number> = {
"1": 2001, "2": 2002, "3": 2003, "4": 2004, "5": 2005, "1": 2001,
"6": 2006, "7": 2007, "8": 2008, "9": 2009, "2": 2002,
A: 2010, B: 2011, C: 2012, D: 2013, E: 2014, F: 2015, "3": 2003,
G: 2016, H: 2017, J: 2018, K: 2019, L: 2020, M: 2021, "4": 2004,
N: 2022, P: 2023, R: 2024, S: 2025, T: 2026, V: 2027, "5": 2005,
W: 2028, X: 2029, Y: 2030, "6": 2006,
"7": 2007,
"8": 2008,
"9": 2009,
A: 2010,
B: 2011,
C: 2012,
D: 2013,
E: 2014,
F: 2015,
G: 2016,
H: 2017,
J: 2018,
K: 2019,
L: 2020,
M: 2021,
N: 2022,
P: 2023,
R: 2024,
S: 2025,
T: 2026,
V: 2027,
W: 2028,
X: 2029,
Y: 2030,
}; };
return yearMap[yearChar] || 0; return yearMap[yearChar] || 0;
} }

View File

@@ -1,7 +1,7 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { PL24Service } from "./pl24.service";
import { PL24AuthService } from "./pl24-auth.service"; import { PL24AuthService } from "./pl24-auth.service";
import { PL24FordLegacyService } from "./pl24-ford-legacy.service"; import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
import { PL24Service } from "./pl24.service";
@Module({ @Module({
providers: [PL24Service, PL24AuthService, PL24FordLegacyService], providers: [PL24Service, PL24AuthService, PL24FordLegacyService],

View File

@@ -7,31 +7,31 @@
import { createHash } from "crypto"; import { createHash } from "crypto";
import { import {
BadRequestException,
Injectable, Injectable,
Logger, Logger,
BadRequestException,
ServiceUnavailableException,
NotFoundException, NotFoundException,
ServiceUnavailableException,
} from "@nestjs/common"; } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import type { ConfigService } from "@nestjs/config";
import { PL24AuthService } from "./pl24-auth.service"; import type { RedisService } from "../../redis/redis.service";
import { PL24FordLegacyService } from "./pl24-ford-legacy.service"; import type { StorageService } from "../../storage/storage.service";
import { RedisService } from "../../redis/redis.service"; import type { PL24AuthService } from "./pl24-auth.service";
import { StorageService } from "../../storage/storage.service"; import type { PL24FordLegacyService } from "./pl24-ford-legacy.service";
import { PL24_DEFAULTS } from "./pl24.constants"; import { PL24_DEFAULTS } from "./pl24.constants";
import { import {
type PL24DecodedCategory,
type PL24DecodedVehicle,
type PL24Hotspot,
type PL24MainGroup,
type PL24Part,
type PL24PartsResponse,
PL24_WMI_SERVICE_MAP, PL24_WMI_SERVICE_MAP,
PL24DecodedVehicle, SERVICE_TO_BRAND,
PL24DecodedCategory,
PL24PartsResponse,
PL24Part,
PL24Hotspot,
PL24MainGroup,
getServiceApiPath, getServiceApiPath,
getServiceConfig, getServiceConfig,
isP5Modern,
isLegacyArchitecture, isLegacyArchitecture,
SERVICE_TO_BRAND, isP5Modern,
} from "./pl24.types"; } from "./pl24.types";
@Injectable() @Injectable()
@@ -48,10 +48,7 @@ export class PL24Service {
private redis: RedisService, private redis: RedisService,
private storage: StorageService, private storage: StorageService,
) { ) {
this.baseUrl = this.configService.get<string>( this.baseUrl = this.configService.get<string>("pl24.baseUrl", "https://www.partslink24.com");
"pl24.baseUrl",
"https://www.partslink24.com",
);
this.timeout = 30000; this.timeout = 30000;
this.language = "tr"; this.language = "tr";
} }
@@ -86,15 +83,11 @@ export class PL24Service {
return null; return null;
} }
this.logger.log( this.logger.log(`Decoding VIN: ${cleanVin} with service: ${serviceName}`);
`Decoding VIN: ${cleanVin} with service: ${serviceName}`,
);
const catalogConfig = getServiceConfig(serviceName); const catalogConfig = getServiceConfig(serviceName);
if (!catalogConfig) { if (!catalogConfig) {
throw new BadRequestException( throw new BadRequestException(`Bu marka PL24'te desteklenmiyor: ${serviceName}`);
`Bu marka PL24'te desteklenmiyor: ${serviceName}`,
);
} }
try { try {
@@ -124,33 +117,21 @@ export class PL24Service {
const vinData = responseData.data || responseData; const vinData = responseData.data || responseData;
if (responseData.error || responseData.errorCode) { if (responseData.error || responseData.errorCode) {
this.logger.warn( this.logger.warn(`VIN decode error: ${responseData.error || responseData.errorCode}`);
`VIN decode error: ${responseData.error || responseData.errorCode}`, throw new BadRequestException(responseData.error || "VIN sorgulanamadi");
);
throw new BadRequestException(
responseData.error || "VIN sorgulanamadi",
);
} }
if (vinData.resultStatus !== "VEHICLE_IDENTIFIED") { if (vinData.resultStatus !== "VEHICLE_IDENTIFIED") {
const message = const message = (responseData.messages as string[])?.[0] || "VIN bulunamadi";
(responseData.messages as string[])?.[0] || "VIN bulunamadi";
throw new NotFoundException(message); throw new NotFoundException(message);
} }
// Parse vehicle info // Parse vehicle info
const vehicle = this.parseVehicleResponse( const vehicle = this.parseVehicleResponse(cleanVin, vinData, serviceName);
cleanVin,
vinData,
serviceName,
);
// Fetch main groups (categories) // Fetch main groups (categories)
const mainGroupsPath = vehicle.catalogInfo?.mainGroupsPath || ""; const mainGroupsPath = vehicle.catalogInfo?.mainGroupsPath || "";
const categories = await this.fetchMainGroupsByPath( const categories = await this.fetchMainGroupsByPath(mainGroupsPath, headers);
mainGroupsPath,
headers,
);
const result: PL24DecodedVehicle = { const result: PL24DecodedVehicle = {
...vehicle, ...vehicle,
@@ -173,15 +154,11 @@ export class PL24Service {
} }
if (err.name === "TimeoutError") { if (err.name === "TimeoutError") {
throw new ServiceUnavailableException( throw new ServiceUnavailableException("PL24 zaman asimina ugradi. Lutfen tekrar deneyin.");
"PL24 zaman asimina ugradi. Lutfen tekrar deneyin.",
);
} }
this.logger.error(`VIN decode error: ${err.message}`, err.stack); this.logger.error(`VIN decode error: ${err.message}`, err.stack);
throw new ServiceUnavailableException( throw new ServiceUnavailableException("VIN sorgulama sirasinda bir hata olustu");
"VIN sorgulama sirasinda bir hata olustu",
);
} }
} }
@@ -227,8 +204,7 @@ export class PL24Service {
const data = (await response.json()) as Record<string, any>; const data = (await response.json()) as Record<string, any>;
const crumbs = const crumbs = (data.crumbs as Array<{ name: string }>) || [];
(data.crumbs as Array<{ name: string }>) || [];
const groupName = crumbs[crumbs.length - 1]?.name || ""; const groupName = crumbs[crumbs.length - 1]?.name || "";
const imageData = this.extractImageData(data); const imageData = this.extractImageData(data);
@@ -310,14 +286,13 @@ export class PL24Service {
const data = (await response.json()) as Record<string, any>; const data = (await response.json()) as Record<string, any>;
const crumbs = const crumbs = (data.crumbs as Array<{ name: string }>) || [];
(data.crumbs as Array<{ name: string }>) || [];
const groupName = crumbs[crumbs.length - 1]?.name || ""; const groupName = crumbs[crumbs.length - 1]?.name || "";
const illustrationId = const illustrationId = linkPath.match(/illustrationId=(\d+)/)?.[1] || "";
linkPath.match(/illustrationId=(\d+)/)?.[1] || "";
// Temporary: log raw images field to diagnose 404 issue // Temporary: log raw images field to diagnose 404 issue
const rawImages = (data?.data as any)?.images || data?.images; const rawImages = (data?.data as any)?.images || data?.images;
if (rawImages) this.logger.log(`[imgdbg] images field: ${JSON.stringify(rawImages).substring(0, 500)}`); if (rawImages)
this.logger.log(`[imgdbg] images field: ${JSON.stringify(rawImages).substring(0, 500)}`);
const imageData = this.extractImageData(data); const imageData = this.extractImageData(data);
let parts = this.parsePartsResponse(data); let parts = this.parsePartsResponse(data);
@@ -325,9 +300,7 @@ export class PL24Service {
// Filter parts by position if this is a position-level request // Filter parts by position if this is a position-level request
if (positionFilter && parts.length > 0) { if (positionFilter && parts.length > 0) {
const filtered = parts.filter( const filtered = parts.filter(
(p) => (p) => p.positionCode === positionFilter || p.positionCode === `(${positionFilter})`,
p.positionCode === positionFilter ||
p.positionCode === `(${positionFilter})`,
); );
if (filtered.length > 0) parts = filtered; if (filtered.length > 0) parts = filtered;
} }
@@ -425,7 +398,13 @@ export class PL24Service {
} }
// PSA illustrations dispatch (/psa/.../json-illustrations.action → illustrations) // PSA illustrations dispatch (/psa/.../json-illustrations.action → illustrations)
if (this.isPsaIllusPath(linkPath)) { if (this.isPsaIllusPath(linkPath)) {
return this.fordLegacyService.fetchPsaIllustrations(linkPath, serviceName, body, engine, gearbox); return this.fordLegacyService.fetchPsaIllustrations(
linkPath,
serviceName,
body,
engine,
gearbox,
);
} }
this.logger.log(`Fetching sub-groups by path: ${linkPath}`); this.logger.log(`Fetching sub-groups by path: ${linkPath}`);
@@ -502,7 +481,14 @@ export class PL24Service {
mode: string, mode: string,
upds: string, upds: string,
): Promise<{ code: string; name: string }[]> { ): Promise<{ code: string; name: string }[]> {
return this.fordLegacyService.fetchPsaEnginesForBody(svc, familyId, salesTypeId, bodyCode, mode, upds); return this.fordLegacyService.fetchPsaEnginesForBody(
svc,
familyId,
salesTypeId,
bodyCode,
mode,
upds,
);
} }
/** /**
@@ -517,7 +503,15 @@ export class PL24Service {
mode: string, mode: string,
upds: string, upds: string,
): Promise<{ code: string; name: string }[]> { ): Promise<{ code: string; name: string }[]> {
return this.fordLegacyService.fetchPsaGearboxes(svc, familyId, salesTypeId, bodyCode, engineCode, mode, upds); return this.fordLegacyService.fetchPsaGearboxes(
svc,
familyId,
salesTypeId,
bodyCode,
engineCode,
mode,
upds,
);
} }
/** /**
@@ -565,7 +559,16 @@ export class PL24Service {
upds: string, upds: string,
catCode?: string, catCode?: string,
): Promise<PL24DecodedCategory[]> { ): Promise<PL24DecodedCategory[]> {
return this.fordLegacyService.fetchFordMainGroups(svc, familyId, modelYear, engine, gearbox, mode, upds, catCode); return this.fordLegacyService.fetchFordMainGroups(
svc,
familyId,
modelYear,
engine,
gearbox,
mode,
upds,
catCode,
);
} }
/** /**
@@ -621,8 +624,15 @@ export class PL24Service {
// - OR the records' link.wid indicates parts navigation (subGroup, partsList, etc.) // - OR the records' link.wid indicates parts navigation (subGroup, partsList, etc.)
const fetchedMainGroups = restrictionPath.includes("/mainGroup"); const fetchedMainGroups = restrictionPath.includes("/mainGroup");
const firstWid = String(records[0]?.link?.wid ?? ""); const firstWid = String(records[0]?.link?.wid ?? "");
const partsWids = ["subGroupTable", "subGroupNodeTable", "partsListTable", "mainGroupNodeTable"]; const partsWids = [
const widsIndicateParts = partsWids.some((w) => firstWid.includes(w) || firstWid.includes("Group")); "subGroupTable",
"subGroupNodeTable",
"partsListTable",
"mainGroupNodeTable",
];
const widsIndicateParts = partsWids.some(
(w) => firstWid.includes(w) || firstWid.includes("Group"),
);
const isFinal = fetchedMainGroups || widsIndicateParts; const isFinal = fetchedMainGroups || widsIndicateParts;
if (isFinal) { if (isFinal) {
@@ -709,9 +719,7 @@ export class PL24Service {
}); });
if (!response.ok) { if (!response.ok) {
this.logger.warn( this.logger.warn(`Failed to download image: HTTP ${response.status}`);
`Failed to download image: HTTP ${response.status}`,
);
return null; return null;
} }
@@ -725,10 +733,8 @@ export class PL24Service {
const jsonData = (await response.json()) as Record<string, any>; const jsonData = (await response.json()) as Record<string, any>;
if (jsonData.image && typeof jsonData.image === "string") { if (jsonData.image && typeof jsonData.image === "string") {
buffer = Buffer.from(jsonData.image, "base64"); buffer = Buffer.from(jsonData.image, "base64");
width = width = jsonData.originalWidth || jsonData.scaledWidth || null;
jsonData.originalWidth || jsonData.scaledWidth || null; height = jsonData.originalHeight || jsonData.scaledHeight || null;
height =
jsonData.originalHeight || jsonData.scaledHeight || null;
if (Array.isArray(jsonData.hotspots)) { if (Array.isArray(jsonData.hotspots)) {
hotspots = jsonData.hotspots.map( hotspots = jsonData.hotspots.map(
@@ -763,11 +769,7 @@ export class PL24Service {
// Upload to MinIO // Upload to MinIO
const minioKey = `schemas/${imageId}.png`; const minioKey = `schemas/${imageId}.png`;
const uploadedUrl = await this.storage.upload( const uploadedUrl = await this.storage.upload(minioKey, buffer, "image/png");
minioKey,
buffer,
"image/png",
);
const result = { const result = {
imageUrl: uploadedUrl, imageUrl: uploadedUrl,
@@ -780,9 +782,7 @@ export class PL24Service {
return result; return result;
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;
this.logger.error( this.logger.error(`Failed to download schema image: ${err.message}`);
`Failed to download schema image: ${err.message}`,
);
return null; return null;
} }
} }
@@ -945,12 +945,36 @@ export class PL24Service {
if (!vin || vin.length < 10) return null; if (!vin || vin.length < 10) return null;
const yearChar = vin.charAt(9).toUpperCase(); const yearChar = vin.charAt(9).toUpperCase();
const yearMap: Record<string, number> = { const yearMap: Record<string, number> = {
"1": 2001, "2": 2002, "3": 2003, "4": 2004, "5": 2005, "1": 2001,
"6": 2006, "7": 2007, "8": 2008, "9": 2009, "2": 2002,
A: 2010, B: 2011, C: 2012, D: 2013, E: 2014, F: 2015, "3": 2003,
G: 2016, H: 2017, J: 2018, K: 2019, L: 2020, M: 2021, "4": 2004,
N: 2022, P: 2023, R: 2024, S: 2025, T: 2026, V: 2027, "5": 2005,
W: 2028, X: 2029, Y: 2030, "6": 2006,
"7": 2007,
"8": 2008,
"9": 2009,
A: 2010,
B: 2011,
C: 2012,
D: 2013,
E: 2014,
F: 2015,
G: 2016,
H: 2017,
J: 2018,
K: 2019,
L: 2020,
M: 2021,
N: 2022,
P: 2023,
R: 2024,
S: 2025,
T: 2026,
V: 2027,
W: 2028,
X: 2029,
Y: 2030,
}; };
return yearMap[yearChar] || null; return yearMap[yearChar] || null;
} }
@@ -966,21 +990,15 @@ export class PL24Service {
serviceName: string, serviceName: string,
): Omit<PL24DecodedVehicle, "categories"> { ): Omit<PL24DecodedVehicle, "categories"> {
const segments = const segments =
(data.segments as Record< (data.segments as Record<string, { records?: Array<{ values: Record<string, string> }> }>) ||
string, {};
{ records?: Array<{ values: Record<string, string> }> }
>) || {};
const vinfoRecords = segments.vinfoBasic?.records || []; const vinfoRecords = segments.vinfoBasic?.records || [];
const vehicleData: Record<string, string> = {}; const vehicleData: Record<string, string> = {};
for (const record of vinfoRecords) { for (const record of vinfoRecords) {
if (record.values) { if (record.values) {
const key = const key = record.values.description?.toLowerCase().replace(/[\s\/]+/g, "_") || "";
record.values.description vehicleData[key] = record.values.value?.replace(/\r?\n/g, " ").trim() || "";
?.toLowerCase()
.replace(/[\s\/]+/g, "_") || "";
vehicleData[key] =
record.values.value?.replace(/\r?\n/g, " ").trim() || "";
} }
} }
@@ -1004,14 +1022,10 @@ export class PL24Service {
} }
// Extract main groups link path // Extract main groups link path
let mainGroupsPath = let mainGroupsPath = (data.link as Record<string, string>)?.path || "";
(data.link as Record<string, string>)?.path || "";
// Mercedes: convert vin_scope to vin_main // Mercedes: convert vin_scope to vin_main
if ( if (this.isDaimlerService(serviceName) && mainGroupsPath.includes("vin_scope")) {
this.isDaimlerService(serviceName) &&
mainGroupsPath.includes("vin_scope")
) {
mainGroupsPath = mainGroupsPath mainGroupsPath = mainGroupsPath
.replace("/groups/vin_scope", "/groups/vin_main") .replace("/groups/vin_scope", "/groups/vin_main")
.replace("?", "?scope=F&subAggregate=n-r&"); .replace("?", "?scope=F&subAggregate=n-r&");
@@ -1024,35 +1038,27 @@ export class PL24Service {
const transmissionCode = lookup("şanzıman_kodu", "sanzıman_kodu", "transmission_code"); const transmissionCode = lookup("şanzıman_kodu", "sanzıman_kodu", "transmission_code");
// Build body type from prNr K8* (Kaporta formları) // Build body type from prNr K8* (Kaporta formları)
const bodyType = Object.entries(prNrByCode).find( const bodyType =
([code]) => code.startsWith("K8"), Object.entries(prNrByCode).find(([code]) => code.startsWith("K8"))?.[1] || null;
)?.[1] || null;
// Engine description from prNr D3* (Motor nitelikleri) // Engine description from prNr D3* (Motor nitelikleri)
const engineDesc = Object.entries(prNrByCode).find( const engineDesc =
([code]) => code.startsWith("D3"), Object.entries(prNrByCode).find(([code]) => code.startsWith("D3"))?.[1] || null;
)?.[1] || null;
// Transmission type from prNr G0* (Şanzıman nitelikleri) // Transmission type from prNr G0* (Şanzıman nitelikleri)
const transmissionDesc = Object.entries(prNrByCode).find( const transmissionDesc =
([code]) => code.startsWith("G0"), Object.entries(prNrByCode).find(([code]) => code.startsWith("G0"))?.[1] || null;
)?.[1] || null;
// Drive type from prNr 1X* (Tahrik türü) // Drive type from prNr 1X* (Tahrik türü)
const driveType = Object.entries(prNrByCode).find( const driveType =
([code]) => code.startsWith("1X"), Object.entries(prNrByCode).find(([code]) => code.startsWith("1X"))?.[1] ||
)?.[1] || lookup("aks_tahrigi_tanimi", "axle_drive"); lookup("aks_tahrigi_tanimi", "axle_drive");
return { return {
brand: SERVICE_TO_BRAND[serviceName] || serviceName.replace("_parts", ""), brand: SERVICE_TO_BRAND[serviceName] || serviceName.replace("_parts", ""),
model: model: lookup("model")?.trim() || (data.description as string)?.split(" - ")[0]?.trim() || "",
lookup("model")?.trim() ||
(data.description as string)?.split(" - ")[0]?.trim() ||
"",
year: year:
parseInt(lookup("model_yili", "year") || "", 10) || Number.parseInt(lookup("model_yili", "year") || "", 10) || this.getYearFromVin(vin) || 0,
this.getYearFromVin(vin) ||
0,
series: lookup("satis_tipi", "sales_type"), series: lookup("satis_tipi", "sales_type"),
bodyType, bodyType,
engineCode: engineCode || (engineDesc ? engineDesc.split("/")[0]?.trim() : null), engineCode: engineCode || (engineDesc ? engineDesc.split("/")[0]?.trim() : null),
@@ -1097,9 +1103,7 @@ export class PL24Service {
}); });
if (!response.ok) { if (!response.ok) {
this.logger.warn( this.logger.warn(`Failed to fetch main groups: HTTP ${response.status}`);
`Failed to fetch main groups: HTTP ${response.status}`,
);
return []; return [];
} }
@@ -1133,9 +1137,7 @@ export class PL24Service {
} }
// Filter out section headers // Filter out section headers
const categoryRecords = records.filter( const categoryRecords = records.filter((record) => record.characteristic !== "sectionrow");
(record) => record.characteristic !== "sectionrow",
);
return categoryRecords.map((record) => { return categoryRecords.map((record) => {
const values = (record.values as Record<string, string>) || {}; const values = (record.values as Record<string, string>) || {};
@@ -1246,9 +1248,7 @@ export class PL24Service {
.replace(/\\r?\\n/g, " ") .replace(/\\r?\\n/g, " ")
.trim(); .trim();
const separatorMatch = rawCaption.match( const separatorMatch = rawCaption.match(/^[\w_]+\s*[–—-]\s*(.+)$/);
/^[\w_]+\s*[–—-]\s*(.+)$/,
);
let name = separatorMatch ? separatorMatch[1].trim() : rawCaption; let name = separatorMatch ? separatorMatch[1].trim() : rawCaption;
const remarks = (values.remarks || "").replace(/\r?\n/g, " ").trim(); const remarks = (values.remarks || "").replace(/\r?\n/g, " ").trim();
@@ -1270,9 +1270,8 @@ export class PL24Service {
// Construct linkPath: use record's own link.path, or bomBaseLink + record id // Construct linkPath: use record's own link.path, or bomBaseLink + record id
const recordLinkPath = (link.path as string) || undefined; const recordLinkPath = (link.path as string) || undefined;
const constructedPath = !recordLinkPath && bomBasePath const constructedPath =
? `${bomBasePath}${record.id}` !recordLinkPath && bomBasePath ? `${bomBasePath}${record.id}` : recordLinkPath;
: recordLinkPath;
return { return {
id: String(record.id || ""), id: String(record.id || ""),
@@ -1293,8 +1292,7 @@ export class PL24Service {
*/ */
private parsePartsResponse(response: unknown): PL24Part[] { private parsePartsResponse(response: unknown): PL24Part[] {
const responseData = response as Record<string, unknown>; const responseData = response as Record<string, unknown>;
const data = const data = (responseData.data as Record<string, unknown>) || responseData;
(responseData.data as Record<string, unknown>) || responseData;
let records: Array<Record<string, unknown>> = []; let records: Array<Record<string, unknown>> = [];
if (Array.isArray(data.records)) { if (Array.isArray(data.records)) {
@@ -1314,26 +1312,18 @@ export class PL24Service {
return partRecords.map((part) => { return partRecords.map((part) => {
const values = (part.values as Record<string, string>) || {}; const values = (part.values as Record<string, string>) || {};
const formattedPartNo = ( const formattedPartNo = ((part.partno as string) || values.partno || "").trim();
(part.partno as string) ||
values.partno ||
""
).trim();
const cleanPartNo = formattedPartNo.replace(/\s+/g, ""); const cleanPartNo = formattedPartNo.replace(/\s+/g, "");
const qtyStr = values.qty || ""; const qtyStr = values.qty || "";
const quantity = parseInt(qtyStr.trim(), 10) || undefined; const quantity = Number.parseInt(qtyStr.trim(), 10) || undefined;
const remark = values.remark?.trim() || undefined; const remark = values.remark?.trim() || undefined;
const modelCodes = values.modelDescription?.trim() || undefined; const modelCodes = values.modelDescription?.trim() || undefined;
let superseded: let superseded: { oldCode: string; newCode: string } | undefined;
| { oldCode: string; newCode: string } const supersededByValue = (part.supersededBy as string) || values.supersededBy || "";
| undefined; const supersedesValue = (part.supersedes as string) || values.supersedes || "";
const supersededByValue =
(part.supersededBy as string) || values.supersededBy || "";
const supersedesValue =
(part.supersedes as string) || values.supersedes || "";
if (supersededByValue || supersedesValue) { if (supersededByValue || supersedesValue) {
superseded = { superseded = {
oldCode: supersedesValue ? cleanPartNo : "", oldCode: supersedesValue ? cleanPartNo : "",
@@ -1343,22 +1333,23 @@ export class PL24Service {
// Price extraction (de account with German market returns EUR prices in BOM) // Price extraction (de account with German market returns EUR prices in BOM)
const priceRaw = String( const priceRaw = String(
values.listPrice || values.netPrice || values.price || values.grossPrice || values.retailPrice || "", values.listPrice ||
values.netPrice ||
values.price ||
values.grossPrice ||
values.retailPrice ||
"",
).trim(); ).trim();
const priceNum = priceRaw ? parseFloat(priceRaw.replace(",", ".")) : Number.NaN; const priceNum = priceRaw ? Number.parseFloat(priceRaw.replace(",", ".")) : Number.NaN;
const price = Number.isNaN(priceNum) ? undefined : priceNum; const price = Number.isNaN(priceNum) ? undefined : priceNum;
const currency = price !== undefined ? (values.currency || "EUR") : undefined; const currency = price !== undefined ? values.currency || "EUR" : undefined;
return { return {
id: String(part.id || ""), id: String(part.id || ""),
oemCode: cleanPartNo, oemCode: cleanPartNo,
formattedPartNo: formattedPartNo || undefined, formattedPartNo: formattedPartNo || undefined,
name: String( name: String(part.description || values.description || ""),
part.description || values.description || "", description: String(part.description || values.description || ""),
),
description: String(
part.description || values.description || "",
),
remark, remark,
quantity, quantity,
positionCode: String(part.pos || values.pos || ""), positionCode: String(part.pos || values.pos || ""),
@@ -1409,8 +1400,7 @@ export class PL24Service {
private extractIllustrationUrl(response: unknown): string | null { private extractIllustrationUrl(response: unknown): string | null {
const responseData = response as Record<string, unknown>; const responseData = response as Record<string, unknown>;
const data = const data = (responseData.data as Record<string, unknown>) || responseData;
(responseData.data as Record<string, unknown>) || responseData;
const images = const images =
(data.images as Array<{ (data.images as Array<{
id: string; id: string;
@@ -1418,8 +1408,7 @@ export class PL24Service {
name: string; name: string;
}>) || []; }>) || [];
const defaultImage = const defaultImage = images.find((img) => img.id === "_DFLT_") || images[0];
images.find((img) => img.id === "_DFLT_") || images[0];
if (defaultImage?.uri) { if (defaultImage?.uri) {
return `${this.baseUrl}${defaultImage.uri}`; return `${this.baseUrl}${defaultImage.uri}`;
} }
@@ -1432,8 +1421,7 @@ export class PL24Service {
schemaHeight?: number; schemaHeight?: number;
} { } {
const responseData = response as Record<string, unknown>; const responseData = response as Record<string, unknown>;
const data = const data = (responseData.data as Record<string, unknown>) || responseData;
(responseData.data as Record<string, unknown>) || responseData;
const images = const images =
(data.images as Array<{ (data.images as Array<{
id?: string; id?: string;
@@ -1443,18 +1431,14 @@ export class PL24Service {
hotspots?: PL24Hotspot[]; hotspots?: PL24Hotspot[];
}>) || []; }>) || [];
const defaultImage = const defaultImage = images.find((img) => img.id === "_DFLT_") || images[0];
images.find((img) => img.id === "_DFLT_") || images[0];
if (!defaultImage) { if (!defaultImage) {
return { hotspots: [] }; return { hotspots: [] };
} }
const hotspots: PL24Hotspot[] = []; const hotspots: PL24Hotspot[] = [];
if ( if (defaultImage.hotspots && Array.isArray(defaultImage.hotspots)) {
defaultImage.hotspots &&
Array.isArray(defaultImage.hotspots)
) {
for (const hs of defaultImage.hotspots) { for (const hs of defaultImage.hotspots) {
if (hs.key && hs.areas) { if (hs.key && hs.areas) {
hotspots.push({ key: hs.key, areas: hs.areas }); hotspots.push({ key: hs.key, areas: hs.areas });
@@ -1475,9 +1459,7 @@ export class PL24Service {
const standardMatch = imageUrl.match(/\/images\/(\d+)\?/); const standardMatch = imageUrl.match(/\/images\/(\d+)\?/);
if (standardMatch) return standardMatch[1]; if (standardMatch) return standardMatch[1];
const tiffMatch = imageUrl.match( const tiffMatch = imageUrl.match(/\/tiffimages\/(?:[^/]+\/)+([a-zA-Z0-9_-]+)\.\w+/);
/\/tiffimages\/(?:[^/]+\/)+([a-zA-Z0-9_-]+)\.\w+/,
);
if (tiffMatch) return tiffMatch[1]; if (tiffMatch) return tiffMatch[1];
const mercedesMatch = imageUrl.match(/[?&]illu=([a-zA-Z0-9_]+)/); const mercedesMatch = imageUrl.match(/[?&]illu=([a-zA-Z0-9_]+)/);
@@ -1533,21 +1515,15 @@ export class PL24Service {
} }
private isDaimlerService(serviceName: string): boolean { private isDaimlerService(serviceName: string): boolean {
return ( return serviceName.startsWith("mercedes") || serviceName === "smart_parts";
serviceName.startsWith("mercedes") || serviceName === "smart_parts"
);
} }
private isDaimlerSubPath(linkPath: string): boolean { private isDaimlerSubPath(linkPath: string): boolean {
return ( return linkPath.includes("/p5daimler/") && linkPath.includes("vin_sub");
linkPath.includes("/p5daimler/") && linkPath.includes("vin_sub")
);
} }
private isJlrIllusPath(linkPath: string): boolean { private isJlrIllusPath(linkPath: string): boolean {
return ( return linkPath.includes("/p5jlr/") && linkPath.includes("vin_illus");
linkPath.includes("/p5jlr/") && linkPath.includes("vin_illus")
);
} }
/** /**
@@ -1570,17 +1546,15 @@ export class PL24Service {
}); });
if (!subResponse.ok) { if (!subResponse.ok) {
throw new Error( throw new Error(`HTTP ${subResponse.status}: ${subResponse.statusText}`);
`HTTP ${subResponse.status}: ${subResponse.statusText}`,
);
} }
const subData = (await subResponse.json()) as Record<string, any>; const subData = (await subResponse.json()) as Record<string, any>;
const records = ((subData.data?.records || []) as Array<{ const records = (subData.data?.records || []) as Array<{
id?: string; id?: string;
link?: { path?: string }; link?: { path?: string };
values?: Record<string, string>; values?: Record<string, string>;
}>); }>;
if (records.length === 0) { if (records.length === 0) {
return { return {
@@ -1610,9 +1584,7 @@ export class PL24Service {
}); });
if (!partsResponse.ok) { if (!partsResponse.ok) {
throw new Error( throw new Error(`Parts HTTP ${partsResponse.status}: ${partsResponse.statusText}`);
`Parts HTTP ${partsResponse.status}: ${partsResponse.statusText}`,
);
} }
const partsData = (await partsResponse.json()) as Record<string, any>; const partsData = (await partsResponse.json()) as Record<string, any>;
@@ -1624,17 +1596,13 @@ export class PL24Service {
let schemaHeight: number | undefined; let schemaHeight: number | undefined;
if (schemaImageUrl) { if (schemaImageUrl) {
const imageData = await this.fetchDaimlerImageWithHotspots( const imageData = await this.fetchDaimlerImageWithHotspots(schemaImageUrl, serviceName);
schemaImageUrl,
serviceName,
);
hotspots = imageData.hotspots; hotspots = imageData.hotspots;
schemaWidth = imageData.width; schemaWidth = imageData.width;
schemaHeight = imageData.height; schemaHeight = imageData.height;
} }
const crumbs = const crumbs = (partsData.crumbs as Array<{ name: string }>) || [];
(partsData.crumbs as Array<{ name: string }>) || [];
const groupName = crumbs[crumbs.length - 1]?.name || ""; const groupName = crumbs[crumbs.length - 1]?.name || "";
return { return {
@@ -1650,16 +1618,14 @@ export class PL24Service {
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;
this.logger.error(`Mercedes parts fetch error: ${err.message}`); this.logger.error(`Mercedes parts fetch error: ${err.message}`);
throw new ServiceUnavailableException( throw new ServiceUnavailableException("Mercedes parca listesi alinamadi");
"Mercedes parca listesi alinamadi",
);
} }
} }
private parseDaimlerPartsResponse(response: unknown): PL24Part[] { private parseDaimlerPartsResponse(response: unknown): PL24Part[] {
const responseData = response as Record<string, unknown>; const responseData = response as Record<string, unknown>;
const data = (responseData.data as Record<string, unknown>) || {}; const data = (responseData.data as Record<string, unknown>) || {};
const records = ((data.records || []) as Array<{ const records = (data.records || []) as Array<{
id?: string; id?: string;
partno?: string; partno?: string;
description?: string; description?: string;
@@ -1674,14 +1640,11 @@ export class PL24Service {
qty?: string; qty?: string;
pos?: string; pos?: string;
}; };
}>); }>;
return records.map((record) => { return records.map((record) => {
const values = record.values || {}; const values = record.values || {};
const oemCode = (record.partno || values.partno || "").replace( const oemCode = (record.partno || values.partno || "").replace(/\s+/g, "");
/\s+/g,
"",
);
const formattedPartNo = record.partno || values.partno || ""; const formattedPartNo = record.partno || values.partno || "";
return { return {
@@ -1692,7 +1655,7 @@ export class PL24Service {
description: values.remark || undefined, description: values.remark || undefined,
positionCode: record.pos || values.pos || undefined, positionCode: record.pos || values.pos || undefined,
hotspotId: record.hotspotId || record.pos || undefined, hotspotId: record.hotspotId || record.pos || undefined,
quantity: parseInt(values.qty || "1", 10) || 1, quantity: Number.parseInt(values.qty || "1", 10) || 1,
modelCodes: values.restrictions || undefined, modelCodes: values.restrictions || undefined,
presel: !!record.presel, presel: !!record.presel,
}; };
@@ -1704,19 +1667,17 @@ export class PL24Service {
} { } {
const responseData = response as Record<string, unknown>; const responseData = response as Record<string, unknown>;
const data = (responseData.data as Record<string, unknown>) || {}; const data = (responseData.data as Record<string, unknown>) || {};
const images = ((data.images || []) as Array<{ const images = (data.images || []) as Array<{
id?: string; id?: string;
uri?: string; uri?: string;
}>); }>;
if (images.length === 0) return {}; if (images.length === 0) return {};
const imageUri = images[0].uri; const imageUri = images[0].uri;
if (!imageUri) return {}; if (!imageUri) return {};
const schemaImageUrl = imageUri.startsWith("http") const schemaImageUrl = imageUri.startsWith("http") ? imageUri : `${this.baseUrl}${imageUri}`;
? imageUri
: `${this.baseUrl}${imageUri}`;
return { schemaImageUrl }; return { schemaImageUrl };
} }
@@ -1773,21 +1734,15 @@ export class PL24Service {
return { return {
hotspots, hotspots,
width: width: jsonData.originalWidth || jsonData.scaledWidth || undefined,
jsonData.originalWidth || jsonData.scaledWidth || undefined, height: jsonData.originalHeight || jsonData.scaledHeight || undefined,
height:
jsonData.originalHeight ||
jsonData.scaledHeight ||
undefined,
}; };
} }
return { hotspots: [] }; return { hotspots: [] };
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;
this.logger.error( this.logger.error(`Mercedes: Failed to fetch image hotspots: ${err.message}`);
`Mercedes: Failed to fetch image hotspots: ${err.message}`,
);
return { hotspots: [] }; return { hotspots: [] };
} }
} }
@@ -1812,9 +1767,7 @@ export class PL24Service {
}); });
if (!illusResponse.ok) { if (!illusResponse.ok) {
throw new Error( throw new Error(`HTTP ${illusResponse.status}: ${illusResponse.statusText}`);
`HTTP ${illusResponse.status}: ${illusResponse.statusText}`,
);
} }
const illusData = (await illusResponse.json()) as Record<string, any>; const illusData = (await illusResponse.json()) as Record<string, any>;
@@ -1830,9 +1783,7 @@ export class PL24Service {
}; };
} }
const urlParams = new URLSearchParams( const urlParams = new URLSearchParams(illusPath.split("?")[1] || "");
illusPath.split("?")[1] || "",
);
const mg = urlParams.get("mg") || ""; const mg = urlParams.get("mg") || "";
const sg = urlParams.get("sg") || ""; const sg = urlParams.get("sg") || "";
const vin = urlParams.get("vin") || ""; const vin = urlParams.get("vin") || "";
@@ -1849,22 +1800,17 @@ export class PL24Service {
}); });
if (!bomResponse.ok) { if (!bomResponse.ok) {
throw new Error( throw new Error(`HTTP ${bomResponse.status}: ${bomResponse.statusText}`);
`HTTP ${bomResponse.status}: ${bomResponse.statusText}`,
);
} }
const bomData = (await bomResponse.json()) as Record<string, any>; const bomData = (await bomResponse.json()) as Record<string, any>;
const crumbs = const crumbs = (bomData.crumbs as Array<{ name: string }>) || [];
(bomData.crumbs as Array<{ name: string }>) || []; const groupName = crumbs[crumbs.length - 1]?.name || firstIllus.name || "";
const groupName =
crumbs[crumbs.length - 1]?.name || firstIllus.name || "";
const parts = this.parsePartsResponse(bomData); const parts = this.parsePartsResponse(bomData);
const schemaImageUrl = this.extractIllustrationUrl(bomData); const schemaImageUrl = this.extractIllustrationUrl(bomData);
const { hotspots, schemaWidth, schemaHeight } = const { hotspots, schemaWidth, schemaHeight } = this.extractImageData(bomData);
this.extractImageData(bomData);
return { return {
success: true, success: true,
@@ -1879,9 +1825,7 @@ export class PL24Service {
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;
this.logger.error(`JLR fetch parts error: ${err.message}`); this.logger.error(`JLR fetch parts error: ${err.message}`);
throw new ServiceUnavailableException( throw new ServiceUnavailableException("Parca listesi alinamadi (JLR)");
"Parca listesi alinamadi (JLR)",
);
} }
} }
@@ -1935,9 +1879,7 @@ export class PL24Service {
await this.authService.authorizeServiceForAccount(serviceName, "de"); await this.authService.authorizeServiceForAccount(serviceName, "de");
headers = await this.authService.buildAuthHeadersForAccount("de", serviceName); headers = await this.authService.buildAuthHeadersForAccount("de", serviceName);
} catch { } catch {
this.logger.warn( this.logger.warn(`fetchVehicleList: de auth failed for ${serviceName}, using main token`);
`fetchVehicleList: de auth failed for ${serviceName}, using main token`,
);
headers = await this.authService.buildAuthHeaders(); headers = await this.authService.buildAuthHeaders();
} }
@@ -2049,7 +1991,12 @@ export class PL24Service {
const vehicleId = String(r.id || r.vehicleId || r.vid || ""); const vehicleId = String(r.id || r.vehicleId || r.vid || "");
// modelfamilies uses values.caption; older formats use values.model / r.description // modelfamilies uses values.caption; older formats use values.model / r.description
const model = const model =
values.caption || values.model || values.description || r.description || r.name || vehicleId; values.caption ||
values.model ||
values.description ||
r.description ||
r.name ||
vehicleId;
const link = r.link || {}; const link = r.link || {};
return { return {
@@ -2107,7 +2054,10 @@ export class PL24Service {
body = await response.text(); body = await response.text();
} }
} }
results[endpoint] = { status, body: body ? JSON.stringify(body).substring(0, 2000) : null }; results[endpoint] = {
status,
body: body ? JSON.stringify(body).substring(0, 2000) : null,
};
} catch (err) { } catch (err) {
results[endpoint] = { error: (err as Error).message }; results[endpoint] = { error: (err as Error).message };
} }
@@ -2124,8 +2074,7 @@ export class PL24Service {
response: unknown, response: unknown,
): Array<{ btnr: number; name: string; code: string }> { ): Array<{ btnr: number; name: string; code: string }> {
const responseData = response as Record<string, unknown>; const responseData = response as Record<string, unknown>;
const data = const data = (responseData.data as Record<string, unknown>) || responseData;
(responseData.data as Record<string, unknown>) || responseData;
let records: Array<Record<string, unknown>> = []; let records: Array<Record<string, unknown>> = [];
if (Array.isArray(data.records)) { if (Array.isArray(data.records)) {
@@ -2135,8 +2084,7 @@ export class PL24Service {
} }
const availableRecords = records.filter( const availableRecords = records.filter(
(record) => (record) => !record.unavailable && record.characteristic !== "sectionrow",
!record.unavailable && record.characteristic !== "sectionrow",
); );
return availableRecords return availableRecords
@@ -2146,7 +2094,7 @@ export class PL24Service {
const linkPath = (link.path as string) || ""; const linkPath = (link.path as string) || "";
const btnrMatch = linkPath.match(/btnr=(\d+)/); const btnrMatch = linkPath.match(/btnr=(\d+)/);
const btnr = btnrMatch ? parseInt(btnrMatch[1], 10) : 0; const btnr = btnrMatch ? Number.parseInt(btnrMatch[1], 10) : 0;
const name = const name =
values.captions || values.captions ||
@@ -2157,10 +2105,7 @@ export class PL24Service {
""; "";
const code = const code =
values.illustrationNumber || values.illustrationNumber || values.subgroup || values.code || String(record.id || "");
values.subgroup ||
values.code ||
String(record.id || "");
return { btnr, name, code }; return { btnr, name, code };
}) })

View File

@@ -25,12 +25,7 @@ export interface PL24LoginRequest {
} }
export interface PL24LoginResponse { export interface PL24LoginResponse {
status: status: "OK" | "USER_ALREADY_LOGGED_IN" | "INVALID_CREDENTIALS" | "ERROR" | null;
| "OK"
| "USER_ALREADY_LOGGED_IN"
| "INVALID_CREDENTIALS"
| "ERROR"
| null;
message?: string; message?: string;
token?: { token?: {
access_token: string; access_token: string;
@@ -388,9 +383,7 @@ export function getServiceApiPath(serviceName: string): string {
return config?.apiPath || "/p5vwag"; return config?.apiPath || "/p5vwag";
} }
export function getServiceConfig( export function getServiceConfig(serviceName: string): PL24CatalogConfig | null {
serviceName: string,
): PL24CatalogConfig | null {
return PL24_SERVICE_CATALOGS[serviceName] || null; return PL24_SERVICE_CATALOGS[serviceName] || null;
} }

View File

@@ -1,4 +1,4 @@
import { ConnectionOptions } from "bullmq"; import type { ConnectionOptions } from "bullmq";
import { isOtelEnabled } from "../telemetry"; import { isOtelEnabled } from "../telemetry";
export function getBullConnection(): ConnectionOptions { export function getBullConnection(): ConnectionOptions {

View File

@@ -1,17 +1,17 @@
import { Module, OnModuleInit, Inject, OnModuleDestroy } from "@nestjs/common"; import { Inject, Module, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
import { Queue } from "bullmq"; import type { Queue } from "bullmq";
import { EmexScrapeQueueProvider, EMEX_SCRAPE_QUEUE } from "./queues/emex-scrape.queue";
import {
SubscriptionExpiryQueueProvider,
SUBSCRIPTION_EXPIRY_QUEUE,
} from "./queues/subscription-expiry.queue";
import { QueryCleanupQueueProvider, QUERY_CLEANUP_QUEUE } from "./queues/query-cleanup.queue";
import {
CatalogPrefetchQueueProvider,
CATALOG_PREFETCH_QUEUE,
} from "./queues/catalog-prefetch.queue";
import { PrefetchWorkerService } from "./prefetch-worker.service";
import { CategoriesModule } from "../categories/categories.module"; import { CategoriesModule } from "../categories/categories.module";
import { PrefetchWorkerService } from "./prefetch-worker.service";
import {
CATALOG_PREFETCH_QUEUE,
CatalogPrefetchQueueProvider,
} from "./queues/catalog-prefetch.queue";
import { EMEX_SCRAPE_QUEUE, EmexScrapeQueueProvider } from "./queues/emex-scrape.queue";
import { QUERY_CLEANUP_QUEUE, QueryCleanupQueueProvider } from "./queues/query-cleanup.queue";
import {
SUBSCRIPTION_EXPIRY_QUEUE,
SubscriptionExpiryQueueProvider,
} from "./queues/subscription-expiry.queue";
@Module({ @Module({
imports: [CategoriesModule], imports: [CategoriesModule],

View File

@@ -1,4 +1,4 @@
import { RedisService } from "../redis/redis.service"; import type { RedisService } from "../redis/redis.service";
/** /**
* Custom error that tells BullMQ to retry after a delay. * Custom error that tells BullMQ to retry after a delay.
@@ -15,10 +15,7 @@ export class RateLimitError extends Error {
* Check if a user is actively using the source. * Check if a user is actively using the source.
* Throws RateLimitError (1min retry) if cooldown key exists. * Throws RateLimitError (1min retry) if cooldown key exists.
*/ */
export async function checkCooldown( export async function checkCooldown(redis: RedisService, source: string): Promise<void> {
redis: RedisService,
source: string,
): Promise<void> {
const key = `prefetch:activity:${source}`; const key = `prefetch:activity:${source}`;
const exists = await redis.exists(key); const exists = await redis.exists(key);
if (exists) { if (exists) {
@@ -39,7 +36,7 @@ export function checkTimeWindow(source: string): void {
hour: "numeric", hour: "numeric",
hour12: false, hour12: false,
}).format(new Date()); }).format(new Date());
const h = parseInt(hourStr, 10); const h = Number.parseInt(hourStr, 10);
const endHour = source === "parts-catalogs" ? 19 : 18; const endHour = source === "parts-catalogs" ? 19 : 18;
if (h < 9 || h >= endHour) { if (h < 9 || h >= endHour) {
@@ -66,7 +63,7 @@ export function msUntilNext9AM(): number {
}).formatToParts(now); }).formatToParts(now);
const get = (type: string) => const get = (type: string) =>
parseInt(istParts.find((p) => p.type === type)?.value || "0", 10); Number.parseInt(istParts.find((p) => p.type === type)?.value || "0", 10);
const hour = get("hour"); const hour = get("hour");
const minute = get("minute"); const minute = get("minute");
@@ -81,10 +78,7 @@ export function msUntilNext9AM(): number {
hoursToWait = 24 - hour + 9; hoursToWait = 24 - hour + 9;
} }
const ms = const ms = hoursToWait * 3600_000 - minute * 60_000 - second * 1000;
hoursToWait * 3600_000 -
minute * 60_000 -
second * 1000;
// At least 1 minute, at most 15 hours // At least 1 minute, at most 15 hours
return Math.max(60_000, Math.min(ms, 15 * 3600_000)); return Math.max(60_000, Math.min(ms, 15 * 3600_000));
@@ -104,19 +98,20 @@ export interface PrefetchProgress {
updatedAt: string; updatedAt: string;
} }
export async function initProgress( export async function initProgress(redis: RedisService, vehicleId: string): Promise<void> {
redis: RedisService,
vehicleId: string,
): Promise<void> {
const now = new Date().toISOString(); const now = new Date().toISOString();
await redis.setJson(progressKey(vehicleId), { await redis.setJson(
progressKey(vehicleId),
{
status: "running", status: "running",
total: 0, total: 0,
completed: 0, completed: 0,
errors: 0, errors: 0,
startedAt: now, startedAt: now,
updatedAt: now, updatedAt: now,
} satisfies PrefetchProgress, 86400); } satisfies PrefetchProgress,
86400,
);
} }
export async function updateProgress( export async function updateProgress(

View File

@@ -2,17 +2,16 @@ import {
Inject, Inject,
Injectable, Injectable,
Logger, Logger,
OnModuleDestroy, type OnModuleDestroy,
OnModuleInit, type OnModuleInit,
} from "@nestjs/common"; } from "@nestjs/common";
import { Job, Queue, Worker } from "bullmq"; import { type Job, type Queue, Worker } from "bullmq";
import { eq, and, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { CATALOG_PREFETCH_QUEUE } from "./queues/catalog-prefetch.queue"; import type { CategoriesService } from "../categories/categories.service";
import { getBullConnection, QUEUE_NAMES } from "./bull.config"; import { DATABASE, type Database } from "../database/database.provider";
import { import { categories, parts, vehicles } from "../database/schema/core";
PrefetchInitJobData, import type { RedisService } from "../redis/redis.service";
PrefetchCategoryJobData, import { QUEUE_NAMES, getBullConnection } from "./bull.config";
} from "./prefetch.types";
import { import {
RateLimitError, RateLimitError,
checkCooldown, checkCooldown,
@@ -20,10 +19,11 @@ import {
initProgress, initProgress,
updateProgress, updateProgress,
} from "./prefetch-utils"; } from "./prefetch-utils";
import { CategoriesService } from "../categories/categories.service"; import type {
import { RedisService } from "../redis/redis.service"; PrefetchCategoryJobData,
import { DATABASE, Database } from "../database/database.provider"; PrefetchInitJobData,
import { categories, parts, vehicles } from "../database/schema/core"; } from "./prefetch.types";
import { CATALOG_PREFETCH_QUEUE } from "./queues/catalog-prefetch.queue";
const MAX_DEPTH = 5; const MAX_DEPTH = 5;

View File

@@ -1,14 +1,14 @@
import { Job } from "bullmq"; import type { Job } from "bullmq";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { import {
emexCatalogs, emexCatalogs,
emexVehicles,
emexVehicleVins,
emexPartGroups, emexPartGroups,
emexParts,
emexPartNumbers, emexPartNumbers,
emexParts,
emexScrapeSessions, emexScrapeSessions,
emexVehicleVins,
emexVehicles,
} from "../../database/schema/emex"; } from "../../database/schema/emex";
// Legacy type — kept inline since emex.types.ts was rewritten for emexdwc.ae integration // Legacy type — kept inline since emex.types.ts was rewritten for emexdwc.ae integration
interface EmexScrapeJobData { interface EmexScrapeJobData {
@@ -47,7 +47,7 @@ export async function processEmexScrape(
try { try {
// ── Step 1: Resolve vehicle from VIN ────────────────── // ── Step 1: Resolve vehicle from VIN ──────────────────
let emexVehicleRecord = await db const emexVehicleRecord = await db
.select({ id: emexVehicles.id, vehicleId: emexVehicles.vehicleId }) .select({ id: emexVehicles.id, vehicleId: emexVehicles.vehicleId })
.from(emexVehicles) .from(emexVehicles)
.innerJoin(emexVehicleVins, eq(emexVehicleVins.emexVehicleId, emexVehicles.id)) .innerJoin(emexVehicleVins, eq(emexVehicleVins.emexVehicleId, emexVehicles.id))

View File

@@ -1,6 +1,6 @@
import { Job } from "bullmq"; import type { Job } from "bullmq";
import { lt } from "drizzle-orm"; import { lt } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { queryLogs } from "../../database/schema/core"; import { queryLogs } from "../../database/schema/core";
type Database = PostgresJsDatabase<Record<string, unknown>>; type Database = PostgresJsDatabase<Record<string, unknown>>;

View File

@@ -1,7 +1,7 @@
import { Job } from "bullmq"; import type { Job } from "bullmq";
import { and, eq, lt } from "drizzle-orm"; import { and, eq, lt } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { userSubscriptions, userBrands } from "../../database/schema/core"; import { userBrands, userSubscriptions } from "../../database/schema/core";
type Database = PostgresJsDatabase<Record<string, unknown>>; type Database = PostgresJsDatabase<Record<string, unknown>>;
@@ -17,12 +17,7 @@ export async function processSubscriptionExpiry(
const expiredSubs = await db const expiredSubs = await db
.select({ id: userSubscriptions.id, userId: userSubscriptions.userId }) .select({ id: userSubscriptions.id, userId: userSubscriptions.userId })
.from(userSubscriptions) .from(userSubscriptions)
.where( .where(and(eq(userSubscriptions.status, "active"), lt(userSubscriptions.endDate, now)));
and(
eq(userSubscriptions.status, "active"),
lt(userSubscriptions.endDate, now),
),
);
if (expiredSubs.length === 0) { if (expiredSubs.length === 0) {
console.log("[subscription-expiry] No expired subscriptions found"); console.log("[subscription-expiry] No expired subscriptions found");

View File

@@ -1,6 +1,6 @@
import { Provider } from "@nestjs/common"; import type { Provider } from "@nestjs/common";
import { Queue } from "bullmq"; import { Queue } from "bullmq";
import { getBullConnection, getBullTelemetry, QUEUE_NAMES } from "../bull.config"; import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
export const CATALOG_PREFETCH_QUEUE = "CATALOG_PREFETCH_QUEUE"; export const CATALOG_PREFETCH_QUEUE = "CATALOG_PREFETCH_QUEUE";

View File

@@ -1,6 +1,6 @@
import { Provider } from "@nestjs/common"; import type { Provider } from "@nestjs/common";
import { Queue } from "bullmq"; import { Queue } from "bullmq";
import { getBullConnection, getBullTelemetry, QUEUE_NAMES } from "../bull.config"; import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
export const EMEX_SCRAPE_QUEUE = "EMEX_SCRAPE_QUEUE"; export const EMEX_SCRAPE_QUEUE = "EMEX_SCRAPE_QUEUE";

View File

@@ -1,6 +1,6 @@
import { Provider } from "@nestjs/common"; import type { Provider } from "@nestjs/common";
import { Queue } from "bullmq"; import { Queue } from "bullmq";
import { getBullConnection, getBullTelemetry, QUEUE_NAMES } from "../bull.config"; import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
export const QUERY_CLEANUP_QUEUE = "QUERY_CLEANUP_QUEUE"; export const QUERY_CLEANUP_QUEUE = "QUERY_CLEANUP_QUEUE";

View File

@@ -1,6 +1,6 @@
import { Provider } from "@nestjs/common"; import type { Provider } from "@nestjs/common";
import { Queue } from "bullmq"; import { Queue } from "bullmq";
import { getBullConnection, getBullTelemetry, QUEUE_NAMES } from "../bull.config"; import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
export const SUBSCRIPTION_EXPIRY_QUEUE = "SUBSCRIPTION_EXPIRY_QUEUE"; export const SUBSCRIPTION_EXPIRY_QUEUE = "SUBSCRIPTION_EXPIRY_QUEUE";

View File

@@ -1,9 +1,9 @@
import "./telemetry/tracing"; // MUST be first — instruments modules before they load import "./telemetry/tracing"; // MUST be first — instruments modules before they load
import { NestFactory } from "@nestjs/core";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { NestFactory } from "@nestjs/core";
import type { NextFunction, Request, Response } from "express";
import helmet from "helmet"; import helmet from "helmet";
import type { Request, Response, NextFunction } from "express";
import { AppModule } from "./app.module"; import { AppModule } from "./app.module";
import { fileUploadValidation } from "./common/middleware/file-upload-validation.middleware"; import { fileUploadValidation } from "./common/middleware/file-upload-validation.middleware";

View File

@@ -1,5 +1,5 @@
import { Controller, Get, Param, Query } from "@nestjs/common"; import { Controller, Get, Param, Query } from "@nestjs/common";
import { PartsService } from "./parts.service"; import type { PartsService } from "./parts.service";
@Controller("parts") @Controller("parts")
export class PartsController { export class PartsController {

View File

@@ -1,7 +1,7 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { PL24Module } from "../integrations/pl24/pl24.module";
import { PartsController } from "./parts.controller"; import { PartsController } from "./parts.controller";
import { PartsService } from "./parts.service"; import { PartsService } from "./parts.service";
import { PL24Module } from "../integrations/pl24/pl24.module";
@Module({ @Module({
imports: [PL24Module], imports: [PL24Module],

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NotFoundException } from "@nestjs/common"; import { NotFoundException } from "@nestjs/common";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PartsService } from "./parts.service"; import { PartsService } from "./parts.service";
function createService(db: any) { function createService(db: any) {
@@ -46,7 +46,16 @@ describe("PartsService", () => {
it("should fetch from PL24 when DB is empty", async () => { it("should fetch from PL24 when DB is empty", async () => {
const category = { id: "cat-1", vehicleId: "v1", externalId: "g1" }; const category = { id: "cat-1", vehicleId: "v1", externalId: "g1" };
const vehicle = { id: "v1", rawData: { vehicleId: "pl24-v1" }, brandName: "BMW" }; const vehicle = { id: "v1", rawData: { vehicleId: "pl24-v1" }, brandName: "BMW" };
const pl24Parts = [{ name: "Oil Filter", oemCodes: ["OEM-1"], description: "Filter", quantity: 1, position: null, hotspotIndex: null }]; const pl24Parts = [
{
name: "Oil Filter",
oemCodes: ["OEM-1"],
description: "Filter",
quantity: 1,
position: null,
hotspotIndex: null,
},
];
const insertedParts = [{ id: "p1", name: "Oil Filter", oemCode: "OEM-1" }]; const insertedParts = [{ id: "p1", name: "Oil Filter", oemCode: "OEM-1" }];
let selectCall = 0; let selectCall = 0;

View File

@@ -1,8 +1,8 @@
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { eq, like } from "drizzle-orm"; import { eq, like } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider"; import { DATABASE, type Database } from "../database/database.provider";
import { parts, categories, vehicles, schemaPics } from "../database/schema/core"; import { categories, parts, schemaPics, vehicles } from "../database/schema/core";
import { PL24Service } from "../integrations/pl24/pl24.service"; import type { PL24Service } from "../integrations/pl24/pl24.service";
@Injectable() @Injectable()
export class PartsService { export class PartsService {
@@ -65,10 +65,10 @@ export class PartsService {
name: p.name, name: p.name,
nameOriginal: p.name, nameOriginal: p.name,
description: p.description || null, description: p.description || null,
quantity: p.quantity ? (parseInt(String(p.quantity), 10) || null) : null, quantity: p.quantity ? (Number.parseInt(String(p.quantity), 10) || null) : null,
position: p.positionCode || null, position: p.positionCode || null,
hotspotIndex: p.hotspotId ? (() => { hotspotIndex: p.hotspotId ? (() => {
const val = parseInt(p.hotspotId!, 10); const val = Number.parseInt(p.hotspotId!, 10);
return (val > 0 && val <= 2147483647) ? val : null; return (val > 0 && val <= 2147483647) ? val : null;
})() : null, })() : null,
unavailable: p.unavailable || false, unavailable: p.unavailable || false,

View File

@@ -1,20 +1,20 @@
import { import {
BadRequestException,
Body,
Controller, Controller,
Get, Get,
Post,
Patch,
Param, Param,
Body, Patch,
Post,
UploadedFile,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
UploadedFile,
BadRequestException,
} from "@nestjs/common"; } from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express"; import { FileInterceptor } from "@nestjs/platform-express";
import { PaymentsService } from "./payments.service";
import { CurrentUser } from "../common/decorators/current-user.decorator"; import { CurrentUser } from "../common/decorators/current-user.decorator";
import { Roles } from "../common/decorators/roles.decorator"; import { Roles } from "../common/decorators/roles.decorator";
import { RolesGuard } from "../common/guards/roles.guard"; import { RolesGuard } from "../common/guards/roles.guard";
import type { PaymentsService } from "./payments.service";
@Controller("payments") @Controller("payments")
export class PaymentsController { export class PaymentsController {

View File

@@ -1,7 +1,7 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
import { PaymentsController } from "./payments.controller"; import { PaymentsController } from "./payments.controller";
import { PaymentsService } from "./payments.service"; import { PaymentsService } from "./payments.service";
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
@Module({ @Module({
imports: [SubscriptionsModule], imports: [SubscriptionsModule],

View File

@@ -1,14 +1,27 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { BadRequestException, NotFoundException } from "@nestjs/common"; import { BadRequestException, NotFoundException } from "@nestjs/common";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PaymentsService } from "./payments.service"; import { PaymentsService } from "./payments.service";
function createMockDb(overrides: Record<string, unknown> = {}) { function createMockDb(overrides: Record<string, unknown> = {}) {
function chainable(terminalValue: unknown) { function chainable(terminalValue: unknown) {
const chain: Record<string, unknown> = {}; const chain: Record<string, unknown> = {};
const methods = [ const methods = [
"select", "from", "where", "orderBy", "limit", "offset", "select",
"innerJoin", "leftJoin", "insert", "values", "update", "set", "from",
"delete", "returning", "onConflictDoNothing", "groupBy", "where",
"orderBy",
"limit",
"offset",
"innerJoin",
"leftJoin",
"insert",
"values",
"update",
"set",
"delete",
"returning",
"onConflictDoNothing",
"groupBy",
]; ];
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain); for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
chain.limit = vi.fn().mockReturnValue(terminalValue); chain.limit = vi.fn().mockReturnValue(terminalValue);
@@ -26,7 +39,10 @@ function createMockDb(overrides: Record<string, unknown> = {}) {
}; };
} }
function createService(dbOverrides: Record<string, unknown> = {}, subServiceOverrides: Record<string, any> = {}) { function createService(
dbOverrides: Record<string, unknown> = {},
subServiceOverrides: Record<string, any> = {},
) {
const db = typeof dbOverrides.select === "function" ? dbOverrides : createMockDb(dbOverrides); const db = typeof dbOverrides.select === "function" ? dbOverrides : createMockDb(dbOverrides);
const configService = { get: vi.fn().mockReturnValue("test-value") }; const configService = { get: vi.fn().mockReturnValue("test-value") };
const subscriptionsService = { const subscriptionsService = {
@@ -60,7 +76,17 @@ describe("PaymentsService", () => {
select: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnThis(), from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([{ id: "plan-1", brandCount: 1, priceMonthly: 20000, priceYearly: 200000, isActive: true }]), limit: vi
.fn()
.mockReturnValue([
{
id: "plan-1",
brandCount: 1,
priceMonthly: 20000,
priceYearly: 200000,
isActive: true,
},
]),
}), }),
insert: vi.fn().mockReturnValue({ insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnThis(), values: vi.fn().mockReturnThis(),
@@ -78,7 +104,9 @@ describe("PaymentsService", () => {
it("should throw BadRequestException for invalid plan key", async () => { it("should throw BadRequestException for invalid plan key", async () => {
const { service } = createService(); const { service } = createService();
await expect(service.initializeIyzico("u1", "invalid", "monthly", [])).rejects.toThrow(BadRequestException); await expect(service.initializeIyzico("u1", "invalid", "monthly", [])).rejects.toThrow(
BadRequestException,
);
}); });
}); });
@@ -88,7 +116,9 @@ describe("PaymentsService", () => {
select: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnThis(), from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([{ id: "pay-1", subscriptionId: "sub-1", status: "pending" }]), limit: vi
.fn()
.mockReturnValue([{ id: "pay-1", subscriptionId: "sub-1", status: "pending" }]),
}), }),
update: vi.fn().mockReturnValue({ update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnThis(), set: vi.fn().mockReturnThis(),
@@ -107,7 +137,9 @@ describe("PaymentsService", () => {
select: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnThis(), from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([{ id: "pay-1", subscriptionId: "sub-1", status: "pending" }]), limit: vi
.fn()
.mockReturnValue([{ id: "pay-1", subscriptionId: "sub-1", status: "pending" }]),
}), }),
update: vi.fn().mockReturnValue({ update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnThis(), set: vi.fn().mockReturnThis(),
@@ -131,7 +163,9 @@ describe("PaymentsService", () => {
}; };
const { service } = createService(db); const { service } = createService(db);
await expect(service.handleIyzicoCallback("nonexistent", "iyz-1", "success")).rejects.toThrow(NotFoundException); await expect(service.handleIyzicoCallback("nonexistent", "iyz-1", "success")).rejects.toThrow(
NotFoundException,
);
}); });
}); });
@@ -141,11 +175,23 @@ describe("PaymentsService", () => {
select: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnThis(), from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([{ id: "plan-1", brandCount: 0, priceMonthly: 99900, priceYearly: 999000, isActive: true }]), limit: vi
.fn()
.mockReturnValue([
{
id: "plan-1",
brandCount: 0,
priceMonthly: 99900,
priceYearly: 999000,
isActive: true,
},
]),
}), }),
insert: vi.fn().mockReturnValue({ insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnThis(), values: vi.fn().mockReturnThis(),
returning: vi.fn().mockReturnValue([{ id: "pay-eft-1", method: "eft", status: "pending" }]), returning: vi
.fn()
.mockReturnValue([{ id: "pay-eft-1", method: "eft", status: "pending" }]),
}), }),
}; };
const { service, subscriptionsService } = createService(db); const { service, subscriptionsService } = createService(db);
@@ -160,7 +206,9 @@ describe("PaymentsService", () => {
it("should throw BadRequestException for invalid plan key", async () => { it("should throw BadRequestException for invalid plan key", async () => {
const { service } = createService(); const { service } = createService();
await expect(service.createEftPayment("u1", "nonexistent", "monthly", [])).rejects.toThrow(BadRequestException); await expect(service.createEftPayment("u1", "nonexistent", "monthly", [])).rejects.toThrow(
BadRequestException,
);
}); });
}); });
@@ -179,7 +227,12 @@ describe("PaymentsService", () => {
}; };
const { service } = createService(db); const { service } = createService(db);
const result = await service.uploadEftReceipt("pay-1", "u1", Buffer.from("pdf"), "receipt.pdf"); const result = await service.uploadEftReceipt(
"pay-1",
"u1",
Buffer.from("pdf"),
"receipt.pdf",
);
expect(result.receiptUrl).toBe("https://storage.test/receipt.pdf"); expect(result.receiptUrl).toBe("https://storage.test/receipt.pdf");
}); });
@@ -193,7 +246,9 @@ describe("PaymentsService", () => {
}; };
const { service } = createService(db); const { service } = createService(db);
await expect(service.uploadEftReceipt("pay-x", "u1", Buffer.from("pdf"), "r.pdf")).rejects.toThrow(NotFoundException); await expect(
service.uploadEftReceipt("pay-x", "u1", Buffer.from("pdf"), "r.pdf"),
).rejects.toThrow(NotFoundException);
}); });
it("should throw BadRequestException when not EFT method", async () => { it("should throw BadRequestException when not EFT method", async () => {
@@ -206,7 +261,9 @@ describe("PaymentsService", () => {
}; };
const { service } = createService(db); const { service } = createService(db);
await expect(service.uploadEftReceipt("pay-1", "u1", Buffer.from("pdf"), "r.pdf")).rejects.toThrow(BadRequestException); await expect(
service.uploadEftReceipt("pay-1", "u1", Buffer.from("pdf"), "r.pdf"),
).rejects.toThrow(BadRequestException);
}); });
}); });

View File

@@ -5,12 +5,12 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import type { ConfigService } from "@nestjs/config";
import { eq, and, desc } from "drizzle-orm"; import { and, desc, eq } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider"; import { DATABASE, type Database } from "../database/database.provider";
import { payments, userSubscriptions, plans } from "../database/schema/core"; import { payments, plans, userSubscriptions } from "../database/schema/core";
import { SubscriptionsService } from "../subscriptions/subscriptions.service"; import type { StorageService } from "../storage/storage.service";
import { StorageService } from "../storage/storage.service"; import type { SubscriptionsService } from "../subscriptions/subscriptions.service";
const PLAN_KEY_TO_BRAND_COUNT: Record<string, number> = { const PLAN_KEY_TO_BRAND_COUNT: Record<string, number> = {
brand1: 1, brand1: 1,

View File

@@ -1,8 +1,8 @@
import { Controller, Get, Post, Patch, Param, Body, UseGuards } from "@nestjs/common"; import { Body, Controller, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
import { PlansService } from "./plans.service";
import { Public } from "../common/decorators/public.decorator"; import { Public } from "../common/decorators/public.decorator";
import { Roles } from "../common/decorators/roles.decorator"; import { Roles } from "../common/decorators/roles.decorator";
import { RolesGuard } from "../common/guards/roles.guard"; import { RolesGuard } from "../common/guards/roles.guard";
import type { PlansService } from "./plans.service";
@Controller("plans") @Controller("plans")
export class PlansController { export class PlansController {

View File

@@ -1,13 +1,22 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NotFoundException } from "@nestjs/common"; import { NotFoundException } from "@nestjs/common";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PlansService } from "./plans.service"; import { PlansService } from "./plans.service";
function createMockDb(overrides: Record<string, unknown> = {}) { function createMockDb(overrides: Record<string, unknown> = {}) {
function chainable(terminalValue: unknown) { function chainable(terminalValue: unknown) {
const chain: Record<string, unknown> = {}; const chain: Record<string, unknown> = {};
const methods = [ const methods = [
"select", "from", "where", "orderBy", "limit", "offset", "select",
"insert", "values", "update", "set", "returning", "from",
"where",
"orderBy",
"limit",
"offset",
"insert",
"values",
"update",
"set",
"returning",
]; ];
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain); for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
chain.limit = vi.fn().mockReturnValue(terminalValue); chain.limit = vi.fn().mockReturnValue(terminalValue);
@@ -73,11 +82,22 @@ describe("PlansService", () => {
describe("create", () => { describe("create", () => {
it("should create and return plan", async () => { it("should create and return plan", async () => {
const newPlan = { id: "p1", name: "1 Marka", brandCount: 1, priceMonthly: 20000, priceYearly: 200000 }; const newPlan = {
id: "p1",
name: "1 Marka",
brandCount: 1,
priceMonthly: 20000,
priceYearly: 200000,
};
const db = createMockDb({ _insertRows: [newPlan] }); const db = createMockDb({ _insertRows: [newPlan] });
const service = new PlansService(db as any); const service = new PlansService(db as any);
const result = await service.create({ name: "1 Marka", brandCount: 1, priceMonthly: 20000, priceYearly: 200000 }); const result = await service.create({
name: "1 Marka",
brandCount: 1,
priceMonthly: 20000,
priceYearly: 200000,
});
expect(result).toEqual(newPlan); expect(result).toEqual(newPlan);
}); });
}); });

View File

@@ -1,6 +1,6 @@
import { Inject, Injectable, NotFoundException } from "@nestjs/common"; import { Inject, Injectable, NotFoundException } from "@nestjs/common";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider"; import { DATABASE, type Database } from "../database/database.provider";
import { plans } from "../database/schema/core"; import { plans } from "../database/schema/core";
@Injectable() @Injectable()

View File

@@ -1,4 +1,4 @@
import { Provider } from "@nestjs/common"; import type { Provider } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import Redis from "ioredis"; import Redis from "ioredis";

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable, OnModuleDestroy } from "@nestjs/common"; import { Inject, Injectable, type OnModuleDestroy } from "@nestjs/common";
import Redis from "ioredis"; import type Redis from "ioredis";
import { REDIS_CLIENT } from "./redis.provider"; import { REDIS_CLIENT } from "./redis.provider";
@Injectable() @Injectable()

View File

@@ -1,6 +1,6 @@
import { Controller, Get, Post, Body } from "@nestjs/common"; import { Body, Controller, Get, Post } from "@nestjs/common";
import { ReferralsService } from "./referrals.service";
import { CurrentUser } from "../common/decorators/current-user.decorator"; import { CurrentUser } from "../common/decorators/current-user.decorator";
import type { ReferralsService } from "./referrals.service";
@Controller("referrals") @Controller("referrals")
export class ReferralsController { export class ReferralsController {

View File

@@ -1,7 +1,7 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
import { ReferralsController } from "./referrals.controller"; import { ReferralsController } from "./referrals.controller";
import { ReferralsService } from "./referrals.service"; import { ReferralsService } from "./referrals.service";
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
@Module({ @Module({
imports: [SubscriptionsModule], imports: [SubscriptionsModule],

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { BadRequestException, NotFoundException } from "@nestjs/common"; import { BadRequestException, NotFoundException } from "@nestjs/common";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ReferralsService } from "./referrals.service"; import { ReferralsService } from "./referrals.service";
vi.mock("@sase/shared", () => ({ vi.mock("@sase/shared", () => ({
@@ -166,7 +166,9 @@ describe("ReferralsService", () => {
}), }),
}; };
const { service } = createService(db); const { service } = createService(db);
await expect(service.applyReferralCode("u1", "SELF-CODE")).rejects.toThrow(BadRequestException); await expect(service.applyReferralCode("u1", "SELF-CODE")).rejects.toThrow(
BadRequestException,
);
}); });
it("should throw BadRequestException when already referred", async () => { it("should throw BadRequestException when already referred", async () => {

View File

@@ -1,10 +1,10 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from "@nestjs/common"; import { BadRequestException, Inject, Injectable, NotFoundException } from "@nestjs/common";
import { eq, and, sql } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider";
import { users, referrals } from "../database/schema/core";
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
import { REFERRAL_REWARDS } from "@sase/shared"; import { REFERRAL_REWARDS } from "@sase/shared";
import { generateReferralCode } from "@sase/shared"; import { generateReferralCode } from "@sase/shared";
import { and, eq, sql } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { referrals, users } from "../database/schema/core";
import type { SubscriptionsService } from "../subscriptions/subscriptions.service";
@Injectable() @Injectable()
export class ReferralsService { export class ReferralsService {

View File

@@ -1,11 +1,11 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand, DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3"; } from "@aws-sdk/client-s3";
import { Injectable, Logger } from "@nestjs/common";
import type { ConfigService } from "@nestjs/config";
@Injectable() @Injectable()
export class StorageService { export class StorageService {

View File

@@ -1,8 +1,8 @@
import { Controller, Get, Post, Patch, Body, Query, UseGuards } from "@nestjs/common"; import { Body, Controller, Get, Patch, Post, Query, UseGuards } from "@nestjs/common";
import { SubscriptionsService } from "./subscriptions.service";
import { CurrentUser } from "../common/decorators/current-user.decorator"; import { CurrentUser } from "../common/decorators/current-user.decorator";
import { Roles } from "../common/decorators/roles.decorator"; import { Roles } from "../common/decorators/roles.decorator";
import { RolesGuard } from "../common/guards/roles.guard"; import { RolesGuard } from "../common/guards/roles.guard";
import type { SubscriptionsService } from "./subscriptions.service";
@Controller("subscriptions") @Controller("subscriptions")
export class SubscriptionsController { export class SubscriptionsController {
@@ -54,8 +54,8 @@ export class SubscriptionsController {
@Roles("admin") @Roles("admin")
async findAll(@Query("page") page?: string, @Query("limit") limit?: string) { async findAll(@Query("page") page?: string, @Query("limit") limit?: string) {
return this.subscriptionsService.findAll( return this.subscriptionsService.findAll(
page ? parseInt(page, 10) : 1, page ? Number.parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20, limit ? Number.parseInt(limit, 10) : 20,
); );
} }
} }

View File

@@ -1,8 +1,8 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { SubscriptionsController } from "./subscriptions.controller";
import { SubscriptionsService } from "./subscriptions.service";
import { BrandsModule } from "../brands/brands.module"; import { BrandsModule } from "../brands/brands.module";
import { PlansModule } from "../plans/plans.module"; import { PlansModule } from "../plans/plans.module";
import { SubscriptionsController } from "./subscriptions.controller";
import { SubscriptionsService } from "./subscriptions.service";
@Module({ @Module({
imports: [BrandsModule, PlansModule], imports: [BrandsModule, PlansModule],

View File

@@ -1,9 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
import { import { beforeEach, describe, expect, it, vi } from "vitest";
BadRequestException,
ConflictException,
NotFoundException,
} from "@nestjs/common";
import { SubscriptionsService } from "./subscriptions.service"; import { SubscriptionsService } from "./subscriptions.service";
/** /**
@@ -141,9 +137,7 @@ describe("SubscriptionsService", () => {
let callCount = 0; let callCount = 0;
const insertChain = { const insertChain = {
values: vi.fn().mockReturnThis(), values: vi.fn().mockReturnThis(),
returning: vi.fn().mockReturnValue([ returning: vi.fn().mockReturnValue([{ id: "sub-1", userId: "user-1", status: "pending" }]),
{ id: "sub-1", userId: "user-1", status: "pending" },
]),
}; };
const db = { const db = {
select: vi.fn().mockImplementation(() => { select: vi.fn().mockImplementation(() => {
@@ -188,9 +182,7 @@ describe("SubscriptionsService", () => {
}; };
const service = createService(db); const service = createService(db);
await expect(service.cancel("user-1")).rejects.toThrow( await expect(service.cancel("user-1")).rejects.toThrow(NotFoundException);
NotFoundException,
);
}); });
it("should set status to cancelled and set cancelledAt", async () => { it("should set status to cancelled and set cancelledAt", async () => {
@@ -240,9 +232,7 @@ describe("SubscriptionsService", () => {
}; };
const service = createService(db); const service = createService(db);
await expect(service.resume("user-1")).rejects.toThrow( await expect(service.resume("user-1")).rejects.toThrow(NotFoundException);
NotFoundException,
);
}); });
it("should set status to active and clear cancelledAt", async () => { it("should set status to active and clear cancelledAt", async () => {
@@ -262,9 +252,7 @@ describe("SubscriptionsService", () => {
select: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnThis(), from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([ limit: vi.fn().mockReturnValue([{ id: "sub-1", userId: "user-1", status: "cancelled" }]),
{ id: "sub-1", userId: "user-1", status: "cancelled" },
]),
}), }),
update: vi.fn().mockReturnValue(updateChain), update: vi.fn().mockReturnValue(updateChain),
}; };

View File

@@ -5,9 +5,9 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { eq, and, desc, or, inArray } from "drizzle-orm"; import { and, desc, eq, inArray, or } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider"; import { DATABASE, type Database } from "../database/database.provider";
import { userSubscriptions, userBrands, plans, brands } from "../database/schema/core"; import { brands, plans, userBrands, userSubscriptions } from "../database/schema/core";
@Injectable() @Injectable()
export class SubscriptionsService { export class SubscriptionsService {

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("Telemetry module", () => { describe("Telemetry module", () => {
const originalEnv = process.env; const originalEnv = process.env;

View File

@@ -1,4 +1,4 @@
import { trace, metrics, SpanStatusCode, type Span } from "@opentelemetry/api"; import { type Span, SpanStatusCode, metrics, trace } from "@opentelemetry/api";
export const isOtelEnabled = process.env.OTEL_ENABLED === "true"; export const isOtelEnabled = process.env.OTEL_ENABLED === "true";

View File

@@ -1,16 +1,16 @@
import { NodeSDK } from "@opentelemetry/sdk-node"; import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import type { Instrumentation } from "@opentelemetry/instrumentation";
import { resourceFromAttributes } from "@opentelemetry/resources"; import { resourceFromAttributes } from "@opentelemetry/resources";
import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { BatchSpanProcessor, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
import { import {
ATTR_SERVICE_NAME, ATTR_SERVICE_NAME,
SEMRESATTRS_DEPLOYMENT_ENVIRONMENT, SEMRESATTRS_DEPLOYMENT_ENVIRONMENT,
} from "@opentelemetry/semantic-conventions"; } from "@opentelemetry/semantic-conventions";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { BatchSpanProcessor, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
import type { Instrumentation } from "@opentelemetry/instrumentation";
export interface SDKConfig { export interface SDKConfig {
serviceName: string; serviceName: string;
@@ -37,7 +37,7 @@ export function createNodeSDK(config: SDKConfig): NodeSDK | null {
} }
const headers = parseHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS || ""); const headers = parseHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS || "");
const sampleRate = parseFloat(process.env.OTEL_TRACE_SAMPLE_RATE || "1.0"); const sampleRate = Number.parseFloat(process.env.OTEL_TRACE_SAMPLE_RATE || "1.0");
const resource = resourceFromAttributes({ const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: config.serviceName, [ATTR_SERVICE_NAME]: config.serviceName,

View File

@@ -1,6 +1,6 @@
import "dotenv/config"; // Load env vars before checking OTEL_ENABLED import "dotenv/config"; // Load env vars before checking OTEL_ENABLED
import { isOtelEnabled } from "./index";
import type { NodeSDK } from "@opentelemetry/sdk-node"; import type { NodeSDK } from "@opentelemetry/sdk-node";
import { isOtelEnabled } from "./index";
let sdk: NodeSDK | null = null; let sdk: NodeSDK | null = null;

Some files were not shown because too many files have changed in this diff Show More