style: apply biome safe auto-fixes

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

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

View File

@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
import { 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,
);
}
}

View File

@@ -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],

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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: [

View File

@@ -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 {

View File

@@ -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],

View File

@@ -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 });

View File

@@ -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 {

View File

@@ -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(),

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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],

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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],

View File

@@ -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,

View File

@@ -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,

View File

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

View File

@@ -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);
}),

View File

@@ -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()

View File

@@ -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";
}
}
}

View File

@@ -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);
});
});

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 }) {

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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);
});
});

View File

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

View File

@@ -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"),
},
});

View File

@@ -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";

View File

@@ -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" }),

View File

@@ -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 ───────────────────────────────────

View File

@@ -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)],
);

View File

@@ -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 ───────────────────────────────────

View File

@@ -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] }),
}));

View File

@@ -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...");

View File

@@ -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;

View File

@@ -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([

View File

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

View File

@@ -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()

View File

@@ -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
);
}
}

View File

@@ -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: [],
};

View File

@@ -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(/&amp;/g, '&');
const href = m[1].replace(/&amp;/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(/&amp;/g, '&');
const href = m[1].replace(/&amp;/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,

View File

@@ -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" },
};

View File

@@ -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;
}

View File

@@ -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}`);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,8 +1,8 @@
import { BasePL24Parser } from "./base-parser";
import { BmwPL24Parser } from "./bmw-parser";
import { MercedesPL24Parser } from "./mercedes-parser";
import { GenericPL24Parser } from "./generic-parser";
import { PL24_WMI_SERVICE_MAP, isP5Modern } from "../pl24.types";
import 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(),

View File

@@ -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;

View File

@@ -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(/&nbsp;/g, " ").trim() : "";
const text = textTdMatch
? textTdMatch[1]
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/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(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").trim() : "";
const name = captionMatch
? captionMatch[1].replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").trim()
: "";
const hotspotMatch = segment.match(/\bhotspot="([^"]*?)"/);
const 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(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim() || item.databaseKey || item.identifier;
const cleanName =
(item.caption || "")
.replace(/<div[^>]*restriction[^>]*>[\s\S]*/i, "")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/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. &lt;div class="restrictionBlock"...&gt;)
@@ -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(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&")
.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
// Trim restriction block divs — everything from <div class="restriction to end
.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(/&amp;/g, "&").replace(/&nbsp;/g, " ").trim();
const name = captionMatch[1]
.replace(/&amp;/g, "&")
.replace(/&nbsp;/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(/&amp;/g, "&").replace(/&nbsp;/g, " ").trim();
const name = captionMatch[1]
.replace(/&amp;/g, "&")
.replace(/&nbsp;/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(/&amp;/g, "&").replace(/&nbsp;/g, " ").replace(/\xa0/g, " ").trim();
name = captionMatch[1]
.replace(/&amp;/g, "&")
.replace(/&nbsp;/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;
}

View File

@@ -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],

View File

@@ -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 };
})

View File

@@ -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;
}

View File

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

View File

@@ -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],

View File

@@ -1,4 +1,4 @@
import { RedisService } from "../redis/redis.service";
import type { RedisService } from "../redis/redis.service";
/**
* Custom error that tells BullMQ to retry after a delay.
@@ -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(

View File

@@ -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;

View File

@@ -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))

View File

@@ -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>>;

View File

@@ -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");

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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],

View File

@@ -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;

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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],

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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);
});
});

View File

@@ -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()

View File

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

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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],

View File

@@ -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 () => {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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,
);
}
}

View File

@@ -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],

View File

@@ -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),
};

View File

@@ -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 {

View File

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

View File

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

View File

@@ -1,16 +1,16 @@
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import type { Instrumentation } from "@opentelemetry/instrumentation";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { 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