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:
@@ -1,6 +1,6 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||
import { AdminService } from "./admin.service";
|
||||
import { Roles } from "../common/decorators/roles.decorator";
|
||||
import type { AdminService } from "./admin.service";
|
||||
|
||||
@Controller("admin")
|
||||
@Roles("admin")
|
||||
@@ -27,8 +27,8 @@ export class AdminController {
|
||||
) {
|
||||
return this.adminService.getUsers(
|
||||
search,
|
||||
page ? parseInt(page, 10) : 1,
|
||||
limit ? parseInt(limit, 10) : 20,
|
||||
page ? Number.parseInt(page, 10) : 1,
|
||||
limit ? Number.parseInt(limit, 10) : 20,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ export class AdminController {
|
||||
@Query("userId") userId?: string,
|
||||
) {
|
||||
return this.adminService.getQueryLogs(
|
||||
page ? parseInt(page, 10) : 1,
|
||||
limit ? parseInt(limit, 10) : 50,
|
||||
page ? Number.parseInt(page, 10) : 1,
|
||||
limit ? Number.parseInt(limit, 10) : 50,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
@@ -63,8 +63,8 @@ export class AdminController {
|
||||
) {
|
||||
return this.adminService.getReferrals(
|
||||
search,
|
||||
page ? parseInt(page, 10) : 1,
|
||||
limit ? parseInt(limit, 10) : 20,
|
||||
page ? Number.parseInt(page, 10) : 1,
|
||||
limit ? Number.parseInt(limit, 10) : 20,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,8 +80,8 @@ export class AdminController {
|
||||
@Query("userId") userId?: string,
|
||||
) {
|
||||
return this.adminService.getCopyLogs(
|
||||
page ? parseInt(page, 10) : 1,
|
||||
limit ? parseInt(limit, 10) : 50,
|
||||
page ? Number.parseInt(page, 10) : 1,
|
||||
limit ? Number.parseInt(limit, 10) : 50,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
@@ -92,8 +92,8 @@ export class AdminController {
|
||||
@Query("limit") limit?: string,
|
||||
) {
|
||||
return this.adminService.getTopCopiedCodes(
|
||||
days ? parseInt(days, 10) : 30,
|
||||
limit ? parseInt(limit, 10) : 20,
|
||||
days ? Number.parseInt(days, 10) : 30,
|
||||
limit ? Number.parseInt(limit, 10) : 20,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AnalyticsModule } from "../analytics/analytics.module";
|
||||
import { AdminController } from "./admin.controller";
|
||||
import { AdminService } from "./admin.service";
|
||||
import { AnalyticsModule } from "../analytics/analytics.module";
|
||||
|
||||
@Module({
|
||||
imports: [AnalyticsModule],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AdminService } from "./admin.service";
|
||||
|
||||
describe("AdminService", () => {
|
||||
@@ -174,7 +174,9 @@ describe("AdminService", () => {
|
||||
|
||||
describe("getPendingPayments", () => {
|
||||
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
|
||||
const c: Record<string, any> = {};
|
||||
c.from = vi.fn().mockReturnValue(c);
|
||||
|
||||
@@ -5,20 +5,20 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} 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 { DATABASE, Database } from "../database/database.provider";
|
||||
import type { AnalyticsService } from "../analytics/analytics.service";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import {
|
||||
users,
|
||||
accounts,
|
||||
userSubscriptions,
|
||||
brands,
|
||||
payments,
|
||||
queryLogs,
|
||||
brands,
|
||||
referrals,
|
||||
userSubscriptions,
|
||||
users,
|
||||
} from "../database/schema/core";
|
||||
import { hashPassword } from "better-auth/crypto";
|
||||
import { generateReferralCode } from "@sase/shared";
|
||||
import { AnalyticsService } from "../analytics/analytics.service";
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Body, Controller, Post } from "@nestjs/common";
|
||||
import { AnalyticsService } from "./analytics.service";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import type { AnalyticsService } from "./analytics.service";
|
||||
|
||||
@Controller("analytics")
|
||||
export class AnalyticsController {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
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";
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
import { resolve } from "path";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { resolve } from "path";
|
||||
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 { validate } from "./config/env.validation";
|
||||
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 { 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 { AuthGuard } from "./common/guards/auth.guard";
|
||||
import { RolesGuard } from "./common/guards/roles.guard";
|
||||
import { TransformInterceptor } from "./common/interceptors/transform.interceptor";
|
||||
import { LoggingInterceptor } from "./common/interceptors/logging.interceptor";
|
||||
import { TimeoutInterceptor } from "./common/interceptors/timeout.interceptor";
|
||||
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
|
||||
import { EmexModule } from "./integrations/emex/emex.module";
|
||||
import { JobsModule } from "./jobs/jobs.module";
|
||||
import { PartsModule } from "./parts/parts.module";
|
||||
import { PaymentsModule } from "./payments/payments.module";
|
||||
import { PlansModule } from "./plans/plans.module";
|
||||
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({
|
||||
imports: [
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { All, Controller, Req, Res } from "@nestjs/common";
|
||||
import { Request, Response } from "express";
|
||||
import { getAuth } from "./auth";
|
||||
import { toNodeHandler } from "better-auth/node";
|
||||
import type { Request, Response } from "express";
|
||||
import { Public } from "../common/decorators/public.decorator";
|
||||
import { getAuth } from "./auth";
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Module, OnModuleInit } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Module, type OnModuleInit } from "@nestjs/common";
|
||||
import type { ConfigService } from "@nestjs/config";
|
||||
import type { EmailService } from "../email/email.service";
|
||||
import { createAuth } from "./auth";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { EmailService } from "../email/email.service";
|
||||
import { createAuth } from "./auth";
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { generateReferralCode } from "@sase/shared";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import { randomUUID } from "crypto";
|
||||
import * as schema from "../database/schema/core";
|
||||
import type { EmailService } from "../email/email.service";
|
||||
import { generateReferralCode } from "@sase/shared";
|
||||
|
||||
let authInstance: ReturnType<typeof betterAuth> | null = null;
|
||||
|
||||
@@ -19,7 +19,12 @@ interface AuthOptions {
|
||||
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;
|
||||
|
||||
const client = postgres(databaseUrl, { max: 5 });
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Controller, Get, Post, Patch, Param, Body, UseGuards } from "@nestjs/common";
|
||||
import { BrandsService } from "./brands.service";
|
||||
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
import { Public } from "../common/decorators/public.decorator";
|
||||
import { Roles } from "../common/decorators/roles.decorator";
|
||||
import { RolesGuard } from "../common/guards/roles.guard";
|
||||
import type { BrandsService } from "./brands.service";
|
||||
|
||||
@Controller("brands")
|
||||
export class BrandsController {
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BrandsService } from "./brands.service";
|
||||
|
||||
function createMockDb(overrides: Record<string, unknown> = {}) {
|
||||
function chainable(terminalValue: unknown) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
const methods = [
|
||||
"select", "from", "where", "orderBy", "limit", "offset",
|
||||
"insert", "values", "update", "set", "returning",
|
||||
"select",
|
||||
"from",
|
||||
"where",
|
||||
"orderBy",
|
||||
"limit",
|
||||
"offset",
|
||||
"insert",
|
||||
"values",
|
||||
"update",
|
||||
"set",
|
||||
"returning",
|
||||
];
|
||||
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
|
||||
chain.limit = vi.fn().mockReturnValue(terminalValue);
|
||||
@@ -41,7 +50,10 @@ describe("BrandsService", () => {
|
||||
});
|
||||
|
||||
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
|
||||
const chain: Record<string, any> = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
|
||||
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";
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { Roles } from "../common/decorators/roles.decorator";
|
||||
import { CatalogService } from "./catalog.service";
|
||||
import type { CatalogService } from "./catalog.service";
|
||||
|
||||
@Controller("catalog")
|
||||
export class CatalogController {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { CatalogService } from "./catalog.service";
|
||||
import { PL24Module } from "../integrations/pl24/pl24.module";
|
||||
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
||||
import { StorageModule } from "../storage/storage.module";
|
||||
|
||||
@Module({
|
||||
imports: [PL24Module, SubscriptionsModule, StorageModule],
|
||||
|
||||
@@ -5,27 +5,27 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { eq, and, or, inArray, sql } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../database/database.provider";
|
||||
import { and, eq, inArray, or, sql } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import {
|
||||
brands,
|
||||
catalogVehicles,
|
||||
categories,
|
||||
parts,
|
||||
schemaPics,
|
||||
brands,
|
||||
userSubscriptions,
|
||||
userBrands,
|
||||
plans,
|
||||
schemaPics,
|
||||
userBrands,
|
||||
userSubscriptions,
|
||||
} from "../database/schema/core";
|
||||
import { RedisService } from "../redis/redis.service";
|
||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
import type { PL24Service } from "../integrations/pl24/pl24.service";
|
||||
import {
|
||||
PL24_SERVICE_CATALOGS,
|
||||
SERVICE_TO_BRAND,
|
||||
SERVICE_DISPLAY_NAMES,
|
||||
SERVICE_TO_BRAND,
|
||||
isP5Modern,
|
||||
} from "../integrations/pl24/pl24.types";
|
||||
import type { RedisService } from "../redis/redis.service";
|
||||
import type { StorageService } from "../storage/storage.service";
|
||||
|
||||
@Injectable()
|
||||
export class CatalogService {
|
||||
@@ -909,11 +909,11 @@ export class CatalogService {
|
||||
name: p.name,
|
||||
nameOriginal: p.name,
|
||||
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,
|
||||
hotspotIndex: p.hotspotId
|
||||
? (() => {
|
||||
const val = parseInt(p.hotspotId!, 10);
|
||||
const val = Number.parseInt(p.hotspotId!, 10);
|
||||
return val > 0 && val <= 2147483647 ? val : null;
|
||||
})()
|
||||
: null,
|
||||
@@ -1027,7 +1027,7 @@ export class CatalogService {
|
||||
(hs.areas || []).map((area, areaIdx) => ({
|
||||
id: `hs-${hs.key}-${areaIdx}`,
|
||||
key: hs.key,
|
||||
group: parseInt(hs.key, 10) || 0,
|
||||
group: Number.parseInt(hs.key, 10) || 0,
|
||||
shape: "rect" as const,
|
||||
coordinates: [area.left, area.top, area.width, area.height],
|
||||
label: hs.label || hs.key,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Controller, Get, Param } from "@nestjs/common";
|
||||
import { CategoriesService } from "./categories.service";
|
||||
import type { CategoriesService } from "./categories.service";
|
||||
|
||||
@Controller("categories")
|
||||
export class CategoriesController {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module";
|
||||
import { PL24Module } from "../integrations/pl24/pl24.module";
|
||||
import { TranslationsModule } from "../translations/translations.module";
|
||||
import { CategoriesController } from "./categories.controller";
|
||||
import { CategoriesService } from "./categories.service";
|
||||
|
||||
@Module({
|
||||
imports: [PL24Module, EmexModule, PartsCatalogsModule, TranslationsModule],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CategoriesService } from "./categories.service";
|
||||
|
||||
function createService(db: any) {
|
||||
@@ -26,12 +26,16 @@ function createService(db: any) {
|
||||
fetchCategoriesForPsaVin: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
const translationsService = {
|
||||
translate: vi.fn().mockImplementation((_key: string, sourceText: string) =>
|
||||
Promise.resolve({ translatedText: sourceText, source: "none", isAutoTranslated: false }),
|
||||
),
|
||||
translateMany: vi.fn().mockImplementation((texts: string[]) =>
|
||||
Promise.resolve(new Map<string, string>(texts.map((t) => [t, t]))),
|
||||
),
|
||||
translate: vi
|
||||
.fn()
|
||||
.mockImplementation((_key: string, sourceText: string) =>
|
||||
Promise.resolve({ translatedText: sourceText, source: "none", isAutoTranslated: false }),
|
||||
),
|
||||
translateMany: vi
|
||||
.fn()
|
||||
.mockImplementation((texts: string[]) =>
|
||||
Promise.resolve(new Map<string, string>(texts.map((t) => [t, t]))),
|
||||
),
|
||||
};
|
||||
const service = new CategoriesService(
|
||||
db as any,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../database/database.provider";
|
||||
import { categories, vehicles, schemaPics, parts } from "../database/schema/core";
|
||||
import { RedisService } from "../redis/redis.service";
|
||||
import { PL24Service } from "../integrations/pl24/pl24.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 { DATABASE, type Database } from "../database/database.provider";
|
||||
import { categories, parts, schemaPics, vehicles } from "../database/schema/core";
|
||||
import type { EmexService } from "../integrations/emex/emex.service";
|
||||
import type { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
|
||||
import type { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
import { TranslationsService } from "../translations/translations.service";
|
||||
import type { PL24FordLegacyService } from "../integrations/pl24/pl24-ford-legacy.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()
|
||||
export class CategoriesService {
|
||||
@@ -642,7 +642,7 @@ export class CategoriesService {
|
||||
description: p.notice,
|
||||
quantity: null,
|
||||
position: p.positionNumber,
|
||||
hotspotIndex: p.positionNumber ? parseInt(p.positionNumber, 10) || null : null,
|
||||
hotspotIndex: p.positionNumber ? Number.parseInt(p.positionNumber, 10) || null : null,
|
||||
unavailable: false,
|
||||
remark: null as string | null,
|
||||
modelCodes: null as string | null,
|
||||
@@ -830,10 +830,10 @@ export class CategoriesService {
|
||||
name: p.name,
|
||||
nameOriginal: p.name,
|
||||
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,
|
||||
hotspotIndex: p.hotspotId ? (() => {
|
||||
const val = parseInt(p.hotspotId!, 10);
|
||||
const val = Number.parseInt(p.hotspotId!, 10);
|
||||
return (val > 0 && val <= 2147483647) ? val : null;
|
||||
})() : null,
|
||||
unavailable: p.unavailable || false,
|
||||
@@ -977,7 +977,7 @@ export class CategoriesService {
|
||||
(hs.areas || []).map((area, areaIdx) => ({
|
||||
id: `hs-${hs.key}-${areaIdx}`,
|
||||
key: hs.key,
|
||||
group: parseInt(hs.key, 10) || 0,
|
||||
group: Number.parseInt(hs.key, 10) || 0,
|
||||
shape: "rect" as const,
|
||||
coordinates: [area.left, area.top, area.width, area.height],
|
||||
label: hs.label || hs.key,
|
||||
|
||||
@@ -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) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
|
||||
@@ -5,14 +5,14 @@ export const paginationSchema = z.object({
|
||||
.string()
|
||||
.optional()
|
||||
.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;
|
||||
}),
|
||||
limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.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;
|
||||
return Math.min(parsed, 100);
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus, Logger } from "@nestjs/common";
|
||||
import { Response } from "express";
|
||||
import {
|
||||
type ArgumentsHost,
|
||||
Catch,
|
||||
type ExceptionFilter,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import type { Response } from "express";
|
||||
|
||||
// Drizzle/postgres unique violation error
|
||||
@Catch()
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from "@nestjs/common";
|
||||
import { Response } from "express";
|
||||
import { trace, SpanStatusCode } from "@opentelemetry/api";
|
||||
import {
|
||||
type ArgumentsHost,
|
||||
Catch,
|
||||
type ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { SpanStatusCode, trace } from "@opentelemetry/api";
|
||||
import type { Response } from "express";
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
@@ -54,13 +61,20 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
|
||||
private getCodeFromStatus(status: number): string {
|
||||
switch (status) {
|
||||
case 400: return "GEN_002";
|
||||
case 401: return "AUTH_004";
|
||||
case 403: return "AUTH_005";
|
||||
case 404: return "GEN_001";
|
||||
case 409: return "GEN_005";
|
||||
case 429: return "GEN_004";
|
||||
default: return "GEN_003";
|
||||
case 400:
|
||||
return "GEN_002";
|
||||
case 401:
|
||||
return "AUTH_004";
|
||||
case 403:
|
||||
return "AUTH_005";
|
||||
case 404:
|
||||
return "GEN_001";
|
||||
case 409:
|
||||
return "GEN_005";
|
||||
case 429:
|
||||
return "GEN_004";
|
||||
default:
|
||||
return "GEN_003";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
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";
|
||||
|
||||
vi.mock("../../auth/auth", () => ({
|
||||
@@ -67,9 +67,7 @@ describe("AuthGuard", () => {
|
||||
headers: { authorization: "Bearer token123" },
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context as any)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
await expect(guard.canActivate(context as any)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException when session has no user", async () => {
|
||||
@@ -85,9 +83,7 @@ describe("AuthGuard", () => {
|
||||
headers: { authorization: "Bearer token123" },
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context as any)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
await expect(guard.canActivate(context as any)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it("should set user and session on request for valid session", async () => {
|
||||
@@ -127,9 +123,7 @@ describe("AuthGuard", () => {
|
||||
headers: {},
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context as any)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
await expect(guard.canActivate(context as any)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it("should re-throw UnauthorizedException from inner code", async () => {
|
||||
@@ -137,9 +131,7 @@ describe("AuthGuard", () => {
|
||||
|
||||
mockedGetAuth.mockReturnValue({
|
||||
api: {
|
||||
getSession: vi.fn().mockRejectedValue(
|
||||
new UnauthorizedException("Custom auth error"),
|
||||
),
|
||||
getSession: vi.fn().mockRejectedValue(new UnauthorizedException("Custom auth error")),
|
||||
},
|
||||
} as any);
|
||||
|
||||
@@ -147,8 +139,6 @@ describe("AuthGuard", () => {
|
||||
headers: { authorization: "Bearer token" },
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context as any)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
await expect(guard.canActivate(context as any)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { IS_PUBLIC_KEY } from "../decorators/public.decorator";
|
||||
import {
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import type { Reflector } from "@nestjs/core";
|
||||
import { getAuth } from "../../auth/auth";
|
||||
import { IS_PUBLIC_KEY } from "../decorators/public.decorator";
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { ForbiddenException } from "@nestjs/common";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BrandAccessGuard } from "./brand-access.guard";
|
||||
|
||||
function createMockDb(overrides: Record<string, unknown> = {}) {
|
||||
function chainable(terminalValue: 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);
|
||||
chain.limit = vi.fn().mockReturnValue(terminalValue);
|
||||
return chain;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable } from "@nestjs/common";
|
||||
import { eq, and, or } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../../database/database.provider";
|
||||
import { userSubscriptions, userBrands, plans } from "../../database/schema/core";
|
||||
import { type CanActivate, type ExecutionContext, ForbiddenException, Inject, Injectable } from "@nestjs/common";
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../../database/database.provider";
|
||||
import { plans, userBrands, userSubscriptions } from "../../database/schema/core";
|
||||
|
||||
@Injectable()
|
||||
export class BrandAccessGuard implements CanActivate {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
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";
|
||||
|
||||
function createMockExecutionContext(user?: { role: string }) {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import {
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from "@nestjs/common";
|
||||
import type { Reflector } from "@nestjs/core";
|
||||
import { ROLES_KEY } from "../decorators/roles.decorator";
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from "@nestjs/common";
|
||||
import { Observable, tap } from "rxjs";
|
||||
import {
|
||||
type CallHandler,
|
||||
type ExecutionContext,
|
||||
Injectable,
|
||||
Logger,
|
||||
type NestInterceptor,
|
||||
} from "@nestjs/common";
|
||||
import { trace } from "@opentelemetry/api";
|
||||
import { type Observable, tap } from "rxjs";
|
||||
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {
|
||||
type CallHandler,
|
||||
type ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
type NestInterceptor,
|
||||
RequestTimeoutException,
|
||||
} from "@nestjs/common";
|
||||
import { Observable, throwError, timeout, catchError, TimeoutError } from "rxjs";
|
||||
import { type Observable, TimeoutError, catchError, throwError, timeout } from "rxjs";
|
||||
|
||||
@Injectable()
|
||||
export class TimeoutInterceptor implements NestInterceptor {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from "@nestjs/common";
|
||||
import { Observable, map } from "rxjs";
|
||||
import {
|
||||
type CallHandler,
|
||||
type ExecutionContext,
|
||||
Injectable,
|
||||
type NestInterceptor,
|
||||
} from "@nestjs/common";
|
||||
import { type Observable, map } from "rxjs";
|
||||
|
||||
export interface TransformedResponse<T> {
|
||||
success: boolean;
|
||||
@@ -9,10 +14,7 @@ export interface TransformedResponse<T> {
|
||||
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T> implements NestInterceptor<T, TransformedResponse<T>> {
|
||||
intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<TransformedResponse<T>> {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<TransformedResponse<T>> {
|
||||
return next.handle().pipe(
|
||||
map((data) => {
|
||||
// If already wrapped, pass through
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
const ALLOWED_MIME_TYPES = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"application/pdf",
|
||||
];
|
||||
const ALLOWED_MIME_TYPES = ["image/png", "image/jpeg", "image/jpg", "application/pdf"];
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { VinValidationPipe } from "./vin-validation.pipe";
|
||||
|
||||
describe("VinValidationPipe", () => {
|
||||
@@ -32,27 +32,19 @@ describe("VinValidationPipe", () => {
|
||||
});
|
||||
|
||||
it("should throw BadRequestException for VIN longer than 17 characters", () => {
|
||||
expect(() => pipe.transform("WBAPH5C55BA12345678")).toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(() => pipe.transform("WBAPH5C55BA12345678")).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException for VIN containing letter I", () => {
|
||||
expect(() => pipe.transform("WBAPH5C55IA123456")).toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(() => pipe.transform("WBAPH5C55IA123456")).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException for VIN containing letter O", () => {
|
||||
expect(() => pipe.transform("WBAPH5C55OA123456")).toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(() => pipe.transform("WBAPH5C55OA123456")).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException for VIN containing letter Q", () => {
|
||||
expect(() => pipe.transform("WBAPH5C55QA123456")).toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(() => pipe.transform("WBAPH5C55QA123456")).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException for empty string", () => {
|
||||
@@ -61,17 +53,11 @@ describe("VinValidationPipe", () => {
|
||||
});
|
||||
|
||||
it("should throw BadRequestException for null/undefined value", () => {
|
||||
expect(() => pipe.transform(null as unknown as string)).toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(() => pipe.transform(undefined as unknown as string)).toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(() => pipe.transform(null as unknown as string)).toThrow(BadRequestException);
|
||||
expect(() => pipe.transform(undefined as unknown as string)).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException for non-string value", () => {
|
||||
expect(() => pipe.transform(12345 as unknown as string)).toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(() => pipe.transform(12345 as unknown as string)).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PipeTransform, Injectable, BadRequestException } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable, type PipeTransform } from "@nestjs/common";
|
||||
import { isValidVin } from "@sase/shared";
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export default () => ({
|
||||
port: parseInt(process.env.PORT || "4000", 10),
|
||||
port: Number.parseInt(process.env.PORT || "4000", 10),
|
||||
database: {
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
redis: {
|
||||
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,
|
||||
},
|
||||
auth: {
|
||||
@@ -54,6 +54,6 @@ export default () => ({
|
||||
enabled: process.env.OTEL_ENABLED === "true",
|
||||
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
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"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Provider } from "@nestjs/common";
|
||||
import type { Provider } from "@nestjs/common";
|
||||
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 * 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 * 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";
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
numeric,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
text,
|
||||
boolean,
|
||||
integer,
|
||||
numeric,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
// ─── Users ───────────────────────────────────────────
|
||||
@@ -256,7 +256,11 @@ export const catalogVehicles = pgTable(
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(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_service_name_idx").on(table.serviceName),
|
||||
],
|
||||
@@ -281,9 +285,7 @@ export const vehicles = pgTable(
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("vehicles_vin_unique_idx").on(table.vin),
|
||||
],
|
||||
(table) => [uniqueIndex("vehicles_vin_unique_idx").on(table.vin)],
|
||||
);
|
||||
|
||||
// ─── User Vehicles (junction — user ↔ shared vehicle) ─
|
||||
@@ -312,7 +314,9 @@ export const categories = pgTable(
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
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(),
|
||||
nameOriginal: varchar("name_original", { length: 500 }),
|
||||
parentId: uuid("parent_id"),
|
||||
@@ -327,7 +331,12 @@ export const categories = pgTable(
|
||||
index("categories_vehicle_id_idx").on(table.vehicleId),
|
||||
index("categories_catalog_vehicle_id_idx").on(table.catalogVehicleId),
|
||||
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(),
|
||||
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")
|
||||
.notNull()
|
||||
.references(() => categories.id, { onDelete: "cascade" }),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
text,
|
||||
boolean,
|
||||
integer,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
// ─── EMEX Catalog ───────────────────────────────────
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
jsonb,
|
||||
pgTable,
|
||||
serial,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
text,
|
||||
boolean,
|
||||
serial,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
// ─── PCAT Vehicles — VIN decode results ────────────
|
||||
@@ -59,9 +59,7 @@ export const pcatParts = pgTable(
|
||||
description: text("description"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("pcat_parts_name_idx").on(table.name),
|
||||
],
|
||||
(table) => [index("pcat_parts_name_idx").on(table.name)],
|
||||
);
|
||||
|
||||
// ─── PCAT Schema Pics — Schema images + hotspots ───
|
||||
@@ -76,7 +74,5 @@ export const pcatSchemaPics = pgTable(
|
||||
hotspots: jsonb("hotspots"), // positions array from API
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("pcat_schema_pics_group_car_idx").on(table.groupId, table.carId),
|
||||
],
|
||||
(table) => [index("pcat_schema_pics_group_car_idx").on(table.groupId, table.carId)],
|
||||
);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
text,
|
||||
boolean,
|
||||
integer,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
// ─── PL24 Catalog ───────────────────────────────────
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
users,
|
||||
sessions,
|
||||
accounts,
|
||||
brands,
|
||||
plans,
|
||||
userSubscriptions,
|
||||
userBrands,
|
||||
payments,
|
||||
queryLogs,
|
||||
vehicles,
|
||||
userVehicles,
|
||||
catalogVehicles,
|
||||
categories,
|
||||
parts,
|
||||
schemaPics,
|
||||
payments,
|
||||
plans,
|
||||
queryLogs,
|
||||
referrals,
|
||||
catalogVehicles,
|
||||
schemaPics,
|
||||
sessions,
|
||||
userBrands,
|
||||
userSubscriptions,
|
||||
userVehicles,
|
||||
users,
|
||||
vehicles,
|
||||
} from "./core";
|
||||
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
@@ -96,7 +96,10 @@ export const userVehiclesRelations = relations(userVehicles, ({ one }) => ({
|
||||
|
||||
export const categoriesRelations = relations(categories, ({ one, many }) => ({
|
||||
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, {
|
||||
fields: [categories.parentId],
|
||||
references: [categories.id],
|
||||
@@ -109,7 +112,10 @@ export const categoriesRelations = relations(categories, ({ one, many }) => ({
|
||||
|
||||
export const partsRelations = relations(parts, ({ one }) => ({
|
||||
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] }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "dotenv/config";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import { brands, plans, users, accounts } from "./schema/core";
|
||||
import { accounts, brands, plans, users } from "./schema/core";
|
||||
|
||||
const BRANDS_DATA = [
|
||||
// Mevcut markalar
|
||||
@@ -76,7 +76,10 @@ async function seed() {
|
||||
...p,
|
||||
isActive: false,
|
||||
}));
|
||||
await db.insert(plans).values([...activePlans, ...trialPlans]).onConflictDoNothing();
|
||||
await db
|
||||
.insert(plans)
|
||||
.values([...activePlans, ...trialPlans])
|
||||
.onConflictDoNothing();
|
||||
|
||||
// Seed admin user
|
||||
console.log("Seeding admin user...");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import type { ConfigService } from "@nestjs/config";
|
||||
|
||||
export interface SendEmailOptions {
|
||||
to: string;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { 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> {
|
||||
return Promise.race([
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { CorgiService } from "./corgi.service";
|
||||
|
||||
describe("CorgiService", () => {
|
||||
|
||||
@@ -9,32 +9,56 @@ interface CorgiDecodeResult {
|
||||
|
||||
const WMI_DATABASE: Record<string, string> = {
|
||||
// BMW
|
||||
WBA: "BMW", WBS: "BMW", WBY: "BMW", "5UX": "BMW",
|
||||
WBA: "BMW",
|
||||
WBS: "BMW",
|
||||
WBY: "BMW",
|
||||
"5UX": "BMW",
|
||||
// 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
|
||||
WAU: "Audi", WUA: "Audi",
|
||||
WAU: "Audi",
|
||||
WUA: "Audi",
|
||||
// Volkswagen
|
||||
WVW: "Volkswagen", WVG: "Volkswagen", "3VW": "Volkswagen",
|
||||
WV1: "Volkswagen", WV2: "Volkswagen", WV3: "Volkswagen",
|
||||
WVW: "Volkswagen",
|
||||
WVG: "Volkswagen",
|
||||
"3VW": "Volkswagen",
|
||||
WV1: "Volkswagen",
|
||||
WV2: "Volkswagen",
|
||||
WV3: "Volkswagen",
|
||||
// Toyota
|
||||
JTD: "Toyota", JTE: "Toyota", JTN: "Toyota", "2T1": "Toyota", "4T1": "Toyota",
|
||||
JTD: "Toyota",
|
||||
JTE: "Toyota",
|
||||
JTN: "Toyota",
|
||||
"2T1": "Toyota",
|
||||
"4T1": "Toyota",
|
||||
// Fiat
|
||||
ZFA: "Fiat", ZFC: "Fiat",
|
||||
ZFA: "Fiat",
|
||||
ZFC: "Fiat",
|
||||
// Renault
|
||||
VF1: "Renault", VF2: "Renault",
|
||||
VF1: "Renault",
|
||||
VF2: "Renault",
|
||||
// Peugeot
|
||||
VF3: "Peugeot",
|
||||
// Citroen
|
||||
VF7: "Citroen",
|
||||
// Honda
|
||||
JHM: "Honda", SHH: "Honda", "1HG": "Honda",
|
||||
JHM: "Honda",
|
||||
SHH: "Honda",
|
||||
"1HG": "Honda",
|
||||
// Hyundai
|
||||
KMH: "Hyundai", "5NP": "Hyundai",
|
||||
KMH: "Hyundai",
|
||||
"5NP": "Hyundai",
|
||||
// Kia
|
||||
KNA: "Kia", KND: "Kia",
|
||||
KNA: "Kia",
|
||||
KND: "Kia",
|
||||
// Ford
|
||||
WF0: "Ford", NM0: "Ford", "1FA": "Ford", "3FA": "Ford",
|
||||
WF0: "Ford",
|
||||
NM0: "Ford",
|
||||
"1FA": "Ford",
|
||||
"3FA": "Ford",
|
||||
// Opel
|
||||
W0L: "Opel",
|
||||
// Skoda
|
||||
@@ -44,11 +68,16 @@ const WMI_DATABASE: Record<string, string> = {
|
||||
// Volvo
|
||||
YV1: "Volvo",
|
||||
// Nissan
|
||||
JN1: "Nissan", "1N4": "Nissan", "3N1": "Nissan",
|
||||
JN1: "Nissan",
|
||||
"1N4": "Nissan",
|
||||
"3N1": "Nissan",
|
||||
// Mazda
|
||||
JMZ: "Mazda", JM1: "Mazda", JM3: "Mazda",
|
||||
JMZ: "Mazda",
|
||||
JM1: "Mazda",
|
||||
JM3: "Mazda",
|
||||
// Porsche
|
||||
WP0: "Porsche", WP1: "Porsche",
|
||||
WP0: "Porsche",
|
||||
WP1: "Porsche",
|
||||
// Land Rover
|
||||
SAL: "Land Rover",
|
||||
// Jaguar
|
||||
@@ -58,19 +87,52 @@ const WMI_DATABASE: Record<string, string> = {
|
||||
// Dacia
|
||||
UU1: "Dacia",
|
||||
// Subaru
|
||||
JF1: "Subaru", JF2: "Subaru",
|
||||
JF1: "Subaru",
|
||||
JF2: "Subaru",
|
||||
// Suzuki
|
||||
JS2: "Suzuki", JS3: "Suzuki", TSM: "Suzuki", MA3: "Suzuki", MBH: "Suzuki",
|
||||
JS2: "Suzuki",
|
||||
JS3: "Suzuki",
|
||||
TSM: "Suzuki",
|
||||
MA3: "Suzuki",
|
||||
MBH: "Suzuki",
|
||||
// Mitsubishi
|
||||
JMB: "Mitsubishi", JMY: "Mitsubishi", MMB: "Mitsubishi", ML3: "Mitsubishi",
|
||||
JMB: "Mitsubishi",
|
||||
JMY: "Mitsubishi",
|
||||
MMB: "Mitsubishi",
|
||||
ML3: "Mitsubishi",
|
||||
};
|
||||
|
||||
const YEAR_MAP: Record<string, number> = {
|
||||
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,
|
||||
"1": 2001, "2": 2002, "3": 2003, "4": 2004, "5": 2005,
|
||||
"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,
|
||||
"1": 2001,
|
||||
"2": 2002,
|
||||
"3": 2003,
|
||||
"4": 2004,
|
||||
"5": 2005,
|
||||
"6": 2006,
|
||||
"7": 2007,
|
||||
"8": 2008,
|
||||
"9": 2009,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -11,18 +11,13 @@
|
||||
* - Crash recovery (auto-relaunch if browser disconnects)
|
||||
*/
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Browser, BrowserContext, Page } from 'playwright';
|
||||
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
|
||||
import type { ConfigService } from "@nestjs/config";
|
||||
import type { Browser, BrowserContext, Page } from "playwright";
|
||||
|
||||
const SESSION_TTL_MS = 25 * 60 * 1000; // 25 minutes
|
||||
const MAX_CONCURRENT_PAGES = 3;
|
||||
const EMEX_BASE_URL = 'https://emexdwc.ae';
|
||||
const EMEX_BASE_URL = "https://emexdwc.ae";
|
||||
|
||||
/** Simple counting semaphore */
|
||||
class Semaphore {
|
||||
@@ -76,47 +71,28 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
||||
constructor(private configService: ConfigService) {
|
||||
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
|
||||
|
||||
this.useProxy =
|
||||
this.configService.get<string>('EMEX_USE_PROXY', 'true') === 'true';
|
||||
this.proxyHost = this.configService.get<string>(
|
||||
'EMEX_PROXY_HOST',
|
||||
'74.81.81.81',
|
||||
);
|
||||
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',
|
||||
);
|
||||
this.useProxy = this.configService.get<string>("EMEX_USE_PROXY", "true") === "true";
|
||||
this.proxyHost = this.configService.get<string>("EMEX_PROXY_HOST", "74.81.81.81");
|
||||
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> {
|
||||
try {
|
||||
await this.launchBrowser();
|
||||
this.logger.log('Browser launched on module init');
|
||||
this.logger.log("Browser launched on module init");
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
this.logger.error(
|
||||
`Failed to launch browser on init: ${e.message}`,
|
||||
e.stack,
|
||||
);
|
||||
this.logger.error(`Failed to launch browser on init: ${e.message}`, e.stack);
|
||||
// Non-fatal — will retry on first acquirePage()
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
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> {
|
||||
// Dynamic import — playwright is a devDependency
|
||||
const { chromium } = await import('playwright');
|
||||
const { chromium } = await import("playwright");
|
||||
|
||||
const launchOptions: Record<string, unknown> = {
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-accelerated-2d-canvas',
|
||||
'--disable-gpu',
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-accelerated-2d-canvas",
|
||||
"--disable-gpu",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -213,15 +189,15 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
||||
this.context = await this.browser.newContext({
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
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.sessionExpiry = 0; // force session establish on first acquirePage
|
||||
|
||||
// Auto-recover on disconnect
|
||||
this.browser.on('disconnected', () => {
|
||||
this.logger.warn('Browser disconnected — will relaunch on next request');
|
||||
this.browser.on("disconnected", () => {
|
||||
this.logger.warn("Browser disconnected — will relaunch on next request");
|
||||
this.browser = null;
|
||||
this.context = null;
|
||||
this.sessionExpiry = 0;
|
||||
@@ -243,7 +219,7 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
private async ensureBrowser(): Promise<void> {
|
||||
if (this.browser?.isConnected()) return;
|
||||
this.logger.log('Browser not connected — relaunching');
|
||||
this.logger.log("Browser not connected — relaunching");
|
||||
await this.launchBrowser();
|
||||
}
|
||||
|
||||
@@ -253,21 +229,21 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
||||
private async ensureSession(): Promise<void> {
|
||||
if (Date.now() < this.sessionExpiry) return;
|
||||
|
||||
this.logger.log('Establishing EMEX session...');
|
||||
this.logger.log("Establishing EMEX session...");
|
||||
const page = await this.context!.newPage();
|
||||
try {
|
||||
await page.goto(EMEX_BASE_URL, {
|
||||
waitUntil: 'networkidle',
|
||||
waitUntil: "networkidle",
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
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) {
|
||||
this.sessionExpiry = Date.now() + SESSION_TTL_MS;
|
||||
this.logger.log('Session established, TTL 25 min');
|
||||
this.logger.log("Session established, TTL 25 min");
|
||||
} 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
|
||||
this.sessionExpiry = Date.now() + 60_000;
|
||||
}
|
||||
@@ -278,9 +254,8 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
private randomProxyPort(): number {
|
||||
return (
|
||||
Math.floor(
|
||||
Math.random() * (this.proxyPortEnd - this.proxyPortStart + 1),
|
||||
) + this.proxyPortStart
|
||||
Math.floor(Math.random() * (this.proxyPortEnd - this.proxyPortStart + 1)) +
|
||||
this.proxyPortStart
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,75 +11,75 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
EmexScraperResponse,
|
||||
EmexCategory,
|
||||
DecodedVehicle,
|
||||
DecodedCategory,
|
||||
CATALOG_MAP,
|
||||
} from './emex.types';
|
||||
type DecodedCategory,
|
||||
type DecodedVehicle,
|
||||
type EmexCategory,
|
||||
type EmexScraperResponse,
|
||||
} from "./emex.types";
|
||||
|
||||
// ==================== VEHICLE ATTRIBUTE TRANSLATIONS ====================
|
||||
|
||||
const TR_TRANSLATIONS = {
|
||||
// Body types
|
||||
bodyTypes: {
|
||||
sedan: 'Sedan',
|
||||
coupe: 'Coupe',
|
||||
hatchback: 'Hatchback',
|
||||
wagon: 'Station Wagon',
|
||||
'station wagon': 'Station Wagon',
|
||||
estate: 'Station Wagon',
|
||||
convertible: 'Ustu Acik',
|
||||
cabriolet: 'Kabriyole',
|
||||
suv: 'SUV',
|
||||
crossover: 'Crossover',
|
||||
pickup: 'Pikap',
|
||||
van: 'Minivan',
|
||||
minivan: 'Minivan',
|
||||
mpv: 'Cok Amacli Arac',
|
||||
roadster: 'Roadster',
|
||||
sedan: "Sedan",
|
||||
coupe: "Coupe",
|
||||
hatchback: "Hatchback",
|
||||
wagon: "Station Wagon",
|
||||
"station wagon": "Station Wagon",
|
||||
estate: "Station Wagon",
|
||||
convertible: "Ustu Acik",
|
||||
cabriolet: "Kabriyole",
|
||||
suv: "SUV",
|
||||
crossover: "Crossover",
|
||||
pickup: "Pikap",
|
||||
van: "Minivan",
|
||||
minivan: "Minivan",
|
||||
mpv: "Cok Amacli Arac",
|
||||
roadster: "Roadster",
|
||||
} as Record<string, string>,
|
||||
|
||||
engineTypes: {
|
||||
gasoline: 'Benzin',
|
||||
petrol: 'Benzin',
|
||||
benzin: 'Benzin',
|
||||
diesel: 'Dizel',
|
||||
electric: 'Elektrik',
|
||||
hybrid: 'Hibrit',
|
||||
'plug-in hybrid': 'Sarjli Hibrit',
|
||||
phev: 'Sarjli Hibrit',
|
||||
lpg: 'LPG',
|
||||
cng: 'CNG',
|
||||
hydrogen: 'Hidrojen',
|
||||
gasoline: "Benzin",
|
||||
petrol: "Benzin",
|
||||
benzin: "Benzin",
|
||||
diesel: "Dizel",
|
||||
electric: "Elektrik",
|
||||
hybrid: "Hibrit",
|
||||
"plug-in hybrid": "Sarjli Hibrit",
|
||||
phev: "Sarjli Hibrit",
|
||||
lpg: "LPG",
|
||||
cng: "CNG",
|
||||
hydrogen: "Hidrojen",
|
||||
} as Record<string, string>,
|
||||
|
||||
transmissions: {
|
||||
automatic: 'Otomatik',
|
||||
manual: 'Manuel',
|
||||
'semi-automatic': 'Yari Otomatik',
|
||||
dct: 'Cift Kavramali',
|
||||
cvt: 'CVT',
|
||||
'dual clutch': 'Cift Kavramali',
|
||||
dsg: 'DSG',
|
||||
tiptronic: 'Tiptronic',
|
||||
steptronic: 'Steptronic',
|
||||
at: 'Otomatik',
|
||||
mt: 'Manuel',
|
||||
automatic: "Otomatik",
|
||||
manual: "Manuel",
|
||||
"semi-automatic": "Yari Otomatik",
|
||||
dct: "Cift Kavramali",
|
||||
cvt: "CVT",
|
||||
"dual clutch": "Cift Kavramali",
|
||||
dsg: "DSG",
|
||||
tiptronic: "Tiptronic",
|
||||
steptronic: "Steptronic",
|
||||
at: "Otomatik",
|
||||
mt: "Manuel",
|
||||
} as Record<string, string>,
|
||||
|
||||
driveTypes: {
|
||||
fwd: 'Ondan Cekis',
|
||||
rwd: 'Arkadan Itis',
|
||||
awd: 'Dort Ceker',
|
||||
'4wd': 'Dort Ceker',
|
||||
'4x4': 'Dort Ceker',
|
||||
'front-wheel drive': 'Ondan Cekis',
|
||||
'rear-wheel drive': 'Arkadan Itis',
|
||||
'all-wheel drive': 'Dort Ceker',
|
||||
quattro: 'Quattro (Dort Ceker)',
|
||||
xdrive: 'xDrive (Dort Ceker)',
|
||||
'4matic': '4MATIC (Dort Ceker)',
|
||||
fwd: "Ondan Cekis",
|
||||
rwd: "Arkadan Itis",
|
||||
awd: "Dort Ceker",
|
||||
"4wd": "Dort Ceker",
|
||||
"4x4": "Dort Ceker",
|
||||
"front-wheel drive": "Ondan Cekis",
|
||||
"rear-wheel drive": "Arkadan Itis",
|
||||
"all-wheel drive": "Dort Ceker",
|
||||
quattro: "Quattro (Dort Ceker)",
|
||||
xdrive: "xDrive (Dort Ceker)",
|
||||
"4matic": "4MATIC (Dort Ceker)",
|
||||
} as Record<string, string>,
|
||||
};
|
||||
|
||||
@@ -102,9 +102,7 @@ export function translateEngineType(engineType: string | null): string | null {
|
||||
return translateToTurkish(engineType, TR_TRANSLATIONS.engineTypes);
|
||||
}
|
||||
|
||||
export function translateTransmission(
|
||||
transmission: string | null,
|
||||
): string | null {
|
||||
export function translateTransmission(transmission: string | null): string | null {
|
||||
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
|
||||
const wmi = response.vin.substring(0, 3);
|
||||
const catalogEntry = CATALOG_MAP[wmi];
|
||||
const brand = catalogEntry?.brand || vehicle.brand || 'Unknown';
|
||||
const brand = catalogEntry?.brand || vehicle.brand || "Unknown";
|
||||
|
||||
return {
|
||||
brand: brand.toUpperCase(),
|
||||
model: vehicle.model || 'Unknown',
|
||||
model: vehicle.model || "Unknown",
|
||||
year: vehicle.year || extractYearFromVin(response.vin),
|
||||
series: vehicle.series || null,
|
||||
bodyType: vehicle.bodyType || null,
|
||||
@@ -148,15 +146,15 @@ export function mapEmexResponse(response: EmexScraperResponse): DecodedVehicle {
|
||||
function extractYearFromVin(vin: string): number {
|
||||
const yearChar = vin.charAt(9).toUpperCase();
|
||||
const yearMap: Record<string, number> = {
|
||||
'1': 2001,
|
||||
'2': 2002,
|
||||
'3': 2003,
|
||||
'4': 2004,
|
||||
'5': 2005,
|
||||
'6': 2006,
|
||||
'7': 2007,
|
||||
'8': 2008,
|
||||
'9': 2009,
|
||||
"1": 2001,
|
||||
"2": 2002,
|
||||
"3": 2003,
|
||||
"4": 2004,
|
||||
"5": 2005,
|
||||
"6": 2006,
|
||||
"7": 2007,
|
||||
"8": 2008,
|
||||
"9": 2009,
|
||||
A: 2010,
|
||||
B: 2011,
|
||||
C: 2012,
|
||||
@@ -186,11 +184,9 @@ function extractYearFromVin(vin: string): number {
|
||||
* Builds the raw response object for storage
|
||||
* Includes category URLs for on-demand parts fetching
|
||||
*/
|
||||
function buildRawResponse(
|
||||
response: EmexScraperResponse,
|
||||
): Record<string, unknown> {
|
||||
function buildRawResponse(response: EmexScraperResponse): Record<string, unknown> {
|
||||
return {
|
||||
source: 'emex', // Explicit source identifier for on-demand loading
|
||||
source: "emex", // Explicit source identifier for on-demand loading
|
||||
method: response.method,
|
||||
vin: response.vin,
|
||||
catalogCode: response.catalogCode,
|
||||
@@ -204,11 +200,12 @@ function buildRawResponse(
|
||||
// Store category tree for hierarchical insertion (QuickGroups.aspx)
|
||||
emexCategoryTree: response.categoryTree || [],
|
||||
// Store flat category URLs for on-demand parts fetching (fallback)
|
||||
emexCategories: response.categories?.map((cat) => ({
|
||||
gid: cat.gid,
|
||||
name: cat.name,
|
||||
url: cat.url,
|
||||
})) || [],
|
||||
emexCategories:
|
||||
response.categories?.map((cat) => ({
|
||||
gid: cat.gid,
|
||||
name: cat.name,
|
||||
url: cat.url,
|
||||
})) || [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -219,9 +216,7 @@ function buildRawResponse(
|
||||
* Parts are NOT included here; they are fetched on-demand when the user
|
||||
* clicks a category.
|
||||
*/
|
||||
function mapCategories(
|
||||
categories?: EmexCategory[],
|
||||
): DecodedCategory[] {
|
||||
function mapCategories(categories?: EmexCategory[]): DecodedCategory[] {
|
||||
if (!categories || categories.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -246,27 +241,27 @@ function deriveIconName(categoryName: string): string | null {
|
||||
const normalized = categoryName.toLowerCase();
|
||||
|
||||
const iconMap: Record<string, string> = {
|
||||
engine: 'engine',
|
||||
motor: 'engine',
|
||||
brake: 'brake',
|
||||
brakes: 'brake',
|
||||
suspension: 'suspension',
|
||||
steering: 'steering',
|
||||
transmission: 'transmission',
|
||||
gearbox: 'transmission',
|
||||
exhaust: 'exhaust',
|
||||
cooling: 'cooling',
|
||||
electrical: 'electrical',
|
||||
interior: 'interior',
|
||||
exterior: 'exterior',
|
||||
body: 'body',
|
||||
lighting: 'lighting',
|
||||
lights: 'lighting',
|
||||
wheels: 'wheels',
|
||||
fuel: 'fuel',
|
||||
air: 'air',
|
||||
climate: 'climate',
|
||||
filters: 'filters',
|
||||
engine: "engine",
|
||||
motor: "engine",
|
||||
brake: "brake",
|
||||
brakes: "brake",
|
||||
suspension: "suspension",
|
||||
steering: "steering",
|
||||
transmission: "transmission",
|
||||
gearbox: "transmission",
|
||||
exhaust: "exhaust",
|
||||
cooling: "cooling",
|
||||
electrical: "electrical",
|
||||
interior: "interior",
|
||||
exterior: "exterior",
|
||||
body: "body",
|
||||
lighting: "lighting",
|
||||
lights: "lighting",
|
||||
wheels: "wheels",
|
||||
fuel: "fuel",
|
||||
air: "air",
|
||||
climate: "climate",
|
||||
filters: "filters",
|
||||
};
|
||||
|
||||
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
|
||||
*/
|
||||
export function createEmptyDecodedVehicle(
|
||||
vin: string,
|
||||
errorMessage?: string,
|
||||
): DecodedVehicle {
|
||||
export function createEmptyDecodedVehicle(vin: string, errorMessage?: string): DecodedVehicle {
|
||||
const wmi = vin.substring(0, 3);
|
||||
const catalogEntry = CATALOG_MAP[wmi];
|
||||
|
||||
return {
|
||||
brand: catalogEntry?.brand?.toUpperCase() || 'UNKNOWN',
|
||||
model: 'Unknown',
|
||||
brand: catalogEntry?.brand?.toUpperCase() || "UNKNOWN",
|
||||
model: "Unknown",
|
||||
year: extractYearFromVin(vin),
|
||||
series: null,
|
||||
bodyType: null,
|
||||
@@ -302,8 +294,8 @@ export function createEmptyDecodedVehicle(
|
||||
colorCode: null,
|
||||
raw: {
|
||||
vin,
|
||||
error: errorMessage || 'Vehicle data not found',
|
||||
source: 'emexdwc.ae',
|
||||
error: errorMessage || "Vehicle data not found",
|
||||
source: "emexdwc.ae",
|
||||
},
|
||||
categories: [],
|
||||
};
|
||||
|
||||
@@ -9,30 +9,31 @@
|
||||
* QuickGroups.aspx, or QuickDetails.aspx — plain HTTP GET works.
|
||||
*/
|
||||
|
||||
import * as path from "path";
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
BadRequestException,
|
||||
ServiceUnavailableException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as path from 'path';
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
} 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 {
|
||||
EmexScraperResponse,
|
||||
EmexCategoryTreeNode,
|
||||
EmexPartsResult,
|
||||
DecodedVehicle,
|
||||
CATALOG_MAP,
|
||||
} from './emex.types';
|
||||
import { mapEmexResponse, createEmptyDecodedVehicle } from './emex.mapper';
|
||||
import { EmexBrowserService } from './emex.browser';
|
||||
import { RedisService } from '../../redis/redis.service';
|
||||
type DecodedVehicle,
|
||||
type EmexCategoryTreeNode,
|
||||
type EmexPartsResult,
|
||||
type EmexScraperResponse,
|
||||
} from "./emex.types";
|
||||
|
||||
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_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";
|
||||
|
||||
interface EmexHttpVehicle {
|
||||
label: string;
|
||||
@@ -109,23 +110,20 @@ export class EmexService {
|
||||
) {
|
||||
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
|
||||
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
|
||||
const monorepoRoot = path.resolve(__dirname, '..', '..', '..', '..', '..');
|
||||
const defaultPath = path.resolve(monorepoRoot, 'scripts/emex-vin-scraper.js');
|
||||
this.scraperPath = this.configService.get<string>(
|
||||
'EMEX_SCRAPER_PATH',
|
||||
defaultPath,
|
||||
);
|
||||
const monorepoRoot = path.resolve(__dirname, "..", "..", "..", "..", "..");
|
||||
const defaultPath = path.resolve(monorepoRoot, "scripts/emex-vin-scraper.js");
|
||||
this.scraperPath = this.configService.get<string>("EMEX_SCRAPER_PATH", defaultPath);
|
||||
|
||||
this.timeout = this.configService.get<number>('EMEX_TIMEOUT', 60000);
|
||||
this.debug = this.configService.get<boolean>('EMEX_DEBUG', false);
|
||||
this.timeout = this.configService.get<number>("EMEX_TIMEOUT", 60000);
|
||||
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) {
|
||||
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 portEnd = this.configService.get<number>('EMEX_PROXY_PORT_END', 10099);
|
||||
const user = this.configService.get<string>('EMEX_PROXY_USER', '1726bbe361918676d44e');
|
||||
const pass = this.configService.get<string>('EMEX_PROXY_PASS', 'f11c7b6128cc86c6');
|
||||
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 portEnd = this.configService.get<number>("EMEX_PROXY_PORT_END", 10099);
|
||||
const user = this.configService.get<string>("EMEX_PROXY_USER", "1726bbe361918676d44e");
|
||||
const pass = this.configService.get<string>("EMEX_PROXY_PASS", "f11c7b6128cc86c6");
|
||||
const port = Math.floor(Math.random() * (portEnd - portStart + 1)) + portStart;
|
||||
this.proxyAgent = new ProxyAgent({
|
||||
uri: `http://${user}:${pass}@${host}:${port}`,
|
||||
@@ -169,7 +167,7 @@ export class EmexService {
|
||||
try {
|
||||
this.logger.log(`Loading EMEX scraper module from: ${this.scraperPath}`);
|
||||
|
||||
const fs = require('fs');
|
||||
const fs = require("fs");
|
||||
if (!fs.existsSync(this.scraperPath)) {
|
||||
this.logger.error(`Scraper file not found at: ${this.scraperPath}`);
|
||||
this.logger.error(`Current working directory: ${process.cwd()}`);
|
||||
@@ -181,17 +179,12 @@ export class EmexService {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
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;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(
|
||||
`Failed to load EMEX scraper module: ${err.message}`,
|
||||
err.stack,
|
||||
);
|
||||
throw new InternalServerErrorException(
|
||||
'EMEX servis modulu yuklenemedi',
|
||||
);
|
||||
this.logger.error(`Failed to load EMEX scraper module: ${err.message}`, err.stack);
|
||||
throw new InternalServerErrorException("EMEX servis modulu yuklenemedi");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +199,7 @@ export class EmexService {
|
||||
await this.initializeScraper();
|
||||
|
||||
if (!this.scraperModule) {
|
||||
throw new InternalServerErrorException('EMEX scraper modulu yuklenemedi');
|
||||
throw new InternalServerErrorException("EMEX scraper modulu yuklenemedi");
|
||||
}
|
||||
|
||||
const { page, release } = await this.browserService.acquirePage();
|
||||
@@ -221,20 +214,18 @@ export class EmexService {
|
||||
*/
|
||||
private validateVin(vin: string): void {
|
||||
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) {
|
||||
throw new BadRequestException(
|
||||
'VIN numarasi 17 karakter olmalidir',
|
||||
);
|
||||
throw new BadRequestException("VIN numarasi 17 karakter olmalidir");
|
||||
}
|
||||
|
||||
if (/[IOQ]/i.test(cleanVin)) {
|
||||
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> {
|
||||
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),
|
||||
...(this.proxyAgent ? { dispatcher: this.proxyAgent } : {}),
|
||||
} as RequestInit);
|
||||
@@ -267,26 +258,27 @@ export class EmexService {
|
||||
const vehicles: EmexHttpVehicle[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = linkRx.exec(html)) !== null) {
|
||||
const href = m[1].replace(/&/g, '&');
|
||||
const href = m[1].replace(/&/g, "&");
|
||||
if (seen.has(href)) continue;
|
||||
seen.add(href);
|
||||
const label = m[2].trim();
|
||||
const params = new URLSearchParams(href.replace('Vehicle.aspx?', ''));
|
||||
const c = params.get('c');
|
||||
const vid = params.get('vid');
|
||||
const ssd = params.get('ssd');
|
||||
const params = new URLSearchParams(href.replace("Vehicle.aspx?", ""));
|
||||
const c = params.get("c");
|
||||
const vid = params.get("vid");
|
||||
const ssd = params.get("ssd");
|
||||
const modelMatch = label.match(/^([^\[]+)/);
|
||||
const yearMatch = label.match(/\((\d{4})/);
|
||||
vehicles.push({
|
||||
label,
|
||||
model: modelMatch ? modelMatch[1].trim() : label,
|
||||
yearFrom: yearMatch ? parseInt(yearMatch[1], 10) : null,
|
||||
yearFrom: yearMatch ? Number.parseInt(yearMatch[1], 10) : null,
|
||||
catalogCode: c,
|
||||
vid,
|
||||
ssd,
|
||||
quickGroupsUrl: c && vid != null && ssd
|
||||
? `${EMEX_BASE_URL}/QuickGroups.aspx?c=${c}&vid=${vid}&ssd=${encodeURIComponent(ssd)}`
|
||||
: null,
|
||||
quickGroupsUrl:
|
||||
c && vid != null && ssd
|
||||
? `${EMEX_BASE_URL}/QuickGroups.aspx?c=${c}&vid=${vid}&ssd=${encodeURIComponent(ssd)}`
|
||||
: null,
|
||||
});
|
||||
}
|
||||
return vehicles;
|
||||
@@ -301,12 +293,12 @@ export class EmexService {
|
||||
const cats: EmexHttpCategory[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = catRx.exec(html)) !== null) {
|
||||
const href = m[1].replace(/&/g, '&');
|
||||
const href = m[1].replace(/&/g, "&");
|
||||
const name = m[2].trim();
|
||||
if (name.length < 2 || seen.has(href)) continue;
|
||||
seen.add(href);
|
||||
const params = new URLSearchParams(href.replace('QuickDetails.aspx?', ''));
|
||||
cats.push({ gid: params.get('gid'), name, url: `${EMEX_BASE_URL}/${href}` });
|
||||
const params = new URLSearchParams(href.replace("QuickDetails.aspx?", ""));
|
||||
cats.push({ gid: params.get("gid"), name, url: `${EMEX_BASE_URL}/${href}` });
|
||||
}
|
||||
return cats;
|
||||
}
|
||||
@@ -318,13 +310,25 @@ export class EmexService {
|
||||
if (!c) return null;
|
||||
const upper = c.toUpperCase();
|
||||
const prefixes: [string, string][] = [
|
||||
['BMW', 'BMW'], ['MB', 'Mercedes-Benz'], ['AU', 'Audi'],
|
||||
['VW', 'Volkswagen'], ['FFIAT', 'Fiat'], ['RFIAT', 'Alfa Romeo'],
|
||||
['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'],
|
||||
["BMW", "BMW"],
|
||||
["MB", "Mercedes-Benz"],
|
||||
["AU", "Audi"],
|
||||
["VW", "Volkswagen"],
|
||||
["FFIAT", "Fiat"],
|
||||
["RFIAT", "Alfa Romeo"],
|
||||
["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) {
|
||||
if (upper.startsWith(prefix)) return brand;
|
||||
@@ -354,7 +358,7 @@ export class EmexService {
|
||||
// Determine brand: prefer CATALOG_MAP lookup, then catalog code heuristic
|
||||
const wmi = vin.substring(0, 3).toUpperCase();
|
||||
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)
|
||||
let categories: EmexHttpCategory[] = [];
|
||||
@@ -371,10 +375,10 @@ export class EmexService {
|
||||
// Build a response compatible with mapEmexResponse
|
||||
const response: EmexScraperResponse = {
|
||||
success: true,
|
||||
source: 'emexdwc.ae',
|
||||
method: 'vin_url',
|
||||
source: "emexdwc.ae",
|
||||
method: "vin_url",
|
||||
vin,
|
||||
catalogCode: v.catalogCode || '',
|
||||
catalogCode: v.catalogCode || "",
|
||||
ssd: v.ssd || undefined,
|
||||
vehicle: {
|
||||
brand,
|
||||
@@ -388,7 +392,7 @@ export class EmexService {
|
||||
driveType: 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: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
@@ -403,43 +407,45 @@ export class EmexService {
|
||||
* - `{ type: 'notFound' }` — VIN not in EMEX
|
||||
* - `{ type: 'error' }` — fetch failed
|
||||
*/
|
||||
async decodeVinOrCandidates(vin: string): Promise<
|
||||
| { type: 'vehicle'; vehicle: DecodedVehicle }
|
||||
| { type: 'candidates'; candidates: EmexCandidate[] }
|
||||
| { type: 'notFound' }
|
||||
| { type: 'error' }
|
||||
async decodeVinOrCandidates(
|
||||
vin: string,
|
||||
): Promise<
|
||||
| { type: "vehicle"; vehicle: DecodedVehicle }
|
||||
| { type: "candidates"; candidates: EmexCandidate[] }
|
||||
| { type: "notFound" }
|
||||
| { type: "error" }
|
||||
> {
|
||||
try {
|
||||
const vinUrl = `${EMEX_BASE_URL}/Vehicles.aspx?ft=findByVIN&c=&ssd=&vin=${vin}`;
|
||||
const html = await this.fetchEmexHtml(vinUrl);
|
||||
const vehicleList = this.parseVehiclesList(html);
|
||||
|
||||
if (vehicleList.length === 0) return { type: 'notFound' };
|
||||
if (vehicleList.length === 0) return { type: "notFound" };
|
||||
|
||||
if (vehicleList.length > 1) {
|
||||
const candidates: EmexCandidate[] = vehicleList.map((v, i) => {
|
||||
const params: Array<{ key: string; idx: string; value: string }> = [];
|
||||
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.yearFrom) params.push({ key: "year", idx: "0", value: String(v.yearFrom) });
|
||||
if (v.catalogCode) params.push({ key: "catalog", idx: "1", value: v.catalogCode });
|
||||
return {
|
||||
id: String(i),
|
||||
name: v.label,
|
||||
parameters: params,
|
||||
catalogId: v.catalogCode || '',
|
||||
catalogId: v.catalogCode || "",
|
||||
_index: i,
|
||||
_quickGroupsUrl: v.quickGroupsUrl,
|
||||
_ssd: v.ssd,
|
||||
_vid: v.vid,
|
||||
};
|
||||
});
|
||||
return { type: 'candidates', candidates };
|
||||
return { type: "candidates", candidates };
|
||||
}
|
||||
|
||||
// Single result — decode directly
|
||||
const v = vehicleList[0];
|
||||
const wmi = vin.substring(0, 3).toUpperCase();
|
||||
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[] = [];
|
||||
if (v.quickGroupsUrl) {
|
||||
@@ -454,10 +460,10 @@ export class EmexService {
|
||||
|
||||
const response: EmexScraperResponse = {
|
||||
success: true,
|
||||
source: 'emexdwc.ae',
|
||||
method: 'vin_url',
|
||||
source: "emexdwc.ae",
|
||||
method: "vin_url",
|
||||
vin,
|
||||
catalogCode: v.catalogCode || '',
|
||||
catalogCode: v.catalogCode || "",
|
||||
ssd: v.ssd || undefined,
|
||||
vehicle: {
|
||||
brand,
|
||||
@@ -471,15 +477,15 @@ export class EmexService {
|
||||
driveType: 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: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return { type: 'vehicle', vehicle: mapEmexResponse(response) };
|
||||
return { type: "vehicle", vehicle: mapEmexResponse(response) };
|
||||
} catch (err) {
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -503,7 +511,7 @@ export class EmexService {
|
||||
|
||||
const wmi = vin.substring(0, 3).toUpperCase();
|
||||
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[] = [];
|
||||
if (v.quickGroupsUrl) {
|
||||
@@ -511,16 +519,18 @@ export class EmexService {
|
||||
const qgHtml = await this.fetchEmexHtml(v.quickGroupsUrl);
|
||||
categories = this.parseCategoryList(qgHtml);
|
||||
} 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 = {
|
||||
success: true,
|
||||
source: 'emexdwc.ae',
|
||||
method: 'vin_url',
|
||||
source: "emexdwc.ae",
|
||||
method: "vin_url",
|
||||
vin,
|
||||
catalogCode: v.catalogCode || '',
|
||||
catalogCode: v.catalogCode || "",
|
||||
ssd: v.ssd || undefined,
|
||||
vehicle: {
|
||||
brand,
|
||||
@@ -534,7 +544,7 @@ export class EmexService {
|
||||
driveType: 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: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
@@ -552,7 +562,7 @@ export class EmexService {
|
||||
* Fallback: Playwright browser scraper (slower, used if HTTP fails).
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -564,13 +574,11 @@ export class EmexService {
|
||||
try {
|
||||
const result = await this.decodeVinHttp(cleanVin);
|
||||
if (result) {
|
||||
this.logger.log(
|
||||
`EMEX HTTP decode OK: ${result.brand} ${result.model} (${result.year})`,
|
||||
);
|
||||
this.logger.log(`EMEX HTTP decode OK: ${result.brand} ${result.model} (${result.year})`);
|
||||
return result;
|
||||
}
|
||||
// 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) {
|
||||
const err = httpErr as Error;
|
||||
this.logger.warn(`EMEX HTTP decode failed (${err.message}), falling back to browser`);
|
||||
@@ -584,28 +592,18 @@ export class EmexService {
|
||||
const scraper = instance.scraper;
|
||||
release = instance.release;
|
||||
|
||||
const response = await this.executeWithTimeout(
|
||||
scraper.searchByVIN(cleanVin),
|
||||
this.timeout,
|
||||
);
|
||||
const response = await this.executeWithTimeout(scraper.searchByVIN(cleanVin), this.timeout);
|
||||
|
||||
if (this.debug) {
|
||||
this.logger.debug(
|
||||
`EMEX browser raw response: ${JSON.stringify(response, null, 2)}`,
|
||||
);
|
||||
this.logger.debug(`EMEX browser raw response: ${JSON.stringify(response, null, 2)}`);
|
||||
}
|
||||
|
||||
if (!response.success) {
|
||||
this.logger.warn(
|
||||
`EMEX browser search unsuccessful: ${response.message || response.error}`,
|
||||
);
|
||||
this.logger.warn(`EMEX browser search unsuccessful: ${response.message || response.error}`);
|
||||
if (response.vehicle && response.vehicle.brand) {
|
||||
return mapEmexResponse(response);
|
||||
}
|
||||
return createEmptyDecodedVehicle(
|
||||
cleanVin,
|
||||
response.message || response.error,
|
||||
);
|
||||
return createEmptyDecodedVehicle(cleanVin, response.message || response.error);
|
||||
}
|
||||
|
||||
const decodedVehicle = mapEmexResponse(response);
|
||||
@@ -624,17 +622,15 @@ export class EmexService {
|
||||
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}`);
|
||||
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);
|
||||
throw new ServiceUnavailableException(
|
||||
'VIN sorgulama sirasinda bir hata olustu',
|
||||
);
|
||||
throw new ServiceUnavailableException("VIN sorgulama sirasinda bir hata olustu");
|
||||
} finally {
|
||||
if (release) {
|
||||
try {
|
||||
@@ -650,16 +646,13 @@ export class EmexService {
|
||||
/**
|
||||
* Executes a promise with timeout
|
||||
*/
|
||||
private async executeWithTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
private async executeWithTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
const error = new Error(`Operation timed out after ${timeoutMs}ms`);
|
||||
error.name = 'TimeoutError';
|
||||
error.name = "TimeoutError";
|
||||
reject(error);
|
||||
}, timeoutMs);
|
||||
});
|
||||
@@ -709,7 +702,7 @@ export class EmexService {
|
||||
*/
|
||||
async fetchCategoryParts(categoryUrl: string): Promise<EmexPartsResult> {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -723,10 +716,7 @@ export class EmexService {
|
||||
const scraper = instance.scraper;
|
||||
release = instance.release;
|
||||
|
||||
const result = await this.executeWithTimeout(
|
||||
scraper.getParts(categoryUrl),
|
||||
this.timeout,
|
||||
);
|
||||
const result = await this.executeWithTimeout(scraper.getParts(categoryUrl), this.timeout);
|
||||
|
||||
if (result && result.parts.length > 0) {
|
||||
this.logger.log(`Fetched ${result.parts.length} parts from category`);
|
||||
@@ -763,15 +753,15 @@ export class EmexService {
|
||||
|
||||
const yearChar = vin.charAt(9).toUpperCase();
|
||||
const yearMap: Record<string, number> = {
|
||||
'1': 2001,
|
||||
'2': 2002,
|
||||
'3': 2003,
|
||||
'4': 2004,
|
||||
'5': 2005,
|
||||
'6': 2006,
|
||||
'7': 2007,
|
||||
'8': 2008,
|
||||
'9': 2009,
|
||||
"1": 2001,
|
||||
"2": 2002,
|
||||
"3": 2003,
|
||||
"4": 2004,
|
||||
"5": 2005,
|
||||
"6": 2006,
|
||||
"7": 2007,
|
||||
"8": 2008,
|
||||
"9": 2009,
|
||||
A: 2010,
|
||||
B: 2011,
|
||||
C: 2012,
|
||||
|
||||
@@ -126,7 +126,7 @@ export interface EmexWizardOption {
|
||||
export interface EmexScraperResponse {
|
||||
success: boolean;
|
||||
source: string;
|
||||
method: 'api' | 'vin_url' | 'wizard' | 'html_parse' | 'fallback';
|
||||
method: "api" | "vin_url" | "wizard" | "html_parse" | "fallback";
|
||||
vin: string;
|
||||
catalogCode: string;
|
||||
ssd?: string;
|
||||
@@ -231,33 +231,33 @@ export interface CatalogEntry {
|
||||
* WMI (World Manufacturer Identifier) to catalog mapping
|
||||
*/
|
||||
export const CATALOG_MAP: Record<string, CatalogEntry> = {
|
||||
WBA: { code: 'BMW202501', brand: 'BMW' },
|
||||
WBS: { code: 'BMW202501', brand: 'BMW' },
|
||||
WBY: { code: 'BMW202501', brand: 'BMW' },
|
||||
WDB: { code: 'MB201810', brand: 'Mercedes-Benz' },
|
||||
WDD: { code: 'MB201810', brand: 'Mercedes-Benz' },
|
||||
WDC: { code: 'MB201810', brand: 'Mercedes-Benz' },
|
||||
WDF: { code: 'MB201810', brand: 'Mercedes-Benz' },
|
||||
WAU: { code: 'AU1587', brand: 'Audi' },
|
||||
WVW: { code: 'VW1587', brand: 'Volkswagen' },
|
||||
WVG: { code: 'VW1587', brand: 'Volkswagen' },
|
||||
VF1: { code: 'RENAULT201910', brand: 'Renault' },
|
||||
VF7: { code: 'CPSA01', brand: 'Peugeot' },
|
||||
VF3: { code: 'CPSA01', brand: 'Peugeot' },
|
||||
ZFA: { code: 'CFIAT84', brand: 'Fiat' },
|
||||
ZAR: { code: 'RFIAT84', brand: 'Alfa Romeo' },
|
||||
WF0: { code: 'FORD202201', brand: 'Ford' },
|
||||
NM0: { code: 'FORD202201', brand: 'Ford' },
|
||||
JTD: { code: 'TOYOTA00', brand: 'Toyota' },
|
||||
JTE: { code: 'TOYOTA00', brand: 'Toyota' },
|
||||
SHH: { code: 'HONDA00', brand: 'Honda' },
|
||||
KNM: { code: 'HYUNDAI00', brand: 'Hyundai' },
|
||||
KNA: { code: 'KIA00', brand: 'Kia' },
|
||||
WP0: { code: 'PO799', brand: 'Porsche' },
|
||||
WP1: { code: 'PO799', brand: 'Porsche' },
|
||||
JF1: { code: 'SUBARU201802', brand: 'Subaru' },
|
||||
JF2: { code: 'SUBARU201802', brand: 'Subaru' },
|
||||
JMZ: { code: 'MAZDA2020', brand: 'Mazda' },
|
||||
JM1: { code: 'MAZDA2020', brand: 'Mazda' },
|
||||
JM3: { code: 'MAZDA2020', brand: 'Mazda' },
|
||||
WBA: { code: "BMW202501", brand: "BMW" },
|
||||
WBS: { code: "BMW202501", brand: "BMW" },
|
||||
WBY: { code: "BMW202501", brand: "BMW" },
|
||||
WDB: { code: "MB201810", brand: "Mercedes-Benz" },
|
||||
WDD: { code: "MB201810", brand: "Mercedes-Benz" },
|
||||
WDC: { code: "MB201810", brand: "Mercedes-Benz" },
|
||||
WDF: { code: "MB201810", brand: "Mercedes-Benz" },
|
||||
WAU: { code: "AU1587", brand: "Audi" },
|
||||
WVW: { code: "VW1587", brand: "Volkswagen" },
|
||||
WVG: { code: "VW1587", brand: "Volkswagen" },
|
||||
VF1: { code: "RENAULT201910", brand: "Renault" },
|
||||
VF7: { code: "CPSA01", brand: "Peugeot" },
|
||||
VF3: { code: "CPSA01", brand: "Peugeot" },
|
||||
ZFA: { code: "CFIAT84", brand: "Fiat" },
|
||||
ZAR: { code: "RFIAT84", brand: "Alfa Romeo" },
|
||||
WF0: { code: "FORD202201", brand: "Ford" },
|
||||
NM0: { code: "FORD202201", brand: "Ford" },
|
||||
JTD: { code: "TOYOTA00", brand: "Toyota" },
|
||||
JTE: { code: "TOYOTA00", brand: "Toyota" },
|
||||
SHH: { code: "HONDA00", brand: "Honda" },
|
||||
KNM: { code: "HYUNDAI00", brand: "Hyundai" },
|
||||
KNA: { code: "KIA00", brand: "Kia" },
|
||||
WP0: { code: "PO799", brand: "Porsche" },
|
||||
WP1: { code: "PO799", brand: "Porsche" },
|
||||
JF1: { code: "SUBARU201802", brand: "Subaru" },
|
||||
JF2: { code: "SUBARU201802", brand: "Subaru" },
|
||||
JMZ: { code: "MAZDA2020", brand: "Mazda" },
|
||||
JM1: { code: "MAZDA2020", brand: "Mazda" },
|
||||
JM3: { code: "MAZDA2020", brand: "Mazda" },
|
||||
};
|
||||
|
||||
@@ -13,15 +13,10 @@
|
||||
* 19:00-09:00 → on-demand only: capture only when needed
|
||||
*/
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
|
||||
import type { ConfigService } from "@nestjs/config";
|
||||
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 REFRESH_BUFFER = 90; // Refresh 90s before expiry
|
||||
@@ -113,17 +108,10 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly proxyPass: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.useProxy =
|
||||
this.configService.get<string>("PCAT_USE_PROXY", "true") === "true";
|
||||
this.useProxy = this.configService.get<string>("PCAT_USE_PROXY", "true") === "true";
|
||||
this.proxyHost = this.configService.get<string>("PCAT_PROXY_HOST", DI_HOST);
|
||||
this.proxyUser = this.configService.get<string>(
|
||||
"PCAT_PROXY_USER",
|
||||
DI_DEFAULT_USER,
|
||||
);
|
||||
this.proxyPass = this.configService.get<string>(
|
||||
"PCAT_PROXY_PASS",
|
||||
DI_DEFAULT_PASS,
|
||||
);
|
||||
this.proxyUser = this.configService.get<string>("PCAT_PROXY_USER", DI_DEFAULT_USER);
|
||||
this.proxyPass = this.configService.get<string>("PCAT_PROXY_PASS", DI_DEFAULT_PASS);
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
@@ -131,9 +119,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
await this.launchBrowser();
|
||||
this.logger.log("Browser launched for JWT capture");
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to launch browser on init: ${(err as Error).message}`,
|
||||
);
|
||||
this.logger.error(`Failed to launch browser on init: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// Start business hours scheduling
|
||||
@@ -279,9 +265,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
const ttl = jwt.exp - Math.floor(Date.now() / 1000);
|
||||
const refreshIn = this.isBusinessHours()
|
||||
? Math.max(ttl - REFRESH_BUFFER, 30)
|
||||
: null;
|
||||
const refreshIn = this.isBusinessHours() ? Math.max(ttl - REFRESH_BUFFER, 30) : null;
|
||||
this.logger.log(
|
||||
`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,
|
||||
}).formatToParts(new Date());
|
||||
|
||||
const hour = parseInt(parts.find((p) => p.type === "hour")!.value, 10);
|
||||
const minute = parseInt(parts.find((p) => p.type === "minute")!.value, 10);
|
||||
const hour = Number.parseInt(parts.find((p) => p.type === "hour")!.value, 10);
|
||||
const minute = Number.parseInt(parts.find((p) => p.type === "minute")!.value, 10);
|
||||
return { hour, minute };
|
||||
}
|
||||
|
||||
@@ -478,7 +462,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
// All on cooldown — pick the one with oldest usage
|
||||
let oldestIdx = 0;
|
||||
let oldestTime = Infinity;
|
||||
let oldestTime = Number.POSITIVE_INFINITY;
|
||||
for (let i = 0; i < JWT_SITES.length; i++) {
|
||||
const lastUsed = this.siteLastUsedAt.get(JWT_SITES[i]) || 0;
|
||||
if (lastUsed < oldestTime) {
|
||||
@@ -496,10 +480,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
// ─── JWT capture via Playwright ───────────────────────────
|
||||
|
||||
private async attemptCapture(
|
||||
siteUrl: string,
|
||||
port: number,
|
||||
): Promise<PcatJwtToken | null> {
|
||||
private async attemptCapture(siteUrl: string, port: number): Promise<PcatJwtToken | null> {
|
||||
let context: BrowserContext | null = null;
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -574,9 +555,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
} catch (navErr) {
|
||||
// Navigation may timeout but JWT could still be captured
|
||||
this.logger.debug(
|
||||
`Navigation ended: ${(navErr as Error).message?.slice(0, 80)}`,
|
||||
);
|
||||
this.logger.debug(`Navigation ended: ${(navErr as Error).message?.slice(0, 80)}`);
|
||||
}
|
||||
|
||||
// Poll for token
|
||||
@@ -588,9 +567,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
if (capturedToken) {
|
||||
this.logger.log(
|
||||
`Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`,
|
||||
);
|
||||
this.logger.log(`Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`);
|
||||
return capturedToken;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import type { RedisService } from "../../redis/redis.service";
|
||||
import type { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
|
||||
import type {
|
||||
PcatVinResult,
|
||||
PcatCar,
|
||||
PcatGroup,
|
||||
PcatPartsResult,
|
||||
PcatSession,
|
||||
PcatVinResult,
|
||||
} from "./parts-catalogs.types";
|
||||
|
||||
const API_BASE = "https://gui.parts-catalogs.com/v3/api/proxy";
|
||||
@@ -95,10 +95,7 @@ export class PartsCatalogsService {
|
||||
if (groupId) params.groupId = groupId;
|
||||
if (carParams) Object.assign(params, carParams);
|
||||
|
||||
const data = await this.fetchWithAuth(
|
||||
`/catalogs/${catalogId}/groups2/`,
|
||||
params,
|
||||
);
|
||||
const data = await this.fetchWithAuth(`/catalogs/${catalogId}/groups2/`, params);
|
||||
|
||||
if (!Array.isArray(data)) return [];
|
||||
|
||||
@@ -125,10 +122,7 @@ export class PartsCatalogsService {
|
||||
const params: Record<string, string> = { carId, groupId };
|
||||
if (carParams) Object.assign(params, carParams);
|
||||
|
||||
const data = await this.fetchWithAuth(
|
||||
`/catalogs/${catalogId}/parts2`,
|
||||
params,
|
||||
);
|
||||
const data = await this.fetchWithAuth(`/catalogs/${catalogId}/parts2`, params);
|
||||
|
||||
if (!data || typeof data !== "object") return null;
|
||||
|
||||
@@ -171,10 +165,7 @@ export class PartsCatalogsService {
|
||||
|
||||
// ─── Private ─────────────────────────────────────────────
|
||||
|
||||
private async fetchWithAuth(
|
||||
endpoint: string,
|
||||
params?: Record<string, string>,
|
||||
): Promise<any> {
|
||||
private async fetchWithAuth(endpoint: string, params?: Record<string, string>): Promise<any> {
|
||||
const maxRetries = 2;
|
||||
|
||||
let session: PcatSession | null = null;
|
||||
@@ -229,9 +220,7 @@ export class PartsCatalogsService {
|
||||
}
|
||||
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`HTTP ${response.status} from ${endpoint}: ${text.slice(0, 200)}`,
|
||||
);
|
||||
throw new Error(`HTTP ${response.status} from ${endpoint}: ${text.slice(0, 200)}`);
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "TimeoutError") {
|
||||
this.logger.warn(`Timeout on ${endpoint}, attempt ${attempt + 1}`);
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
* IP-bound (must be reused with the same proxy port that captured it).
|
||||
*/
|
||||
export interface PcatJwtToken {
|
||||
raw: string; // x-api-key value, e.g. "TWS-016EA7BE-..."
|
||||
exp: number; // unix epoch seconds (capturedAt + TTL_FALLBACK)
|
||||
apiPath: string; // x-api-path (upstream PCAT API base URL)
|
||||
guiVersion: string; // x-gui-version (e.g. "3")
|
||||
userId: string; // x-user-id (per-session UUID minted by widget)
|
||||
origin: string; // partner-site origin
|
||||
referer: string; // partner-site referer
|
||||
raw: string; // x-api-key value, e.g. "TWS-016EA7BE-..."
|
||||
exp: number; // unix epoch seconds (capturedAt + TTL_FALLBACK)
|
||||
apiPath: string; // x-api-path (upstream PCAT API base URL)
|
||||
guiVersion: string; // x-gui-version (e.g. "3")
|
||||
userId: string; // x-user-id (per-session UUID minted by widget)
|
||||
origin: string; // partner-site origin
|
||||
referer: string; // partner-site referer
|
||||
}
|
||||
|
||||
export interface JwtSlot {
|
||||
@@ -22,7 +22,7 @@ export interface JwtSlot {
|
||||
}
|
||||
|
||||
export interface PcatSession {
|
||||
apiKey: string; // x-api-key (TWS- token)
|
||||
apiKey: string; // x-api-key (TWS- token)
|
||||
apiPath: string;
|
||||
guiVersion: string;
|
||||
userId: string;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* response normalization.
|
||||
*/
|
||||
|
||||
import type { PL24DecodedVehicle, PL24DecodedCategory, PL24Part } from "../pl24.types";
|
||||
import type { PL24DecodedCategory, PL24DecodedVehicle, PL24Part } from "../pl24.types";
|
||||
|
||||
export abstract class BasePL24Parser {
|
||||
abstract readonly brandName: string;
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* 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 type { PL24DecodedVehicle, PL24DecodedCategory, PL24Part } from "../pl24.types";
|
||||
|
||||
export class GenericPL24Parser extends BasePL24Parser {
|
||||
readonly brandName: string;
|
||||
|
||||
@@ -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 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> = {
|
||||
BMW: () => new BmwPL24Parser(),
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
*/
|
||||
|
||||
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 type {
|
||||
PL24AuthorizeRequest,
|
||||
PL24AuthorizeResponse,
|
||||
PL24JWTPayload,
|
||||
PL24LoginRequest,
|
||||
PL24LoginResponse,
|
||||
PL24TokenData,
|
||||
PL24JWTPayload,
|
||||
PL24AuthorizeRequest,
|
||||
PL24AuthorizeResponse,
|
||||
} from "./pl24.types";
|
||||
|
||||
@Injectable()
|
||||
@@ -282,7 +282,9 @@ export class PL24AuthService {
|
||||
}
|
||||
|
||||
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(
|
||||
`PL24 giris basarisiz: ${data.message || data.status || "Token alinamadi"}`,
|
||||
);
|
||||
@@ -300,7 +302,9 @@ export class PL24AuthService {
|
||||
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;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
@@ -419,7 +423,9 @@ export class PL24AuthService {
|
||||
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;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
|
||||
@@ -8,24 +8,21 @@
|
||||
|
||||
import { createHash } from "crypto";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import { StorageService } from "../../storage/storage.service";
|
||||
import type { ConfigService } from "@nestjs/config";
|
||||
import type { RedisService } from "../../redis/redis.service";
|
||||
import type { 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 {
|
||||
PL24DecodedVehicle,
|
||||
PL24DecodedCategory,
|
||||
PL24PartsResponse,
|
||||
PL24Part,
|
||||
PL24MainGroup,
|
||||
type PL24DecodedCategory,
|
||||
type PL24DecodedVehicle,
|
||||
type PL24MainGroup,
|
||||
type PL24Part,
|
||||
type PL24PartsResponse,
|
||||
SERVICE_TO_BRAND,
|
||||
getServiceConfig,
|
||||
} from "./pl24.types";
|
||||
import {
|
||||
FORD_LEGACY_ENDPOINTS,
|
||||
type FordPL24Support,
|
||||
} from "./pl24-ford-legacy.types";
|
||||
|
||||
@Injectable()
|
||||
export class PL24FordLegacyService {
|
||||
@@ -40,10 +37,7 @@ export class PL24FordLegacyService {
|
||||
private redis: RedisService,
|
||||
private storage: StorageService,
|
||||
) {
|
||||
this.baseUrl = this.configService.get<string>(
|
||||
"pl24.baseUrl",
|
||||
"https://www.partslink24.com",
|
||||
);
|
||||
this.baseUrl = this.configService.get<string>("pl24.baseUrl", "https://www.partslink24.com");
|
||||
this.timeout = 30000;
|
||||
}
|
||||
|
||||
@@ -178,7 +172,9 @@ export class PL24FordLegacyService {
|
||||
}
|
||||
if (groups.length === 0) {
|
||||
// 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) {
|
||||
await this.redis.setJson(cacheKey, groups, 86400);
|
||||
@@ -247,7 +243,9 @@ export class PL24FordLegacyService {
|
||||
// Fallback: extract any .action links from the page
|
||||
const links = this.extractLinks(html, /\.action/);
|
||||
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) => ({
|
||||
id: String(idx),
|
||||
code: this.extractCodeFromText(link.text) || String(idx),
|
||||
@@ -518,12 +516,11 @@ export class PL24FordLegacyService {
|
||||
for (const item of items) {
|
||||
// Avoid duplicating family name prefix (e.g. family="BERLINGO", salesType="BERLINGO VP" → "BERLINGO VP")
|
||||
const alreadyPrefixed =
|
||||
item.name &&
|
||||
item.name.toUpperCase().startsWith(family.name.toUpperCase());
|
||||
item.name && item.name.toUpperCase().startsWith(family.name.toUpperCase());
|
||||
const modelLabel =
|
||||
item.name && !alreadyPrefixed && item.name !== family.name
|
||||
? `${family.name} ${item.name}`
|
||||
: (item.name || family.name);
|
||||
: item.name || family.name;
|
||||
|
||||
vehicles.push({
|
||||
vehicleId: `${serviceName}::${family.id}::${item.code}`,
|
||||
@@ -690,7 +687,9 @@ export class PL24FordLegacyService {
|
||||
if (!html) return [];
|
||||
|
||||
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) {
|
||||
await this.redis.setJson(cacheKey, scopes, 7200);
|
||||
@@ -712,7 +711,10 @@ export class PL24FordLegacyService {
|
||||
body !== "_all_" || engine !== "_all_" || gearbox !== "_all_"
|
||||
? `: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 cached = await this.redis.getJson<PL24MainGroup[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
@@ -781,7 +783,10 @@ export class PL24FordLegacyService {
|
||||
body !== "_all_" || engine !== "_all_" || gearbox !== "_all_"
|
||||
? `: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 cached = await this.redis.getJson<PL24MainGroup[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
@@ -822,7 +827,9 @@ export class PL24FordLegacyService {
|
||||
seenPaths.add(key);
|
||||
|
||||
const boardUrl = item.url
|
||||
? item.url.startsWith("/") ? item.url : `/psa/${svcFromPath}/${item.url}`
|
||||
? item.url.startsWith("/")
|
||||
? item.url
|
||||
: `/psa/${svcFromPath}/${item.url}`
|
||||
: undefined;
|
||||
|
||||
groups.push({
|
||||
@@ -859,7 +866,10 @@ export class PL24FordLegacyService {
|
||||
body !== "_all_" || engine !== "_all_" || gearbox !== "_all_"
|
||||
? `: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}`;
|
||||
if (!bypassCache) {
|
||||
const cached = await this.redis.getJson<PL24PartsResponse>(cacheKey);
|
||||
@@ -1022,7 +1032,13 @@ export class PL24FordLegacyService {
|
||||
// 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).
|
||||
// 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) {
|
||||
this.logger.log(`Ford: ${catCodeVehicles.length} catCode vehicles for ${serviceName}`);
|
||||
@@ -1090,7 +1106,9 @@ export class PL24FordLegacyService {
|
||||
await this.authService.authorizeService(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
|
||||
const groupUrl = `${this.baseUrl}${basePath}/group.action?lang=tr`;
|
||||
@@ -1138,7 +1156,10 @@ export class PL24FordLegacyService {
|
||||
return vehicles;
|
||||
} catch (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 [];
|
||||
}
|
||||
}
|
||||
@@ -1205,7 +1226,10 @@ export class PL24FordLegacyService {
|
||||
return vehicles;
|
||||
} catch (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 [];
|
||||
}
|
||||
}
|
||||
@@ -1318,7 +1342,9 @@ export class PL24FordLegacyService {
|
||||
// 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 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)
|
||||
// Our subscription doesn't support Volvo VIN browsing, so auth'd request returns 500.
|
||||
@@ -1328,7 +1354,10 @@ export class PL24FordLegacyService {
|
||||
try {
|
||||
const resp = await fetch(vinGroupUrl, {
|
||||
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),
|
||||
redirect: "follow",
|
||||
});
|
||||
@@ -1424,12 +1453,14 @@ export class PL24FordLegacyService {
|
||||
|
||||
let modelYears: { code: string; name: string }[] = [];
|
||||
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);
|
||||
modelYears = items
|
||||
.map((it) => {
|
||||
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 };
|
||||
})
|
||||
.filter((it) => it.code);
|
||||
@@ -1536,17 +1567,19 @@ export class PL24FordLegacyService {
|
||||
return [];
|
||||
}
|
||||
|
||||
const familyMap = vehiclesData.modelFamilyToModelList as Record<
|
||||
string,
|
||||
Array<{
|
||||
url?: string;
|
||||
jsonUrl?: string;
|
||||
identifier?: string;
|
||||
caption?: string;
|
||||
year?: string;
|
||||
gray?: boolean;
|
||||
}>
|
||||
> | undefined;
|
||||
const familyMap = vehiclesData.modelFamilyToModelList as
|
||||
| Record<
|
||||
string,
|
||||
Array<{
|
||||
url?: string;
|
||||
jsonUrl?: string;
|
||||
identifier?: string;
|
||||
caption?: string;
|
||||
year?: string;
|
||||
gray?: boolean;
|
||||
}>
|
||||
>
|
||||
| undefined;
|
||||
|
||||
if (!familyMap) return [];
|
||||
|
||||
@@ -1554,7 +1587,9 @@ export class PL24FordLegacyService {
|
||||
const familyKey =
|
||||
Object.keys(familyMap).find((k) => k === familyId) ||
|
||||
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;
|
||||
if (!Array.isArray(models) || models.length === 0) return [];
|
||||
@@ -1564,8 +1599,8 @@ export class PL24FordLegacyService {
|
||||
if (m2.gray === true) continue; // Skip unavailable sub-models
|
||||
|
||||
// catCode may be in identifier OR embedded in the URL
|
||||
const catCode = (m2.identifier?.trim() || "") ||
|
||||
(m2.url?.match(/[?&]catCode=([^&"]+)/)?.[1] || "");
|
||||
const catCode =
|
||||
m2.identifier?.trim() || "" || m2.url?.match(/[?&]catCode=([^&"]+)/)?.[1] || "";
|
||||
if (!catCode) continue;
|
||||
|
||||
// Build display name: caption if available, else family+year range
|
||||
@@ -1738,7 +1773,9 @@ export class PL24FordLegacyService {
|
||||
let firstSalesTypeId = firstFamily.id;
|
||||
if (salesRaw) {
|
||||
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);
|
||||
if (items.length > 0) firstSalesTypeId = items[0].code;
|
||||
} catch {
|
||||
@@ -1759,7 +1796,7 @@ export class PL24FordLegacyService {
|
||||
|
||||
this.logger.log(
|
||||
`PSA: ${scopes.length} scopes for ${serviceName} VIN ${vin}` +
|
||||
` (family=${familyId || "?"}, salesType=${salesTypeId || "?"})`,
|
||||
` (family=${familyId || "?"}, salesType=${salesTypeId || "?"})`,
|
||||
);
|
||||
return scopes;
|
||||
}
|
||||
@@ -1877,10 +1914,14 @@ export class PL24FordLegacyService {
|
||||
const salesRaw = await this.fetchPsaPage(salesUrl, serviceName, jsessionId, true);
|
||||
if (salesRaw) {
|
||||
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);
|
||||
if (items.length > 0) firstSalesTypeId = items[0].code;
|
||||
} catch { /* keep firstFamily.id */ }
|
||||
} catch {
|
||||
/* keep firstFamily.id */
|
||||
}
|
||||
}
|
||||
const groupUrl =
|
||||
`${this.baseUrl}/psa/${serviceName}/group.action` +
|
||||
@@ -1889,7 +1930,10 @@ export class PL24FordLegacyService {
|
||||
`&startup=false&mode=${mode}&upds=${upds}`;
|
||||
const groupHtml = await this.fetchPsaPage(groupUrl, serviceName, jsessionId);
|
||||
if (groupHtml) {
|
||||
({ scopes, familyId, salesTypeId } = this.parsePsaScopesAndParams(groupHtml, serviceName));
|
||||
({ scopes, familyId, salesTypeId } = this.parsePsaScopesAndParams(
|
||||
groupHtml,
|
||||
serviceName,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1910,7 +1954,7 @@ export class PL24FordLegacyService {
|
||||
await this.redis.setJson(cacheKey, result, 86400);
|
||||
this.logger.log(
|
||||
`PSA: decoded VIN ${vin} (${serviceName}) — ${vehicle.brand} ${vehicle.model} ${vehicle.year},` +
|
||||
` ${scopes.length} scopes`,
|
||||
` ${scopes.length} scopes`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
@@ -1987,16 +2031,18 @@ export class PL24FordLegacyService {
|
||||
return [];
|
||||
}
|
||||
|
||||
const familyMap = vehiclesData.modelFamilyToModelList as Record<
|
||||
string,
|
||||
Array<{
|
||||
url?: string;
|
||||
jsonUrl?: string;
|
||||
identifier?: string;
|
||||
caption?: string;
|
||||
year?: string;
|
||||
}>
|
||||
> | undefined;
|
||||
const familyMap = vehiclesData.modelFamilyToModelList as
|
||||
| Record<
|
||||
string,
|
||||
Array<{
|
||||
url?: string;
|
||||
jsonUrl?: string;
|
||||
identifier?: string;
|
||||
caption?: string;
|
||||
year?: string;
|
||||
}>
|
||||
>
|
||||
| undefined;
|
||||
|
||||
if (!familyMap || typeof familyMap !== "object") return [];
|
||||
|
||||
@@ -2011,8 +2057,8 @@ export class PL24FordLegacyService {
|
||||
if (!Array.isArray(models)) continue;
|
||||
for (const m2 of models) {
|
||||
// catCode may be in identifier field OR embedded in the url (e.g. "vehicle.action?catCode=CB7&...")
|
||||
const catCode = (m2.identifier?.trim() || "") ||
|
||||
(m2.url?.match(/[?&]catCode=([^&"]+)/)?.[1] || "");
|
||||
const catCode =
|
||||
m2.identifier?.trim() || "" || m2.url?.match(/[?&]catCode=([^&"]+)/)?.[1] || "";
|
||||
if (!catCode) continue;
|
||||
|
||||
// Build display name: "Focus (1998-2005)" or just "Focus" if no year
|
||||
@@ -2092,7 +2138,6 @@ export class PL24FordLegacyService {
|
||||
return families;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parse items array from PSA JSON variant endpoints (bodies/engines/gearboxes).
|
||||
* PL24 PSA uses variant-specific field names:
|
||||
@@ -2105,7 +2150,8 @@ export class PL24FordLegacyService {
|
||||
raw: string,
|
||||
type: "body" | "engine" | "gearbox",
|
||||
): { 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";
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
@@ -2244,7 +2290,12 @@ export class PL24FordLegacyService {
|
||||
const textTdMatch =
|
||||
segment.match(/class="text[^"]*"[^>]*>([\s\S]*?)<\/td>/) ||
|
||||
segment.match(/class="caption[^"]*"[^>]*>([\s\S]*?)<\/td>/);
|
||||
const text = textTdMatch ? textTdMatch[1].replace(/<[^>]+>/g, "").replace(/ /g, " ").trim() : "";
|
||||
const text = textTdMatch
|
||||
? textTdMatch[1]
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.trim()
|
||||
: "";
|
||||
|
||||
rows.push({
|
||||
pnc,
|
||||
@@ -2331,14 +2382,16 @@ export class PL24FordLegacyService {
|
||||
if (!oemCode) continue;
|
||||
|
||||
const captionMatch = segment.match(/\bcaption="([^"]*)"/);
|
||||
const name = captionMatch ? captionMatch[1].replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").trim() : "";
|
||||
const name = captionMatch
|
||||
? captionMatch[1].replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").trim()
|
||||
: "";
|
||||
|
||||
const hotspotMatch = segment.match(/\bhotspot="([^"]*?)"/);
|
||||
const hotspot = hotspotMatch?.[1]?.trim() ?? "";
|
||||
|
||||
const qtyTdMatch = segment.match(/class="quantity"[^>]*>\s*(\d+)\s*</);
|
||||
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 positionCode = hotspot || bomDetailIdMatch?.[1] || oemCode;
|
||||
@@ -2407,12 +2460,15 @@ export class PL24FordLegacyService {
|
||||
.filter((item) => !item.subheader && !item.gray && !!(item.jsonUrl || item.url))
|
||||
.map((item) => {
|
||||
// Strip HTML restriction block divs from caption (Ford embeds them in JSON too)
|
||||
const cleanName = (item.caption || "")
|
||||
.replace(/<div[^>]*restriction[^>]*>[\s\S]*/i, "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim() || item.databaseKey || item.identifier;
|
||||
const cleanName =
|
||||
(item.caption || "")
|
||||
.replace(/<div[^>]*restriction[^>]*>[\s\S]*/i, "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim() ||
|
||||
item.databaseKey ||
|
||||
item.identifier;
|
||||
return {
|
||||
id: item.databaseKey || item.identifier,
|
||||
code: item.databaseKey || item.identifier,
|
||||
@@ -2454,9 +2510,7 @@ export class PL24FordLegacyService {
|
||||
id: String(item.id || idx),
|
||||
code: String(item.id || idx),
|
||||
name: (item.caption ?? item.name ?? "").replace(/^\d+\s+/, "").trim(),
|
||||
linkPath: item.url
|
||||
? `${basePath}${item.url}`
|
||||
: `${basePath}${item.jsonUrl}`,
|
||||
linkPath: item.url ? `${basePath}${item.url}` : `${basePath}${item.jsonUrl}`,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
@@ -2496,16 +2550,20 @@ export class PL24FordLegacyService {
|
||||
|
||||
try {
|
||||
// 1. entry.action → 302 with JSESSIONID
|
||||
const entryRes = await fetch(
|
||||
`${this.baseUrl}/psa/pl24-entry.action?service=${serviceName}`,
|
||||
{ method: "GET", headers, redirect: "manual", signal: AbortSignal.timeout(this.timeout) },
|
||||
);
|
||||
const entryRes = await fetch(`${this.baseUrl}/psa/pl24-entry.action?service=${serviceName}`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
const jsessionMatch = entryRes.headers.get("set-cookie")?.match(/JSESSIONID=([^;]+)/);
|
||||
const jsessionId = jsessionMatch?.[1] || "";
|
||||
const loc1 = entryRes.headers.get("location");
|
||||
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
|
||||
const startup1Url = loc1.startsWith("http") ? loc1 : `${this.baseUrl}${loc1}`;
|
||||
@@ -2527,7 +2585,9 @@ export class PL24FordLegacyService {
|
||||
|
||||
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 };
|
||||
} catch (error) {
|
||||
this.logger.error(`PSA session init error: ${(error as Error).message}`);
|
||||
@@ -2567,9 +2627,7 @@ export class PL24FordLegacyService {
|
||||
const headers = {
|
||||
...baseHeaders,
|
||||
Cookie: cookieStr,
|
||||
Accept: isJson
|
||||
? "application/json,*/*"
|
||||
: "text/html,application/xhtml+xml,*/*;q=0.9",
|
||||
Accept: isJson ? "application/json,*/*" : "text/html,application/xhtml+xml,*/*;q=0.9",
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -2637,7 +2695,9 @@ export class PL24FordLegacyService {
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
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;
|
||||
}
|
||||
const info = (await infoRes.json()) as {
|
||||
@@ -2788,7 +2848,9 @@ export class PL24FordLegacyService {
|
||||
fromHtmlAttrs.push({ id: m[1], name: m[2] });
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -2807,7 +2869,8 @@ export class PL24FordLegacyService {
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (selectMatch) {
|
||||
const optionRegex = /<option[^>]*value=["']([^"']+)["'][^>]*>([\s\S]*?)<\/option>/gi;
|
||||
@@ -2851,12 +2914,24 @@ export class PL24FordLegacyService {
|
||||
// Log select/option elements for model selection hints
|
||||
const selectMatches = html.match(/<select[^>]*>[\s\S]*?<\/select>/gi);
|
||||
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
|
||||
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) {
|
||||
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 [];
|
||||
}
|
||||
@@ -2920,7 +2995,10 @@ export class PL24FordLegacyService {
|
||||
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
|
||||
this.authService.clearTokensForAccount(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));
|
||||
}
|
||||
|
||||
@@ -2939,7 +3017,9 @@ export class PL24FordLegacyService {
|
||||
return await response.text();
|
||||
} catch (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;
|
||||
}
|
||||
}
|
||||
@@ -2977,14 +3057,15 @@ export class PL24FordLegacyService {
|
||||
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
|
||||
this.authService.clearTokensForAccount(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);
|
||||
}
|
||||
|
||||
const jsessionId =
|
||||
response.headers
|
||||
.get("set-cookie")
|
||||
?.match(/JSESSIONID=([^;,\s]+)/)?.[1] ?? "";
|
||||
response.headers.get("set-cookie")?.match(/JSESSIONID=([^;,\s]+)/)?.[1] ?? "";
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn(`Ford legacy: HTTP ${response.status} for ${fullUrl}`);
|
||||
@@ -3207,7 +3288,10 @@ export class PL24FordLegacyService {
|
||||
.replace(/\xa0/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (text.length >= 3) { tdName = text; break; }
|
||||
if (text.length >= 3) {
|
||||
tdName = text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Caption may contain HTML-encoded content (e.g. <div class="restrictionBlock"...>)
|
||||
@@ -3215,8 +3299,12 @@ export class PL24FordLegacyService {
|
||||
const rawCaption = captionMatch?.[1] || "";
|
||||
const cleanCaption = rawCaption
|
||||
// Decode HTML entities first (so we can then strip decoded HTML tags)
|
||||
.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&")
|
||||
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, " ")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ")
|
||||
// Trim restriction block divs — everything from <div class="restriction to end
|
||||
.replace(/<div[^>]*restriction[^>]*>[\s\S]*/i, "")
|
||||
// Strip remaining HTML tags
|
||||
@@ -3294,14 +3382,17 @@ export class PL24FordLegacyService {
|
||||
const trParts = html.split(/<tr\s/);
|
||||
for (const segment of trParts) {
|
||||
if (!segment.includes("tc-data-row")) continue;
|
||||
if (!segment.includes('catalog=')) continue;
|
||||
if (!segment.includes("catalog=")) continue;
|
||||
|
||||
const catalogMatch = segment.match(/\bcatalog="([^"]+)"/);
|
||||
const captionMatch = segment.match(/\bcaption="([^"]+)"/);
|
||||
if (!catalogMatch || !captionMatch) continue;
|
||||
|
||||
const id = catalogMatch[1].trim();
|
||||
const name = captionMatch[1].replace(/&/g, "&").replace(/ /g, " ").trim();
|
||||
const name = captionMatch[1]
|
||||
.replace(/&/g, "&")
|
||||
.replace(/ /g, " ")
|
||||
.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
|
||||
@@ -3331,7 +3422,10 @@ export class PL24FordLegacyService {
|
||||
if (!identMatch || !captionMatch) continue;
|
||||
|
||||
const id = identMatch[1].trim();
|
||||
const name = captionMatch[1].replace(/&/g, "&").replace(/ /g, " ").trim();
|
||||
const name = captionMatch[1]
|
||||
.replace(/&/g, "&")
|
||||
.replace(/ /g, " ")
|
||||
.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
|
||||
@@ -3406,7 +3500,11 @@ export class PL24FordLegacyService {
|
||||
const captionMatch = segment.match(/\bcaption="([^"]+)"/);
|
||||
let name = "";
|
||||
if (captionMatch) {
|
||||
name = captionMatch[1].replace(/&/g, "&").replace(/ /g, " ").replace(/\xa0/g, " ").trim();
|
||||
name = captionMatch[1]
|
||||
.replace(/&/g, "&")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/\xa0/g, " ")
|
||||
.trim();
|
||||
} else {
|
||||
// Extract text from first non-empty <td> (strip inner tags)
|
||||
const tdPattern = /<td[^>]*>([\s\S]*?)<\/td>/g;
|
||||
@@ -3585,7 +3683,7 @@ export class PL24FordLegacyService {
|
||||
|
||||
if (vehicleData) {
|
||||
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;
|
||||
engineCode = vehicleData.engineCode || vehicleData.engine || null;
|
||||
engineType = vehicleData.engineDescription || vehicleData.engineType || null;
|
||||
@@ -3623,7 +3721,7 @@ export class PL24FordLegacyService {
|
||||
model = values[i] || model;
|
||||
}
|
||||
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"))) {
|
||||
engineCode = values[i] || engineCode;
|
||||
@@ -3744,30 +3842,45 @@ export class PL24FordLegacyService {
|
||||
for (const row of rows) {
|
||||
// Try to find OEM code column (various possible names)
|
||||
const oemCode = this.findColumnValue(row, [
|
||||
"partno", "part_no", "part number", "parca no", "parça no",
|
||||
"oem", "oemcode", "code", "kod", "no",
|
||||
"partno",
|
||||
"part_no",
|
||||
"part number",
|
||||
"parca no",
|
||||
"parça no",
|
||||
"oem",
|
||||
"oemcode",
|
||||
"code",
|
||||
"kod",
|
||||
"no",
|
||||
]);
|
||||
|
||||
if (!oemCode) continue;
|
||||
|
||||
const cleanOem = oemCode.replace(/\s+/g, "");
|
||||
const name = this.findColumnValue(row, [
|
||||
"description", "name", "aciklama", "açıklama", "tanim", "tanım",
|
||||
"descr", "part name", "parca adi", "parça adı",
|
||||
]) || "";
|
||||
const name =
|
||||
this.findColumnValue(row, [
|
||||
"description",
|
||||
"name",
|
||||
"aciklama",
|
||||
"açıklama",
|
||||
"tanim",
|
||||
"tanım",
|
||||
"descr",
|
||||
"part name",
|
||||
"parca adi",
|
||||
"parça adı",
|
||||
]) || "";
|
||||
|
||||
const positionCode = this.findColumnValue(row, [
|
||||
"pos", "position", "pozisyon", "no", "sira",
|
||||
]) || "";
|
||||
const positionCode =
|
||||
this.findColumnValue(row, ["pos", "position", "pozisyon", "no", "sira"]) || "";
|
||||
|
||||
const qtyStr = this.findColumnValue(row, [
|
||||
"qty", "quantity", "miktar", "adet", "count",
|
||||
]) || "";
|
||||
const quantity = parseInt(qtyStr, 10) || undefined;
|
||||
const qtyStr =
|
||||
this.findColumnValue(row, ["qty", "quantity", "miktar", "adet", "count"]) || "";
|
||||
const quantity = Number.parseInt(qtyStr, 10) || undefined;
|
||||
|
||||
const remark = this.findColumnValue(row, [
|
||||
"remark", "remarks", "note", "notes", "not", "aciklama2",
|
||||
]) || undefined;
|
||||
const remark =
|
||||
this.findColumnValue(row, ["remark", "remarks", "note", "notes", "not", "aciklama2"]) ||
|
||||
undefined;
|
||||
|
||||
parts.push({
|
||||
id: cleanOem,
|
||||
@@ -3828,10 +3941,7 @@ export class PL24FordLegacyService {
|
||||
/**
|
||||
* Find a column value in a row by trying multiple possible column names.
|
||||
*/
|
||||
private findColumnValue(
|
||||
row: Record<string, string>,
|
||||
possibleKeys: string[],
|
||||
): string | null {
|
||||
private findColumnValue(row: Record<string, string>, possibleKeys: string[]): string | null {
|
||||
// Try exact match first
|
||||
for (const key of possibleKeys) {
|
||||
if (row[key]) return row[key];
|
||||
@@ -3840,17 +3950,13 @@ export class PL24FordLegacyService {
|
||||
// Try case-insensitive match
|
||||
const rowKeys = Object.keys(row);
|
||||
for (const key of possibleKeys) {
|
||||
const found = rowKeys.find(
|
||||
(k) => k.toLowerCase() === key.toLowerCase(),
|
||||
);
|
||||
const found = rowKeys.find((k) => k.toLowerCase() === key.toLowerCase());
|
||||
if (found && row[found]) return row[found];
|
||||
}
|
||||
|
||||
// Try partial match
|
||||
for (const key of possibleKeys) {
|
||||
const found = rowKeys.find(
|
||||
(k) => k.toLowerCase().includes(key.toLowerCase()),
|
||||
);
|
||||
const found = rowKeys.find((k) => k.toLowerCase().includes(key.toLowerCase()));
|
||||
if (found && row[found]) return row[found];
|
||||
}
|
||||
|
||||
@@ -3872,12 +3978,36 @@ export class PL24FordLegacyService {
|
||||
if (!vin || vin.length < 10) return 0;
|
||||
const yearChar = vin.charAt(9).toUpperCase();
|
||||
const yearMap: Record<string, number> = {
|
||||
"1": 2001, "2": 2002, "3": 2003, "4": 2004, "5": 2005,
|
||||
"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,
|
||||
"1": 2001,
|
||||
"2": 2002,
|
||||
"3": 2003,
|
||||
"4": 2004,
|
||||
"5": 2005,
|
||||
"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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PL24Service } from "./pl24.service";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||
import { PL24Service } from "./pl24.service";
|
||||
|
||||
@Module({
|
||||
providers: [PL24Service, PL24AuthService, PL24FordLegacyService],
|
||||
|
||||
@@ -7,31 +7,31 @@
|
||||
|
||||
import { createHash } from "crypto";
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
BadRequestException,
|
||||
ServiceUnavailableException,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import { StorageService } from "../../storage/storage.service";
|
||||
import type { ConfigService } from "@nestjs/config";
|
||||
import type { RedisService } from "../../redis/redis.service";
|
||||
import type { StorageService } from "../../storage/storage.service";
|
||||
import type { PL24AuthService } from "./pl24-auth.service";
|
||||
import type { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||
import { PL24_DEFAULTS } from "./pl24.constants";
|
||||
import {
|
||||
type PL24DecodedCategory,
|
||||
type PL24DecodedVehicle,
|
||||
type PL24Hotspot,
|
||||
type PL24MainGroup,
|
||||
type PL24Part,
|
||||
type PL24PartsResponse,
|
||||
PL24_WMI_SERVICE_MAP,
|
||||
PL24DecodedVehicle,
|
||||
PL24DecodedCategory,
|
||||
PL24PartsResponse,
|
||||
PL24Part,
|
||||
PL24Hotspot,
|
||||
PL24MainGroup,
|
||||
SERVICE_TO_BRAND,
|
||||
getServiceApiPath,
|
||||
getServiceConfig,
|
||||
isP5Modern,
|
||||
isLegacyArchitecture,
|
||||
SERVICE_TO_BRAND,
|
||||
isP5Modern,
|
||||
} from "./pl24.types";
|
||||
|
||||
@Injectable()
|
||||
@@ -48,10 +48,7 @@ export class PL24Service {
|
||||
private redis: RedisService,
|
||||
private storage: StorageService,
|
||||
) {
|
||||
this.baseUrl = this.configService.get<string>(
|
||||
"pl24.baseUrl",
|
||||
"https://www.partslink24.com",
|
||||
);
|
||||
this.baseUrl = this.configService.get<string>("pl24.baseUrl", "https://www.partslink24.com");
|
||||
this.timeout = 30000;
|
||||
this.language = "tr";
|
||||
}
|
||||
@@ -86,15 +83,11 @@ export class PL24Service {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Decoding VIN: ${cleanVin} with service: ${serviceName}`,
|
||||
);
|
||||
this.logger.log(`Decoding VIN: ${cleanVin} with service: ${serviceName}`);
|
||||
|
||||
const catalogConfig = getServiceConfig(serviceName);
|
||||
if (!catalogConfig) {
|
||||
throw new BadRequestException(
|
||||
`Bu marka PL24'te desteklenmiyor: ${serviceName}`,
|
||||
);
|
||||
throw new BadRequestException(`Bu marka PL24'te desteklenmiyor: ${serviceName}`);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -124,33 +117,21 @@ export class PL24Service {
|
||||
const vinData = responseData.data || responseData;
|
||||
|
||||
if (responseData.error || responseData.errorCode) {
|
||||
this.logger.warn(
|
||||
`VIN decode error: ${responseData.error || responseData.errorCode}`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
responseData.error || "VIN sorgulanamadi",
|
||||
);
|
||||
this.logger.warn(`VIN decode error: ${responseData.error || responseData.errorCode}`);
|
||||
throw new BadRequestException(responseData.error || "VIN sorgulanamadi");
|
||||
}
|
||||
|
||||
if (vinData.resultStatus !== "VEHICLE_IDENTIFIED") {
|
||||
const message =
|
||||
(responseData.messages as string[])?.[0] || "VIN bulunamadi";
|
||||
const message = (responseData.messages as string[])?.[0] || "VIN bulunamadi";
|
||||
throw new NotFoundException(message);
|
||||
}
|
||||
|
||||
// Parse vehicle info
|
||||
const vehicle = this.parseVehicleResponse(
|
||||
cleanVin,
|
||||
vinData,
|
||||
serviceName,
|
||||
);
|
||||
const vehicle = this.parseVehicleResponse(cleanVin, vinData, serviceName);
|
||||
|
||||
// Fetch main groups (categories)
|
||||
const mainGroupsPath = vehicle.catalogInfo?.mainGroupsPath || "";
|
||||
const categories = await this.fetchMainGroupsByPath(
|
||||
mainGroupsPath,
|
||||
headers,
|
||||
);
|
||||
const categories = await this.fetchMainGroupsByPath(mainGroupsPath, headers);
|
||||
|
||||
const result: PL24DecodedVehicle = {
|
||||
...vehicle,
|
||||
@@ -173,15 +154,11 @@ export class PL24Service {
|
||||
}
|
||||
|
||||
if (err.name === "TimeoutError") {
|
||||
throw new ServiceUnavailableException(
|
||||
"PL24 zaman asimina ugradi. Lutfen tekrar deneyin.",
|
||||
);
|
||||
throw new ServiceUnavailableException("PL24 zaman asimina ugradi. Lutfen tekrar deneyin.");
|
||||
}
|
||||
|
||||
this.logger.error(`VIN decode error: ${err.message}`, err.stack);
|
||||
throw new ServiceUnavailableException(
|
||||
"VIN sorgulama sirasinda bir hata olustu",
|
||||
);
|
||||
throw new ServiceUnavailableException("VIN sorgulama sirasinda bir hata olustu");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,8 +204,7 @@ export class PL24Service {
|
||||
|
||||
const data = (await response.json()) as Record<string, any>;
|
||||
|
||||
const crumbs =
|
||||
(data.crumbs as Array<{ name: string }>) || [];
|
||||
const crumbs = (data.crumbs as Array<{ name: string }>) || [];
|
||||
const groupName = crumbs[crumbs.length - 1]?.name || "";
|
||||
const imageData = this.extractImageData(data);
|
||||
|
||||
@@ -310,14 +286,13 @@ export class PL24Service {
|
||||
|
||||
const data = (await response.json()) as Record<string, any>;
|
||||
|
||||
const crumbs =
|
||||
(data.crumbs as Array<{ name: string }>) || [];
|
||||
const crumbs = (data.crumbs as Array<{ name: string }>) || [];
|
||||
const groupName = crumbs[crumbs.length - 1]?.name || "";
|
||||
const illustrationId =
|
||||
linkPath.match(/illustrationId=(\d+)/)?.[1] || "";
|
||||
const illustrationId = linkPath.match(/illustrationId=(\d+)/)?.[1] || "";
|
||||
// Temporary: log raw images field to diagnose 404 issue
|
||||
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);
|
||||
|
||||
let parts = this.parsePartsResponse(data);
|
||||
@@ -325,9 +300,7 @@ export class PL24Service {
|
||||
// Filter parts by position if this is a position-level request
|
||||
if (positionFilter && parts.length > 0) {
|
||||
const filtered = parts.filter(
|
||||
(p) =>
|
||||
p.positionCode === positionFilter ||
|
||||
p.positionCode === `(${positionFilter})`,
|
||||
(p) => p.positionCode === positionFilter || p.positionCode === `(${positionFilter})`,
|
||||
);
|
||||
if (filtered.length > 0) parts = filtered;
|
||||
}
|
||||
@@ -425,7 +398,13 @@ export class PL24Service {
|
||||
}
|
||||
// PSA illustrations dispatch (/psa/.../json-illustrations.action → illustrations)
|
||||
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}`);
|
||||
@@ -502,7 +481,14 @@ export class PL24Service {
|
||||
mode: string,
|
||||
upds: 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,
|
||||
upds: 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,
|
||||
catCode?: string,
|
||||
): 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.)
|
||||
const fetchedMainGroups = restrictionPath.includes("/mainGroup");
|
||||
const firstWid = String(records[0]?.link?.wid ?? "");
|
||||
const partsWids = ["subGroupTable", "subGroupNodeTable", "partsListTable", "mainGroupNodeTable"];
|
||||
const widsIndicateParts = partsWids.some((w) => firstWid.includes(w) || firstWid.includes("Group"));
|
||||
const partsWids = [
|
||||
"subGroupTable",
|
||||
"subGroupNodeTable",
|
||||
"partsListTable",
|
||||
"mainGroupNodeTable",
|
||||
];
|
||||
const widsIndicateParts = partsWids.some(
|
||||
(w) => firstWid.includes(w) || firstWid.includes("Group"),
|
||||
);
|
||||
|
||||
const isFinal = fetchedMainGroups || widsIndicateParts;
|
||||
if (isFinal) {
|
||||
@@ -709,9 +719,7 @@ export class PL24Service {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn(
|
||||
`Failed to download image: HTTP ${response.status}`,
|
||||
);
|
||||
this.logger.warn(`Failed to download image: HTTP ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -725,10 +733,8 @@ export class PL24Service {
|
||||
const jsonData = (await response.json()) as Record<string, any>;
|
||||
if (jsonData.image && typeof jsonData.image === "string") {
|
||||
buffer = Buffer.from(jsonData.image, "base64");
|
||||
width =
|
||||
jsonData.originalWidth || jsonData.scaledWidth || null;
|
||||
height =
|
||||
jsonData.originalHeight || jsonData.scaledHeight || null;
|
||||
width = jsonData.originalWidth || jsonData.scaledWidth || null;
|
||||
height = jsonData.originalHeight || jsonData.scaledHeight || null;
|
||||
|
||||
if (Array.isArray(jsonData.hotspots)) {
|
||||
hotspots = jsonData.hotspots.map(
|
||||
@@ -763,11 +769,7 @@ export class PL24Service {
|
||||
|
||||
// Upload to MinIO
|
||||
const minioKey = `schemas/${imageId}.png`;
|
||||
const uploadedUrl = await this.storage.upload(
|
||||
minioKey,
|
||||
buffer,
|
||||
"image/png",
|
||||
);
|
||||
const uploadedUrl = await this.storage.upload(minioKey, buffer, "image/png");
|
||||
|
||||
const result = {
|
||||
imageUrl: uploadedUrl,
|
||||
@@ -780,9 +782,7 @@ export class PL24Service {
|
||||
return result;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(
|
||||
`Failed to download schema image: ${err.message}`,
|
||||
);
|
||||
this.logger.error(`Failed to download schema image: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -945,12 +945,36 @@ export class PL24Service {
|
||||
if (!vin || vin.length < 10) return null;
|
||||
const yearChar = vin.charAt(9).toUpperCase();
|
||||
const yearMap: Record<string, number> = {
|
||||
"1": 2001, "2": 2002, "3": 2003, "4": 2004, "5": 2005,
|
||||
"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,
|
||||
"1": 2001,
|
||||
"2": 2002,
|
||||
"3": 2003,
|
||||
"4": 2004,
|
||||
"5": 2005,
|
||||
"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;
|
||||
}
|
||||
@@ -966,21 +990,15 @@ export class PL24Service {
|
||||
serviceName: string,
|
||||
): Omit<PL24DecodedVehicle, "categories"> {
|
||||
const segments =
|
||||
(data.segments as Record<
|
||||
string,
|
||||
{ records?: Array<{ values: Record<string, string> }> }
|
||||
>) || {};
|
||||
(data.segments as Record<string, { records?: Array<{ values: Record<string, string> }> }>) ||
|
||||
{};
|
||||
const vinfoRecords = segments.vinfoBasic?.records || [];
|
||||
|
||||
const vehicleData: Record<string, string> = {};
|
||||
for (const record of vinfoRecords) {
|
||||
if (record.values) {
|
||||
const key =
|
||||
record.values.description
|
||||
?.toLowerCase()
|
||||
.replace(/[\s\/]+/g, "_") || "";
|
||||
vehicleData[key] =
|
||||
record.values.value?.replace(/\r?\n/g, " ").trim() || "";
|
||||
const key = record.values.description?.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
|
||||
let mainGroupsPath =
|
||||
(data.link as Record<string, string>)?.path || "";
|
||||
let mainGroupsPath = (data.link as Record<string, string>)?.path || "";
|
||||
|
||||
// Mercedes: convert vin_scope to vin_main
|
||||
if (
|
||||
this.isDaimlerService(serviceName) &&
|
||||
mainGroupsPath.includes("vin_scope")
|
||||
) {
|
||||
if (this.isDaimlerService(serviceName) && mainGroupsPath.includes("vin_scope")) {
|
||||
mainGroupsPath = mainGroupsPath
|
||||
.replace("/groups/vin_scope", "/groups/vin_main")
|
||||
.replace("?", "?scope=F&subAggregate=n-r&");
|
||||
@@ -1024,35 +1038,27 @@ export class PL24Service {
|
||||
const transmissionCode = lookup("şanzıman_kodu", "sanzıman_kodu", "transmission_code");
|
||||
|
||||
// Build body type from prNr K8* (Kaporta formları)
|
||||
const bodyType = Object.entries(prNrByCode).find(
|
||||
([code]) => code.startsWith("K8"),
|
||||
)?.[1] || null;
|
||||
const bodyType =
|
||||
Object.entries(prNrByCode).find(([code]) => code.startsWith("K8"))?.[1] || null;
|
||||
|
||||
// Engine description from prNr D3* (Motor nitelikleri)
|
||||
const engineDesc = Object.entries(prNrByCode).find(
|
||||
([code]) => code.startsWith("D3"),
|
||||
)?.[1] || null;
|
||||
const engineDesc =
|
||||
Object.entries(prNrByCode).find(([code]) => code.startsWith("D3"))?.[1] || null;
|
||||
|
||||
// Transmission type from prNr G0* (Şanzıman nitelikleri)
|
||||
const transmissionDesc = Object.entries(prNrByCode).find(
|
||||
([code]) => code.startsWith("G0"),
|
||||
)?.[1] || null;
|
||||
const transmissionDesc =
|
||||
Object.entries(prNrByCode).find(([code]) => code.startsWith("G0"))?.[1] || null;
|
||||
|
||||
// Drive type from prNr 1X* (Tahrik türü)
|
||||
const driveType = Object.entries(prNrByCode).find(
|
||||
([code]) => code.startsWith("1X"),
|
||||
)?.[1] || lookup("aks_tahrigi_tanimi", "axle_drive");
|
||||
const driveType =
|
||||
Object.entries(prNrByCode).find(([code]) => code.startsWith("1X"))?.[1] ||
|
||||
lookup("aks_tahrigi_tanimi", "axle_drive");
|
||||
|
||||
return {
|
||||
brand: SERVICE_TO_BRAND[serviceName] || serviceName.replace("_parts", ""),
|
||||
model:
|
||||
lookup("model")?.trim() ||
|
||||
(data.description as string)?.split(" - ")[0]?.trim() ||
|
||||
"",
|
||||
model: lookup("model")?.trim() || (data.description as string)?.split(" - ")[0]?.trim() || "",
|
||||
year:
|
||||
parseInt(lookup("model_yili", "year") || "", 10) ||
|
||||
this.getYearFromVin(vin) ||
|
||||
0,
|
||||
Number.parseInt(lookup("model_yili", "year") || "", 10) || this.getYearFromVin(vin) || 0,
|
||||
series: lookup("satis_tipi", "sales_type"),
|
||||
bodyType,
|
||||
engineCode: engineCode || (engineDesc ? engineDesc.split("/")[0]?.trim() : null),
|
||||
@@ -1097,9 +1103,7 @@ export class PL24Service {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn(
|
||||
`Failed to fetch main groups: HTTP ${response.status}`,
|
||||
);
|
||||
this.logger.warn(`Failed to fetch main groups: HTTP ${response.status}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1133,9 +1137,7 @@ export class PL24Service {
|
||||
}
|
||||
|
||||
// Filter out section headers
|
||||
const categoryRecords = records.filter(
|
||||
(record) => record.characteristic !== "sectionrow",
|
||||
);
|
||||
const categoryRecords = records.filter((record) => record.characteristic !== "sectionrow");
|
||||
|
||||
return categoryRecords.map((record) => {
|
||||
const values = (record.values as Record<string, string>) || {};
|
||||
@@ -1246,9 +1248,7 @@ export class PL24Service {
|
||||
.replace(/\\r?\\n/g, " ")
|
||||
.trim();
|
||||
|
||||
const separatorMatch = rawCaption.match(
|
||||
/^[\w_]+\s*[–—-]\s*(.+)$/,
|
||||
);
|
||||
const separatorMatch = rawCaption.match(/^[\w_]+\s*[–—-]\s*(.+)$/);
|
||||
let name = separatorMatch ? separatorMatch[1].trim() : rawCaption;
|
||||
|
||||
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
|
||||
const recordLinkPath = (link.path as string) || undefined;
|
||||
const constructedPath = !recordLinkPath && bomBasePath
|
||||
? `${bomBasePath}${record.id}`
|
||||
: recordLinkPath;
|
||||
const constructedPath =
|
||||
!recordLinkPath && bomBasePath ? `${bomBasePath}${record.id}` : recordLinkPath;
|
||||
|
||||
return {
|
||||
id: String(record.id || ""),
|
||||
@@ -1293,8 +1292,7 @@ export class PL24Service {
|
||||
*/
|
||||
private parsePartsResponse(response: unknown): PL24Part[] {
|
||||
const responseData = response as Record<string, unknown>;
|
||||
const data =
|
||||
(responseData.data as Record<string, unknown>) || responseData;
|
||||
const data = (responseData.data as Record<string, unknown>) || responseData;
|
||||
|
||||
let records: Array<Record<string, unknown>> = [];
|
||||
if (Array.isArray(data.records)) {
|
||||
@@ -1314,26 +1312,18 @@ export class PL24Service {
|
||||
return partRecords.map((part) => {
|
||||
const values = (part.values as Record<string, string>) || {};
|
||||
|
||||
const formattedPartNo = (
|
||||
(part.partno as string) ||
|
||||
values.partno ||
|
||||
""
|
||||
).trim();
|
||||
const formattedPartNo = ((part.partno as string) || values.partno || "").trim();
|
||||
const cleanPartNo = formattedPartNo.replace(/\s+/g, "");
|
||||
|
||||
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 modelCodes = values.modelDescription?.trim() || undefined;
|
||||
|
||||
let superseded:
|
||||
| { oldCode: string; newCode: string }
|
||||
| undefined;
|
||||
const supersededByValue =
|
||||
(part.supersededBy as string) || values.supersededBy || "";
|
||||
const supersedesValue =
|
||||
(part.supersedes as string) || values.supersedes || "";
|
||||
let superseded: { oldCode: string; newCode: string } | undefined;
|
||||
const supersededByValue = (part.supersededBy as string) || values.supersededBy || "";
|
||||
const supersedesValue = (part.supersedes as string) || values.supersedes || "";
|
||||
if (supersededByValue || supersedesValue) {
|
||||
superseded = {
|
||||
oldCode: supersedesValue ? cleanPartNo : "",
|
||||
@@ -1343,22 +1333,23 @@ export class PL24Service {
|
||||
|
||||
// Price extraction (de account with German market returns EUR prices in BOM)
|
||||
const priceRaw = String(
|
||||
values.listPrice || values.netPrice || values.price || values.grossPrice || values.retailPrice || "",
|
||||
values.listPrice ||
|
||||
values.netPrice ||
|
||||
values.price ||
|
||||
values.grossPrice ||
|
||||
values.retailPrice ||
|
||||
"",
|
||||
).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 currency = price !== undefined ? (values.currency || "EUR") : undefined;
|
||||
const currency = price !== undefined ? values.currency || "EUR" : undefined;
|
||||
|
||||
return {
|
||||
id: String(part.id || ""),
|
||||
oemCode: cleanPartNo,
|
||||
formattedPartNo: formattedPartNo || undefined,
|
||||
name: String(
|
||||
part.description || values.description || "",
|
||||
),
|
||||
description: String(
|
||||
part.description || values.description || "",
|
||||
),
|
||||
name: String(part.description || values.description || ""),
|
||||
description: String(part.description || values.description || ""),
|
||||
remark,
|
||||
quantity,
|
||||
positionCode: String(part.pos || values.pos || ""),
|
||||
@@ -1409,8 +1400,7 @@ export class PL24Service {
|
||||
|
||||
private extractIllustrationUrl(response: unknown): string | null {
|
||||
const responseData = response as Record<string, unknown>;
|
||||
const data =
|
||||
(responseData.data as Record<string, unknown>) || responseData;
|
||||
const data = (responseData.data as Record<string, unknown>) || responseData;
|
||||
const images =
|
||||
(data.images as Array<{
|
||||
id: string;
|
||||
@@ -1418,8 +1408,7 @@ export class PL24Service {
|
||||
name: string;
|
||||
}>) || [];
|
||||
|
||||
const defaultImage =
|
||||
images.find((img) => img.id === "_DFLT_") || images[0];
|
||||
const defaultImage = images.find((img) => img.id === "_DFLT_") || images[0];
|
||||
if (defaultImage?.uri) {
|
||||
return `${this.baseUrl}${defaultImage.uri}`;
|
||||
}
|
||||
@@ -1432,8 +1421,7 @@ export class PL24Service {
|
||||
schemaHeight?: number;
|
||||
} {
|
||||
const responseData = response as Record<string, unknown>;
|
||||
const data =
|
||||
(responseData.data as Record<string, unknown>) || responseData;
|
||||
const data = (responseData.data as Record<string, unknown>) || responseData;
|
||||
const images =
|
||||
(data.images as Array<{
|
||||
id?: string;
|
||||
@@ -1443,18 +1431,14 @@ export class PL24Service {
|
||||
hotspots?: PL24Hotspot[];
|
||||
}>) || [];
|
||||
|
||||
const defaultImage =
|
||||
images.find((img) => img.id === "_DFLT_") || images[0];
|
||||
const defaultImage = images.find((img) => img.id === "_DFLT_") || images[0];
|
||||
|
||||
if (!defaultImage) {
|
||||
return { hotspots: [] };
|
||||
}
|
||||
|
||||
const hotspots: PL24Hotspot[] = [];
|
||||
if (
|
||||
defaultImage.hotspots &&
|
||||
Array.isArray(defaultImage.hotspots)
|
||||
) {
|
||||
if (defaultImage.hotspots && Array.isArray(defaultImage.hotspots)) {
|
||||
for (const hs of defaultImage.hotspots) {
|
||||
if (hs.key && hs.areas) {
|
||||
hotspots.push({ key: hs.key, areas: hs.areas });
|
||||
@@ -1475,9 +1459,7 @@ export class PL24Service {
|
||||
const standardMatch = imageUrl.match(/\/images\/(\d+)\?/);
|
||||
if (standardMatch) return standardMatch[1];
|
||||
|
||||
const tiffMatch = imageUrl.match(
|
||||
/\/tiffimages\/(?:[^/]+\/)+([a-zA-Z0-9_-]+)\.\w+/,
|
||||
);
|
||||
const tiffMatch = imageUrl.match(/\/tiffimages\/(?:[^/]+\/)+([a-zA-Z0-9_-]+)\.\w+/);
|
||||
if (tiffMatch) return tiffMatch[1];
|
||||
|
||||
const mercedesMatch = imageUrl.match(/[?&]illu=([a-zA-Z0-9_]+)/);
|
||||
@@ -1533,21 +1515,15 @@ export class PL24Service {
|
||||
}
|
||||
|
||||
private isDaimlerService(serviceName: string): boolean {
|
||||
return (
|
||||
serviceName.startsWith("mercedes") || serviceName === "smart_parts"
|
||||
);
|
||||
return serviceName.startsWith("mercedes") || serviceName === "smart_parts";
|
||||
}
|
||||
|
||||
private isDaimlerSubPath(linkPath: string): boolean {
|
||||
return (
|
||||
linkPath.includes("/p5daimler/") && linkPath.includes("vin_sub")
|
||||
);
|
||||
return linkPath.includes("/p5daimler/") && linkPath.includes("vin_sub");
|
||||
}
|
||||
|
||||
private isJlrIllusPath(linkPath: string): boolean {
|
||||
return (
|
||||
linkPath.includes("/p5jlr/") && linkPath.includes("vin_illus")
|
||||
);
|
||||
return linkPath.includes("/p5jlr/") && linkPath.includes("vin_illus");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1570,17 +1546,15 @@ export class PL24Service {
|
||||
});
|
||||
|
||||
if (!subResponse.ok) {
|
||||
throw new Error(
|
||||
`HTTP ${subResponse.status}: ${subResponse.statusText}`,
|
||||
);
|
||||
throw new Error(`HTTP ${subResponse.status}: ${subResponse.statusText}`);
|
||||
}
|
||||
|
||||
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;
|
||||
link?: { path?: string };
|
||||
values?: Record<string, string>;
|
||||
}>);
|
||||
}>;
|
||||
|
||||
if (records.length === 0) {
|
||||
return {
|
||||
@@ -1610,9 +1584,7 @@ export class PL24Service {
|
||||
});
|
||||
|
||||
if (!partsResponse.ok) {
|
||||
throw new Error(
|
||||
`Parts HTTP ${partsResponse.status}: ${partsResponse.statusText}`,
|
||||
);
|
||||
throw new Error(`Parts HTTP ${partsResponse.status}: ${partsResponse.statusText}`);
|
||||
}
|
||||
|
||||
const partsData = (await partsResponse.json()) as Record<string, any>;
|
||||
@@ -1624,17 +1596,13 @@ export class PL24Service {
|
||||
let schemaHeight: number | undefined;
|
||||
|
||||
if (schemaImageUrl) {
|
||||
const imageData = await this.fetchDaimlerImageWithHotspots(
|
||||
schemaImageUrl,
|
||||
serviceName,
|
||||
);
|
||||
const imageData = await this.fetchDaimlerImageWithHotspots(schemaImageUrl, serviceName);
|
||||
hotspots = imageData.hotspots;
|
||||
schemaWidth = imageData.width;
|
||||
schemaHeight = imageData.height;
|
||||
}
|
||||
|
||||
const crumbs =
|
||||
(partsData.crumbs as Array<{ name: string }>) || [];
|
||||
const crumbs = (partsData.crumbs as Array<{ name: string }>) || [];
|
||||
const groupName = crumbs[crumbs.length - 1]?.name || "";
|
||||
|
||||
return {
|
||||
@@ -1650,16 +1618,14 @@ export class PL24Service {
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`Mercedes parts fetch error: ${err.message}`);
|
||||
throw new ServiceUnavailableException(
|
||||
"Mercedes parca listesi alinamadi",
|
||||
);
|
||||
throw new ServiceUnavailableException("Mercedes parca listesi alinamadi");
|
||||
}
|
||||
}
|
||||
|
||||
private parseDaimlerPartsResponse(response: unknown): PL24Part[] {
|
||||
const responseData = response 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;
|
||||
partno?: string;
|
||||
description?: string;
|
||||
@@ -1674,14 +1640,11 @@ export class PL24Service {
|
||||
qty?: string;
|
||||
pos?: string;
|
||||
};
|
||||
}>);
|
||||
}>;
|
||||
|
||||
return records.map((record) => {
|
||||
const values = record.values || {};
|
||||
const oemCode = (record.partno || values.partno || "").replace(
|
||||
/\s+/g,
|
||||
"",
|
||||
);
|
||||
const oemCode = (record.partno || values.partno || "").replace(/\s+/g, "");
|
||||
const formattedPartNo = record.partno || values.partno || "";
|
||||
|
||||
return {
|
||||
@@ -1692,7 +1655,7 @@ export class PL24Service {
|
||||
description: values.remark || undefined,
|
||||
positionCode: record.pos || values.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,
|
||||
presel: !!record.presel,
|
||||
};
|
||||
@@ -1704,19 +1667,17 @@ export class PL24Service {
|
||||
} {
|
||||
const responseData = response 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;
|
||||
uri?: string;
|
||||
}>);
|
||||
}>;
|
||||
|
||||
if (images.length === 0) return {};
|
||||
|
||||
const imageUri = images[0].uri;
|
||||
if (!imageUri) return {};
|
||||
|
||||
const schemaImageUrl = imageUri.startsWith("http")
|
||||
? imageUri
|
||||
: `${this.baseUrl}${imageUri}`;
|
||||
const schemaImageUrl = imageUri.startsWith("http") ? imageUri : `${this.baseUrl}${imageUri}`;
|
||||
|
||||
return { schemaImageUrl };
|
||||
}
|
||||
@@ -1773,21 +1734,15 @@ export class PL24Service {
|
||||
|
||||
return {
|
||||
hotspots,
|
||||
width:
|
||||
jsonData.originalWidth || jsonData.scaledWidth || undefined,
|
||||
height:
|
||||
jsonData.originalHeight ||
|
||||
jsonData.scaledHeight ||
|
||||
undefined,
|
||||
width: jsonData.originalWidth || jsonData.scaledWidth || undefined,
|
||||
height: jsonData.originalHeight || jsonData.scaledHeight || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { hotspots: [] };
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(
|
||||
`Mercedes: Failed to fetch image hotspots: ${err.message}`,
|
||||
);
|
||||
this.logger.error(`Mercedes: Failed to fetch image hotspots: ${err.message}`);
|
||||
return { hotspots: [] };
|
||||
}
|
||||
}
|
||||
@@ -1812,9 +1767,7 @@ export class PL24Service {
|
||||
});
|
||||
|
||||
if (!illusResponse.ok) {
|
||||
throw new Error(
|
||||
`HTTP ${illusResponse.status}: ${illusResponse.statusText}`,
|
||||
);
|
||||
throw new Error(`HTTP ${illusResponse.status}: ${illusResponse.statusText}`);
|
||||
}
|
||||
|
||||
const illusData = (await illusResponse.json()) as Record<string, any>;
|
||||
@@ -1830,9 +1783,7 @@ export class PL24Service {
|
||||
};
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(
|
||||
illusPath.split("?")[1] || "",
|
||||
);
|
||||
const urlParams = new URLSearchParams(illusPath.split("?")[1] || "");
|
||||
const mg = urlParams.get("mg") || "";
|
||||
const sg = urlParams.get("sg") || "";
|
||||
const vin = urlParams.get("vin") || "";
|
||||
@@ -1849,22 +1800,17 @@ export class PL24Service {
|
||||
});
|
||||
|
||||
if (!bomResponse.ok) {
|
||||
throw new Error(
|
||||
`HTTP ${bomResponse.status}: ${bomResponse.statusText}`,
|
||||
);
|
||||
throw new Error(`HTTP ${bomResponse.status}: ${bomResponse.statusText}`);
|
||||
}
|
||||
|
||||
const bomData = (await bomResponse.json()) as Record<string, any>;
|
||||
|
||||
const crumbs =
|
||||
(bomData.crumbs as Array<{ name: string }>) || [];
|
||||
const groupName =
|
||||
crumbs[crumbs.length - 1]?.name || firstIllus.name || "";
|
||||
const crumbs = (bomData.crumbs as Array<{ name: string }>) || [];
|
||||
const groupName = crumbs[crumbs.length - 1]?.name || firstIllus.name || "";
|
||||
|
||||
const parts = this.parsePartsResponse(bomData);
|
||||
const schemaImageUrl = this.extractIllustrationUrl(bomData);
|
||||
const { hotspots, schemaWidth, schemaHeight } =
|
||||
this.extractImageData(bomData);
|
||||
const { hotspots, schemaWidth, schemaHeight } = this.extractImageData(bomData);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -1879,9 +1825,7 @@ export class PL24Service {
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`JLR fetch parts error: ${err.message}`);
|
||||
throw new ServiceUnavailableException(
|
||||
"Parca listesi alinamadi (JLR)",
|
||||
);
|
||||
throw new ServiceUnavailableException("Parca listesi alinamadi (JLR)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1935,9 +1879,7 @@ export class PL24Service {
|
||||
await this.authService.authorizeServiceForAccount(serviceName, "de");
|
||||
headers = await this.authService.buildAuthHeadersForAccount("de", serviceName);
|
||||
} catch {
|
||||
this.logger.warn(
|
||||
`fetchVehicleList: de auth failed for ${serviceName}, using main token`,
|
||||
);
|
||||
this.logger.warn(`fetchVehicleList: de auth failed for ${serviceName}, using main token`);
|
||||
headers = await this.authService.buildAuthHeaders();
|
||||
}
|
||||
|
||||
@@ -1946,15 +1888,15 @@ export class PL24Service {
|
||||
// Discovered via Playwright explorer (scripts/pl24-catalog-explorer.js → docs/pl24-catalog/*.md)
|
||||
// Each P5 backend uses a different initial model listing endpoint
|
||||
const BACKEND_MODEL_PATH: Record<string, string> = {
|
||||
p5vwag: "/extern/vehicle/modelfamilies", // VW, Audi, Skoda, SEAT, Cupra, Porsche, Bentley
|
||||
p5bmw: "/extern/vehicle/models", // BMW, MINI, Motorrad
|
||||
p5daimler: "/extern/vehicle/scope", // Mercedes-Benz, smart
|
||||
p5renault: "/extern/vehicle/catalogs", // Renault, Dacia, Alpine
|
||||
p5jlr: "/extern/vehicle/models", // Jaguar, Land Rover
|
||||
p5toyota: "/extern/vehicle/modelFamilies", // Toyota, Lexus (capital F)
|
||||
p5vwag: "/extern/vehicle/modelfamilies", // VW, Audi, Skoda, SEAT, Cupra, Porsche, Bentley
|
||||
p5bmw: "/extern/vehicle/models", // BMW, MINI, Motorrad
|
||||
p5daimler: "/extern/vehicle/scope", // Mercedes-Benz, smart
|
||||
p5renault: "/extern/vehicle/catalogs", // Renault, Dacia, Alpine
|
||||
p5jlr: "/extern/vehicle/models", // Jaguar, Land Rover
|
||||
p5toyota: "/extern/vehicle/modelFamilies", // Toyota, Lexus (capital F)
|
||||
p5mitsubishi: "/extern/vehicles/vehiclesOverview", // Mitsubishi
|
||||
p5suzuki: "/extern/vehicle/modelFamilies", // Suzuki
|
||||
p5man: "/extern/model/categories", // MAN trucks
|
||||
p5suzuki: "/extern/vehicle/modelFamilies", // Suzuki
|
||||
p5man: "/extern/model/categories", // MAN trucks
|
||||
};
|
||||
|
||||
// catalogBase is like "/p5vwag" — strip leading slash for map lookup
|
||||
@@ -2049,7 +1991,12 @@ export class PL24Service {
|
||||
const vehicleId = String(r.id || r.vehicleId || r.vid || "");
|
||||
// modelfamilies uses values.caption; older formats use values.model / r.description
|
||||
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 || {};
|
||||
|
||||
return {
|
||||
@@ -2107,7 +2054,10 @@ export class PL24Service {
|
||||
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) {
|
||||
results[endpoint] = { error: (err as Error).message };
|
||||
}
|
||||
@@ -2124,8 +2074,7 @@ export class PL24Service {
|
||||
response: unknown,
|
||||
): Array<{ btnr: number; name: string; code: string }> {
|
||||
const responseData = response as Record<string, unknown>;
|
||||
const data =
|
||||
(responseData.data as Record<string, unknown>) || responseData;
|
||||
const data = (responseData.data as Record<string, unknown>) || responseData;
|
||||
|
||||
let records: Array<Record<string, unknown>> = [];
|
||||
if (Array.isArray(data.records)) {
|
||||
@@ -2135,8 +2084,7 @@ export class PL24Service {
|
||||
}
|
||||
|
||||
const availableRecords = records.filter(
|
||||
(record) =>
|
||||
!record.unavailable && record.characteristic !== "sectionrow",
|
||||
(record) => !record.unavailable && record.characteristic !== "sectionrow",
|
||||
);
|
||||
|
||||
return availableRecords
|
||||
@@ -2146,7 +2094,7 @@ export class PL24Service {
|
||||
|
||||
const linkPath = (link.path as string) || "";
|
||||
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 =
|
||||
values.captions ||
|
||||
@@ -2157,10 +2105,7 @@ export class PL24Service {
|
||||
"";
|
||||
|
||||
const code =
|
||||
values.illustrationNumber ||
|
||||
values.subgroup ||
|
||||
values.code ||
|
||||
String(record.id || "");
|
||||
values.illustrationNumber || values.subgroup || values.code || String(record.id || "");
|
||||
|
||||
return { btnr, name, code };
|
||||
})
|
||||
|
||||
@@ -25,12 +25,7 @@ export interface PL24LoginRequest {
|
||||
}
|
||||
|
||||
export interface PL24LoginResponse {
|
||||
status:
|
||||
| "OK"
|
||||
| "USER_ALREADY_LOGGED_IN"
|
||||
| "INVALID_CREDENTIALS"
|
||||
| "ERROR"
|
||||
| null;
|
||||
status: "OK" | "USER_ALREADY_LOGGED_IN" | "INVALID_CREDENTIALS" | "ERROR" | null;
|
||||
message?: string;
|
||||
token?: {
|
||||
access_token: string;
|
||||
@@ -388,9 +383,7 @@ export function getServiceApiPath(serviceName: string): string {
|
||||
return config?.apiPath || "/p5vwag";
|
||||
}
|
||||
|
||||
export function getServiceConfig(
|
||||
serviceName: string,
|
||||
): PL24CatalogConfig | null {
|
||||
export function getServiceConfig(serviceName: string): PL24CatalogConfig | null {
|
||||
return PL24_SERVICE_CATALOGS[serviceName] || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConnectionOptions } from "bullmq";
|
||||
import type { ConnectionOptions } from "bullmq";
|
||||
import { isOtelEnabled } from "../telemetry";
|
||||
|
||||
export function getBullConnection(): ConnectionOptions {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Module, OnModuleInit, Inject, OnModuleDestroy } from "@nestjs/common";
|
||||
import { 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 { Inject, Module, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
|
||||
import type { Queue } from "bullmq";
|
||||
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({
|
||||
imports: [CategoriesModule],
|
||||
|
||||
@@ -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.
|
||||
@@ -15,10 +15,7 @@ export class RateLimitError extends Error {
|
||||
* Check if a user is actively using the source.
|
||||
* Throws RateLimitError (1min retry) if cooldown key exists.
|
||||
*/
|
||||
export async function checkCooldown(
|
||||
redis: RedisService,
|
||||
source: string,
|
||||
): Promise<void> {
|
||||
export async function checkCooldown(redis: RedisService, source: string): Promise<void> {
|
||||
const key = `prefetch:activity:${source}`;
|
||||
const exists = await redis.exists(key);
|
||||
if (exists) {
|
||||
@@ -39,7 +36,7 @@ export function checkTimeWindow(source: string): void {
|
||||
hour: "numeric",
|
||||
hour12: false,
|
||||
}).format(new Date());
|
||||
const h = parseInt(hourStr, 10);
|
||||
const h = Number.parseInt(hourStr, 10);
|
||||
|
||||
const endHour = source === "parts-catalogs" ? 19 : 18;
|
||||
if (h < 9 || h >= endHour) {
|
||||
@@ -66,7 +63,7 @@ export function msUntilNext9AM(): number {
|
||||
}).formatToParts(now);
|
||||
|
||||
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 minute = get("minute");
|
||||
@@ -81,10 +78,7 @@ export function msUntilNext9AM(): number {
|
||||
hoursToWait = 24 - hour + 9;
|
||||
}
|
||||
|
||||
const ms =
|
||||
hoursToWait * 3600_000 -
|
||||
minute * 60_000 -
|
||||
second * 1000;
|
||||
const ms = hoursToWait * 3600_000 - minute * 60_000 - second * 1000;
|
||||
|
||||
// At least 1 minute, at most 15 hours
|
||||
return Math.max(60_000, Math.min(ms, 15 * 3600_000));
|
||||
@@ -104,19 +98,20 @@ export interface PrefetchProgress {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export async function initProgress(
|
||||
redis: RedisService,
|
||||
vehicleId: string,
|
||||
): Promise<void> {
|
||||
export async function initProgress(redis: RedisService, vehicleId: string): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
await redis.setJson(progressKey(vehicleId), {
|
||||
status: "running",
|
||||
total: 0,
|
||||
completed: 0,
|
||||
errors: 0,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
} satisfies PrefetchProgress, 86400);
|
||||
await redis.setJson(
|
||||
progressKey(vehicleId),
|
||||
{
|
||||
status: "running",
|
||||
total: 0,
|
||||
completed: 0,
|
||||
errors: 0,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
} satisfies PrefetchProgress,
|
||||
86400,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateProgress(
|
||||
|
||||
@@ -2,17 +2,16 @@ import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
type OnModuleDestroy,
|
||||
type OnModuleInit,
|
||||
} from "@nestjs/common";
|
||||
import { Job, Queue, Worker } from "bullmq";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { CATALOG_PREFETCH_QUEUE } from "./queues/catalog-prefetch.queue";
|
||||
import { getBullConnection, QUEUE_NAMES } from "./bull.config";
|
||||
import {
|
||||
PrefetchInitJobData,
|
||||
PrefetchCategoryJobData,
|
||||
} from "./prefetch.types";
|
||||
import { type Job, type Queue, Worker } from "bullmq";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import type { CategoriesService } from "../categories/categories.service";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { categories, parts, vehicles } from "../database/schema/core";
|
||||
import type { RedisService } from "../redis/redis.service";
|
||||
import { QUEUE_NAMES, getBullConnection } from "./bull.config";
|
||||
import {
|
||||
RateLimitError,
|
||||
checkCooldown,
|
||||
@@ -20,10 +19,11 @@ import {
|
||||
initProgress,
|
||||
updateProgress,
|
||||
} from "./prefetch-utils";
|
||||
import { CategoriesService } from "../categories/categories.service";
|
||||
import { RedisService } from "../redis/redis.service";
|
||||
import { DATABASE, Database } from "../database/database.provider";
|
||||
import { categories, parts, vehicles } from "../database/schema/core";
|
||||
import type {
|
||||
PrefetchCategoryJobData,
|
||||
PrefetchInitJobData,
|
||||
} from "./prefetch.types";
|
||||
import { CATALOG_PREFETCH_QUEUE } from "./queues/catalog-prefetch.queue";
|
||||
|
||||
const MAX_DEPTH = 5;
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Job } from "bullmq";
|
||||
import type { Job } from "bullmq";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||
import {
|
||||
emexCatalogs,
|
||||
emexVehicles,
|
||||
emexVehicleVins,
|
||||
emexPartGroups,
|
||||
emexParts,
|
||||
emexPartNumbers,
|
||||
emexParts,
|
||||
emexScrapeSessions,
|
||||
emexVehicleVins,
|
||||
emexVehicles,
|
||||
} from "../../database/schema/emex";
|
||||
// Legacy type — kept inline since emex.types.ts was rewritten for emexdwc.ae integration
|
||||
interface EmexScrapeJobData {
|
||||
@@ -47,7 +47,7 @@ export async function processEmexScrape(
|
||||
|
||||
try {
|
||||
// ── Step 1: Resolve vehicle from VIN ──────────────────
|
||||
let emexVehicleRecord = await db
|
||||
const emexVehicleRecord = await db
|
||||
.select({ id: emexVehicles.id, vehicleId: emexVehicles.vehicleId })
|
||||
.from(emexVehicles)
|
||||
.innerJoin(emexVehicleVins, eq(emexVehicleVins.emexVehicleId, emexVehicles.id))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Job } from "bullmq";
|
||||
import type { Job } from "bullmq";
|
||||
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";
|
||||
|
||||
type Database = PostgresJsDatabase<Record<string, unknown>>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Job } from "bullmq";
|
||||
import type { Job } from "bullmq";
|
||||
import { and, eq, lt } from "drizzle-orm";
|
||||
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||
import { userSubscriptions, userBrands } from "../../database/schema/core";
|
||||
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||
import { userBrands, userSubscriptions } from "../../database/schema/core";
|
||||
|
||||
type Database = PostgresJsDatabase<Record<string, unknown>>;
|
||||
|
||||
@@ -17,12 +17,7 @@ export async function processSubscriptionExpiry(
|
||||
const expiredSubs = await db
|
||||
.select({ id: userSubscriptions.id, userId: userSubscriptions.userId })
|
||||
.from(userSubscriptions)
|
||||
.where(
|
||||
and(
|
||||
eq(userSubscriptions.status, "active"),
|
||||
lt(userSubscriptions.endDate, now),
|
||||
),
|
||||
);
|
||||
.where(and(eq(userSubscriptions.status, "active"), lt(userSubscriptions.endDate, now)));
|
||||
|
||||
if (expiredSubs.length === 0) {
|
||||
console.log("[subscription-expiry] No expired subscriptions found");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Provider } from "@nestjs/common";
|
||||
import type { Provider } from "@nestjs/common";
|
||||
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";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Provider } from "@nestjs/common";
|
||||
import type { Provider } from "@nestjs/common";
|
||||
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";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Provider } from "@nestjs/common";
|
||||
import type { Provider } from "@nestjs/common";
|
||||
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";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Provider } from "@nestjs/common";
|
||||
import type { Provider } from "@nestjs/common";
|
||||
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";
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import "./telemetry/tracing"; // MUST be first — instruments modules before they load
|
||||
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import helmet from "helmet";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { AppModule } from "./app.module";
|
||||
import { fileUploadValidation } from "./common/middleware/file-upload-validation.middleware";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Controller, Get, Param, Query } from "@nestjs/common";
|
||||
import { PartsService } from "./parts.service";
|
||||
import type { PartsService } from "./parts.service";
|
||||
|
||||
@Controller("parts")
|
||||
export class PartsController {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PL24Module } from "../integrations/pl24/pl24.module";
|
||||
import { PartsController } from "./parts.controller";
|
||||
import { PartsService } from "./parts.service";
|
||||
import { PL24Module } from "../integrations/pl24/pl24.module";
|
||||
|
||||
@Module({
|
||||
imports: [PL24Module],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PartsService } from "./parts.service";
|
||||
|
||||
function createService(db: any) {
|
||||
@@ -46,7 +46,16 @@ describe("PartsService", () => {
|
||||
it("should fetch from PL24 when DB is empty", async () => {
|
||||
const category = { id: "cat-1", vehicleId: "v1", externalId: "g1" };
|
||||
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" }];
|
||||
|
||||
let selectCall = 0;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { eq, like } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../database/database.provider";
|
||||
import { parts, categories, vehicles, schemaPics } from "../database/schema/core";
|
||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { categories, parts, schemaPics, vehicles } from "../database/schema/core";
|
||||
import type { PL24Service } from "../integrations/pl24/pl24.service";
|
||||
|
||||
@Injectable()
|
||||
export class PartsService {
|
||||
@@ -65,10 +65,10 @@ export class PartsService {
|
||||
name: p.name,
|
||||
nameOriginal: p.name,
|
||||
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,
|
||||
hotspotIndex: p.hotspotId ? (() => {
|
||||
const val = parseInt(p.hotspotId!, 10);
|
||||
const val = Number.parseInt(p.hotspotId!, 10);
|
||||
return (val > 0 && val <= 2147483647) ? val : null;
|
||||
})() : null,
|
||||
unavailable: p.unavailable || false,
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Param,
|
||||
Body,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { Roles } from "../common/decorators/roles.decorator";
|
||||
import { RolesGuard } from "../common/guards/roles.guard";
|
||||
import type { PaymentsService } from "./payments.service";
|
||||
|
||||
@Controller("payments")
|
||||
export class PaymentsController {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
||||
import { PaymentsController } from "./payments.controller";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
||||
|
||||
@Module({
|
||||
imports: [SubscriptionsModule],
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
|
||||
function createMockDb(overrides: Record<string, unknown> = {}) {
|
||||
function chainable(terminalValue: unknown) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
const methods = [
|
||||
"select", "from", "where", "orderBy", "limit", "offset",
|
||||
"innerJoin", "leftJoin", "insert", "values", "update", "set",
|
||||
"delete", "returning", "onConflictDoNothing", "groupBy",
|
||||
"select",
|
||||
"from",
|
||||
"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);
|
||||
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 configService = { get: vi.fn().mockReturnValue("test-value") };
|
||||
const subscriptionsService = {
|
||||
@@ -60,7 +76,17 @@ describe("PaymentsService", () => {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: 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({
|
||||
values: vi.fn().mockReturnThis(),
|
||||
@@ -78,7 +104,9 @@ describe("PaymentsService", () => {
|
||||
it("should throw BadRequestException for invalid plan key", async () => {
|
||||
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({
|
||||
from: 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({
|
||||
set: vi.fn().mockReturnThis(),
|
||||
@@ -107,7 +137,9 @@ describe("PaymentsService", () => {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: 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({
|
||||
set: vi.fn().mockReturnThis(),
|
||||
@@ -131,7 +163,9 @@ describe("PaymentsService", () => {
|
||||
};
|
||||
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({
|
||||
from: 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({
|
||||
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);
|
||||
@@ -160,7 +206,9 @@ describe("PaymentsService", () => {
|
||||
it("should throw BadRequestException for invalid plan key", async () => {
|
||||
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 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");
|
||||
});
|
||||
|
||||
@@ -193,7 +246,9 @@ describe("PaymentsService", () => {
|
||||
};
|
||||
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 () => {
|
||||
@@ -206,7 +261,9 @@ describe("PaymentsService", () => {
|
||||
};
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../database/database.provider";
|
||||
import { payments, userSubscriptions, plans } from "../database/schema/core";
|
||||
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
import type { ConfigService } from "@nestjs/config";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { payments, plans, userSubscriptions } from "../database/schema/core";
|
||||
import type { StorageService } from "../storage/storage.service";
|
||||
import type { SubscriptionsService } from "../subscriptions/subscriptions.service";
|
||||
|
||||
const PLAN_KEY_TO_BRAND_COUNT: Record<string, number> = {
|
||||
brand1: 1,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Controller, Get, Post, Patch, Param, Body, UseGuards } from "@nestjs/common";
|
||||
import { PlansService } from "./plans.service";
|
||||
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
import { Public } from "../common/decorators/public.decorator";
|
||||
import { Roles } from "../common/decorators/roles.decorator";
|
||||
import { RolesGuard } from "../common/guards/roles.guard";
|
||||
import type { PlansService } from "./plans.service";
|
||||
|
||||
@Controller("plans")
|
||||
export class PlansController {
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PlansService } from "./plans.service";
|
||||
|
||||
function createMockDb(overrides: Record<string, unknown> = {}) {
|
||||
function chainable(terminalValue: unknown) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
const methods = [
|
||||
"select", "from", "where", "orderBy", "limit", "offset",
|
||||
"insert", "values", "update", "set", "returning",
|
||||
"select",
|
||||
"from",
|
||||
"where",
|
||||
"orderBy",
|
||||
"limit",
|
||||
"offset",
|
||||
"insert",
|
||||
"values",
|
||||
"update",
|
||||
"set",
|
||||
"returning",
|
||||
];
|
||||
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
|
||||
chain.limit = vi.fn().mockReturnValue(terminalValue);
|
||||
@@ -73,11 +82,22 @@ describe("PlansService", () => {
|
||||
|
||||
describe("create", () => {
|
||||
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 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
|
||||
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";
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Provider } from "@nestjs/common";
|
||||
import type { Provider } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import Redis from "ioredis";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable, OnModuleDestroy } from "@nestjs/common";
|
||||
import Redis from "ioredis";
|
||||
import { Inject, Injectable, type OnModuleDestroy } from "@nestjs/common";
|
||||
import type Redis from "ioredis";
|
||||
import { REDIS_CLIENT } from "./redis.provider";
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Controller, Get, Post, Body } from "@nestjs/common";
|
||||
import { ReferralsService } from "./referrals.service";
|
||||
import { Body, Controller, Get, Post } from "@nestjs/common";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import type { ReferralsService } from "./referrals.service";
|
||||
|
||||
@Controller("referrals")
|
||||
export class ReferralsController {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
||||
import { ReferralsController } from "./referrals.controller";
|
||||
import { ReferralsService } from "./referrals.service";
|
||||
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
||||
|
||||
@Module({
|
||||
imports: [SubscriptionsModule],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ReferralsService } from "./referrals.service";
|
||||
|
||||
vi.mock("@sase/shared", () => ({
|
||||
@@ -166,7 +166,9 @@ describe("ReferralsService", () => {
|
||||
}),
|
||||
};
|
||||
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 () => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { 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()
|
||||
export class ReferralsService {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import type { ConfigService } from "@nestjs/config";
|
||||
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Controller, Get, Post, Patch, Body, Query, UseGuards } from "@nestjs/common";
|
||||
import { SubscriptionsService } from "./subscriptions.service";
|
||||
import { Body, Controller, Get, Patch, Post, Query, UseGuards } from "@nestjs/common";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { Roles } from "../common/decorators/roles.decorator";
|
||||
import { RolesGuard } from "../common/guards/roles.guard";
|
||||
import type { SubscriptionsService } from "./subscriptions.service";
|
||||
|
||||
@Controller("subscriptions")
|
||||
export class SubscriptionsController {
|
||||
@@ -54,8 +54,8 @@ export class SubscriptionsController {
|
||||
@Roles("admin")
|
||||
async findAll(@Query("page") page?: string, @Query("limit") limit?: string) {
|
||||
return this.subscriptionsService.findAll(
|
||||
page ? parseInt(page, 10) : 1,
|
||||
limit ? parseInt(limit, 10) : 20,
|
||||
page ? Number.parseInt(page, 10) : 1,
|
||||
limit ? Number.parseInt(limit, 10) : 20,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SubscriptionsController } from "./subscriptions.controller";
|
||||
import { SubscriptionsService } from "./subscriptions.service";
|
||||
import { BrandsModule } from "../brands/brands.module";
|
||||
import { PlansModule } from "../plans/plans.module";
|
||||
import { SubscriptionsController } from "./subscriptions.controller";
|
||||
import { SubscriptionsService } from "./subscriptions.service";
|
||||
|
||||
@Module({
|
||||
imports: [BrandsModule, PlansModule],
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SubscriptionsService } from "./subscriptions.service";
|
||||
|
||||
/**
|
||||
@@ -141,9 +137,7 @@ describe("SubscriptionsService", () => {
|
||||
let callCount = 0;
|
||||
const insertChain = {
|
||||
values: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn().mockReturnValue([
|
||||
{ id: "sub-1", userId: "user-1", status: "pending" },
|
||||
]),
|
||||
returning: vi.fn().mockReturnValue([{ id: "sub-1", userId: "user-1", status: "pending" }]),
|
||||
};
|
||||
const db = {
|
||||
select: vi.fn().mockImplementation(() => {
|
||||
@@ -188,9 +182,7 @@ describe("SubscriptionsService", () => {
|
||||
};
|
||||
const service = createService(db);
|
||||
|
||||
await expect(service.cancel("user-1")).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(service.cancel("user-1")).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should set status to cancelled and set cancelledAt", async () => {
|
||||
@@ -240,9 +232,7 @@ describe("SubscriptionsService", () => {
|
||||
};
|
||||
const service = createService(db);
|
||||
|
||||
await expect(service.resume("user-1")).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(service.resume("user-1")).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should set status to active and clear cancelledAt", async () => {
|
||||
@@ -262,9 +252,7 @@ describe("SubscriptionsService", () => {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([
|
||||
{ id: "sub-1", userId: "user-1", status: "cancelled" },
|
||||
]),
|
||||
limit: vi.fn().mockReturnValue([{ id: "sub-1", userId: "user-1", status: "cancelled" }]),
|
||||
}),
|
||||
update: vi.fn().mockReturnValue(updateChain),
|
||||
};
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { eq, and, desc, or, inArray } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../database/database.provider";
|
||||
import { userSubscriptions, userBrands, plans, brands } from "../database/schema/core";
|
||||
import { and, desc, eq, inArray, or } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { brands, plans, userBrands, userSubscriptions } from "../database/schema/core";
|
||||
|
||||
@Injectable()
|
||||
export class SubscriptionsService {
|
||||
|
||||
@@ -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", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 { 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 {
|
||||
ATTR_SERVICE_NAME,
|
||||
SEMRESATTRS_DEPLOYMENT_ENVIRONMENT,
|
||||
} 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 {
|
||||
serviceName: string;
|
||||
@@ -37,7 +37,7 @@ export function createNodeSDK(config: SDKConfig): NodeSDK | null {
|
||||
}
|
||||
|
||||
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({
|
||||
[ATTR_SERVICE_NAME]: config.serviceName,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user