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:
38
apps/api/.env.example
Normal file
38
apps/api/.env.example
Normal 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
8
apps/api/nest-cli.json
Normal 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
82
apps/api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
295
apps/api/prisma/schema.prisma
Normal file
295
apps/api/prisma/schema.prisma
Normal 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
139
apps/api/prisma/seed.ts
Normal 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();
|
||||
});
|
||||
58
apps/api/src/app.module.ts
Normal file
58
apps/api/src/app.module.ts
Normal 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 {}
|
||||
19
apps/api/src/common/decorators/current-user.decorator.ts
Normal file
19
apps/api/src/common/decorators/current-user.decorator.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
4
apps/api/src/common/decorators/public.decorator.ts
Normal file
4
apps/api/src/common/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
23
apps/api/src/common/decorators/roles.decorator.ts
Normal file
23
apps/api/src/common/decorators/roles.decorator.ts
Normal 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];
|
||||
};
|
||||
27
apps/api/src/common/dto/api-response.dto.ts
Normal file
27
apps/api/src/common/dto/api-response.dto.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
41
apps/api/src/common/dto/pagination.dto.ts
Normal file
41
apps/api/src/common/dto/pagination.dto.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
62
apps/api/src/common/filters/http-exception.filter.ts
Normal file
62
apps/api/src/common/filters/http-exception.filter.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
59
apps/api/src/common/filters/prisma-exception.filter.ts
Normal file
59
apps/api/src/common/filters/prisma-exception.filter.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
56
apps/api/src/common/guards/brand-access.guard.ts
Normal file
56
apps/api/src/common/guards/brand-access.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
31
apps/api/src/common/guards/jwt-auth.guard.ts
Normal file
31
apps/api/src/common/guards/jwt-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
28
apps/api/src/common/guards/roles.guard.ts
Normal file
28
apps/api/src/common/guards/roles.guard.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
44
apps/api/src/common/interceptors/logging.interceptor.ts
Normal file
44
apps/api/src/common/interceptors/logging.interceptor.ts
Normal 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}`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
26
apps/api/src/common/interceptors/timeout.interceptor.ts
Normal file
26
apps/api/src/common/interceptors/timeout.interceptor.ts
Normal 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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
27
apps/api/src/common/interceptors/transform.interceptor.ts
Normal file
27
apps/api/src/common/interceptors/transform.interceptor.ts
Normal 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(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
20
apps/api/src/common/pipes/vin-validation.pipe.ts
Normal file
20
apps/api/src/common/pipes/vin-validation.pipe.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
543
apps/api/src/integrations/emex/emex.mapper.ts
Normal file
543
apps/api/src/integrations/emex/emex.mapper.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
17
apps/api/src/integrations/emex/emex.module.ts
Normal file
17
apps/api/src/integrations/emex/emex.module.ts
Normal 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 {}
|
||||
406
apps/api/src/integrations/emex/emex.service.ts
Normal file
406
apps/api/src/integrations/emex/emex.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
214
apps/api/src/integrations/emex/emex.types.ts
Normal file
214
apps/api/src/integrations/emex/emex.types.ts
Normal 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' },
|
||||
};
|
||||
10
apps/api/src/integrations/emex/index.ts
Normal file
10
apps/api/src/integrations/emex/index.ts
Normal 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';
|
||||
10
apps/api/src/integrations/integrations.module.ts
Normal file
10
apps/api/src/integrations/integrations.module.ts
Normal 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 {}
|
||||
58
apps/api/src/integrations/vin-api/vin-api.mapper.ts
Normal file
58
apps/api/src/integrations/vin-api/vin-api.mapper.ts
Normal 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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
148
apps/api/src/integrations/vin-api/vin-api.service.ts
Normal file
148
apps/api/src/integrations/vin-api/vin-api.service.ts
Normal 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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
92
apps/api/src/integrations/vin-api/vin-api.types.ts
Normal file
92
apps/api/src/integrations/vin-api/vin-api.types.ts
Normal 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
51
apps/api/src/main.ts
Normal 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();
|
||||
82
apps/api/src/modules/auth/auth.controller.ts
Normal file
82
apps/api/src/modules/auth/auth.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
28
apps/api/src/modules/auth/auth.module.ts
Normal file
28
apps/api/src/modules/auth/auth.module.ts
Normal 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 {}
|
||||
220
apps/api/src/modules/auth/auth.service.ts
Normal file
220
apps/api/src/modules/auth/auth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
18
apps/api/src/modules/auth/dto/forgot-password.dto.ts
Normal file
18
apps/api/src/modules/auth/dto/forgot-password.dto.ts
Normal 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;
|
||||
}
|
||||
10
apps/api/src/modules/auth/dto/login.dto.ts
Normal file
10
apps/api/src/modules/auth/dto/login.dto.ts
Normal 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;
|
||||
}
|
||||
7
apps/api/src/modules/auth/dto/refresh-token.dto.ts
Normal file
7
apps/api/src/modules/auth/dto/refresh-token.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsString, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Refresh token gerekli' })
|
||||
refreshToken: string;
|
||||
}
|
||||
20
apps/api/src/modules/auth/dto/register.dto.ts
Normal file
20
apps/api/src/modules/auth/dto/register.dto.ts
Normal 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;
|
||||
}
|
||||
27
apps/api/src/modules/auth/strategies/jwt-refresh.strategy.ts
Normal file
27
apps/api/src/modules/auth/strategies/jwt-refresh.strategy.ts
Normal 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') {}
|
||||
30
apps/api/src/modules/auth/strategies/jwt.strategy.ts
Normal file
30
apps/api/src/modules/auth/strategies/jwt.strategy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
6
apps/api/src/modules/auth/types/jwt-payload.type.ts
Normal file
6
apps/api/src/modules/auth/types/jwt-payload.type.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
59
apps/api/src/modules/brands/brands.controller.ts
Normal file
59
apps/api/src/modules/brands/brands.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/brands/brands.module.ts
Normal file
10
apps/api/src/modules/brands/brands.module.ts
Normal 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 {}
|
||||
183
apps/api/src/modules/brands/brands.service.ts
Normal file
183
apps/api/src/modules/brands/brands.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
8
apps/api/src/modules/brands/dto/select-brands.dto.ts
Normal file
8
apps/api/src/modules/brands/dto/select-brands.dto.ts
Normal 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[];
|
||||
}
|
||||
6
apps/api/src/modules/categories/categories.module.ts
Normal file
6
apps/api/src/modules/categories/categories.module.ts
Normal 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 {}
|
||||
27
apps/api/src/modules/parts/categories.controller.ts
Normal file
27
apps/api/src/modules/parts/categories.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
76
apps/api/src/modules/parts/categories.service.ts
Normal file
76
apps/api/src/modules/parts/categories.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
19
apps/api/src/modules/parts/dto/part-search.dto.ts
Normal file
19
apps/api/src/modules/parts/dto/part-search.dto.ts
Normal 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;
|
||||
}
|
||||
37
apps/api/src/modules/parts/parts.controller.ts
Normal file
37
apps/api/src/modules/parts/parts.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/modules/parts/parts.module.ts
Normal file
12
apps/api/src/modules/parts/parts.module.ts
Normal 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 {}
|
||||
110
apps/api/src/modules/parts/parts.service.ts
Normal file
110
apps/api/src/modules/parts/parts.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
85
apps/api/src/modules/payments/dto/initialize-payment.dto.ts
Normal file
85
apps/api/src/modules/payments/dto/initialize-payment.dto.ts
Normal 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;
|
||||
}
|
||||
11
apps/api/src/modules/payments/dto/payment-callback.dto.ts
Normal file
11
apps/api/src/modules/payments/dto/payment-callback.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsString, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class PaymentCallbackDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
token: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
conversationId: string;
|
||||
}
|
||||
186
apps/api/src/modules/payments/iyzico.service.ts
Normal file
186
apps/api/src/modules/payments/iyzico.service.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
63
apps/api/src/modules/payments/payments.controller.ts
Normal file
63
apps/api/src/modules/payments/payments.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/modules/payments/payments.module.ts
Normal file
11
apps/api/src/modules/payments/payments.module.ts
Normal 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 {}
|
||||
283
apps/api/src/modules/payments/payments.service.ts
Normal file
283
apps/api/src/modules/payments/payments.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
41
apps/api/src/modules/subscriptions/plans.service.ts
Normal file
41
apps/api/src/modules/subscriptions/plans.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/modules/subscriptions/subscriptions.module.ts
Normal file
11
apps/api/src/modules/subscriptions/subscriptions.module.ts
Normal 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 {}
|
||||
228
apps/api/src/modules/subscriptions/subscriptions.service.ts
Normal file
228
apps/api/src/modules/subscriptions/subscriptions.service.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
405
apps/api/src/modules/translations/data/automotive-terms.ts
Normal file
405
apps/api/src/modules/translations/data/automotive-terms.ts
Normal 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,
|
||||
};
|
||||
663
apps/api/src/modules/translations/data/brand-names.ts
Normal file
663
apps/api/src/modules/translations/data/brand-names.ts
Normal 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());
|
||||
}
|
||||
39
apps/api/src/modules/translations/index.ts
Normal file
39
apps/api/src/modules/translations/index.ts
Normal 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';
|
||||
8
apps/api/src/modules/translations/translations.module.ts
Normal file
8
apps/api/src/modules/translations/translations.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TranslationsService } from './translations.service';
|
||||
|
||||
@Module({
|
||||
providers: [TranslationsService],
|
||||
exports: [TranslationsService],
|
||||
})
|
||||
export class TranslationsModule {}
|
||||
459
apps/api/src/modules/translations/translations.service.ts
Normal file
459
apps/api/src/modules/translations/translations.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
13
apps/api/src/modules/users/dto/update-user.dto.ts
Normal file
13
apps/api/src/modules/users/dto/update-user.dto.ts
Normal 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;
|
||||
}
|
||||
39
apps/api/src/modules/users/users.controller.ts
Normal file
39
apps/api/src/modules/users/users.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/users/users.module.ts
Normal file
10
apps/api/src/modules/users/users.module.ts
Normal 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 {}
|
||||
59
apps/api/src/modules/users/users.service.ts
Normal file
59
apps/api/src/modules/users/users.service.ts
Normal 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() },
|
||||
});
|
||||
}
|
||||
}
|
||||
12
apps/api/src/modules/vehicles/dto/decode-vin.dto.ts
Normal file
12
apps/api/src/modules/vehicles/dto/decode-vin.dto.ts
Normal 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;
|
||||
}
|
||||
19
apps/api/src/modules/vehicles/dto/vehicle-filter.dto.ts
Normal file
19
apps/api/src/modules/vehicles/dto/vehicle-filter.dto.ts
Normal 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;
|
||||
}
|
||||
109
apps/api/src/modules/vehicles/vehicles.controller.ts
Normal file
109
apps/api/src/modules/vehicles/vehicles.controller.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/modules/vehicles/vehicles.module.ts
Normal file
13
apps/api/src/modules/vehicles/vehicles.module.ts
Normal 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 {}
|
||||
258
apps/api/src/modules/vehicles/vehicles.service.ts
Normal file
258
apps/api/src/modules/vehicles/vehicles.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
341
apps/api/src/modules/vehicles/vin-decoder.service.ts
Normal file
341
apps/api/src/modules/vehicles/vin-decoder.service.ts
Normal 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, '-');
|
||||
}
|
||||
}
|
||||
9
apps/api/src/prisma/prisma.module.ts
Normal file
9
apps/api/src/prisma/prisma.module.ts
Normal 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 {}
|
||||
28
apps/api/src/prisma/prisma.service.ts
Normal file
28
apps/api/src/prisma/prisma.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
9
apps/api/src/redis/redis.module.ts
Normal file
9
apps/api/src/redis/redis.module.ts
Normal 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 {}
|
||||
83
apps/api/src/redis/redis.service.ts
Normal file
83
apps/api/src/redis/redis.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
4
apps/api/tsconfig.build.json
Normal file
4
apps/api/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
26
apps/api/tsconfig.json
Normal file
26
apps/api/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user