Initial commit: Sase.tr VIN Sorgulama Platformu

Features:
- Next.js 16.1.3 frontend with Turbopack
- NestJS API with Prisma ORM
- EMEX VIN scraper integration
- Turkish translations for automotive parts
- JWT authentication with refresh tokens
- PM2 production deployment

Tech Stack:
- Frontend: Next.js 16.1, React 19, TailwindCSS
- Backend: NestJS, Prisma, MySQL
- Scraping: Puppeteer

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
root
2026-01-17 03:04:30 +01:00
commit b9532a61ee
195 changed files with 31777 additions and 0 deletions

38
apps/api/.env.example Normal file
View File

@@ -0,0 +1,38 @@
# ===========================================
# API Environment Configuration
# ===========================================
# ----- App -----
NODE_ENV=production
PORT=4000
API_PREFIX=api
CORS_ORIGIN=https://sase.tr
# ----- Database -----
DATABASE_URL=mysql://sase_user:your_password@localhost:3306/sase_tr
# ----- Redis -----
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
# ----- JWT -----
JWT_SECRET=your-super-secret-jwt-key-min-32-chars-here
JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=your-super-secret-refresh-key-min-32-chars-here
JWT_REFRESH_EXPIRES_IN=7d
# ----- External VIN API -----
VIN_API_URL=https://api.vinprovider.com/v1
VIN_API_KEY=your-vin-api-key
VIN_API_TIMEOUT=30000
# ----- iyzico Payment -----
IYZICO_API_KEY=your-iyzico-api-key
IYZICO_SECRET_KEY=your-iyzico-secret-key
IYZICO_BASE_URL=https://api.iyzipay.com
IYZICO_CALLBACK_URL=https://sase.tr/api/payments/callback
# ----- Rate Limiting -----
THROTTLE_TTL=60
THROTTLE_LIMIT=100

8
apps/api/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

82
apps/api/package.json Normal file
View File

@@ -0,0 +1,82 @@
{
"name": "api",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate deploy",
"db:migrate:dev": "prisma migrate dev",
"db:seed": "ts-node prisma/seed.ts",
"db:studio": "prisma studio",
"clean": "rm -rf dist"
},
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/throttler": "^6.3.0",
"@prisma/client": "^6.1.0",
"@sase/shared": "workspace:*",
"axios": "^1.7.9",
"bcrypt": "^5.1.1",
"bullmq": "^5.30.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"ioredis": "^5.4.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"puppeteer": "^24.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"uuid": "^11.0.3"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.15",
"@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.0",
"@types/passport-jwt": "^4.0.1",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.18.1",
"@typescript-eslint/parser": "^8.18.1",
"eslint": "^9.17.0",
"jest": "^29.7.0",
"prisma": "^6.1.0",
"source-map-support": "^0.5.21",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.2"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s"],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@@ -0,0 +1,295 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
// ==================== USERS ====================
model User {
id String @id @default(cuid())
email String @unique
name String?
avatar String?
role Role @default(USER)
provider AuthProvider @default(EMAIL)
providerId String?
passwordHash String?
isActive Boolean @default(true)
subscription UserSubscription?
selectedBrands UserBrand[]
vehicles Vehicle[] @relation("QueriedBy")
queryLogs QueryLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email])
@@index([role])
@@map("users")
}
enum Role {
USER
MODERATOR
ADMIN
SUPER_ADMIN
}
enum AuthProvider {
EMAIL
GOOGLE
APPLE
FACEBOOK
}
// ==================== BRANDS ====================
model Brand {
id String @id @default(cuid())
code String @unique
name String
logo String?
isActive Boolean @default(true)
sortOrder Int @default(0)
vehicles Vehicle[]
userBrands UserBrand[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("brands")
}
// ==================== SUBSCRIPTIONS ====================
model Plan {
id String @id @default(cuid())
name String
slug String @unique
description String? @db.Text
price Decimal @db.Decimal(10, 2)
currency String @default("TRY")
brandLimit Int
hasFullAccess Boolean @default(false)
features Json
durationDays Int @default(30)
sortOrder Int @default(0)
isActive Boolean @default(true)
isPopular Boolean @default(false)
subscriptions UserSubscription[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("plans")
}
model UserSubscription {
id String @id @default(cuid())
userId String @unique
planId String
status SubscriptionStatus @default(ACTIVE)
currentPeriodStart DateTime @default(now())
currentPeriodEnd DateTime
cancelAtPeriodEnd Boolean @default(false)
cancelledAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
plan Plan @relation(fields: [planId], references: [id])
payments Payment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([currentPeriodEnd])
@@map("user_subscriptions")
}
model UserBrand {
id String @id @default(cuid())
userId String
brandId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([userId, brandId])
@@index([userId])
@@map("user_brands")
}
enum SubscriptionStatus {
ACTIVE
PAST_DUE
CANCELLED
EXPIRED
SUSPENDED
PENDING
}
// ==================== VEHICLES ====================
model Vehicle {
id String @id @default(cuid())
vin String @unique @db.VarChar(17)
brandId String
model String
year Int
series String?
bodyType String?
engineCode String?
engineType String?
engineVolume String?
transmission String?
driveType String?
colorCode String?
rawResponse Json
queriedById String?
queriedBy User? @relation("QueriedBy", fields: [queriedById], references: [id], onDelete: SetNull)
brand Brand @relation(fields: [brandId], references: [id])
categories VehicleCategory[]
parts Part[]
queryLogs QueryLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([vin])
@@index([brandId])
@@map("vehicles")
}
// ==================== CATEGORIES ====================
model Category {
id String @id @default(cuid())
code String @unique
nameEn String
nameTr String
slug String @unique
description String? @db.Text
parentId String?
iconName String?
schemaImageUrl String?
sortOrder Int @default(0)
isActive Boolean @default(true)
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull)
children Category[] @relation("CategoryTree")
vehicleCategories VehicleCategory[]
parts Part[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([parentId])
@@map("categories")
}
model VehicleCategory {
id String @id @default(cuid())
vehicleId String
categoryId String
partCount Int @default(0)
vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([vehicleId, categoryId])
@@index([vehicleId])
@@index([categoryId])
@@map("vehicle_categories")
}
// ==================== PARTS ====================
model Part {
id String @id @default(cuid())
vehicleId String
categoryId String
oemCode String
oemCodes Json?
nameEn String
nameTr String
description String? @db.Text
positionCode String?
positionX Float?
positionY Float?
brandPrices Json @default("[]")
imageUrl String?
notes String? @db.Text
vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([oemCode])
@@index([vehicleId])
@@index([categoryId])
@@index([vehicleId, categoryId])
@@map("parts")
}
// ==================== PAYMENTS ====================
model Payment {
id String @id @default(cuid())
subscriptionId String
amount Decimal @db.Decimal(10, 2)
currency String @default("TRY")
status PaymentStatus @default(PENDING)
provider String @default("iyzico")
providerTxId String? @unique
providerData Json?
invoiceNumber String?
invoiceUrl String?
failureReason String?
subscription UserSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([providerTxId])
@@map("payments")
}
enum PaymentStatus {
PENDING
PROCESSING
COMPLETED
FAILED
REFUNDED
CANCELLED
}
// ==================== QUERY LOGS ====================
model QueryLog {
id String @id @default(cuid())
userId String
vehicleId String?
vin String @db.VarChar(17)
responseTime Int?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
vehicle Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
@@index([userId])
@@index([vin])
@@index([createdAt])
@@index([userId, createdAt])
@@map("query_logs")
}

139
apps/api/prisma/seed.ts Normal file
View File

@@ -0,0 +1,139 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding database...');
// Seed Brands
const brands = [
{ code: 'FIAT', name: 'Fiat', sortOrder: 1 },
{ code: 'RENAULT', name: 'Renault', sortOrder: 2 },
{ code: 'VOLKSWAGEN', name: 'Volkswagen', sortOrder: 3 },
{ code: 'BMW', name: 'BMW', sortOrder: 4 },
{ code: 'MERCEDES', name: 'Mercedes-Benz', sortOrder: 5 },
{ code: 'AUDI', name: 'Audi', sortOrder: 6 },
{ code: 'TOYOTA', name: 'Toyota', sortOrder: 7 },
{ code: 'HONDA', name: 'Honda', sortOrder: 8 },
{ code: 'FORD', name: 'Ford', sortOrder: 9 },
{ code: 'OPEL', name: 'Opel', sortOrder: 10 },
{ code: 'HYUNDAI', name: 'Hyundai', sortOrder: 11 },
{ code: 'KIA', name: 'Kia', sortOrder: 12 },
{ code: 'PEUGEOT', name: 'Peugeot', sortOrder: 13 },
{ code: 'CITROEN', name: 'Citroen', sortOrder: 14 },
{ code: 'SKODA', name: 'Skoda', sortOrder: 15 },
{ code: 'SEAT', name: 'SEAT', sortOrder: 16 },
{ code: 'NISSAN', name: 'Nissan', sortOrder: 17 },
{ code: 'MAZDA', name: 'Mazda', sortOrder: 18 },
{ code: 'VOLVO', name: 'Volvo', sortOrder: 19 },
{ code: 'DACIA', name: 'Dacia', sortOrder: 20 },
];
for (const brand of brands) {
await prisma.brand.upsert({
where: { code: brand.code },
update: {},
create: brand,
});
}
console.log(`Seeded ${brands.length} brands`);
// Seed Plans
const plans = [
{
name: 'Baslangic',
slug: 'starter',
description: 'Tek marka icin ideal baslangic paketi',
price: 299,
currency: 'TRY',
brandLimit: 1,
hasFullAccess: false,
features: JSON.stringify(['1 marka erisimi', 'Sinirsiz sorgu', 'Email destek']),
durationDays: 30,
sortOrder: 1,
isPopular: false,
},
{
name: 'Pro',
slug: 'pro',
description: 'Kucuk isletmeler icin ideal paket',
price: 599,
currency: 'TRY',
brandLimit: 3,
hasFullAccess: false,
features: JSON.stringify(['3 marka erisimi', 'Sinirsiz sorgu', 'Oncelikli destek']),
durationDays: 30,
sortOrder: 2,
isPopular: true,
},
{
name: 'Isletme',
slug: 'business',
description: 'Orta olcekli isletmeler icin',
price: 999,
currency: 'TRY',
brandLimit: 10,
hasFullAccess: false,
features: JSON.stringify(['10 marka erisimi', 'Sinirsiz sorgu', 'Telefon destek', 'API erisimi']),
durationDays: 30,
sortOrder: 3,
isPopular: false,
},
{
name: 'Full',
slug: 'full',
description: 'Tum markalara sinirsiz erisim',
price: 1999,
currency: 'TRY',
brandLimit: 0,
hasFullAccess: true,
features: JSON.stringify(['Tum markalar', 'Sinirsiz sorgu', '7/24 destek', 'API erisimi', 'Ozel entegrasyon']),
durationDays: 30,
sortOrder: 4,
isPopular: false,
},
];
for (const plan of plans) {
await prisma.plan.upsert({
where: { slug: plan.slug },
update: {},
create: plan,
});
}
console.log(`Seeded ${plans.length} plans`);
// Seed Categories
const categories = [
{ code: 'ENGINE', nameEn: 'Engine', nameTr: 'Motor', slug: 'motor', iconName: 'engine', sortOrder: 1 },
{ code: 'BRAKE', nameEn: 'Brake System', nameTr: 'Fren Sistemi', slug: 'fren-sistemi', iconName: 'brake', sortOrder: 2 },
{ code: 'SUSPENSION', nameEn: 'Suspension', nameTr: 'Suspansiyon', slug: 'suspansiyon', iconName: 'suspension', sortOrder: 3 },
{ code: 'ELECTRICAL', nameEn: 'Electrical', nameTr: 'Elektrik', slug: 'elektrik', iconName: 'zap', sortOrder: 4 },
{ code: 'BODY', nameEn: 'Body', nameTr: 'Govde', slug: 'govde', iconName: 'car', sortOrder: 5 },
{ code: 'INTERIOR', nameEn: 'Interior', nameTr: 'Ic Mekan', slug: 'ic-mekan', iconName: 'sofa', sortOrder: 6 },
{ code: 'EXTERIOR', nameEn: 'Exterior', nameTr: 'Dis Mekan', slug: 'dis-mekan', iconName: 'sun', sortOrder: 7 },
{ code: 'TRANSMISSION', nameEn: 'Transmission', nameTr: 'Sanziman', slug: 'sanziman', iconName: 'settings', sortOrder: 8 },
{ code: 'EXHAUST', nameEn: 'Exhaust', nameTr: 'Egzoz', slug: 'egzoz', iconName: 'wind', sortOrder: 9 },
{ code: 'COOLING', nameEn: 'Cooling', nameTr: 'Sogutma', slug: 'sogutma', iconName: 'thermometer', sortOrder: 10 },
];
for (const category of categories) {
await prisma.category.upsert({
where: { code: category.code },
update: {},
create: category,
});
}
console.log(`Seeded ${categories.length} categories`);
console.log('Seeding completed!');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,58 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
import { PrismaModule } from './prisma/prisma.module';
import { RedisModule } from './redis/redis.module';
import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
import { BrandsModule } from './modules/brands/brands.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module';
import { PartsModule } from './modules/parts/parts.module';
import { CategoriesModule } from './modules/categories/categories.module';
import { SubscriptionsModule } from './modules/subscriptions/subscriptions.module';
import { PaymentsModule } from './modules/payments/payments.module';
import { IntegrationsModule } from './integrations/integrations.module';
@Module({
imports: [
// Configuration
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
// Rate limiting
ThrottlerModule.forRoot([
{
ttl: 60000,
limit: 100,
},
]),
// Database & Cache
PrismaModule,
RedisModule,
// Feature modules
AuthModule,
UsersModule,
BrandsModule,
VehiclesModule,
PartsModule,
CategoriesModule,
SubscriptionsModule,
PaymentsModule,
// External integrations
IntegrationsModule,
],
providers: [
// Apply rate limiting globally
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}

View File

@@ -0,0 +1,19 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface CurrentUserData {
id: string;
email: string;
}
export const CurrentUser = createParamDecorator(
(data: keyof CurrentUserData | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as CurrentUserData;
if (!user) {
return null;
}
return data ? user[data] : user;
},
);

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -0,0 +1,23 @@
import { SetMetadata } from '@nestjs/common';
export enum Role {
USER = 'USER',
MODERATOR = 'MODERATOR',
ADMIN = 'ADMIN',
SUPER_ADMIN = 'SUPER_ADMIN',
}
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
// Helper to check role hierarchy
export const ROLE_HIERARCHY: Record<Role, number> = {
[Role.USER]: 1,
[Role.MODERATOR]: 2,
[Role.ADMIN]: 3,
[Role.SUPER_ADMIN]: 4,
};
export const hasMinimumRole = (userRole: Role, requiredRole: Role): boolean => {
return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[requiredRole];
};

View File

@@ -0,0 +1,27 @@
export class ApiResponseDto<T> {
success: boolean;
data: T;
timestamp: string;
constructor(data: T) {
this.success = true;
this.data = data;
this.timestamp = new Date().toISOString();
}
}
export class ApiErrorResponseDto {
success: false;
error: {
code: string;
message: string;
details?: Record<string, string[]>;
};
timestamp: string;
constructor(code: string, message: string, details?: Record<string, string[]>) {
this.success = false;
this.error = { code, message, details };
this.timestamp = new Date().toISOString();
}
}

View File

@@ -0,0 +1,41 @@
import { IsOptional, IsInt, Min, Max, IsString, IsIn } from 'class-validator';
import { Type } from 'class-transformer';
export class PaginationDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
@IsOptional()
@IsString()
sortBy?: string;
@IsOptional()
@IsIn(['asc', 'desc'])
sortOrder?: 'asc' | 'desc' = 'desc';
}
export class PaginatedResponseDto<T> {
items: T[];
total: number;
page: number;
limit: number;
totalPages: number;
constructor(items: T[], total: number, page: number, limit: number) {
this.items = items;
this.total = total;
this.page = page;
this.limit = limit;
this.totalPages = Math.ceil(total / limit);
}
}

View File

@@ -0,0 +1,62 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response, Request } from 'express';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger('ExceptionFilter');
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Beklenmeyen bir hata olustu';
let code = 'INTERNAL_ERROR';
let details: Record<string, string[]> | undefined;
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
} else if (typeof exceptionResponse === 'object') {
const responseObj = exceptionResponse as Record<string, unknown>;
message = (responseObj.message as string) || message;
code = (responseObj.code as string) || code;
// Handle validation errors
if (Array.isArray(responseObj.message)) {
details = { validation: responseObj.message as string[] };
message = 'Dogrulama hatasi';
code = 'VALIDATION_ERROR';
}
}
}
// Log error
this.logger.error(
`${request.method} ${request.url} - ${status} - ${message}`,
exception instanceof Error ? exception.stack : undefined,
);
response.status(status).json({
success: false,
error: {
code,
message,
details,
},
timestamp: new Date().toISOString(),
path: request.url,
});
}
}

View File

@@ -0,0 +1,59 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Response } from 'express';
@Catch(Prisma.PrismaClientKnownRequestError)
export class PrismaExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger('PrismaExceptionFilter');
catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Veritabani hatasi';
let code = 'DATABASE_ERROR';
switch (exception.code) {
case 'P2002':
// Unique constraint violation
status = HttpStatus.CONFLICT;
const field = (exception.meta?.target as string[])?.join(', ') || 'alan';
message = `Bu ${field} zaten kullaniliyor`;
code = 'DUPLICATE_ENTRY';
break;
case 'P2025':
// Record not found
status = HttpStatus.NOT_FOUND;
message = 'Kayit bulunamadi';
code = 'NOT_FOUND';
break;
case 'P2003':
// Foreign key constraint failed
status = HttpStatus.BAD_REQUEST;
message = 'Iliskili kayit bulunamadi';
code = 'FOREIGN_KEY_ERROR';
break;
default:
this.logger.error(`Prisma error: ${exception.code}`, exception.message);
}
response.status(status).json({
success: false,
error: {
code,
message,
},
timestamp: new Date().toISOString(),
});
}
}

View File

@@ -0,0 +1,56 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class BrandAccessGuard implements CanActivate {
constructor(private prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
return false;
}
// Get user subscription with plan
const subscription = await this.prisma.userSubscription.findUnique({
where: { userId: user.id },
include: { plan: true },
});
// Check active subscription
if (!subscription || subscription.status !== 'ACTIVE') {
throw new ForbiddenException('Aktif aboneliginiz bulunmuyor');
}
if (new Date() > subscription.currentPeriodEnd) {
throw new ForbiddenException('Abonelik sureniz dolmus');
}
// Full access check
if (subscription.plan.hasFullAccess) {
request.hasFullAccess = true;
request.subscription = subscription;
return true;
}
// Get user's selected brands
const userBrands = await this.prisma.userBrand.findMany({
where: { userId: user.id },
include: { brand: true },
});
request.allowedBrandIds = userBrands.map((ub) => ub.brandId);
request.allowedBrandCodes = userBrands.map((ub) => ub.brand.code);
request.subscription = subscription;
request.hasFullAccess = false;
return true;
}
}

View File

@@ -0,0 +1,31 @@
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
handleRequest<TUser = any>(err: Error | null, user: TUser, info: Error | null): TUser {
if (err || !user) {
throw err || new UnauthorizedException('Oturum gecersiz veya suresi dolmus');
}
return user;
}
}

View File

@@ -0,0 +1,28 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY, Role, hasMinimumRole } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user || !user.role) {
return false;
}
// Check if user has any of the required roles or higher
return requiredRoles.some((role) => hasMinimumRole(user.role, role));
}
}

View File

@@ -0,0 +1,44 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, url, ip } = request;
const userAgent = request.get('user-agent') || '';
const userId = request.user?.id || 'anonymous';
const now = Date.now();
return next.handle().pipe(
tap({
next: () => {
const response = context.switchToHttp().getResponse();
const { statusCode } = response;
const contentLength = response.get('content-length') || 0;
const duration = Date.now() - now;
this.logger.log(
`${method} ${url} ${statusCode} ${contentLength} - ${duration}ms - ${userId} - ${ip} - ${userAgent}`,
);
},
error: (error) => {
const duration = Date.now() - now;
this.logger.error(
`${method} ${url} ${error.status || 500} - ${duration}ms - ${userId} - ${ip} - ${error.message}`,
);
},
}),
);
}
}

View File

@@ -0,0 +1,26 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
RequestTimeoutException,
} from '@nestjs/common';
import { Observable, throwError, TimeoutError } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
constructor(private readonly timeoutMs: number = 30000) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
timeout(this.timeoutMs),
catchError((err) => {
if (err instanceof TimeoutError) {
return throwError(() => new RequestTimeoutException('Istek zaman asimina ugradi'));
}
return throwError(() => err);
}),
);
}
}

View File

@@ -0,0 +1,27 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface Response<T> {
success: boolean;
data: T;
timestamp: string;
}
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
return next.handle().pipe(
map((data) => ({
success: true,
data,
timestamp: new Date().toISOString(),
})),
);
}
}

View File

@@ -0,0 +1,20 @@
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
import { validateVin, normalizeVin } from '@sase/shared';
@Injectable()
export class VinValidationPipe implements PipeTransform<string, string> {
transform(value: string): string {
if (!value) {
throw new BadRequestException('VIN numarasi gerekli');
}
const normalized = normalizeVin(value);
const validation = validateVin(normalized);
if (!validation.valid) {
throw new BadRequestException(validation.error);
}
return normalized;
}
}

View File

@@ -0,0 +1,543 @@
/**
* EMEX Response Mapper
*
* Transforms raw EmexVinScraper responses into standardized DecodedVehicle format.
* Includes Turkish translation support for common automotive terms.
*/
import {
EmexScraperResponse,
EmexCategory,
EmexPart,
DecodedVehicle,
DecodedCategory,
DecodedPart,
CATALOG_MAP,
} from './emex.types';
// ==================== TURKISH TRANSLATIONS ====================
/**
* Turkish translations for common automotive terms
*/
const TR_TRANSLATIONS = {
// Body types
bodyTypes: {
sedan: 'Sedan',
coupe: 'Coupe',
hatchback: 'Hatchback',
wagon: 'Station Wagon',
'station wagon': 'Station Wagon',
estate: 'Station Wagon',
convertible: 'Ustu Acik',
cabriolet: 'Kabriyole',
suv: 'SUV',
crossover: 'Crossover',
pickup: 'Pikap',
van: 'Minivan',
minivan: 'Minivan',
mpv: 'Cok Amacli Arac',
roadster: 'Roadster',
} as Record<string, string>,
// Engine types
engineTypes: {
gasoline: 'Benzin',
petrol: 'Benzin',
benzin: 'Benzin',
diesel: 'Dizel',
electric: 'Elektrik',
hybrid: 'Hibrit',
'plug-in hybrid': 'Sarjli Hibrit',
phev: 'Sarjli Hibrit',
lpg: 'LPG',
cng: 'CNG',
hydrogen: 'Hidrojen',
} as Record<string, string>,
// Transmission types
transmissions: {
automatic: 'Otomatik',
manual: 'Manuel',
'semi-automatic': 'Yari Otomatik',
dct: 'Cift Kavramali',
cvt: 'CVT',
'dual clutch': 'Cift Kavramali',
dsg: 'DSG',
tiptronic: 'Tiptronic',
steptronic: 'Steptronic',
at: 'Otomatik',
mt: 'Manuel',
} as Record<string, string>,
// Drive types
driveTypes: {
fwd: 'Ondan Cekis',
rwd: 'Arkadan Itis',
awd: 'Dort Ceker',
'4wd': 'Dort Ceker',
'4x4': 'Dort Ceker',
'front-wheel drive': 'Ondan Cekis',
'rear-wheel drive': 'Arkadan Itis',
'all-wheel drive': 'Dort Ceker',
quattro: 'Quattro (Dort Ceker)',
xdrive: 'xDrive (Dort Ceker)',
'4matic': '4MATIC (Dort Ceker)',
} as Record<string, string>,
// Common part categories
categories: {
engine: 'Motor',
brake: 'Fren Sistemi',
brakes: 'Fren Sistemi',
suspension: 'Suspansiyon',
steering: 'Direksiyon',
transmission: 'Sanziman',
exhaust: 'Egzoz Sistemi',
cooling: 'Sogutma Sistemi',
electrical: 'Elektrik Sistemi',
interior: 'Ic Aksam',
exterior: 'Dis Aksam',
body: 'Kaporta',
lighting: 'Aydinlatma',
lights: 'Aydinlatma',
wheels: 'Jantlar',
tires: 'Lastikler',
fuel: 'Yakit Sistemi',
'fuel system': 'Yakit Sistemi',
air: 'Hava Sistemi',
'air conditioning': 'Klima',
climate: 'Klima',
filters: 'Filtreler',
oil: 'Yag',
battery: 'Akku',
alternator: 'Alternator',
starter: 'Mars Motoru',
clutch: 'Debriyaj',
gearbox: 'Vites Kutusu',
axle: 'Aks',
differential: 'Diferansiyel',
driveshaft: 'Saft',
'cv joint': 'Aks Kafasi',
'tie rod': 'Rot Kolu',
'ball joint': 'Rotil',
'control arm': 'Salincak',
shock: 'Amortisor',
'shock absorber': 'Amortisor',
spring: 'Yay',
strut: 'Makfersan',
'brake pad': 'Fren Balatasi',
'brake disc': 'Fren Diski',
'brake rotor': 'Fren Diski',
caliper: 'Fren Kaliperi',
'master cylinder': 'Ana Merkez',
'wheel bearing': 'Bilyali Rulman',
hub: 'Porya',
mirror: 'Ayna',
bumper: 'Tampon',
fender: 'Camurluk',
hood: 'Kaput',
bonnet: 'Kaput',
trunk: 'Bagaj',
boot: 'Bagaj',
door: 'Kapi',
window: 'Cam',
windshield: 'On Cam',
windscreen: 'On Cam',
wiper: 'Silecek',
headlight: 'Far',
taillight: 'Stop Lambasi',
'turn signal': 'Sinyal Lambasi',
indicator: 'Sinyal Lambasi',
seat: 'Koltuk',
dashboard: 'Gosterge Paneli',
'steering wheel': 'Direksiyon Simidi',
pedal: 'Pedal',
carpet: 'Hali',
mat: 'Paspas',
'water pump': 'Su Pompasi',
thermostat: 'Termostat',
radiator: 'Radyator',
fan: 'Fan',
hose: 'Hortum',
belt: 'Kayis',
'timing belt': 'Eksantrik Kayisi',
'timing chain': 'Eksantrik Zinciri',
'serpentine belt': 'V Kayis',
gasket: 'Conta',
seal: 'Simir',
'o-ring': 'O-Ring',
sensor: 'Sensor',
oxygen: 'Oksijen Sensoru',
abs: 'ABS Sensoru',
airbag: 'Hava Yastigi',
horn: 'Korna',
relay: 'Role',
fuse: 'Sigorta',
switch: 'Dugme',
motor: 'Motor',
pump: 'Pompa',
compressor: 'Kompresor',
condenser: 'Kondenser',
evaporator: 'Evaporator',
heater: 'Isitici',
blower: 'Ufleyici',
'spark plug': 'Buji',
'ignition coil': 'Atesleme Bobini',
injector: 'Enjktor',
'fuel pump': 'Yakit Pompasi',
'fuel filter': 'Yakit Filtresi',
'air filter': 'Hava Filtresi',
'oil filter': 'Yag Filtresi',
'cabin filter': 'Polen Filtresi',
'pollen filter': 'Polen Filtresi',
} as Record<string, string>,
// Common part names
parts: {
'oil filter': 'Yag Filtresi',
'air filter': 'Hava Filtresi',
'fuel filter': 'Yakit Filtresi',
'cabin filter': 'Polen Filtresi',
'spark plug': 'Buji',
'brake pad': 'Fren Balatasi',
'brake disc': 'Fren Diski',
'brake rotor': 'Fren Diski',
'timing belt': 'Eksantrik Kayisi',
'water pump': 'Su Pompasi',
thermostat: 'Termostat',
alternator: 'Alternator',
starter: 'Mars Motoru',
battery: 'Akku',
radiator: 'Radyator',
'shock absorber': 'Amortisor',
'control arm': 'Salincak',
'tie rod': 'Rot Kolu',
'ball joint': 'Rotil',
'cv joint': 'Aks Kafasi',
clutch: 'Debriyaj',
'clutch kit': 'Debriyaj Seti',
flywheel: 'Volan',
'wheel bearing': 'Bilyali Rulman',
'hub bearing': 'Porya Rulmani',
caliper: 'Fren Kaliperi',
'master cylinder': 'Ana Merkez',
'slave cylinder': 'Yardimci Merkez',
'brake hose': 'Fren Hortumu',
'brake line': 'Fren Borusu',
'abs sensor': 'ABS Sensoru',
'oxygen sensor': 'Oksijen Sensoru',
'crankshaft sensor': 'Krank Sensoru',
'camshaft sensor': 'Eksantrik Sensoru',
'coolant sensor': 'Su Isisi Sensoru',
'oil pressure sensor': 'Yag Basinci Sensoru',
'ignition coil': 'Atesleme Bobini',
injector: 'Enjktor',
'fuel pump': 'Yakit Pompasi',
'fuel injector': 'Yakit Enjektoru',
gasket: 'Conta',
'head gasket': 'Silindir Kapagi Contasi',
'valve cover gasket': 'Kulbtor Kapagi Contasi',
'oil pan gasket': 'Karter Contasi',
'intake manifold gasket': 'Emme Manifoldu Contasi',
'exhaust manifold gasket': 'Egzoz Manifoldu Contasi',
'serpentine belt': 'V Kayis',
'drive belt': 'Tahrik Kayisi',
tensioner: 'Gerdirici',
'belt tensioner': 'Kayis Gerdirici',
idler: 'Avare',
'idler pulley': 'Avare Kasnak',
pulley: 'Kasnak',
'crankshaft pulley': 'Krank Kasnagi',
'power steering pump': 'Hidrolik Direksiyon Pompasi',
'power steering hose': 'Hidrolik Direksiyon Hortumu',
'steering rack': 'Kremayer',
'steering gear': 'Direksiyon Kutusu',
'cv boot': 'Aks Korfezi',
'drive shaft': 'Saft',
'axle shaft': 'Aks Mili',
'wheel hub': 'Porya',
'wheel stud': 'Bijon',
'lug nut': 'Bijon Somunu',
headlight: 'Far',
'headlight bulb': 'Far Ampulu',
taillight: 'Stop Lambasi',
'turn signal': 'Sinyal Lambasi',
'fog light': 'Sis Lambasi',
mirror: 'Ayna',
'side mirror': 'Yan Ayna',
'rear view mirror': 'Ic Ayna',
wiper: 'Silecek',
'wiper blade': 'Silecek Lastigi',
'wiper motor': 'Silecek Motoru',
'window regulator': 'Cam Mekanizmasi',
'window motor': 'Cam Motoru',
'door handle': 'Kapi Kolu',
'door lock': 'Kapi Kilidi',
'door hinge': 'Kapi Mentesesi',
bumper: 'Tampon',
grille: 'Izgara',
hood: 'Kaput',
fender: 'Camurluk',
'splash guard': 'Paclama',
mudguard: 'Camurluk',
} as Record<string, string>,
};
// ==================== TRANSLATION HELPERS ====================
/**
* Translates a term to Turkish if available
*/
function translateToTurkish(
term: string | null | undefined,
dictionary: Record<string, string>,
): string | null {
if (!term) return null;
const normalized = term.toLowerCase().trim();
return dictionary[normalized] || null;
}
/**
* Translates body type to Turkish
*/
export function translateBodyType(bodyType: string | null): string | null {
return translateToTurkish(bodyType, TR_TRANSLATIONS.bodyTypes);
}
/**
* Translates engine type to Turkish
*/
export function translateEngineType(engineType: string | null): string | null {
return translateToTurkish(engineType, TR_TRANSLATIONS.engineTypes);
}
/**
* Translates transmission type to Turkish
*/
export function translateTransmission(
transmission: string | null,
): string | null {
return translateToTurkish(transmission, TR_TRANSLATIONS.transmissions);
}
/**
* Translates drive type to Turkish
*/
export function translateDriveType(driveType: string | null): string | null {
return translateToTurkish(driveType, TR_TRANSLATIONS.driveTypes);
}
/**
* Translates category name to Turkish
*/
export function translateCategoryName(name: string): string {
const normalized = name.toLowerCase().trim();
return TR_TRANSLATIONS.categories[normalized] || name;
}
/**
* Translates part name to Turkish
*/
export function translatePartName(name: string): string {
const normalized = name.toLowerCase().trim();
return TR_TRANSLATIONS.parts[normalized] || name;
}
// ==================== MAPPER FUNCTIONS ====================
/**
* Maps raw EMEX scraper response to standardized DecodedVehicle
*/
export function mapEmexResponse(response: EmexScraperResponse): DecodedVehicle {
const vehicle = response.vehicle;
// Get brand from catalog map or use the one from response
const wmi = response.vin.substring(0, 3);
const catalogEntry = CATALOG_MAP[wmi];
const brand = catalogEntry?.brand || vehicle.brand || 'Unknown';
return {
brand: brand.toUpperCase(),
model: vehicle.model || 'Unknown',
year: vehicle.year || extractYearFromVin(response.vin),
series: vehicle.series || null,
bodyType: vehicle.bodyType || null,
engineCode: vehicle.engineCode || null,
engineType: vehicle.engineType || null,
engineVolume: vehicle.engineVolume || null,
transmission: vehicle.transmission || null,
driveType: vehicle.driveType || null,
colorCode: null, // EMEX doesn't provide color info
raw: buildRawResponse(response),
categories: mapCategories(response.categories, response.sampleParts),
};
}
/**
* Extracts year from VIN (10th character)
*/
function extractYearFromVin(vin: string): number {
const yearChar = vin.charAt(9).toUpperCase();
const yearMap: Record<string, number> = {
'1': 2001,
'2': 2002,
'3': 2003,
'4': 2004,
'5': 2005,
'6': 2006,
'7': 2007,
'8': 2008,
'9': 2009,
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] || new Date().getFullYear();
}
/**
* Builds the raw response object for storage
*/
function buildRawResponse(
response: EmexScraperResponse,
): Record<string, unknown> {
return {
source: response.source,
method: response.method,
vin: response.vin,
catalogCode: response.catalogCode,
ssd: response.ssd,
quickGroupsUrl: response.quickGroupsUrl,
timestamp: response.timestamp,
success: response.success,
message: response.message,
parsedOptions: response.parsedOptions,
rawResponse: response.rawResponse,
};
}
/**
* Maps EMEX categories to standardized DecodedCategory format
*/
function mapCategories(
categories?: EmexCategory[],
sampleParts?: EmexPart[],
): DecodedCategory[] {
if (!categories || categories.length === 0) {
return [];
}
return categories.map((cat, index) => ({
code: cat.gid || `CAT_${index}`,
nameEn: cat.name,
nameTr: translateCategoryName(cat.name),
description: null,
iconName: deriveIconName(cat.name),
schemaImageUrl: null,
parts:
index === 0 && sampleParts ? mapParts(sampleParts) : ([] as DecodedPart[]),
}));
}
/**
* Maps EMEX parts to standardized DecodedPart format
*/
function mapParts(parts: EmexPart[]): DecodedPart[] {
return parts.map((part) => ({
oemCode: part.oemCode,
nameEn: part.nameEn,
nameTr: translatePartName(part.nameEn),
description: null,
positionCode: part.positionCode,
prices: [], // EMEX doesn't provide pricing
}));
}
/**
* Derives icon name from category name
*/
function deriveIconName(categoryName: string): string | null {
const normalized = categoryName.toLowerCase();
const iconMap: Record<string, string> = {
engine: 'engine',
motor: 'engine',
brake: 'brake',
brakes: 'brake',
suspension: 'suspension',
steering: 'steering',
transmission: 'transmission',
gearbox: 'transmission',
exhaust: 'exhaust',
cooling: 'cooling',
electrical: 'electrical',
interior: 'interior',
exterior: 'exterior',
body: 'body',
lighting: 'lighting',
lights: 'lighting',
wheels: 'wheels',
fuel: 'fuel',
air: 'air',
climate: 'climate',
filters: 'filters',
};
for (const [key, icon] of Object.entries(iconMap)) {
if (normalized.includes(key)) {
return icon;
}
}
return null;
}
/**
* Creates an empty/default DecodedVehicle for error cases
*/
export function createEmptyDecodedVehicle(
vin: string,
errorMessage?: string,
): DecodedVehicle {
const wmi = vin.substring(0, 3);
const catalogEntry = CATALOG_MAP[wmi];
return {
brand: catalogEntry?.brand?.toUpperCase() || 'UNKNOWN',
model: 'Unknown',
year: extractYearFromVin(vin),
series: null,
bodyType: null,
engineCode: null,
engineType: null,
engineVolume: null,
transmission: null,
driveType: null,
colorCode: null,
raw: {
vin,
error: errorMessage || 'Vehicle data not found',
source: 'emexdwc.ae',
},
categories: [],
};
}

View File

@@ -0,0 +1,17 @@
/**
* EMEX Integration Module
*
* NestJS module for emexdwc.ae VIN integration.
* Provides EmexService for VIN decoding using the EMEX scraper.
*/
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { EmexService } from './emex.service';
@Module({
imports: [ConfigModule],
providers: [EmexService],
exports: [EmexService],
})
export class EmexModule {}

View File

@@ -0,0 +1,406 @@
/**
* EMEX VIN Service
*
* NestJS service for emexdwc.ae VIN integration.
* Wraps the EmexVinScraper from scripts/emex-vin-scraper.js
* and provides standardized DecodedVehicle responses.
*/
import {
Injectable,
Logger,
BadRequestException,
ServiceUnavailableException,
InternalServerErrorException,
OnModuleDestroy,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as path from 'path';
import {
EmexScraperResponse,
DecodedVehicle,
CATALOG_MAP,
} from './emex.types';
import { mapEmexResponse, createEmptyDecodedVehicle } from './emex.mapper';
// Type definition for the imported scraper module
interface EmexScraperModule {
EmexVinScraper: new () => EmexVinScraperInstance;
getCatalogCode: (vin: string) => string | null;
getYearFromVIN: (vin: string) => number | null;
CONFIG: Record<string, unknown>;
}
interface EmexVinScraperInstance {
init(): Promise<void>;
close(): Promise<void>;
searchByVIN(vin: string): Promise<EmexScraperResponse>;
getCategories(quickGroupsUrl: string): Promise<unknown[]>;
getParts(detailsUrl: string): Promise<unknown[]>;
}
@Injectable()
export class EmexService implements OnModuleDestroy {
private readonly logger = new Logger(EmexService.name);
private scraperModule: EmexScraperModule | null = null;
private scraperInstance: EmexVinScraperInstance | null = null;
private isInitialized = false;
private initializationPromise: Promise<void> | null = null;
private readonly scraperPath: string;
private readonly timeout: number;
private readonly debug: boolean;
constructor(private configService: ConfigService) {
// Configure scraper path - relative to project root
this.scraperPath = this.configService.get<string>(
'EMEX_SCRAPER_PATH',
path.resolve(__dirname, '../../../../../scripts/emex-vin-scraper.js'),
);
this.timeout = this.configService.get<number>('EMEX_TIMEOUT', 60000);
this.debug = this.configService.get<boolean>('EMEX_DEBUG', false);
this.logger.log(`EMEX Service initialized with scraper path: ${this.scraperPath}`);
}
/**
* Cleanup on module destroy
*/
async onModuleDestroy(): Promise<void> {
await this.closeScraper();
}
/**
* Lazily initialize the scraper module
*/
private async initializeScraper(): Promise<void> {
if (this.isInitialized) {
return;
}
if (this.initializationPromise) {
return this.initializationPromise;
}
this.initializationPromise = this.doInitialize();
return this.initializationPromise;
}
private async doInitialize(): Promise<void> {
try {
this.logger.log('Loading EMEX scraper module...');
// Dynamically import the scraper module
// eslint-disable-next-line @typescript-eslint/no-var-requires
this.scraperModule = require(this.scraperPath) as EmexScraperModule;
this.logger.log('EMEX scraper module loaded successfully');
this.isInitialized = true;
} catch (error) {
const err = error as Error;
this.logger.error(
`Failed to load EMEX scraper module: ${err.message}`,
err.stack,
);
throw new InternalServerErrorException(
'EMEX servis modulu yuklenemedi',
);
}
}
/**
* Creates a new scraper instance and initializes browser
*/
private async createScraperInstance(): Promise<EmexVinScraperInstance> {
await this.initializeScraper();
if (!this.scraperModule) {
throw new InternalServerErrorException('EMEX scraper modulu yuklenemedi');
}
const instance = new this.scraperModule.EmexVinScraper();
await instance.init();
return instance;
}
/**
* Closes the scraper instance if it exists
*/
private async closeScraper(): Promise<void> {
if (this.scraperInstance) {
try {
await this.scraperInstance.close();
this.scraperInstance = null;
this.logger.log('EMEX scraper instance closed');
} catch (error) {
const err = error as Error;
this.logger.warn(`Error closing scraper: ${err.message}`);
}
}
}
/**
* Validates VIN format
*/
private validateVin(vin: string): void {
if (!vin) {
throw new BadRequestException('VIN numarasi gereklidir');
}
const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, '');
if (cleanVin.length !== 17) {
throw new BadRequestException(
'VIN numarasi 17 karakter olmalidir',
);
}
// Check for invalid characters (I, O, Q are not used in VINs)
if (/[IOQ]/i.test(cleanVin)) {
throw new BadRequestException(
'VIN numarasi gecersiz karakterler iceriyor (I, O, Q kullanilamaz)',
);
}
}
/**
* Checks if VIN manufacturer is supported
*/
private checkManufacturerSupport(vin: string): void {
const wmi = vin.substring(0, 3).toUpperCase();
if (!CATALOG_MAP[wmi]) {
throw new BadRequestException(
`Bu uretici desteklenmiyor: ${wmi}. Desteklenen markalar: BMW, Mercedes-Benz, Audi, Volkswagen, Renault, Peugeot, Fiat, Alfa Romeo, Ford, Toyota, Honda, Hyundai, Kia`,
);
}
}
/**
* Decodes a VIN number using EMEX scraper
*
* @param vin - The 17-character VIN to decode
* @returns Standardized DecodedVehicle object
* @throws BadRequestException for invalid VINs
* @throws ServiceUnavailableException for scraper failures
*/
async decodeVin(vin: string): Promise<DecodedVehicle> {
const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, '');
// Validate VIN
this.validateVin(cleanVin);
// Check manufacturer support
this.checkManufacturerSupport(cleanVin);
this.logger.log(`Decoding VIN: ${cleanVin}`);
let scraper: EmexVinScraperInstance | null = null;
try {
// Create scraper instance
scraper = await this.createScraperInstance();
// Execute search with timeout
const response = await this.executeWithTimeout(
scraper.searchByVIN(cleanVin),
this.timeout,
);
if (this.debug) {
this.logger.debug(
`EMEX raw response: ${JSON.stringify(response, null, 2)}`,
);
}
// Check for failed response
if (!response.success) {
this.logger.warn(
`EMEX search unsuccessful: ${response.message || response.error}`,
);
// Return partial data if available
if (response.vehicle && response.vehicle.brand) {
return mapEmexResponse(response);
}
// Return empty vehicle with error info
return createEmptyDecodedVehicle(
cleanVin,
response.message || response.error,
);
}
// Map successful response
const decodedVehicle = mapEmexResponse(response);
this.logger.log(
`VIN decoded successfully: ${decodedVehicle.brand} ${decodedVehicle.model} (${decodedVehicle.year})`,
);
return decodedVehicle;
} catch (error) {
const err = error as Error;
// Handle specific error types
if (
err instanceof BadRequestException ||
err instanceof ServiceUnavailableException ||
err instanceof InternalServerErrorException
) {
throw err;
}
// Handle timeout
if (err.message?.includes('timeout') || err.name === 'TimeoutError') {
this.logger.error(`VIN decode timeout for: ${cleanVin}`);
throw new ServiceUnavailableException(
'EMEX servisi zaman asimina ugradi. Lutfen tekrar deneyin.',
);
}
// Handle browser/puppeteer errors
if (
err.message?.includes('browser') ||
err.message?.includes('puppeteer') ||
err.message?.includes('navigation')
) {
this.logger.error(`Browser error: ${err.message}`, err.stack);
throw new ServiceUnavailableException(
'EMEX servisine baglanamadi. Lutfen daha sonra tekrar deneyin.',
);
}
// Generic error
this.logger.error(`VIN decode error: ${err.message}`, err.stack);
throw new ServiceUnavailableException(
'VIN sorgulama sirasinda bir hata olustu',
);
} finally {
// Always close the scraper
if (scraper) {
try {
await scraper.close();
} catch (closeError) {
const err = closeError as Error;
this.logger.warn(`Error closing scraper: ${err.message}`);
}
}
}
}
/**
* Executes a promise with timeout
*/
private async executeWithTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
): Promise<T> {
let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
const error = new Error(`Operation timed out after ${timeoutMs}ms`);
error.name = 'TimeoutError';
reject(error);
}, timeoutMs);
});
try {
const result = await Promise.race([promise, timeoutPromise]);
clearTimeout(timeoutId!);
return result;
} catch (error) {
clearTimeout(timeoutId!);
throw error;
}
}
/**
* Gets the catalog code for a VIN
*
* @param vin - The VIN to check
* @returns Catalog code or null if not supported
*/
getCatalogCode(vin: string): string | null {
const wmi = vin.substring(0, 3).toUpperCase();
return CATALOG_MAP[wmi]?.code || null;
}
/**
* Checks if a VIN's manufacturer is supported
*
* @param vin - The VIN to check
* @returns True if supported
*/
isSupported(vin: string): boolean {
if (!vin || vin.length < 3) {
return false;
}
const wmi = vin.substring(0, 3).toUpperCase();
return wmi in CATALOG_MAP;
}
/**
* Gets list of supported manufacturers
*
* @returns Array of supported brand names
*/
getSupportedBrands(): string[] {
const brands = new Set<string>();
for (const entry of Object.values(CATALOG_MAP)) {
brands.add(entry.brand);
}
return Array.from(brands).sort();
}
/**
* Extracts year from VIN (10th character)
*
* @param vin - The VIN to extract year from
* @returns Year number or null
*/
getYearFromVin(vin: string): number | null {
if (!vin || vin.length < 10) {
return null;
}
const yearChar = vin.charAt(9).toUpperCase();
const yearMap: Record<string, number> = {
'1': 2001,
'2': 2002,
'3': 2003,
'4': 2004,
'5': 2005,
'6': 2006,
'7': 2007,
'8': 2008,
'9': 2009,
A: 2010,
B: 2011,
C: 2012,
D: 2013,
E: 2014,
F: 2015,
G: 2016,
H: 2017,
J: 2018,
K: 2019,
L: 2020,
M: 2021,
N: 2022,
P: 2023,
R: 2024,
S: 2025,
T: 2026,
V: 2027,
W: 2028,
X: 2029,
Y: 2030,
};
return yearMap[yearChar] || null;
}
}

View File

@@ -0,0 +1,214 @@
/**
* EMEX VIN API Types
*
* Type definitions for emexdwc.ae VIN scraping integration.
* These types represent the raw response from the EmexVinScraper.
*/
// ==================== RAW EMEX RESPONSE TYPES ====================
/**
* Raw vehicle data from EMEX API/scraper response
*/
export interface EmexVehicleData {
brand: string | null;
model: string | null;
year: number | null;
series?: string | null;
bodyType?: string | null;
engineCode?: string | null;
engineType?: string | null;
engineVolume?: string | null;
transmission?: string | null;
driveType?: string | null;
}
/**
* Parsed options from vehicle HTML/API response
*/
export interface EmexParsedOptions {
vehicle_type?: string;
engine_type?: string;
gearbox_type?: string;
[key: string]: string | undefined;
}
/**
* Vehicle entry from HTML parsing
*/
export interface EmexHtmlVehicle {
name: string;
engine: string;
options: string;
quickGroupsUrl: string;
}
/**
* Part category from EMEX
*/
export interface EmexCategory {
gid: string;
name: string;
url: string | null;
}
/**
* Part data from EMEX
*/
export interface EmexPart {
oemCode: string;
nameEn: string;
positionCode?: string;
}
/**
* Wizard step from EMEX API
*/
export interface EmexWizardStep {
name: string;
determined: boolean;
allowlistvehicles?: boolean;
options?: EmexWizardOption[];
}
/**
* Wizard option from EMEX API
*/
export interface EmexWizardOption {
key: string;
value: string;
}
/**
* Main response from EmexVinScraper.searchByVIN()
*/
export interface EmexScraperResponse {
success: boolean;
source: string;
method: 'api' | 'wizard' | 'html_parse' | 'fallback';
vin: string;
catalogCode: string;
ssd?: string;
vehicle: EmexVehicleData;
message?: string;
error?: string;
rawResponse?: Record<string, unknown>;
wizardSteps?: EmexWizardStep[];
allVehicles?: EmexHtmlVehicle[];
quickGroupsUrl?: string | null;
parsedOptions?: EmexParsedOptions;
categories?: EmexCategory[];
sampleParts?: EmexPart[];
timestamp: string;
}
// ==================== STANDARDIZED OUTPUT TYPES ====================
// (Re-exported from vin-api for consistency)
/**
* Standardized decoded vehicle response
* Matches the sase.tr schema structure
*/
export interface DecodedVehicle {
brand: string;
model: string;
year: number;
series: string | null;
bodyType: string | null;
engineCode: string | null;
engineType: string | null;
engineVolume: string | null;
transmission: string | null;
driveType: string | null;
colorCode: string | null;
raw: Record<string, unknown>;
categories: DecodedCategory[];
}
/**
* Standardized category structure
*/
export interface DecodedCategory {
code: string;
nameEn: string;
nameTr?: string;
description: string | null;
iconName: string | null;
schemaImageUrl: string | null;
parts: DecodedPart[];
}
/**
* Standardized part structure
*/
export interface DecodedPart {
oemCode: string;
alternativeOems?: string[];
nameEn: string;
nameTr?: string;
description: string | null;
positionCode?: string;
positionX?: number;
positionY?: number;
imageUrl?: string;
prices: DecodedPrice[];
}
/**
* Standardized price structure
*/
export interface DecodedPrice {
brand: string;
price: number;
currency: string;
inStock: boolean;
}
// ==================== SERVICE CONFIG TYPES ====================
/**
* EMEX service configuration
*/
export interface EmexConfig {
/** Timeout for scraper operations in milliseconds */
timeout: number;
/** Whether to enable debug logging */
debug: boolean;
/** Path to the scraper script */
scraperPath: string;
}
/**
* Catalog mapping entry
*/
export interface CatalogEntry {
code: string;
brand: string;
}
/**
* WMI (World Manufacturer Identifier) to catalog mapping
*/
export const CATALOG_MAP: Record<string, CatalogEntry> = {
WBA: { code: 'BMW202501', brand: 'BMW' },
WBS: { code: 'BMW202501', brand: 'BMW' },
WBY: { code: 'BMW202501', brand: 'BMW' },
WDB: { code: 'MB201810', brand: 'Mercedes-Benz' },
WDD: { code: 'MB201810', brand: 'Mercedes-Benz' },
WDC: { code: 'MB201810', brand: 'Mercedes-Benz' },
WDF: { code: 'MB201810', brand: 'Mercedes-Benz' },
WAU: { code: 'AU1587', brand: 'Audi' },
WVW: { code: 'VW1587', brand: 'Volkswagen' },
WVG: { code: 'VW1587', brand: 'Volkswagen' },
VF1: { code: 'RENAULT201910', brand: 'Renault' },
VF7: { code: 'CPSA01', brand: 'Peugeot' },
VF3: { code: 'CPSA01', brand: 'Peugeot' },
ZFA: { code: 'CFIAT84', brand: 'Fiat' },
ZAR: { code: 'RFIAT84', brand: 'Alfa Romeo' },
WF0: { code: 'FORD00', brand: 'Ford' },
JTD: { code: 'TOYOTA00', brand: 'Toyota' },
JTE: { code: 'TOYOTA00', brand: 'Toyota' },
SHH: { code: 'HONDA00', brand: 'Honda' },
KNM: { code: 'HYUNDAI00', brand: 'Hyundai' },
KNA: { code: 'KIA00', brand: 'Kia' },
};

View File

@@ -0,0 +1,10 @@
/**
* EMEX Integration Module Exports
*
* Barrel file for emexdwc.ae VIN integration.
*/
export * from './emex.module';
export * from './emex.service';
export * from './emex.types';
export * from './emex.mapper';

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { VinApiService } from './vin-api/vin-api.service';
import { EmexModule } from './emex/emex.module';
@Module({
imports: [EmexModule],
providers: [VinApiService],
exports: [VinApiService, EmexModule],
})
export class IntegrationsModule {}

View File

@@ -0,0 +1,58 @@
import {
VinApiResponse,
VinApiCategory,
VinApiPart,
DecodedVehicle,
DecodedCategory,
DecodedPart,
} from './vin-api.types';
export function mapApiResponse(response: VinApiResponse): DecodedVehicle {
const data = response.data;
return {
brand: data.make,
model: data.model,
year: data.year,
series: data.series || null,
bodyType: data.body_type || null,
engineCode: data.engine_code || null,
engineType: data.fuel_type || null,
engineVolume: data.displacement || null,
transmission: data.transmission || null,
driveType: data.drive_type || null,
colorCode: data.color_code || null,
raw: (response.raw || response) as Record<string, unknown>,
categories: (data.categories || []).map(mapCategory),
};
}
function mapCategory(category: VinApiCategory): DecodedCategory {
return {
code: category.code || category.id,
nameEn: category.name,
description: category.description || null,
iconName: category.icon || null,
schemaImageUrl: category.schema_image_url || null,
parts: (category.parts || []).map(mapPart),
};
}
function mapPart(part: VinApiPart): DecodedPart {
return {
oemCode: part.oem_code,
alternativeOems: part.alternative_oems,
nameEn: part.name,
description: part.description || null,
positionCode: part.position_code,
positionX: part.position_x,
positionY: part.position_y,
imageUrl: part.image_url,
prices: (part.prices || []).map((p) => ({
brand: p.brand,
price: p.price,
currency: p.currency,
inStock: p.in_stock,
})),
};
}

View File

@@ -0,0 +1,148 @@
import { Injectable, BadRequestException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance, AxiosError } from 'axios';
import { VinApiResponse, DecodedVehicle } from './vin-api.types';
import { mapApiResponse } from './vin-api.mapper';
@Injectable()
export class VinApiService {
private readonly logger = new Logger(VinApiService.name);
private readonly client: AxiosInstance;
constructor(private configService: ConfigService) {
const baseURL = this.configService.get<string>('VIN_API_URL', 'https://api.vinprovider.com/v1');
const apiKey = this.configService.get<string>('VIN_API_KEY', '');
const timeout = this.configService.get<number>('VIN_API_TIMEOUT', 30000);
this.client = axios.create({
baseURL,
timeout,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
});
}
async decodeVin(vin: string): Promise<DecodedVehicle> {
try {
this.logger.log(`Decoding VIN: ${vin}`);
// In production, this would call the actual VIN API
// For development, we'll simulate a response
const isDevelopment = this.configService.get<string>('NODE_ENV') === 'development';
if (isDevelopment || !this.configService.get<string>('VIN_API_KEY')) {
return this.getMockResponse(vin);
}
const response = await this.client.get<VinApiResponse>(`/decode/${vin}`);
return mapApiResponse(response.data);
} catch (error) {
const axiosError = error as AxiosError;
this.logger.error(`VIN decode failed: ${axiosError.message}`, axiosError.stack);
if (axiosError.response?.status === 404) {
throw new BadRequestException('VIN numarasi bulunamadi veya gecersiz');
}
if (axiosError.response?.status === 429) {
throw new BadRequestException('Cok fazla istek gonderildi, lutfen bekleyin');
}
throw new BadRequestException('VIN sorgulama sirasinda bir hata olustu');
}
}
// Mock response for development/testing
private getMockResponse(vin: string): DecodedVehicle {
// Extract mock data from VIN structure
const wmi = vin.substring(0, 3);
const yearChar = vin.charAt(9);
// Simple brand detection from WMI
const brandMap: Record<string, string> = {
WVW: 'VOLKSWAGEN',
WBA: 'BMW',
WDB: 'MERCEDES',
WAU: 'AUDI',
ZFA: 'FIAT',
VF1: 'RENAULT',
JTD: 'TOYOTA',
JHM: 'HONDA',
WF0: 'FORD',
W0L: 'OPEL',
};
const brand = brandMap[wmi] || 'VOLKSWAGEN';
const yearMap: Record<string, number> = {
A: 2010, B: 2011, C: 2012, D: 2013, E: 2014,
F: 2015, G: 2016, H: 2017, J: 2018, K: 2019,
L: 2020, M: 2021, N: 2022, P: 2023, R: 2024,
};
const year = yearMap[yearChar] || 2020;
return {
brand,
model: brand === 'BMW' ? '320i' : brand === 'MERCEDES' ? 'C200' : 'Golf',
year,
series: brand === 'BMW' ? '3 Serisi' : null,
bodyType: 'Sedan',
engineCode: 'TSI',
engineType: 'Benzin',
engineVolume: '2.0L',
transmission: 'Otomatik',
driveType: 'FWD',
colorCode: null,
raw: { vin, mockData: true },
categories: [
{
code: 'ENGINE',
nameEn: 'Engine',
description: 'Engine components',
iconName: 'engine',
schemaImageUrl: null,
parts: [
{
oemCode: 'OEM-001',
nameEn: 'Oil Filter',
description: 'Engine oil filter',
prices: [
{ brand: 'Bosch', price: 150, currency: 'TRY', inStock: true },
{ brand: 'Mann', price: 180, currency: 'TRY', inStock: true },
],
},
{
oemCode: 'OEM-002',
nameEn: 'Air Filter',
description: 'Engine air filter',
prices: [
{ brand: 'Bosch', price: 200, currency: 'TRY', inStock: true },
],
},
],
},
{
code: 'BRAKE',
nameEn: 'Brake System',
description: 'Brake components',
iconName: 'brake',
schemaImageUrl: null,
parts: [
{
oemCode: 'OEM-101',
nameEn: 'Front Brake Pads',
description: 'Front brake pad set',
prices: [
{ brand: 'Brembo', price: 800, currency: 'TRY', inStock: true },
{ brand: 'TRW', price: 650, currency: 'TRY', inStock: true },
],
},
],
},
],
};
}
}

View File

@@ -0,0 +1,92 @@
export interface VinApiResponse {
status: string;
data: {
vin: string;
make: string;
model: string;
year: number;
series?: string;
body_type?: string;
engine_code?: string;
fuel_type?: string;
displacement?: string;
transmission?: string;
drive_type?: string;
color_code?: string;
categories?: VinApiCategory[];
};
raw?: Record<string, unknown>;
}
export interface VinApiCategory {
id: string;
code: string;
name: string;
description?: string;
icon?: string;
schema_image_url?: string;
parts?: VinApiPart[];
}
export interface VinApiPart {
oem_code: string;
alternative_oems?: string[];
name: string;
description?: string;
position_code?: string;
position_x?: number;
position_y?: number;
image_url?: string;
prices?: VinApiPrice[];
}
export interface VinApiPrice {
brand: string;
price: number;
currency: string;
in_stock: boolean;
}
export interface DecodedVehicle {
brand: string;
model: string;
year: number;
series: string | null;
bodyType: string | null;
engineCode: string | null;
engineType: string | null;
engineVolume: string | null;
transmission: string | null;
driveType: string | null;
colorCode: string | null;
raw: Record<string, unknown>;
categories: DecodedCategory[];
}
export interface DecodedCategory {
code: string;
nameEn: string;
description: string | null;
iconName: string | null;
schemaImageUrl: string | null;
parts: DecodedPart[];
}
export interface DecodedPart {
oemCode: string;
alternativeOems?: string[];
nameEn: string;
description: string | null;
positionCode?: string;
positionX?: number;
positionY?: number;
imageUrl?: string;
prices: DecodedPrice[];
}
export interface DecodedPrice {
brand: string;
price: number;
currency: string;
inStock: boolean;
}

51
apps/api/src/main.ts Normal file
View File

@@ -0,0 +1,51 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
// Global prefix
const apiPrefix = configService.get<string>('API_PREFIX', 'api');
app.setGlobalPrefix(apiPrefix);
// CORS
const corsOrigin = configService.get<string>('CORS_ORIGIN', 'http://localhost:3000');
app.enableCors({
origin: corsOrigin.split(','),
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
});
// Global pipes
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
// Global filters
app.useGlobalFilters(new HttpExceptionFilter());
// Global interceptors
app.useGlobalInterceptors(new LoggingInterceptor(), new TransformInterceptor());
// Start server
const port = configService.get<number>('PORT', 4000);
await app.listen(port);
console.log(`Application is running on: http://localhost:${port}/${apiPrefix}`);
}
bootstrap();

View File

@@ -0,0 +1,82 @@
import {
Controller,
Post,
Body,
Get,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { ForgotPasswordDto, ResetPasswordDto } from './dto/forgot-password.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { JwtRefreshGuard } from './strategies/jwt-refresh.strategy';
import { Public } from '../../common/decorators/public.decorator';
import { CurrentUser, CurrentUserData } from '../../common/decorators/current-user.decorator';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
// Rate limit: 3 requests per minute for registration
@Public()
@Post('register')
@Throttle({ default: { limit: 3, ttl: 60000 } })
async register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
// Rate limit: 5 requests per minute for login
@Public()
@Post('login')
@Throttle({ default: { limit: 5, ttl: 60000 } })
@HttpCode(HttpStatus.OK)
async login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
// Rate limit: 10 requests per minute for token refresh
@Public()
@Post('refresh')
@Throttle({ default: { limit: 10, ttl: 60000 } })
@UseGuards(JwtRefreshGuard)
@HttpCode(HttpStatus.OK)
async refresh(@Body() dto: RefreshTokenDto) {
return this.authService.refreshTokens(dto.refreshToken);
}
@Post('logout')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
async logout(@CurrentUser() user: CurrentUserData) {
return this.authService.logout(user.id);
}
// Rate limit: 3 requests per minute for forgot password (prevent email enumeration)
@Public()
@Post('forgot-password')
@Throttle({ default: { limit: 3, ttl: 60000 } })
@HttpCode(HttpStatus.OK)
async forgotPassword(@Body() dto: ForgotPasswordDto) {
return this.authService.forgotPassword(dto.email);
}
// Rate limit: 5 requests per minute for password reset
@Public()
@Post('reset-password')
@Throttle({ default: { limit: 5, ttl: 60000 } })
@HttpCode(HttpStatus.OK)
async resetPassword(@Body() dto: ResetPasswordDto) {
return this.authService.resetPassword(dto.token, dto.password);
}
@Get('me')
@UseGuards(JwtAuthGuard)
async me(@CurrentUser() user: CurrentUserData) {
return this.authService.getProfile(user.id);
}
}

View File

@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: configService.get<string>('JWT_EXPIRES_IN', '15m'),
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtRefreshStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}

View File

@@ -0,0 +1,220 @@
import {
Injectable,
UnauthorizedException,
ConflictException,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../../prisma/prisma.service';
import { RedisService } from '../../redis/redis.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { JwtPayload } from './types/jwt-payload.type';
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
private configService: ConfigService,
private redisService: RedisService,
) {}
async register(dto: RegisterDto) {
// Check if email exists
const existingUser = await this.prisma.user.findUnique({
where: { email: dto.email.toLowerCase() },
});
if (existingUser) {
throw new ConflictException('Bu email adresi zaten kayitli');
}
// Hash password
const passwordHash = await bcrypt.hash(dto.password, 12);
// Create user
const user = await this.prisma.user.create({
data: {
email: dto.email.toLowerCase(),
name: dto.name,
passwordHash,
provider: 'EMAIL',
},
});
// Generate tokens
const tokens = await this.generateTokens(user.id, user.email);
return {
user: this.sanitizeUser(user),
...tokens,
};
}
async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({
where: { email: dto.email.toLowerCase() },
});
if (!user || !user.passwordHash) {
throw new UnauthorizedException('Email veya sifre hatali');
}
const isPasswordValid = await bcrypt.compare(dto.password, user.passwordHash);
if (!isPasswordValid) {
throw new UnauthorizedException('Email veya sifre hatali');
}
if (!user.isActive) {
throw new UnauthorizedException('Hesabiniz aktif degil');
}
const tokens = await this.generateTokens(user.id, user.email);
return {
user: this.sanitizeUser(user),
...tokens,
};
}
async refreshTokens(refreshToken: string) {
try {
const payload = this.jwtService.verify<JwtPayload>(refreshToken, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
});
// Check if refresh token is blacklisted
const isBlacklisted = await this.redisService.exists(`blacklist:${refreshToken}`);
if (isBlacklisted) {
throw new UnauthorizedException('Gecersiz refresh token');
}
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
});
if (!user || !user.isActive) {
throw new UnauthorizedException('Kullanici bulunamadi');
}
// Blacklist old refresh token
const ttl = Math.floor((payload.exp! - Date.now() / 1000) + 60);
if (ttl > 0) {
await this.redisService.set(`blacklist:${refreshToken}`, '1', ttl);
}
return this.generateTokens(user.id, user.email);
} catch {
throw new UnauthorizedException('Gecersiz refresh token');
}
}
async logout(userId: string) {
// Could blacklist current tokens here if needed
return { message: 'Basariyla cikis yapildi' };
}
async forgotPassword(email: string) {
const user = await this.prisma.user.findUnique({
where: { email: email.toLowerCase() },
});
// Don't reveal if email exists
if (!user) {
return { message: 'Eger email kayitliysa, sifre sifirlama linki gonderildi' };
}
// Generate reset token
const resetToken = uuidv4();
const expiry = 3600; // 1 hour
await this.redisService.set(
`password-reset:${resetToken}`,
user.id,
expiry,
);
// TODO: Implement email service (SendGrid, AWS SES, etc.)
// Email should contain: ${process.env.FRONTEND_URL}/reset-password?token=${resetToken}
// For now, token is stored in Redis and will expire in 1 hour
return { message: 'Eger email kayitliysa, sifre sifirlama linki gonderildi' };
}
async resetPassword(token: string, newPassword: string) {
const userId = await this.redisService.get(`password-reset:${token}`);
if (!userId) {
throw new BadRequestException('Gecersiz veya suresi dolmus token');
}
const passwordHash = await bcrypt.hash(newPassword, 12);
await this.prisma.user.update({
where: { id: userId },
data: { passwordHash },
});
// Delete reset token
await this.redisService.del(`password-reset:${token}`);
return { message: 'Sifre basariyla degistirildi' };
}
async getProfile(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
subscription: {
include: { plan: true },
},
selectedBrands: {
include: { brand: true },
},
},
});
if (!user) {
throw new NotFoundException('Kullanici bulunamadi');
}
return this.sanitizeUser(user);
}
async validateUser(payload: JwtPayload) {
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
});
if (!user || !user.isActive) {
return null;
}
return { id: user.id, email: user.email, role: user.role };
}
private async generateTokens(userId: string, email: string) {
const payload: JwtPayload = { sub: userId, email };
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload),
this.jwtService.signAsync(payload, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRES_IN', '7d'),
}),
]);
return { accessToken, refreshToken };
}
private sanitizeUser<T extends { passwordHash?: string | null }>(user: T): Omit<T, 'passwordHash'> {
const { passwordHash, ...sanitized } = user;
return sanitized;
}
}

View File

@@ -0,0 +1,18 @@
import { IsEmail, IsString, MinLength, Matches } from 'class-validator';
export class ForgotPasswordDto {
@IsEmail({}, { message: 'Gecersiz email adresi' })
email: string;
}
export class ResetPasswordDto {
@IsString()
token: string;
@IsString()
@MinLength(8, { message: 'Sifre en az 8 karakter olmalidir' })
@Matches(/^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{8,}$/, {
message: 'Sifre en az bir harf ve bir rakam icermelidir',
})
password: string;
}

View File

@@ -0,0 +1,10 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail({}, { message: 'Gecersiz email adresi' })
email: string;
@IsString()
@MinLength(1, { message: 'Sifre gerekli' })
password: string;
}

View File

@@ -0,0 +1,7 @@
import { IsString, IsNotEmpty } from 'class-validator';
export class RefreshTokenDto {
@IsString()
@IsNotEmpty({ message: 'Refresh token gerekli' })
refreshToken: string;
}

View File

@@ -0,0 +1,20 @@
import { IsEmail, IsString, MinLength, MaxLength, Matches, IsOptional } from 'class-validator';
export class RegisterDto {
@IsEmail({}, { message: 'Gecersiz email adresi' })
email: string;
@IsString()
@MinLength(8, { message: 'Sifre en az 8 karakter olmalidir' })
@MaxLength(50, { message: 'Sifre en fazla 50 karakter olabilir' })
@Matches(/^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{8,}$/, {
message: 'Sifre en az bir harf ve bir rakam icermelidir',
})
password: string;
@IsOptional()
@IsString()
@MinLength(2, { message: 'Ad en az 2 karakter olmalidir' })
@MaxLength(100, { message: 'Ad en fazla 100 karakter olabilir' })
name?: string;
}

View File

@@ -0,0 +1,27 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { AuthGuard } from '@nestjs/passport';
import { JwtPayload } from '../types/jwt-payload.type';
@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromBodyField('refreshToken'),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_REFRESH_SECRET'),
});
}
async validate(payload: JwtPayload) {
if (!payload.sub) {
throw new UnauthorizedException('Gecersiz refresh token');
}
return { id: payload.sub, email: payload.email };
}
}
@Injectable()
export class JwtRefreshGuard extends AuthGuard('jwt-refresh') {}

View File

@@ -0,0 +1,30 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { AuthService } from '../auth.service';
import { JwtPayload } from '../types/jwt-payload.type';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private configService: ConfigService,
private authService: AuthService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET'),
});
}
async validate(payload: JwtPayload) {
const user = await this.authService.validateUser(payload);
if (!user) {
throw new UnauthorizedException('Oturum gecersiz');
}
return user;
}
}

View File

@@ -0,0 +1,6 @@
export interface JwtPayload {
sub: string;
email: string;
iat?: number;
exp?: number;
}

View File

@@ -0,0 +1,59 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { BrandsService } from './brands.service';
import { SelectBrandsDto } from './dto/select-brands.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Public } from '../../common/decorators/public.decorator';
import { CurrentUser, CurrentUserData } from '../../common/decorators/current-user.decorator';
@Controller('brands')
export class BrandsController {
constructor(private readonly brandsService: BrandsService) {}
@Public()
@Get()
async getAllBrands() {
return this.brandsService.getAllBrands();
}
@Get('selected')
@UseGuards(JwtAuthGuard)
async getSelectedBrands(@CurrentUser() user: CurrentUserData) {
return this.brandsService.getSelectedBrands(user.id);
}
@Post('select')
@UseGuards(JwtAuthGuard)
async selectBrands(
@CurrentUser() user: CurrentUserData,
@Body() dto: SelectBrandsDto,
) {
return this.brandsService.selectBrands(user.id, dto.brandIds);
}
@Put('select')
@UseGuards(JwtAuthGuard)
async updateSelectedBrands(
@CurrentUser() user: CurrentUserData,
@Body() dto: SelectBrandsDto,
) {
return this.brandsService.updateSelectedBrands(user.id, dto.brandIds);
}
@Delete('select/:brandId')
@UseGuards(JwtAuthGuard)
async removeBrand(
@CurrentUser() user: CurrentUserData,
@Param('brandId') brandId: string,
) {
return this.brandsService.removeBrand(user.id, brandId);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { BrandsController } from './brands.controller';
import { BrandsService } from './brands.service';
@Module({
controllers: [BrandsController],
providers: [BrandsService],
exports: [BrandsService],
})
export class BrandsModule {}

View File

@@ -0,0 +1,183 @@
import {
Injectable,
BadRequestException,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { RedisService } from '../../redis/redis.service';
@Injectable()
export class BrandsService {
constructor(
private prisma: PrismaService,
private redisService: RedisService,
) {}
async getAllBrands() {
// Try cache first
const cached = await this.redisService.getJson<any[]>('brands:all');
if (cached) {
return { items: cached, total: cached.length };
}
const brands = await this.prisma.brand.findMany({
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
});
// Cache for 1 hour
await this.redisService.setJson('brands:all', brands, 3600);
return { items: brands, total: brands.length };
}
async getSelectedBrands(userId: string) {
const userBrands = await this.prisma.userBrand.findMany({
where: { userId },
include: { brand: true },
orderBy: { createdAt: 'asc' },
});
return {
items: userBrands.map((ub) => ({
id: ub.id,
brandId: ub.brandId,
brand: ub.brand,
createdAt: ub.createdAt,
})),
total: userBrands.length,
};
}
async selectBrands(userId: string, brandIds: string[]) {
// Get user's subscription and plan
const subscription = await this.prisma.userSubscription.findUnique({
where: { userId },
include: { plan: true },
});
if (!subscription || subscription.status !== 'ACTIVE') {
throw new ForbiddenException('Aktif aboneliginiz bulunmuyor');
}
// Check if user has full access
if (subscription.plan.hasFullAccess) {
throw new BadRequestException('Full pakette marka secimi gerekmez');
}
// Check brand limit
const currentBrands = await this.prisma.userBrand.count({
where: { userId },
});
const newBrandsCount = brandIds.length;
const totalBrands = currentBrands + newBrandsCount;
if (totalBrands > subscription.plan.brandLimit) {
throw new ForbiddenException(
`Paketiniz maksimum ${subscription.plan.brandLimit} marka secimini destekliyor. ` +
`Mevcut: ${currentBrands}, Eklenmek istenen: ${newBrandsCount}`,
);
}
// Verify all brands exist
const brands = await this.prisma.brand.findMany({
where: { id: { in: brandIds }, isActive: true },
});
if (brands.length !== brandIds.length) {
throw new BadRequestException('Bir veya daha fazla marka bulunamadi');
}
// Add brands
await this.prisma.userBrand.createMany({
data: brandIds.map((brandId) => ({ userId, brandId })),
skipDuplicates: true,
});
return this.getSelectedBrands(userId);
}
async updateSelectedBrands(userId: string, brandIds: string[]) {
// Get user's subscription and plan
const subscription = await this.prisma.userSubscription.findUnique({
where: { userId },
include: { plan: true },
});
if (!subscription || subscription.status !== 'ACTIVE') {
throw new ForbiddenException('Aktif aboneliginiz bulunmuyor');
}
if (subscription.plan.hasFullAccess) {
throw new BadRequestException('Full pakette marka secimi gerekmez');
}
// Check brand limit
if (brandIds.length > subscription.plan.brandLimit) {
throw new ForbiddenException(
`Paketiniz maksimum ${subscription.plan.brandLimit} marka secimini destekliyor`,
);
}
// Verify all brands exist
const brands = await this.prisma.brand.findMany({
where: { id: { in: brandIds }, isActive: true },
});
if (brands.length !== brandIds.length) {
throw new BadRequestException('Bir veya daha fazla marka bulunamadi');
}
// Replace all brands (transaction)
await this.prisma.$transaction([
this.prisma.userBrand.deleteMany({ where: { userId } }),
this.prisma.userBrand.createMany({
data: brandIds.map((brandId) => ({ userId, brandId })),
}),
]);
return this.getSelectedBrands(userId);
}
async removeBrand(userId: string, brandId: string) {
const userBrand = await this.prisma.userBrand.findFirst({
where: { userId, brandId },
});
if (!userBrand) {
throw new NotFoundException('Marka seciminizde bulunamadi');
}
await this.prisma.userBrand.delete({
where: { id: userBrand.id },
});
return { message: 'Marka secimden kaldirildi' };
}
async checkBrandAccess(userId: string, brandCode: string): Promise<boolean> {
const subscription = await this.prisma.userSubscription.findUnique({
where: { userId },
include: { plan: true },
});
if (!subscription || subscription.status !== 'ACTIVE') {
return false;
}
if (subscription.plan.hasFullAccess) {
return true;
}
const userBrand = await this.prisma.userBrand.findFirst({
where: {
userId,
brand: { code: brandCode },
},
});
return !!userBrand;
}
}

View File

@@ -0,0 +1,8 @@
import { IsArray, ArrayMinSize, IsString } from 'class-validator';
export class SelectBrandsDto {
@IsArray()
@ArrayMinSize(1, { message: 'En az bir marka secmelisiniz' })
@IsString({ each: true })
brandIds: string[];
}

View File

@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
// Categories are handled in PartsModule to avoid circular dependency
// This module is for potential future standalone category operations
@Module({})
export class CategoriesModule {}

View File

@@ -0,0 +1,27 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { CategoriesService } from './categories.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Public } from '../../common/decorators/public.decorator';
@Controller('categories')
export class CategoriesController {
constructor(private readonly categoriesService: CategoriesService) {}
@Public()
@Get()
async getAllCategories() {
return this.categoriesService.getCategoryTree();
}
@Public()
@Get(':id')
async getCategoryById(@Param('id') id: string) {
return this.categoriesService.getCategoryById(id);
}
@Public()
@Get(':id/children')
async getCategoryChildren(@Param('id') id: string) {
return this.categoriesService.getCategoryChildren(id);
}
}

View File

@@ -0,0 +1,76 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { RedisService } from '../../redis/redis.service';
@Injectable()
export class CategoriesService {
constructor(
private prisma: PrismaService,
private redisService: RedisService,
) {}
async getCategoryTree() {
// Try cache first
const cached = await this.redisService.getJson<any[]>('categories:tree');
if (cached) {
return { items: cached };
}
const categories = await this.prisma.category.findMany({
where: { isActive: true, parentId: null },
include: {
children: {
where: { isActive: true },
include: {
children: {
where: { isActive: true },
},
},
orderBy: { sortOrder: 'asc' },
},
},
orderBy: { sortOrder: 'asc' },
});
// Cache for 1 hour
await this.redisService.setJson('categories:tree', categories, 3600);
return { items: categories };
}
async getCategoryById(categoryId: string) {
const category = await this.prisma.category.findUnique({
where: { id: categoryId },
include: {
parent: true,
children: {
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
},
},
});
if (!category) {
throw new NotFoundException('Kategori bulunamadi');
}
return category;
}
async getCategoryChildren(categoryId: string) {
const parent = await this.prisma.category.findUnique({
where: { id: categoryId },
});
if (!parent) {
throw new NotFoundException('Kategori bulunamadi');
}
const children = await this.prisma.category.findMany({
where: { parentId: categoryId, isActive: true },
orderBy: { sortOrder: 'asc' },
});
return { items: children, total: children.length };
}
}

View File

@@ -0,0 +1,19 @@
import { IsOptional, IsString } from 'class-validator';
export class PartSearchDto {
@IsOptional()
@IsString()
oem?: string;
@IsOptional()
@IsString()
vehicleId?: string;
@IsOptional()
@IsString()
categoryId?: string;
@IsOptional()
@IsString()
search?: string;
}

View File

@@ -0,0 +1,37 @@
import {
Controller,
Get,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { PartsService } from './parts.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { BrandAccessGuard } from '../../common/guards/brand-access.guard';
import { PaginationDto } from '../../common/dto/pagination.dto';
import { PartSearchDto } from './dto/part-search.dto';
@Controller()
@UseGuards(JwtAuthGuard, BrandAccessGuard)
export class PartsController {
constructor(private readonly partsService: PartsService) {}
@Get('vehicles/:vehicleId/categories/:categoryId/parts')
async getCategoryParts(
@Param('vehicleId') vehicleId: string,
@Param('categoryId') categoryId: string,
@Query() pagination: PaginationDto,
) {
return this.partsService.getCategoryParts(vehicleId, categoryId, pagination);
}
@Get('parts/:id')
async getPartById(@Param('id') id: string) {
return this.partsService.getPartById(id);
}
@Get('parts/search')
async searchParts(@Query() dto: PartSearchDto, @Query() pagination: PaginationDto) {
return this.partsService.searchParts(dto, pagination);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PartsController } from './parts.controller';
import { PartsService } from './parts.service';
import { CategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
@Module({
controllers: [PartsController, CategoriesController],
providers: [PartsService, CategoriesService],
exports: [PartsService, CategoriesService],
})
export class PartsModule {}

View File

@@ -0,0 +1,110 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { PaginationDto, PaginatedResponseDto } from '../../common/dto/pagination.dto';
import { PartSearchDto } from './dto/part-search.dto';
@Injectable()
export class PartsService {
constructor(private prisma: PrismaService) {}
async getCategoryParts(
vehicleId: string,
categoryId: string,
pagination: PaginationDto,
) {
const { page = 1, limit = 20, sortBy = 'nameTr', sortOrder = 'asc' } = pagination;
const skip = (page - 1) * limit;
// Verify vehicle and category exist
const vehicleCategory = await this.prisma.vehicleCategory.findFirst({
where: { vehicleId, categoryId },
include: { category: true },
});
if (!vehicleCategory) {
throw new NotFoundException('Arac kategorisi bulunamadi');
}
const [parts, total] = await Promise.all([
this.prisma.part.findMany({
where: { vehicleId, categoryId },
skip,
take: limit,
orderBy: { [sortBy]: sortOrder },
}),
this.prisma.part.count({ where: { vehicleId, categoryId } }),
]);
return {
category: vehicleCategory.category,
parts: new PaginatedResponseDto(parts, total, page, limit),
};
}
async getPartById(partId: string) {
const part = await this.prisma.part.findUnique({
where: { id: partId },
include: {
category: true,
vehicle: {
include: { brand: true },
},
},
});
if (!part) {
throw new NotFoundException('Parca bulunamadi');
}
return part;
}
async searchParts(dto: PartSearchDto, pagination: PaginationDto) {
const { page = 1, limit = 20, sortBy = 'nameTr', sortOrder = 'asc' } = pagination;
const skip = (page - 1) * limit;
const where: Prisma.PartWhereInput = {};
if (dto.oem) {
where.OR = [
{ oemCode: { contains: dto.oem } },
{ oemCodes: { array_contains: dto.oem } },
];
}
if (dto.vehicleId) {
where.vehicleId = dto.vehicleId;
}
if (dto.categoryId) {
where.categoryId = dto.categoryId;
}
if (dto.search) {
where.OR = [
...(where.OR || []),
{ nameTr: { contains: dto.search } },
{ nameEn: { contains: dto.search } },
];
}
const [parts, total] = await Promise.all([
this.prisma.part.findMany({
where,
include: {
category: true,
vehicle: {
include: { brand: true },
},
},
skip,
take: limit,
orderBy: { [sortBy]: sortOrder },
}),
this.prisma.part.count({ where }),
]);
return new PaginatedResponseDto(parts, total, page, limit);
}
}

View File

@@ -0,0 +1,85 @@
import {
IsString,
IsArray,
IsOptional,
ValidateNested,
IsNotEmpty,
Matches,
Length,
} from 'class-validator';
import { Type } from 'class-transformer';
export class CardDto {
@IsString()
@IsNotEmpty({ message: 'Kart sahibi adi gerekli' })
cardHolderName: string;
@IsString()
@Matches(/^\d{16}$/, { message: 'Kart numarasi 16 haneli olmalidir' })
cardNumber: string;
@IsString()
@Matches(/^(0[1-9]|1[0-2])$/, { message: 'Gecersiz ay' })
expireMonth: string;
@IsString()
@Matches(/^\d{2}$/, { message: 'Gecersiz yil' })
expireYear: string;
@IsString()
@Matches(/^\d{3,4}$/, { message: 'Gecersiz CVC' })
cvc: string;
}
export class BuyerDto {
@IsString()
@IsNotEmpty({ message: 'Ad gerekli' })
name: string;
@IsString()
@IsNotEmpty({ message: 'Soyad gerekli' })
surname: string;
@IsString()
@Matches(/^(\+90|0)?[0-9]{10}$/, { message: 'Gecersiz telefon numarasi' })
phone: string;
@IsString()
@Matches(/^[1-9][0-9]{10}$/, { message: 'Gecersiz TC Kimlik numarasi' })
identityNumber: string;
@IsString()
@IsNotEmpty({ message: 'Email gerekli' })
email: string;
@IsString()
@Length(10, 200, { message: 'Adres en az 10 karakter olmalidir' })
address: string;
@IsString()
@IsNotEmpty({ message: 'Sehir gerekli' })
city: string;
@IsOptional()
@IsString()
country?: string = 'Turkey';
}
export class InitializePaymentDto {
@IsString()
@IsNotEmpty()
planId: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
brandIds?: string[];
@ValidateNested()
@Type(() => CardDto)
card: CardDto;
@ValidateNested()
@Type(() => BuyerDto)
buyer: BuyerDto;
}

View File

@@ -0,0 +1,11 @@
import { IsString, IsNotEmpty } from 'class-validator';
export class PaymentCallbackDto {
@IsString()
@IsNotEmpty()
token: string;
@IsString()
@IsNotEmpty()
conversationId: string;
}

View File

@@ -0,0 +1,186 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import * as crypto from 'crypto';
interface IyzicoPaymentRequest {
conversationId: string;
price: number;
paidPrice: number;
currency: string;
basketId: string;
paymentCard: {
cardHolderName: string;
cardNumber: string;
expireMonth: string;
expireYear: string;
cvc: string;
};
buyer: {
id: string;
name: string;
surname: string;
phone: string;
email: string;
identityNumber: string;
address: string;
city: string;
country: string;
};
basketItems: {
id: string;
name: string;
category1: string;
itemType: string;
price: number;
}[];
}
@Injectable()
export class IyzicoService {
private readonly logger = new Logger(IyzicoService.name);
private readonly apiKey: string;
private readonly secretKey: string;
private readonly baseUrl: string;
private readonly callbackUrl: string;
constructor(private configService: ConfigService) {
this.apiKey = this.configService.get<string>('IYZICO_API_KEY', '');
this.secretKey = this.configService.get<string>('IYZICO_SECRET_KEY', '');
this.baseUrl = this.configService.get<string>(
'IYZICO_BASE_URL',
'https://sandbox-api.iyzipay.com',
);
this.callbackUrl = this.configService.get<string>(
'IYZICO_CALLBACK_URL',
'http://localhost:3000/api/payments/callback',
);
}
async initialize3DSecure(request: IyzicoPaymentRequest) {
const requestData = {
locale: 'tr',
conversationId: request.conversationId,
price: request.price.toFixed(2),
paidPrice: request.paidPrice.toFixed(2),
currency: request.currency,
installment: 1,
basketId: request.basketId,
paymentChannel: 'WEB',
paymentGroup: 'SUBSCRIPTION',
callbackUrl: this.callbackUrl,
paymentCard: {
cardHolderName: request.paymentCard.cardHolderName,
cardNumber: request.paymentCard.cardNumber.replace(/\s/g, ''),
expireMonth: request.paymentCard.expireMonth,
expireYear: request.paymentCard.expireYear,
cvc: request.paymentCard.cvc,
registerCard: 0,
},
buyer: {
id: request.buyer.id,
name: request.buyer.name,
surname: request.buyer.surname,
gsmNumber: request.buyer.phone,
email: request.buyer.email,
identityNumber: request.buyer.identityNumber,
registrationAddress: request.buyer.address,
city: request.buyer.city,
country: request.buyer.country,
ip: '127.0.0.1',
},
shippingAddress: {
contactName: `${request.buyer.name} ${request.buyer.surname}`,
city: request.buyer.city,
country: request.buyer.country,
address: request.buyer.address,
},
billingAddress: {
contactName: `${request.buyer.name} ${request.buyer.surname}`,
city: request.buyer.city,
country: request.buyer.country,
address: request.buyer.address,
},
basketItems: request.basketItems.map((item) => ({
id: item.id,
name: item.name,
category1: item.category1,
itemType: item.itemType,
price: item.price.toFixed(2),
})),
};
try {
const response = await this.makeRequest(
'/payment/3dsecure/initialize',
requestData,
);
return {
status: response.status === 'success' ? 'success' : 'error',
threeDSHtmlContent: response.threeDSHtmlContent,
errorMessage: response.errorMessage,
};
} catch (error) {
this.logger.error('iyzico 3D secure initialization failed', error);
return {
status: 'error',
errorMessage: 'Odeme sistemiyle baglanti kurulamadi',
};
}
}
async verifyCallback(token: string, conversationId: string) {
const requestData = {
locale: 'tr',
conversationId,
paymentId: token,
};
try {
const response = await this.makeRequest(
'/payment/3dsecure/auth',
requestData,
);
return {
status: response.status === 'success' ? 'success' : 'error',
paymentId: response.paymentId,
errorMessage: response.errorMessage,
...response,
};
} catch (error) {
this.logger.error('iyzico callback verification failed', error);
return {
status: 'error',
errorMessage: 'Odeme dogrulanamadi',
};
}
}
private async makeRequest(path: string, data: Record<string, unknown>) {
const randomKey = this.generateRandomKey();
const jsonData = JSON.stringify(data);
const authString = this.generateAuthString(jsonData, randomKey);
const response = await axios.post(`${this.baseUrl}${path}`, data, {
headers: {
'Content-Type': 'application/json',
Authorization: authString,
'x-iyzi-rnd': randomKey,
},
});
return response.data;
}
private generateRandomKey(): string {
return crypto.randomBytes(8).toString('hex');
}
private generateAuthString(jsonData: string, randomKey: string): string {
const hashString = this.apiKey + randomKey + this.secretKey + jsonData;
const hash = crypto.createHash('sha1').update(hashString).digest('base64');
return `IYZWS ${this.apiKey}:${hash}`;
}
}

View File

@@ -0,0 +1,63 @@
import {
Controller,
Get,
Post,
Param,
Body,
Query,
UseGuards,
} from '@nestjs/common';
import { PaymentsService } from './payments.service';
import { InitializePaymentDto } from './dto/initialize-payment.dto';
import { PaymentCallbackDto } from './dto/payment-callback.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Public } from '../../common/decorators/public.decorator';
import { CurrentUser, CurrentUserData } from '../../common/decorators/current-user.decorator';
import { PaginationDto } from '../../common/dto/pagination.dto';
@Controller('payments')
export class PaymentsController {
constructor(private readonly paymentsService: PaymentsService) {}
@Post('initialize')
@UseGuards(JwtAuthGuard)
async initializePayment(
@CurrentUser() user: CurrentUserData,
@Body() dto: InitializePaymentDto,
) {
return this.paymentsService.initializePayment(user.id, dto);
}
@Public()
@Post('callback')
async handleCallback(@Body() dto: PaymentCallbackDto) {
return this.paymentsService.handleCallback(dto);
}
@Get('history')
@UseGuards(JwtAuthGuard)
async getPaymentHistory(
@CurrentUser() user: CurrentUserData,
@Query() pagination: PaginationDto,
) {
return this.paymentsService.getPaymentHistory(user.id, pagination);
}
@Get(':id')
@UseGuards(JwtAuthGuard)
async getPayment(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.paymentsService.getPaymentById(user.id, id);
}
@Get(':id/invoice')
@UseGuards(JwtAuthGuard)
async getInvoice(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.paymentsService.getInvoice(user.id, id);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
import { IyzicoService } from './iyzico.service';
@Module({
controllers: [PaymentsController],
providers: [PaymentsService, IyzicoService],
exports: [PaymentsService],
})
export class PaymentsModule {}

View File

@@ -0,0 +1,283 @@
import {
Injectable,
NotFoundException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { IyzicoService } from './iyzico.service';
import { InitializePaymentDto } from './dto/initialize-payment.dto';
import { PaymentCallbackDto } from './dto/payment-callback.dto';
import { PaginationDto, PaginatedResponseDto } from '../../common/dto/pagination.dto';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name);
constructor(
private prisma: PrismaService,
private iyzicoService: IyzicoService,
) {}
async initializePayment(userId: string, dto: InitializePaymentDto) {
// Get plan
const plan = await this.prisma.plan.findUnique({
where: { id: dto.planId },
});
if (!plan || !plan.isActive) {
throw new NotFoundException('Paket bulunamadi');
}
// Validate brands
if (!plan.hasFullAccess) {
if (!dto.brandIds || dto.brandIds.length === 0) {
throw new BadRequestException('Lutfen marka seciniz');
}
if (dto.brandIds.length > plan.brandLimit) {
throw new BadRequestException(
`Bu paket maksimum ${plan.brandLimit} marka secimini destekliyor`,
);
}
}
// Get user
const user = await this.prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new NotFoundException('Kullanici bulunamadi');
}
// Get or create subscription
let subscription = await this.prisma.userSubscription.findUnique({
where: { userId },
});
if (!subscription) {
const now = new Date();
const periodEnd = new Date(now);
periodEnd.setDate(periodEnd.getDate() + plan.durationDays);
subscription = await this.prisma.userSubscription.create({
data: {
userId,
planId: plan.id,
status: 'PENDING',
currentPeriodStart: now,
currentPeriodEnd: periodEnd,
},
});
}
// Create payment record
const conversationId = uuidv4();
const payment = await this.prisma.payment.create({
data: {
subscriptionId: subscription.id,
amount: plan.price,
currency: plan.currency,
status: 'PENDING',
provider: 'iyzico',
providerData: {
conversationId,
planId: dto.planId,
brandIds: dto.brandIds,
},
},
});
// Initialize iyzico payment
try {
const result = await this.iyzicoService.initialize3DSecure({
conversationId,
price: Number(plan.price),
paidPrice: Number(plan.price),
currency: plan.currency,
basketId: payment.id,
paymentCard: dto.card,
buyer: {
name: dto.buyer.name,
surname: dto.buyer.surname,
phone: dto.buyer.phone,
identityNumber: dto.buyer.identityNumber,
address: dto.buyer.address,
city: dto.buyer.city,
country: dto.buyer.country || 'Turkey',
id: userId,
email: user.email,
},
basketItems: [
{
id: plan.id,
name: `Sase.tr ${plan.name} Paketi`,
category1: 'Abonelik',
itemType: 'VIRTUAL',
price: Number(plan.price),
},
],
});
if (result.status === 'success') {
await this.prisma.payment.update({
where: { id: payment.id },
data: {
status: 'PROCESSING',
providerData: {
...((payment.providerData as object) || {}),
threeDSHtmlContent: result.threeDSHtmlContent,
},
},
});
return {
status: 'success',
htmlContent: result.threeDSHtmlContent,
paymentId: payment.id,
conversationId,
};
} else {
await this.prisma.payment.update({
where: { id: payment.id },
data: {
status: 'FAILED',
failureReason: result.errorMessage,
},
});
throw new BadRequestException(result.errorMessage || 'Odeme baslatma basarisiz');
}
} catch (error) {
this.logger.error('Payment initialization failed', error);
throw error;
}
}
async handleCallback(dto: PaymentCallbackDto) {
// Verify callback with iyzico
const result = await this.iyzicoService.verifyCallback(dto.token, dto.conversationId);
// Find payment by conversationId in providerData JSON
const payments = await this.prisma.payment.findMany({
where: {
status: 'PROCESSING',
},
include: {
subscription: true,
},
});
// Filter by conversationId in providerData
const payment = payments.find((p) => {
const data = p.providerData as any;
return data?.conversationId === dto.conversationId;
});
if (!payment) {
this.logger.error(`Payment not found for conversationId: ${dto.conversationId}`);
return { status: 'error', message: 'Odeme bulunamadi' };
}
const subscription = payment.subscription;
if (result.status === 'success') {
// Update payment
await this.prisma.payment.update({
where: { id: payment.id },
data: {
status: 'COMPLETED',
providerTxId: result.paymentId,
providerData: result,
},
});
// Activate subscription
const providerData = payment.providerData as any;
const brandIds = providerData?.brandIds || [];
await this.prisma.$transaction(async (tx) => {
await tx.userSubscription.update({
where: { id: payment.subscriptionId },
data: {
status: 'ACTIVE',
planId: providerData?.planId || subscription.planId,
},
});
// Update brands if not full access
if (brandIds.length > 0) {
await tx.userBrand.deleteMany({
where: { userId: subscription.userId },
});
await tx.userBrand.createMany({
data: brandIds.map((brandId: string) => ({
userId: subscription.userId,
brandId,
})),
});
}
});
return { status: 'success', message: 'Odeme basarili' };
} else {
await this.prisma.payment.update({
where: { id: payment.id },
data: {
status: 'FAILED',
failureReason: result.errorMessage,
providerData: result,
},
});
return { status: 'error', message: result.errorMessage || 'Odeme basarisiz' };
}
}
async getPaymentHistory(userId: string, pagination: PaginationDto) {
const { page = 1, limit = 20, sortOrder = 'desc' } = pagination;
const skip = (page - 1) * limit;
const [payments, total] = await Promise.all([
this.prisma.payment.findMany({
where: {
subscription: { userId },
},
skip,
take: limit,
orderBy: { createdAt: sortOrder },
}),
this.prisma.payment.count({
where: { subscription: { userId } },
}),
]);
return new PaginatedResponseDto(payments, total, page, limit);
}
async getPaymentById(userId: string, paymentId: string) {
const payment = await this.prisma.payment.findFirst({
where: {
id: paymentId,
subscription: { userId },
},
});
if (!payment) {
throw new NotFoundException('Odeme bulunamadi');
}
return payment;
}
async getInvoice(userId: string, paymentId: string) {
const payment = await this.getPaymentById(userId, paymentId);
if (!payment.invoiceUrl) {
throw new NotFoundException('Fatura bulunamadi');
}
return { invoiceUrl: payment.invoiceUrl };
}
}

View File

@@ -0,0 +1,11 @@
import { IsString, IsArray, IsOptional, ArrayMinSize } from 'class-validator';
export class CreateSubscriptionDto {
@IsString()
planId: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
brandIds?: string[];
}

View File

@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { RedisService } from '../../redis/redis.service';
@Injectable()
export class PlansService {
constructor(
private prisma: PrismaService,
private redisService: RedisService,
) {}
async getAllPlans() {
// Try cache first
const cached = await this.redisService.getJson<any[]>('plans:all');
if (cached) {
return { items: cached };
}
const plans = await this.prisma.plan.findMany({
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
});
// Cache for 1 hour
await this.redisService.setJson('plans:all', plans, 3600);
return { items: plans };
}
async getPlanById(planId: string) {
return this.prisma.plan.findUnique({
where: { id: planId },
});
}
async getPlanBySlug(slug: string) {
return this.prisma.plan.findUnique({
where: { slug },
});
}
}

View File

@@ -0,0 +1,72 @@
import {
Controller,
Get,
Post,
Patch,
Param,
Body,
UseGuards,
} from '@nestjs/common';
import { SubscriptionsService } from './subscriptions.service';
import { PlansService } from './plans.service';
import { CreateSubscriptionDto } from './dto/create-subscription.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Public } from '../../common/decorators/public.decorator';
import { CurrentUser, CurrentUserData } from '../../common/decorators/current-user.decorator';
@Controller('subscriptions')
export class SubscriptionsController {
constructor(
private readonly subscriptionsService: SubscriptionsService,
private readonly plansService: PlansService,
) {}
@Public()
@Get('plans')
async getPlans() {
return this.plansService.getAllPlans();
}
@Get('current')
@UseGuards(JwtAuthGuard)
async getCurrentSubscription(@CurrentUser() user: CurrentUserData) {
return this.subscriptionsService.getCurrentSubscription(user.id);
}
@Post()
@UseGuards(JwtAuthGuard)
async createSubscription(
@CurrentUser() user: CurrentUserData,
@Body() dto: CreateSubscriptionDto,
) {
return this.subscriptionsService.createSubscription(user.id, dto);
}
@Patch(':id')
@UseGuards(JwtAuthGuard)
async updateSubscription(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: CreateSubscriptionDto,
) {
return this.subscriptionsService.updateSubscription(user.id, id, dto);
}
@Post(':id/cancel')
@UseGuards(JwtAuthGuard)
async cancelSubscription(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.subscriptionsService.cancelSubscription(user.id, id);
}
@Post(':id/resume')
@UseGuards(JwtAuthGuard)
async resumeSubscription(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.subscriptionsService.resumeSubscription(user.id, id);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { SubscriptionsController } from './subscriptions.controller';
import { SubscriptionsService } from './subscriptions.service';
import { PlansService } from './plans.service';
@Module({
controllers: [SubscriptionsController],
providers: [SubscriptionsService, PlansService],
exports: [SubscriptionsService, PlansService],
})
export class SubscriptionsModule {}

View File

@@ -0,0 +1,228 @@
import {
Injectable,
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateSubscriptionDto } from './dto/create-subscription.dto';
@Injectable()
export class SubscriptionsService {
constructor(private prisma: PrismaService) {}
async getCurrentSubscription(userId: string) {
const subscription = await this.prisma.userSubscription.findUnique({
where: { userId },
include: {
plan: true,
},
});
if (!subscription) {
return null;
}
const selectedBrands = await this.prisma.userBrand.findMany({
where: { userId },
include: { brand: true },
});
return {
subscription,
selectedBrands: selectedBrands.map((ub) => ({
id: ub.brand.id,
code: ub.brand.code,
name: ub.brand.name,
})),
};
}
async createSubscription(userId: string, dto: CreateSubscriptionDto) {
// Check if user already has active subscription
const existing = await this.prisma.userSubscription.findUnique({
where: { userId },
});
if (existing && existing.status === 'ACTIVE') {
throw new BadRequestException('Zaten aktif bir aboneliginiz var');
}
// Get plan
const plan = await this.prisma.plan.findUnique({
where: { id: dto.planId },
});
if (!plan || !plan.isActive) {
throw new NotFoundException('Paket bulunamadi');
}
// Validate brand selection
if (!plan.hasFullAccess) {
if (!dto.brandIds || dto.brandIds.length === 0) {
throw new BadRequestException('Lutfen marka seciniz');
}
if (dto.brandIds.length > plan.brandLimit) {
throw new BadRequestException(
`Bu paket maksimum ${plan.brandLimit} marka secimini destekliyor`,
);
}
// Verify brands exist
const brands = await this.prisma.brand.findMany({
where: { id: { in: dto.brandIds }, isActive: true },
});
if (brands.length !== dto.brandIds.length) {
throw new BadRequestException('Bir veya daha fazla marka bulunamadi');
}
}
// Calculate period
const now = new Date();
const periodEnd = new Date(now);
periodEnd.setDate(periodEnd.getDate() + plan.durationDays);
// Create subscription and brand selections in transaction
const result = await this.prisma.$transaction(async (tx) => {
// Create or update subscription
const subscription = existing
? await tx.userSubscription.update({
where: { userId },
data: {
planId: plan.id,
status: 'ACTIVE',
currentPeriodStart: now,
currentPeriodEnd: periodEnd,
cancelAtPeriodEnd: false,
cancelledAt: null,
},
include: { plan: true },
})
: await tx.userSubscription.create({
data: {
userId,
planId: plan.id,
status: 'ACTIVE',
currentPeriodStart: now,
currentPeriodEnd: periodEnd,
},
include: { plan: true },
});
// Update brand selections (only for non-full plans)
if (!plan.hasFullAccess && dto.brandIds) {
await tx.userBrand.deleteMany({ where: { userId } });
await tx.userBrand.createMany({
data: dto.brandIds.map((brandId) => ({ userId, brandId })),
});
}
return subscription;
});
// Get selected brands
const selectedBrands = await this.prisma.userBrand.findMany({
where: { userId },
include: { brand: true },
});
return {
subscription: result,
selectedBrands: selectedBrands.map((ub) => ({
id: ub.brand.id,
code: ub.brand.code,
name: ub.brand.name,
})),
};
}
async updateSubscription(userId: string, subscriptionId: string, dto: CreateSubscriptionDto) {
const subscription = await this.prisma.userSubscription.findFirst({
where: { id: subscriptionId, userId },
});
if (!subscription) {
throw new NotFoundException('Abonelik bulunamadi');
}
// Get new plan
const plan = await this.prisma.plan.findUnique({
where: { id: dto.planId },
});
if (!plan || !plan.isActive) {
throw new NotFoundException('Paket bulunamadi');
}
// Validate brand selection for non-full plans
if (!plan.hasFullAccess && dto.brandIds) {
if (dto.brandIds.length > plan.brandLimit) {
throw new BadRequestException(
`Bu paket maksimum ${plan.brandLimit} marka secimini destekliyor`,
);
}
}
// Update subscription
const updated = await this.prisma.$transaction(async (tx) => {
const result = await tx.userSubscription.update({
where: { id: subscriptionId },
data: { planId: plan.id },
include: { plan: true },
});
if (!plan.hasFullAccess && dto.brandIds) {
await tx.userBrand.deleteMany({ where: { userId } });
await tx.userBrand.createMany({
data: dto.brandIds.map((brandId) => ({ userId, brandId })),
});
}
return result;
});
return this.getCurrentSubscription(userId);
}
async cancelSubscription(userId: string, subscriptionId: string) {
const subscription = await this.prisma.userSubscription.findFirst({
where: { id: subscriptionId, userId, status: 'ACTIVE' },
});
if (!subscription) {
throw new NotFoundException('Aktif abonelik bulunamadi');
}
await this.prisma.userSubscription.update({
where: { id: subscriptionId },
data: {
cancelAtPeriodEnd: true,
cancelledAt: new Date(),
},
});
return { message: 'Abonelik donem sonunda iptal edilecek' };
}
async resumeSubscription(userId: string, subscriptionId: string) {
const subscription = await this.prisma.userSubscription.findFirst({
where: { id: subscriptionId, userId, cancelAtPeriodEnd: true },
});
if (!subscription) {
throw new NotFoundException('Iptal edilmis abonelik bulunamadi');
}
await this.prisma.userSubscription.update({
where: { id: subscriptionId },
data: {
cancelAtPeriodEnd: false,
cancelledAt: null,
},
});
return { message: 'Abonelik iptal istegi geri alindi' };
}
}

View File

@@ -0,0 +1,405 @@
/**
* Comprehensive Turkish translations for automotive terms
* Bidirectional mapping: English <-> Turkish
*/
export interface TranslationEntry {
en: string;
tr: string;
aliases?: string[];
}
export interface TranslationCategory {
[key: string]: TranslationEntry;
}
/**
* Engine Types - Motor Tipleri
*/
export const ENGINE_TYPES: TranslationCategory = {
petrol: { en: 'Petrol', tr: 'Benzin', aliases: ['gasoline', 'gas'] },
diesel: { en: 'Diesel', tr: 'Dizel' },
electric: { en: 'Electric', tr: 'Elektrik', aliases: ['ev', 'bev'] },
hybrid: { en: 'Hybrid', tr: 'Hibrit' },
plug_in_hybrid: { en: 'Plug-in Hybrid', tr: 'Plug-in Hibrit', aliases: ['phev'] },
mild_hybrid: { en: 'Mild Hybrid', tr: 'Hafif Hibrit', aliases: ['mhev'] },
hydrogen: { en: 'Hydrogen', tr: 'Hidrojen', aliases: ['fcev', 'fuel cell'] },
lpg: { en: 'LPG', tr: 'LPG', aliases: ['autogas'] },
cng: { en: 'CNG', tr: 'CNG', aliases: ['natural gas'] },
turbo_petrol: { en: 'Turbo Petrol', tr: 'Turbo Benzin' },
turbo_diesel: { en: 'Turbo Diesel', tr: 'Turbo Dizel', aliases: ['tdi'] },
biturbo: { en: 'Bi-Turbo', tr: 'Bi-Turbo', aliases: ['twin turbo'] },
};
/**
* Transmission Types - Sanziman Tipleri
*/
export const TRANSMISSION_TYPES: TranslationCategory = {
manual: { en: 'Manual', tr: 'Manuel', aliases: ['mt', 'stick shift'] },
automatic: { en: 'Automatic', tr: 'Otomatik', aliases: ['at', 'auto'] },
semi_automatic: { en: 'Semi-Automatic', tr: 'Yari Otomatik', aliases: ['automated manual'] },
cvt: { en: 'CVT', tr: 'CVT', aliases: ['continuously variable'] },
dct: { en: 'DCT', tr: 'DCT', aliases: ['dual clutch', 'dsg', 'pdk', 's tronic'] },
tiptronic: { en: 'Tiptronic', tr: 'Tiptronic' },
sequential: { en: 'Sequential', tr: 'Sirali', aliases: ['smg'] },
robotized: { en: 'Robotized', tr: 'Robotize' },
single_speed: { en: 'Single Speed', tr: 'Tek Vites' },
'5_speed': { en: '5-Speed', tr: '5 Ileri' },
'6_speed': { en: '6-Speed', tr: '6 Ileri' },
'7_speed': { en: '7-Speed', tr: '7 Ileri' },
'8_speed': { en: '8-Speed', tr: '8 Ileri' },
'9_speed': { en: '9-Speed', tr: '9 Ileri' },
'10_speed': { en: '10-Speed', tr: '10 Ileri' },
};
/**
* Drive Types - Cekis Tipleri
*/
export const DRIVE_TYPES: TranslationCategory = {
fwd: { en: 'Front-Wheel Drive', tr: 'On Ceker', aliases: ['front wheel drive', 'ff'] },
rwd: { en: 'Rear-Wheel Drive', tr: 'Arka Ceker', aliases: ['rear wheel drive', 'fr'] },
awd: { en: 'All-Wheel Drive', tr: '4x4', aliases: ['4wd', 'four wheel drive', '4x4'] },
'4wd': { en: '4WD', tr: '4 Ceker', aliases: ['four wheel drive'] },
'4x4': { en: '4x4', tr: '4x4' },
quattro: { en: 'Quattro', tr: 'Quattro' },
xdrive: { en: 'xDrive', tr: 'xDrive' },
'4matic': { en: '4MATIC', tr: '4MATIC' },
sline: { en: 'S-Line', tr: 'S-Line' },
};
/**
* Body Types - Kasa Tipleri
*/
export const BODY_TYPES: TranslationCategory = {
sedan: { en: 'Sedan', tr: 'Sedan', aliases: ['saloon'] },
hatchback: { en: 'Hatchback', tr: 'Hatchback', aliases: ['hatch'] },
suv: { en: 'SUV', tr: 'SUV', aliases: ['sport utility vehicle'] },
crossover: { en: 'Crossover', tr: 'Crossover', aliases: ['cuv'] },
coupe: { en: 'Coupe', tr: 'Coupe', aliases: ['coupé'] },
cabrio: { en: 'Cabriolet', tr: 'Cabrio', aliases: ['convertible', 'roadster', 'cabriolet'] },
station_wagon: { en: 'Station Wagon', tr: 'Station Wagon', aliases: ['estate', 'wagon', 'kombi', 'touring'] },
mpv: { en: 'MPV', tr: 'MPV', aliases: ['minivan', 'people carrier'] },
pickup: { en: 'Pickup', tr: 'Pikap', aliases: ['truck', 'pick-up'] },
van: { en: 'Van', tr: 'Van', aliases: ['panel van'] },
minibus: { en: 'Minibus', tr: 'Minibus' },
limousine: { en: 'Limousine', tr: 'Limuzin' },
sports_car: { en: 'Sports Car', tr: 'Spor Araba' },
grand_tourer: { en: 'Grand Tourer', tr: 'Gran Turismo', aliases: ['gt'] },
targa: { en: 'Targa', tr: 'Targa' },
shooting_brake: { en: 'Shooting Brake', tr: 'Shooting Brake' },
fastback: { en: 'Fastback', tr: 'Fastback' },
liftback: { en: 'Liftback', tr: 'Liftback' },
};
/**
* Part Categories - Parca Kategorileri
*/
export const PART_CATEGORIES: TranslationCategory = {
engine: { en: 'Engine', tr: 'Motor' },
engine_parts: { en: 'Engine Parts', tr: 'Motor Parcalari' },
transmission: { en: 'Transmission', tr: 'Sanziman' },
transmission_parts: { en: 'Transmission Parts', tr: 'Sanziman Parcalari' },
brake_system: { en: 'Brake System', tr: 'Fren Sistemi' },
suspension: { en: 'Suspension', tr: 'Suspansiyon', aliases: ['chassis'] },
steering: { en: 'Steering', tr: 'Direksiyon' },
electrical: { en: 'Electrical', tr: 'Elektrik', aliases: ['electronics'] },
body: { en: 'Body', tr: 'Kaporta', aliases: ['bodywork'] },
interior: { en: 'Interior', tr: 'Ic Mekan' },
exterior: { en: 'Exterior', tr: 'Dis Mekan' },
lighting: { en: 'Lighting', tr: 'Aydinlatma' },
cooling_system: { en: 'Cooling System', tr: 'Sogutma Sistemi' },
heating_ac: { en: 'Heating & AC', tr: 'Isitma ve Klima', aliases: ['hvac', 'climate control'] },
exhaust: { en: 'Exhaust', tr: 'Egzoz' },
fuel_system: { en: 'Fuel System', tr: 'Yakit Sistemi' },
air_intake: { en: 'Air Intake', tr: 'Hava Emis Sistemi' },
clutch: { en: 'Clutch', tr: 'Debriyaj' },
drivetrain: { en: 'Drivetrain', tr: 'Aktarma Organlari' },
axles: { en: 'Axles', tr: 'Akslar' },
wheels_tires: { en: 'Wheels & Tires', tr: 'Jant ve Lastikler' },
filters: { en: 'Filters', tr: 'Filtreler' },
belts_chains: { en: 'Belts & Chains', tr: 'Kayislar ve Zincirler' },
gaskets_seals: { en: 'Gaskets & Seals', tr: 'Contalar ve Keceler' },
sensors: { en: 'Sensors', tr: 'Sensorler' },
airbags: { en: 'Airbags', tr: 'Hava Yastiği' },
safety: { en: 'Safety', tr: 'Guvenlik' },
accessories: { en: 'Accessories', tr: 'Aksesuarlar' },
fluids: { en: 'Fluids', tr: 'Sivilar' },
mirrors: { en: 'Mirrors', tr: 'Aynalar' },
windows: { en: 'Windows', tr: 'Camlar' },
doors: { en: 'Doors', tr: 'Kapilar' },
seats: { en: 'Seats', tr: 'Koltuklar' },
dashboard: { en: 'Dashboard', tr: 'Gosterge Paneli' },
infotainment: { en: 'Infotainment', tr: 'Multimedya Sistemi' },
};
/**
* Common Part Names - Yaygin Parca Isimleri
*/
export const COMMON_PARTS: TranslationCategory = {
// Filters - Filtreler
oil_filter: { en: 'Oil Filter', tr: 'Yag Filtresi' },
air_filter: { en: 'Air Filter', tr: 'Hava Filtresi' },
fuel_filter: { en: 'Fuel Filter', tr: 'Yakit Filtresi' },
cabin_filter: { en: 'Cabin Filter', tr: 'Polen Filtresi', aliases: ['pollen filter'] },
transmission_filter: { en: 'Transmission Filter', tr: 'Sanziman Filtresi' },
hydraulic_filter: { en: 'Hydraulic Filter', tr: 'Hidrolik Filtre' },
// Brake Parts - Fren Parcalari
brake_pad: { en: 'Brake Pad', tr: 'Fren Balatasi' },
brake_disc: { en: 'Brake Disc', tr: 'Fren Diski', aliases: ['brake rotor'] },
brake_drum: { en: 'Brake Drum', tr: 'Fren Kampanasi' },
brake_caliper: { en: 'Brake Caliper', tr: 'Fren Kaliperi' },
brake_shoe: { en: 'Brake Shoe', tr: 'Fren Pabucu' },
brake_hose: { en: 'Brake Hose', tr: 'Fren Hortumu' },
brake_line: { en: 'Brake Line', tr: 'Fren Borusu' },
brake_master_cylinder: { en: 'Brake Master Cylinder', tr: 'Fren Ana Merkezi' },
abs_sensor: { en: 'ABS Sensor', tr: 'ABS Sensoru' },
abs_module: { en: 'ABS Module', tr: 'ABS Beyni' },
handbrake_cable: { en: 'Handbrake Cable', tr: 'El Freni Teli' },
// Engine Parts - Motor Parcalari
spark_plug: { en: 'Spark Plug', tr: 'Buji' },
glow_plug: { en: 'Glow Plug', tr: 'Kizdirma Bujisi' },
ignition_coil: { en: 'Ignition Coil', tr: 'Ates Bobini' },
timing_belt: { en: 'Timing Belt', tr: 'Eksantrik Kayisi', aliases: ['cam belt'] },
timing_chain: { en: 'Timing Chain', tr: 'Eksantrik Zinciri' },
serpentine_belt: { en: 'Serpentine Belt', tr: 'V Kayisi', aliases: ['drive belt', 'v-belt'] },
water_pump: { en: 'Water Pump', tr: 'Su Pompasi' },
oil_pump: { en: 'Oil Pump', tr: 'Yag Pompasi' },
fuel_pump: { en: 'Fuel Pump', tr: 'Yakit Pompasi' },
thermostat: { en: 'Thermostat', tr: 'Termostat' },
radiator: { en: 'Radiator', tr: 'Radyator' },
radiator_hose: { en: 'Radiator Hose', tr: 'Radyator Hortumu' },
engine_mount: { en: 'Engine Mount', tr: 'Motor Takozu' },
piston: { en: 'Piston', tr: 'Piston' },
piston_ring: { en: 'Piston Ring', tr: 'Segman' },
connecting_rod: { en: 'Connecting Rod', tr: 'Biyel Kolu' },
crankshaft: { en: 'Crankshaft', tr: 'Krank Mili' },
camshaft: { en: 'Camshaft', tr: 'Eksantrik Mili' },
cylinder_head: { en: 'Cylinder Head', tr: 'Silindir Kapagi' },
head_gasket: { en: 'Head Gasket', tr: 'Silindir Kapak Contasi' },
valve: { en: 'Valve', tr: 'Supap' },
valve_cover: { en: 'Valve Cover', tr: 'Supap Kapagi' },
valve_cover_gasket: { en: 'Valve Cover Gasket', tr: 'Supap Kapak Contasi' },
oil_pan: { en: 'Oil Pan', tr: 'Karter' },
oil_pan_gasket: { en: 'Oil Pan Gasket', tr: 'Karter Contasi' },
intake_manifold: { en: 'Intake Manifold', tr: 'Emme Manifoldu' },
exhaust_manifold: { en: 'Exhaust Manifold', tr: 'Egzoz Manifoldu' },
turbocharger: { en: 'Turbocharger', tr: 'Turbo', aliases: ['turbo'] },
supercharger: { en: 'Supercharger', tr: 'Kompressor' },
intercooler: { en: 'Intercooler', tr: 'Ara Sogutucusu' },
egr_valve: { en: 'EGR Valve', tr: 'EGR Valfi' },
throttle_body: { en: 'Throttle Body', tr: 'Gaz Kelbeği' },
mass_air_flow: { en: 'Mass Air Flow Sensor', tr: 'Hava Akis Olcer', aliases: ['maf sensor'] },
oxygen_sensor: { en: 'Oxygen Sensor', tr: 'Oksijen Sensoru', aliases: ['lambda sensor', 'o2 sensor'] },
knock_sensor: { en: 'Knock Sensor', tr: 'Vuruntu Sensoru' },
crankshaft_sensor: { en: 'Crankshaft Sensor', tr: 'Krank Sensoru' },
camshaft_sensor: { en: 'Camshaft Sensor', tr: 'Eksantrik Sensoru' },
coolant_temp_sensor: { en: 'Coolant Temperature Sensor', tr: 'Su Sicakligi Sensoru' },
oil_pressure_sensor: { en: 'Oil Pressure Sensor', tr: 'Yag Basinci Sensoru' },
engine_oil: { en: 'Engine Oil', tr: 'Motor Yagi' },
coolant: { en: 'Coolant', tr: 'Antifriz', aliases: ['antifreeze'] },
// Suspension Parts - Suspansiyon Parcalari
shock_absorber: { en: 'Shock Absorber', tr: 'Amortisor' },
strut: { en: 'Strut', tr: 'Amortisor Bacagi' },
coil_spring: { en: 'Coil Spring', tr: 'Helisel Yay' },
leaf_spring: { en: 'Leaf Spring', tr: 'Yaprak Yay' },
air_spring: { en: 'Air Spring', tr: 'Hava Yastigi' },
control_arm: { en: 'Control Arm', tr: 'Salincak', aliases: ['wishbone'] },
ball_joint: { en: 'Ball Joint', tr: 'Rotil Basi' },
tie_rod: { en: 'Tie Rod', tr: 'Rotil' },
tie_rod_end: { en: 'Tie Rod End', tr: 'Rotil Basi' },
sway_bar: { en: 'Sway Bar', tr: 'Viraj Denge Cubugu', aliases: ['stabilizer bar', 'anti-roll bar'] },
sway_bar_link: { en: 'Sway Bar Link', tr: 'Viraj Denge Rotu' },
strut_mount: { en: 'Strut Mount', tr: 'Amortisor Takozu' },
wheel_bearing: { en: 'Wheel Bearing', tr: 'Teker Rulman' },
hub_assembly: { en: 'Hub Assembly', tr: 'Porya' },
cv_joint: { en: 'CV Joint', tr: 'Aks Kafasi', aliases: ['constant velocity joint'] },
cv_boot: { en: 'CV Boot', tr: 'Aks Körugu' },
driveshaft: { en: 'Driveshaft', tr: 'Saft' },
axle_shaft: { en: 'Axle Shaft', tr: 'Aks Mili' },
differential: { en: 'Differential', tr: 'Diferansiyel' },
subframe: { en: 'Subframe', tr: 'Alt Sase' },
bushing: { en: 'Bushing', tr: 'Burç' },
// Steering Parts - Direksiyon Parcalari
steering_rack: { en: 'Steering Rack', tr: 'Direksiyon Kutusu' },
power_steering_pump: { en: 'Power Steering Pump', tr: 'Hidrolik Direksiyon Pompasi' },
steering_column: { en: 'Steering Column', tr: 'Direksiyon Kolonu' },
steering_wheel: { en: 'Steering Wheel', tr: 'Direksiyon Simidi' },
power_steering_fluid: { en: 'Power Steering Fluid', tr: 'Direksiyon Hidrolik Yagi' },
// Electrical Parts - Elektrik Parcalari
battery: { en: 'Battery', tr: 'Aku' },
alternator: { en: 'Alternator', tr: 'Alternator', aliases: ['generator'] },
starter_motor: { en: 'Starter Motor', tr: 'Mars Motoru' },
fuse: { en: 'Fuse', tr: 'Sigorta' },
relay: { en: 'Relay', tr: 'Role' },
wiper_motor: { en: 'Wiper Motor', tr: 'Silecek Motoru' },
wiper_blade: { en: 'Wiper Blade', tr: 'Silecek Lastigi' },
window_motor: { en: 'Window Motor', tr: 'Cam Motoru' },
window_regulator: { en: 'Window Regulator', tr: 'Cam Krikosu' },
door_lock: { en: 'Door Lock', tr: 'Kapi Kilidi' },
central_locking: { en: 'Central Locking', tr: 'Merkezi Kilit' },
horn: { en: 'Horn', tr: 'Korna' },
// Lighting - Aydinlatma
headlight: { en: 'Headlight', tr: 'Far' },
headlight_bulb: { en: 'Headlight Bulb', tr: 'Far Ampulu' },
tail_light: { en: 'Tail Light', tr: 'Stop Lambasi' },
brake_light: { en: 'Brake Light', tr: 'Fren Lambasi' },
turn_signal: { en: 'Turn Signal', tr: 'Sinyal Lambasi' },
fog_light: { en: 'Fog Light', tr: 'Sis Farı' },
reverse_light: { en: 'Reverse Light', tr: 'Geri Vites Lambasi' },
interior_light: { en: 'Interior Light', tr: 'Ic Aydinlatma' },
led_module: { en: 'LED Module', tr: 'LED Modul' },
xenon_bulb: { en: 'Xenon Bulb', tr: 'Xenon Ampul' },
ballast: { en: 'Ballast', tr: 'Balast' },
// Exhaust Parts - Egzoz Parcalari
catalytic_converter: { en: 'Catalytic Converter', tr: 'Katalitik Konvertor', aliases: ['cat'] },
muffler: { en: 'Muffler', tr: 'Susturucu', aliases: ['silencer'] },
exhaust_pipe: { en: 'Exhaust Pipe', tr: 'Egzoz Borusu' },
exhaust_manifold_gasket: { en: 'Exhaust Manifold Gasket', tr: 'Egzoz Manifold Contasi' },
dpf: { en: 'DPF', tr: 'Partikul Filtresi', aliases: ['diesel particulate filter'] },
scr: { en: 'SCR', tr: 'SCR Sistemi', aliases: ['selective catalytic reduction'] },
adblue: { en: 'AdBlue', tr: 'AdBlue' },
// Clutch Parts - Debriyaj Parcalari
clutch_kit: { en: 'Clutch Kit', tr: 'Debriyaj Seti' },
clutch_disc: { en: 'Clutch Disc', tr: 'Debriyaj Balatasi' },
clutch_pressure_plate: { en: 'Clutch Pressure Plate', tr: 'Debriyaj Baskisi' },
clutch_release_bearing: { en: 'Clutch Release Bearing', tr: 'Debriyaj Bilyasi' },
flywheel: { en: 'Flywheel', tr: 'Volan' },
dual_mass_flywheel: { en: 'Dual Mass Flywheel', tr: 'Cift Kutleli Volan', aliases: ['dmf'] },
clutch_master_cylinder: { en: 'Clutch Master Cylinder', tr: 'Debriyaj Ana Merkezi' },
clutch_slave_cylinder: { en: 'Clutch Slave Cylinder', tr: 'Debriyaj Alt Merkezi' },
// Transmission Parts - Sanziman Parcalari
gearbox: { en: 'Gearbox', tr: 'Sanziman' },
gear_oil: { en: 'Gear Oil', tr: 'Sanziman Yagi' },
transmission_mount: { en: 'Transmission Mount', tr: 'Sanziman Takozu' },
shift_cable: { en: 'Shift Cable', tr: 'Vites Teli' },
synchro_ring: { en: 'Synchro Ring', tr: 'Senkromec' },
torque_converter: { en: 'Torque Converter', tr: 'Tork Konvertor' },
// Heating & AC - Isitma ve Klima
heater_core: { en: 'Heater Core', tr: 'Kalorifer Radyatoru' },
heater_blower: { en: 'Heater Blower', tr: 'Kalorifer Fan Motoru' },
ac_compressor: { en: 'AC Compressor', tr: 'Klima Kompresoru' },
ac_condenser: { en: 'AC Condenser', tr: 'Klima Kondensoru' },
ac_evaporator: { en: 'AC Evaporator', tr: 'Klima Evaporatoru' },
expansion_valve: { en: 'Expansion Valve', tr: 'Genlesme Valfi' },
receiver_drier: { en: 'Receiver Drier', tr: 'Kurutucu' },
refrigerant: { en: 'Refrigerant', tr: 'Klima Gazi' },
// Body Parts - Kaporta Parcalari
hood: { en: 'Hood', tr: 'Kaput', aliases: ['bonnet'] },
trunk: { en: 'Trunk', tr: 'Bagaj', aliases: ['boot'] },
fender: { en: 'Fender', tr: 'Camurluk', aliases: ['wing'] },
bumper: { en: 'Bumper', tr: 'Tampon' },
front_bumper: { en: 'Front Bumper', tr: 'On Tampon' },
rear_bumper: { en: 'Rear Bumper', tr: 'Arka Tampon' },
grille: { en: 'Grille', tr: 'Izgara' },
door_panel: { en: 'Door Panel', tr: 'Kapi Dosemesi' },
door_handle: { en: 'Door Handle', tr: 'Kapi Kolu' },
side_mirror: { en: 'Side Mirror', tr: 'Yan Ayna' },
rear_view_mirror: { en: 'Rear View Mirror', tr: 'Ic Ayna' },
windshield: { en: 'Windshield', tr: 'On Cam', aliases: ['windscreen'] },
rear_window: { en: 'Rear Window', tr: 'Arka Cam' },
side_window: { en: 'Side Window', tr: 'Yan Cam' },
sunroof: { en: 'Sunroof', tr: 'Acilir Tavan' },
roof_rack: { en: 'Roof Rack', tr: 'Tavan Bagaji' },
spoiler: { en: 'Spoiler', tr: 'Spoiler' },
emblem: { en: 'Emblem', tr: 'Amblem' },
// Interior Parts - Ic Mekan Parcalari
seat_cover: { en: 'Seat Cover', tr: 'Koltuk Kilifi' },
seat_belt: { en: 'Seat Belt', tr: 'Emniyet Kemeri' },
floor_mat: { en: 'Floor Mat', tr: 'Paspas' },
steering_wheel_cover: { en: 'Steering Wheel Cover', tr: 'Direksiyon Kilifi' },
gear_knob: { en: 'Gear Knob', tr: 'Vites Topuzu' },
handbrake_lever: { en: 'Handbrake Lever', tr: 'El Freni Kolu' },
pedal_pad: { en: 'Pedal Pad', tr: 'Pedal Lastigi' },
instrument_cluster: { en: 'Instrument Cluster', tr: 'Gosterge Paneli' },
speedometer: { en: 'Speedometer', tr: 'Kilometre Saati' },
tachometer: { en: 'Tachometer', tr: 'Devir Saati' },
fuel_gauge: { en: 'Fuel Gauge', tr: 'Yakit Gostergesi' },
// Wheels & Tires - Jant ve Lastikler
wheel: { en: 'Wheel', tr: 'Jant', aliases: ['rim'] },
alloy_wheel: { en: 'Alloy Wheel', tr: 'Alaşim Jant' },
steel_wheel: { en: 'Steel Wheel', tr: 'Sac Jant' },
tire: { en: 'Tire', tr: 'Lastik', aliases: ['tyre'] },
summer_tire: { en: 'Summer Tire', tr: 'Yaz Lastigi' },
winter_tire: { en: 'Winter Tire', tr: 'Kis Lastigi' },
all_season_tire: { en: 'All Season Tire', tr: 'Dort Mevsim Lastik' },
spare_tire: { en: 'Spare Tire', tr: 'Stepne' },
wheel_nut: { en: 'Wheel Nut', tr: 'Bijon' },
wheel_bolt: { en: 'Wheel Bolt', tr: 'Bijon Civata' },
wheel_cap: { en: 'Wheel Cap', tr: 'Jant Kapagi' },
tire_valve: { en: 'Tire Valve', tr: 'Lastik Sibop' },
tpms_sensor: { en: 'TPMS Sensor', tr: 'Lastik Basinci Sensoru' },
// Safety Parts - Guvenlik Parcalari
airbag: { en: 'Airbag', tr: 'Hava Yastigi' },
airbag_module: { en: 'Airbag Module', tr: 'Hava Yastigi Beyni' },
crash_sensor: { en: 'Crash Sensor', tr: 'Carpisma Sensoru' },
parking_sensor: { en: 'Parking Sensor', tr: 'Park Sensoru' },
backup_camera: { en: 'Backup Camera', tr: 'Geri Gorus Kamerasi' },
blind_spot_sensor: { en: 'Blind Spot Sensor', tr: 'Kor Nokta Sensoru' },
lane_assist: { en: 'Lane Assist', tr: 'Serit Takip Sistemi' },
adaptive_cruise: { en: 'Adaptive Cruise Control', tr: 'Adaptif Hiz Sabitleyici' },
};
/**
* Vehicle Conditions - Arac Durumlari
*/
export const VEHICLE_CONDITIONS: TranslationCategory = {
new: { en: 'New', tr: 'Sifir' },
used: { en: 'Used', tr: 'Ikinci El' },
certified: { en: 'Certified Pre-Owned', tr: 'Sertifikali Ikinci El' },
salvage: { en: 'Salvage', tr: 'Hasarli' },
restored: { en: 'Restored', tr: 'Restore Edilmis' },
classic: { en: 'Classic', tr: 'Klasik' },
vintage: { en: 'Vintage', tr: 'Antika' },
};
/**
* Colors - Renkler
*/
export const COLORS: TranslationCategory = {
white: { en: 'White', tr: 'Beyaz' },
black: { en: 'Black', tr: 'Siyah' },
silver: { en: 'Silver', tr: 'Gumus' },
gray: { en: 'Gray', tr: 'Gri', aliases: ['grey'] },
red: { en: 'Red', tr: 'Kirmizi' },
blue: { en: 'Blue', tr: 'Mavi' },
green: { en: 'Green', tr: 'Yesil' },
yellow: { en: 'Yellow', tr: 'Sari' },
orange: { en: 'Orange', tr: 'Turuncu' },
brown: { en: 'Brown', tr: 'Kahverengi' },
beige: { en: 'Beige', tr: 'Bej' },
gold: { en: 'Gold', tr: 'Altin' },
bronze: { en: 'Bronze', tr: 'Bronz' },
burgundy: { en: 'Burgundy', tr: 'Bordo' },
navy: { en: 'Navy Blue', tr: 'Lacivert' },
pearl_white: { en: 'Pearl White', tr: 'Inci Beyazi' },
metallic: { en: 'Metallic', tr: 'Metalik' },
matte: { en: 'Matte', tr: 'Mat' },
};
/**
* All categories combined for easy lookup
*/
export const ALL_AUTOMOTIVE_TERMS: Record<string, TranslationCategory> = {
engine_types: ENGINE_TYPES,
transmission_types: TRANSMISSION_TYPES,
drive_types: DRIVE_TYPES,
body_types: BODY_TYPES,
part_categories: PART_CATEGORIES,
common_parts: COMMON_PARTS,
vehicle_conditions: VEHICLE_CONDITIONS,
colors: COLORS,
};

View File

@@ -0,0 +1,663 @@
/**
* Brand name mappings for automotive manufacturers
* Includes official names, common variations, and Turkish market names
*/
export interface BrandEntry {
official: string;
display: string;
variations: string[];
country: string;
}
/**
* Automotive brand name mappings
*/
export const BRAND_NAMES: Record<string, BrandEntry> = {
// German Brands - Alman Markalari
mercedes: {
official: 'Mercedes-Benz',
display: 'Mercedes-Benz',
variations: ['mercedes', 'mercedes benz', 'mb', 'merc', 'benz'],
country: 'Almanya',
},
bmw: {
official: 'Bayerische Motoren Werke AG',
display: 'BMW',
variations: ['bmw', 'bayerische motoren werke', 'beamer', 'bimmer'],
country: 'Almanya',
},
audi: {
official: 'Audi AG',
display: 'Audi',
variations: ['audi'],
country: 'Almanya',
},
volkswagen: {
official: 'Volkswagen AG',
display: 'Volkswagen',
variations: ['volkswagen', 'vw', 'volks'],
country: 'Almanya',
},
porsche: {
official: 'Porsche AG',
display: 'Porsche',
variations: ['porsche'],
country: 'Almanya',
},
opel: {
official: 'Opel Automobile GmbH',
display: 'Opel',
variations: ['opel', 'vauxhall'],
country: 'Almanya',
},
mini: {
official: 'MINI',
display: 'MINI',
variations: ['mini', 'mini cooper'],
country: 'Almanya',
},
smart: {
official: 'Smart Automobile',
display: 'Smart',
variations: ['smart'],
country: 'Almanya',
},
// Japanese Brands - Japon Markalari
toyota: {
official: 'Toyota Motor Corporation',
display: 'Toyota',
variations: ['toyota'],
country: 'Japonya',
},
honda: {
official: 'Honda Motor Company',
display: 'Honda',
variations: ['honda'],
country: 'Japonya',
},
nissan: {
official: 'Nissan Motor Corporation',
display: 'Nissan',
variations: ['nissan', 'datsun'],
country: 'Japonya',
},
mazda: {
official: 'Mazda Motor Corporation',
display: 'Mazda',
variations: ['mazda'],
country: 'Japonya',
},
mitsubishi: {
official: 'Mitsubishi Motors Corporation',
display: 'Mitsubishi',
variations: ['mitsubishi', 'mitsu'],
country: 'Japonya',
},
subaru: {
official: 'Subaru Corporation',
display: 'Subaru',
variations: ['subaru'],
country: 'Japonya',
},
suzuki: {
official: 'Suzuki Motor Corporation',
display: 'Suzuki',
variations: ['suzuki'],
country: 'Japonya',
},
lexus: {
official: 'Lexus',
display: 'Lexus',
variations: ['lexus'],
country: 'Japonya',
},
infiniti: {
official: 'Infiniti',
display: 'Infiniti',
variations: ['infiniti'],
country: 'Japonya',
},
acura: {
official: 'Acura',
display: 'Acura',
variations: ['acura'],
country: 'Japonya',
},
isuzu: {
official: 'Isuzu Motors',
display: 'Isuzu',
variations: ['isuzu'],
country: 'Japonya',
},
// Korean Brands - Kore Markalari
hyundai: {
official: 'Hyundai Motor Company',
display: 'Hyundai',
variations: ['hyundai'],
country: 'Guney Kore',
},
kia: {
official: 'Kia Corporation',
display: 'Kia',
variations: ['kia'],
country: 'Guney Kore',
},
genesis: {
official: 'Genesis Motor',
display: 'Genesis',
variations: ['genesis'],
country: 'Guney Kore',
},
ssangyong: {
official: 'SsangYong Motor Company',
display: 'SsangYong',
variations: ['ssangyong', 'ssang yong'],
country: 'Guney Kore',
},
// American Brands - Amerikan Markalari
ford: {
official: 'Ford Motor Company',
display: 'Ford',
variations: ['ford'],
country: 'Amerika',
},
chevrolet: {
official: 'Chevrolet',
display: 'Chevrolet',
variations: ['chevrolet', 'chevy'],
country: 'Amerika',
},
gmc: {
official: 'GMC',
display: 'GMC',
variations: ['gmc', 'general motors'],
country: 'Amerika',
},
cadillac: {
official: 'Cadillac',
display: 'Cadillac',
variations: ['cadillac', 'caddy'],
country: 'Amerika',
},
jeep: {
official: 'Jeep',
display: 'Jeep',
variations: ['jeep'],
country: 'Amerika',
},
dodge: {
official: 'Dodge',
display: 'Dodge',
variations: ['dodge'],
country: 'Amerika',
},
chrysler: {
official: 'Chrysler',
display: 'Chrysler',
variations: ['chrysler'],
country: 'Amerika',
},
ram: {
official: 'RAM Trucks',
display: 'RAM',
variations: ['ram', 'ram trucks'],
country: 'Amerika',
},
lincoln: {
official: 'Lincoln',
display: 'Lincoln',
variations: ['lincoln'],
country: 'Amerika',
},
buick: {
official: 'Buick',
display: 'Buick',
variations: ['buick'],
country: 'Amerika',
},
tesla: {
official: 'Tesla, Inc.',
display: 'Tesla',
variations: ['tesla'],
country: 'Amerika',
},
rivian: {
official: 'Rivian Automotive',
display: 'Rivian',
variations: ['rivian'],
country: 'Amerika',
},
lucid: {
official: 'Lucid Motors',
display: 'Lucid',
variations: ['lucid', 'lucid motors'],
country: 'Amerika',
},
// French Brands - Fransiz Markalari
renault: {
official: 'Renault S.A.',
display: 'Renault',
variations: ['renault'],
country: 'Fransa',
},
peugeot: {
official: 'Peugeot S.A.',
display: 'Peugeot',
variations: ['peugeot'],
country: 'Fransa',
},
citroen: {
official: 'Citroen',
display: 'Citroen',
variations: ['citroen', 'citroën'],
country: 'Fransa',
},
ds: {
official: 'DS Automobiles',
display: 'DS',
variations: ['ds', 'ds automobiles'],
country: 'Fransa',
},
alpine: {
official: 'Alpine',
display: 'Alpine',
variations: ['alpine'],
country: 'Fransa',
},
dacia: {
official: 'Dacia',
display: 'Dacia',
variations: ['dacia'],
country: 'Romanya',
},
// Italian Brands - Italyan Markalari
fiat: {
official: 'Fiat Automobiles',
display: 'Fiat',
variations: ['fiat'],
country: 'Italya',
},
alfa_romeo: {
official: 'Alfa Romeo',
display: 'Alfa Romeo',
variations: ['alfa romeo', 'alfa', 'ar'],
country: 'Italya',
},
ferrari: {
official: 'Ferrari S.p.A.',
display: 'Ferrari',
variations: ['ferrari'],
country: 'Italya',
},
lamborghini: {
official: 'Automobili Lamborghini S.p.A.',
display: 'Lamborghini',
variations: ['lamborghini', 'lambo'],
country: 'Italya',
},
maserati: {
official: 'Maserati S.p.A.',
display: 'Maserati',
variations: ['maserati'],
country: 'Italya',
},
lancia: {
official: 'Lancia',
display: 'Lancia',
variations: ['lancia'],
country: 'Italya',
},
abarth: {
official: 'Abarth',
display: 'Abarth',
variations: ['abarth'],
country: 'Italya',
},
pagani: {
official: 'Pagani Automobili',
display: 'Pagani',
variations: ['pagani'],
country: 'Italya',
},
// British Brands - Ingiliz Markalari
jaguar: {
official: 'Jaguar Land Rover',
display: 'Jaguar',
variations: ['jaguar', 'jag'],
country: 'Ingiltere',
},
land_rover: {
official: 'Land Rover',
display: 'Land Rover',
variations: ['land rover', 'landrover', 'lr'],
country: 'Ingiltere',
},
range_rover: {
official: 'Range Rover',
display: 'Range Rover',
variations: ['range rover', 'rr'],
country: 'Ingiltere',
},
bentley: {
official: 'Bentley Motors',
display: 'Bentley',
variations: ['bentley'],
country: 'Ingiltere',
},
rolls_royce: {
official: 'Rolls-Royce Motor Cars',
display: 'Rolls-Royce',
variations: ['rolls royce', 'rolls-royce', 'rr'],
country: 'Ingiltere',
},
aston_martin: {
official: 'Aston Martin Lagonda',
display: 'Aston Martin',
variations: ['aston martin', 'aston'],
country: 'Ingiltere',
},
mclaren: {
official: 'McLaren Automotive',
display: 'McLaren',
variations: ['mclaren'],
country: 'Ingiltere',
},
lotus: {
official: 'Lotus Cars',
display: 'Lotus',
variations: ['lotus'],
country: 'Ingiltere',
},
mg: {
official: 'MG Motor',
display: 'MG',
variations: ['mg', 'mg motor'],
country: 'Ingiltere',
},
// Swedish Brands - Isvec Markalari
volvo: {
official: 'Volvo Cars',
display: 'Volvo',
variations: ['volvo'],
country: 'Isvec',
},
polestar: {
official: 'Polestar',
display: 'Polestar',
variations: ['polestar'],
country: 'Isvec',
},
saab: {
official: 'Saab Automobile',
display: 'Saab',
variations: ['saab'],
country: 'Isvec',
},
koenigsegg: {
official: 'Koenigsegg Automotive AB',
display: 'Koenigsegg',
variations: ['koenigsegg'],
country: 'Isvec',
},
// Czech Brands - Cek Markalari
skoda: {
official: 'Skoda Auto',
display: 'Skoda',
variations: ['skoda', 'škoda'],
country: 'Cekya',
},
// Spanish Brands - Ispanyol Markalari
seat: {
official: 'SEAT S.A.',
display: 'SEAT',
variations: ['seat'],
country: 'Ispanya',
},
cupra: {
official: 'CUPRA',
display: 'CUPRA',
variations: ['cupra'],
country: 'Ispanya',
},
// Chinese Brands - Cin Markalari
byd: {
official: 'BYD Company',
display: 'BYD',
variations: ['byd', 'build your dreams'],
country: 'Cin',
},
geely: {
official: 'Geely Automobile',
display: 'Geely',
variations: ['geely'],
country: 'Cin',
},
nio: {
official: 'NIO Inc.',
display: 'NIO',
variations: ['nio'],
country: 'Cin',
},
xpeng: {
official: 'XPeng Inc.',
display: 'XPeng',
variations: ['xpeng', 'x peng'],
country: 'Cin',
},
great_wall: {
official: 'Great Wall Motors',
display: 'Great Wall',
variations: ['great wall', 'gwm', 'haval'],
country: 'Cin',
},
chery: {
official: 'Chery Automobile',
display: 'Chery',
variations: ['chery'],
country: 'Cin',
},
mg_saic: {
official: 'MG (SAIC)',
display: 'MG',
variations: ['mg saic'],
country: 'Cin',
},
lynk_co: {
official: 'Lynk & Co',
display: 'Lynk & Co',
variations: ['lynk & co', 'lynk and co', 'lynkco'],
country: 'Cin',
},
aiways: {
official: 'Aiways',
display: 'Aiways',
variations: ['aiways'],
country: 'Cin',
},
omoda: {
official: 'OMODA',
display: 'OMODA',
variations: ['omoda'],
country: 'Cin',
},
jaecoo: {
official: 'JAECOO',
display: 'JAECOO',
variations: ['jaecoo'],
country: 'Cin',
},
// Turkish Brands - Turk Markalari
togg: {
official: 'TOGG',
display: 'TOGG',
variations: ['togg', 'turkiyenin otomobili'],
country: 'Turkiye',
},
karsan: {
official: 'Karsan',
display: 'Karsan',
variations: ['karsan'],
country: 'Turkiye',
},
temsa: {
official: 'TEMSA',
display: 'TEMSA',
variations: ['temsa'],
country: 'Turkiye',
},
bmc: {
official: 'BMC',
display: 'BMC',
variations: ['bmc'],
country: 'Turkiye',
},
// Indian Brands - Hint Markalari
tata: {
official: 'Tata Motors',
display: 'Tata',
variations: ['tata', 'tata motors'],
country: 'Hindistan',
},
mahindra: {
official: 'Mahindra & Mahindra',
display: 'Mahindra',
variations: ['mahindra'],
country: 'Hindistan',
},
// Other Brands - Diger Markalar
caterham: {
official: 'Caterham Cars',
display: 'Caterham',
variations: ['caterham'],
country: 'Ingiltere',
},
morgan: {
official: 'Morgan Motor Company',
display: 'Morgan',
variations: ['morgan'],
country: 'Ingiltere',
},
bugatti: {
official: 'Bugatti Automobiles',
display: 'Bugatti',
variations: ['bugatti'],
country: 'Fransa',
},
};
/**
* Country name translations
*/
export const COUNTRY_NAMES: Record<string, { en: string; tr: string }> = {
germany: { en: 'Germany', tr: 'Almanya' },
japan: { en: 'Japan', tr: 'Japonya' },
south_korea: { en: 'South Korea', tr: 'Guney Kore' },
usa: { en: 'United States', tr: 'Amerika' },
france: { en: 'France', tr: 'Fransa' },
italy: { en: 'Italy', tr: 'Italya' },
uk: { en: 'United Kingdom', tr: 'Ingiltere' },
sweden: { en: 'Sweden', tr: 'Isvec' },
czech: { en: 'Czech Republic', tr: 'Cekya' },
spain: { en: 'Spain', tr: 'Ispanya' },
china: { en: 'China', tr: 'Cin' },
turkey: { en: 'Turkey', tr: 'Turkiye' },
india: { en: 'India', tr: 'Hindistan' },
romania: { en: 'Romania', tr: 'Romanya' },
};
/**
* Luxury segment identifiers
*/
export const LUXURY_BRANDS = [
'mercedes',
'bmw',
'audi',
'porsche',
'lexus',
'infiniti',
'acura',
'genesis',
'cadillac',
'lincoln',
'jaguar',
'land_rover',
'range_rover',
'bentley',
'rolls_royce',
'aston_martin',
'mclaren',
'ferrari',
'lamborghini',
'maserati',
'bugatti',
'pagani',
'koenigsegg',
'lotus',
'polestar',
];
/**
* Electric vehicle focused brands
*/
export const EV_BRANDS = [
'tesla',
'rivian',
'lucid',
'nio',
'xpeng',
'byd',
'polestar',
'togg',
'aiways',
];
/**
* Get brand entry by any variation
*/
export function getBrandByVariation(input: string): BrandEntry | undefined {
const normalizedInput = input.toLowerCase().trim();
for (const [, brand] of Object.entries(BRAND_NAMES)) {
if (brand.variations.includes(normalizedInput)) {
return brand;
}
}
return undefined;
}
/**
* Get display name for a brand
*/
export function getBrandDisplayName(input: string): string {
const brand = getBrandByVariation(input);
return brand?.display || input;
}
/**
* Check if a brand is luxury segment
*/
export function isLuxuryBrand(brandKey: string): boolean {
return LUXURY_BRANDS.includes(brandKey.toLowerCase());
}
/**
* Check if a brand is EV-focused
*/
export function isEVBrand(brandKey: string): boolean {
return EV_BRANDS.includes(brandKey.toLowerCase());
}

View File

@@ -0,0 +1,39 @@
// Module
export { TranslationsModule } from './translations.module';
// Service
export {
TranslationsService,
TranslationDirection,
VehicleData,
PartData,
TranslationResult,
} from './translations.service';
// Data - Automotive Terms
export {
TranslationEntry,
TranslationCategory,
ENGINE_TYPES,
TRANSMISSION_TYPES,
DRIVE_TYPES,
BODY_TYPES,
PART_CATEGORIES,
COMMON_PARTS,
VEHICLE_CONDITIONS,
COLORS,
ALL_AUTOMOTIVE_TERMS,
} from './data/automotive-terms';
// Data - Brand Names
export {
BrandEntry,
BRAND_NAMES,
COUNTRY_NAMES,
LUXURY_BRANDS,
EV_BRANDS,
getBrandByVariation,
getBrandDisplayName,
isLuxuryBrand,
isEVBrand,
} from './data/brand-names';

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { TranslationsService } from './translations.service';
@Module({
providers: [TranslationsService],
exports: [TranslationsService],
})
export class TranslationsModule {}

View File

@@ -0,0 +1,459 @@
import { Injectable } from '@nestjs/common';
import {
ALL_AUTOMOTIVE_TERMS,
TranslationCategory,
TranslationEntry,
ENGINE_TYPES,
TRANSMISSION_TYPES,
DRIVE_TYPES,
BODY_TYPES,
PART_CATEGORIES,
COMMON_PARTS,
VEHICLE_CONDITIONS,
COLORS,
} from './data/automotive-terms';
import {
BRAND_NAMES,
BrandEntry,
getBrandByVariation,
getBrandDisplayName,
} from './data/brand-names';
/**
* Translation direction enum
*/
export enum TranslationDirection {
EN_TO_TR = 'en_to_tr',
TR_TO_EN = 'tr_to_en',
AUTO = 'auto',
}
/**
* Vehicle data interface for translation
*/
export interface VehicleData {
brand?: string;
model?: string;
year?: number;
engineType?: string;
transmissionType?: string;
driveType?: string;
bodyType?: string;
color?: string;
condition?: string;
[key: string]: unknown;
}
/**
* Part data interface for translation
*/
export interface PartData {
name?: string;
category?: string;
description?: string;
[key: string]: unknown;
}
/**
* Translation result with metadata
*/
export interface TranslationResult {
original: string;
translated: string;
direction: TranslationDirection;
category?: string;
found: boolean;
}
@Injectable()
export class TranslationsService {
private readonly categoryMap: Record<string, TranslationCategory> = {
engine: ENGINE_TYPES,
engine_type: ENGINE_TYPES,
engine_types: ENGINE_TYPES,
transmission: TRANSMISSION_TYPES,
transmission_type: TRANSMISSION_TYPES,
transmission_types: TRANSMISSION_TYPES,
drive: DRIVE_TYPES,
drive_type: DRIVE_TYPES,
drive_types: DRIVE_TYPES,
body: BODY_TYPES,
body_type: BODY_TYPES,
body_types: BODY_TYPES,
category: PART_CATEGORIES,
categories: PART_CATEGORIES,
part_category: PART_CATEGORIES,
part_categories: PART_CATEGORIES,
part: COMMON_PARTS,
parts: COMMON_PARTS,
common_part: COMMON_PARTS,
common_parts: COMMON_PARTS,
condition: VEHICLE_CONDITIONS,
conditions: VEHICLE_CONDITIONS,
vehicle_condition: VEHICLE_CONDITIONS,
color: COLORS,
colors: COLORS,
};
/**
* Translate a single term with optional category hint
* @param term - The term to translate
* @param category - Optional category hint for faster lookup
* @param direction - Translation direction (default: AUTO)
* @returns Translated term or original if not found
*/
translateTerm(
term: string,
category?: string,
direction: TranslationDirection = TranslationDirection.AUTO,
): string {
const result = this.translateTermWithMetadata(term, category, direction);
return result.translated;
}
/**
* Translate a term and return full metadata
* @param term - The term to translate
* @param category - Optional category hint
* @param direction - Translation direction
* @returns TranslationResult with metadata
*/
translateTermWithMetadata(
term: string,
category?: string,
direction: TranslationDirection = TranslationDirection.AUTO,
): TranslationResult {
if (!term || typeof term !== 'string') {
return {
original: term || '',
translated: term || '',
direction,
found: false,
};
}
const normalizedTerm = term.toLowerCase().trim();
// Try category-specific lookup first if category is provided
if (category) {
const normalizedCategory = category.toLowerCase().trim();
const categoryData = this.categoryMap[normalizedCategory];
if (categoryData) {
const result = this.findInCategory(
normalizedTerm,
categoryData,
direction,
);
if (result.found) {
return { ...result, category: normalizedCategory };
}
}
}
// Search through all categories
for (const [catName, categoryData] of Object.entries(ALL_AUTOMOTIVE_TERMS)) {
const result = this.findInCategory(normalizedTerm, categoryData, direction);
if (result.found) {
return { ...result, category: catName };
}
}
// Return original term if no translation found
return {
original: term,
translated: term,
direction,
found: false,
};
}
/**
* Translate vehicle data object
* @param vehicle - Vehicle data to translate
* @param direction - Translation direction
* @returns Translated vehicle data
*/
translateVehicle(
vehicle: VehicleData,
direction: TranslationDirection = TranslationDirection.EN_TO_TR,
): VehicleData {
if (!vehicle || typeof vehicle !== 'object') {
return vehicle;
}
const translated: VehicleData = { ...vehicle };
// Translate brand name
if (vehicle.brand) {
translated.brand = getBrandDisplayName(vehicle.brand);
}
// Translate engine type
if (vehicle.engineType) {
translated.engineType = this.translateTerm(
vehicle.engineType,
'engine_type',
direction,
);
}
// Translate transmission type
if (vehicle.transmissionType) {
translated.transmissionType = this.translateTerm(
vehicle.transmissionType,
'transmission_type',
direction,
);
}
// Translate drive type
if (vehicle.driveType) {
translated.driveType = this.translateTerm(
vehicle.driveType,
'drive_type',
direction,
);
}
// Translate body type
if (vehicle.bodyType) {
translated.bodyType = this.translateTerm(
vehicle.bodyType,
'body_type',
direction,
);
}
// Translate color
if (vehicle.color) {
translated.color = this.translateTerm(vehicle.color, 'color', direction);
}
// Translate condition
if (vehicle.condition) {
translated.condition = this.translateTerm(
vehicle.condition,
'condition',
direction,
);
}
return translated;
}
/**
* Translate part data object
* @param part - Part data to translate
* @param direction - Translation direction
* @returns Translated part data
*/
translatePart(
part: PartData,
direction: TranslationDirection = TranslationDirection.EN_TO_TR,
): PartData {
if (!part || typeof part !== 'object') {
return part;
}
const translated: PartData = { ...part };
// Translate part name
if (part.name) {
translated.name = this.translateTerm(part.name, 'parts', direction);
}
// Translate category
if (part.category) {
translated.category = this.translateTerm(
part.category,
'part_categories',
direction,
);
}
return translated;
}
/**
* Batch translate multiple terms
* @param terms - Array of terms to translate
* @param category - Optional category hint
* @param direction - Translation direction
* @returns Array of translation results
*/
translateTerms(
terms: string[],
category?: string,
direction: TranslationDirection = TranslationDirection.AUTO,
): TranslationResult[] {
return terms.map((term) =>
this.translateTermWithMetadata(term, category, direction),
);
}
/**
* Get brand information by any variation
* @param brandInput - Brand name or variation
* @returns Brand entry or undefined
*/
getBrand(brandInput: string): BrandEntry | undefined {
return getBrandByVariation(brandInput);
}
/**
* Get all translations for a specific category
* @param category - Category name
* @returns Array of translation entries or empty array
*/
getCategoryTranslations(category: string): TranslationEntry[] {
const normalizedCategory = category.toLowerCase().trim();
const categoryData = this.categoryMap[normalizedCategory];
if (!categoryData) {
return [];
}
return Object.values(categoryData);
}
/**
* Get all available categories
* @returns Array of category names
*/
getAvailableCategories(): string[] {
return Object.keys(ALL_AUTOMOTIVE_TERMS);
}
/**
* Detect language of a term
* @param term - Term to analyze
* @returns 'en', 'tr', or 'unknown'
*/
detectLanguage(term: string): 'en' | 'tr' | 'unknown' {
if (!term) return 'unknown';
const normalizedTerm = term.toLowerCase().trim();
// Search through all categories to determine if it's EN or TR
for (const categoryData of Object.values(ALL_AUTOMOTIVE_TERMS)) {
for (const entry of Object.values(categoryData)) {
// Check if it matches English
if (
entry.en.toLowerCase() === normalizedTerm ||
entry.aliases?.some((a) => a.toLowerCase() === normalizedTerm)
) {
return 'en';
}
// Check if it matches Turkish
if (entry.tr.toLowerCase() === normalizedTerm) {
return 'tr';
}
}
}
return 'unknown';
}
/**
* Search for terms matching a query
* @param query - Search query
* @param category - Optional category to search in
* @returns Array of matching translation entries
*/
searchTerms(
query: string,
category?: string,
): Array<TranslationEntry & { category: string }> {
const results: Array<TranslationEntry & { category: string }> = [];
const normalizedQuery = query.toLowerCase().trim();
const categoriesToSearch = category
? { [category]: this.categoryMap[category.toLowerCase()] }
: ALL_AUTOMOTIVE_TERMS;
for (const [catName, categoryData] of Object.entries(categoriesToSearch)) {
if (!categoryData) continue;
for (const entry of Object.values(categoryData)) {
if (
entry.en.toLowerCase().includes(normalizedQuery) ||
entry.tr.toLowerCase().includes(normalizedQuery) ||
entry.aliases?.some((a) =>
a.toLowerCase().includes(normalizedQuery),
)
) {
results.push({ ...entry, category: catName });
}
}
}
return results;
}
/**
* Find translation in a specific category
*/
private findInCategory(
term: string,
category: TranslationCategory,
direction: TranslationDirection,
): TranslationResult {
for (const entry of Object.values(category)) {
const enLower = entry.en.toLowerCase();
const trLower = entry.tr.toLowerCase();
// Auto-detect direction based on match
if (direction === TranslationDirection.AUTO) {
// Check English -> Turkish
if (
enLower === term ||
entry.aliases?.some((a) => a.toLowerCase() === term)
) {
return {
original: term,
translated: entry.tr,
direction: TranslationDirection.EN_TO_TR,
found: true,
};
}
// Check Turkish -> English
if (trLower === term) {
return {
original: term,
translated: entry.en,
direction: TranslationDirection.TR_TO_EN,
found: true,
};
}
} else if (direction === TranslationDirection.EN_TO_TR) {
if (
enLower === term ||
entry.aliases?.some((a) => a.toLowerCase() === term)
) {
return {
original: term,
translated: entry.tr,
direction,
found: true,
};
}
} else if (direction === TranslationDirection.TR_TO_EN) {
if (trLower === term) {
return {
original: term,
translated: entry.en,
direction,
found: true,
};
}
}
}
return {
original: term,
translated: term,
direction,
found: false,
};
}
}

View File

@@ -0,0 +1,13 @@
import { IsString, IsOptional, MinLength, MaxLength, IsUrl } from 'class-validator';
export class UpdateUserDto {
@IsOptional()
@IsString()
@MinLength(2, { message: 'Ad en az 2 karakter olmalidir' })
@MaxLength(100, { message: 'Ad en fazla 100 karakter olabilir' })
name?: string;
@IsOptional()
@IsUrl({}, { message: 'Gecersiz avatar URL' })
avatar?: string;
}

View File

@@ -0,0 +1,39 @@
import {
Controller,
Get,
Patch,
Delete,
Body,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { UpdateUserDto } from './dto/update-user.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser, CurrentUserData } from '../../common/decorators/current-user.decorator';
@Controller('users')
@UseGuards(JwtAuthGuard)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get('profile')
async getProfile(@CurrentUser() user: CurrentUserData) {
return this.usersService.getProfile(user.id);
}
@Patch('profile')
async updateProfile(
@CurrentUser() user: CurrentUserData,
@Body() dto: UpdateUserDto,
) {
return this.usersService.updateProfile(user.id, dto);
}
@Delete('account')
@HttpCode(HttpStatus.OK)
async deleteAccount(@CurrentUser() user: CurrentUserData) {
return this.usersService.deleteAccount(user.id);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,59 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { UpdateUserDto } from './dto/update-user.dto';
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
async getProfile(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
subscription: {
include: { plan: true },
},
selectedBrands: {
include: { brand: true },
},
},
});
if (!user) {
throw new NotFoundException('Kullanici bulunamadi');
}
const { passwordHash, ...profile } = user;
return profile;
}
async updateProfile(userId: string, dto: UpdateUserDto) {
const user = await this.prisma.user.update({
where: { id: userId },
data: dto,
});
const { passwordHash, ...profile } = user;
return profile;
}
async deleteAccount(userId: string) {
await this.prisma.user.delete({
where: { id: userId },
});
return { message: 'Hesabiniz basariyla silindi' };
}
async findById(userId: string) {
return this.prisma.user.findUnique({
where: { id: userId },
});
}
async findByEmail(email: string) {
return this.prisma.user.findUnique({
where: { email: email.toLowerCase() },
});
}
}

View File

@@ -0,0 +1,12 @@
import { IsString, Length, Matches } from 'class-validator';
import { Transform } from 'class-transformer';
export class DecodeVinDto {
@IsString()
@Length(17, 17, { message: 'VIN 17 karakter olmalidir' })
@Matches(/^[A-HJ-NPR-Z0-9]{17}$/i, {
message: 'Gecersiz VIN formati (I, O, Q kullanilamaz)',
})
@Transform(({ value }) => value?.toUpperCase().trim())
vin: string;
}

View File

@@ -0,0 +1,19 @@
import { IsOptional, IsString, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
export class VehicleFilterDto {
@IsOptional()
@IsString()
brandId?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1900)
@Max(2100)
year?: number;
@IsOptional()
@IsString()
search?: string;
}

View File

@@ -0,0 +1,109 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Query,
UseGuards,
Req,
} from '@nestjs/common';
import { Request } from 'express';
import { VehiclesService } from './vehicles.service';
import { VinDecoderService } from './vin-decoder.service';
import { DecodeVinDto } from './dto/decode-vin.dto';
import { VehicleFilterDto } from './dto/vehicle-filter.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { BrandAccessGuard } from '../../common/guards/brand-access.guard';
import { CurrentUser, CurrentUserData } from '../../common/decorators/current-user.decorator';
import { PaginationDto } from '../../common/dto/pagination.dto';
// Extended request interface with brand access properties
interface BrandAccessRequest extends Request {
allowedBrandCodes?: string[];
hasFullAccess?: boolean;
}
@Controller('vehicles')
@UseGuards(JwtAuthGuard, BrandAccessGuard)
export class VehiclesController {
constructor(
private readonly vehiclesService: VehiclesService,
private readonly vinDecoderService: VinDecoderService,
) {}
@Post('decode')
async decodeVin(
@CurrentUser() user: CurrentUserData,
@Body() dto: DecodeVinDto,
@Req() req: BrandAccessRequest,
) {
return this.vinDecoderService.decodeVin(
user.id,
dto.vin,
req.allowedBrandCodes || [],
req.hasFullAccess || false,
);
}
@Get()
async getUserVehicles(
@CurrentUser() user: CurrentUserData,
@Query() pagination: PaginationDto,
@Query() filter: VehicleFilterDto,
) {
return this.vehiclesService.getUserVehicles(user.id, pagination, filter);
}
@Get(':id')
async getVehicle(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Req() req: BrandAccessRequest,
) {
return this.vehiclesService.getVehicleById(
id,
user.id,
req.allowedBrandCodes || [],
req.hasFullAccess || false,
);
}
@Delete(':id')
async deleteVehicle(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.vehiclesService.deleteVehicle(id, user.id);
}
@Get(':id/categories')
async getVehicleCategories(@Param('id') id: string) {
return this.vehiclesService.getVehicleCategories(id);
}
@Get(':id/parts')
async getVehicleParts(
@Param('id') id: string,
@Query() pagination: PaginationDto,
) {
return this.vehiclesService.getVehicleParts(id, pagination);
}
@Get(':vin/categories/:categoryId/parts')
async getCategoryParts(
@Param('vin') vin: string,
@Param('categoryId') categoryId: string,
@Query() pagination: PaginationDto,
@Req() req: BrandAccessRequest,
) {
return this.vehiclesService.getCategoryPartsByVin(
vin,
categoryId,
pagination,
req.allowedBrandCodes || [],
req.hasFullAccess || false,
);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { VehiclesController } from './vehicles.controller';
import { VehiclesService } from './vehicles.service';
import { VinDecoderService } from './vin-decoder.service';
import { IntegrationsModule } from '../../integrations/integrations.module';
@Module({
imports: [IntegrationsModule],
controllers: [VehiclesController],
providers: [VehiclesService, VinDecoderService],
exports: [VehiclesService, VinDecoderService],
})
export class VehiclesModule {}

View File

@@ -0,0 +1,258 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { PaginationDto, PaginatedResponseDto } from '../../common/dto/pagination.dto';
import { VehicleFilterDto } from './dto/vehicle-filter.dto';
@Injectable()
export class VehiclesService {
constructor(private prisma: PrismaService) {}
async getUserVehicles(userId: string, pagination: PaginationDto, filter: VehicleFilterDto) {
const { page = 1, limit = 20, sortBy = 'createdAt', sortOrder = 'desc' } = pagination;
const skip = (page - 1) * limit;
const where: Prisma.VehicleWhereInput = {
queryLogs: {
some: { userId },
},
};
if (filter.brandId) {
where.brandId = filter.brandId;
}
if (filter.year) {
where.year = filter.year;
}
if (filter.search) {
where.OR = [
{ vin: { contains: filter.search } },
{ model: { contains: filter.search } },
];
}
const [vehicles, total] = await Promise.all([
this.prisma.vehicle.findMany({
where,
include: {
brand: true,
},
skip,
take: limit,
orderBy: { [sortBy]: sortOrder },
}),
this.prisma.vehicle.count({ where }),
]);
return new PaginatedResponseDto(vehicles, total, page, limit);
}
async getVehicleById(
vehicleId: string,
userId: string,
allowedBrandCodes: string[],
hasFullAccess: boolean,
) {
const vehicle = await this.prisma.vehicle.findUnique({
where: { id: vehicleId },
include: {
brand: true,
categories: {
include: { category: true },
orderBy: { category: { sortOrder: 'asc' } },
},
},
});
if (!vehicle) {
throw new NotFoundException('Arac bulunamadi');
}
// Check brand access
if (!hasFullAccess && !allowedBrandCodes.includes(vehicle.brand.code)) {
throw new ForbiddenException(
`"${vehicle.brand.name}" markasina erisiniz bulunmuyor`,
);
}
return vehicle;
}
async getVehicleByVin(vin: string) {
return this.prisma.vehicle.findUnique({
where: { vin },
include: {
brand: true,
categories: {
include: { category: true },
},
parts: true,
},
});
}
async deleteVehicle(vehicleId: string, userId: string) {
// Check if vehicle exists and user has queried it
const queryLog = await this.prisma.queryLog.findFirst({
where: {
vehicleId,
userId,
},
});
if (!queryLog) {
throw new NotFoundException('Arac gecmisinizde bulunamadi');
}
// Delete only the query log, not the vehicle itself
await this.prisma.queryLog.deleteMany({
where: {
vehicleId,
userId,
},
});
return { message: 'Arac gecmisinizden kaldirildi' };
}
async getVehicleCategories(vehicleId: string) {
const vehicle = await this.prisma.vehicle.findUnique({
where: { id: vehicleId },
include: {
categories: {
include: {
category: {
include: {
children: true,
},
},
},
orderBy: { category: { sortOrder: 'asc' } },
},
},
});
if (!vehicle) {
throw new NotFoundException('Arac bulunamadi');
}
return {
items: vehicle.categories.map((vc) => ({
...vc.category,
partCount: vc.partCount,
})),
total: vehicle.categories.length,
};
}
async getVehicleParts(vehicleId: string, pagination: PaginationDto) {
const { page = 1, limit = 20, sortBy = 'createdAt', sortOrder = 'desc' } = pagination;
const skip = (page - 1) * limit;
const [parts, total] = await Promise.all([
this.prisma.part.findMany({
where: { vehicleId },
include: { category: true },
skip,
take: limit,
orderBy: { [sortBy]: sortOrder },
}),
this.prisma.part.count({ where: { vehicleId } }),
]);
return new PaginatedResponseDto(parts, total, page, limit);
}
async getCategoryPartsByVin(
vin: string,
categoryId: string,
pagination: PaginationDto,
allowedBrandCodes: string[],
hasFullAccess: boolean,
) {
// Find vehicle by VIN
const vehicle = await this.prisma.vehicle.findUnique({
where: { vin: vin.toUpperCase() },
include: {
brand: true,
},
});
if (!vehicle) {
throw new NotFoundException('Arac bulunamadi');
}
// Check brand access
if (!hasFullAccess && !allowedBrandCodes.includes(vehicle.brand.code)) {
throw new ForbiddenException(
`"${vehicle.brand.name}" markasina erisiniz bulunmuyor`,
);
}
// Find category
const category = await this.prisma.category.findUnique({
where: { id: categoryId },
});
if (!category) {
throw new NotFoundException('Kategori bulunamadi');
}
// Get parts for this vehicle and category
const { page = 1, limit = 50, sortBy = 'oemCode', sortOrder = 'asc' } = pagination;
const skip = (page - 1) * limit;
const [parts, total] = await Promise.all([
this.prisma.part.findMany({
where: {
vehicleId: vehicle.id,
categoryId: categoryId,
},
skip,
take: limit,
orderBy: { [sortBy]: sortOrder },
}),
this.prisma.part.count({
where: {
vehicleId: vehicle.id,
categoryId: categoryId,
},
}),
]);
return {
vehicle: {
id: vehicle.id,
vin: vehicle.vin,
brand: vehicle.brand,
model: vehicle.model,
year: vehicle.year,
series: vehicle.series,
engineCode: vehicle.engineCode,
},
category: {
id: category.id,
code: category.code,
nameTr: category.nameTr,
nameEn: category.nameEn,
slug: category.slug,
iconName: category.iconName,
schemaImageUrl: category.schemaImageUrl,
},
parts: parts.map((part) => ({
id: part.id,
oemCode: part.oemCode,
alternativeOems: (part.oemCodes as string[]) || [],
nameEn: part.nameEn,
nameTr: part.nameTr,
description: part.description,
positionCode: part.positionCode,
imageUrl: part.imageUrl,
prices: (part.brandPrices as Array<{ brand: string; price: number; currency: string; inStock: boolean }>) || [],
})),
totalParts: total,
};
}
}

View File

@@ -0,0 +1,341 @@
import { Injectable, ForbiddenException, Logger } from '@nestjs/common';
import { Prisma, PrismaClient } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { VinApiService } from '../../integrations/vin-api/vin-api.service';
import { DecodedCategory, DecodedPart } from '../../integrations/vin-api/vin-api.types';
import { EmexService, DecodedVehicle as EmexDecodedVehicle } from '../../integrations/emex';
import { normalizeVin } from '@sase/shared';
// Type for Prisma transaction client
type PrismaTransactionClient = Omit<
PrismaClient,
'$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
>;
@Injectable()
export class VinDecoderService {
private readonly logger = new Logger(VinDecoderService.name);
constructor(
private prisma: PrismaService,
private vinApiService: VinApiService,
private emexService: EmexService,
) {}
async decodeVin(
userId: string,
vin: string,
allowedBrandCodes: string[],
hasFullAccess: boolean,
) {
const normalizedVin = normalizeVin(vin);
// 1. Check if vehicle exists in database
const existingVehicle = await this.prisma.vehicle.findUnique({
where: { vin: normalizedVin },
include: {
brand: true,
categories: {
include: { category: true },
orderBy: { category: { sortOrder: 'asc' } },
},
parts: {
include: { category: true },
},
},
});
// 2. If exists, check brand access and return
if (existingVehicle) {
if (!hasFullAccess && !allowedBrandCodes.includes(existingVehicle.brand.code)) {
throw new ForbiddenException(
`"${existingVehicle.brand.name}" markasina erisiniz bulunmuyor. ` +
`Paketinizi yukseltin veya marka seciminizi degistirin.`,
);
}
// Log query
await this.logQuery(userId, normalizedVin, existingVehicle.id);
return {
vehicle: existingVehicle,
fromCache: true,
};
}
// 3. If not exists, call external API (EMEX primary, VinApi fallback)
this.logger.log(`Fetching VIN from external API: ${normalizedVin}`);
const apiResponse = await this.fetchVinFromExternalSources(normalizedVin);
// 4. Get or create brand
const brand = await this.getOrCreateBrand(apiResponse.brand);
// 5. Check brand access before saving
if (!hasFullAccess && !allowedBrandCodes.includes(brand.code)) {
throw new ForbiddenException(
`"${brand.name}" markasina erisiniz bulunmuyor. ` +
`Paketinizi yukseltin veya marka seciminizi degistirin.`,
);
}
// 6. Save vehicle with categories and parts
const vehicle = await this.prisma.$transaction(async (tx) => {
// Create vehicle
const newVehicle = await tx.vehicle.create({
data: {
vin: normalizedVin,
brandId: brand.id,
model: apiResponse.model,
year: apiResponse.year,
series: apiResponse.series,
bodyType: apiResponse.bodyType,
engineCode: apiResponse.engineCode,
engineType: apiResponse.engineType,
engineVolume: apiResponse.engineVolume,
transmission: apiResponse.transmission,
driveType: apiResponse.driveType,
colorCode: apiResponse.colorCode,
rawResponse: apiResponse.raw as Prisma.InputJsonValue,
queriedById: userId,
},
});
// Create categories and parts
for (const cat of apiResponse.categories || []) {
const category = await this.getOrCreateCategory(tx, cat);
// Create vehicle-category relation
await tx.vehicleCategory.create({
data: {
vehicleId: newVehicle.id,
categoryId: category.id,
partCount: cat.parts?.length || 0,
},
});
// Create parts
if (cat.parts && cat.parts.length > 0) {
await tx.part.createMany({
data: cat.parts.map((p: DecodedPart) => ({
vehicleId: newVehicle.id,
categoryId: category.id,
oemCode: p.oemCode,
oemCodes: p.alternativeOems,
nameEn: p.nameEn,
nameTr: this.translatePartName(p.nameEn),
description: p.description,
positionCode: p.positionCode,
positionX: p.positionX,
positionY: p.positionY,
brandPrices: JSON.parse(JSON.stringify(p.prices || [])),
imageUrl: p.imageUrl,
})),
});
}
}
return newVehicle;
});
// 7. Log query
await this.logQuery(userId, normalizedVin, vehicle.id);
// 8. Fetch complete vehicle with relations
const completeVehicle = await this.prisma.vehicle.findUnique({
where: { id: vehicle.id },
include: {
brand: true,
categories: {
include: { category: true },
orderBy: { category: { sortOrder: 'asc' } },
},
parts: {
include: { category: true },
},
},
});
return {
vehicle: completeVehicle,
fromCache: false,
};
}
/**
* Fetches VIN data from external sources.
* Uses EMEX as the primary source, falls back to VinApiService if EMEX fails.
*/
private async fetchVinFromExternalSources(vin: string) {
// Check if VIN manufacturer is supported by EMEX
const isEmexSupported = this.emexService.isSupported(vin);
if (isEmexSupported) {
try {
this.logger.log(`Attempting EMEX decode for VIN: ${vin}`);
const emexResponse = await this.emexService.decodeVin(vin);
// Validate EMEX response has required data
if (emexResponse && emexResponse.brand && emexResponse.model) {
this.logger.log(
`EMEX decode successful: ${emexResponse.brand} ${emexResponse.model} (${emexResponse.year})`,
);
return this.mapEmexResponseToVinApiFormat(emexResponse);
}
this.logger.warn(
`EMEX returned incomplete data for VIN: ${vin}, falling back to VinApi`,
);
} catch (error) {
const err = error as Error;
this.logger.warn(
`EMEX decode failed for VIN: ${vin}, falling back to VinApi. Error: ${err.message}`,
);
}
} else {
this.logger.log(
`VIN manufacturer not supported by EMEX: ${vin}, using VinApi`,
);
}
// Fallback to VinApiService
this.logger.log(`Using VinApi fallback for VIN: ${vin}`);
return this.vinApiService.decodeVin(vin);
}
/**
* Maps EMEX response format to VinApiService format for compatibility
*/
private mapEmexResponseToVinApiFormat(emexResponse: EmexDecodedVehicle) {
return {
brand: emexResponse.brand,
model: emexResponse.model,
year: emexResponse.year,
series: emexResponse.series,
bodyType: emexResponse.bodyType,
engineCode: emexResponse.engineCode,
engineType: emexResponse.engineType,
engineVolume: emexResponse.engineVolume,
transmission: emexResponse.transmission,
driveType: emexResponse.driveType,
colorCode: emexResponse.colorCode,
raw: emexResponse.raw,
categories: emexResponse.categories.map((cat) => ({
code: cat.code,
nameEn: cat.nameEn,
nameTr: cat.nameTr,
description: cat.description,
iconName: cat.iconName,
schemaImageUrl: cat.schemaImageUrl,
parts: cat.parts.map((part) => ({
oemCode: part.oemCode,
alternativeOems: part.alternativeOems || [],
nameEn: part.nameEn,
nameTr: part.nameTr,
description: part.description,
positionCode: part.positionCode,
positionX: part.positionX,
positionY: part.positionY,
imageUrl: part.imageUrl,
prices: part.prices || [],
})),
})),
};
}
private async getOrCreateBrand(brandName: string) {
const code = brandName.toUpperCase().replace(/[^A-Z0-9]/g, '');
let brand = await this.prisma.brand.findUnique({ where: { code } });
if (!brand) {
brand = await this.prisma.brand.create({
data: { code, name: brandName },
});
}
return brand;
}
private async getOrCreateCategory(tx: PrismaTransactionClient, cat: DecodedCategory) {
const code = cat.code;
let category = await tx.category.findUnique({ where: { code } });
if (!category) {
category = await tx.category.create({
data: {
code,
nameEn: cat.nameEn,
nameTr: this.translateCategoryName(cat.nameEn),
slug: this.slugify(cat.nameEn),
description: cat.description,
iconName: cat.iconName,
schemaImageUrl: cat.schemaImageUrl,
},
});
}
return category;
}
private async logQuery(userId: string, vin: string, vehicleId: string) {
await this.prisma.queryLog.create({
data: {
userId,
vin,
vehicleId,
},
});
}
// Simple translation mapping (in production, use a proper translation service)
private translatePartName(nameEn: string): string {
const translations: Record<string, string> = {
'Engine': 'Motor',
'Brake': 'Fren',
'Wheel': 'Tekerlek',
'Oil Filter': 'Yag Filtresi',
'Air Filter': 'Hava Filtresi',
'Spark Plug': 'Buji',
'Battery': 'Aku',
'Headlight': 'Far',
'Mirror': 'Ayna',
'Bumper': 'Tampon',
};
for (const [en, tr] of Object.entries(translations)) {
if (nameEn.toLowerCase().includes(en.toLowerCase())) {
return nameEn.replace(new RegExp(en, 'gi'), tr);
}
}
return nameEn;
}
private translateCategoryName(nameEn: string): string {
const translations: Record<string, string> = {
'Engine': 'Motor',
'Brake System': 'Fren Sistemi',
'Suspension': 'Suspansiyon',
'Electrical': 'Elektrik',
'Body': 'Govde',
'Interior': 'Ic Mekan',
'Exterior': 'Dis Mekan',
'Transmission': 'Sanziman',
'Exhaust': 'Egzoz',
'Cooling': 'Sogutma',
};
return translations[nameEn] || nameEn;
}
private slugify(text: string): string {
return text
.toString()
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w\-]+/g, '')
.replace(/\-\-+/g, '-');
}
}

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@@ -0,0 +1,28 @@
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
constructor() {
super({
log: [
{ emit: 'event', level: 'query' },
{ emit: 'stdout', level: 'info' },
{ emit: 'stdout', level: 'warn' },
{ emit: 'stdout', level: 'error' },
],
});
}
async onModuleInit() {
await this.$connect();
this.logger.log('Prisma connected to database');
}
async onModuleDestroy() {
await this.$disconnect();
this.logger.log('Prisma disconnected from database');
}
}

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { RedisService } from './redis.service';
@Global()
@Module({
providers: [RedisService],
exports: [RedisService],
})
export class RedisModule {}

View File

@@ -0,0 +1,83 @@
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
@Injectable()
export class RedisService implements OnModuleInit, OnModuleDestroy {
private client: Redis;
private readonly logger = new Logger(RedisService.name);
constructor(private configService: ConfigService) {}
async onModuleInit() {
this.client = new Redis({
host: this.configService.get<string>('REDIS_HOST', 'localhost'),
port: this.configService.get<number>('REDIS_PORT', 6379),
password: this.configService.get<string>('REDIS_PASSWORD', ''),
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
},
});
this.client.on('connect', () => {
this.logger.log('Redis connected');
});
this.client.on('error', (err) => {
this.logger.error('Redis error:', err);
});
}
async onModuleDestroy() {
await this.client.quit();
this.logger.log('Redis disconnected');
}
getClient(): Redis {
return this.client;
}
async get(key: string): Promise<string | null> {
return this.client.get(key);
}
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
if (ttlSeconds) {
await this.client.set(key, value, 'EX', ttlSeconds);
} else {
await this.client.set(key, value);
}
}
async del(key: string): Promise<void> {
await this.client.del(key);
}
async exists(key: string): Promise<boolean> {
const result = await this.client.exists(key);
return result === 1;
}
async incr(key: string): Promise<number> {
return this.client.incr(key);
}
async expire(key: string, seconds: number): Promise<void> {
await this.client.expire(key, seconds);
}
async setJson<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
await this.set(key, JSON.stringify(value), ttlSeconds);
}
async getJson<T>(key: string): Promise<T | null> {
const value = await this.get(key);
if (!value) return null;
try {
return JSON.parse(value) as T;
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

26
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2022",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*", "prisma/**/*"],
"exclude": ["node_modules", "dist"]
}