style: apply biome safe auto-fixes
Run 'biome check --fix' on apps/api/src and apps/web/src to clear the safe-fixable lint backlog (151 files: parseInt → Number.parseInt, isNaN → Number.isNaN, organize imports, etc.). 769 errors remain that require manual changes (mostly noExplicitAny). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||||
import { AdminService } from "./admin.service";
|
|
||||||
import { Roles } from "../common/decorators/roles.decorator";
|
import { Roles } from "../common/decorators/roles.decorator";
|
||||||
|
import type { AdminService } from "./admin.service";
|
||||||
|
|
||||||
@Controller("admin")
|
@Controller("admin")
|
||||||
@Roles("admin")
|
@Roles("admin")
|
||||||
@@ -27,8 +27,8 @@ export class AdminController {
|
|||||||
) {
|
) {
|
||||||
return this.adminService.getUsers(
|
return this.adminService.getUsers(
|
||||||
search,
|
search,
|
||||||
page ? parseInt(page, 10) : 1,
|
page ? Number.parseInt(page, 10) : 1,
|
||||||
limit ? parseInt(limit, 10) : 20,
|
limit ? Number.parseInt(limit, 10) : 20,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +49,8 @@ export class AdminController {
|
|||||||
@Query("userId") userId?: string,
|
@Query("userId") userId?: string,
|
||||||
) {
|
) {
|
||||||
return this.adminService.getQueryLogs(
|
return this.adminService.getQueryLogs(
|
||||||
page ? parseInt(page, 10) : 1,
|
page ? Number.parseInt(page, 10) : 1,
|
||||||
limit ? parseInt(limit, 10) : 50,
|
limit ? Number.parseInt(limit, 10) : 50,
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -63,8 +63,8 @@ export class AdminController {
|
|||||||
) {
|
) {
|
||||||
return this.adminService.getReferrals(
|
return this.adminService.getReferrals(
|
||||||
search,
|
search,
|
||||||
page ? parseInt(page, 10) : 1,
|
page ? Number.parseInt(page, 10) : 1,
|
||||||
limit ? parseInt(limit, 10) : 20,
|
limit ? Number.parseInt(limit, 10) : 20,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,8 +80,8 @@ export class AdminController {
|
|||||||
@Query("userId") userId?: string,
|
@Query("userId") userId?: string,
|
||||||
) {
|
) {
|
||||||
return this.adminService.getCopyLogs(
|
return this.adminService.getCopyLogs(
|
||||||
page ? parseInt(page, 10) : 1,
|
page ? Number.parseInt(page, 10) : 1,
|
||||||
limit ? parseInt(limit, 10) : 50,
|
limit ? Number.parseInt(limit, 10) : 50,
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -92,8 +92,8 @@ export class AdminController {
|
|||||||
@Query("limit") limit?: string,
|
@Query("limit") limit?: string,
|
||||||
) {
|
) {
|
||||||
return this.adminService.getTopCopiedCodes(
|
return this.adminService.getTopCopiedCodes(
|
||||||
days ? parseInt(days, 10) : 30,
|
days ? Number.parseInt(days, 10) : 30,
|
||||||
limit ? parseInt(limit, 10) : 20,
|
limit ? Number.parseInt(limit, 10) : 20,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AnalyticsModule } from "../analytics/analytics.module";
|
||||||
import { AdminController } from "./admin.controller";
|
import { AdminController } from "./admin.controller";
|
||||||
import { AdminService } from "./admin.service";
|
import { AdminService } from "./admin.service";
|
||||||
import { AnalyticsModule } from "../analytics/analytics.module";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AnalyticsModule],
|
imports: [AnalyticsModule],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { NotFoundException } from "@nestjs/common";
|
import { NotFoundException } from "@nestjs/common";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { AdminService } from "./admin.service";
|
import { AdminService } from "./admin.service";
|
||||||
|
|
||||||
describe("AdminService", () => {
|
describe("AdminService", () => {
|
||||||
@@ -174,7 +174,9 @@ describe("AdminService", () => {
|
|||||||
|
|
||||||
describe("getPendingPayments", () => {
|
describe("getPendingPayments", () => {
|
||||||
it("should return pending EFT payments with user info", async () => {
|
it("should return pending EFT payments with user info", async () => {
|
||||||
const pending = [{ id: "pay-1", userId: "u1", userName: "Ali", method: "eft", status: "pending" }];
|
const pending = [
|
||||||
|
{ id: "pay-1", userId: "u1", userName: "Ali", method: "eft", status: "pending" },
|
||||||
|
];
|
||||||
// select().from().innerJoin().where().orderBy() — orderBy terminal
|
// select().from().innerJoin().where().orderBy() — orderBy terminal
|
||||||
const c: Record<string, any> = {};
|
const c: Record<string, any> = {};
|
||||||
c.from = vi.fn().mockReturnValue(c);
|
c.from = vi.fn().mockReturnValue(c);
|
||||||
|
|||||||
@@ -5,20 +5,20 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { generateReferralCode } from "@sase/shared";
|
||||||
|
import { hashPassword } from "better-auth/crypto";
|
||||||
import { and, count, desc, eq, gte, ilike, inArray, or, sql } from "drizzle-orm";
|
import { and, count, desc, eq, gte, ilike, inArray, or, sql } from "drizzle-orm";
|
||||||
import { DATABASE, Database } from "../database/database.provider";
|
import type { AnalyticsService } from "../analytics/analytics.service";
|
||||||
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
import {
|
import {
|
||||||
users,
|
|
||||||
accounts,
|
accounts,
|
||||||
userSubscriptions,
|
brands,
|
||||||
payments,
|
payments,
|
||||||
queryLogs,
|
queryLogs,
|
||||||
brands,
|
|
||||||
referrals,
|
referrals,
|
||||||
|
userSubscriptions,
|
||||||
|
users,
|
||||||
} from "../database/schema/core";
|
} from "../database/schema/core";
|
||||||
import { hashPassword } from "better-auth/crypto";
|
|
||||||
import { generateReferralCode } from "@sase/shared";
|
|
||||||
import { AnalyticsService } from "../analytics/analytics.service";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Body, Controller, Post } from "@nestjs/common";
|
import { Body, Controller, Post } from "@nestjs/common";
|
||||||
import { AnalyticsService } from "./analytics.service";
|
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
|
import type { AnalyticsService } from "./analytics.service";
|
||||||
|
|
||||||
@Controller("analytics")
|
@Controller("analytics")
|
||||||
export class AnalyticsController {
|
export class AnalyticsController {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||||
import { count, desc, eq, gte, sql } from "drizzle-orm";
|
import { count, desc, eq, gte, sql } from "drizzle-orm";
|
||||||
import { DATABASE, Database } from "../database/database.provider";
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
import { oemCodeCopies, users } from "../database/schema/core";
|
import { oemCodeCopies, users } from "../database/schema/core";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -1,37 +1,37 @@
|
|||||||
|
import { resolve } from "path";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule } from "@nestjs/config";
|
||||||
import { resolve } from "path";
|
|
||||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
|
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
|
||||||
import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler";
|
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||||
|
import { AdminModule } from "./admin/admin.module";
|
||||||
|
import { AnalyticsModule } from "./analytics/analytics.module";
|
||||||
|
import { AuthModule } from "./auth/auth.module";
|
||||||
|
import { BrandsModule } from "./brands/brands.module";
|
||||||
|
import { CatalogModule } from "./catalog/catalog.module";
|
||||||
|
import { CategoriesModule } from "./categories/categories.module";
|
||||||
|
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
|
||||||
|
import { AuthGuard } from "./common/guards/auth.guard";
|
||||||
|
import { RolesGuard } from "./common/guards/roles.guard";
|
||||||
|
import { LoggingInterceptor } from "./common/interceptors/logging.interceptor";
|
||||||
|
import { TimeoutInterceptor } from "./common/interceptors/timeout.interceptor";
|
||||||
|
import { TransformInterceptor } from "./common/interceptors/transform.interceptor";
|
||||||
import configuration from "./config/configuration";
|
import configuration from "./config/configuration";
|
||||||
import { validate } from "./config/env.validation";
|
import { validate } from "./config/env.validation";
|
||||||
import { DatabaseModule } from "./database/database.module";
|
import { DatabaseModule } from "./database/database.module";
|
||||||
import { RedisModule } from "./redis/redis.module";
|
|
||||||
import { AuthModule } from "./auth/auth.module";
|
|
||||||
import { UsersModule } from "./users/users.module";
|
|
||||||
import { EmailModule } from "./email/email.module";
|
import { EmailModule } from "./email/email.module";
|
||||||
import { BrandsModule } from "./brands/brands.module";
|
|
||||||
import { PlansModule } from "./plans/plans.module";
|
|
||||||
import { SubscriptionsModule } from "./subscriptions/subscriptions.module";
|
|
||||||
import { PaymentsModule } from "./payments/payments.module";
|
|
||||||
import { StorageModule } from "./storage/storage.module";
|
|
||||||
import { ReferralsModule } from "./referrals/referrals.module";
|
|
||||||
import { VehiclesModule } from "./vehicles/vehicles.module";
|
|
||||||
import { CategoriesModule } from "./categories/categories.module";
|
|
||||||
import { PartsModule } from "./parts/parts.module";
|
|
||||||
import { JobsModule } from "./jobs/jobs.module";
|
|
||||||
import { EmexModule } from "./integrations/emex/emex.module";
|
|
||||||
import { TranslationsModule } from "./translations/translations.module";
|
|
||||||
import { AdminModule } from "./admin/admin.module";
|
|
||||||
import { AnalyticsModule } from "./analytics/analytics.module";
|
|
||||||
import { CatalogModule } from "./catalog/catalog.module";
|
|
||||||
import { HealthController } from "./health.controller";
|
import { HealthController } from "./health.controller";
|
||||||
import { AuthGuard } from "./common/guards/auth.guard";
|
import { EmexModule } from "./integrations/emex/emex.module";
|
||||||
import { RolesGuard } from "./common/guards/roles.guard";
|
import { JobsModule } from "./jobs/jobs.module";
|
||||||
import { TransformInterceptor } from "./common/interceptors/transform.interceptor";
|
import { PartsModule } from "./parts/parts.module";
|
||||||
import { LoggingInterceptor } from "./common/interceptors/logging.interceptor";
|
import { PaymentsModule } from "./payments/payments.module";
|
||||||
import { TimeoutInterceptor } from "./common/interceptors/timeout.interceptor";
|
import { PlansModule } from "./plans/plans.module";
|
||||||
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
|
import { RedisModule } from "./redis/redis.module";
|
||||||
|
import { ReferralsModule } from "./referrals/referrals.module";
|
||||||
|
import { StorageModule } from "./storage/storage.module";
|
||||||
|
import { SubscriptionsModule } from "./subscriptions/subscriptions.module";
|
||||||
|
import { TranslationsModule } from "./translations/translations.module";
|
||||||
|
import { UsersModule } from "./users/users.module";
|
||||||
|
import { VehiclesModule } from "./vehicles/vehicles.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { All, Controller, Req, Res } from "@nestjs/common";
|
import { All, Controller, Req, Res } from "@nestjs/common";
|
||||||
import { Request, Response } from "express";
|
|
||||||
import { getAuth } from "./auth";
|
|
||||||
import { toNodeHandler } from "better-auth/node";
|
import { toNodeHandler } from "better-auth/node";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
import { Public } from "../common/decorators/public.decorator";
|
import { Public } from "../common/decorators/public.decorator";
|
||||||
|
import { getAuth } from "./auth";
|
||||||
|
|
||||||
@Controller("auth")
|
@Controller("auth")
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Module, OnModuleInit } from "@nestjs/common";
|
import { Module, type OnModuleInit } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import type { ConfigService } from "@nestjs/config";
|
||||||
|
import type { EmailService } from "../email/email.service";
|
||||||
|
import { createAuth } from "./auth";
|
||||||
import { AuthController } from "./auth.controller";
|
import { AuthController } from "./auth.controller";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { EmailService } from "../email/email.service";
|
|
||||||
import { createAuth } from "./auth";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { generateReferralCode } from "@sase/shared";
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import { randomUUID } from "crypto";
|
|
||||||
import * as schema from "../database/schema/core";
|
import * as schema from "../database/schema/core";
|
||||||
import type { EmailService } from "../email/email.service";
|
import type { EmailService } from "../email/email.service";
|
||||||
import { generateReferralCode } from "@sase/shared";
|
|
||||||
|
|
||||||
let authInstance: ReturnType<typeof betterAuth> | null = null;
|
let authInstance: ReturnType<typeof betterAuth> | null = null;
|
||||||
|
|
||||||
@@ -19,7 +19,12 @@ interface AuthOptions {
|
|||||||
emailService?: EmailService;
|
emailService?: EmailService;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAuth(databaseUrl: string, secret: string, baseUrl: string, options?: AuthOptions) {
|
export function createAuth(
|
||||||
|
databaseUrl: string,
|
||||||
|
secret: string,
|
||||||
|
baseUrl: string,
|
||||||
|
options?: AuthOptions,
|
||||||
|
) {
|
||||||
if (authInstance) return authInstance;
|
if (authInstance) return authInstance;
|
||||||
|
|
||||||
const client = postgres(databaseUrl, { max: 5 });
|
const client = postgres(databaseUrl, { max: 5 });
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Controller, Get, Post, Patch, Param, Body, UseGuards } from "@nestjs/common";
|
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||||
import { BrandsService } from "./brands.service";
|
|
||||||
import { Public } from "../common/decorators/public.decorator";
|
import { Public } from "../common/decorators/public.decorator";
|
||||||
import { Roles } from "../common/decorators/roles.decorator";
|
import { Roles } from "../common/decorators/roles.decorator";
|
||||||
import { RolesGuard } from "../common/guards/roles.guard";
|
import { RolesGuard } from "../common/guards/roles.guard";
|
||||||
|
import type { BrandsService } from "./brands.service";
|
||||||
|
|
||||||
@Controller("brands")
|
@Controller("brands")
|
||||||
export class BrandsController {
|
export class BrandsController {
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { NotFoundException } from "@nestjs/common";
|
import { NotFoundException } from "@nestjs/common";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { BrandsService } from "./brands.service";
|
import { BrandsService } from "./brands.service";
|
||||||
|
|
||||||
function createMockDb(overrides: Record<string, unknown> = {}) {
|
function createMockDb(overrides: Record<string, unknown> = {}) {
|
||||||
function chainable(terminalValue: unknown) {
|
function chainable(terminalValue: unknown) {
|
||||||
const chain: Record<string, unknown> = {};
|
const chain: Record<string, unknown> = {};
|
||||||
const methods = [
|
const methods = [
|
||||||
"select", "from", "where", "orderBy", "limit", "offset",
|
"select",
|
||||||
"insert", "values", "update", "set", "returning",
|
"from",
|
||||||
|
"where",
|
||||||
|
"orderBy",
|
||||||
|
"limit",
|
||||||
|
"offset",
|
||||||
|
"insert",
|
||||||
|
"values",
|
||||||
|
"update",
|
||||||
|
"set",
|
||||||
|
"returning",
|
||||||
];
|
];
|
||||||
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
|
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
|
||||||
chain.limit = vi.fn().mockReturnValue(terminalValue);
|
chain.limit = vi.fn().mockReturnValue(terminalValue);
|
||||||
@@ -41,7 +50,10 @@ describe("BrandsService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should return all brands when activeOnly is false", async () => {
|
it("should return all brands when activeOnly is false", async () => {
|
||||||
const brands = [{ id: "b1", name: "BMW" }, { id: "b2", name: "Audi" }];
|
const brands = [
|
||||||
|
{ id: "b1", name: "BMW" },
|
||||||
|
{ id: "b2", name: "Audi" },
|
||||||
|
];
|
||||||
// When activeOnly=false, chain is: select().from().orderBy() — no where
|
// When activeOnly=false, chain is: select().from().orderBy() — no where
|
||||||
const chain: Record<string, any> = {
|
const chain: Record<string, any> = {
|
||||||
from: vi.fn().mockReturnThis(),
|
from: vi.fn().mockReturnThis(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
|
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { DATABASE, Database } from "../database/database.provider";
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
import { brands } from "../database/schema/core";
|
import { brands } from "../database/schema/core";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Controller, Get, Param, Post, Query } from "@nestjs/common";
|
import { Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
import { Roles } from "../common/decorators/roles.decorator";
|
import { Roles } from "../common/decorators/roles.decorator";
|
||||||
import { CatalogService } from "./catalog.service";
|
import type { CatalogService } from "./catalog.service";
|
||||||
|
|
||||||
@Controller("catalog")
|
@Controller("catalog")
|
||||||
export class CatalogController {
|
export class CatalogController {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PL24Module } from "../integrations/pl24/pl24.module";
|
||||||
|
import { StorageModule } from "../storage/storage.module";
|
||||||
|
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
||||||
import { CatalogController } from "./catalog.controller";
|
import { CatalogController } from "./catalog.controller";
|
||||||
import { CatalogService } from "./catalog.service";
|
import { CatalogService } from "./catalog.service";
|
||||||
import { PL24Module } from "../integrations/pl24/pl24.module";
|
|
||||||
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
|
||||||
import { StorageModule } from "../storage/storage.module";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PL24Module, SubscriptionsModule, StorageModule],
|
imports: [PL24Module, SubscriptionsModule, StorageModule],
|
||||||
|
|||||||
@@ -5,27 +5,27 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { eq, and, or, inArray, sql } from "drizzle-orm";
|
import { and, eq, inArray, or, sql } from "drizzle-orm";
|
||||||
import { DATABASE, Database } from "../database/database.provider";
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
import {
|
import {
|
||||||
|
brands,
|
||||||
catalogVehicles,
|
catalogVehicles,
|
||||||
categories,
|
categories,
|
||||||
parts,
|
parts,
|
||||||
schemaPics,
|
|
||||||
brands,
|
|
||||||
userSubscriptions,
|
|
||||||
userBrands,
|
|
||||||
plans,
|
plans,
|
||||||
|
schemaPics,
|
||||||
|
userBrands,
|
||||||
|
userSubscriptions,
|
||||||
} from "../database/schema/core";
|
} from "../database/schema/core";
|
||||||
import { RedisService } from "../redis/redis.service";
|
import type { PL24Service } from "../integrations/pl24/pl24.service";
|
||||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
|
||||||
import { StorageService } from "../storage/storage.service";
|
|
||||||
import {
|
import {
|
||||||
PL24_SERVICE_CATALOGS,
|
PL24_SERVICE_CATALOGS,
|
||||||
SERVICE_TO_BRAND,
|
|
||||||
SERVICE_DISPLAY_NAMES,
|
SERVICE_DISPLAY_NAMES,
|
||||||
|
SERVICE_TO_BRAND,
|
||||||
isP5Modern,
|
isP5Modern,
|
||||||
} from "../integrations/pl24/pl24.types";
|
} from "../integrations/pl24/pl24.types";
|
||||||
|
import type { RedisService } from "../redis/redis.service";
|
||||||
|
import type { StorageService } from "../storage/storage.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CatalogService {
|
export class CatalogService {
|
||||||
@@ -909,11 +909,11 @@ export class CatalogService {
|
|||||||
name: p.name,
|
name: p.name,
|
||||||
nameOriginal: p.name,
|
nameOriginal: p.name,
|
||||||
description: p.description || null,
|
description: p.description || null,
|
||||||
quantity: p.quantity ? (parseInt(String(p.quantity), 10) || null) : null,
|
quantity: p.quantity ? (Number.parseInt(String(p.quantity), 10) || null) : null,
|
||||||
position: p.positionCode || null,
|
position: p.positionCode || null,
|
||||||
hotspotIndex: p.hotspotId
|
hotspotIndex: p.hotspotId
|
||||||
? (() => {
|
? (() => {
|
||||||
const val = parseInt(p.hotspotId!, 10);
|
const val = Number.parseInt(p.hotspotId!, 10);
|
||||||
return val > 0 && val <= 2147483647 ? val : null;
|
return val > 0 && val <= 2147483647 ? val : null;
|
||||||
})()
|
})()
|
||||||
: null,
|
: null,
|
||||||
@@ -1027,7 +1027,7 @@ export class CatalogService {
|
|||||||
(hs.areas || []).map((area, areaIdx) => ({
|
(hs.areas || []).map((area, areaIdx) => ({
|
||||||
id: `hs-${hs.key}-${areaIdx}`,
|
id: `hs-${hs.key}-${areaIdx}`,
|
||||||
key: hs.key,
|
key: hs.key,
|
||||||
group: parseInt(hs.key, 10) || 0,
|
group: Number.parseInt(hs.key, 10) || 0,
|
||||||
shape: "rect" as const,
|
shape: "rect" as const,
|
||||||
coordinates: [area.left, area.top, area.width, area.height],
|
coordinates: [area.left, area.top, area.width, area.height],
|
||||||
label: hs.label || hs.key,
|
label: hs.label || hs.key,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Controller, Get, Param } from "@nestjs/common";
|
import { Controller, Get, Param } from "@nestjs/common";
|
||||||
import { CategoriesService } from "./categories.service";
|
import type { CategoriesService } from "./categories.service";
|
||||||
|
|
||||||
@Controller("categories")
|
@Controller("categories")
|
||||||
export class CategoriesController {
|
export class CategoriesController {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { CategoriesController } from "./categories.controller";
|
|
||||||
import { CategoriesService } from "./categories.service";
|
|
||||||
import { PL24Module } from "../integrations/pl24/pl24.module";
|
|
||||||
import { EmexModule } from "../integrations/emex/emex.module";
|
import { EmexModule } from "../integrations/emex/emex.module";
|
||||||
import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module";
|
import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module";
|
||||||
|
import { PL24Module } from "../integrations/pl24/pl24.module";
|
||||||
import { TranslationsModule } from "../translations/translations.module";
|
import { TranslationsModule } from "../translations/translations.module";
|
||||||
|
import { CategoriesController } from "./categories.controller";
|
||||||
|
import { CategoriesService } from "./categories.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PL24Module, EmexModule, PartsCatalogsModule, TranslationsModule],
|
imports: [PL24Module, EmexModule, PartsCatalogsModule, TranslationsModule],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { NotFoundException } from "@nestjs/common";
|
import { NotFoundException } from "@nestjs/common";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { CategoriesService } from "./categories.service";
|
import { CategoriesService } from "./categories.service";
|
||||||
|
|
||||||
function createService(db: any) {
|
function createService(db: any) {
|
||||||
@@ -26,12 +26,16 @@ function createService(db: any) {
|
|||||||
fetchCategoriesForPsaVin: vi.fn().mockResolvedValue([]),
|
fetchCategoriesForPsaVin: vi.fn().mockResolvedValue([]),
|
||||||
};
|
};
|
||||||
const translationsService = {
|
const translationsService = {
|
||||||
translate: vi.fn().mockImplementation((_key: string, sourceText: string) =>
|
translate: vi
|
||||||
Promise.resolve({ translatedText: sourceText, source: "none", isAutoTranslated: false }),
|
.fn()
|
||||||
),
|
.mockImplementation((_key: string, sourceText: string) =>
|
||||||
translateMany: vi.fn().mockImplementation((texts: string[]) =>
|
Promise.resolve({ translatedText: sourceText, source: "none", isAutoTranslated: false }),
|
||||||
Promise.resolve(new Map<string, string>(texts.map((t) => [t, t]))),
|
),
|
||||||
),
|
translateMany: vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((texts: string[]) =>
|
||||||
|
Promise.resolve(new Map<string, string>(texts.map((t) => [t, t]))),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
const service = new CategoriesService(
|
const service = new CategoriesService(
|
||||||
db as any,
|
db as any,
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
import { eq, inArray, isNull, sql } from "drizzle-orm";
|
import { eq, inArray, isNull, sql } from "drizzle-orm";
|
||||||
import { DATABASE, Database } from "../database/database.provider";
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
import { categories, vehicles, schemaPics, parts } from "../database/schema/core";
|
import { categories, parts, schemaPics, vehicles } from "../database/schema/core";
|
||||||
import { RedisService } from "../redis/redis.service";
|
import type { EmexService } from "../integrations/emex/emex.service";
|
||||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
import type { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
|
||||||
import { PL24FordLegacyService } from "../integrations/pl24/pl24-ford-legacy.service";
|
|
||||||
import { EmexService } from "../integrations/emex/emex.service";
|
|
||||||
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
|
|
||||||
import type { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
|
import type { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
|
||||||
import { StorageService } from "../storage/storage.service";
|
import type { PL24FordLegacyService } from "../integrations/pl24/pl24-ford-legacy.service";
|
||||||
import { TranslationsService } from "../translations/translations.service";
|
import type { PL24Service } from "../integrations/pl24/pl24.service";
|
||||||
|
import type { RedisService } from "../redis/redis.service";
|
||||||
|
import type { StorageService } from "../storage/storage.service";
|
||||||
|
import type { TranslationsService } from "../translations/translations.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CategoriesService {
|
export class CategoriesService {
|
||||||
@@ -642,7 +642,7 @@ export class CategoriesService {
|
|||||||
description: p.notice,
|
description: p.notice,
|
||||||
quantity: null,
|
quantity: null,
|
||||||
position: p.positionNumber,
|
position: p.positionNumber,
|
||||||
hotspotIndex: p.positionNumber ? parseInt(p.positionNumber, 10) || null : null,
|
hotspotIndex: p.positionNumber ? Number.parseInt(p.positionNumber, 10) || null : null,
|
||||||
unavailable: false,
|
unavailable: false,
|
||||||
remark: null as string | null,
|
remark: null as string | null,
|
||||||
modelCodes: null as string | null,
|
modelCodes: null as string | null,
|
||||||
@@ -830,10 +830,10 @@ export class CategoriesService {
|
|||||||
name: p.name,
|
name: p.name,
|
||||||
nameOriginal: p.name,
|
nameOriginal: p.name,
|
||||||
description: p.description || null,
|
description: p.description || null,
|
||||||
quantity: p.quantity ? (parseInt(String(p.quantity), 10) || null) : null,
|
quantity: p.quantity ? (Number.parseInt(String(p.quantity), 10) || null) : null,
|
||||||
position: p.positionCode || null,
|
position: p.positionCode || null,
|
||||||
hotspotIndex: p.hotspotId ? (() => {
|
hotspotIndex: p.hotspotId ? (() => {
|
||||||
const val = parseInt(p.hotspotId!, 10);
|
const val = Number.parseInt(p.hotspotId!, 10);
|
||||||
return (val > 0 && val <= 2147483647) ? val : null;
|
return (val > 0 && val <= 2147483647) ? val : null;
|
||||||
})() : null,
|
})() : null,
|
||||||
unavailable: p.unavailable || false,
|
unavailable: p.unavailable || false,
|
||||||
@@ -977,7 +977,7 @@ export class CategoriesService {
|
|||||||
(hs.areas || []).map((area, areaIdx) => ({
|
(hs.areas || []).map((area, areaIdx) => ({
|
||||||
id: `hs-${hs.key}-${areaIdx}`,
|
id: `hs-${hs.key}-${areaIdx}`,
|
||||||
key: hs.key,
|
key: hs.key,
|
||||||
group: parseInt(hs.key, 10) || 0,
|
group: Number.parseInt(hs.key, 10) || 0,
|
||||||
shape: "rect" as const,
|
shape: "rect" as const,
|
||||||
coordinates: [area.left, area.top, area.width, area.height],
|
coordinates: [area.left, area.top, area.width, area.height],
|
||||||
label: hs.label || hs.key,
|
label: hs.label || hs.key,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
|
import { type ExecutionContext, createParamDecorator } from "@nestjs/common";
|
||||||
|
|
||||||
export const CurrentUser = createParamDecorator((data: string, ctx: ExecutionContext) => {
|
export const CurrentUser = createParamDecorator((data: string, ctx: ExecutionContext) => {
|
||||||
const request = ctx.switchToHttp().getRequest();
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ export const paginationSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((val) => {
|
.transform((val) => {
|
||||||
const parsed = val ? parseInt(val, 10) : 1;
|
const parsed = val ? Number.parseInt(val, 10) : 1;
|
||||||
return Number.isNaN(parsed) || parsed < 1 ? 1 : parsed;
|
return Number.isNaN(parsed) || parsed < 1 ? 1 : parsed;
|
||||||
}),
|
}),
|
||||||
limit: z
|
limit: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((val) => {
|
.transform((val) => {
|
||||||
const parsed = val ? parseInt(val, 10) : 20;
|
const parsed = val ? Number.parseInt(val, 10) : 20;
|
||||||
if (Number.isNaN(parsed) || parsed < 1) return 20;
|
if (Number.isNaN(parsed) || parsed < 1) return 20;
|
||||||
return Math.min(parsed, 100);
|
return Math.min(parsed, 100);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus, Logger } from "@nestjs/common";
|
import {
|
||||||
import { Response } from "express";
|
type ArgumentsHost,
|
||||||
|
Catch,
|
||||||
|
type ExceptionFilter,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import type { Response } from "express";
|
||||||
|
|
||||||
// Drizzle/postgres unique violation error
|
// Drizzle/postgres unique violation error
|
||||||
@Catch()
|
@Catch()
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from "@nestjs/common";
|
import {
|
||||||
import { Response } from "express";
|
type ArgumentsHost,
|
||||||
import { trace, SpanStatusCode } from "@opentelemetry/api";
|
Catch,
|
||||||
|
type ExceptionFilter,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { SpanStatusCode, trace } from "@opentelemetry/api";
|
||||||
|
import type { Response } from "express";
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class HttpExceptionFilter implements ExceptionFilter {
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
@@ -54,13 +61,20 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
|
|
||||||
private getCodeFromStatus(status: number): string {
|
private getCodeFromStatus(status: number): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 400: return "GEN_002";
|
case 400:
|
||||||
case 401: return "AUTH_004";
|
return "GEN_002";
|
||||||
case 403: return "AUTH_005";
|
case 401:
|
||||||
case 404: return "GEN_001";
|
return "AUTH_004";
|
||||||
case 409: return "GEN_005";
|
case 403:
|
||||||
case 429: return "GEN_004";
|
return "AUTH_005";
|
||||||
default: return "GEN_003";
|
case 404:
|
||||||
|
return "GEN_001";
|
||||||
|
case 409:
|
||||||
|
return "GEN_005";
|
||||||
|
case 429:
|
||||||
|
return "GEN_004";
|
||||||
|
default:
|
||||||
|
return "GEN_003";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { UnauthorizedException } from "@nestjs/common";
|
import { UnauthorizedException } from "@nestjs/common";
|
||||||
import { Reflector } from "@nestjs/core";
|
import type { Reflector } from "@nestjs/core";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { AuthGuard } from "./auth.guard";
|
import { AuthGuard } from "./auth.guard";
|
||||||
|
|
||||||
vi.mock("../../auth/auth", () => ({
|
vi.mock("../../auth/auth", () => ({
|
||||||
@@ -67,9 +67,7 @@ describe("AuthGuard", () => {
|
|||||||
headers: { authorization: "Bearer token123" },
|
headers: { authorization: "Bearer token123" },
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(guard.canActivate(context as any)).rejects.toThrow(
|
await expect(guard.canActivate(context as any)).rejects.toThrow(UnauthorizedException);
|
||||||
UnauthorizedException,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw UnauthorizedException when session has no user", async () => {
|
it("should throw UnauthorizedException when session has no user", async () => {
|
||||||
@@ -85,9 +83,7 @@ describe("AuthGuard", () => {
|
|||||||
headers: { authorization: "Bearer token123" },
|
headers: { authorization: "Bearer token123" },
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(guard.canActivate(context as any)).rejects.toThrow(
|
await expect(guard.canActivate(context as any)).rejects.toThrow(UnauthorizedException);
|
||||||
UnauthorizedException,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should set user and session on request for valid session", async () => {
|
it("should set user and session on request for valid session", async () => {
|
||||||
@@ -127,9 +123,7 @@ describe("AuthGuard", () => {
|
|||||||
headers: {},
|
headers: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(guard.canActivate(context as any)).rejects.toThrow(
|
await expect(guard.canActivate(context as any)).rejects.toThrow(UnauthorizedException);
|
||||||
UnauthorizedException,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should re-throw UnauthorizedException from inner code", async () => {
|
it("should re-throw UnauthorizedException from inner code", async () => {
|
||||||
@@ -137,9 +131,7 @@ describe("AuthGuard", () => {
|
|||||||
|
|
||||||
mockedGetAuth.mockReturnValue({
|
mockedGetAuth.mockReturnValue({
|
||||||
api: {
|
api: {
|
||||||
getSession: vi.fn().mockRejectedValue(
|
getSession: vi.fn().mockRejectedValue(new UnauthorizedException("Custom auth error")),
|
||||||
new UnauthorizedException("Custom auth error"),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
@@ -147,8 +139,6 @@ describe("AuthGuard", () => {
|
|||||||
headers: { authorization: "Bearer token" },
|
headers: { authorization: "Bearer token" },
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(guard.canActivate(context as any)).rejects.toThrow(
|
await expect(guard.canActivate(context as any)).rejects.toThrow(UnauthorizedException);
|
||||||
UnauthorizedException,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
|
import {
|
||||||
import { Reflector } from "@nestjs/core";
|
type CanActivate,
|
||||||
import { IS_PUBLIC_KEY } from "../decorators/public.decorator";
|
type ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import type { Reflector } from "@nestjs/core";
|
||||||
import { getAuth } from "../../auth/auth";
|
import { getAuth } from "../../auth/auth";
|
||||||
|
import { IS_PUBLIC_KEY } from "../decorators/public.decorator";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthGuard implements CanActivate {
|
export class AuthGuard implements CanActivate {
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { ForbiddenException } from "@nestjs/common";
|
import { ForbiddenException } from "@nestjs/common";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { BrandAccessGuard } from "./brand-access.guard";
|
import { BrandAccessGuard } from "./brand-access.guard";
|
||||||
|
|
||||||
function createMockDb(overrides: Record<string, unknown> = {}) {
|
function createMockDb(overrides: Record<string, unknown> = {}) {
|
||||||
function chainable(terminalValue: unknown) {
|
function chainable(terminalValue: unknown) {
|
||||||
const chain: Record<string, unknown> = {};
|
const chain: Record<string, unknown> = {};
|
||||||
const methods = ["select", "from", "where", "orderBy", "limit", "offset", "innerJoin", "insert", "values", "update", "set", "returning"];
|
const methods = [
|
||||||
|
"select",
|
||||||
|
"from",
|
||||||
|
"where",
|
||||||
|
"orderBy",
|
||||||
|
"limit",
|
||||||
|
"offset",
|
||||||
|
"innerJoin",
|
||||||
|
"insert",
|
||||||
|
"values",
|
||||||
|
"update",
|
||||||
|
"set",
|
||||||
|
"returning",
|
||||||
|
];
|
||||||
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
|
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
|
||||||
chain.limit = vi.fn().mockReturnValue(terminalValue);
|
chain.limit = vi.fn().mockReturnValue(terminalValue);
|
||||||
return chain;
|
return chain;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable } from "@nestjs/common";
|
import { type CanActivate, type ExecutionContext, ForbiddenException, Inject, Injectable } from "@nestjs/common";
|
||||||
import { eq, and, or } from "drizzle-orm";
|
import { and, eq, or } from "drizzle-orm";
|
||||||
import { DATABASE, Database } from "../../database/database.provider";
|
import { DATABASE, type Database } from "../../database/database.provider";
|
||||||
import { userSubscriptions, userBrands, plans } from "../../database/schema/core";
|
import { plans, userBrands, userSubscriptions } from "../../database/schema/core";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrandAccessGuard implements CanActivate {
|
export class BrandAccessGuard implements CanActivate {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { ForbiddenException } from "@nestjs/common";
|
import { ForbiddenException } from "@nestjs/common";
|
||||||
import { Reflector } from "@nestjs/core";
|
import type { Reflector } from "@nestjs/core";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { RolesGuard } from "./roles.guard";
|
import { RolesGuard } from "./roles.guard";
|
||||||
|
|
||||||
function createMockExecutionContext(user?: { role: string }) {
|
function createMockExecutionContext(user?: { role: string }) {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
|
import {
|
||||||
import { Reflector } from "@nestjs/core";
|
type CanActivate,
|
||||||
|
type ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import type { Reflector } from "@nestjs/core";
|
||||||
import { ROLES_KEY } from "../decorators/roles.decorator";
|
import { ROLES_KEY } from "../decorators/roles.decorator";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from "@nestjs/common";
|
import {
|
||||||
import { Observable, tap } from "rxjs";
|
type CallHandler,
|
||||||
|
type ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
type NestInterceptor,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { trace } from "@opentelemetry/api";
|
import { trace } from "@opentelemetry/api";
|
||||||
|
import { type Observable, tap } from "rxjs";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LoggingInterceptor implements NestInterceptor {
|
export class LoggingInterceptor implements NestInterceptor {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
|
type CallHandler,
|
||||||
|
type ExecutionContext,
|
||||||
Injectable,
|
Injectable,
|
||||||
NestInterceptor,
|
type NestInterceptor,
|
||||||
ExecutionContext,
|
|
||||||
CallHandler,
|
|
||||||
RequestTimeoutException,
|
RequestTimeoutException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Observable, throwError, timeout, catchError, TimeoutError } from "rxjs";
|
import { type Observable, TimeoutError, catchError, throwError, timeout } from "rxjs";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TimeoutInterceptor implements NestInterceptor {
|
export class TimeoutInterceptor implements NestInterceptor {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from "@nestjs/common";
|
import {
|
||||||
import { Observable, map } from "rxjs";
|
type CallHandler,
|
||||||
|
type ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
type NestInterceptor,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { type Observable, map } from "rxjs";
|
||||||
|
|
||||||
export interface TransformedResponse<T> {
|
export interface TransformedResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -9,10 +14,7 @@ export interface TransformedResponse<T> {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TransformInterceptor<T> implements NestInterceptor<T, TransformedResponse<T>> {
|
export class TransformInterceptor<T> implements NestInterceptor<T, TransformedResponse<T>> {
|
||||||
intercept(
|
intercept(context: ExecutionContext, next: CallHandler): Observable<TransformedResponse<T>> {
|
||||||
context: ExecutionContext,
|
|
||||||
next: CallHandler,
|
|
||||||
): Observable<TransformedResponse<T>> {
|
|
||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
map((data) => {
|
map((data) => {
|
||||||
// If already wrapped, pass through
|
// If already wrapped, pass through
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import type { Request, Response, NextFunction } from "express";
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
|
||||||
const ALLOWED_MIME_TYPES = [
|
const ALLOWED_MIME_TYPES = ["image/png", "image/jpeg", "image/jpg", "application/pdf"];
|
||||||
"image/png",
|
|
||||||
"image/jpeg",
|
|
||||||
"image/jpg",
|
|
||||||
"application/pdf",
|
|
||||||
];
|
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
|
||||||
import { BadRequestException } from "@nestjs/common";
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import { VinValidationPipe } from "./vin-validation.pipe";
|
import { VinValidationPipe } from "./vin-validation.pipe";
|
||||||
|
|
||||||
describe("VinValidationPipe", () => {
|
describe("VinValidationPipe", () => {
|
||||||
@@ -32,27 +32,19 @@ describe("VinValidationPipe", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should throw BadRequestException for VIN longer than 17 characters", () => {
|
it("should throw BadRequestException for VIN longer than 17 characters", () => {
|
||||||
expect(() => pipe.transform("WBAPH5C55BA12345678")).toThrow(
|
expect(() => pipe.transform("WBAPH5C55BA12345678")).toThrow(BadRequestException);
|
||||||
BadRequestException,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw BadRequestException for VIN containing letter I", () => {
|
it("should throw BadRequestException for VIN containing letter I", () => {
|
||||||
expect(() => pipe.transform("WBAPH5C55IA123456")).toThrow(
|
expect(() => pipe.transform("WBAPH5C55IA123456")).toThrow(BadRequestException);
|
||||||
BadRequestException,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw BadRequestException for VIN containing letter O", () => {
|
it("should throw BadRequestException for VIN containing letter O", () => {
|
||||||
expect(() => pipe.transform("WBAPH5C55OA123456")).toThrow(
|
expect(() => pipe.transform("WBAPH5C55OA123456")).toThrow(BadRequestException);
|
||||||
BadRequestException,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw BadRequestException for VIN containing letter Q", () => {
|
it("should throw BadRequestException for VIN containing letter Q", () => {
|
||||||
expect(() => pipe.transform("WBAPH5C55QA123456")).toThrow(
|
expect(() => pipe.transform("WBAPH5C55QA123456")).toThrow(BadRequestException);
|
||||||
BadRequestException,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw BadRequestException for empty string", () => {
|
it("should throw BadRequestException for empty string", () => {
|
||||||
@@ -61,17 +53,11 @@ describe("VinValidationPipe", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should throw BadRequestException for null/undefined value", () => {
|
it("should throw BadRequestException for null/undefined value", () => {
|
||||||
expect(() => pipe.transform(null as unknown as string)).toThrow(
|
expect(() => pipe.transform(null as unknown as string)).toThrow(BadRequestException);
|
||||||
BadRequestException,
|
expect(() => pipe.transform(undefined as unknown as string)).toThrow(BadRequestException);
|
||||||
);
|
|
||||||
expect(() => pipe.transform(undefined as unknown as string)).toThrow(
|
|
||||||
BadRequestException,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw BadRequestException for non-string value", () => {
|
it("should throw BadRequestException for non-string value", () => {
|
||||||
expect(() => pipe.transform(12345 as unknown as string)).toThrow(
|
expect(() => pipe.transform(12345 as unknown as string)).toThrow(BadRequestException);
|
||||||
BadRequestException,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PipeTransform, Injectable, BadRequestException } from "@nestjs/common";
|
import { BadRequestException, Injectable, type PipeTransform } from "@nestjs/common";
|
||||||
import { isValidVin } from "@sase/shared";
|
import { isValidVin } from "@sase/shared";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
export default () => ({
|
export default () => ({
|
||||||
port: parseInt(process.env.PORT || "4000", 10),
|
port: Number.parseInt(process.env.PORT || "4000", 10),
|
||||||
database: {
|
database: {
|
||||||
url: process.env.DATABASE_URL,
|
url: process.env.DATABASE_URL,
|
||||||
},
|
},
|
||||||
redis: {
|
redis: {
|
||||||
host: process.env.REDIS_HOST || "127.0.0.1",
|
host: process.env.REDIS_HOST || "127.0.0.1",
|
||||||
port: parseInt(process.env.REDIS_PORT || "6379", 10),
|
port: Number.parseInt(process.env.REDIS_PORT || "6379", 10),
|
||||||
password: process.env.REDIS_PASSWORD,
|
password: process.env.REDIS_PASSWORD,
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
@@ -54,6 +54,6 @@ export default () => ({
|
|||||||
enabled: process.env.OTEL_ENABLED === "true",
|
enabled: process.env.OTEL_ENABLED === "true",
|
||||||
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||||
serviceName: process.env.OTEL_SERVICE_NAME || "sase-api",
|
serviceName: process.env.OTEL_SERVICE_NAME || "sase-api",
|
||||||
sampleRate: parseFloat(process.env.OTEL_TRACE_SAMPLE_RATE || "1.0"),
|
sampleRate: Number.parseFloat(process.env.OTEL_TRACE_SAMPLE_RATE || "1.0"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Provider } from "@nestjs/common";
|
import type { Provider } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { drizzle, PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
import { type PostgresJsDatabase, drizzle } from "drizzle-orm/postgres-js";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import * as core from "./schema/core";
|
|
||||||
import * as pl24 from "./schema/pl24";
|
|
||||||
import * as emex from "./schema/emex";
|
|
||||||
import * as relations from "./schema/relations";
|
|
||||||
import { isOtelEnabled } from "../telemetry";
|
import { isOtelEnabled } from "../telemetry";
|
||||||
|
import * as core from "./schema/core";
|
||||||
|
import * as emex from "./schema/emex";
|
||||||
|
import * as pl24 from "./schema/pl24";
|
||||||
|
import * as relations from "./schema/relations";
|
||||||
|
|
||||||
export const DATABASE = "DATABASE";
|
export const DATABASE = "DATABASE";
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
|
boolean,
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
|
numeric,
|
||||||
pgTable,
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
uuid,
|
uuid,
|
||||||
varchar,
|
varchar,
|
||||||
text,
|
|
||||||
boolean,
|
|
||||||
integer,
|
|
||||||
numeric,
|
|
||||||
timestamp,
|
|
||||||
jsonb,
|
|
||||||
index,
|
|
||||||
uniqueIndex,
|
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
// ─── Users ───────────────────────────────────────────
|
// ─── Users ───────────────────────────────────────────
|
||||||
@@ -256,7 +256,11 @@ export const catalogVehicles = pgTable(
|
|||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
uniqueIndex("catalog_vehicles_source_svc_vid_idx").on(table.source, table.serviceName, table.serviceVehicleId),
|
uniqueIndex("catalog_vehicles_source_svc_vid_idx").on(
|
||||||
|
table.source,
|
||||||
|
table.serviceName,
|
||||||
|
table.serviceVehicleId,
|
||||||
|
),
|
||||||
index("catalog_vehicles_brand_name_idx").on(table.brandName),
|
index("catalog_vehicles_brand_name_idx").on(table.brandName),
|
||||||
index("catalog_vehicles_service_name_idx").on(table.serviceName),
|
index("catalog_vehicles_service_name_idx").on(table.serviceName),
|
||||||
],
|
],
|
||||||
@@ -281,9 +285,7 @@ export const vehicles = pgTable(
|
|||||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [uniqueIndex("vehicles_vin_unique_idx").on(table.vin)],
|
||||||
uniqueIndex("vehicles_vin_unique_idx").on(table.vin),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── User Vehicles (junction — user ↔ shared vehicle) ─
|
// ─── User Vehicles (junction — user ↔ shared vehicle) ─
|
||||||
@@ -312,7 +314,9 @@ export const categories = pgTable(
|
|||||||
{
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
vehicleId: uuid("vehicle_id").references(() => vehicles.id, { onDelete: "cascade" }),
|
vehicleId: uuid("vehicle_id").references(() => vehicles.id, { onDelete: "cascade" }),
|
||||||
catalogVehicleId: uuid("catalog_vehicle_id").references(() => catalogVehicles.id, { onDelete: "cascade" }),
|
catalogVehicleId: uuid("catalog_vehicle_id").references(() => catalogVehicles.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
name: varchar("name", { length: 500 }).notNull(),
|
name: varchar("name", { length: 500 }).notNull(),
|
||||||
nameOriginal: varchar("name_original", { length: 500 }),
|
nameOriginal: varchar("name_original", { length: 500 }),
|
||||||
parentId: uuid("parent_id"),
|
parentId: uuid("parent_id"),
|
||||||
@@ -327,7 +331,12 @@ export const categories = pgTable(
|
|||||||
index("categories_vehicle_id_idx").on(table.vehicleId),
|
index("categories_vehicle_id_idx").on(table.vehicleId),
|
||||||
index("categories_catalog_vehicle_id_idx").on(table.catalogVehicleId),
|
index("categories_catalog_vehicle_id_idx").on(table.catalogVehicleId),
|
||||||
index("categories_parent_id_idx").on(table.parentId),
|
index("categories_parent_id_idx").on(table.parentId),
|
||||||
uniqueIndex("categories_vehicle_name_source_idx").on(table.vehicleId, table.catalogVehicleId, table.name, table.source),
|
uniqueIndex("categories_vehicle_name_source_idx").on(
|
||||||
|
table.vehicleId,
|
||||||
|
table.catalogVehicleId,
|
||||||
|
table.name,
|
||||||
|
table.source,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -337,7 +346,9 @@ export const parts = pgTable(
|
|||||||
{
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
vehicleId: uuid("vehicle_id").references(() => vehicles.id, { onDelete: "cascade" }),
|
vehicleId: uuid("vehicle_id").references(() => vehicles.id, { onDelete: "cascade" }),
|
||||||
catalogVehicleId: uuid("catalog_vehicle_id").references(() => catalogVehicles.id, { onDelete: "cascade" }),
|
catalogVehicleId: uuid("catalog_vehicle_id").references(() => catalogVehicles.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
categoryId: uuid("category_id")
|
categoryId: uuid("category_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => categories.id, { onDelete: "cascade" }),
|
.references(() => categories.id, { onDelete: "cascade" }),
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
|
boolean,
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
pgTable,
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
uuid,
|
uuid,
|
||||||
varchar,
|
varchar,
|
||||||
text,
|
|
||||||
boolean,
|
|
||||||
integer,
|
|
||||||
timestamp,
|
|
||||||
jsonb,
|
|
||||||
index,
|
|
||||||
uniqueIndex,
|
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
// ─── EMEX Catalog ───────────────────────────────────
|
// ─── EMEX Catalog ───────────────────────────────────
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
|
boolean,
|
||||||
|
index,
|
||||||
|
jsonb,
|
||||||
pgTable,
|
pgTable,
|
||||||
|
serial,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
uuid,
|
uuid,
|
||||||
varchar,
|
varchar,
|
||||||
text,
|
|
||||||
boolean,
|
|
||||||
serial,
|
|
||||||
timestamp,
|
|
||||||
jsonb,
|
|
||||||
index,
|
|
||||||
uniqueIndex,
|
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
// ─── PCAT Vehicles — VIN decode results ────────────
|
// ─── PCAT Vehicles — VIN decode results ────────────
|
||||||
@@ -59,9 +59,7 @@ export const pcatParts = pgTable(
|
|||||||
description: text("description"),
|
description: text("description"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [index("pcat_parts_name_idx").on(table.name)],
|
||||||
index("pcat_parts_name_idx").on(table.name),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── PCAT Schema Pics — Schema images + hotspots ───
|
// ─── PCAT Schema Pics — Schema images + hotspots ───
|
||||||
@@ -76,7 +74,5 @@ export const pcatSchemaPics = pgTable(
|
|||||||
hotspots: jsonb("hotspots"), // positions array from API
|
hotspots: jsonb("hotspots"), // positions array from API
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [index("pcat_schema_pics_group_car_idx").on(table.groupId, table.carId)],
|
||||||
index("pcat_schema_pics_group_car_idx").on(table.groupId, table.carId),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
|
boolean,
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
pgTable,
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
uuid,
|
uuid,
|
||||||
varchar,
|
varchar,
|
||||||
text,
|
|
||||||
boolean,
|
|
||||||
integer,
|
|
||||||
timestamp,
|
|
||||||
jsonb,
|
|
||||||
index,
|
|
||||||
uniqueIndex,
|
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
// ─── PL24 Catalog ───────────────────────────────────
|
// ─── PL24 Catalog ───────────────────────────────────
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
users,
|
|
||||||
sessions,
|
|
||||||
accounts,
|
accounts,
|
||||||
brands,
|
brands,
|
||||||
plans,
|
catalogVehicles,
|
||||||
userSubscriptions,
|
|
||||||
userBrands,
|
|
||||||
payments,
|
|
||||||
queryLogs,
|
|
||||||
vehicles,
|
|
||||||
userVehicles,
|
|
||||||
categories,
|
categories,
|
||||||
parts,
|
parts,
|
||||||
schemaPics,
|
payments,
|
||||||
|
plans,
|
||||||
|
queryLogs,
|
||||||
referrals,
|
referrals,
|
||||||
catalogVehicles,
|
schemaPics,
|
||||||
|
sessions,
|
||||||
|
userBrands,
|
||||||
|
userSubscriptions,
|
||||||
|
userVehicles,
|
||||||
|
users,
|
||||||
|
vehicles,
|
||||||
} from "./core";
|
} from "./core";
|
||||||
|
|
||||||
export const usersRelations = relations(users, ({ many }) => ({
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
@@ -96,7 +96,10 @@ export const userVehiclesRelations = relations(userVehicles, ({ one }) => ({
|
|||||||
|
|
||||||
export const categoriesRelations = relations(categories, ({ one, many }) => ({
|
export const categoriesRelations = relations(categories, ({ one, many }) => ({
|
||||||
vehicle: one(vehicles, { fields: [categories.vehicleId], references: [vehicles.id] }),
|
vehicle: one(vehicles, { fields: [categories.vehicleId], references: [vehicles.id] }),
|
||||||
catalogVehicle: one(catalogVehicles, { fields: [categories.catalogVehicleId], references: [catalogVehicles.id] }),
|
catalogVehicle: one(catalogVehicles, {
|
||||||
|
fields: [categories.catalogVehicleId],
|
||||||
|
references: [catalogVehicles.id],
|
||||||
|
}),
|
||||||
parent: one(categories, {
|
parent: one(categories, {
|
||||||
fields: [categories.parentId],
|
fields: [categories.parentId],
|
||||||
references: [categories.id],
|
references: [categories.id],
|
||||||
@@ -109,7 +112,10 @@ export const categoriesRelations = relations(categories, ({ one, many }) => ({
|
|||||||
|
|
||||||
export const partsRelations = relations(parts, ({ one }) => ({
|
export const partsRelations = relations(parts, ({ one }) => ({
|
||||||
vehicle: one(vehicles, { fields: [parts.vehicleId], references: [vehicles.id] }),
|
vehicle: one(vehicles, { fields: [parts.vehicleId], references: [vehicles.id] }),
|
||||||
catalogVehicle: one(catalogVehicles, { fields: [parts.catalogVehicleId], references: [catalogVehicles.id] }),
|
catalogVehicle: one(catalogVehicles, {
|
||||||
|
fields: [parts.catalogVehicleId],
|
||||||
|
references: [catalogVehicles.id],
|
||||||
|
}),
|
||||||
category: one(categories, { fields: [parts.categoryId], references: [categories.id] }),
|
category: one(categories, { fields: [parts.categoryId], references: [categories.id] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import { brands, plans, users, accounts } from "./schema/core";
|
import { accounts, brands, plans, users } from "./schema/core";
|
||||||
|
|
||||||
const BRANDS_DATA = [
|
const BRANDS_DATA = [
|
||||||
// Mevcut markalar
|
// Mevcut markalar
|
||||||
@@ -76,7 +76,10 @@ async function seed() {
|
|||||||
...p,
|
...p,
|
||||||
isActive: false,
|
isActive: false,
|
||||||
}));
|
}));
|
||||||
await db.insert(plans).values([...activePlans, ...trialPlans]).onConflictDoNothing();
|
await db
|
||||||
|
.insert(plans)
|
||||||
|
.values([...activePlans, ...trialPlans])
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
// Seed admin user
|
// Seed admin user
|
||||||
console.log("Seeding admin user...");
|
console.log("Seeding admin user...");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import type { ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
export interface SendEmailOptions {
|
export interface SendEmailOptions {
|
||||||
to: string;
|
to: string;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Controller, Get, Inject } from "@nestjs/common";
|
import { Controller, Get, Inject } from "@nestjs/common";
|
||||||
import { Public } from "./common/decorators/public.decorator";
|
|
||||||
import { DATABASE, Database } from "./database/database.provider";
|
|
||||||
import { RedisService } from "./redis/redis.service";
|
|
||||||
import { isOtelEnabled } from "./telemetry";
|
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
import { Public } from "./common/decorators/public.decorator";
|
||||||
|
import { DATABASE, type Database } from "./database/database.provider";
|
||||||
|
import type { RedisService } from "./redis/redis.service";
|
||||||
|
import { isOtelEnabled } from "./telemetry";
|
||||||
|
|
||||||
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||||
return Promise.race([
|
return Promise.race([
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import { CorgiService } from "./corgi.service";
|
import { CorgiService } from "./corgi.service";
|
||||||
|
|
||||||
describe("CorgiService", () => {
|
describe("CorgiService", () => {
|
||||||
|
|||||||
@@ -9,32 +9,56 @@ interface CorgiDecodeResult {
|
|||||||
|
|
||||||
const WMI_DATABASE: Record<string, string> = {
|
const WMI_DATABASE: Record<string, string> = {
|
||||||
// BMW
|
// BMW
|
||||||
WBA: "BMW", WBS: "BMW", WBY: "BMW", "5UX": "BMW",
|
WBA: "BMW",
|
||||||
|
WBS: "BMW",
|
||||||
|
WBY: "BMW",
|
||||||
|
"5UX": "BMW",
|
||||||
// Mercedes-Benz
|
// Mercedes-Benz
|
||||||
WDB: "Mercedes-Benz", WDC: "Mercedes-Benz", WDD: "Mercedes-Benz", WDF: "Mercedes-Benz",
|
WDB: "Mercedes-Benz",
|
||||||
|
WDC: "Mercedes-Benz",
|
||||||
|
WDD: "Mercedes-Benz",
|
||||||
|
WDF: "Mercedes-Benz",
|
||||||
// Audi
|
// Audi
|
||||||
WAU: "Audi", WUA: "Audi",
|
WAU: "Audi",
|
||||||
|
WUA: "Audi",
|
||||||
// Volkswagen
|
// Volkswagen
|
||||||
WVW: "Volkswagen", WVG: "Volkswagen", "3VW": "Volkswagen",
|
WVW: "Volkswagen",
|
||||||
WV1: "Volkswagen", WV2: "Volkswagen", WV3: "Volkswagen",
|
WVG: "Volkswagen",
|
||||||
|
"3VW": "Volkswagen",
|
||||||
|
WV1: "Volkswagen",
|
||||||
|
WV2: "Volkswagen",
|
||||||
|
WV3: "Volkswagen",
|
||||||
// Toyota
|
// Toyota
|
||||||
JTD: "Toyota", JTE: "Toyota", JTN: "Toyota", "2T1": "Toyota", "4T1": "Toyota",
|
JTD: "Toyota",
|
||||||
|
JTE: "Toyota",
|
||||||
|
JTN: "Toyota",
|
||||||
|
"2T1": "Toyota",
|
||||||
|
"4T1": "Toyota",
|
||||||
// Fiat
|
// Fiat
|
||||||
ZFA: "Fiat", ZFC: "Fiat",
|
ZFA: "Fiat",
|
||||||
|
ZFC: "Fiat",
|
||||||
// Renault
|
// Renault
|
||||||
VF1: "Renault", VF2: "Renault",
|
VF1: "Renault",
|
||||||
|
VF2: "Renault",
|
||||||
// Peugeot
|
// Peugeot
|
||||||
VF3: "Peugeot",
|
VF3: "Peugeot",
|
||||||
// Citroen
|
// Citroen
|
||||||
VF7: "Citroen",
|
VF7: "Citroen",
|
||||||
// Honda
|
// Honda
|
||||||
JHM: "Honda", SHH: "Honda", "1HG": "Honda",
|
JHM: "Honda",
|
||||||
|
SHH: "Honda",
|
||||||
|
"1HG": "Honda",
|
||||||
// Hyundai
|
// Hyundai
|
||||||
KMH: "Hyundai", "5NP": "Hyundai",
|
KMH: "Hyundai",
|
||||||
|
"5NP": "Hyundai",
|
||||||
// Kia
|
// Kia
|
||||||
KNA: "Kia", KND: "Kia",
|
KNA: "Kia",
|
||||||
|
KND: "Kia",
|
||||||
// Ford
|
// Ford
|
||||||
WF0: "Ford", NM0: "Ford", "1FA": "Ford", "3FA": "Ford",
|
WF0: "Ford",
|
||||||
|
NM0: "Ford",
|
||||||
|
"1FA": "Ford",
|
||||||
|
"3FA": "Ford",
|
||||||
// Opel
|
// Opel
|
||||||
W0L: "Opel",
|
W0L: "Opel",
|
||||||
// Skoda
|
// Skoda
|
||||||
@@ -44,11 +68,16 @@ const WMI_DATABASE: Record<string, string> = {
|
|||||||
// Volvo
|
// Volvo
|
||||||
YV1: "Volvo",
|
YV1: "Volvo",
|
||||||
// Nissan
|
// Nissan
|
||||||
JN1: "Nissan", "1N4": "Nissan", "3N1": "Nissan",
|
JN1: "Nissan",
|
||||||
|
"1N4": "Nissan",
|
||||||
|
"3N1": "Nissan",
|
||||||
// Mazda
|
// Mazda
|
||||||
JMZ: "Mazda", JM1: "Mazda", JM3: "Mazda",
|
JMZ: "Mazda",
|
||||||
|
JM1: "Mazda",
|
||||||
|
JM3: "Mazda",
|
||||||
// Porsche
|
// Porsche
|
||||||
WP0: "Porsche", WP1: "Porsche",
|
WP0: "Porsche",
|
||||||
|
WP1: "Porsche",
|
||||||
// Land Rover
|
// Land Rover
|
||||||
SAL: "Land Rover",
|
SAL: "Land Rover",
|
||||||
// Jaguar
|
// Jaguar
|
||||||
@@ -58,19 +87,52 @@ const WMI_DATABASE: Record<string, string> = {
|
|||||||
// Dacia
|
// Dacia
|
||||||
UU1: "Dacia",
|
UU1: "Dacia",
|
||||||
// Subaru
|
// Subaru
|
||||||
JF1: "Subaru", JF2: "Subaru",
|
JF1: "Subaru",
|
||||||
|
JF2: "Subaru",
|
||||||
// Suzuki
|
// Suzuki
|
||||||
JS2: "Suzuki", JS3: "Suzuki", TSM: "Suzuki", MA3: "Suzuki", MBH: "Suzuki",
|
JS2: "Suzuki",
|
||||||
|
JS3: "Suzuki",
|
||||||
|
TSM: "Suzuki",
|
||||||
|
MA3: "Suzuki",
|
||||||
|
MBH: "Suzuki",
|
||||||
// Mitsubishi
|
// Mitsubishi
|
||||||
JMB: "Mitsubishi", JMY: "Mitsubishi", MMB: "Mitsubishi", ML3: "Mitsubishi",
|
JMB: "Mitsubishi",
|
||||||
|
JMY: "Mitsubishi",
|
||||||
|
MMB: "Mitsubishi",
|
||||||
|
ML3: "Mitsubishi",
|
||||||
};
|
};
|
||||||
|
|
||||||
const YEAR_MAP: Record<string, number> = {
|
const YEAR_MAP: Record<string, number> = {
|
||||||
A: 2010, B: 2011, C: 2012, D: 2013, E: 2014, F: 2015, G: 2016, H: 2017,
|
A: 2010,
|
||||||
J: 2018, K: 2019, L: 2020, M: 2021, N: 2022, P: 2023, R: 2024, S: 2025,
|
B: 2011,
|
||||||
T: 2026, V: 2027, W: 2028, X: 2029, Y: 2030,
|
C: 2012,
|
||||||
"1": 2001, "2": 2002, "3": 2003, "4": 2004, "5": 2005,
|
D: 2013,
|
||||||
"6": 2006, "7": 2007, "8": 2008, "9": 2009,
|
E: 2014,
|
||||||
|
F: 2015,
|
||||||
|
G: 2016,
|
||||||
|
H: 2017,
|
||||||
|
J: 2018,
|
||||||
|
K: 2019,
|
||||||
|
L: 2020,
|
||||||
|
M: 2021,
|
||||||
|
N: 2022,
|
||||||
|
P: 2023,
|
||||||
|
R: 2024,
|
||||||
|
S: 2025,
|
||||||
|
T: 2026,
|
||||||
|
V: 2027,
|
||||||
|
W: 2028,
|
||||||
|
X: 2029,
|
||||||
|
Y: 2030,
|
||||||
|
"1": 2001,
|
||||||
|
"2": 2002,
|
||||||
|
"3": 2003,
|
||||||
|
"4": 2004,
|
||||||
|
"5": 2005,
|
||||||
|
"6": 2006,
|
||||||
|
"7": 2007,
|
||||||
|
"8": 2008,
|
||||||
|
"9": 2009,
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -11,18 +11,13 @@
|
|||||||
* - Crash recovery (auto-relaunch if browser disconnects)
|
* - Crash recovery (auto-relaunch if browser disconnects)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
|
||||||
Injectable,
|
import type { ConfigService } from "@nestjs/config";
|
||||||
Logger,
|
import type { Browser, BrowserContext, Page } from "playwright";
|
||||||
OnModuleInit,
|
|
||||||
OnModuleDestroy,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import type { Browser, BrowserContext, Page } from 'playwright';
|
|
||||||
|
|
||||||
const SESSION_TTL_MS = 25 * 60 * 1000; // 25 minutes
|
const SESSION_TTL_MS = 25 * 60 * 1000; // 25 minutes
|
||||||
const MAX_CONCURRENT_PAGES = 3;
|
const MAX_CONCURRENT_PAGES = 3;
|
||||||
const EMEX_BASE_URL = 'https://emexdwc.ae';
|
const EMEX_BASE_URL = "https://emexdwc.ae";
|
||||||
|
|
||||||
/** Simple counting semaphore */
|
/** Simple counting semaphore */
|
||||||
class Semaphore {
|
class Semaphore {
|
||||||
@@ -76,47 +71,28 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
constructor(private configService: ConfigService) {
|
constructor(private configService: ConfigService) {
|
||||||
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
|
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
|
||||||
|
|
||||||
this.useProxy =
|
this.useProxy = this.configService.get<string>("EMEX_USE_PROXY", "true") === "true";
|
||||||
this.configService.get<string>('EMEX_USE_PROXY', 'true') === 'true';
|
this.proxyHost = this.configService.get<string>("EMEX_PROXY_HOST", "74.81.81.81");
|
||||||
this.proxyHost = this.configService.get<string>(
|
this.proxyPortStart = this.configService.get<number>("EMEX_PROXY_PORT_START", 10001);
|
||||||
'EMEX_PROXY_HOST',
|
this.proxyPortEnd = this.configService.get<number>("EMEX_PROXY_PORT_END", 10099);
|
||||||
'74.81.81.81',
|
this.proxyUsername = this.configService.get<string>("EMEX_PROXY_USER", "1726bbe361918676d44e");
|
||||||
);
|
this.proxyPassword = this.configService.get<string>("EMEX_PROXY_PASS", "f11c7b6128cc86c6");
|
||||||
this.proxyPortStart = this.configService.get<number>(
|
|
||||||
'EMEX_PROXY_PORT_START',
|
|
||||||
10001,
|
|
||||||
);
|
|
||||||
this.proxyPortEnd = this.configService.get<number>(
|
|
||||||
'EMEX_PROXY_PORT_END',
|
|
||||||
10099,
|
|
||||||
);
|
|
||||||
this.proxyUsername = this.configService.get<string>(
|
|
||||||
'EMEX_PROXY_USER',
|
|
||||||
'1726bbe361918676d44e',
|
|
||||||
);
|
|
||||||
this.proxyPassword = this.configService.get<string>(
|
|
||||||
'EMEX_PROXY_PASS',
|
|
||||||
'f11c7b6128cc86c6',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.launchBrowser();
|
await this.launchBrowser();
|
||||||
this.logger.log('Browser launched on module init');
|
this.logger.log("Browser launched on module init");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as Error;
|
const e = err as Error;
|
||||||
this.logger.error(
|
this.logger.error(`Failed to launch browser on init: ${e.message}`, e.stack);
|
||||||
`Failed to launch browser on init: ${e.message}`,
|
|
||||||
e.stack,
|
|
||||||
);
|
|
||||||
// Non-fatal — will retry on first acquirePage()
|
// Non-fatal — will retry on first acquirePage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
async onModuleDestroy(): Promise<void> {
|
||||||
await this.closeBrowser();
|
await this.closeBrowser();
|
||||||
this.logger.log('Browser closed on module destroy');
|
this.logger.log("Browser closed on module destroy");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,16 +162,16 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
private async _doLaunch(): Promise<void> {
|
private async _doLaunch(): Promise<void> {
|
||||||
// Dynamic import — playwright is a devDependency
|
// Dynamic import — playwright is a devDependency
|
||||||
const { chromium } = await import('playwright');
|
const { chromium } = await import("playwright");
|
||||||
|
|
||||||
const launchOptions: Record<string, unknown> = {
|
const launchOptions: Record<string, unknown> = {
|
||||||
headless: true,
|
headless: true,
|
||||||
args: [
|
args: [
|
||||||
'--no-sandbox',
|
"--no-sandbox",
|
||||||
'--disable-setuid-sandbox',
|
"--disable-setuid-sandbox",
|
||||||
'--disable-dev-shm-usage',
|
"--disable-dev-shm-usage",
|
||||||
'--disable-accelerated-2d-canvas',
|
"--disable-accelerated-2d-canvas",
|
||||||
'--disable-gpu',
|
"--disable-gpu",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -213,15 +189,15 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.context = await this.browser.newContext({
|
this.context = await this.browser.newContext({
|
||||||
viewport: { width: 1920, height: 1080 },
|
viewport: { width: 1920, height: 1080 },
|
||||||
userAgent:
|
userAgent:
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
});
|
});
|
||||||
|
|
||||||
this.startedAt = Date.now();
|
this.startedAt = Date.now();
|
||||||
this.sessionExpiry = 0; // force session establish on first acquirePage
|
this.sessionExpiry = 0; // force session establish on first acquirePage
|
||||||
|
|
||||||
// Auto-recover on disconnect
|
// Auto-recover on disconnect
|
||||||
this.browser.on('disconnected', () => {
|
this.browser.on("disconnected", () => {
|
||||||
this.logger.warn('Browser disconnected — will relaunch on next request');
|
this.logger.warn("Browser disconnected — will relaunch on next request");
|
||||||
this.browser = null;
|
this.browser = null;
|
||||||
this.context = null;
|
this.context = null;
|
||||||
this.sessionExpiry = 0;
|
this.sessionExpiry = 0;
|
||||||
@@ -243,7 +219,7 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
private async ensureBrowser(): Promise<void> {
|
private async ensureBrowser(): Promise<void> {
|
||||||
if (this.browser?.isConnected()) return;
|
if (this.browser?.isConnected()) return;
|
||||||
this.logger.log('Browser not connected — relaunching');
|
this.logger.log("Browser not connected — relaunching");
|
||||||
await this.launchBrowser();
|
await this.launchBrowser();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,21 +229,21 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private async ensureSession(): Promise<void> {
|
private async ensureSession(): Promise<void> {
|
||||||
if (Date.now() < this.sessionExpiry) return;
|
if (Date.now() < this.sessionExpiry) return;
|
||||||
|
|
||||||
this.logger.log('Establishing EMEX session...');
|
this.logger.log("Establishing EMEX session...");
|
||||||
const page = await this.context!.newPage();
|
const page = await this.context!.newPage();
|
||||||
try {
|
try {
|
||||||
await page.goto(EMEX_BASE_URL, {
|
await page.goto(EMEX_BASE_URL, {
|
||||||
waitUntil: 'networkidle',
|
waitUntil: "networkidle",
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cookies = await this.context!.cookies();
|
const cookies = await this.context!.cookies();
|
||||||
const session = cookies.find((c) => c.name === 'ASP.NET_SessionId');
|
const session = cookies.find((c) => c.name === "ASP.NET_SessionId");
|
||||||
if (session) {
|
if (session) {
|
||||||
this.sessionExpiry = Date.now() + SESSION_TTL_MS;
|
this.sessionExpiry = Date.now() + SESSION_TTL_MS;
|
||||||
this.logger.log('Session established, TTL 25 min');
|
this.logger.log("Session established, TTL 25 min");
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn('No session cookie found after visiting baseUrl');
|
this.logger.warn("No session cookie found after visiting baseUrl");
|
||||||
// Still set a short TTL to avoid hammering
|
// Still set a short TTL to avoid hammering
|
||||||
this.sessionExpiry = Date.now() + 60_000;
|
this.sessionExpiry = Date.now() + 60_000;
|
||||||
}
|
}
|
||||||
@@ -278,9 +254,8 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
private randomProxyPort(): number {
|
private randomProxyPort(): number {
|
||||||
return (
|
return (
|
||||||
Math.floor(
|
Math.floor(Math.random() * (this.proxyPortEnd - this.proxyPortStart + 1)) +
|
||||||
Math.random() * (this.proxyPortEnd - this.proxyPortStart + 1),
|
this.proxyPortStart
|
||||||
) + this.proxyPortStart
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,75 +11,75 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EmexScraperResponse,
|
|
||||||
EmexCategory,
|
|
||||||
DecodedVehicle,
|
|
||||||
DecodedCategory,
|
|
||||||
CATALOG_MAP,
|
CATALOG_MAP,
|
||||||
} from './emex.types';
|
type DecodedCategory,
|
||||||
|
type DecodedVehicle,
|
||||||
|
type EmexCategory,
|
||||||
|
type EmexScraperResponse,
|
||||||
|
} from "./emex.types";
|
||||||
|
|
||||||
// ==================== VEHICLE ATTRIBUTE TRANSLATIONS ====================
|
// ==================== VEHICLE ATTRIBUTE TRANSLATIONS ====================
|
||||||
|
|
||||||
const TR_TRANSLATIONS = {
|
const TR_TRANSLATIONS = {
|
||||||
// Body types
|
// Body types
|
||||||
bodyTypes: {
|
bodyTypes: {
|
||||||
sedan: 'Sedan',
|
sedan: "Sedan",
|
||||||
coupe: 'Coupe',
|
coupe: "Coupe",
|
||||||
hatchback: 'Hatchback',
|
hatchback: "Hatchback",
|
||||||
wagon: 'Station Wagon',
|
wagon: "Station Wagon",
|
||||||
'station wagon': 'Station Wagon',
|
"station wagon": "Station Wagon",
|
||||||
estate: 'Station Wagon',
|
estate: "Station Wagon",
|
||||||
convertible: 'Ustu Acik',
|
convertible: "Ustu Acik",
|
||||||
cabriolet: 'Kabriyole',
|
cabriolet: "Kabriyole",
|
||||||
suv: 'SUV',
|
suv: "SUV",
|
||||||
crossover: 'Crossover',
|
crossover: "Crossover",
|
||||||
pickup: 'Pikap',
|
pickup: "Pikap",
|
||||||
van: 'Minivan',
|
van: "Minivan",
|
||||||
minivan: 'Minivan',
|
minivan: "Minivan",
|
||||||
mpv: 'Cok Amacli Arac',
|
mpv: "Cok Amacli Arac",
|
||||||
roadster: 'Roadster',
|
roadster: "Roadster",
|
||||||
} as Record<string, string>,
|
} as Record<string, string>,
|
||||||
|
|
||||||
engineTypes: {
|
engineTypes: {
|
||||||
gasoline: 'Benzin',
|
gasoline: "Benzin",
|
||||||
petrol: 'Benzin',
|
petrol: "Benzin",
|
||||||
benzin: 'Benzin',
|
benzin: "Benzin",
|
||||||
diesel: 'Dizel',
|
diesel: "Dizel",
|
||||||
electric: 'Elektrik',
|
electric: "Elektrik",
|
||||||
hybrid: 'Hibrit',
|
hybrid: "Hibrit",
|
||||||
'plug-in hybrid': 'Sarjli Hibrit',
|
"plug-in hybrid": "Sarjli Hibrit",
|
||||||
phev: 'Sarjli Hibrit',
|
phev: "Sarjli Hibrit",
|
||||||
lpg: 'LPG',
|
lpg: "LPG",
|
||||||
cng: 'CNG',
|
cng: "CNG",
|
||||||
hydrogen: 'Hidrojen',
|
hydrogen: "Hidrojen",
|
||||||
} as Record<string, string>,
|
} as Record<string, string>,
|
||||||
|
|
||||||
transmissions: {
|
transmissions: {
|
||||||
automatic: 'Otomatik',
|
automatic: "Otomatik",
|
||||||
manual: 'Manuel',
|
manual: "Manuel",
|
||||||
'semi-automatic': 'Yari Otomatik',
|
"semi-automatic": "Yari Otomatik",
|
||||||
dct: 'Cift Kavramali',
|
dct: "Cift Kavramali",
|
||||||
cvt: 'CVT',
|
cvt: "CVT",
|
||||||
'dual clutch': 'Cift Kavramali',
|
"dual clutch": "Cift Kavramali",
|
||||||
dsg: 'DSG',
|
dsg: "DSG",
|
||||||
tiptronic: 'Tiptronic',
|
tiptronic: "Tiptronic",
|
||||||
steptronic: 'Steptronic',
|
steptronic: "Steptronic",
|
||||||
at: 'Otomatik',
|
at: "Otomatik",
|
||||||
mt: 'Manuel',
|
mt: "Manuel",
|
||||||
} as Record<string, string>,
|
} as Record<string, string>,
|
||||||
|
|
||||||
driveTypes: {
|
driveTypes: {
|
||||||
fwd: 'Ondan Cekis',
|
fwd: "Ondan Cekis",
|
||||||
rwd: 'Arkadan Itis',
|
rwd: "Arkadan Itis",
|
||||||
awd: 'Dort Ceker',
|
awd: "Dort Ceker",
|
||||||
'4wd': 'Dort Ceker',
|
"4wd": "Dort Ceker",
|
||||||
'4x4': 'Dort Ceker',
|
"4x4": "Dort Ceker",
|
||||||
'front-wheel drive': 'Ondan Cekis',
|
"front-wheel drive": "Ondan Cekis",
|
||||||
'rear-wheel drive': 'Arkadan Itis',
|
"rear-wheel drive": "Arkadan Itis",
|
||||||
'all-wheel drive': 'Dort Ceker',
|
"all-wheel drive": "Dort Ceker",
|
||||||
quattro: 'Quattro (Dort Ceker)',
|
quattro: "Quattro (Dort Ceker)",
|
||||||
xdrive: 'xDrive (Dort Ceker)',
|
xdrive: "xDrive (Dort Ceker)",
|
||||||
'4matic': '4MATIC (Dort Ceker)',
|
"4matic": "4MATIC (Dort Ceker)",
|
||||||
} as Record<string, string>,
|
} as Record<string, string>,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,9 +102,7 @@ export function translateEngineType(engineType: string | null): string | null {
|
|||||||
return translateToTurkish(engineType, TR_TRANSLATIONS.engineTypes);
|
return translateToTurkish(engineType, TR_TRANSLATIONS.engineTypes);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function translateTransmission(
|
export function translateTransmission(transmission: string | null): string | null {
|
||||||
transmission: string | null,
|
|
||||||
): string | null {
|
|
||||||
return translateToTurkish(transmission, TR_TRANSLATIONS.transmissions);
|
return translateToTurkish(transmission, TR_TRANSLATIONS.transmissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,11 +121,11 @@ export function mapEmexResponse(response: EmexScraperResponse): DecodedVehicle {
|
|||||||
// Get brand from catalog map or use the one from response
|
// Get brand from catalog map or use the one from response
|
||||||
const wmi = response.vin.substring(0, 3);
|
const wmi = response.vin.substring(0, 3);
|
||||||
const catalogEntry = CATALOG_MAP[wmi];
|
const catalogEntry = CATALOG_MAP[wmi];
|
||||||
const brand = catalogEntry?.brand || vehicle.brand || 'Unknown';
|
const brand = catalogEntry?.brand || vehicle.brand || "Unknown";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
brand: brand.toUpperCase(),
|
brand: brand.toUpperCase(),
|
||||||
model: vehicle.model || 'Unknown',
|
model: vehicle.model || "Unknown",
|
||||||
year: vehicle.year || extractYearFromVin(response.vin),
|
year: vehicle.year || extractYearFromVin(response.vin),
|
||||||
series: vehicle.series || null,
|
series: vehicle.series || null,
|
||||||
bodyType: vehicle.bodyType || null,
|
bodyType: vehicle.bodyType || null,
|
||||||
@@ -148,15 +146,15 @@ export function mapEmexResponse(response: EmexScraperResponse): DecodedVehicle {
|
|||||||
function extractYearFromVin(vin: string): number {
|
function extractYearFromVin(vin: string): number {
|
||||||
const yearChar = vin.charAt(9).toUpperCase();
|
const yearChar = vin.charAt(9).toUpperCase();
|
||||||
const yearMap: Record<string, number> = {
|
const yearMap: Record<string, number> = {
|
||||||
'1': 2001,
|
"1": 2001,
|
||||||
'2': 2002,
|
"2": 2002,
|
||||||
'3': 2003,
|
"3": 2003,
|
||||||
'4': 2004,
|
"4": 2004,
|
||||||
'5': 2005,
|
"5": 2005,
|
||||||
'6': 2006,
|
"6": 2006,
|
||||||
'7': 2007,
|
"7": 2007,
|
||||||
'8': 2008,
|
"8": 2008,
|
||||||
'9': 2009,
|
"9": 2009,
|
||||||
A: 2010,
|
A: 2010,
|
||||||
B: 2011,
|
B: 2011,
|
||||||
C: 2012,
|
C: 2012,
|
||||||
@@ -186,11 +184,9 @@ function extractYearFromVin(vin: string): number {
|
|||||||
* Builds the raw response object for storage
|
* Builds the raw response object for storage
|
||||||
* Includes category URLs for on-demand parts fetching
|
* Includes category URLs for on-demand parts fetching
|
||||||
*/
|
*/
|
||||||
function buildRawResponse(
|
function buildRawResponse(response: EmexScraperResponse): Record<string, unknown> {
|
||||||
response: EmexScraperResponse,
|
|
||||||
): Record<string, unknown> {
|
|
||||||
return {
|
return {
|
||||||
source: 'emex', // Explicit source identifier for on-demand loading
|
source: "emex", // Explicit source identifier for on-demand loading
|
||||||
method: response.method,
|
method: response.method,
|
||||||
vin: response.vin,
|
vin: response.vin,
|
||||||
catalogCode: response.catalogCode,
|
catalogCode: response.catalogCode,
|
||||||
@@ -204,11 +200,12 @@ function buildRawResponse(
|
|||||||
// Store category tree for hierarchical insertion (QuickGroups.aspx)
|
// Store category tree for hierarchical insertion (QuickGroups.aspx)
|
||||||
emexCategoryTree: response.categoryTree || [],
|
emexCategoryTree: response.categoryTree || [],
|
||||||
// Store flat category URLs for on-demand parts fetching (fallback)
|
// Store flat category URLs for on-demand parts fetching (fallback)
|
||||||
emexCategories: response.categories?.map((cat) => ({
|
emexCategories:
|
||||||
gid: cat.gid,
|
response.categories?.map((cat) => ({
|
||||||
name: cat.name,
|
gid: cat.gid,
|
||||||
url: cat.url,
|
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
|
* Parts are NOT included here; they are fetched on-demand when the user
|
||||||
* clicks a category.
|
* clicks a category.
|
||||||
*/
|
*/
|
||||||
function mapCategories(
|
function mapCategories(categories?: EmexCategory[]): DecodedCategory[] {
|
||||||
categories?: EmexCategory[],
|
|
||||||
): DecodedCategory[] {
|
|
||||||
if (!categories || categories.length === 0) {
|
if (!categories || categories.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -246,27 +241,27 @@ function deriveIconName(categoryName: string): string | null {
|
|||||||
const normalized = categoryName.toLowerCase();
|
const normalized = categoryName.toLowerCase();
|
||||||
|
|
||||||
const iconMap: Record<string, string> = {
|
const iconMap: Record<string, string> = {
|
||||||
engine: 'engine',
|
engine: "engine",
|
||||||
motor: 'engine',
|
motor: "engine",
|
||||||
brake: 'brake',
|
brake: "brake",
|
||||||
brakes: 'brake',
|
brakes: "brake",
|
||||||
suspension: 'suspension',
|
suspension: "suspension",
|
||||||
steering: 'steering',
|
steering: "steering",
|
||||||
transmission: 'transmission',
|
transmission: "transmission",
|
||||||
gearbox: 'transmission',
|
gearbox: "transmission",
|
||||||
exhaust: 'exhaust',
|
exhaust: "exhaust",
|
||||||
cooling: 'cooling',
|
cooling: "cooling",
|
||||||
electrical: 'electrical',
|
electrical: "electrical",
|
||||||
interior: 'interior',
|
interior: "interior",
|
||||||
exterior: 'exterior',
|
exterior: "exterior",
|
||||||
body: 'body',
|
body: "body",
|
||||||
lighting: 'lighting',
|
lighting: "lighting",
|
||||||
lights: 'lighting',
|
lights: "lighting",
|
||||||
wheels: 'wheels',
|
wheels: "wheels",
|
||||||
fuel: 'fuel',
|
fuel: "fuel",
|
||||||
air: 'air',
|
air: "air",
|
||||||
climate: 'climate',
|
climate: "climate",
|
||||||
filters: 'filters',
|
filters: "filters",
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const [key, icon] of Object.entries(iconMap)) {
|
for (const [key, icon] of Object.entries(iconMap)) {
|
||||||
@@ -281,16 +276,13 @@ function deriveIconName(categoryName: string): string | null {
|
|||||||
/**
|
/**
|
||||||
* Creates an empty/default DecodedVehicle for error cases
|
* Creates an empty/default DecodedVehicle for error cases
|
||||||
*/
|
*/
|
||||||
export function createEmptyDecodedVehicle(
|
export function createEmptyDecodedVehicle(vin: string, errorMessage?: string): DecodedVehicle {
|
||||||
vin: string,
|
|
||||||
errorMessage?: string,
|
|
||||||
): DecodedVehicle {
|
|
||||||
const wmi = vin.substring(0, 3);
|
const wmi = vin.substring(0, 3);
|
||||||
const catalogEntry = CATALOG_MAP[wmi];
|
const catalogEntry = CATALOG_MAP[wmi];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
brand: catalogEntry?.brand?.toUpperCase() || 'UNKNOWN',
|
brand: catalogEntry?.brand?.toUpperCase() || "UNKNOWN",
|
||||||
model: 'Unknown',
|
model: "Unknown",
|
||||||
year: extractYearFromVin(vin),
|
year: extractYearFromVin(vin),
|
||||||
series: null,
|
series: null,
|
||||||
bodyType: null,
|
bodyType: null,
|
||||||
@@ -302,8 +294,8 @@ export function createEmptyDecodedVehicle(
|
|||||||
colorCode: null,
|
colorCode: null,
|
||||||
raw: {
|
raw: {
|
||||||
vin,
|
vin,
|
||||||
error: errorMessage || 'Vehicle data not found',
|
error: errorMessage || "Vehicle data not found",
|
||||||
source: 'emexdwc.ae',
|
source: "emexdwc.ae",
|
||||||
},
|
},
|
||||||
categories: [],
|
categories: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,30 +9,31 @@
|
|||||||
* QuickGroups.aspx, or QuickDetails.aspx — plain HTTP GET works.
|
* QuickGroups.aspx, or QuickDetails.aspx — plain HTTP GET works.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as path from "path";
|
||||||
import {
|
import {
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ServiceUnavailableException,
|
Injectable,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
} from '@nestjs/common';
|
Logger,
|
||||||
import { ConfigService } from '@nestjs/config';
|
ServiceUnavailableException,
|
||||||
import * as path from 'path';
|
} from "@nestjs/common";
|
||||||
|
import type { ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
import { ProxyAgent } from 'undici';
|
import { ProxyAgent } from "undici";
|
||||||
|
import type { RedisService } from "../../redis/redis.service";
|
||||||
|
import type { EmexBrowserService } from "./emex.browser";
|
||||||
|
import { createEmptyDecodedVehicle, mapEmexResponse } from "./emex.mapper";
|
||||||
import {
|
import {
|
||||||
EmexScraperResponse,
|
|
||||||
EmexCategoryTreeNode,
|
|
||||||
EmexPartsResult,
|
|
||||||
DecodedVehicle,
|
|
||||||
CATALOG_MAP,
|
CATALOG_MAP,
|
||||||
} from './emex.types';
|
type DecodedVehicle,
|
||||||
import { mapEmexResponse, createEmptyDecodedVehicle } from './emex.mapper';
|
type EmexCategoryTreeNode,
|
||||||
import { EmexBrowserService } from './emex.browser';
|
type EmexPartsResult,
|
||||||
import { RedisService } from '../../redis/redis.service';
|
type EmexScraperResponse,
|
||||||
|
} from "./emex.types";
|
||||||
|
|
||||||
const EMEX_BASE_URL = 'https://emexdwc.ae';
|
const EMEX_BASE_URL = "https://emexdwc.ae";
|
||||||
const EMEX_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
const EMEX_UA =
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||||
|
|
||||||
interface EmexHttpVehicle {
|
interface EmexHttpVehicle {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -109,23 +110,20 @@ export class EmexService {
|
|||||||
) {
|
) {
|
||||||
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
|
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
|
||||||
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
|
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
|
||||||
const monorepoRoot = path.resolve(__dirname, '..', '..', '..', '..', '..');
|
const monorepoRoot = path.resolve(__dirname, "..", "..", "..", "..", "..");
|
||||||
const defaultPath = path.resolve(monorepoRoot, 'scripts/emex-vin-scraper.js');
|
const defaultPath = path.resolve(monorepoRoot, "scripts/emex-vin-scraper.js");
|
||||||
this.scraperPath = this.configService.get<string>(
|
this.scraperPath = this.configService.get<string>("EMEX_SCRAPER_PATH", defaultPath);
|
||||||
'EMEX_SCRAPER_PATH',
|
|
||||||
defaultPath,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.timeout = this.configService.get<number>('EMEX_TIMEOUT', 60000);
|
this.timeout = this.configService.get<number>("EMEX_TIMEOUT", 60000);
|
||||||
this.debug = this.configService.get<boolean>('EMEX_DEBUG', false);
|
this.debug = this.configService.get<boolean>("EMEX_DEBUG", false);
|
||||||
|
|
||||||
const useProxy = this.configService.get<string>('EMEX_USE_PROXY', 'true') === 'true';
|
const useProxy = this.configService.get<string>("EMEX_USE_PROXY", "true") === "true";
|
||||||
if (useProxy) {
|
if (useProxy) {
|
||||||
const host = this.configService.get<string>('EMEX_PROXY_HOST', '74.81.81.81');
|
const host = this.configService.get<string>("EMEX_PROXY_HOST", "74.81.81.81");
|
||||||
const portStart = this.configService.get<number>('EMEX_PROXY_PORT_START', 10001);
|
const portStart = this.configService.get<number>("EMEX_PROXY_PORT_START", 10001);
|
||||||
const portEnd = this.configService.get<number>('EMEX_PROXY_PORT_END', 10099);
|
const portEnd = this.configService.get<number>("EMEX_PROXY_PORT_END", 10099);
|
||||||
const user = this.configService.get<string>('EMEX_PROXY_USER', '1726bbe361918676d44e');
|
const user = this.configService.get<string>("EMEX_PROXY_USER", "1726bbe361918676d44e");
|
||||||
const pass = this.configService.get<string>('EMEX_PROXY_PASS', 'f11c7b6128cc86c6');
|
const pass = this.configService.get<string>("EMEX_PROXY_PASS", "f11c7b6128cc86c6");
|
||||||
const port = Math.floor(Math.random() * (portEnd - portStart + 1)) + portStart;
|
const port = Math.floor(Math.random() * (portEnd - portStart + 1)) + portStart;
|
||||||
this.proxyAgent = new ProxyAgent({
|
this.proxyAgent = new ProxyAgent({
|
||||||
uri: `http://${user}:${pass}@${host}:${port}`,
|
uri: `http://${user}:${pass}@${host}:${port}`,
|
||||||
@@ -169,7 +167,7 @@ export class EmexService {
|
|||||||
try {
|
try {
|
||||||
this.logger.log(`Loading EMEX scraper module from: ${this.scraperPath}`);
|
this.logger.log(`Loading EMEX scraper module from: ${this.scraperPath}`);
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require("fs");
|
||||||
if (!fs.existsSync(this.scraperPath)) {
|
if (!fs.existsSync(this.scraperPath)) {
|
||||||
this.logger.error(`Scraper file not found at: ${this.scraperPath}`);
|
this.logger.error(`Scraper file not found at: ${this.scraperPath}`);
|
||||||
this.logger.error(`Current working directory: ${process.cwd()}`);
|
this.logger.error(`Current working directory: ${process.cwd()}`);
|
||||||
@@ -181,17 +179,12 @@ export class EmexService {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
this.scraperModule = require(this.scraperPath) as EmexScraperModule;
|
this.scraperModule = require(this.scraperPath) as EmexScraperModule;
|
||||||
|
|
||||||
this.logger.log('EMEX scraper module loaded successfully');
|
this.logger.log("EMEX scraper module loaded successfully");
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
this.logger.error(
|
this.logger.error(`Failed to load EMEX scraper module: ${err.message}`, err.stack);
|
||||||
`Failed to load EMEX scraper module: ${err.message}`,
|
throw new InternalServerErrorException("EMEX servis modulu yuklenemedi");
|
||||||
err.stack,
|
|
||||||
);
|
|
||||||
throw new InternalServerErrorException(
|
|
||||||
'EMEX servis modulu yuklenemedi',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +199,7 @@ export class EmexService {
|
|||||||
await this.initializeScraper();
|
await this.initializeScraper();
|
||||||
|
|
||||||
if (!this.scraperModule) {
|
if (!this.scraperModule) {
|
||||||
throw new InternalServerErrorException('EMEX scraper modulu yuklenemedi');
|
throw new InternalServerErrorException("EMEX scraper modulu yuklenemedi");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { page, release } = await this.browserService.acquirePage();
|
const { page, release } = await this.browserService.acquirePage();
|
||||||
@@ -221,20 +214,18 @@ export class EmexService {
|
|||||||
*/
|
*/
|
||||||
private validateVin(vin: string): void {
|
private validateVin(vin: string): void {
|
||||||
if (!vin) {
|
if (!vin) {
|
||||||
throw new BadRequestException('VIN numarasi gereklidir');
|
throw new BadRequestException("VIN numarasi gereklidir");
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, '');
|
const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, "");
|
||||||
|
|
||||||
if (cleanVin.length !== 17) {
|
if (cleanVin.length !== 17) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException("VIN numarasi 17 karakter olmalidir");
|
||||||
'VIN numarasi 17 karakter olmalidir',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/[IOQ]/i.test(cleanVin)) {
|
if (/[IOQ]/i.test(cleanVin)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'VIN numarasi gecersiz karakterler iceriyor (I, O, Q kullanilamaz)',
|
"VIN numarasi gecersiz karakterler iceriyor (I, O, Q kullanilamaz)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,7 +239,7 @@ export class EmexService {
|
|||||||
*/
|
*/
|
||||||
private async fetchEmexHtml(url: string): Promise<string> {
|
private async fetchEmexHtml(url: string): Promise<string> {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: { 'User-Agent': EMEX_UA, 'Accept': 'text/html,application/xhtml+xml' },
|
headers: { "User-Agent": EMEX_UA, Accept: "text/html,application/xhtml+xml" },
|
||||||
signal: AbortSignal.timeout(this.timeout),
|
signal: AbortSignal.timeout(this.timeout),
|
||||||
...(this.proxyAgent ? { dispatcher: this.proxyAgent } : {}),
|
...(this.proxyAgent ? { dispatcher: this.proxyAgent } : {}),
|
||||||
} as RequestInit);
|
} as RequestInit);
|
||||||
@@ -267,26 +258,27 @@ export class EmexService {
|
|||||||
const vehicles: EmexHttpVehicle[] = [];
|
const vehicles: EmexHttpVehicle[] = [];
|
||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = linkRx.exec(html)) !== null) {
|
while ((m = linkRx.exec(html)) !== null) {
|
||||||
const href = m[1].replace(/&/g, '&');
|
const href = m[1].replace(/&/g, "&");
|
||||||
if (seen.has(href)) continue;
|
if (seen.has(href)) continue;
|
||||||
seen.add(href);
|
seen.add(href);
|
||||||
const label = m[2].trim();
|
const label = m[2].trim();
|
||||||
const params = new URLSearchParams(href.replace('Vehicle.aspx?', ''));
|
const params = new URLSearchParams(href.replace("Vehicle.aspx?", ""));
|
||||||
const c = params.get('c');
|
const c = params.get("c");
|
||||||
const vid = params.get('vid');
|
const vid = params.get("vid");
|
||||||
const ssd = params.get('ssd');
|
const ssd = params.get("ssd");
|
||||||
const modelMatch = label.match(/^([^\[]+)/);
|
const modelMatch = label.match(/^([^\[]+)/);
|
||||||
const yearMatch = label.match(/\((\d{4})/);
|
const yearMatch = label.match(/\((\d{4})/);
|
||||||
vehicles.push({
|
vehicles.push({
|
||||||
label,
|
label,
|
||||||
model: modelMatch ? modelMatch[1].trim() : label,
|
model: modelMatch ? modelMatch[1].trim() : label,
|
||||||
yearFrom: yearMatch ? parseInt(yearMatch[1], 10) : null,
|
yearFrom: yearMatch ? Number.parseInt(yearMatch[1], 10) : null,
|
||||||
catalogCode: c,
|
catalogCode: c,
|
||||||
vid,
|
vid,
|
||||||
ssd,
|
ssd,
|
||||||
quickGroupsUrl: c && vid != null && ssd
|
quickGroupsUrl:
|
||||||
? `${EMEX_BASE_URL}/QuickGroups.aspx?c=${c}&vid=${vid}&ssd=${encodeURIComponent(ssd)}`
|
c && vid != null && ssd
|
||||||
: null,
|
? `${EMEX_BASE_URL}/QuickGroups.aspx?c=${c}&vid=${vid}&ssd=${encodeURIComponent(ssd)}`
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return vehicles;
|
return vehicles;
|
||||||
@@ -301,12 +293,12 @@ export class EmexService {
|
|||||||
const cats: EmexHttpCategory[] = [];
|
const cats: EmexHttpCategory[] = [];
|
||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = catRx.exec(html)) !== null) {
|
while ((m = catRx.exec(html)) !== null) {
|
||||||
const href = m[1].replace(/&/g, '&');
|
const href = m[1].replace(/&/g, "&");
|
||||||
const name = m[2].trim();
|
const name = m[2].trim();
|
||||||
if (name.length < 2 || seen.has(href)) continue;
|
if (name.length < 2 || seen.has(href)) continue;
|
||||||
seen.add(href);
|
seen.add(href);
|
||||||
const params = new URLSearchParams(href.replace('QuickDetails.aspx?', ''));
|
const params = new URLSearchParams(href.replace("QuickDetails.aspx?", ""));
|
||||||
cats.push({ gid: params.get('gid'), name, url: `${EMEX_BASE_URL}/${href}` });
|
cats.push({ gid: params.get("gid"), name, url: `${EMEX_BASE_URL}/${href}` });
|
||||||
}
|
}
|
||||||
return cats;
|
return cats;
|
||||||
}
|
}
|
||||||
@@ -318,13 +310,25 @@ export class EmexService {
|
|||||||
if (!c) return null;
|
if (!c) return null;
|
||||||
const upper = c.toUpperCase();
|
const upper = c.toUpperCase();
|
||||||
const prefixes: [string, string][] = [
|
const prefixes: [string, string][] = [
|
||||||
['BMW', 'BMW'], ['MB', 'Mercedes-Benz'], ['AU', 'Audi'],
|
["BMW", "BMW"],
|
||||||
['VW', 'Volkswagen'], ['FFIAT', 'Fiat'], ['RFIAT', 'Alfa Romeo'],
|
["MB", "Mercedes-Benz"],
|
||||||
['FORD', 'Ford'], ['RENAULT', 'Renault'], ['TOYOTA', 'Toyota'],
|
["AU", "Audi"],
|
||||||
['HONDA', 'Honda'], ['KIA', 'Kia'], ['HYUNDAI', 'Hyundai'],
|
["VW", "Volkswagen"],
|
||||||
['PORSCHE', 'Porsche'], ['SUBARU', 'Subaru'], ['MAZDA', 'Mazda'],
|
["FFIAT", "Fiat"],
|
||||||
['CPSA', 'Citroën/Peugeot'], ['VOLVO', 'Volvo'], ['NISSAN', 'Nissan'],
|
["RFIAT", "Alfa Romeo"],
|
||||||
['OPEL', 'Opel'],
|
["FORD", "Ford"],
|
||||||
|
["RENAULT", "Renault"],
|
||||||
|
["TOYOTA", "Toyota"],
|
||||||
|
["HONDA", "Honda"],
|
||||||
|
["KIA", "Kia"],
|
||||||
|
["HYUNDAI", "Hyundai"],
|
||||||
|
["PORSCHE", "Porsche"],
|
||||||
|
["SUBARU", "Subaru"],
|
||||||
|
["MAZDA", "Mazda"],
|
||||||
|
["CPSA", "Citroën/Peugeot"],
|
||||||
|
["VOLVO", "Volvo"],
|
||||||
|
["NISSAN", "Nissan"],
|
||||||
|
["OPEL", "Opel"],
|
||||||
];
|
];
|
||||||
for (const [prefix, brand] of prefixes) {
|
for (const [prefix, brand] of prefixes) {
|
||||||
if (upper.startsWith(prefix)) return brand;
|
if (upper.startsWith(prefix)) return brand;
|
||||||
@@ -354,7 +358,7 @@ export class EmexService {
|
|||||||
// Determine brand: prefer CATALOG_MAP lookup, then catalog code heuristic
|
// Determine brand: prefer CATALOG_MAP lookup, then catalog code heuristic
|
||||||
const wmi = vin.substring(0, 3).toUpperCase();
|
const wmi = vin.substring(0, 3).toUpperCase();
|
||||||
const catalogEntry = CATALOG_MAP[wmi];
|
const catalogEntry = CATALOG_MAP[wmi];
|
||||||
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || 'Unknown';
|
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || "Unknown";
|
||||||
|
|
||||||
// Fetch categories from QuickGroups.aspx (fast HTTP, no browser)
|
// Fetch categories from QuickGroups.aspx (fast HTTP, no browser)
|
||||||
let categories: EmexHttpCategory[] = [];
|
let categories: EmexHttpCategory[] = [];
|
||||||
@@ -371,10 +375,10 @@ export class EmexService {
|
|||||||
// Build a response compatible with mapEmexResponse
|
// Build a response compatible with mapEmexResponse
|
||||||
const response: EmexScraperResponse = {
|
const response: EmexScraperResponse = {
|
||||||
success: true,
|
success: true,
|
||||||
source: 'emexdwc.ae',
|
source: "emexdwc.ae",
|
||||||
method: 'vin_url',
|
method: "vin_url",
|
||||||
vin,
|
vin,
|
||||||
catalogCode: v.catalogCode || '',
|
catalogCode: v.catalogCode || "",
|
||||||
ssd: v.ssd || undefined,
|
ssd: v.ssd || undefined,
|
||||||
vehicle: {
|
vehicle: {
|
||||||
brand,
|
brand,
|
||||||
@@ -388,7 +392,7 @@ export class EmexService {
|
|||||||
driveType: null,
|
driveType: null,
|
||||||
},
|
},
|
||||||
quickGroupsUrl: v.quickGroupsUrl || null,
|
quickGroupsUrl: v.quickGroupsUrl || null,
|
||||||
categories: categories.map((c) => ({ gid: c.gid || '', name: c.name, url: c.url })),
|
categories: categories.map((c) => ({ gid: c.gid || "", name: c.name, url: c.url })),
|
||||||
categoryTree: [],
|
categoryTree: [],
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -403,43 +407,45 @@ export class EmexService {
|
|||||||
* - `{ type: 'notFound' }` — VIN not in EMEX
|
* - `{ type: 'notFound' }` — VIN not in EMEX
|
||||||
* - `{ type: 'error' }` — fetch failed
|
* - `{ type: 'error' }` — fetch failed
|
||||||
*/
|
*/
|
||||||
async decodeVinOrCandidates(vin: string): Promise<
|
async decodeVinOrCandidates(
|
||||||
| { type: 'vehicle'; vehicle: DecodedVehicle }
|
vin: string,
|
||||||
| { type: 'candidates'; candidates: EmexCandidate[] }
|
): Promise<
|
||||||
| { type: 'notFound' }
|
| { type: "vehicle"; vehicle: DecodedVehicle }
|
||||||
| { type: 'error' }
|
| { type: "candidates"; candidates: EmexCandidate[] }
|
||||||
|
| { type: "notFound" }
|
||||||
|
| { type: "error" }
|
||||||
> {
|
> {
|
||||||
try {
|
try {
|
||||||
const vinUrl = `${EMEX_BASE_URL}/Vehicles.aspx?ft=findByVIN&c=&ssd=&vin=${vin}`;
|
const vinUrl = `${EMEX_BASE_URL}/Vehicles.aspx?ft=findByVIN&c=&ssd=&vin=${vin}`;
|
||||||
const html = await this.fetchEmexHtml(vinUrl);
|
const html = await this.fetchEmexHtml(vinUrl);
|
||||||
const vehicleList = this.parseVehiclesList(html);
|
const vehicleList = this.parseVehiclesList(html);
|
||||||
|
|
||||||
if (vehicleList.length === 0) return { type: 'notFound' };
|
if (vehicleList.length === 0) return { type: "notFound" };
|
||||||
|
|
||||||
if (vehicleList.length > 1) {
|
if (vehicleList.length > 1) {
|
||||||
const candidates: EmexCandidate[] = vehicleList.map((v, i) => {
|
const candidates: EmexCandidate[] = vehicleList.map((v, i) => {
|
||||||
const params: Array<{ key: string; idx: string; value: string }> = [];
|
const params: Array<{ key: string; idx: string; value: string }> = [];
|
||||||
if (v.yearFrom) params.push({ key: 'year', idx: '0', value: String(v.yearFrom) });
|
if (v.yearFrom) params.push({ key: "year", idx: "0", value: String(v.yearFrom) });
|
||||||
if (v.catalogCode) params.push({ key: 'catalog', idx: '1', value: v.catalogCode });
|
if (v.catalogCode) params.push({ key: "catalog", idx: "1", value: v.catalogCode });
|
||||||
return {
|
return {
|
||||||
id: String(i),
|
id: String(i),
|
||||||
name: v.label,
|
name: v.label,
|
||||||
parameters: params,
|
parameters: params,
|
||||||
catalogId: v.catalogCode || '',
|
catalogId: v.catalogCode || "",
|
||||||
_index: i,
|
_index: i,
|
||||||
_quickGroupsUrl: v.quickGroupsUrl,
|
_quickGroupsUrl: v.quickGroupsUrl,
|
||||||
_ssd: v.ssd,
|
_ssd: v.ssd,
|
||||||
_vid: v.vid,
|
_vid: v.vid,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return { type: 'candidates', candidates };
|
return { type: "candidates", candidates };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single result — decode directly
|
// Single result — decode directly
|
||||||
const v = vehicleList[0];
|
const v = vehicleList[0];
|
||||||
const wmi = vin.substring(0, 3).toUpperCase();
|
const wmi = vin.substring(0, 3).toUpperCase();
|
||||||
const catalogEntry = CATALOG_MAP[wmi];
|
const catalogEntry = CATALOG_MAP[wmi];
|
||||||
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || 'Unknown';
|
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || "Unknown";
|
||||||
|
|
||||||
let categories: EmexHttpCategory[] = [];
|
let categories: EmexHttpCategory[] = [];
|
||||||
if (v.quickGroupsUrl) {
|
if (v.quickGroupsUrl) {
|
||||||
@@ -454,10 +460,10 @@ export class EmexService {
|
|||||||
|
|
||||||
const response: EmexScraperResponse = {
|
const response: EmexScraperResponse = {
|
||||||
success: true,
|
success: true,
|
||||||
source: 'emexdwc.ae',
|
source: "emexdwc.ae",
|
||||||
method: 'vin_url',
|
method: "vin_url",
|
||||||
vin,
|
vin,
|
||||||
catalogCode: v.catalogCode || '',
|
catalogCode: v.catalogCode || "",
|
||||||
ssd: v.ssd || undefined,
|
ssd: v.ssd || undefined,
|
||||||
vehicle: {
|
vehicle: {
|
||||||
brand,
|
brand,
|
||||||
@@ -471,15 +477,15 @@ export class EmexService {
|
|||||||
driveType: null,
|
driveType: null,
|
||||||
},
|
},
|
||||||
quickGroupsUrl: v.quickGroupsUrl || null,
|
quickGroupsUrl: v.quickGroupsUrl || null,
|
||||||
categories: categories.map((c) => ({ gid: c.gid || '', name: c.name, url: c.url })),
|
categories: categories.map((c) => ({ gid: c.gid || "", name: c.name, url: c.url })),
|
||||||
categoryTree: [],
|
categoryTree: [],
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return { type: 'vehicle', vehicle: mapEmexResponse(response) };
|
return { type: "vehicle", vehicle: mapEmexResponse(response) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`decodeVinOrCandidates failed: ${(err as Error).message}`);
|
this.logger.warn(`decodeVinOrCandidates failed: ${(err as Error).message}`);
|
||||||
return { type: 'error' };
|
return { type: "error" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,7 +500,9 @@ export class EmexService {
|
|||||||
const vehicleList = this.parseVehiclesList(vinHtml);
|
const vehicleList = this.parseVehiclesList(vinHtml);
|
||||||
|
|
||||||
if (index < 0 || index >= vehicleList.length) {
|
if (index < 0 || index >= vehicleList.length) {
|
||||||
this.logger.warn(`EMEX decodeVinByIndex: index ${index} out of range (${vehicleList.length} vehicles)`);
|
this.logger.warn(
|
||||||
|
`EMEX decodeVinByIndex: index ${index} out of range (${vehicleList.length} vehicles)`,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,7 +511,7 @@ export class EmexService {
|
|||||||
|
|
||||||
const wmi = vin.substring(0, 3).toUpperCase();
|
const wmi = vin.substring(0, 3).toUpperCase();
|
||||||
const catalogEntry = CATALOG_MAP[wmi];
|
const catalogEntry = CATALOG_MAP[wmi];
|
||||||
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || 'Unknown';
|
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || "Unknown";
|
||||||
|
|
||||||
let categories: EmexHttpCategory[] = [];
|
let categories: EmexHttpCategory[] = [];
|
||||||
if (v.quickGroupsUrl) {
|
if (v.quickGroupsUrl) {
|
||||||
@@ -511,16 +519,18 @@ export class EmexService {
|
|||||||
const qgHtml = await this.fetchEmexHtml(v.quickGroupsUrl);
|
const qgHtml = await this.fetchEmexHtml(v.quickGroupsUrl);
|
||||||
categories = this.parseCategoryList(qgHtml);
|
categories = this.parseCategoryList(qgHtml);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`EMEX decodeVinByIndex category fetch failed: ${(err as Error).message}`);
|
this.logger.warn(
|
||||||
|
`EMEX decodeVinByIndex category fetch failed: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response: EmexScraperResponse = {
|
const response: EmexScraperResponse = {
|
||||||
success: true,
|
success: true,
|
||||||
source: 'emexdwc.ae',
|
source: "emexdwc.ae",
|
||||||
method: 'vin_url',
|
method: "vin_url",
|
||||||
vin,
|
vin,
|
||||||
catalogCode: v.catalogCode || '',
|
catalogCode: v.catalogCode || "",
|
||||||
ssd: v.ssd || undefined,
|
ssd: v.ssd || undefined,
|
||||||
vehicle: {
|
vehicle: {
|
||||||
brand,
|
brand,
|
||||||
@@ -534,7 +544,7 @@ export class EmexService {
|
|||||||
driveType: null,
|
driveType: null,
|
||||||
},
|
},
|
||||||
quickGroupsUrl: v.quickGroupsUrl || null,
|
quickGroupsUrl: v.quickGroupsUrl || null,
|
||||||
categories: categories.map((c) => ({ gid: c.gid || '', name: c.name, url: c.url })),
|
categories: categories.map((c) => ({ gid: c.gid || "", name: c.name, url: c.url })),
|
||||||
categoryTree: [],
|
categoryTree: [],
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -552,7 +562,7 @@ export class EmexService {
|
|||||||
* Fallback: Playwright browser scraper (slower, used if HTTP fails).
|
* Fallback: Playwright browser scraper (slower, used if HTTP fails).
|
||||||
*/
|
*/
|
||||||
async decodeVin(vin: string): Promise<DecodedVehicle> {
|
async decodeVin(vin: string): Promise<DecodedVehicle> {
|
||||||
const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, '');
|
const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, "");
|
||||||
|
|
||||||
this.validateVin(cleanVin);
|
this.validateVin(cleanVin);
|
||||||
|
|
||||||
@@ -564,13 +574,11 @@ export class EmexService {
|
|||||||
try {
|
try {
|
||||||
const result = await this.decodeVinHttp(cleanVin);
|
const result = await this.decodeVinHttp(cleanVin);
|
||||||
if (result) {
|
if (result) {
|
||||||
this.logger.log(
|
this.logger.log(`EMEX HTTP decode OK: ${result.brand} ${result.model} (${result.year})`);
|
||||||
`EMEX HTTP decode OK: ${result.brand} ${result.model} (${result.year})`,
|
|
||||||
);
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
// VIN not in EMEX — return empty rather than hitting browser
|
// VIN not in EMEX — return empty rather than hitting browser
|
||||||
return createEmptyDecodedVehicle(cleanVin, 'Vehicle not found in EMEX database');
|
return createEmptyDecodedVehicle(cleanVin, "Vehicle not found in EMEX database");
|
||||||
} catch (httpErr) {
|
} catch (httpErr) {
|
||||||
const err = httpErr as Error;
|
const err = httpErr as Error;
|
||||||
this.logger.warn(`EMEX HTTP decode failed (${err.message}), falling back to browser`);
|
this.logger.warn(`EMEX HTTP decode failed (${err.message}), falling back to browser`);
|
||||||
@@ -584,28 +592,18 @@ export class EmexService {
|
|||||||
const scraper = instance.scraper;
|
const scraper = instance.scraper;
|
||||||
release = instance.release;
|
release = instance.release;
|
||||||
|
|
||||||
const response = await this.executeWithTimeout(
|
const response = await this.executeWithTimeout(scraper.searchByVIN(cleanVin), this.timeout);
|
||||||
scraper.searchByVIN(cleanVin),
|
|
||||||
this.timeout,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
this.logger.debug(
|
this.logger.debug(`EMEX browser raw response: ${JSON.stringify(response, null, 2)}`);
|
||||||
`EMEX browser raw response: ${JSON.stringify(response, null, 2)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
this.logger.warn(
|
this.logger.warn(`EMEX browser search unsuccessful: ${response.message || response.error}`);
|
||||||
`EMEX browser search unsuccessful: ${response.message || response.error}`,
|
|
||||||
);
|
|
||||||
if (response.vehicle && response.vehicle.brand) {
|
if (response.vehicle && response.vehicle.brand) {
|
||||||
return mapEmexResponse(response);
|
return mapEmexResponse(response);
|
||||||
}
|
}
|
||||||
return createEmptyDecodedVehicle(
|
return createEmptyDecodedVehicle(cleanVin, response.message || response.error);
|
||||||
cleanVin,
|
|
||||||
response.message || response.error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const decodedVehicle = mapEmexResponse(response);
|
const decodedVehicle = mapEmexResponse(response);
|
||||||
@@ -624,17 +622,15 @@ export class EmexService {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err.message?.includes('timeout') || err.name === 'TimeoutError') {
|
if (err.message?.includes("timeout") || err.name === "TimeoutError") {
|
||||||
this.logger.error(`VIN decode timeout for: ${cleanVin}`);
|
this.logger.error(`VIN decode timeout for: ${cleanVin}`);
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
'EMEX servisi zaman asimina ugradi. Lutfen tekrar deneyin.',
|
"EMEX servisi zaman asimina ugradi. Lutfen tekrar deneyin.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.error(`VIN decode error: ${err.message}`, err.stack);
|
this.logger.error(`VIN decode error: ${err.message}`, err.stack);
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException("VIN sorgulama sirasinda bir hata olustu");
|
||||||
'VIN sorgulama sirasinda bir hata olustu',
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (release) {
|
if (release) {
|
||||||
try {
|
try {
|
||||||
@@ -650,16 +646,13 @@ export class EmexService {
|
|||||||
/**
|
/**
|
||||||
* Executes a promise with timeout
|
* Executes a promise with timeout
|
||||||
*/
|
*/
|
||||||
private async executeWithTimeout<T>(
|
private async executeWithTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||||
promise: Promise<T>,
|
|
||||||
timeoutMs: number,
|
|
||||||
): Promise<T> {
|
|
||||||
let timeoutId: NodeJS.Timeout;
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
timeoutId = setTimeout(() => {
|
timeoutId = setTimeout(() => {
|
||||||
const error = new Error(`Operation timed out after ${timeoutMs}ms`);
|
const error = new Error(`Operation timed out after ${timeoutMs}ms`);
|
||||||
error.name = 'TimeoutError';
|
error.name = "TimeoutError";
|
||||||
reject(error);
|
reject(error);
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
});
|
});
|
||||||
@@ -709,7 +702,7 @@ export class EmexService {
|
|||||||
*/
|
*/
|
||||||
async fetchCategoryParts(categoryUrl: string): Promise<EmexPartsResult> {
|
async fetchCategoryParts(categoryUrl: string): Promise<EmexPartsResult> {
|
||||||
if (!categoryUrl) {
|
if (!categoryUrl) {
|
||||||
this.logger.warn('fetchCategoryParts called with empty URL');
|
this.logger.warn("fetchCategoryParts called with empty URL");
|
||||||
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
|
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -723,10 +716,7 @@ export class EmexService {
|
|||||||
const scraper = instance.scraper;
|
const scraper = instance.scraper;
|
||||||
release = instance.release;
|
release = instance.release;
|
||||||
|
|
||||||
const result = await this.executeWithTimeout(
|
const result = await this.executeWithTimeout(scraper.getParts(categoryUrl), this.timeout);
|
||||||
scraper.getParts(categoryUrl),
|
|
||||||
this.timeout,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result && result.parts.length > 0) {
|
if (result && result.parts.length > 0) {
|
||||||
this.logger.log(`Fetched ${result.parts.length} parts from category`);
|
this.logger.log(`Fetched ${result.parts.length} parts from category`);
|
||||||
@@ -763,15 +753,15 @@ export class EmexService {
|
|||||||
|
|
||||||
const yearChar = vin.charAt(9).toUpperCase();
|
const yearChar = vin.charAt(9).toUpperCase();
|
||||||
const yearMap: Record<string, number> = {
|
const yearMap: Record<string, number> = {
|
||||||
'1': 2001,
|
"1": 2001,
|
||||||
'2': 2002,
|
"2": 2002,
|
||||||
'3': 2003,
|
"3": 2003,
|
||||||
'4': 2004,
|
"4": 2004,
|
||||||
'5': 2005,
|
"5": 2005,
|
||||||
'6': 2006,
|
"6": 2006,
|
||||||
'7': 2007,
|
"7": 2007,
|
||||||
'8': 2008,
|
"8": 2008,
|
||||||
'9': 2009,
|
"9": 2009,
|
||||||
A: 2010,
|
A: 2010,
|
||||||
B: 2011,
|
B: 2011,
|
||||||
C: 2012,
|
C: 2012,
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export interface EmexWizardOption {
|
|||||||
export interface EmexScraperResponse {
|
export interface EmexScraperResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
source: string;
|
source: string;
|
||||||
method: 'api' | 'vin_url' | 'wizard' | 'html_parse' | 'fallback';
|
method: "api" | "vin_url" | "wizard" | "html_parse" | "fallback";
|
||||||
vin: string;
|
vin: string;
|
||||||
catalogCode: string;
|
catalogCode: string;
|
||||||
ssd?: string;
|
ssd?: string;
|
||||||
@@ -231,33 +231,33 @@ export interface CatalogEntry {
|
|||||||
* WMI (World Manufacturer Identifier) to catalog mapping
|
* WMI (World Manufacturer Identifier) to catalog mapping
|
||||||
*/
|
*/
|
||||||
export const CATALOG_MAP: Record<string, CatalogEntry> = {
|
export const CATALOG_MAP: Record<string, CatalogEntry> = {
|
||||||
WBA: { code: 'BMW202501', brand: 'BMW' },
|
WBA: { code: "BMW202501", brand: "BMW" },
|
||||||
WBS: { code: 'BMW202501', brand: 'BMW' },
|
WBS: { code: "BMW202501", brand: "BMW" },
|
||||||
WBY: { code: 'BMW202501', brand: 'BMW' },
|
WBY: { code: "BMW202501", brand: "BMW" },
|
||||||
WDB: { code: 'MB201810', brand: 'Mercedes-Benz' },
|
WDB: { code: "MB201810", brand: "Mercedes-Benz" },
|
||||||
WDD: { code: 'MB201810', brand: 'Mercedes-Benz' },
|
WDD: { code: "MB201810", brand: "Mercedes-Benz" },
|
||||||
WDC: { code: 'MB201810', brand: 'Mercedes-Benz' },
|
WDC: { code: "MB201810", brand: "Mercedes-Benz" },
|
||||||
WDF: { code: 'MB201810', brand: 'Mercedes-Benz' },
|
WDF: { code: "MB201810", brand: "Mercedes-Benz" },
|
||||||
WAU: { code: 'AU1587', brand: 'Audi' },
|
WAU: { code: "AU1587", brand: "Audi" },
|
||||||
WVW: { code: 'VW1587', brand: 'Volkswagen' },
|
WVW: { code: "VW1587", brand: "Volkswagen" },
|
||||||
WVG: { code: 'VW1587', brand: 'Volkswagen' },
|
WVG: { code: "VW1587", brand: "Volkswagen" },
|
||||||
VF1: { code: 'RENAULT201910', brand: 'Renault' },
|
VF1: { code: "RENAULT201910", brand: "Renault" },
|
||||||
VF7: { code: 'CPSA01', brand: 'Peugeot' },
|
VF7: { code: "CPSA01", brand: "Peugeot" },
|
||||||
VF3: { code: 'CPSA01', brand: 'Peugeot' },
|
VF3: { code: "CPSA01", brand: "Peugeot" },
|
||||||
ZFA: { code: 'CFIAT84', brand: 'Fiat' },
|
ZFA: { code: "CFIAT84", brand: "Fiat" },
|
||||||
ZAR: { code: 'RFIAT84', brand: 'Alfa Romeo' },
|
ZAR: { code: "RFIAT84", brand: "Alfa Romeo" },
|
||||||
WF0: { code: 'FORD202201', brand: 'Ford' },
|
WF0: { code: "FORD202201", brand: "Ford" },
|
||||||
NM0: { code: 'FORD202201', brand: 'Ford' },
|
NM0: { code: "FORD202201", brand: "Ford" },
|
||||||
JTD: { code: 'TOYOTA00', brand: 'Toyota' },
|
JTD: { code: "TOYOTA00", brand: "Toyota" },
|
||||||
JTE: { code: 'TOYOTA00', brand: 'Toyota' },
|
JTE: { code: "TOYOTA00", brand: "Toyota" },
|
||||||
SHH: { code: 'HONDA00', brand: 'Honda' },
|
SHH: { code: "HONDA00", brand: "Honda" },
|
||||||
KNM: { code: 'HYUNDAI00', brand: 'Hyundai' },
|
KNM: { code: "HYUNDAI00", brand: "Hyundai" },
|
||||||
KNA: { code: 'KIA00', brand: 'Kia' },
|
KNA: { code: "KIA00", brand: "Kia" },
|
||||||
WP0: { code: 'PO799', brand: 'Porsche' },
|
WP0: { code: "PO799", brand: "Porsche" },
|
||||||
WP1: { code: 'PO799', brand: 'Porsche' },
|
WP1: { code: "PO799", brand: "Porsche" },
|
||||||
JF1: { code: 'SUBARU201802', brand: 'Subaru' },
|
JF1: { code: "SUBARU201802", brand: "Subaru" },
|
||||||
JF2: { code: 'SUBARU201802', brand: 'Subaru' },
|
JF2: { code: "SUBARU201802", brand: "Subaru" },
|
||||||
JMZ: { code: 'MAZDA2020', brand: 'Mazda' },
|
JMZ: { code: "MAZDA2020", brand: "Mazda" },
|
||||||
JM1: { code: 'MAZDA2020', brand: 'Mazda' },
|
JM1: { code: "MAZDA2020", brand: "Mazda" },
|
||||||
JM3: { code: 'MAZDA2020', brand: 'Mazda' },
|
JM3: { code: "MAZDA2020", brand: "Mazda" },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,15 +13,10 @@
|
|||||||
* 19:00-09:00 → on-demand only: capture only when needed
|
* 19:00-09:00 → on-demand only: capture only when needed
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
|
||||||
Injectable,
|
import type { ConfigService } from "@nestjs/config";
|
||||||
Logger,
|
|
||||||
OnModuleInit,
|
|
||||||
OnModuleDestroy,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import type { Browser, BrowserContext } from "playwright";
|
import type { Browser, BrowserContext } from "playwright";
|
||||||
import type { PcatJwtToken, JwtSlot, PcatSession } from "./parts-catalogs.types";
|
import type { JwtSlot, PcatJwtToken, PcatSession } from "./parts-catalogs.types";
|
||||||
|
|
||||||
const TOKEN_TTL = 600; // seconds — TWS- has no built-in expiry, refresh aggressively
|
const TOKEN_TTL = 600; // seconds — TWS- has no built-in expiry, refresh aggressively
|
||||||
const REFRESH_BUFFER = 90; // Refresh 90s before expiry
|
const REFRESH_BUFFER = 90; // Refresh 90s before expiry
|
||||||
@@ -113,17 +108,10 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly proxyPass: string;
|
private readonly proxyPass: string;
|
||||||
|
|
||||||
constructor(private configService: ConfigService) {
|
constructor(private configService: ConfigService) {
|
||||||
this.useProxy =
|
this.useProxy = this.configService.get<string>("PCAT_USE_PROXY", "true") === "true";
|
||||||
this.configService.get<string>("PCAT_USE_PROXY", "true") === "true";
|
|
||||||
this.proxyHost = this.configService.get<string>("PCAT_PROXY_HOST", DI_HOST);
|
this.proxyHost = this.configService.get<string>("PCAT_PROXY_HOST", DI_HOST);
|
||||||
this.proxyUser = this.configService.get<string>(
|
this.proxyUser = this.configService.get<string>("PCAT_PROXY_USER", DI_DEFAULT_USER);
|
||||||
"PCAT_PROXY_USER",
|
this.proxyPass = this.configService.get<string>("PCAT_PROXY_PASS", DI_DEFAULT_PASS);
|
||||||
DI_DEFAULT_USER,
|
|
||||||
);
|
|
||||||
this.proxyPass = this.configService.get<string>(
|
|
||||||
"PCAT_PROXY_PASS",
|
|
||||||
DI_DEFAULT_PASS,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
@@ -131,9 +119,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
await this.launchBrowser();
|
await this.launchBrowser();
|
||||||
this.logger.log("Browser launched for JWT capture");
|
this.logger.log("Browser launched for JWT capture");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(`Failed to launch browser on init: ${(err as Error).message}`);
|
||||||
`Failed to launch browser on init: ${(err as Error).message}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start business hours scheduling
|
// Start business hours scheduling
|
||||||
@@ -279,9 +265,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ttl = jwt.exp - Math.floor(Date.now() / 1000);
|
const ttl = jwt.exp - Math.floor(Date.now() / 1000);
|
||||||
const refreshIn = this.isBusinessHours()
|
const refreshIn = this.isBusinessHours() ? Math.max(ttl - REFRESH_BUFFER, 30) : null;
|
||||||
? Math.max(ttl - REFRESH_BUFFER, 30)
|
|
||||||
: null;
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`JWT pool: slot captured, TTL: ${ttl}s${refreshIn ? `, refresh in ${refreshIn}s` : ""}, pool size: ${this.pool.length}`,
|
`JWT pool: slot captured, TTL: ${ttl}s${refreshIn ? `, refresh in ${refreshIn}s` : ""}, pool size: ${this.pool.length}`,
|
||||||
);
|
);
|
||||||
@@ -363,8 +347,8 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
hour12: false,
|
hour12: false,
|
||||||
}).formatToParts(new Date());
|
}).formatToParts(new Date());
|
||||||
|
|
||||||
const hour = parseInt(parts.find((p) => p.type === "hour")!.value, 10);
|
const hour = Number.parseInt(parts.find((p) => p.type === "hour")!.value, 10);
|
||||||
const minute = parseInt(parts.find((p) => p.type === "minute")!.value, 10);
|
const minute = Number.parseInt(parts.find((p) => p.type === "minute")!.value, 10);
|
||||||
return { hour, minute };
|
return { hour, minute };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,7 +462,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
// All on cooldown — pick the one with oldest usage
|
// All on cooldown — pick the one with oldest usage
|
||||||
let oldestIdx = 0;
|
let oldestIdx = 0;
|
||||||
let oldestTime = Infinity;
|
let oldestTime = Number.POSITIVE_INFINITY;
|
||||||
for (let i = 0; i < JWT_SITES.length; i++) {
|
for (let i = 0; i < JWT_SITES.length; i++) {
|
||||||
const lastUsed = this.siteLastUsedAt.get(JWT_SITES[i]) || 0;
|
const lastUsed = this.siteLastUsedAt.get(JWT_SITES[i]) || 0;
|
||||||
if (lastUsed < oldestTime) {
|
if (lastUsed < oldestTime) {
|
||||||
@@ -496,10 +480,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
// ─── JWT capture via Playwright ───────────────────────────
|
// ─── JWT capture via Playwright ───────────────────────────
|
||||||
|
|
||||||
private async attemptCapture(
|
private async attemptCapture(siteUrl: string, port: number): Promise<PcatJwtToken | null> {
|
||||||
siteUrl: string,
|
|
||||||
port: number,
|
|
||||||
): Promise<PcatJwtToken | null> {
|
|
||||||
let context: BrowserContext | null = null;
|
let context: BrowserContext | null = null;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
@@ -574,9 +555,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
} catch (navErr) {
|
} catch (navErr) {
|
||||||
// Navigation may timeout but JWT could still be captured
|
// Navigation may timeout but JWT could still be captured
|
||||||
this.logger.debug(
|
this.logger.debug(`Navigation ended: ${(navErr as Error).message?.slice(0, 80)}`);
|
||||||
`Navigation ended: ${(navErr as Error).message?.slice(0, 80)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll for token
|
// Poll for token
|
||||||
@@ -588,9 +567,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const elapsed = Date.now() - startTime;
|
const elapsed = Date.now() - startTime;
|
||||||
|
|
||||||
if (capturedToken) {
|
if (capturedToken) {
|
||||||
this.logger.log(
|
this.logger.log(`Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`);
|
||||||
`Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`,
|
|
||||||
);
|
|
||||||
return capturedToken;
|
return capturedToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
|
import type { RedisService } from "../../redis/redis.service";
|
||||||
import { RedisService } from "../../redis/redis.service";
|
import type { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
|
||||||
import type {
|
import type {
|
||||||
PcatVinResult,
|
|
||||||
PcatCar,
|
PcatCar,
|
||||||
PcatGroup,
|
PcatGroup,
|
||||||
PcatPartsResult,
|
PcatPartsResult,
|
||||||
PcatSession,
|
PcatSession,
|
||||||
|
PcatVinResult,
|
||||||
} from "./parts-catalogs.types";
|
} from "./parts-catalogs.types";
|
||||||
|
|
||||||
const API_BASE = "https://gui.parts-catalogs.com/v3/api/proxy";
|
const API_BASE = "https://gui.parts-catalogs.com/v3/api/proxy";
|
||||||
@@ -95,10 +95,7 @@ export class PartsCatalogsService {
|
|||||||
if (groupId) params.groupId = groupId;
|
if (groupId) params.groupId = groupId;
|
||||||
if (carParams) Object.assign(params, carParams);
|
if (carParams) Object.assign(params, carParams);
|
||||||
|
|
||||||
const data = await this.fetchWithAuth(
|
const data = await this.fetchWithAuth(`/catalogs/${catalogId}/groups2/`, params);
|
||||||
`/catalogs/${catalogId}/groups2/`,
|
|
||||||
params,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!Array.isArray(data)) return [];
|
if (!Array.isArray(data)) return [];
|
||||||
|
|
||||||
@@ -125,10 +122,7 @@ export class PartsCatalogsService {
|
|||||||
const params: Record<string, string> = { carId, groupId };
|
const params: Record<string, string> = { carId, groupId };
|
||||||
if (carParams) Object.assign(params, carParams);
|
if (carParams) Object.assign(params, carParams);
|
||||||
|
|
||||||
const data = await this.fetchWithAuth(
|
const data = await this.fetchWithAuth(`/catalogs/${catalogId}/parts2`, params);
|
||||||
`/catalogs/${catalogId}/parts2`,
|
|
||||||
params,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!data || typeof data !== "object") return null;
|
if (!data || typeof data !== "object") return null;
|
||||||
|
|
||||||
@@ -171,10 +165,7 @@ export class PartsCatalogsService {
|
|||||||
|
|
||||||
// ─── Private ─────────────────────────────────────────────
|
// ─── Private ─────────────────────────────────────────────
|
||||||
|
|
||||||
private async fetchWithAuth(
|
private async fetchWithAuth(endpoint: string, params?: Record<string, string>): Promise<any> {
|
||||||
endpoint: string,
|
|
||||||
params?: Record<string, string>,
|
|
||||||
): Promise<any> {
|
|
||||||
const maxRetries = 2;
|
const maxRetries = 2;
|
||||||
|
|
||||||
let session: PcatSession | null = null;
|
let session: PcatSession | null = null;
|
||||||
@@ -229,9 +220,7 @@ export class PartsCatalogsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const text = await response.text().catch(() => "");
|
const text = await response.text().catch(() => "");
|
||||||
throw new Error(
|
throw new Error(`HTTP ${response.status} from ${endpoint}: ${text.slice(0, 200)}`);
|
||||||
`HTTP ${response.status} from ${endpoint}: ${text.slice(0, 200)}`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as Error).name === "TimeoutError") {
|
if ((err as Error).name === "TimeoutError") {
|
||||||
this.logger.warn(`Timeout on ${endpoint}, attempt ${attempt + 1}`);
|
this.logger.warn(`Timeout on ${endpoint}, attempt ${attempt + 1}`);
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
* IP-bound (must be reused with the same proxy port that captured it).
|
* IP-bound (must be reused with the same proxy port that captured it).
|
||||||
*/
|
*/
|
||||||
export interface PcatJwtToken {
|
export interface PcatJwtToken {
|
||||||
raw: string; // x-api-key value, e.g. "TWS-016EA7BE-..."
|
raw: string; // x-api-key value, e.g. "TWS-016EA7BE-..."
|
||||||
exp: number; // unix epoch seconds (capturedAt + TTL_FALLBACK)
|
exp: number; // unix epoch seconds (capturedAt + TTL_FALLBACK)
|
||||||
apiPath: string; // x-api-path (upstream PCAT API base URL)
|
apiPath: string; // x-api-path (upstream PCAT API base URL)
|
||||||
guiVersion: string; // x-gui-version (e.g. "3")
|
guiVersion: string; // x-gui-version (e.g. "3")
|
||||||
userId: string; // x-user-id (per-session UUID minted by widget)
|
userId: string; // x-user-id (per-session UUID minted by widget)
|
||||||
origin: string; // partner-site origin
|
origin: string; // partner-site origin
|
||||||
referer: string; // partner-site referer
|
referer: string; // partner-site referer
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JwtSlot {
|
export interface JwtSlot {
|
||||||
@@ -22,7 +22,7 @@ export interface JwtSlot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PcatSession {
|
export interface PcatSession {
|
||||||
apiKey: string; // x-api-key (TWS- token)
|
apiKey: string; // x-api-key (TWS- token)
|
||||||
apiPath: string;
|
apiPath: string;
|
||||||
guiVersion: string;
|
guiVersion: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* response normalization.
|
* response normalization.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PL24DecodedVehicle, PL24DecodedCategory, PL24Part } from "../pl24.types";
|
import type { PL24DecodedCategory, PL24DecodedVehicle, PL24Part } from "../pl24.types";
|
||||||
|
|
||||||
export abstract class BasePL24Parser {
|
export abstract class BasePL24Parser {
|
||||||
abstract readonly brandName: string;
|
abstract readonly brandName: string;
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
* parsing is done directly in PL24Service using the actual API response format.
|
* parsing is done directly in PL24Service using the actual API response format.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { PL24DecodedCategory, PL24DecodedVehicle, PL24Part } from "../pl24.types";
|
||||||
import { BasePL24Parser } from "./base-parser";
|
import { BasePL24Parser } from "./base-parser";
|
||||||
import type { PL24DecodedVehicle, PL24DecodedCategory, PL24Part } from "../pl24.types";
|
|
||||||
|
|
||||||
export class GenericPL24Parser extends BasePL24Parser {
|
export class GenericPL24Parser extends BasePL24Parser {
|
||||||
readonly brandName: string;
|
readonly brandName: string;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { BasePL24Parser } from "./base-parser";
|
|
||||||
import { BmwPL24Parser } from "./bmw-parser";
|
|
||||||
import { MercedesPL24Parser } from "./mercedes-parser";
|
|
||||||
import { GenericPL24Parser } from "./generic-parser";
|
|
||||||
import { PL24_WMI_SERVICE_MAP, isP5Modern } from "../pl24.types";
|
import { PL24_WMI_SERVICE_MAP, isP5Modern } from "../pl24.types";
|
||||||
|
import type { BasePL24Parser } from "./base-parser";
|
||||||
|
import { BmwPL24Parser } from "./bmw-parser";
|
||||||
|
import { GenericPL24Parser } from "./generic-parser";
|
||||||
|
import { MercedesPL24Parser } from "./mercedes-parser";
|
||||||
|
|
||||||
const PARSER_MAP: Record<string, () => BasePL24Parser> = {
|
const PARSER_MAP: Record<string, () => BasePL24Parser> = {
|
||||||
BMW: () => new BmwPL24Parser(),
|
BMW: () => new BmwPL24Parser(),
|
||||||
|
|||||||
@@ -8,15 +8,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
import { Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import type { ConfigService } from "@nestjs/config";
|
||||||
import { PL24_ENDPOINTS } from "./pl24.constants";
|
import { PL24_ENDPOINTS } from "./pl24.constants";
|
||||||
import type {
|
import type {
|
||||||
|
PL24AuthorizeRequest,
|
||||||
|
PL24AuthorizeResponse,
|
||||||
|
PL24JWTPayload,
|
||||||
PL24LoginRequest,
|
PL24LoginRequest,
|
||||||
PL24LoginResponse,
|
PL24LoginResponse,
|
||||||
PL24TokenData,
|
PL24TokenData,
|
||||||
PL24JWTPayload,
|
|
||||||
PL24AuthorizeRequest,
|
|
||||||
PL24AuthorizeResponse,
|
|
||||||
} from "./pl24.types";
|
} from "./pl24.types";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -282,7 +282,9 @@ export class PL24AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!data.token?.access_token) {
|
if (!data.token?.access_token) {
|
||||||
this.logger.error(`PL24 login failed: ${data.status} - ${data.message || "No token returned"}`);
|
this.logger.error(
|
||||||
|
`PL24 login failed: ${data.status} - ${data.message || "No token returned"}`,
|
||||||
|
);
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
`PL24 giris basarisiz: ${data.message || data.status || "Token alinamadi"}`,
|
`PL24 giris basarisiz: ${data.message || data.status || "Token alinamadi"}`,
|
||||||
);
|
);
|
||||||
@@ -300,7 +302,9 @@ export class PL24AuthService {
|
|||||||
services: payload.services || [],
|
services: payload.services || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log(`PL24 login (tr) successful. Token expires at ${this.tokenData.expiresAt.toISOString()}`);
|
this.logger.log(
|
||||||
|
`PL24 login (tr) successful. Token expires at ${this.tokenData.expiresAt.toISOString()}`,
|
||||||
|
);
|
||||||
return this.tokenData;
|
return this.tokenData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
@@ -419,7 +423,9 @@ export class PL24AuthService {
|
|||||||
services: payload.services || [],
|
services: payload.services || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log(`PL24 login (de) successful. Token expires at ${this.tokenData2.expiresAt.toISOString()}`);
|
this.logger.log(
|
||||||
|
`PL24 login (de) successful. Token expires at ${this.tokenData2.expiresAt.toISOString()}`,
|
||||||
|
);
|
||||||
return this.tokenData2;
|
return this.tokenData2;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
|
|||||||
@@ -8,24 +8,21 @@
|
|||||||
|
|
||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import type { ConfigService } from "@nestjs/config";
|
||||||
import { PL24AuthService } from "./pl24-auth.service";
|
import type { RedisService } from "../../redis/redis.service";
|
||||||
import { RedisService } from "../../redis/redis.service";
|
import type { StorageService } from "../../storage/storage.service";
|
||||||
import { StorageService } from "../../storage/storage.service";
|
import type { PL24AuthService } from "./pl24-auth.service";
|
||||||
|
import { FORD_LEGACY_ENDPOINTS, type FordPL24Support } from "./pl24-ford-legacy.types";
|
||||||
import { PL24_DEFAULTS } from "./pl24.constants";
|
import { PL24_DEFAULTS } from "./pl24.constants";
|
||||||
import {
|
import {
|
||||||
PL24DecodedVehicle,
|
type PL24DecodedCategory,
|
||||||
PL24DecodedCategory,
|
type PL24DecodedVehicle,
|
||||||
PL24PartsResponse,
|
type PL24MainGroup,
|
||||||
PL24Part,
|
type PL24Part,
|
||||||
PL24MainGroup,
|
type PL24PartsResponse,
|
||||||
SERVICE_TO_BRAND,
|
SERVICE_TO_BRAND,
|
||||||
getServiceConfig,
|
getServiceConfig,
|
||||||
} from "./pl24.types";
|
} from "./pl24.types";
|
||||||
import {
|
|
||||||
FORD_LEGACY_ENDPOINTS,
|
|
||||||
type FordPL24Support,
|
|
||||||
} from "./pl24-ford-legacy.types";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PL24FordLegacyService {
|
export class PL24FordLegacyService {
|
||||||
@@ -40,10 +37,7 @@ export class PL24FordLegacyService {
|
|||||||
private redis: RedisService,
|
private redis: RedisService,
|
||||||
private storage: StorageService,
|
private storage: StorageService,
|
||||||
) {
|
) {
|
||||||
this.baseUrl = this.configService.get<string>(
|
this.baseUrl = this.configService.get<string>("pl24.baseUrl", "https://www.partslink24.com");
|
||||||
"pl24.baseUrl",
|
|
||||||
"https://www.partslink24.com",
|
|
||||||
);
|
|
||||||
this.timeout = 30000;
|
this.timeout = 30000;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +172,9 @@ export class PL24FordLegacyService {
|
|||||||
}
|
}
|
||||||
if (groups.length === 0) {
|
if (groups.length === 0) {
|
||||||
// Debug: log the response
|
// Debug: log the response
|
||||||
this.logger.log(`json-sub-group unknown response[0:1000]: ${html.substring(0, 1000).replace(/\s+/g, " ")}`);
|
this.logger.log(
|
||||||
|
`json-sub-group unknown response[0:1000]: ${html.substring(0, 1000).replace(/\s+/g, " ")}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (groups.length > 0) {
|
if (groups.length > 0) {
|
||||||
await this.redis.setJson(cacheKey, groups, 86400);
|
await this.redis.setJson(cacheKey, groups, 86400);
|
||||||
@@ -247,7 +243,9 @@ export class PL24FordLegacyService {
|
|||||||
// Fallback: extract any .action links from the page
|
// Fallback: extract any .action links from the page
|
||||||
const links = this.extractLinks(html, /\.action/);
|
const links = this.extractLinks(html, /\.action/);
|
||||||
const groups: PL24MainGroup[] = links
|
const groups: PL24MainGroup[] = links
|
||||||
.filter((link) => !link.href.includes("portal.action") && !link.href.includes("logout.action"))
|
.filter(
|
||||||
|
(link) => !link.href.includes("portal.action") && !link.href.includes("logout.action"),
|
||||||
|
)
|
||||||
.map((link, idx) => ({
|
.map((link, idx) => ({
|
||||||
id: String(idx),
|
id: String(idx),
|
||||||
code: this.extractCodeFromText(link.text) || String(idx),
|
code: this.extractCodeFromText(link.text) || String(idx),
|
||||||
@@ -518,12 +516,11 @@ export class PL24FordLegacyService {
|
|||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
// Avoid duplicating family name prefix (e.g. family="BERLINGO", salesType="BERLINGO VP" → "BERLINGO VP")
|
// Avoid duplicating family name prefix (e.g. family="BERLINGO", salesType="BERLINGO VP" → "BERLINGO VP")
|
||||||
const alreadyPrefixed =
|
const alreadyPrefixed =
|
||||||
item.name &&
|
item.name && item.name.toUpperCase().startsWith(family.name.toUpperCase());
|
||||||
item.name.toUpperCase().startsWith(family.name.toUpperCase());
|
|
||||||
const modelLabel =
|
const modelLabel =
|
||||||
item.name && !alreadyPrefixed && item.name !== family.name
|
item.name && !alreadyPrefixed && item.name !== family.name
|
||||||
? `${family.name} ${item.name}`
|
? `${family.name} ${item.name}`
|
||||||
: (item.name || family.name);
|
: item.name || family.name;
|
||||||
|
|
||||||
vehicles.push({
|
vehicles.push({
|
||||||
vehicleId: `${serviceName}::${family.id}::${item.code}`,
|
vehicleId: `${serviceName}::${family.id}::${item.code}`,
|
||||||
@@ -690,7 +687,9 @@ export class PL24FordLegacyService {
|
|||||||
if (!html) return [];
|
if (!html) return [];
|
||||||
|
|
||||||
const scopes = this.parsePsaScopesFromHtml(html, serviceName, familyId, salesTypeId);
|
const scopes = this.parsePsaScopesFromHtml(html, serviceName, familyId, salesTypeId);
|
||||||
this.logger.log(`PSA: ${scopes.length} scopes for ${serviceName}/${familyId}/${salesTypeId} body=${body} engine=${engine} gearbox=${gearbox}`);
|
this.logger.log(
|
||||||
|
`PSA: ${scopes.length} scopes for ${serviceName}/${familyId}/${salesTypeId} body=${body} engine=${engine} gearbox=${gearbox}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (scopes.length > 0) {
|
if (scopes.length > 0) {
|
||||||
await this.redis.setJson(cacheKey, scopes, 7200);
|
await this.redis.setJson(cacheKey, scopes, 7200);
|
||||||
@@ -712,7 +711,10 @@ export class PL24FordLegacyService {
|
|||||||
body !== "_all_" || engine !== "_all_" || gearbox !== "_all_"
|
body !== "_all_" || engine !== "_all_" || gearbox !== "_all_"
|
||||||
? `:b=${body}:e=${engine}:g=${gearbox}`
|
? `:b=${body}:e=${engine}:g=${gearbox}`
|
||||||
: "";
|
: "";
|
||||||
const pathHash = createHash("sha256").update(linkPath + variantSuffix).digest("hex").substring(0, 16);
|
const pathHash = createHash("sha256")
|
||||||
|
.update(linkPath + variantSuffix)
|
||||||
|
.digest("hex")
|
||||||
|
.substring(0, 16);
|
||||||
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}psa:maingroups:${pathHash}`;
|
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}psa:maingroups:${pathHash}`;
|
||||||
const cached = await this.redis.getJson<PL24MainGroup[]>(cacheKey);
|
const cached = await this.redis.getJson<PL24MainGroup[]>(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
@@ -781,7 +783,10 @@ export class PL24FordLegacyService {
|
|||||||
body !== "_all_" || engine !== "_all_" || gearbox !== "_all_"
|
body !== "_all_" || engine !== "_all_" || gearbox !== "_all_"
|
||||||
? `:b=${body}:e=${engine}:g=${gearbox}`
|
? `:b=${body}:e=${engine}:g=${gearbox}`
|
||||||
: "";
|
: "";
|
||||||
const pathHash = createHash("sha256").update(effectivePath + variantSuffix).digest("hex").substring(0, 16);
|
const pathHash = createHash("sha256")
|
||||||
|
.update(effectivePath + variantSuffix)
|
||||||
|
.digest("hex")
|
||||||
|
.substring(0, 16);
|
||||||
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}psa:illus:${pathHash}`;
|
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}psa:illus:${pathHash}`;
|
||||||
const cached = await this.redis.getJson<PL24MainGroup[]>(cacheKey);
|
const cached = await this.redis.getJson<PL24MainGroup[]>(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
@@ -822,7 +827,9 @@ export class PL24FordLegacyService {
|
|||||||
seenPaths.add(key);
|
seenPaths.add(key);
|
||||||
|
|
||||||
const boardUrl = item.url
|
const boardUrl = item.url
|
||||||
? item.url.startsWith("/") ? item.url : `/psa/${svcFromPath}/${item.url}`
|
? item.url.startsWith("/")
|
||||||
|
? item.url
|
||||||
|
: `/psa/${svcFromPath}/${item.url}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
groups.push({
|
groups.push({
|
||||||
@@ -859,7 +866,10 @@ export class PL24FordLegacyService {
|
|||||||
body !== "_all_" || engine !== "_all_" || gearbox !== "_all_"
|
body !== "_all_" || engine !== "_all_" || gearbox !== "_all_"
|
||||||
? `:b=${body}:e=${engine}:g=${gearbox}`
|
? `:b=${body}:e=${engine}:g=${gearbox}`
|
||||||
: "";
|
: "";
|
||||||
const pathHash = createHash("sha256").update(effectivePath + variantSuffix).digest("hex").substring(0, 16);
|
const pathHash = createHash("sha256")
|
||||||
|
.update(effectivePath + variantSuffix)
|
||||||
|
.digest("hex")
|
||||||
|
.substring(0, 16);
|
||||||
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}psa:parts:${pathHash}`;
|
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}psa:parts:${pathHash}`;
|
||||||
if (!bypassCache) {
|
if (!bypassCache) {
|
||||||
const cached = await this.redis.getJson<PL24PartsResponse>(cacheKey);
|
const cached = await this.redis.getJson<PL24PartsResponse>(cacheKey);
|
||||||
@@ -1022,7 +1032,13 @@ export class PL24FordLegacyService {
|
|||||||
// Parse modelFamilyToModelList from window.vehicles = new Vehicles({...}) JS variable.
|
// Parse modelFamilyToModelList from window.vehicles = new Vehicles({...}) JS variable.
|
||||||
// Each family maps to one or more catCode sub-models (year ranges like CAK=1998-2005).
|
// Each family maps to one or more catCode sub-models (year ranges like CAK=1998-2005).
|
||||||
// The catCode is the true unique key for group.action — modelFamily alone returns 500.
|
// The catCode is the true unique key for group.action — modelFamily alone returns 500.
|
||||||
const catCodeVehicles = this.parseFordCatCodeVehicles(html, serviceName, basePath, mode, upds);
|
const catCodeVehicles = this.parseFordCatCodeVehicles(
|
||||||
|
html,
|
||||||
|
serviceName,
|
||||||
|
basePath,
|
||||||
|
mode,
|
||||||
|
upds,
|
||||||
|
);
|
||||||
|
|
||||||
if (catCodeVehicles.length > 0) {
|
if (catCodeVehicles.length > 0) {
|
||||||
this.logger.log(`Ford: ${catCodeVehicles.length} catCode vehicles for ${serviceName}`);
|
this.logger.log(`Ford: ${catCodeVehicles.length} catCode vehicles for ${serviceName}`);
|
||||||
@@ -1090,7 +1106,9 @@ export class PL24FordLegacyService {
|
|||||||
await this.authService.authorizeService(serviceName);
|
await this.authService.authorizeService(serviceName);
|
||||||
|
|
||||||
const config = getServiceConfig(serviceName);
|
const config = getServiceConfig(serviceName);
|
||||||
const basePath = config ? `${config.basePath}/${serviceName}` : `/hyundai-kia-automotive-group/${serviceName}`;
|
const basePath = config
|
||||||
|
? `${config.basePath}/${serviceName}`
|
||||||
|
: `/hyundai-kia-automotive-group/${serviceName}`;
|
||||||
|
|
||||||
// Step 1: group.action to get session mode/upds
|
// Step 1: group.action to get session mode/upds
|
||||||
const groupUrl = `${this.baseUrl}${basePath}/group.action?lang=tr`;
|
const groupUrl = `${this.baseUrl}${basePath}/group.action?lang=tr`;
|
||||||
@@ -1138,7 +1156,10 @@ export class PL24FordLegacyService {
|
|||||||
return vehicles;
|
return vehicles;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
this.logger.error(`HyundaiKia fetchVehicleList error (${serviceName}): ${err.message}`, err.stack);
|
this.logger.error(
|
||||||
|
`HyundaiKia fetchVehicleList error (${serviceName}): ${err.message}`,
|
||||||
|
err.stack,
|
||||||
|
);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1205,7 +1226,10 @@ export class PL24FordLegacyService {
|
|||||||
return vehicles;
|
return vehicles;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
this.logger.error(`Nissan fetchVehicleList error (${serviceName}): ${err.message}`, err.stack);
|
this.logger.error(
|
||||||
|
`Nissan fetchVehicleList error (${serviceName}): ${err.message}`,
|
||||||
|
err.stack,
|
||||||
|
);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1318,7 +1342,9 @@ export class PL24FordLegacyService {
|
|||||||
// Step 1: group.action?lang=tr WITH auth → get mode/upds for authenticated subsequent calls
|
// Step 1: group.action?lang=tr WITH auth → get mode/upds for authenticated subsequent calls
|
||||||
const groupUrl = `${this.baseUrl}${basePath}/group.action?lang=tr`;
|
const groupUrl = `${this.baseUrl}${basePath}/group.action?lang=tr`;
|
||||||
const groupHtml = await this.fetchP4Page(groupUrl, serviceName, true);
|
const groupHtml = await this.fetchP4Page(groupUrl, serviceName, true);
|
||||||
let { mode, upds } = groupHtml ? this.extractModeUpdsFromHtml(groupHtml) : { mode: "", upds: "" };
|
let { mode, upds } = groupHtml
|
||||||
|
? this.extractModeUpdsFromHtml(groupHtml)
|
||||||
|
: { mode: "", upds: "" };
|
||||||
|
|
||||||
// Step 2: Try vin-group.action?lang=tr WITHOUT auth (demo mode returns 200 with model names)
|
// Step 2: Try vin-group.action?lang=tr WITHOUT auth (demo mode returns 200 with model names)
|
||||||
// Our subscription doesn't support Volvo VIN browsing, so auth'd request returns 500.
|
// Our subscription doesn't support Volvo VIN browsing, so auth'd request returns 500.
|
||||||
@@ -1328,7 +1354,10 @@ export class PL24FordLegacyService {
|
|||||||
try {
|
try {
|
||||||
const resp = await fetch(vinGroupUrl, {
|
const resp = await fetch(vinGroupUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { "Accept": "text/html,application/xhtml+xml,*/*", "Accept-Language": "tr-TR,tr;q=0.9,en;q=0.8" },
|
headers: {
|
||||||
|
Accept: "text/html,application/xhtml+xml,*/*",
|
||||||
|
"Accept-Language": "tr-TR,tr;q=0.9,en;q=0.8",
|
||||||
|
},
|
||||||
signal: AbortSignal.timeout(this.timeout),
|
signal: AbortSignal.timeout(this.timeout),
|
||||||
redirect: "follow",
|
redirect: "follow",
|
||||||
});
|
});
|
||||||
@@ -1424,12 +1453,14 @@ export class PL24FordLegacyService {
|
|||||||
|
|
||||||
let modelYears: { code: string; name: string }[] = [];
|
let modelYears: { code: string; name: string }[] = [];
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as { modelyears?: Array<{ caption?: string; url?: string; gray?: boolean }> };
|
const parsed = JSON.parse(raw) as {
|
||||||
|
modelyears?: Array<{ caption?: string; url?: string; gray?: boolean }>;
|
||||||
|
};
|
||||||
const items = (parsed.modelyears || []).filter((it) => !it.gray);
|
const items = (parsed.modelyears || []).filter((it) => !it.gray);
|
||||||
modelYears = items
|
modelYears = items
|
||||||
.map((it) => {
|
.map((it) => {
|
||||||
const yearMatch = it.url?.match(/[?&]year=([^&"]+)/);
|
const yearMatch = it.url?.match(/[?&]year=([^&"]+)/);
|
||||||
const code = yearMatch ? decodeURIComponent(yearMatch[1]) : (it.caption || "");
|
const code = yearMatch ? decodeURIComponent(yearMatch[1]) : it.caption || "";
|
||||||
return { code, name: it.caption || code };
|
return { code, name: it.caption || code };
|
||||||
})
|
})
|
||||||
.filter((it) => it.code);
|
.filter((it) => it.code);
|
||||||
@@ -1536,17 +1567,19 @@ export class PL24FordLegacyService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const familyMap = vehiclesData.modelFamilyToModelList as Record<
|
const familyMap = vehiclesData.modelFamilyToModelList as
|
||||||
string,
|
| Record<
|
||||||
Array<{
|
string,
|
||||||
url?: string;
|
Array<{
|
||||||
jsonUrl?: string;
|
url?: string;
|
||||||
identifier?: string;
|
jsonUrl?: string;
|
||||||
caption?: string;
|
identifier?: string;
|
||||||
year?: string;
|
caption?: string;
|
||||||
gray?: boolean;
|
year?: string;
|
||||||
}>
|
gray?: boolean;
|
||||||
> | undefined;
|
}>
|
||||||
|
>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
if (!familyMap) return [];
|
if (!familyMap) return [];
|
||||||
|
|
||||||
@@ -1554,7 +1587,9 @@ export class PL24FordLegacyService {
|
|||||||
const familyKey =
|
const familyKey =
|
||||||
Object.keys(familyMap).find((k) => k === familyId) ||
|
Object.keys(familyMap).find((k) => k === familyId) ||
|
||||||
Object.keys(familyMap).find((k) => k.toLowerCase() === familyId.toLowerCase()) ||
|
Object.keys(familyMap).find((k) => k.toLowerCase() === familyId.toLowerCase()) ||
|
||||||
Object.keys(familyMap).find((k) => k.replace(/\s+/g, "-").toLowerCase() === familyId.replace(/\s+/g, "-").toLowerCase());
|
Object.keys(familyMap).find(
|
||||||
|
(k) => k.replace(/\s+/g, "-").toLowerCase() === familyId.replace(/\s+/g, "-").toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
const models = familyKey ? familyMap[familyKey] : undefined;
|
const models = familyKey ? familyMap[familyKey] : undefined;
|
||||||
if (!Array.isArray(models) || models.length === 0) return [];
|
if (!Array.isArray(models) || models.length === 0) return [];
|
||||||
@@ -1564,8 +1599,8 @@ export class PL24FordLegacyService {
|
|||||||
if (m2.gray === true) continue; // Skip unavailable sub-models
|
if (m2.gray === true) continue; // Skip unavailable sub-models
|
||||||
|
|
||||||
// catCode may be in identifier OR embedded in the URL
|
// catCode may be in identifier OR embedded in the URL
|
||||||
const catCode = (m2.identifier?.trim() || "") ||
|
const catCode =
|
||||||
(m2.url?.match(/[?&]catCode=([^&"]+)/)?.[1] || "");
|
m2.identifier?.trim() || "" || m2.url?.match(/[?&]catCode=([^&"]+)/)?.[1] || "";
|
||||||
if (!catCode) continue;
|
if (!catCode) continue;
|
||||||
|
|
||||||
// Build display name: caption if available, else family+year range
|
// Build display name: caption if available, else family+year range
|
||||||
@@ -1738,7 +1773,9 @@ export class PL24FordLegacyService {
|
|||||||
let firstSalesTypeId = firstFamily.id;
|
let firstSalesTypeId = firstFamily.id;
|
||||||
if (salesRaw) {
|
if (salesRaw) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(salesRaw) as { items?: Array<{ code: string; name: string; subheader?: boolean }> };
|
const parsed = JSON.parse(salesRaw) as {
|
||||||
|
items?: Array<{ code: string; name: string; subheader?: boolean }>;
|
||||||
|
};
|
||||||
const items = (parsed.items || []).filter((it) => it.code && !it.subheader);
|
const items = (parsed.items || []).filter((it) => it.code && !it.subheader);
|
||||||
if (items.length > 0) firstSalesTypeId = items[0].code;
|
if (items.length > 0) firstSalesTypeId = items[0].code;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1759,7 +1796,7 @@ export class PL24FordLegacyService {
|
|||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`PSA: ${scopes.length} scopes for ${serviceName} VIN ${vin}` +
|
`PSA: ${scopes.length} scopes for ${serviceName} VIN ${vin}` +
|
||||||
` (family=${familyId || "?"}, salesType=${salesTypeId || "?"})`,
|
` (family=${familyId || "?"}, salesType=${salesTypeId || "?"})`,
|
||||||
);
|
);
|
||||||
return scopes;
|
return scopes;
|
||||||
}
|
}
|
||||||
@@ -1877,10 +1914,14 @@ export class PL24FordLegacyService {
|
|||||||
const salesRaw = await this.fetchPsaPage(salesUrl, serviceName, jsessionId, true);
|
const salesRaw = await this.fetchPsaPage(salesUrl, serviceName, jsessionId, true);
|
||||||
if (salesRaw) {
|
if (salesRaw) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(salesRaw) as { items?: Array<{ code: string; name: string; subheader?: boolean }> };
|
const parsed = JSON.parse(salesRaw) as {
|
||||||
|
items?: Array<{ code: string; name: string; subheader?: boolean }>;
|
||||||
|
};
|
||||||
const items = (parsed.items || []).filter((it) => it.code && !it.subheader);
|
const items = (parsed.items || []).filter((it) => it.code && !it.subheader);
|
||||||
if (items.length > 0) firstSalesTypeId = items[0].code;
|
if (items.length > 0) firstSalesTypeId = items[0].code;
|
||||||
} catch { /* keep firstFamily.id */ }
|
} catch {
|
||||||
|
/* keep firstFamily.id */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const groupUrl =
|
const groupUrl =
|
||||||
`${this.baseUrl}/psa/${serviceName}/group.action` +
|
`${this.baseUrl}/psa/${serviceName}/group.action` +
|
||||||
@@ -1889,7 +1930,10 @@ export class PL24FordLegacyService {
|
|||||||
`&startup=false&mode=${mode}&upds=${upds}`;
|
`&startup=false&mode=${mode}&upds=${upds}`;
|
||||||
const groupHtml = await this.fetchPsaPage(groupUrl, serviceName, jsessionId);
|
const groupHtml = await this.fetchPsaPage(groupUrl, serviceName, jsessionId);
|
||||||
if (groupHtml) {
|
if (groupHtml) {
|
||||||
({ scopes, familyId, salesTypeId } = this.parsePsaScopesAndParams(groupHtml, serviceName));
|
({ scopes, familyId, salesTypeId } = this.parsePsaScopesAndParams(
|
||||||
|
groupHtml,
|
||||||
|
serviceName,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1910,7 +1954,7 @@ export class PL24FordLegacyService {
|
|||||||
await this.redis.setJson(cacheKey, result, 86400);
|
await this.redis.setJson(cacheKey, result, 86400);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`PSA: decoded VIN ${vin} (${serviceName}) — ${vehicle.brand} ${vehicle.model} ${vehicle.year},` +
|
`PSA: decoded VIN ${vin} (${serviceName}) — ${vehicle.brand} ${vehicle.model} ${vehicle.year},` +
|
||||||
` ${scopes.length} scopes`,
|
` ${scopes.length} scopes`,
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -1987,16 +2031,18 @@ export class PL24FordLegacyService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const familyMap = vehiclesData.modelFamilyToModelList as Record<
|
const familyMap = vehiclesData.modelFamilyToModelList as
|
||||||
string,
|
| Record<
|
||||||
Array<{
|
string,
|
||||||
url?: string;
|
Array<{
|
||||||
jsonUrl?: string;
|
url?: string;
|
||||||
identifier?: string;
|
jsonUrl?: string;
|
||||||
caption?: string;
|
identifier?: string;
|
||||||
year?: string;
|
caption?: string;
|
||||||
}>
|
year?: string;
|
||||||
> | undefined;
|
}>
|
||||||
|
>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
if (!familyMap || typeof familyMap !== "object") return [];
|
if (!familyMap || typeof familyMap !== "object") return [];
|
||||||
|
|
||||||
@@ -2011,8 +2057,8 @@ export class PL24FordLegacyService {
|
|||||||
if (!Array.isArray(models)) continue;
|
if (!Array.isArray(models)) continue;
|
||||||
for (const m2 of models) {
|
for (const m2 of models) {
|
||||||
// catCode may be in identifier field OR embedded in the url (e.g. "vehicle.action?catCode=CB7&...")
|
// catCode may be in identifier field OR embedded in the url (e.g. "vehicle.action?catCode=CB7&...")
|
||||||
const catCode = (m2.identifier?.trim() || "") ||
|
const catCode =
|
||||||
(m2.url?.match(/[?&]catCode=([^&"]+)/)?.[1] || "");
|
m2.identifier?.trim() || "" || m2.url?.match(/[?&]catCode=([^&"]+)/)?.[1] || "";
|
||||||
if (!catCode) continue;
|
if (!catCode) continue;
|
||||||
|
|
||||||
// Build display name: "Focus (1998-2005)" or just "Focus" if no year
|
// Build display name: "Focus (1998-2005)" or just "Focus" if no year
|
||||||
@@ -2092,7 +2138,6 @@ export class PL24FordLegacyService {
|
|||||||
return families;
|
return families;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse items array from PSA JSON variant endpoints (bodies/engines/gearboxes).
|
* Parse items array from PSA JSON variant endpoints (bodies/engines/gearboxes).
|
||||||
* PL24 PSA uses variant-specific field names:
|
* PL24 PSA uses variant-specific field names:
|
||||||
@@ -2105,7 +2150,8 @@ export class PL24FordLegacyService {
|
|||||||
raw: string,
|
raw: string,
|
||||||
type: "body" | "engine" | "gearbox",
|
type: "body" | "engine" | "gearbox",
|
||||||
): { code: string; name: string }[] {
|
): { code: string; name: string }[] {
|
||||||
const codeKey = type === "body" ? "bodyCodeTec" : type === "engine" ? "engineCodeTec" : "gearboxCodeTec";
|
const codeKey =
|
||||||
|
type === "body" ? "bodyCodeTec" : type === "engine" ? "engineCodeTec" : "gearboxCodeTec";
|
||||||
const nameKey = type === "body" ? "bodyName" : type === "engine" ? "engineName" : "gearboxName";
|
const nameKey = type === "body" ? "bodyName" : type === "engine" ? "engineName" : "gearboxName";
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as {
|
const parsed = JSON.parse(raw) as {
|
||||||
@@ -2244,7 +2290,12 @@ export class PL24FordLegacyService {
|
|||||||
const textTdMatch =
|
const textTdMatch =
|
||||||
segment.match(/class="text[^"]*"[^>]*>([\s\S]*?)<\/td>/) ||
|
segment.match(/class="text[^"]*"[^>]*>([\s\S]*?)<\/td>/) ||
|
||||||
segment.match(/class="caption[^"]*"[^>]*>([\s\S]*?)<\/td>/);
|
segment.match(/class="caption[^"]*"[^>]*>([\s\S]*?)<\/td>/);
|
||||||
const text = textTdMatch ? textTdMatch[1].replace(/<[^>]+>/g, "").replace(/ /g, " ").trim() : "";
|
const text = textTdMatch
|
||||||
|
? textTdMatch[1]
|
||||||
|
.replace(/<[^>]+>/g, "")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.trim()
|
||||||
|
: "";
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
pnc,
|
pnc,
|
||||||
@@ -2331,14 +2382,16 @@ export class PL24FordLegacyService {
|
|||||||
if (!oemCode) continue;
|
if (!oemCode) continue;
|
||||||
|
|
||||||
const captionMatch = segment.match(/\bcaption="([^"]*)"/);
|
const captionMatch = segment.match(/\bcaption="([^"]*)"/);
|
||||||
const name = captionMatch ? captionMatch[1].replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").trim() : "";
|
const name = captionMatch
|
||||||
|
? captionMatch[1].replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").trim()
|
||||||
|
: "";
|
||||||
|
|
||||||
const hotspotMatch = segment.match(/\bhotspot="([^"]*?)"/);
|
const hotspotMatch = segment.match(/\bhotspot="([^"]*?)"/);
|
||||||
const hotspot = hotspotMatch?.[1]?.trim() ?? "";
|
const hotspot = hotspotMatch?.[1]?.trim() ?? "";
|
||||||
|
|
||||||
const qtyTdMatch = segment.match(/class="quantity"[^>]*>\s*(\d+)\s*</);
|
const qtyTdMatch = segment.match(/class="quantity"[^>]*>\s*(\d+)\s*</);
|
||||||
const qtyUrlMatch = segment.match(/[?&]quantity=(\d+)/);
|
const qtyUrlMatch = segment.match(/[?&]quantity=(\d+)/);
|
||||||
const quantity = parseInt(qtyTdMatch?.[1] ?? qtyUrlMatch?.[1] ?? "1", 10) || 1;
|
const quantity = Number.parseInt(qtyTdMatch?.[1] ?? qtyUrlMatch?.[1] ?? "1", 10) || 1;
|
||||||
|
|
||||||
const bomDetailIdMatch = segment.match(/\bbomDetailId="([^"]+)"/);
|
const bomDetailIdMatch = segment.match(/\bbomDetailId="([^"]+)"/);
|
||||||
const positionCode = hotspot || bomDetailIdMatch?.[1] || oemCode;
|
const positionCode = hotspot || bomDetailIdMatch?.[1] || oemCode;
|
||||||
@@ -2407,12 +2460,15 @@ export class PL24FordLegacyService {
|
|||||||
.filter((item) => !item.subheader && !item.gray && !!(item.jsonUrl || item.url))
|
.filter((item) => !item.subheader && !item.gray && !!(item.jsonUrl || item.url))
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
// Strip HTML restriction block divs from caption (Ford embeds them in JSON too)
|
// Strip HTML restriction block divs from caption (Ford embeds them in JSON too)
|
||||||
const cleanName = (item.caption || "")
|
const cleanName =
|
||||||
.replace(/<div[^>]*restriction[^>]*>[\s\S]*/i, "")
|
(item.caption || "")
|
||||||
.replace(/<[^>]+>/g, " ")
|
.replace(/<div[^>]*restriction[^>]*>[\s\S]*/i, "")
|
||||||
.replace(/ /g, " ")
|
.replace(/<[^>]+>/g, " ")
|
||||||
.replace(/\s+/g, " ")
|
.replace(/ /g, " ")
|
||||||
.trim() || item.databaseKey || item.identifier;
|
.replace(/\s+/g, " ")
|
||||||
|
.trim() ||
|
||||||
|
item.databaseKey ||
|
||||||
|
item.identifier;
|
||||||
return {
|
return {
|
||||||
id: item.databaseKey || item.identifier,
|
id: item.databaseKey || item.identifier,
|
||||||
code: item.databaseKey || item.identifier,
|
code: item.databaseKey || item.identifier,
|
||||||
@@ -2454,9 +2510,7 @@ export class PL24FordLegacyService {
|
|||||||
id: String(item.id || idx),
|
id: String(item.id || idx),
|
||||||
code: String(item.id || idx),
|
code: String(item.id || idx),
|
||||||
name: (item.caption ?? item.name ?? "").replace(/^\d+\s+/, "").trim(),
|
name: (item.caption ?? item.name ?? "").replace(/^\d+\s+/, "").trim(),
|
||||||
linkPath: item.url
|
linkPath: item.url ? `${basePath}${item.url}` : `${basePath}${item.jsonUrl}`,
|
||||||
? `${basePath}${item.url}`
|
|
||||||
: `${basePath}${item.jsonUrl}`,
|
|
||||||
}));
|
}));
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
@@ -2496,16 +2550,20 @@ export class PL24FordLegacyService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. entry.action → 302 with JSESSIONID
|
// 1. entry.action → 302 with JSESSIONID
|
||||||
const entryRes = await fetch(
|
const entryRes = await fetch(`${this.baseUrl}/psa/pl24-entry.action?service=${serviceName}`, {
|
||||||
`${this.baseUrl}/psa/pl24-entry.action?service=${serviceName}`,
|
method: "GET",
|
||||||
{ method: "GET", headers, redirect: "manual", signal: AbortSignal.timeout(this.timeout) },
|
headers,
|
||||||
);
|
redirect: "manual",
|
||||||
|
signal: AbortSignal.timeout(this.timeout),
|
||||||
|
});
|
||||||
const jsessionMatch = entryRes.headers.get("set-cookie")?.match(/JSESSIONID=([^;]+)/);
|
const jsessionMatch = entryRes.headers.get("set-cookie")?.match(/JSESSIONID=([^;]+)/);
|
||||||
const jsessionId = jsessionMatch?.[1] || "";
|
const jsessionId = jsessionMatch?.[1] || "";
|
||||||
const loc1 = entryRes.headers.get("location");
|
const loc1 = entryRes.headers.get("location");
|
||||||
if (!loc1) return null;
|
if (!loc1) return null;
|
||||||
|
|
||||||
const hdrs2 = jsessionId ? { ...headers, Cookie: `${headers.Cookie}; JSESSIONID=${jsessionId}` } : headers;
|
const hdrs2 = jsessionId
|
||||||
|
? { ...headers, Cookie: `${headers.Cookie}; JSESSIONID=${jsessionId}` }
|
||||||
|
: headers;
|
||||||
|
|
||||||
// 2. startup=true → 302 with mode + upds in Location
|
// 2. startup=true → 302 with mode + upds in Location
|
||||||
const startup1Url = loc1.startsWith("http") ? loc1 : `${this.baseUrl}${loc1}`;
|
const startup1Url = loc1.startsWith("http") ? loc1 : `${this.baseUrl}${loc1}`;
|
||||||
@@ -2527,7 +2585,9 @@ export class PL24FordLegacyService {
|
|||||||
|
|
||||||
if (!mode) return null;
|
if (!mode) return null;
|
||||||
|
|
||||||
this.logger.log(`PSA session init: service=${serviceName} mode=${mode} upds=${upds} jsessionId=${jsessionId.substring(0, 10)}...`);
|
this.logger.log(
|
||||||
|
`PSA session init: service=${serviceName} mode=${mode} upds=${upds} jsessionId=${jsessionId.substring(0, 10)}...`,
|
||||||
|
);
|
||||||
return { jsessionId, mode, upds };
|
return { jsessionId, mode, upds };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`PSA session init error: ${(error as Error).message}`);
|
this.logger.error(`PSA session init error: ${(error as Error).message}`);
|
||||||
@@ -2567,9 +2627,7 @@ export class PL24FordLegacyService {
|
|||||||
const headers = {
|
const headers = {
|
||||||
...baseHeaders,
|
...baseHeaders,
|
||||||
Cookie: cookieStr,
|
Cookie: cookieStr,
|
||||||
Accept: isJson
|
Accept: isJson ? "application/json,*/*" : "text/html,application/xhtml+xml,*/*;q=0.9",
|
||||||
? "application/json,*/*"
|
|
||||||
: "text/html,application/xhtml+xml,*/*;q=0.9",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2637,7 +2695,9 @@ export class PL24FordLegacyService {
|
|||||||
signal: AbortSignal.timeout(this.timeout),
|
signal: AbortSignal.timeout(this.timeout),
|
||||||
});
|
});
|
||||||
if (!infoRes.ok) {
|
if (!infoRes.ok) {
|
||||||
this.logger.warn(`PSA GetImageInfo: HTTP ${infoRes.status} for ${infoUrl.substring(0, 120)}`);
|
this.logger.warn(
|
||||||
|
`PSA GetImageInfo: HTTP ${infoRes.status} for ${infoUrl.substring(0, 120)}`,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const info = (await infoRes.json()) as {
|
const info = (await infoRes.json()) as {
|
||||||
@@ -2788,7 +2848,9 @@ export class PL24FordLegacyService {
|
|||||||
fromHtmlAttrs.push({ id: m[1], name: m[2] });
|
fromHtmlAttrs.push({ id: m[1], name: m[2] });
|
||||||
}
|
}
|
||||||
if (fromHtmlAttrs.length > 0) {
|
if (fromHtmlAttrs.length > 0) {
|
||||||
this.logger.log(`PSA HTML: found ${fromHtmlAttrs.length} families via modelFamily attributes`);
|
this.logger.log(
|
||||||
|
`PSA HTML: found ${fromHtmlAttrs.length} families via modelFamily attributes`,
|
||||||
|
);
|
||||||
return fromHtmlAttrs;
|
return fromHtmlAttrs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2807,7 +2869,8 @@ export class PL24FordLegacyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FALLBACK: Try <select> elements with model/family options
|
// FALLBACK: Try <select> elements with model/family options
|
||||||
const selectRegex = /<select[^>]*name=["'](?:modelFamily|famille|modele|model)[^"']*["'][^>]*>([\s\S]*?)<\/select>/i;
|
const selectRegex =
|
||||||
|
/<select[^>]*name=["'](?:modelFamily|famille|modele|model)[^"']*["'][^>]*>([\s\S]*?)<\/select>/i;
|
||||||
const selectMatch = html.match(selectRegex);
|
const selectMatch = html.match(selectRegex);
|
||||||
if (selectMatch) {
|
if (selectMatch) {
|
||||||
const optionRegex = /<option[^>]*value=["']([^"']+)["'][^>]*>([\s\S]*?)<\/option>/gi;
|
const optionRegex = /<option[^>]*value=["']([^"']+)["'][^>]*>([\s\S]*?)<\/option>/gi;
|
||||||
@@ -2851,12 +2914,24 @@ export class PL24FordLegacyService {
|
|||||||
// Log select/option elements for model selection hints
|
// Log select/option elements for model selection hints
|
||||||
const selectMatches = html.match(/<select[^>]*>[\s\S]*?<\/select>/gi);
|
const selectMatches = html.match(/<select[^>]*>[\s\S]*?<\/select>/gi);
|
||||||
if (selectMatches) {
|
if (selectMatches) {
|
||||||
this.logger.log(`PSA HTML selects: ${selectMatches.slice(0, 3).map(s => s.substring(0, 200)).join(" | ")}`);
|
this.logger.log(
|
||||||
|
`PSA HTML selects: ${selectMatches
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((s) => s.substring(0, 200))
|
||||||
|
.join(" | ")}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Log any li/div elements with model names in the navigation
|
// Log any li/div elements with model names in the navigation
|
||||||
const liLinks = html.match(/<(?:li|div)[^>]*class=["'][^"']*(?:family|model|brand|vehicle|catalog)[^"']*["'][^>]*>[\s\S]*?<\/(?:li|div)>/gi);
|
const liLinks = html.match(
|
||||||
|
/<(?:li|div)[^>]*class=["'][^"']*(?:family|model|brand|vehicle|catalog)[^"']*["'][^>]*>[\s\S]*?<\/(?:li|div)>/gi,
|
||||||
|
);
|
||||||
if (liLinks) {
|
if (liLinks) {
|
||||||
this.logger.log(`PSA HTML nav items: ${liLinks.slice(0, 5).map(s => s.substring(0, 150)).join(" | ")}`);
|
this.logger.log(
|
||||||
|
`PSA HTML nav items: ${liLinks
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((s) => s.substring(0, 150))
|
||||||
|
.join(" | ")}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -2920,7 +2995,10 @@ export class PL24FordLegacyService {
|
|||||||
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
|
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
|
||||||
this.authService.clearTokensForAccount(account);
|
this.authService.clearTokensForAccount(account);
|
||||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||||
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(serviceName, account);
|
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(
|
||||||
|
serviceName,
|
||||||
|
account,
|
||||||
|
);
|
||||||
response = await fetch(fullUrl, buildOpts(newHeaders));
|
response = await fetch(fullUrl, buildOpts(newHeaders));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2939,7 +3017,9 @@ export class PL24FordLegacyService {
|
|||||||
return await response.text();
|
return await response.text();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
this.logger.error(`Ford legacy fetch error: ${err.message} [url=${fullUrl.substring(0, 100)}] [cause=${(err as any).cause?.message ?? "none"}]`);
|
this.logger.error(
|
||||||
|
`Ford legacy fetch error: ${err.message} [url=${fullUrl.substring(0, 100)}] [cause=${(err as any).cause?.message ?? "none"}]`,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2977,14 +3057,15 @@ export class PL24FordLegacyService {
|
|||||||
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
|
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
|
||||||
this.authService.clearTokensForAccount(account);
|
this.authService.clearTokensForAccount(account);
|
||||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||||
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(serviceName, account);
|
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(
|
||||||
|
serviceName,
|
||||||
|
account,
|
||||||
|
);
|
||||||
response = await doFetch(newHeaders);
|
response = await doFetch(newHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsessionId =
|
const jsessionId =
|
||||||
response.headers
|
response.headers.get("set-cookie")?.match(/JSESSIONID=([^;,\s]+)/)?.[1] ?? "";
|
||||||
.get("set-cookie")
|
|
||||||
?.match(/JSESSIONID=([^;,\s]+)/)?.[1] ?? "";
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
this.logger.warn(`Ford legacy: HTTP ${response.status} for ${fullUrl}`);
|
this.logger.warn(`Ford legacy: HTTP ${response.status} for ${fullUrl}`);
|
||||||
@@ -3207,7 +3288,10 @@ export class PL24FordLegacyService {
|
|||||||
.replace(/\xa0/g, " ")
|
.replace(/\xa0/g, " ")
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
if (text.length >= 3) { tdName = text; break; }
|
if (text.length >= 3) {
|
||||||
|
tdName = text;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Caption may contain HTML-encoded content (e.g. <div class="restrictionBlock"...>)
|
// Caption may contain HTML-encoded content (e.g. <div class="restrictionBlock"...>)
|
||||||
@@ -3215,8 +3299,12 @@ export class PL24FordLegacyService {
|
|||||||
const rawCaption = captionMatch?.[1] || "";
|
const rawCaption = captionMatch?.[1] || "";
|
||||||
const cleanCaption = rawCaption
|
const cleanCaption = rawCaption
|
||||||
// Decode HTML entities first (so we can then strip decoded HTML tags)
|
// Decode HTML entities first (so we can then strip decoded HTML tags)
|
||||||
.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&")
|
.replace(/</g, "<")
|
||||||
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, " ")
|
.replace(/>/g, ">")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/ /g, " ")
|
||||||
// Trim restriction block divs — everything from <div class="restriction to end
|
// Trim restriction block divs — everything from <div class="restriction to end
|
||||||
.replace(/<div[^>]*restriction[^>]*>[\s\S]*/i, "")
|
.replace(/<div[^>]*restriction[^>]*>[\s\S]*/i, "")
|
||||||
// Strip remaining HTML tags
|
// Strip remaining HTML tags
|
||||||
@@ -3294,14 +3382,17 @@ export class PL24FordLegacyService {
|
|||||||
const trParts = html.split(/<tr\s/);
|
const trParts = html.split(/<tr\s/);
|
||||||
for (const segment of trParts) {
|
for (const segment of trParts) {
|
||||||
if (!segment.includes("tc-data-row")) continue;
|
if (!segment.includes("tc-data-row")) continue;
|
||||||
if (!segment.includes('catalog=')) continue;
|
if (!segment.includes("catalog=")) continue;
|
||||||
|
|
||||||
const catalogMatch = segment.match(/\bcatalog="([^"]+)"/);
|
const catalogMatch = segment.match(/\bcatalog="([^"]+)"/);
|
||||||
const captionMatch = segment.match(/\bcaption="([^"]+)"/);
|
const captionMatch = segment.match(/\bcaption="([^"]+)"/);
|
||||||
if (!catalogMatch || !captionMatch) continue;
|
if (!catalogMatch || !captionMatch) continue;
|
||||||
|
|
||||||
const id = catalogMatch[1].trim();
|
const id = catalogMatch[1].trim();
|
||||||
const name = captionMatch[1].replace(/&/g, "&").replace(/ /g, " ").trim();
|
const name = captionMatch[1]
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.trim();
|
||||||
if (!id || seen.has(id)) continue;
|
if (!id || seen.has(id)) continue;
|
||||||
seen.add(id);
|
seen.add(id);
|
||||||
|
|
||||||
@@ -3331,7 +3422,10 @@ export class PL24FordLegacyService {
|
|||||||
if (!identMatch || !captionMatch) continue;
|
if (!identMatch || !captionMatch) continue;
|
||||||
|
|
||||||
const id = identMatch[1].trim();
|
const id = identMatch[1].trim();
|
||||||
const name = captionMatch[1].replace(/&/g, "&").replace(/ /g, " ").trim();
|
const name = captionMatch[1]
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.trim();
|
||||||
if (!id || seen.has(id)) continue;
|
if (!id || seen.has(id)) continue;
|
||||||
seen.add(id);
|
seen.add(id);
|
||||||
|
|
||||||
@@ -3406,7 +3500,11 @@ export class PL24FordLegacyService {
|
|||||||
const captionMatch = segment.match(/\bcaption="([^"]+)"/);
|
const captionMatch = segment.match(/\bcaption="([^"]+)"/);
|
||||||
let name = "";
|
let name = "";
|
||||||
if (captionMatch) {
|
if (captionMatch) {
|
||||||
name = captionMatch[1].replace(/&/g, "&").replace(/ /g, " ").replace(/\xa0/g, " ").trim();
|
name = captionMatch[1]
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.replace(/\xa0/g, " ")
|
||||||
|
.trim();
|
||||||
} else {
|
} else {
|
||||||
// Extract text from first non-empty <td> (strip inner tags)
|
// Extract text from first non-empty <td> (strip inner tags)
|
||||||
const tdPattern = /<td[^>]*>([\s\S]*?)<\/td>/g;
|
const tdPattern = /<td[^>]*>([\s\S]*?)<\/td>/g;
|
||||||
@@ -3585,7 +3683,7 @@ export class PL24FordLegacyService {
|
|||||||
|
|
||||||
if (vehicleData) {
|
if (vehicleData) {
|
||||||
model = vehicleData.model || vehicleData.modelName || vehicleData.description || "";
|
model = vehicleData.model || vehicleData.modelName || vehicleData.description || "";
|
||||||
year = parseInt(vehicleData.year || vehicleData.modelYear || "", 10) || 0;
|
year = Number.parseInt(vehicleData.year || vehicleData.modelYear || "", 10) || 0;
|
||||||
bodyType = vehicleData.bodyStyle || vehicleData.body || null;
|
bodyType = vehicleData.bodyStyle || vehicleData.body || null;
|
||||||
engineCode = vehicleData.engineCode || vehicleData.engine || null;
|
engineCode = vehicleData.engineCode || vehicleData.engine || null;
|
||||||
engineType = vehicleData.engineDescription || vehicleData.engineType || null;
|
engineType = vehicleData.engineDescription || vehicleData.engineType || null;
|
||||||
@@ -3623,7 +3721,7 @@ export class PL24FordLegacyService {
|
|||||||
model = values[i] || model;
|
model = values[i] || model;
|
||||||
}
|
}
|
||||||
if (!year && (key.includes("year") || key.includes("yil") || key.includes("yıl"))) {
|
if (!year && (key.includes("year") || key.includes("yil") || key.includes("yıl"))) {
|
||||||
year = parseInt(values[i], 10) || year;
|
year = Number.parseInt(values[i], 10) || year;
|
||||||
}
|
}
|
||||||
if (!engineCode && (key.includes("engine") || key.includes("motor"))) {
|
if (!engineCode && (key.includes("engine") || key.includes("motor"))) {
|
||||||
engineCode = values[i] || engineCode;
|
engineCode = values[i] || engineCode;
|
||||||
@@ -3744,30 +3842,45 @@ export class PL24FordLegacyService {
|
|||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
// Try to find OEM code column (various possible names)
|
// Try to find OEM code column (various possible names)
|
||||||
const oemCode = this.findColumnValue(row, [
|
const oemCode = this.findColumnValue(row, [
|
||||||
"partno", "part_no", "part number", "parca no", "parça no",
|
"partno",
|
||||||
"oem", "oemcode", "code", "kod", "no",
|
"part_no",
|
||||||
|
"part number",
|
||||||
|
"parca no",
|
||||||
|
"parça no",
|
||||||
|
"oem",
|
||||||
|
"oemcode",
|
||||||
|
"code",
|
||||||
|
"kod",
|
||||||
|
"no",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!oemCode) continue;
|
if (!oemCode) continue;
|
||||||
|
|
||||||
const cleanOem = oemCode.replace(/\s+/g, "");
|
const cleanOem = oemCode.replace(/\s+/g, "");
|
||||||
const name = this.findColumnValue(row, [
|
const name =
|
||||||
"description", "name", "aciklama", "açıklama", "tanim", "tanım",
|
this.findColumnValue(row, [
|
||||||
"descr", "part name", "parca adi", "parça adı",
|
"description",
|
||||||
]) || "";
|
"name",
|
||||||
|
"aciklama",
|
||||||
|
"açıklama",
|
||||||
|
"tanim",
|
||||||
|
"tanım",
|
||||||
|
"descr",
|
||||||
|
"part name",
|
||||||
|
"parca adi",
|
||||||
|
"parça adı",
|
||||||
|
]) || "";
|
||||||
|
|
||||||
const positionCode = this.findColumnValue(row, [
|
const positionCode =
|
||||||
"pos", "position", "pozisyon", "no", "sira",
|
this.findColumnValue(row, ["pos", "position", "pozisyon", "no", "sira"]) || "";
|
||||||
]) || "";
|
|
||||||
|
|
||||||
const qtyStr = this.findColumnValue(row, [
|
const qtyStr =
|
||||||
"qty", "quantity", "miktar", "adet", "count",
|
this.findColumnValue(row, ["qty", "quantity", "miktar", "adet", "count"]) || "";
|
||||||
]) || "";
|
const quantity = Number.parseInt(qtyStr, 10) || undefined;
|
||||||
const quantity = parseInt(qtyStr, 10) || undefined;
|
|
||||||
|
|
||||||
const remark = this.findColumnValue(row, [
|
const remark =
|
||||||
"remark", "remarks", "note", "notes", "not", "aciklama2",
|
this.findColumnValue(row, ["remark", "remarks", "note", "notes", "not", "aciklama2"]) ||
|
||||||
]) || undefined;
|
undefined;
|
||||||
|
|
||||||
parts.push({
|
parts.push({
|
||||||
id: cleanOem,
|
id: cleanOem,
|
||||||
@@ -3828,10 +3941,7 @@ export class PL24FordLegacyService {
|
|||||||
/**
|
/**
|
||||||
* Find a column value in a row by trying multiple possible column names.
|
* Find a column value in a row by trying multiple possible column names.
|
||||||
*/
|
*/
|
||||||
private findColumnValue(
|
private findColumnValue(row: Record<string, string>, possibleKeys: string[]): string | null {
|
||||||
row: Record<string, string>,
|
|
||||||
possibleKeys: string[],
|
|
||||||
): string | null {
|
|
||||||
// Try exact match first
|
// Try exact match first
|
||||||
for (const key of possibleKeys) {
|
for (const key of possibleKeys) {
|
||||||
if (row[key]) return row[key];
|
if (row[key]) return row[key];
|
||||||
@@ -3840,17 +3950,13 @@ export class PL24FordLegacyService {
|
|||||||
// Try case-insensitive match
|
// Try case-insensitive match
|
||||||
const rowKeys = Object.keys(row);
|
const rowKeys = Object.keys(row);
|
||||||
for (const key of possibleKeys) {
|
for (const key of possibleKeys) {
|
||||||
const found = rowKeys.find(
|
const found = rowKeys.find((k) => k.toLowerCase() === key.toLowerCase());
|
||||||
(k) => k.toLowerCase() === key.toLowerCase(),
|
|
||||||
);
|
|
||||||
if (found && row[found]) return row[found];
|
if (found && row[found]) return row[found];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try partial match
|
// Try partial match
|
||||||
for (const key of possibleKeys) {
|
for (const key of possibleKeys) {
|
||||||
const found = rowKeys.find(
|
const found = rowKeys.find((k) => k.toLowerCase().includes(key.toLowerCase()));
|
||||||
(k) => k.toLowerCase().includes(key.toLowerCase()),
|
|
||||||
);
|
|
||||||
if (found && row[found]) return row[found];
|
if (found && row[found]) return row[found];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3872,12 +3978,36 @@ export class PL24FordLegacyService {
|
|||||||
if (!vin || vin.length < 10) return 0;
|
if (!vin || vin.length < 10) return 0;
|
||||||
const yearChar = vin.charAt(9).toUpperCase();
|
const yearChar = vin.charAt(9).toUpperCase();
|
||||||
const yearMap: Record<string, number> = {
|
const yearMap: Record<string, number> = {
|
||||||
"1": 2001, "2": 2002, "3": 2003, "4": 2004, "5": 2005,
|
"1": 2001,
|
||||||
"6": 2006, "7": 2007, "8": 2008, "9": 2009,
|
"2": 2002,
|
||||||
A: 2010, B: 2011, C: 2012, D: 2013, E: 2014, F: 2015,
|
"3": 2003,
|
||||||
G: 2016, H: 2017, J: 2018, K: 2019, L: 2020, M: 2021,
|
"4": 2004,
|
||||||
N: 2022, P: 2023, R: 2024, S: 2025, T: 2026, V: 2027,
|
"5": 2005,
|
||||||
W: 2028, X: 2029, Y: 2030,
|
"6": 2006,
|
||||||
|
"7": 2007,
|
||||||
|
"8": 2008,
|
||||||
|
"9": 2009,
|
||||||
|
A: 2010,
|
||||||
|
B: 2011,
|
||||||
|
C: 2012,
|
||||||
|
D: 2013,
|
||||||
|
E: 2014,
|
||||||
|
F: 2015,
|
||||||
|
G: 2016,
|
||||||
|
H: 2017,
|
||||||
|
J: 2018,
|
||||||
|
K: 2019,
|
||||||
|
L: 2020,
|
||||||
|
M: 2021,
|
||||||
|
N: 2022,
|
||||||
|
P: 2023,
|
||||||
|
R: 2024,
|
||||||
|
S: 2025,
|
||||||
|
T: 2026,
|
||||||
|
V: 2027,
|
||||||
|
W: 2028,
|
||||||
|
X: 2029,
|
||||||
|
Y: 2030,
|
||||||
};
|
};
|
||||||
return yearMap[yearChar] || 0;
|
return yearMap[yearChar] || 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { PL24Service } from "./pl24.service";
|
|
||||||
import { PL24AuthService } from "./pl24-auth.service";
|
import { PL24AuthService } from "./pl24-auth.service";
|
||||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||||
|
import { PL24Service } from "./pl24.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [PL24Service, PL24AuthService, PL24FordLegacyService],
|
providers: [PL24Service, PL24AuthService, PL24FordLegacyService],
|
||||||
|
|||||||
@@ -7,31 +7,31 @@
|
|||||||
|
|
||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
BadRequestException,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
ServiceUnavailableException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import type { ConfigService } from "@nestjs/config";
|
||||||
import { PL24AuthService } from "./pl24-auth.service";
|
import type { RedisService } from "../../redis/redis.service";
|
||||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
import type { StorageService } from "../../storage/storage.service";
|
||||||
import { RedisService } from "../../redis/redis.service";
|
import type { PL24AuthService } from "./pl24-auth.service";
|
||||||
import { StorageService } from "../../storage/storage.service";
|
import type { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||||
import { PL24_DEFAULTS } from "./pl24.constants";
|
import { PL24_DEFAULTS } from "./pl24.constants";
|
||||||
import {
|
import {
|
||||||
|
type PL24DecodedCategory,
|
||||||
|
type PL24DecodedVehicle,
|
||||||
|
type PL24Hotspot,
|
||||||
|
type PL24MainGroup,
|
||||||
|
type PL24Part,
|
||||||
|
type PL24PartsResponse,
|
||||||
PL24_WMI_SERVICE_MAP,
|
PL24_WMI_SERVICE_MAP,
|
||||||
PL24DecodedVehicle,
|
SERVICE_TO_BRAND,
|
||||||
PL24DecodedCategory,
|
|
||||||
PL24PartsResponse,
|
|
||||||
PL24Part,
|
|
||||||
PL24Hotspot,
|
|
||||||
PL24MainGroup,
|
|
||||||
getServiceApiPath,
|
getServiceApiPath,
|
||||||
getServiceConfig,
|
getServiceConfig,
|
||||||
isP5Modern,
|
|
||||||
isLegacyArchitecture,
|
isLegacyArchitecture,
|
||||||
SERVICE_TO_BRAND,
|
isP5Modern,
|
||||||
} from "./pl24.types";
|
} from "./pl24.types";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -48,10 +48,7 @@ export class PL24Service {
|
|||||||
private redis: RedisService,
|
private redis: RedisService,
|
||||||
private storage: StorageService,
|
private storage: StorageService,
|
||||||
) {
|
) {
|
||||||
this.baseUrl = this.configService.get<string>(
|
this.baseUrl = this.configService.get<string>("pl24.baseUrl", "https://www.partslink24.com");
|
||||||
"pl24.baseUrl",
|
|
||||||
"https://www.partslink24.com",
|
|
||||||
);
|
|
||||||
this.timeout = 30000;
|
this.timeout = 30000;
|
||||||
this.language = "tr";
|
this.language = "tr";
|
||||||
}
|
}
|
||||||
@@ -86,15 +83,11 @@ export class PL24Service {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Decoding VIN: ${cleanVin} with service: ${serviceName}`);
|
||||||
`Decoding VIN: ${cleanVin} with service: ${serviceName}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const catalogConfig = getServiceConfig(serviceName);
|
const catalogConfig = getServiceConfig(serviceName);
|
||||||
if (!catalogConfig) {
|
if (!catalogConfig) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(`Bu marka PL24'te desteklenmiyor: ${serviceName}`);
|
||||||
`Bu marka PL24'te desteklenmiyor: ${serviceName}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -124,33 +117,21 @@ export class PL24Service {
|
|||||||
const vinData = responseData.data || responseData;
|
const vinData = responseData.data || responseData;
|
||||||
|
|
||||||
if (responseData.error || responseData.errorCode) {
|
if (responseData.error || responseData.errorCode) {
|
||||||
this.logger.warn(
|
this.logger.warn(`VIN decode error: ${responseData.error || responseData.errorCode}`);
|
||||||
`VIN decode error: ${responseData.error || responseData.errorCode}`,
|
throw new BadRequestException(responseData.error || "VIN sorgulanamadi");
|
||||||
);
|
|
||||||
throw new BadRequestException(
|
|
||||||
responseData.error || "VIN sorgulanamadi",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vinData.resultStatus !== "VEHICLE_IDENTIFIED") {
|
if (vinData.resultStatus !== "VEHICLE_IDENTIFIED") {
|
||||||
const message =
|
const message = (responseData.messages as string[])?.[0] || "VIN bulunamadi";
|
||||||
(responseData.messages as string[])?.[0] || "VIN bulunamadi";
|
|
||||||
throw new NotFoundException(message);
|
throw new NotFoundException(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse vehicle info
|
// Parse vehicle info
|
||||||
const vehicle = this.parseVehicleResponse(
|
const vehicle = this.parseVehicleResponse(cleanVin, vinData, serviceName);
|
||||||
cleanVin,
|
|
||||||
vinData,
|
|
||||||
serviceName,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fetch main groups (categories)
|
// Fetch main groups (categories)
|
||||||
const mainGroupsPath = vehicle.catalogInfo?.mainGroupsPath || "";
|
const mainGroupsPath = vehicle.catalogInfo?.mainGroupsPath || "";
|
||||||
const categories = await this.fetchMainGroupsByPath(
|
const categories = await this.fetchMainGroupsByPath(mainGroupsPath, headers);
|
||||||
mainGroupsPath,
|
|
||||||
headers,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result: PL24DecodedVehicle = {
|
const result: PL24DecodedVehicle = {
|
||||||
...vehicle,
|
...vehicle,
|
||||||
@@ -173,15 +154,11 @@ export class PL24Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (err.name === "TimeoutError") {
|
if (err.name === "TimeoutError") {
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException("PL24 zaman asimina ugradi. Lutfen tekrar deneyin.");
|
||||||
"PL24 zaman asimina ugradi. Lutfen tekrar deneyin.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.error(`VIN decode error: ${err.message}`, err.stack);
|
this.logger.error(`VIN decode error: ${err.message}`, err.stack);
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException("VIN sorgulama sirasinda bir hata olustu");
|
||||||
"VIN sorgulama sirasinda bir hata olustu",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,8 +204,7 @@ export class PL24Service {
|
|||||||
|
|
||||||
const data = (await response.json()) as Record<string, any>;
|
const data = (await response.json()) as Record<string, any>;
|
||||||
|
|
||||||
const crumbs =
|
const crumbs = (data.crumbs as Array<{ name: string }>) || [];
|
||||||
(data.crumbs as Array<{ name: string }>) || [];
|
|
||||||
const groupName = crumbs[crumbs.length - 1]?.name || "";
|
const groupName = crumbs[crumbs.length - 1]?.name || "";
|
||||||
const imageData = this.extractImageData(data);
|
const imageData = this.extractImageData(data);
|
||||||
|
|
||||||
@@ -310,14 +286,13 @@ export class PL24Service {
|
|||||||
|
|
||||||
const data = (await response.json()) as Record<string, any>;
|
const data = (await response.json()) as Record<string, any>;
|
||||||
|
|
||||||
const crumbs =
|
const crumbs = (data.crumbs as Array<{ name: string }>) || [];
|
||||||
(data.crumbs as Array<{ name: string }>) || [];
|
|
||||||
const groupName = crumbs[crumbs.length - 1]?.name || "";
|
const groupName = crumbs[crumbs.length - 1]?.name || "";
|
||||||
const illustrationId =
|
const illustrationId = linkPath.match(/illustrationId=(\d+)/)?.[1] || "";
|
||||||
linkPath.match(/illustrationId=(\d+)/)?.[1] || "";
|
|
||||||
// Temporary: log raw images field to diagnose 404 issue
|
// Temporary: log raw images field to diagnose 404 issue
|
||||||
const rawImages = (data?.data as any)?.images || data?.images;
|
const rawImages = (data?.data as any)?.images || data?.images;
|
||||||
if (rawImages) this.logger.log(`[imgdbg] images field: ${JSON.stringify(rawImages).substring(0, 500)}`);
|
if (rawImages)
|
||||||
|
this.logger.log(`[imgdbg] images field: ${JSON.stringify(rawImages).substring(0, 500)}`);
|
||||||
const imageData = this.extractImageData(data);
|
const imageData = this.extractImageData(data);
|
||||||
|
|
||||||
let parts = this.parsePartsResponse(data);
|
let parts = this.parsePartsResponse(data);
|
||||||
@@ -325,9 +300,7 @@ export class PL24Service {
|
|||||||
// Filter parts by position if this is a position-level request
|
// Filter parts by position if this is a position-level request
|
||||||
if (positionFilter && parts.length > 0) {
|
if (positionFilter && parts.length > 0) {
|
||||||
const filtered = parts.filter(
|
const filtered = parts.filter(
|
||||||
(p) =>
|
(p) => p.positionCode === positionFilter || p.positionCode === `(${positionFilter})`,
|
||||||
p.positionCode === positionFilter ||
|
|
||||||
p.positionCode === `(${positionFilter})`,
|
|
||||||
);
|
);
|
||||||
if (filtered.length > 0) parts = filtered;
|
if (filtered.length > 0) parts = filtered;
|
||||||
}
|
}
|
||||||
@@ -425,7 +398,13 @@ export class PL24Service {
|
|||||||
}
|
}
|
||||||
// PSA illustrations dispatch (/psa/.../json-illustrations.action → illustrations)
|
// PSA illustrations dispatch (/psa/.../json-illustrations.action → illustrations)
|
||||||
if (this.isPsaIllusPath(linkPath)) {
|
if (this.isPsaIllusPath(linkPath)) {
|
||||||
return this.fordLegacyService.fetchPsaIllustrations(linkPath, serviceName, body, engine, gearbox);
|
return this.fordLegacyService.fetchPsaIllustrations(
|
||||||
|
linkPath,
|
||||||
|
serviceName,
|
||||||
|
body,
|
||||||
|
engine,
|
||||||
|
gearbox,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Fetching sub-groups by path: ${linkPath}`);
|
this.logger.log(`Fetching sub-groups by path: ${linkPath}`);
|
||||||
@@ -502,7 +481,14 @@ export class PL24Service {
|
|||||||
mode: string,
|
mode: string,
|
||||||
upds: string,
|
upds: string,
|
||||||
): Promise<{ code: string; name: string }[]> {
|
): Promise<{ code: string; name: string }[]> {
|
||||||
return this.fordLegacyService.fetchPsaEnginesForBody(svc, familyId, salesTypeId, bodyCode, mode, upds);
|
return this.fordLegacyService.fetchPsaEnginesForBody(
|
||||||
|
svc,
|
||||||
|
familyId,
|
||||||
|
salesTypeId,
|
||||||
|
bodyCode,
|
||||||
|
mode,
|
||||||
|
upds,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -517,7 +503,15 @@ export class PL24Service {
|
|||||||
mode: string,
|
mode: string,
|
||||||
upds: string,
|
upds: string,
|
||||||
): Promise<{ code: string; name: string }[]> {
|
): Promise<{ code: string; name: string }[]> {
|
||||||
return this.fordLegacyService.fetchPsaGearboxes(svc, familyId, salesTypeId, bodyCode, engineCode, mode, upds);
|
return this.fordLegacyService.fetchPsaGearboxes(
|
||||||
|
svc,
|
||||||
|
familyId,
|
||||||
|
salesTypeId,
|
||||||
|
bodyCode,
|
||||||
|
engineCode,
|
||||||
|
mode,
|
||||||
|
upds,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -565,7 +559,16 @@ export class PL24Service {
|
|||||||
upds: string,
|
upds: string,
|
||||||
catCode?: string,
|
catCode?: string,
|
||||||
): Promise<PL24DecodedCategory[]> {
|
): Promise<PL24DecodedCategory[]> {
|
||||||
return this.fordLegacyService.fetchFordMainGroups(svc, familyId, modelYear, engine, gearbox, mode, upds, catCode);
|
return this.fordLegacyService.fetchFordMainGroups(
|
||||||
|
svc,
|
||||||
|
familyId,
|
||||||
|
modelYear,
|
||||||
|
engine,
|
||||||
|
gearbox,
|
||||||
|
mode,
|
||||||
|
upds,
|
||||||
|
catCode,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -621,8 +624,15 @@ export class PL24Service {
|
|||||||
// - OR the records' link.wid indicates parts navigation (subGroup, partsList, etc.)
|
// - OR the records' link.wid indicates parts navigation (subGroup, partsList, etc.)
|
||||||
const fetchedMainGroups = restrictionPath.includes("/mainGroup");
|
const fetchedMainGroups = restrictionPath.includes("/mainGroup");
|
||||||
const firstWid = String(records[0]?.link?.wid ?? "");
|
const firstWid = String(records[0]?.link?.wid ?? "");
|
||||||
const partsWids = ["subGroupTable", "subGroupNodeTable", "partsListTable", "mainGroupNodeTable"];
|
const partsWids = [
|
||||||
const widsIndicateParts = partsWids.some((w) => firstWid.includes(w) || firstWid.includes("Group"));
|
"subGroupTable",
|
||||||
|
"subGroupNodeTable",
|
||||||
|
"partsListTable",
|
||||||
|
"mainGroupNodeTable",
|
||||||
|
];
|
||||||
|
const widsIndicateParts = partsWids.some(
|
||||||
|
(w) => firstWid.includes(w) || firstWid.includes("Group"),
|
||||||
|
);
|
||||||
|
|
||||||
const isFinal = fetchedMainGroups || widsIndicateParts;
|
const isFinal = fetchedMainGroups || widsIndicateParts;
|
||||||
if (isFinal) {
|
if (isFinal) {
|
||||||
@@ -709,9 +719,7 @@ export class PL24Service {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
this.logger.warn(
|
this.logger.warn(`Failed to download image: HTTP ${response.status}`);
|
||||||
`Failed to download image: HTTP ${response.status}`,
|
|
||||||
);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -725,10 +733,8 @@ export class PL24Service {
|
|||||||
const jsonData = (await response.json()) as Record<string, any>;
|
const jsonData = (await response.json()) as Record<string, any>;
|
||||||
if (jsonData.image && typeof jsonData.image === "string") {
|
if (jsonData.image && typeof jsonData.image === "string") {
|
||||||
buffer = Buffer.from(jsonData.image, "base64");
|
buffer = Buffer.from(jsonData.image, "base64");
|
||||||
width =
|
width = jsonData.originalWidth || jsonData.scaledWidth || null;
|
||||||
jsonData.originalWidth || jsonData.scaledWidth || null;
|
height = jsonData.originalHeight || jsonData.scaledHeight || null;
|
||||||
height =
|
|
||||||
jsonData.originalHeight || jsonData.scaledHeight || null;
|
|
||||||
|
|
||||||
if (Array.isArray(jsonData.hotspots)) {
|
if (Array.isArray(jsonData.hotspots)) {
|
||||||
hotspots = jsonData.hotspots.map(
|
hotspots = jsonData.hotspots.map(
|
||||||
@@ -763,11 +769,7 @@ export class PL24Service {
|
|||||||
|
|
||||||
// Upload to MinIO
|
// Upload to MinIO
|
||||||
const minioKey = `schemas/${imageId}.png`;
|
const minioKey = `schemas/${imageId}.png`;
|
||||||
const uploadedUrl = await this.storage.upload(
|
const uploadedUrl = await this.storage.upload(minioKey, buffer, "image/png");
|
||||||
minioKey,
|
|
||||||
buffer,
|
|
||||||
"image/png",
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
imageUrl: uploadedUrl,
|
imageUrl: uploadedUrl,
|
||||||
@@ -780,9 +782,7 @@ export class PL24Service {
|
|||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
this.logger.error(
|
this.logger.error(`Failed to download schema image: ${err.message}`);
|
||||||
`Failed to download schema image: ${err.message}`,
|
|
||||||
);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -945,12 +945,36 @@ export class PL24Service {
|
|||||||
if (!vin || vin.length < 10) return null;
|
if (!vin || vin.length < 10) return null;
|
||||||
const yearChar = vin.charAt(9).toUpperCase();
|
const yearChar = vin.charAt(9).toUpperCase();
|
||||||
const yearMap: Record<string, number> = {
|
const yearMap: Record<string, number> = {
|
||||||
"1": 2001, "2": 2002, "3": 2003, "4": 2004, "5": 2005,
|
"1": 2001,
|
||||||
"6": 2006, "7": 2007, "8": 2008, "9": 2009,
|
"2": 2002,
|
||||||
A: 2010, B: 2011, C: 2012, D: 2013, E: 2014, F: 2015,
|
"3": 2003,
|
||||||
G: 2016, H: 2017, J: 2018, K: 2019, L: 2020, M: 2021,
|
"4": 2004,
|
||||||
N: 2022, P: 2023, R: 2024, S: 2025, T: 2026, V: 2027,
|
"5": 2005,
|
||||||
W: 2028, X: 2029, Y: 2030,
|
"6": 2006,
|
||||||
|
"7": 2007,
|
||||||
|
"8": 2008,
|
||||||
|
"9": 2009,
|
||||||
|
A: 2010,
|
||||||
|
B: 2011,
|
||||||
|
C: 2012,
|
||||||
|
D: 2013,
|
||||||
|
E: 2014,
|
||||||
|
F: 2015,
|
||||||
|
G: 2016,
|
||||||
|
H: 2017,
|
||||||
|
J: 2018,
|
||||||
|
K: 2019,
|
||||||
|
L: 2020,
|
||||||
|
M: 2021,
|
||||||
|
N: 2022,
|
||||||
|
P: 2023,
|
||||||
|
R: 2024,
|
||||||
|
S: 2025,
|
||||||
|
T: 2026,
|
||||||
|
V: 2027,
|
||||||
|
W: 2028,
|
||||||
|
X: 2029,
|
||||||
|
Y: 2030,
|
||||||
};
|
};
|
||||||
return yearMap[yearChar] || null;
|
return yearMap[yearChar] || null;
|
||||||
}
|
}
|
||||||
@@ -966,21 +990,15 @@ export class PL24Service {
|
|||||||
serviceName: string,
|
serviceName: string,
|
||||||
): Omit<PL24DecodedVehicle, "categories"> {
|
): Omit<PL24DecodedVehicle, "categories"> {
|
||||||
const segments =
|
const segments =
|
||||||
(data.segments as Record<
|
(data.segments as Record<string, { records?: Array<{ values: Record<string, string> }> }>) ||
|
||||||
string,
|
{};
|
||||||
{ records?: Array<{ values: Record<string, string> }> }
|
|
||||||
>) || {};
|
|
||||||
const vinfoRecords = segments.vinfoBasic?.records || [];
|
const vinfoRecords = segments.vinfoBasic?.records || [];
|
||||||
|
|
||||||
const vehicleData: Record<string, string> = {};
|
const vehicleData: Record<string, string> = {};
|
||||||
for (const record of vinfoRecords) {
|
for (const record of vinfoRecords) {
|
||||||
if (record.values) {
|
if (record.values) {
|
||||||
const key =
|
const key = record.values.description?.toLowerCase().replace(/[\s\/]+/g, "_") || "";
|
||||||
record.values.description
|
vehicleData[key] = record.values.value?.replace(/\r?\n/g, " ").trim() || "";
|
||||||
?.toLowerCase()
|
|
||||||
.replace(/[\s\/]+/g, "_") || "";
|
|
||||||
vehicleData[key] =
|
|
||||||
record.values.value?.replace(/\r?\n/g, " ").trim() || "";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1004,14 +1022,10 @@ export class PL24Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract main groups link path
|
// Extract main groups link path
|
||||||
let mainGroupsPath =
|
let mainGroupsPath = (data.link as Record<string, string>)?.path || "";
|
||||||
(data.link as Record<string, string>)?.path || "";
|
|
||||||
|
|
||||||
// Mercedes: convert vin_scope to vin_main
|
// Mercedes: convert vin_scope to vin_main
|
||||||
if (
|
if (this.isDaimlerService(serviceName) && mainGroupsPath.includes("vin_scope")) {
|
||||||
this.isDaimlerService(serviceName) &&
|
|
||||||
mainGroupsPath.includes("vin_scope")
|
|
||||||
) {
|
|
||||||
mainGroupsPath = mainGroupsPath
|
mainGroupsPath = mainGroupsPath
|
||||||
.replace("/groups/vin_scope", "/groups/vin_main")
|
.replace("/groups/vin_scope", "/groups/vin_main")
|
||||||
.replace("?", "?scope=F&subAggregate=n-r&");
|
.replace("?", "?scope=F&subAggregate=n-r&");
|
||||||
@@ -1024,35 +1038,27 @@ export class PL24Service {
|
|||||||
const transmissionCode = lookup("şanzıman_kodu", "sanzıman_kodu", "transmission_code");
|
const transmissionCode = lookup("şanzıman_kodu", "sanzıman_kodu", "transmission_code");
|
||||||
|
|
||||||
// Build body type from prNr K8* (Kaporta formları)
|
// Build body type from prNr K8* (Kaporta formları)
|
||||||
const bodyType = Object.entries(prNrByCode).find(
|
const bodyType =
|
||||||
([code]) => code.startsWith("K8"),
|
Object.entries(prNrByCode).find(([code]) => code.startsWith("K8"))?.[1] || null;
|
||||||
)?.[1] || null;
|
|
||||||
|
|
||||||
// Engine description from prNr D3* (Motor nitelikleri)
|
// Engine description from prNr D3* (Motor nitelikleri)
|
||||||
const engineDesc = Object.entries(prNrByCode).find(
|
const engineDesc =
|
||||||
([code]) => code.startsWith("D3"),
|
Object.entries(prNrByCode).find(([code]) => code.startsWith("D3"))?.[1] || null;
|
||||||
)?.[1] || null;
|
|
||||||
|
|
||||||
// Transmission type from prNr G0* (Şanzıman nitelikleri)
|
// Transmission type from prNr G0* (Şanzıman nitelikleri)
|
||||||
const transmissionDesc = Object.entries(prNrByCode).find(
|
const transmissionDesc =
|
||||||
([code]) => code.startsWith("G0"),
|
Object.entries(prNrByCode).find(([code]) => code.startsWith("G0"))?.[1] || null;
|
||||||
)?.[1] || null;
|
|
||||||
|
|
||||||
// Drive type from prNr 1X* (Tahrik türü)
|
// Drive type from prNr 1X* (Tahrik türü)
|
||||||
const driveType = Object.entries(prNrByCode).find(
|
const driveType =
|
||||||
([code]) => code.startsWith("1X"),
|
Object.entries(prNrByCode).find(([code]) => code.startsWith("1X"))?.[1] ||
|
||||||
)?.[1] || lookup("aks_tahrigi_tanimi", "axle_drive");
|
lookup("aks_tahrigi_tanimi", "axle_drive");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
brand: SERVICE_TO_BRAND[serviceName] || serviceName.replace("_parts", ""),
|
brand: SERVICE_TO_BRAND[serviceName] || serviceName.replace("_parts", ""),
|
||||||
model:
|
model: lookup("model")?.trim() || (data.description as string)?.split(" - ")[0]?.trim() || "",
|
||||||
lookup("model")?.trim() ||
|
|
||||||
(data.description as string)?.split(" - ")[0]?.trim() ||
|
|
||||||
"",
|
|
||||||
year:
|
year:
|
||||||
parseInt(lookup("model_yili", "year") || "", 10) ||
|
Number.parseInt(lookup("model_yili", "year") || "", 10) || this.getYearFromVin(vin) || 0,
|
||||||
this.getYearFromVin(vin) ||
|
|
||||||
0,
|
|
||||||
series: lookup("satis_tipi", "sales_type"),
|
series: lookup("satis_tipi", "sales_type"),
|
||||||
bodyType,
|
bodyType,
|
||||||
engineCode: engineCode || (engineDesc ? engineDesc.split("/")[0]?.trim() : null),
|
engineCode: engineCode || (engineDesc ? engineDesc.split("/")[0]?.trim() : null),
|
||||||
@@ -1097,9 +1103,7 @@ export class PL24Service {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
this.logger.warn(
|
this.logger.warn(`Failed to fetch main groups: HTTP ${response.status}`);
|
||||||
`Failed to fetch main groups: HTTP ${response.status}`,
|
|
||||||
);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1133,9 +1137,7 @@ export class PL24Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Filter out section headers
|
// Filter out section headers
|
||||||
const categoryRecords = records.filter(
|
const categoryRecords = records.filter((record) => record.characteristic !== "sectionrow");
|
||||||
(record) => record.characteristic !== "sectionrow",
|
|
||||||
);
|
|
||||||
|
|
||||||
return categoryRecords.map((record) => {
|
return categoryRecords.map((record) => {
|
||||||
const values = (record.values as Record<string, string>) || {};
|
const values = (record.values as Record<string, string>) || {};
|
||||||
@@ -1246,9 +1248,7 @@ export class PL24Service {
|
|||||||
.replace(/\\r?\\n/g, " ")
|
.replace(/\\r?\\n/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
const separatorMatch = rawCaption.match(
|
const separatorMatch = rawCaption.match(/^[\w_]+\s*[–—-]\s*(.+)$/);
|
||||||
/^[\w_]+\s*[–—-]\s*(.+)$/,
|
|
||||||
);
|
|
||||||
let name = separatorMatch ? separatorMatch[1].trim() : rawCaption;
|
let name = separatorMatch ? separatorMatch[1].trim() : rawCaption;
|
||||||
|
|
||||||
const remarks = (values.remarks || "").replace(/\r?\n/g, " ").trim();
|
const remarks = (values.remarks || "").replace(/\r?\n/g, " ").trim();
|
||||||
@@ -1270,9 +1270,8 @@ export class PL24Service {
|
|||||||
|
|
||||||
// Construct linkPath: use record's own link.path, or bomBaseLink + record id
|
// Construct linkPath: use record's own link.path, or bomBaseLink + record id
|
||||||
const recordLinkPath = (link.path as string) || undefined;
|
const recordLinkPath = (link.path as string) || undefined;
|
||||||
const constructedPath = !recordLinkPath && bomBasePath
|
const constructedPath =
|
||||||
? `${bomBasePath}${record.id}`
|
!recordLinkPath && bomBasePath ? `${bomBasePath}${record.id}` : recordLinkPath;
|
||||||
: recordLinkPath;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(record.id || ""),
|
id: String(record.id || ""),
|
||||||
@@ -1293,8 +1292,7 @@ export class PL24Service {
|
|||||||
*/
|
*/
|
||||||
private parsePartsResponse(response: unknown): PL24Part[] {
|
private parsePartsResponse(response: unknown): PL24Part[] {
|
||||||
const responseData = response as Record<string, unknown>;
|
const responseData = response as Record<string, unknown>;
|
||||||
const data =
|
const data = (responseData.data as Record<string, unknown>) || responseData;
|
||||||
(responseData.data as Record<string, unknown>) || responseData;
|
|
||||||
|
|
||||||
let records: Array<Record<string, unknown>> = [];
|
let records: Array<Record<string, unknown>> = [];
|
||||||
if (Array.isArray(data.records)) {
|
if (Array.isArray(data.records)) {
|
||||||
@@ -1314,26 +1312,18 @@ export class PL24Service {
|
|||||||
return partRecords.map((part) => {
|
return partRecords.map((part) => {
|
||||||
const values = (part.values as Record<string, string>) || {};
|
const values = (part.values as Record<string, string>) || {};
|
||||||
|
|
||||||
const formattedPartNo = (
|
const formattedPartNo = ((part.partno as string) || values.partno || "").trim();
|
||||||
(part.partno as string) ||
|
|
||||||
values.partno ||
|
|
||||||
""
|
|
||||||
).trim();
|
|
||||||
const cleanPartNo = formattedPartNo.replace(/\s+/g, "");
|
const cleanPartNo = formattedPartNo.replace(/\s+/g, "");
|
||||||
|
|
||||||
const qtyStr = values.qty || "";
|
const qtyStr = values.qty || "";
|
||||||
const quantity = parseInt(qtyStr.trim(), 10) || undefined;
|
const quantity = Number.parseInt(qtyStr.trim(), 10) || undefined;
|
||||||
|
|
||||||
const remark = values.remark?.trim() || undefined;
|
const remark = values.remark?.trim() || undefined;
|
||||||
const modelCodes = values.modelDescription?.trim() || undefined;
|
const modelCodes = values.modelDescription?.trim() || undefined;
|
||||||
|
|
||||||
let superseded:
|
let superseded: { oldCode: string; newCode: string } | undefined;
|
||||||
| { oldCode: string; newCode: string }
|
const supersededByValue = (part.supersededBy as string) || values.supersededBy || "";
|
||||||
| undefined;
|
const supersedesValue = (part.supersedes as string) || values.supersedes || "";
|
||||||
const supersededByValue =
|
|
||||||
(part.supersededBy as string) || values.supersededBy || "";
|
|
||||||
const supersedesValue =
|
|
||||||
(part.supersedes as string) || values.supersedes || "";
|
|
||||||
if (supersededByValue || supersedesValue) {
|
if (supersededByValue || supersedesValue) {
|
||||||
superseded = {
|
superseded = {
|
||||||
oldCode: supersedesValue ? cleanPartNo : "",
|
oldCode: supersedesValue ? cleanPartNo : "",
|
||||||
@@ -1343,22 +1333,23 @@ export class PL24Service {
|
|||||||
|
|
||||||
// Price extraction (de account with German market returns EUR prices in BOM)
|
// Price extraction (de account with German market returns EUR prices in BOM)
|
||||||
const priceRaw = String(
|
const priceRaw = String(
|
||||||
values.listPrice || values.netPrice || values.price || values.grossPrice || values.retailPrice || "",
|
values.listPrice ||
|
||||||
|
values.netPrice ||
|
||||||
|
values.price ||
|
||||||
|
values.grossPrice ||
|
||||||
|
values.retailPrice ||
|
||||||
|
"",
|
||||||
).trim();
|
).trim();
|
||||||
const priceNum = priceRaw ? parseFloat(priceRaw.replace(",", ".")) : Number.NaN;
|
const priceNum = priceRaw ? Number.parseFloat(priceRaw.replace(",", ".")) : Number.NaN;
|
||||||
const price = Number.isNaN(priceNum) ? undefined : priceNum;
|
const price = Number.isNaN(priceNum) ? undefined : priceNum;
|
||||||
const currency = price !== undefined ? (values.currency || "EUR") : undefined;
|
const currency = price !== undefined ? values.currency || "EUR" : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(part.id || ""),
|
id: String(part.id || ""),
|
||||||
oemCode: cleanPartNo,
|
oemCode: cleanPartNo,
|
||||||
formattedPartNo: formattedPartNo || undefined,
|
formattedPartNo: formattedPartNo || undefined,
|
||||||
name: String(
|
name: String(part.description || values.description || ""),
|
||||||
part.description || values.description || "",
|
description: String(part.description || values.description || ""),
|
||||||
),
|
|
||||||
description: String(
|
|
||||||
part.description || values.description || "",
|
|
||||||
),
|
|
||||||
remark,
|
remark,
|
||||||
quantity,
|
quantity,
|
||||||
positionCode: String(part.pos || values.pos || ""),
|
positionCode: String(part.pos || values.pos || ""),
|
||||||
@@ -1409,8 +1400,7 @@ export class PL24Service {
|
|||||||
|
|
||||||
private extractIllustrationUrl(response: unknown): string | null {
|
private extractIllustrationUrl(response: unknown): string | null {
|
||||||
const responseData = response as Record<string, unknown>;
|
const responseData = response as Record<string, unknown>;
|
||||||
const data =
|
const data = (responseData.data as Record<string, unknown>) || responseData;
|
||||||
(responseData.data as Record<string, unknown>) || responseData;
|
|
||||||
const images =
|
const images =
|
||||||
(data.images as Array<{
|
(data.images as Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1418,8 +1408,7 @@ export class PL24Service {
|
|||||||
name: string;
|
name: string;
|
||||||
}>) || [];
|
}>) || [];
|
||||||
|
|
||||||
const defaultImage =
|
const defaultImage = images.find((img) => img.id === "_DFLT_") || images[0];
|
||||||
images.find((img) => img.id === "_DFLT_") || images[0];
|
|
||||||
if (defaultImage?.uri) {
|
if (defaultImage?.uri) {
|
||||||
return `${this.baseUrl}${defaultImage.uri}`;
|
return `${this.baseUrl}${defaultImage.uri}`;
|
||||||
}
|
}
|
||||||
@@ -1432,8 +1421,7 @@ export class PL24Service {
|
|||||||
schemaHeight?: number;
|
schemaHeight?: number;
|
||||||
} {
|
} {
|
||||||
const responseData = response as Record<string, unknown>;
|
const responseData = response as Record<string, unknown>;
|
||||||
const data =
|
const data = (responseData.data as Record<string, unknown>) || responseData;
|
||||||
(responseData.data as Record<string, unknown>) || responseData;
|
|
||||||
const images =
|
const images =
|
||||||
(data.images as Array<{
|
(data.images as Array<{
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -1443,18 +1431,14 @@ export class PL24Service {
|
|||||||
hotspots?: PL24Hotspot[];
|
hotspots?: PL24Hotspot[];
|
||||||
}>) || [];
|
}>) || [];
|
||||||
|
|
||||||
const defaultImage =
|
const defaultImage = images.find((img) => img.id === "_DFLT_") || images[0];
|
||||||
images.find((img) => img.id === "_DFLT_") || images[0];
|
|
||||||
|
|
||||||
if (!defaultImage) {
|
if (!defaultImage) {
|
||||||
return { hotspots: [] };
|
return { hotspots: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const hotspots: PL24Hotspot[] = [];
|
const hotspots: PL24Hotspot[] = [];
|
||||||
if (
|
if (defaultImage.hotspots && Array.isArray(defaultImage.hotspots)) {
|
||||||
defaultImage.hotspots &&
|
|
||||||
Array.isArray(defaultImage.hotspots)
|
|
||||||
) {
|
|
||||||
for (const hs of defaultImage.hotspots) {
|
for (const hs of defaultImage.hotspots) {
|
||||||
if (hs.key && hs.areas) {
|
if (hs.key && hs.areas) {
|
||||||
hotspots.push({ key: hs.key, areas: hs.areas });
|
hotspots.push({ key: hs.key, areas: hs.areas });
|
||||||
@@ -1475,9 +1459,7 @@ export class PL24Service {
|
|||||||
const standardMatch = imageUrl.match(/\/images\/(\d+)\?/);
|
const standardMatch = imageUrl.match(/\/images\/(\d+)\?/);
|
||||||
if (standardMatch) return standardMatch[1];
|
if (standardMatch) return standardMatch[1];
|
||||||
|
|
||||||
const tiffMatch = imageUrl.match(
|
const tiffMatch = imageUrl.match(/\/tiffimages\/(?:[^/]+\/)+([a-zA-Z0-9_-]+)\.\w+/);
|
||||||
/\/tiffimages\/(?:[^/]+\/)+([a-zA-Z0-9_-]+)\.\w+/,
|
|
||||||
);
|
|
||||||
if (tiffMatch) return tiffMatch[1];
|
if (tiffMatch) return tiffMatch[1];
|
||||||
|
|
||||||
const mercedesMatch = imageUrl.match(/[?&]illu=([a-zA-Z0-9_]+)/);
|
const mercedesMatch = imageUrl.match(/[?&]illu=([a-zA-Z0-9_]+)/);
|
||||||
@@ -1533,21 +1515,15 @@ export class PL24Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isDaimlerService(serviceName: string): boolean {
|
private isDaimlerService(serviceName: string): boolean {
|
||||||
return (
|
return serviceName.startsWith("mercedes") || serviceName === "smart_parts";
|
||||||
serviceName.startsWith("mercedes") || serviceName === "smart_parts"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private isDaimlerSubPath(linkPath: string): boolean {
|
private isDaimlerSubPath(linkPath: string): boolean {
|
||||||
return (
|
return linkPath.includes("/p5daimler/") && linkPath.includes("vin_sub");
|
||||||
linkPath.includes("/p5daimler/") && linkPath.includes("vin_sub")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private isJlrIllusPath(linkPath: string): boolean {
|
private isJlrIllusPath(linkPath: string): boolean {
|
||||||
return (
|
return linkPath.includes("/p5jlr/") && linkPath.includes("vin_illus");
|
||||||
linkPath.includes("/p5jlr/") && linkPath.includes("vin_illus")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1570,17 +1546,15 @@ export class PL24Service {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!subResponse.ok) {
|
if (!subResponse.ok) {
|
||||||
throw new Error(
|
throw new Error(`HTTP ${subResponse.status}: ${subResponse.statusText}`);
|
||||||
`HTTP ${subResponse.status}: ${subResponse.statusText}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const subData = (await subResponse.json()) as Record<string, any>;
|
const subData = (await subResponse.json()) as Record<string, any>;
|
||||||
const records = ((subData.data?.records || []) as Array<{
|
const records = (subData.data?.records || []) as Array<{
|
||||||
id?: string;
|
id?: string;
|
||||||
link?: { path?: string };
|
link?: { path?: string };
|
||||||
values?: Record<string, string>;
|
values?: Record<string, string>;
|
||||||
}>);
|
}>;
|
||||||
|
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
return {
|
return {
|
||||||
@@ -1610,9 +1584,7 @@ export class PL24Service {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!partsResponse.ok) {
|
if (!partsResponse.ok) {
|
||||||
throw new Error(
|
throw new Error(`Parts HTTP ${partsResponse.status}: ${partsResponse.statusText}`);
|
||||||
`Parts HTTP ${partsResponse.status}: ${partsResponse.statusText}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const partsData = (await partsResponse.json()) as Record<string, any>;
|
const partsData = (await partsResponse.json()) as Record<string, any>;
|
||||||
@@ -1624,17 +1596,13 @@ export class PL24Service {
|
|||||||
let schemaHeight: number | undefined;
|
let schemaHeight: number | undefined;
|
||||||
|
|
||||||
if (schemaImageUrl) {
|
if (schemaImageUrl) {
|
||||||
const imageData = await this.fetchDaimlerImageWithHotspots(
|
const imageData = await this.fetchDaimlerImageWithHotspots(schemaImageUrl, serviceName);
|
||||||
schemaImageUrl,
|
|
||||||
serviceName,
|
|
||||||
);
|
|
||||||
hotspots = imageData.hotspots;
|
hotspots = imageData.hotspots;
|
||||||
schemaWidth = imageData.width;
|
schemaWidth = imageData.width;
|
||||||
schemaHeight = imageData.height;
|
schemaHeight = imageData.height;
|
||||||
}
|
}
|
||||||
|
|
||||||
const crumbs =
|
const crumbs = (partsData.crumbs as Array<{ name: string }>) || [];
|
||||||
(partsData.crumbs as Array<{ name: string }>) || [];
|
|
||||||
const groupName = crumbs[crumbs.length - 1]?.name || "";
|
const groupName = crumbs[crumbs.length - 1]?.name || "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1650,16 +1618,14 @@ export class PL24Service {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
this.logger.error(`Mercedes parts fetch error: ${err.message}`);
|
this.logger.error(`Mercedes parts fetch error: ${err.message}`);
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException("Mercedes parca listesi alinamadi");
|
||||||
"Mercedes parca listesi alinamadi",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseDaimlerPartsResponse(response: unknown): PL24Part[] {
|
private parseDaimlerPartsResponse(response: unknown): PL24Part[] {
|
||||||
const responseData = response as Record<string, unknown>;
|
const responseData = response as Record<string, unknown>;
|
||||||
const data = (responseData.data as Record<string, unknown>) || {};
|
const data = (responseData.data as Record<string, unknown>) || {};
|
||||||
const records = ((data.records || []) as Array<{
|
const records = (data.records || []) as Array<{
|
||||||
id?: string;
|
id?: string;
|
||||||
partno?: string;
|
partno?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -1674,14 +1640,11 @@ export class PL24Service {
|
|||||||
qty?: string;
|
qty?: string;
|
||||||
pos?: string;
|
pos?: string;
|
||||||
};
|
};
|
||||||
}>);
|
}>;
|
||||||
|
|
||||||
return records.map((record) => {
|
return records.map((record) => {
|
||||||
const values = record.values || {};
|
const values = record.values || {};
|
||||||
const oemCode = (record.partno || values.partno || "").replace(
|
const oemCode = (record.partno || values.partno || "").replace(/\s+/g, "");
|
||||||
/\s+/g,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
const formattedPartNo = record.partno || values.partno || "";
|
const formattedPartNo = record.partno || values.partno || "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1692,7 +1655,7 @@ export class PL24Service {
|
|||||||
description: values.remark || undefined,
|
description: values.remark || undefined,
|
||||||
positionCode: record.pos || values.pos || undefined,
|
positionCode: record.pos || values.pos || undefined,
|
||||||
hotspotId: record.hotspotId || record.pos || undefined,
|
hotspotId: record.hotspotId || record.pos || undefined,
|
||||||
quantity: parseInt(values.qty || "1", 10) || 1,
|
quantity: Number.parseInt(values.qty || "1", 10) || 1,
|
||||||
modelCodes: values.restrictions || undefined,
|
modelCodes: values.restrictions || undefined,
|
||||||
presel: !!record.presel,
|
presel: !!record.presel,
|
||||||
};
|
};
|
||||||
@@ -1704,19 +1667,17 @@ export class PL24Service {
|
|||||||
} {
|
} {
|
||||||
const responseData = response as Record<string, unknown>;
|
const responseData = response as Record<string, unknown>;
|
||||||
const data = (responseData.data as Record<string, unknown>) || {};
|
const data = (responseData.data as Record<string, unknown>) || {};
|
||||||
const images = ((data.images || []) as Array<{
|
const images = (data.images || []) as Array<{
|
||||||
id?: string;
|
id?: string;
|
||||||
uri?: string;
|
uri?: string;
|
||||||
}>);
|
}>;
|
||||||
|
|
||||||
if (images.length === 0) return {};
|
if (images.length === 0) return {};
|
||||||
|
|
||||||
const imageUri = images[0].uri;
|
const imageUri = images[0].uri;
|
||||||
if (!imageUri) return {};
|
if (!imageUri) return {};
|
||||||
|
|
||||||
const schemaImageUrl = imageUri.startsWith("http")
|
const schemaImageUrl = imageUri.startsWith("http") ? imageUri : `${this.baseUrl}${imageUri}`;
|
||||||
? imageUri
|
|
||||||
: `${this.baseUrl}${imageUri}`;
|
|
||||||
|
|
||||||
return { schemaImageUrl };
|
return { schemaImageUrl };
|
||||||
}
|
}
|
||||||
@@ -1773,21 +1734,15 @@ export class PL24Service {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
hotspots,
|
hotspots,
|
||||||
width:
|
width: jsonData.originalWidth || jsonData.scaledWidth || undefined,
|
||||||
jsonData.originalWidth || jsonData.scaledWidth || undefined,
|
height: jsonData.originalHeight || jsonData.scaledHeight || undefined,
|
||||||
height:
|
|
||||||
jsonData.originalHeight ||
|
|
||||||
jsonData.scaledHeight ||
|
|
||||||
undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { hotspots: [] };
|
return { hotspots: [] };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
this.logger.error(
|
this.logger.error(`Mercedes: Failed to fetch image hotspots: ${err.message}`);
|
||||||
`Mercedes: Failed to fetch image hotspots: ${err.message}`,
|
|
||||||
);
|
|
||||||
return { hotspots: [] };
|
return { hotspots: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1812,9 +1767,7 @@ export class PL24Service {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!illusResponse.ok) {
|
if (!illusResponse.ok) {
|
||||||
throw new Error(
|
throw new Error(`HTTP ${illusResponse.status}: ${illusResponse.statusText}`);
|
||||||
`HTTP ${illusResponse.status}: ${illusResponse.statusText}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const illusData = (await illusResponse.json()) as Record<string, any>;
|
const illusData = (await illusResponse.json()) as Record<string, any>;
|
||||||
@@ -1830,9 +1783,7 @@ export class PL24Service {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(
|
const urlParams = new URLSearchParams(illusPath.split("?")[1] || "");
|
||||||
illusPath.split("?")[1] || "",
|
|
||||||
);
|
|
||||||
const mg = urlParams.get("mg") || "";
|
const mg = urlParams.get("mg") || "";
|
||||||
const sg = urlParams.get("sg") || "";
|
const sg = urlParams.get("sg") || "";
|
||||||
const vin = urlParams.get("vin") || "";
|
const vin = urlParams.get("vin") || "";
|
||||||
@@ -1849,22 +1800,17 @@ export class PL24Service {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!bomResponse.ok) {
|
if (!bomResponse.ok) {
|
||||||
throw new Error(
|
throw new Error(`HTTP ${bomResponse.status}: ${bomResponse.statusText}`);
|
||||||
`HTTP ${bomResponse.status}: ${bomResponse.statusText}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const bomData = (await bomResponse.json()) as Record<string, any>;
|
const bomData = (await bomResponse.json()) as Record<string, any>;
|
||||||
|
|
||||||
const crumbs =
|
const crumbs = (bomData.crumbs as Array<{ name: string }>) || [];
|
||||||
(bomData.crumbs as Array<{ name: string }>) || [];
|
const groupName = crumbs[crumbs.length - 1]?.name || firstIllus.name || "";
|
||||||
const groupName =
|
|
||||||
crumbs[crumbs.length - 1]?.name || firstIllus.name || "";
|
|
||||||
|
|
||||||
const parts = this.parsePartsResponse(bomData);
|
const parts = this.parsePartsResponse(bomData);
|
||||||
const schemaImageUrl = this.extractIllustrationUrl(bomData);
|
const schemaImageUrl = this.extractIllustrationUrl(bomData);
|
||||||
const { hotspots, schemaWidth, schemaHeight } =
|
const { hotspots, schemaWidth, schemaHeight } = this.extractImageData(bomData);
|
||||||
this.extractImageData(bomData);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -1879,9 +1825,7 @@ export class PL24Service {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
this.logger.error(`JLR fetch parts error: ${err.message}`);
|
this.logger.error(`JLR fetch parts error: ${err.message}`);
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException("Parca listesi alinamadi (JLR)");
|
||||||
"Parca listesi alinamadi (JLR)",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1935,9 +1879,7 @@ export class PL24Service {
|
|||||||
await this.authService.authorizeServiceForAccount(serviceName, "de");
|
await this.authService.authorizeServiceForAccount(serviceName, "de");
|
||||||
headers = await this.authService.buildAuthHeadersForAccount("de", serviceName);
|
headers = await this.authService.buildAuthHeadersForAccount("de", serviceName);
|
||||||
} catch {
|
} catch {
|
||||||
this.logger.warn(
|
this.logger.warn(`fetchVehicleList: de auth failed for ${serviceName}, using main token`);
|
||||||
`fetchVehicleList: de auth failed for ${serviceName}, using main token`,
|
|
||||||
);
|
|
||||||
headers = await this.authService.buildAuthHeaders();
|
headers = await this.authService.buildAuthHeaders();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1946,15 +1888,15 @@ export class PL24Service {
|
|||||||
// Discovered via Playwright explorer (scripts/pl24-catalog-explorer.js → docs/pl24-catalog/*.md)
|
// Discovered via Playwright explorer (scripts/pl24-catalog-explorer.js → docs/pl24-catalog/*.md)
|
||||||
// Each P5 backend uses a different initial model listing endpoint
|
// Each P5 backend uses a different initial model listing endpoint
|
||||||
const BACKEND_MODEL_PATH: Record<string, string> = {
|
const BACKEND_MODEL_PATH: Record<string, string> = {
|
||||||
p5vwag: "/extern/vehicle/modelfamilies", // VW, Audi, Skoda, SEAT, Cupra, Porsche, Bentley
|
p5vwag: "/extern/vehicle/modelfamilies", // VW, Audi, Skoda, SEAT, Cupra, Porsche, Bentley
|
||||||
p5bmw: "/extern/vehicle/models", // BMW, MINI, Motorrad
|
p5bmw: "/extern/vehicle/models", // BMW, MINI, Motorrad
|
||||||
p5daimler: "/extern/vehicle/scope", // Mercedes-Benz, smart
|
p5daimler: "/extern/vehicle/scope", // Mercedes-Benz, smart
|
||||||
p5renault: "/extern/vehicle/catalogs", // Renault, Dacia, Alpine
|
p5renault: "/extern/vehicle/catalogs", // Renault, Dacia, Alpine
|
||||||
p5jlr: "/extern/vehicle/models", // Jaguar, Land Rover
|
p5jlr: "/extern/vehicle/models", // Jaguar, Land Rover
|
||||||
p5toyota: "/extern/vehicle/modelFamilies", // Toyota, Lexus (capital F)
|
p5toyota: "/extern/vehicle/modelFamilies", // Toyota, Lexus (capital F)
|
||||||
p5mitsubishi: "/extern/vehicles/vehiclesOverview", // Mitsubishi
|
p5mitsubishi: "/extern/vehicles/vehiclesOverview", // Mitsubishi
|
||||||
p5suzuki: "/extern/vehicle/modelFamilies", // Suzuki
|
p5suzuki: "/extern/vehicle/modelFamilies", // Suzuki
|
||||||
p5man: "/extern/model/categories", // MAN trucks
|
p5man: "/extern/model/categories", // MAN trucks
|
||||||
};
|
};
|
||||||
|
|
||||||
// catalogBase is like "/p5vwag" — strip leading slash for map lookup
|
// 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 || "");
|
const vehicleId = String(r.id || r.vehicleId || r.vid || "");
|
||||||
// modelfamilies uses values.caption; older formats use values.model / r.description
|
// modelfamilies uses values.caption; older formats use values.model / r.description
|
||||||
const model =
|
const model =
|
||||||
values.caption || values.model || values.description || r.description || r.name || vehicleId;
|
values.caption ||
|
||||||
|
values.model ||
|
||||||
|
values.description ||
|
||||||
|
r.description ||
|
||||||
|
r.name ||
|
||||||
|
vehicleId;
|
||||||
const link = r.link || {};
|
const link = r.link || {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -2107,7 +2054,10 @@ export class PL24Service {
|
|||||||
body = await response.text();
|
body = await response.text();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
results[endpoint] = { status, body: body ? JSON.stringify(body).substring(0, 2000) : null };
|
results[endpoint] = {
|
||||||
|
status,
|
||||||
|
body: body ? JSON.stringify(body).substring(0, 2000) : null,
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
results[endpoint] = { error: (err as Error).message };
|
results[endpoint] = { error: (err as Error).message };
|
||||||
}
|
}
|
||||||
@@ -2124,8 +2074,7 @@ export class PL24Service {
|
|||||||
response: unknown,
|
response: unknown,
|
||||||
): Array<{ btnr: number; name: string; code: string }> {
|
): Array<{ btnr: number; name: string; code: string }> {
|
||||||
const responseData = response as Record<string, unknown>;
|
const responseData = response as Record<string, unknown>;
|
||||||
const data =
|
const data = (responseData.data as Record<string, unknown>) || responseData;
|
||||||
(responseData.data as Record<string, unknown>) || responseData;
|
|
||||||
|
|
||||||
let records: Array<Record<string, unknown>> = [];
|
let records: Array<Record<string, unknown>> = [];
|
||||||
if (Array.isArray(data.records)) {
|
if (Array.isArray(data.records)) {
|
||||||
@@ -2135,8 +2084,7 @@ export class PL24Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const availableRecords = records.filter(
|
const availableRecords = records.filter(
|
||||||
(record) =>
|
(record) => !record.unavailable && record.characteristic !== "sectionrow",
|
||||||
!record.unavailable && record.characteristic !== "sectionrow",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return availableRecords
|
return availableRecords
|
||||||
@@ -2146,7 +2094,7 @@ export class PL24Service {
|
|||||||
|
|
||||||
const linkPath = (link.path as string) || "";
|
const linkPath = (link.path as string) || "";
|
||||||
const btnrMatch = linkPath.match(/btnr=(\d+)/);
|
const btnrMatch = linkPath.match(/btnr=(\d+)/);
|
||||||
const btnr = btnrMatch ? parseInt(btnrMatch[1], 10) : 0;
|
const btnr = btnrMatch ? Number.parseInt(btnrMatch[1], 10) : 0;
|
||||||
|
|
||||||
const name =
|
const name =
|
||||||
values.captions ||
|
values.captions ||
|
||||||
@@ -2157,10 +2105,7 @@ export class PL24Service {
|
|||||||
"";
|
"";
|
||||||
|
|
||||||
const code =
|
const code =
|
||||||
values.illustrationNumber ||
|
values.illustrationNumber || values.subgroup || values.code || String(record.id || "");
|
||||||
values.subgroup ||
|
|
||||||
values.code ||
|
|
||||||
String(record.id || "");
|
|
||||||
|
|
||||||
return { btnr, name, code };
|
return { btnr, name, code };
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,12 +25,7 @@ export interface PL24LoginRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PL24LoginResponse {
|
export interface PL24LoginResponse {
|
||||||
status:
|
status: "OK" | "USER_ALREADY_LOGGED_IN" | "INVALID_CREDENTIALS" | "ERROR" | null;
|
||||||
| "OK"
|
|
||||||
| "USER_ALREADY_LOGGED_IN"
|
|
||||||
| "INVALID_CREDENTIALS"
|
|
||||||
| "ERROR"
|
|
||||||
| null;
|
|
||||||
message?: string;
|
message?: string;
|
||||||
token?: {
|
token?: {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
@@ -388,9 +383,7 @@ export function getServiceApiPath(serviceName: string): string {
|
|||||||
return config?.apiPath || "/p5vwag";
|
return config?.apiPath || "/p5vwag";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getServiceConfig(
|
export function getServiceConfig(serviceName: string): PL24CatalogConfig | null {
|
||||||
serviceName: string,
|
|
||||||
): PL24CatalogConfig | null {
|
|
||||||
return PL24_SERVICE_CATALOGS[serviceName] || null;
|
return PL24_SERVICE_CATALOGS[serviceName] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ConnectionOptions } from "bullmq";
|
import type { ConnectionOptions } from "bullmq";
|
||||||
import { isOtelEnabled } from "../telemetry";
|
import { isOtelEnabled } from "../telemetry";
|
||||||
|
|
||||||
export function getBullConnection(): ConnectionOptions {
|
export function getBullConnection(): ConnectionOptions {
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { Module, OnModuleInit, Inject, OnModuleDestroy } from "@nestjs/common";
|
import { Inject, Module, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
|
||||||
import { Queue } from "bullmq";
|
import type { Queue } from "bullmq";
|
||||||
import { EmexScrapeQueueProvider, EMEX_SCRAPE_QUEUE } from "./queues/emex-scrape.queue";
|
|
||||||
import {
|
|
||||||
SubscriptionExpiryQueueProvider,
|
|
||||||
SUBSCRIPTION_EXPIRY_QUEUE,
|
|
||||||
} from "./queues/subscription-expiry.queue";
|
|
||||||
import { QueryCleanupQueueProvider, QUERY_CLEANUP_QUEUE } from "./queues/query-cleanup.queue";
|
|
||||||
import {
|
|
||||||
CatalogPrefetchQueueProvider,
|
|
||||||
CATALOG_PREFETCH_QUEUE,
|
|
||||||
} from "./queues/catalog-prefetch.queue";
|
|
||||||
import { PrefetchWorkerService } from "./prefetch-worker.service";
|
|
||||||
import { CategoriesModule } from "../categories/categories.module";
|
import { CategoriesModule } from "../categories/categories.module";
|
||||||
|
import { PrefetchWorkerService } from "./prefetch-worker.service";
|
||||||
|
import {
|
||||||
|
CATALOG_PREFETCH_QUEUE,
|
||||||
|
CatalogPrefetchQueueProvider,
|
||||||
|
} from "./queues/catalog-prefetch.queue";
|
||||||
|
import { EMEX_SCRAPE_QUEUE, EmexScrapeQueueProvider } from "./queues/emex-scrape.queue";
|
||||||
|
import { QUERY_CLEANUP_QUEUE, QueryCleanupQueueProvider } from "./queues/query-cleanup.queue";
|
||||||
|
import {
|
||||||
|
SUBSCRIPTION_EXPIRY_QUEUE,
|
||||||
|
SubscriptionExpiryQueueProvider,
|
||||||
|
} from "./queues/subscription-expiry.queue";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CategoriesModule],
|
imports: [CategoriesModule],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { RedisService } from "../redis/redis.service";
|
import type { RedisService } from "../redis/redis.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom error that tells BullMQ to retry after a delay.
|
* Custom error that tells BullMQ to retry after a delay.
|
||||||
@@ -15,10 +15,7 @@ export class RateLimitError extends Error {
|
|||||||
* Check if a user is actively using the source.
|
* Check if a user is actively using the source.
|
||||||
* Throws RateLimitError (1min retry) if cooldown key exists.
|
* Throws RateLimitError (1min retry) if cooldown key exists.
|
||||||
*/
|
*/
|
||||||
export async function checkCooldown(
|
export async function checkCooldown(redis: RedisService, source: string): Promise<void> {
|
||||||
redis: RedisService,
|
|
||||||
source: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const key = `prefetch:activity:${source}`;
|
const key = `prefetch:activity:${source}`;
|
||||||
const exists = await redis.exists(key);
|
const exists = await redis.exists(key);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
@@ -39,7 +36,7 @@ export function checkTimeWindow(source: string): void {
|
|||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
hour12: false,
|
hour12: false,
|
||||||
}).format(new Date());
|
}).format(new Date());
|
||||||
const h = parseInt(hourStr, 10);
|
const h = Number.parseInt(hourStr, 10);
|
||||||
|
|
||||||
const endHour = source === "parts-catalogs" ? 19 : 18;
|
const endHour = source === "parts-catalogs" ? 19 : 18;
|
||||||
if (h < 9 || h >= endHour) {
|
if (h < 9 || h >= endHour) {
|
||||||
@@ -66,7 +63,7 @@ export function msUntilNext9AM(): number {
|
|||||||
}).formatToParts(now);
|
}).formatToParts(now);
|
||||||
|
|
||||||
const get = (type: string) =>
|
const get = (type: string) =>
|
||||||
parseInt(istParts.find((p) => p.type === type)?.value || "0", 10);
|
Number.parseInt(istParts.find((p) => p.type === type)?.value || "0", 10);
|
||||||
|
|
||||||
const hour = get("hour");
|
const hour = get("hour");
|
||||||
const minute = get("minute");
|
const minute = get("minute");
|
||||||
@@ -81,10 +78,7 @@ export function msUntilNext9AM(): number {
|
|||||||
hoursToWait = 24 - hour + 9;
|
hoursToWait = 24 - hour + 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ms =
|
const ms = hoursToWait * 3600_000 - minute * 60_000 - second * 1000;
|
||||||
hoursToWait * 3600_000 -
|
|
||||||
minute * 60_000 -
|
|
||||||
second * 1000;
|
|
||||||
|
|
||||||
// At least 1 minute, at most 15 hours
|
// At least 1 minute, at most 15 hours
|
||||||
return Math.max(60_000, Math.min(ms, 15 * 3600_000));
|
return Math.max(60_000, Math.min(ms, 15 * 3600_000));
|
||||||
@@ -104,19 +98,20 @@ export interface PrefetchProgress {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initProgress(
|
export async function initProgress(redis: RedisService, vehicleId: string): Promise<void> {
|
||||||
redis: RedisService,
|
|
||||||
vehicleId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
await redis.setJson(progressKey(vehicleId), {
|
await redis.setJson(
|
||||||
status: "running",
|
progressKey(vehicleId),
|
||||||
total: 0,
|
{
|
||||||
completed: 0,
|
status: "running",
|
||||||
errors: 0,
|
total: 0,
|
||||||
startedAt: now,
|
completed: 0,
|
||||||
updatedAt: now,
|
errors: 0,
|
||||||
} satisfies PrefetchProgress, 86400);
|
startedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
} satisfies PrefetchProgress,
|
||||||
|
86400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProgress(
|
export async function updateProgress(
|
||||||
|
|||||||
@@ -2,17 +2,16 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
OnModuleDestroy,
|
type OnModuleDestroy,
|
||||||
OnModuleInit,
|
type OnModuleInit,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Job, Queue, Worker } from "bullmq";
|
import { type Job, type Queue, Worker } from "bullmq";
|
||||||
import { eq, and, isNull } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import { CATALOG_PREFETCH_QUEUE } from "./queues/catalog-prefetch.queue";
|
import type { CategoriesService } from "../categories/categories.service";
|
||||||
import { getBullConnection, QUEUE_NAMES } from "./bull.config";
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
import {
|
import { categories, parts, vehicles } from "../database/schema/core";
|
||||||
PrefetchInitJobData,
|
import type { RedisService } from "../redis/redis.service";
|
||||||
PrefetchCategoryJobData,
|
import { QUEUE_NAMES, getBullConnection } from "./bull.config";
|
||||||
} from "./prefetch.types";
|
|
||||||
import {
|
import {
|
||||||
RateLimitError,
|
RateLimitError,
|
||||||
checkCooldown,
|
checkCooldown,
|
||||||
@@ -20,10 +19,11 @@ import {
|
|||||||
initProgress,
|
initProgress,
|
||||||
updateProgress,
|
updateProgress,
|
||||||
} from "./prefetch-utils";
|
} from "./prefetch-utils";
|
||||||
import { CategoriesService } from "../categories/categories.service";
|
import type {
|
||||||
import { RedisService } from "../redis/redis.service";
|
PrefetchCategoryJobData,
|
||||||
import { DATABASE, Database } from "../database/database.provider";
|
PrefetchInitJobData,
|
||||||
import { categories, parts, vehicles } from "../database/schema/core";
|
} from "./prefetch.types";
|
||||||
|
import { CATALOG_PREFETCH_QUEUE } from "./queues/catalog-prefetch.queue";
|
||||||
|
|
||||||
const MAX_DEPTH = 5;
|
const MAX_DEPTH = 5;
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Job } from "bullmq";
|
import type { Job } from "bullmq";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||||
import {
|
import {
|
||||||
emexCatalogs,
|
emexCatalogs,
|
||||||
emexVehicles,
|
|
||||||
emexVehicleVins,
|
|
||||||
emexPartGroups,
|
emexPartGroups,
|
||||||
emexParts,
|
|
||||||
emexPartNumbers,
|
emexPartNumbers,
|
||||||
|
emexParts,
|
||||||
emexScrapeSessions,
|
emexScrapeSessions,
|
||||||
|
emexVehicleVins,
|
||||||
|
emexVehicles,
|
||||||
} from "../../database/schema/emex";
|
} from "../../database/schema/emex";
|
||||||
// Legacy type — kept inline since emex.types.ts was rewritten for emexdwc.ae integration
|
// Legacy type — kept inline since emex.types.ts was rewritten for emexdwc.ae integration
|
||||||
interface EmexScrapeJobData {
|
interface EmexScrapeJobData {
|
||||||
@@ -47,7 +47,7 @@ export async function processEmexScrape(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// ── Step 1: Resolve vehicle from VIN ──────────────────
|
// ── Step 1: Resolve vehicle from VIN ──────────────────
|
||||||
let emexVehicleRecord = await db
|
const emexVehicleRecord = await db
|
||||||
.select({ id: emexVehicles.id, vehicleId: emexVehicles.vehicleId })
|
.select({ id: emexVehicles.id, vehicleId: emexVehicles.vehicleId })
|
||||||
.from(emexVehicles)
|
.from(emexVehicles)
|
||||||
.innerJoin(emexVehicleVins, eq(emexVehicleVins.emexVehicleId, emexVehicles.id))
|
.innerJoin(emexVehicleVins, eq(emexVehicleVins.emexVehicleId, emexVehicles.id))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Job } from "bullmq";
|
import type { Job } from "bullmq";
|
||||||
import { lt } from "drizzle-orm";
|
import { lt } from "drizzle-orm";
|
||||||
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||||
import { queryLogs } from "../../database/schema/core";
|
import { queryLogs } from "../../database/schema/core";
|
||||||
|
|
||||||
type Database = PostgresJsDatabase<Record<string, unknown>>;
|
type Database = PostgresJsDatabase<Record<string, unknown>>;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Job } from "bullmq";
|
import type { Job } from "bullmq";
|
||||||
import { and, eq, lt } from "drizzle-orm";
|
import { and, eq, lt } from "drizzle-orm";
|
||||||
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||||
import { userSubscriptions, userBrands } from "../../database/schema/core";
|
import { userBrands, userSubscriptions } from "../../database/schema/core";
|
||||||
|
|
||||||
type Database = PostgresJsDatabase<Record<string, unknown>>;
|
type Database = PostgresJsDatabase<Record<string, unknown>>;
|
||||||
|
|
||||||
@@ -17,12 +17,7 @@ export async function processSubscriptionExpiry(
|
|||||||
const expiredSubs = await db
|
const expiredSubs = await db
|
||||||
.select({ id: userSubscriptions.id, userId: userSubscriptions.userId })
|
.select({ id: userSubscriptions.id, userId: userSubscriptions.userId })
|
||||||
.from(userSubscriptions)
|
.from(userSubscriptions)
|
||||||
.where(
|
.where(and(eq(userSubscriptions.status, "active"), lt(userSubscriptions.endDate, now)));
|
||||||
and(
|
|
||||||
eq(userSubscriptions.status, "active"),
|
|
||||||
lt(userSubscriptions.endDate, now),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (expiredSubs.length === 0) {
|
if (expiredSubs.length === 0) {
|
||||||
console.log("[subscription-expiry] No expired subscriptions found");
|
console.log("[subscription-expiry] No expired subscriptions found");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Provider } from "@nestjs/common";
|
import type { Provider } from "@nestjs/common";
|
||||||
import { Queue } from "bullmq";
|
import { Queue } from "bullmq";
|
||||||
import { getBullConnection, getBullTelemetry, QUEUE_NAMES } from "../bull.config";
|
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
|
||||||
|
|
||||||
export const CATALOG_PREFETCH_QUEUE = "CATALOG_PREFETCH_QUEUE";
|
export const CATALOG_PREFETCH_QUEUE = "CATALOG_PREFETCH_QUEUE";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Provider } from "@nestjs/common";
|
import type { Provider } from "@nestjs/common";
|
||||||
import { Queue } from "bullmq";
|
import { Queue } from "bullmq";
|
||||||
import { getBullConnection, getBullTelemetry, QUEUE_NAMES } from "../bull.config";
|
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
|
||||||
|
|
||||||
export const EMEX_SCRAPE_QUEUE = "EMEX_SCRAPE_QUEUE";
|
export const EMEX_SCRAPE_QUEUE = "EMEX_SCRAPE_QUEUE";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Provider } from "@nestjs/common";
|
import type { Provider } from "@nestjs/common";
|
||||||
import { Queue } from "bullmq";
|
import { Queue } from "bullmq";
|
||||||
import { getBullConnection, getBullTelemetry, QUEUE_NAMES } from "../bull.config";
|
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
|
||||||
|
|
||||||
export const QUERY_CLEANUP_QUEUE = "QUERY_CLEANUP_QUEUE";
|
export const QUERY_CLEANUP_QUEUE = "QUERY_CLEANUP_QUEUE";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Provider } from "@nestjs/common";
|
import type { Provider } from "@nestjs/common";
|
||||||
import { Queue } from "bullmq";
|
import { Queue } from "bullmq";
|
||||||
import { getBullConnection, getBullTelemetry, QUEUE_NAMES } from "../bull.config";
|
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
|
||||||
|
|
||||||
export const SUBSCRIPTION_EXPIRY_QUEUE = "SUBSCRIPTION_EXPIRY_QUEUE";
|
export const SUBSCRIPTION_EXPIRY_QUEUE = "SUBSCRIPTION_EXPIRY_QUEUE";
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import "./telemetry/tracing"; // MUST be first — instruments modules before they load
|
import "./telemetry/tracing"; // MUST be first — instruments modules before they load
|
||||||
|
|
||||||
import { NestFactory } from "@nestjs/core";
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { NestFactory } from "@nestjs/core";
|
||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
import type { Request, Response, NextFunction } from "express";
|
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
import { fileUploadValidation } from "./common/middleware/file-upload-validation.middleware";
|
import { fileUploadValidation } from "./common/middleware/file-upload-validation.middleware";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Controller, Get, Param, Query } from "@nestjs/common";
|
import { Controller, Get, Param, Query } from "@nestjs/common";
|
||||||
import { PartsService } from "./parts.service";
|
import type { PartsService } from "./parts.service";
|
||||||
|
|
||||||
@Controller("parts")
|
@Controller("parts")
|
||||||
export class PartsController {
|
export class PartsController {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PL24Module } from "../integrations/pl24/pl24.module";
|
||||||
import { PartsController } from "./parts.controller";
|
import { PartsController } from "./parts.controller";
|
||||||
import { PartsService } from "./parts.service";
|
import { PartsService } from "./parts.service";
|
||||||
import { PL24Module } from "../integrations/pl24/pl24.module";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PL24Module],
|
imports: [PL24Module],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { NotFoundException } from "@nestjs/common";
|
import { NotFoundException } from "@nestjs/common";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { PartsService } from "./parts.service";
|
import { PartsService } from "./parts.service";
|
||||||
|
|
||||||
function createService(db: any) {
|
function createService(db: any) {
|
||||||
@@ -46,7 +46,16 @@ describe("PartsService", () => {
|
|||||||
it("should fetch from PL24 when DB is empty", async () => {
|
it("should fetch from PL24 when DB is empty", async () => {
|
||||||
const category = { id: "cat-1", vehicleId: "v1", externalId: "g1" };
|
const category = { id: "cat-1", vehicleId: "v1", externalId: "g1" };
|
||||||
const vehicle = { id: "v1", rawData: { vehicleId: "pl24-v1" }, brandName: "BMW" };
|
const vehicle = { id: "v1", rawData: { vehicleId: "pl24-v1" }, brandName: "BMW" };
|
||||||
const pl24Parts = [{ name: "Oil Filter", oemCodes: ["OEM-1"], description: "Filter", quantity: 1, position: null, hotspotIndex: null }];
|
const pl24Parts = [
|
||||||
|
{
|
||||||
|
name: "Oil Filter",
|
||||||
|
oemCodes: ["OEM-1"],
|
||||||
|
description: "Filter",
|
||||||
|
quantity: 1,
|
||||||
|
position: null,
|
||||||
|
hotspotIndex: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
const insertedParts = [{ id: "p1", name: "Oil Filter", oemCode: "OEM-1" }];
|
const insertedParts = [{ id: "p1", name: "Oil Filter", oemCode: "OEM-1" }];
|
||||||
|
|
||||||
let selectCall = 0;
|
let selectCall = 0;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
import { eq, like } from "drizzle-orm";
|
import { eq, like } from "drizzle-orm";
|
||||||
import { DATABASE, Database } from "../database/database.provider";
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
import { parts, categories, vehicles, schemaPics } from "../database/schema/core";
|
import { categories, parts, schemaPics, vehicles } from "../database/schema/core";
|
||||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
import type { PL24Service } from "../integrations/pl24/pl24.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PartsService {
|
export class PartsService {
|
||||||
@@ -65,10 +65,10 @@ export class PartsService {
|
|||||||
name: p.name,
|
name: p.name,
|
||||||
nameOriginal: p.name,
|
nameOriginal: p.name,
|
||||||
description: p.description || null,
|
description: p.description || null,
|
||||||
quantity: p.quantity ? (parseInt(String(p.quantity), 10) || null) : null,
|
quantity: p.quantity ? (Number.parseInt(String(p.quantity), 10) || null) : null,
|
||||||
position: p.positionCode || null,
|
position: p.positionCode || null,
|
||||||
hotspotIndex: p.hotspotId ? (() => {
|
hotspotIndex: p.hotspotId ? (() => {
|
||||||
const val = parseInt(p.hotspotId!, 10);
|
const val = Number.parseInt(p.hotspotId!, 10);
|
||||||
return (val > 0 && val <= 2147483647) ? val : null;
|
return (val > 0 && val <= 2147483647) ? val : null;
|
||||||
})() : null,
|
})() : null,
|
||||||
unavailable: p.unavailable || false,
|
unavailable: p.unavailable || false,
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Post,
|
|
||||||
Patch,
|
|
||||||
Param,
|
Param,
|
||||||
Body,
|
Patch,
|
||||||
|
Post,
|
||||||
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
UploadedFile,
|
|
||||||
BadRequestException,
|
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { FileInterceptor } from "@nestjs/platform-express";
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
import { PaymentsService } from "./payments.service";
|
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
import { Roles } from "../common/decorators/roles.decorator";
|
import { Roles } from "../common/decorators/roles.decorator";
|
||||||
import { RolesGuard } from "../common/guards/roles.guard";
|
import { RolesGuard } from "../common/guards/roles.guard";
|
||||||
|
import type { PaymentsService } from "./payments.service";
|
||||||
|
|
||||||
@Controller("payments")
|
@Controller("payments")
|
||||||
export class PaymentsController {
|
export class PaymentsController {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
||||||
import { PaymentsController } from "./payments.controller";
|
import { PaymentsController } from "./payments.controller";
|
||||||
import { PaymentsService } from "./payments.service";
|
import { PaymentsService } from "./payments.service";
|
||||||
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [SubscriptionsModule],
|
imports: [SubscriptionsModule],
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { PaymentsService } from "./payments.service";
|
import { PaymentsService } from "./payments.service";
|
||||||
|
|
||||||
function createMockDb(overrides: Record<string, unknown> = {}) {
|
function createMockDb(overrides: Record<string, unknown> = {}) {
|
||||||
function chainable(terminalValue: unknown) {
|
function chainable(terminalValue: unknown) {
|
||||||
const chain: Record<string, unknown> = {};
|
const chain: Record<string, unknown> = {};
|
||||||
const methods = [
|
const methods = [
|
||||||
"select", "from", "where", "orderBy", "limit", "offset",
|
"select",
|
||||||
"innerJoin", "leftJoin", "insert", "values", "update", "set",
|
"from",
|
||||||
"delete", "returning", "onConflictDoNothing", "groupBy",
|
"where",
|
||||||
|
"orderBy",
|
||||||
|
"limit",
|
||||||
|
"offset",
|
||||||
|
"innerJoin",
|
||||||
|
"leftJoin",
|
||||||
|
"insert",
|
||||||
|
"values",
|
||||||
|
"update",
|
||||||
|
"set",
|
||||||
|
"delete",
|
||||||
|
"returning",
|
||||||
|
"onConflictDoNothing",
|
||||||
|
"groupBy",
|
||||||
];
|
];
|
||||||
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
|
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
|
||||||
chain.limit = vi.fn().mockReturnValue(terminalValue);
|
chain.limit = vi.fn().mockReturnValue(terminalValue);
|
||||||
@@ -26,7 +39,10 @@ function createMockDb(overrides: Record<string, unknown> = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createService(dbOverrides: Record<string, unknown> = {}, subServiceOverrides: Record<string, any> = {}) {
|
function createService(
|
||||||
|
dbOverrides: Record<string, unknown> = {},
|
||||||
|
subServiceOverrides: Record<string, any> = {},
|
||||||
|
) {
|
||||||
const db = typeof dbOverrides.select === "function" ? dbOverrides : createMockDb(dbOverrides);
|
const db = typeof dbOverrides.select === "function" ? dbOverrides : createMockDb(dbOverrides);
|
||||||
const configService = { get: vi.fn().mockReturnValue("test-value") };
|
const configService = { get: vi.fn().mockReturnValue("test-value") };
|
||||||
const subscriptionsService = {
|
const subscriptionsService = {
|
||||||
@@ -60,7 +76,17 @@ describe("PaymentsService", () => {
|
|||||||
select: vi.fn().mockReturnValue({
|
select: vi.fn().mockReturnValue({
|
||||||
from: vi.fn().mockReturnThis(),
|
from: vi.fn().mockReturnThis(),
|
||||||
where: vi.fn().mockReturnThis(),
|
where: vi.fn().mockReturnThis(),
|
||||||
limit: vi.fn().mockReturnValue([{ id: "plan-1", brandCount: 1, priceMonthly: 20000, priceYearly: 200000, isActive: true }]),
|
limit: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue([
|
||||||
|
{
|
||||||
|
id: "plan-1",
|
||||||
|
brandCount: 1,
|
||||||
|
priceMonthly: 20000,
|
||||||
|
priceYearly: 200000,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
]),
|
||||||
}),
|
}),
|
||||||
insert: vi.fn().mockReturnValue({
|
insert: vi.fn().mockReturnValue({
|
||||||
values: vi.fn().mockReturnThis(),
|
values: vi.fn().mockReturnThis(),
|
||||||
@@ -78,7 +104,9 @@ describe("PaymentsService", () => {
|
|||||||
it("should throw BadRequestException for invalid plan key", async () => {
|
it("should throw BadRequestException for invalid plan key", async () => {
|
||||||
const { service } = createService();
|
const { service } = createService();
|
||||||
|
|
||||||
await expect(service.initializeIyzico("u1", "invalid", "monthly", [])).rejects.toThrow(BadRequestException);
|
await expect(service.initializeIyzico("u1", "invalid", "monthly", [])).rejects.toThrow(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,7 +116,9 @@ describe("PaymentsService", () => {
|
|||||||
select: vi.fn().mockReturnValue({
|
select: vi.fn().mockReturnValue({
|
||||||
from: vi.fn().mockReturnThis(),
|
from: vi.fn().mockReturnThis(),
|
||||||
where: vi.fn().mockReturnThis(),
|
where: vi.fn().mockReturnThis(),
|
||||||
limit: vi.fn().mockReturnValue([{ id: "pay-1", subscriptionId: "sub-1", status: "pending" }]),
|
limit: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue([{ id: "pay-1", subscriptionId: "sub-1", status: "pending" }]),
|
||||||
}),
|
}),
|
||||||
update: vi.fn().mockReturnValue({
|
update: vi.fn().mockReturnValue({
|
||||||
set: vi.fn().mockReturnThis(),
|
set: vi.fn().mockReturnThis(),
|
||||||
@@ -107,7 +137,9 @@ describe("PaymentsService", () => {
|
|||||||
select: vi.fn().mockReturnValue({
|
select: vi.fn().mockReturnValue({
|
||||||
from: vi.fn().mockReturnThis(),
|
from: vi.fn().mockReturnThis(),
|
||||||
where: vi.fn().mockReturnThis(),
|
where: vi.fn().mockReturnThis(),
|
||||||
limit: vi.fn().mockReturnValue([{ id: "pay-1", subscriptionId: "sub-1", status: "pending" }]),
|
limit: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue([{ id: "pay-1", subscriptionId: "sub-1", status: "pending" }]),
|
||||||
}),
|
}),
|
||||||
update: vi.fn().mockReturnValue({
|
update: vi.fn().mockReturnValue({
|
||||||
set: vi.fn().mockReturnThis(),
|
set: vi.fn().mockReturnThis(),
|
||||||
@@ -131,7 +163,9 @@ describe("PaymentsService", () => {
|
|||||||
};
|
};
|
||||||
const { service } = createService(db);
|
const { service } = createService(db);
|
||||||
|
|
||||||
await expect(service.handleIyzicoCallback("nonexistent", "iyz-1", "success")).rejects.toThrow(NotFoundException);
|
await expect(service.handleIyzicoCallback("nonexistent", "iyz-1", "success")).rejects.toThrow(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,11 +175,23 @@ describe("PaymentsService", () => {
|
|||||||
select: vi.fn().mockReturnValue({
|
select: vi.fn().mockReturnValue({
|
||||||
from: vi.fn().mockReturnThis(),
|
from: vi.fn().mockReturnThis(),
|
||||||
where: vi.fn().mockReturnThis(),
|
where: vi.fn().mockReturnThis(),
|
||||||
limit: vi.fn().mockReturnValue([{ id: "plan-1", brandCount: 0, priceMonthly: 99900, priceYearly: 999000, isActive: true }]),
|
limit: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue([
|
||||||
|
{
|
||||||
|
id: "plan-1",
|
||||||
|
brandCount: 0,
|
||||||
|
priceMonthly: 99900,
|
||||||
|
priceYearly: 999000,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
]),
|
||||||
}),
|
}),
|
||||||
insert: vi.fn().mockReturnValue({
|
insert: vi.fn().mockReturnValue({
|
||||||
values: vi.fn().mockReturnThis(),
|
values: vi.fn().mockReturnThis(),
|
||||||
returning: vi.fn().mockReturnValue([{ id: "pay-eft-1", method: "eft", status: "pending" }]),
|
returning: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue([{ id: "pay-eft-1", method: "eft", status: "pending" }]),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const { service, subscriptionsService } = createService(db);
|
const { service, subscriptionsService } = createService(db);
|
||||||
@@ -160,7 +206,9 @@ describe("PaymentsService", () => {
|
|||||||
it("should throw BadRequestException for invalid plan key", async () => {
|
it("should throw BadRequestException for invalid plan key", async () => {
|
||||||
const { service } = createService();
|
const { service } = createService();
|
||||||
|
|
||||||
await expect(service.createEftPayment("u1", "nonexistent", "monthly", [])).rejects.toThrow(BadRequestException);
|
await expect(service.createEftPayment("u1", "nonexistent", "monthly", [])).rejects.toThrow(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,7 +227,12 @@ describe("PaymentsService", () => {
|
|||||||
};
|
};
|
||||||
const { service } = createService(db);
|
const { service } = createService(db);
|
||||||
|
|
||||||
const result = await service.uploadEftReceipt("pay-1", "u1", Buffer.from("pdf"), "receipt.pdf");
|
const result = await service.uploadEftReceipt(
|
||||||
|
"pay-1",
|
||||||
|
"u1",
|
||||||
|
Buffer.from("pdf"),
|
||||||
|
"receipt.pdf",
|
||||||
|
);
|
||||||
expect(result.receiptUrl).toBe("https://storage.test/receipt.pdf");
|
expect(result.receiptUrl).toBe("https://storage.test/receipt.pdf");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,7 +246,9 @@ describe("PaymentsService", () => {
|
|||||||
};
|
};
|
||||||
const { service } = createService(db);
|
const { service } = createService(db);
|
||||||
|
|
||||||
await expect(service.uploadEftReceipt("pay-x", "u1", Buffer.from("pdf"), "r.pdf")).rejects.toThrow(NotFoundException);
|
await expect(
|
||||||
|
service.uploadEftReceipt("pay-x", "u1", Buffer.from("pdf"), "r.pdf"),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw BadRequestException when not EFT method", async () => {
|
it("should throw BadRequestException when not EFT method", async () => {
|
||||||
@@ -206,7 +261,9 @@ describe("PaymentsService", () => {
|
|||||||
};
|
};
|
||||||
const { service } = createService(db);
|
const { service } = createService(db);
|
||||||
|
|
||||||
await expect(service.uploadEftReceipt("pay-1", "u1", Buffer.from("pdf"), "r.pdf")).rejects.toThrow(BadRequestException);
|
await expect(
|
||||||
|
service.uploadEftReceipt("pay-1", "u1", Buffer.from("pdf"), "r.pdf"),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import type { ConfigService } from "@nestjs/config";
|
||||||
import { eq, and, desc } from "drizzle-orm";
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
import { DATABASE, Database } from "../database/database.provider";
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
import { payments, userSubscriptions, plans } from "../database/schema/core";
|
import { payments, plans, userSubscriptions } from "../database/schema/core";
|
||||||
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
|
import type { StorageService } from "../storage/storage.service";
|
||||||
import { StorageService } from "../storage/storage.service";
|
import type { SubscriptionsService } from "../subscriptions/subscriptions.service";
|
||||||
|
|
||||||
const PLAN_KEY_TO_BRAND_COUNT: Record<string, number> = {
|
const PLAN_KEY_TO_BRAND_COUNT: Record<string, number> = {
|
||||||
brand1: 1,
|
brand1: 1,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Controller, Get, Post, Patch, Param, Body, UseGuards } from "@nestjs/common";
|
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||||
import { PlansService } from "./plans.service";
|
|
||||||
import { Public } from "../common/decorators/public.decorator";
|
import { Public } from "../common/decorators/public.decorator";
|
||||||
import { Roles } from "../common/decorators/roles.decorator";
|
import { Roles } from "../common/decorators/roles.decorator";
|
||||||
import { RolesGuard } from "../common/guards/roles.guard";
|
import { RolesGuard } from "../common/guards/roles.guard";
|
||||||
|
import type { PlansService } from "./plans.service";
|
||||||
|
|
||||||
@Controller("plans")
|
@Controller("plans")
|
||||||
export class PlansController {
|
export class PlansController {
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { NotFoundException } from "@nestjs/common";
|
import { NotFoundException } from "@nestjs/common";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { PlansService } from "./plans.service";
|
import { PlansService } from "./plans.service";
|
||||||
|
|
||||||
function createMockDb(overrides: Record<string, unknown> = {}) {
|
function createMockDb(overrides: Record<string, unknown> = {}) {
|
||||||
function chainable(terminalValue: unknown) {
|
function chainable(terminalValue: unknown) {
|
||||||
const chain: Record<string, unknown> = {};
|
const chain: Record<string, unknown> = {};
|
||||||
const methods = [
|
const methods = [
|
||||||
"select", "from", "where", "orderBy", "limit", "offset",
|
"select",
|
||||||
"insert", "values", "update", "set", "returning",
|
"from",
|
||||||
|
"where",
|
||||||
|
"orderBy",
|
||||||
|
"limit",
|
||||||
|
"offset",
|
||||||
|
"insert",
|
||||||
|
"values",
|
||||||
|
"update",
|
||||||
|
"set",
|
||||||
|
"returning",
|
||||||
];
|
];
|
||||||
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
|
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
|
||||||
chain.limit = vi.fn().mockReturnValue(terminalValue);
|
chain.limit = vi.fn().mockReturnValue(terminalValue);
|
||||||
@@ -73,11 +82,22 @@ describe("PlansService", () => {
|
|||||||
|
|
||||||
describe("create", () => {
|
describe("create", () => {
|
||||||
it("should create and return plan", async () => {
|
it("should create and return plan", async () => {
|
||||||
const newPlan = { id: "p1", name: "1 Marka", brandCount: 1, priceMonthly: 20000, priceYearly: 200000 };
|
const newPlan = {
|
||||||
|
id: "p1",
|
||||||
|
name: "1 Marka",
|
||||||
|
brandCount: 1,
|
||||||
|
priceMonthly: 20000,
|
||||||
|
priceYearly: 200000,
|
||||||
|
};
|
||||||
const db = createMockDb({ _insertRows: [newPlan] });
|
const db = createMockDb({ _insertRows: [newPlan] });
|
||||||
const service = new PlansService(db as any);
|
const service = new PlansService(db as any);
|
||||||
|
|
||||||
const result = await service.create({ name: "1 Marka", brandCount: 1, priceMonthly: 20000, priceYearly: 200000 });
|
const result = await service.create({
|
||||||
|
name: "1 Marka",
|
||||||
|
brandCount: 1,
|
||||||
|
priceMonthly: 20000,
|
||||||
|
priceYearly: 200000,
|
||||||
|
});
|
||||||
expect(result).toEqual(newPlan);
|
expect(result).toEqual(newPlan);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
|
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { DATABASE, Database } from "../database/database.provider";
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
import { plans } from "../database/schema/core";
|
import { plans } from "../database/schema/core";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Provider } from "@nestjs/common";
|
import type { Provider } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import Redis from "ioredis";
|
import Redis from "ioredis";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Inject, Injectable, OnModuleDestroy } from "@nestjs/common";
|
import { Inject, Injectable, type OnModuleDestroy } from "@nestjs/common";
|
||||||
import Redis from "ioredis";
|
import type Redis from "ioredis";
|
||||||
import { REDIS_CLIENT } from "./redis.provider";
|
import { REDIS_CLIENT } from "./redis.provider";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Controller, Get, Post, Body } from "@nestjs/common";
|
import { Body, Controller, Get, Post } from "@nestjs/common";
|
||||||
import { ReferralsService } from "./referrals.service";
|
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
|
import type { ReferralsService } from "./referrals.service";
|
||||||
|
|
||||||
@Controller("referrals")
|
@Controller("referrals")
|
||||||
export class ReferralsController {
|
export class ReferralsController {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
||||||
import { ReferralsController } from "./referrals.controller";
|
import { ReferralsController } from "./referrals.controller";
|
||||||
import { ReferralsService } from "./referrals.service";
|
import { ReferralsService } from "./referrals.service";
|
||||||
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [SubscriptionsModule],
|
imports: [SubscriptionsModule],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { ReferralsService } from "./referrals.service";
|
import { ReferralsService } from "./referrals.service";
|
||||||
|
|
||||||
vi.mock("@sase/shared", () => ({
|
vi.mock("@sase/shared", () => ({
|
||||||
@@ -166,7 +166,9 @@ describe("ReferralsService", () => {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const { service } = createService(db);
|
const { service } = createService(db);
|
||||||
await expect(service.applyReferralCode("u1", "SELF-CODE")).rejects.toThrow(BadRequestException);
|
await expect(service.applyReferralCode("u1", "SELF-CODE")).rejects.toThrow(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw BadRequestException when already referred", async () => {
|
it("should throw BadRequestException when already referred", async () => {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { BadRequestException, Inject, Injectable, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, Inject, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { eq, and, sql } from "drizzle-orm";
|
|
||||||
import { DATABASE, Database } from "../database/database.provider";
|
|
||||||
import { users, referrals } from "../database/schema/core";
|
|
||||||
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
|
|
||||||
import { REFERRAL_REWARDS } from "@sase/shared";
|
import { REFERRAL_REWARDS } from "@sase/shared";
|
||||||
import { generateReferralCode } from "@sase/shared";
|
import { generateReferralCode } from "@sase/shared";
|
||||||
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
|
import { referrals, users } from "../database/schema/core";
|
||||||
|
import type { SubscriptionsService } from "../subscriptions/subscriptions.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ReferralsService {
|
export class ReferralsService {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import {
|
import {
|
||||||
S3Client,
|
|
||||||
PutObjectCommand,
|
|
||||||
GetObjectCommand,
|
|
||||||
DeleteObjectCommand,
|
DeleteObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
S3Client,
|
||||||
} from "@aws-sdk/client-s3";
|
} from "@aws-sdk/client-s3";
|
||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import type { ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StorageService {
|
export class StorageService {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Controller, Get, Post, Patch, Body, Query, UseGuards } from "@nestjs/common";
|
import { Body, Controller, Get, Patch, Post, Query, UseGuards } from "@nestjs/common";
|
||||||
import { SubscriptionsService } from "./subscriptions.service";
|
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
import { Roles } from "../common/decorators/roles.decorator";
|
import { Roles } from "../common/decorators/roles.decorator";
|
||||||
import { RolesGuard } from "../common/guards/roles.guard";
|
import { RolesGuard } from "../common/guards/roles.guard";
|
||||||
|
import type { SubscriptionsService } from "./subscriptions.service";
|
||||||
|
|
||||||
@Controller("subscriptions")
|
@Controller("subscriptions")
|
||||||
export class SubscriptionsController {
|
export class SubscriptionsController {
|
||||||
@@ -54,8 +54,8 @@ export class SubscriptionsController {
|
|||||||
@Roles("admin")
|
@Roles("admin")
|
||||||
async findAll(@Query("page") page?: string, @Query("limit") limit?: string) {
|
async findAll(@Query("page") page?: string, @Query("limit") limit?: string) {
|
||||||
return this.subscriptionsService.findAll(
|
return this.subscriptionsService.findAll(
|
||||||
page ? parseInt(page, 10) : 1,
|
page ? Number.parseInt(page, 10) : 1,
|
||||||
limit ? parseInt(limit, 10) : 20,
|
limit ? Number.parseInt(limit, 10) : 20,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { SubscriptionsController } from "./subscriptions.controller";
|
|
||||||
import { SubscriptionsService } from "./subscriptions.service";
|
|
||||||
import { BrandsModule } from "../brands/brands.module";
|
import { BrandsModule } from "../brands/brands.module";
|
||||||
import { PlansModule } from "../plans/plans.module";
|
import { PlansModule } from "../plans/plans.module";
|
||||||
|
import { SubscriptionsController } from "./subscriptions.controller";
|
||||||
|
import { SubscriptionsService } from "./subscriptions.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [BrandsModule, PlansModule],
|
imports: [BrandsModule, PlansModule],
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
|
||||||
import {
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
BadRequestException,
|
|
||||||
ConflictException,
|
|
||||||
NotFoundException,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { SubscriptionsService } from "./subscriptions.service";
|
import { SubscriptionsService } from "./subscriptions.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,9 +137,7 @@ describe("SubscriptionsService", () => {
|
|||||||
let callCount = 0;
|
let callCount = 0;
|
||||||
const insertChain = {
|
const insertChain = {
|
||||||
values: vi.fn().mockReturnThis(),
|
values: vi.fn().mockReturnThis(),
|
||||||
returning: vi.fn().mockReturnValue([
|
returning: vi.fn().mockReturnValue([{ id: "sub-1", userId: "user-1", status: "pending" }]),
|
||||||
{ id: "sub-1", userId: "user-1", status: "pending" },
|
|
||||||
]),
|
|
||||||
};
|
};
|
||||||
const db = {
|
const db = {
|
||||||
select: vi.fn().mockImplementation(() => {
|
select: vi.fn().mockImplementation(() => {
|
||||||
@@ -188,9 +182,7 @@ describe("SubscriptionsService", () => {
|
|||||||
};
|
};
|
||||||
const service = createService(db);
|
const service = createService(db);
|
||||||
|
|
||||||
await expect(service.cancel("user-1")).rejects.toThrow(
|
await expect(service.cancel("user-1")).rejects.toThrow(NotFoundException);
|
||||||
NotFoundException,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should set status to cancelled and set cancelledAt", async () => {
|
it("should set status to cancelled and set cancelledAt", async () => {
|
||||||
@@ -240,9 +232,7 @@ describe("SubscriptionsService", () => {
|
|||||||
};
|
};
|
||||||
const service = createService(db);
|
const service = createService(db);
|
||||||
|
|
||||||
await expect(service.resume("user-1")).rejects.toThrow(
|
await expect(service.resume("user-1")).rejects.toThrow(NotFoundException);
|
||||||
NotFoundException,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should set status to active and clear cancelledAt", async () => {
|
it("should set status to active and clear cancelledAt", async () => {
|
||||||
@@ -262,9 +252,7 @@ describe("SubscriptionsService", () => {
|
|||||||
select: vi.fn().mockReturnValue({
|
select: vi.fn().mockReturnValue({
|
||||||
from: vi.fn().mockReturnThis(),
|
from: vi.fn().mockReturnThis(),
|
||||||
where: vi.fn().mockReturnThis(),
|
where: vi.fn().mockReturnThis(),
|
||||||
limit: vi.fn().mockReturnValue([
|
limit: vi.fn().mockReturnValue([{ id: "sub-1", userId: "user-1", status: "cancelled" }]),
|
||||||
{ id: "sub-1", userId: "user-1", status: "cancelled" },
|
|
||||||
]),
|
|
||||||
}),
|
}),
|
||||||
update: vi.fn().mockReturnValue(updateChain),
|
update: vi.fn().mockReturnValue(updateChain),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { eq, and, desc, or, inArray } from "drizzle-orm";
|
import { and, desc, eq, inArray, or } from "drizzle-orm";
|
||||||
import { DATABASE, Database } from "../database/database.provider";
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
import { userSubscriptions, userBrands, plans, brands } from "../database/schema/core";
|
import { brands, plans, userBrands, userSubscriptions } from "../database/schema/core";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SubscriptionsService {
|
export class SubscriptionsService {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
describe("Telemetry module", () => {
|
describe("Telemetry module", () => {
|
||||||
const originalEnv = process.env;
|
const originalEnv = process.env;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { trace, metrics, SpanStatusCode, type Span } from "@opentelemetry/api";
|
import { type Span, SpanStatusCode, metrics, trace } from "@opentelemetry/api";
|
||||||
|
|
||||||
export const isOtelEnabled = process.env.OTEL_ENABLED === "true";
|
export const isOtelEnabled = process.env.OTEL_ENABLED === "true";
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { NodeSDK } from "@opentelemetry/sdk-node";
|
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
|
||||||
|
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
||||||
|
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
||||||
|
import type { Instrumentation } from "@opentelemetry/instrumentation";
|
||||||
import { resourceFromAttributes } from "@opentelemetry/resources";
|
import { resourceFromAttributes } from "@opentelemetry/resources";
|
||||||
|
import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
|
||||||
|
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
||||||
|
import { NodeSDK } from "@opentelemetry/sdk-node";
|
||||||
|
import { BatchSpanProcessor, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
|
||||||
import {
|
import {
|
||||||
ATTR_SERVICE_NAME,
|
ATTR_SERVICE_NAME,
|
||||||
SEMRESATTRS_DEPLOYMENT_ENVIRONMENT,
|
SEMRESATTRS_DEPLOYMENT_ENVIRONMENT,
|
||||||
} from "@opentelemetry/semantic-conventions";
|
} from "@opentelemetry/semantic-conventions";
|
||||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
||||||
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
|
||||||
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
|
|
||||||
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
|
||||||
import { BatchSpanProcessor, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
|
|
||||||
import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
|
|
||||||
import type { Instrumentation } from "@opentelemetry/instrumentation";
|
|
||||||
|
|
||||||
export interface SDKConfig {
|
export interface SDKConfig {
|
||||||
serviceName: string;
|
serviceName: string;
|
||||||
@@ -37,7 +37,7 @@ export function createNodeSDK(config: SDKConfig): NodeSDK | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headers = parseHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS || "");
|
const headers = parseHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS || "");
|
||||||
const sampleRate = parseFloat(process.env.OTEL_TRACE_SAMPLE_RATE || "1.0");
|
const sampleRate = Number.parseFloat(process.env.OTEL_TRACE_SAMPLE_RATE || "1.0");
|
||||||
|
|
||||||
const resource = resourceFromAttributes({
|
const resource = resourceFromAttributes({
|
||||||
[ATTR_SERVICE_NAME]: config.serviceName,
|
[ATTR_SERVICE_NAME]: config.serviceName,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user