feat: PL24 integration + EMEX on-demand parts loading

## Major Changes

### PL24 Integration (PartsLink24 API)
- Add PL24 auth service with JWT token management
- Add PL24 service for VIN decode and parts catalog
- Support for VAG, Mercedes, BMW, Ford, Renault and more
- Schema image download with base64 decoding
- Image deduplication via SchemaPic table

### EMEX On-Demand Loading
- Remove automatic parts scraping during VIN decode
- Add fetchCategoryParts() for on-demand loading
- Store category URLs in rawResponse for later fetching
- Fix Chrome executable path for Puppeteer

### VIN Decode Flow
- PL24 first, fallback to EMEX if not available
- Both sources now use on-demand parts loading
- Faster VIN lookup (no parts scraped upfront)

### Database Schema
- Add SchemaPic model for image deduplication
- Add schemaPicId to VehicleCategory
- Add EMEX models for parallel scraping

### Frontend Improvements
- Dark theme with Tailwind CSS
- Improved UI components (card, button, input)
- Better category and parts display
- Schema image viewer

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-01-22 11:15:06 +01:00
parent b9532a61ee
commit 2358b38b4d
155 changed files with 31575 additions and 1109 deletions

View File

@@ -7,31 +7,482 @@ datasource db {
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")
id String @id @default(cuid())
email String @unique
name String?
avatar String?
provider AuthProvider @default(EMAIL)
providerId String?
passwordHash String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
role Role @default(USER)
queryLogs QueryLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
selectedBrands UserBrand[]
subscription UserSubscription?
vehicles Vehicle[] @relation("QueriedBy")
@@index([email])
@@index([role])
@@map("users")
}
model Brand {
id String @id @default(cuid())
code String @unique
name String
logo String?
isActive Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userBrands UserBrand[]
vehicles Vehicle[]
@@map("brands")
}
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)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
subscriptions UserSubscription[]
@@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?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
payments Payment[]
plan Plan @relation(fields: [planId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([status])
@@index([currentPeriodEnd])
@@index([planId], map: "user_subscriptions_planId_fkey")
@@map("user_subscriptions")
}
model UserBrand {
id String @id @default(cuid())
userId String
brandId String
createdAt DateTime @default(now())
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, brandId])
@@index([userId])
@@index([brandId], map: "user_brands_brandId_fkey")
@@map("user_brands")
}
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?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parts Part[]
queryLogs QueryLog[]
categories VehicleCategory[]
brand Brand @relation(fields: [brandId], references: [id])
queriedBy User? @relation("QueriedBy", fields: [queriedById], references: [id])
@@index([vin])
@@index([brandId])
@@index([queriedById], map: "vehicles_queriedById_fkey")
@@map("vehicles")
}
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)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parent Category? @relation("CategoryTree", fields: [parentId], references: [id])
children Category[] @relation("CategoryTree")
parts Part[]
vehicleCategories VehicleCategory[]
@@index([parentId])
@@map("categories")
}
model VehicleCategory {
id String @id @default(cuid())
vehicleId String
categoryId String
partCount Int @default(0)
schemaImageUrl String? @db.Text // Original URL from PL24
schemaPicId String? // Reference to deduplicated schema image
createdAt DateTime @default(now())
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
schemaPic SchemaPic? @relation(fields: [schemaPicId], references: [id])
@@unique([vehicleId, categoryId])
@@index([vehicleId])
@@index([categoryId])
@@index([schemaPicId])
@@map("vehicle_categories")
}
model SchemaPic {
id String @id @default(cuid())
imageId String @unique // PL24 image ID (e.g., "194500200") for deduplication
localPath String // Local path: /images/schemas/194500200.png
originalUrl String? @db.Text // Original PL24 URL for reference
fileSize Int? // File size in bytes
width Int? // Image width
height Int? // Image height
createdAt DateTime @default(now())
vehicleCategories VehicleCategory[]
@@index([imageId])
@@map("schema_pics")
}
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
imageUrl String?
notes String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
@@index([oemCode])
@@index([vehicleId])
@@index([categoryId])
@@index([vehicleId, categoryId])
@@map("parts")
}
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?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
subscription UserSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
@@index([status])
@@index([providerTxId])
@@index([subscriptionId], map: "payments_subscriptionId_fkey")
@@map("payments")
}
model QueryLog {
id String @id @default(cuid())
userId String
vehicleId String?
vin String @db.VarChar(17)
responseTime Int?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
vehicle Vehicle? @relation(fields: [vehicleId], references: [id])
@@index([userId])
@@index([vin])
@@index([createdAt])
@@index([userId, createdAt])
@@index([vehicleId], map: "query_logs_vehicleId_fkey")
@@map("query_logs")
}
model EmexCatalog {
id String @id @default(cuid())
code String @unique
name String
brandCode String
icon String?
supportVinSearch Boolean @default(false)
supportParameterIdentification Boolean @default(false)
supportQuickGroups Boolean @default(false)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
categories EmexCategory[]
parts EmexPart[]
vehicles EmexVehicle[]
@@index([brandCode])
@@map("emex_catalogs")
}
model EmexVehicle {
id String @id @default(cuid())
catalogId String
name String
engine String?
engineCode String?
options Json?
ssd String @db.VarChar(500)
pathData String? @db.Text
sourceUrl String? @db.Text
uniqueKey String @db.VarChar(255)
scrapeComplete Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
groupLinks EmexVehicleCategory[]
partLinks EmexVehiclePart[]
catalog EmexCatalog @relation(fields: [catalogId], references: [id], onDelete: Cascade)
@@unique([catalogId, uniqueKey])
@@index([catalogId])
@@index([ssd])
@@map("emex_vehicles")
}
model EmexCategory {
id String @id @default(cuid())
catalogId String
groupId String
parentId String?
name String
nameTr String
level Int @default(1)
sortOrder Int @default(0)
hasParts Boolean @default(true)
hasChildren Boolean @default(false)
schemaImageUrl String? @db.Text
localImagePath String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
catalog EmexCatalog @relation(fields: [catalogId], references: [id], onDelete: Cascade)
parent EmexCategory? @relation("CategoryTree", fields: [parentId], references: [id])
children EmexCategory[] @relation("CategoryTree")
images EmexPartImage[]
parts EmexPart[]
vehicleLinks EmexVehicleCategory[]
vehiclePartLinks EmexVehiclePart[]
@@unique([catalogId, groupId])
@@index([catalogId])
@@index([parentId])
@@map("emex_categories")
}
model EmexPart {
id String @id @default(cuid())
catalogId String
partNumber String @db.VarChar(50)
name String
nameTr String?
description String? @db.Text
brand String?
oemNumber String?
unit String @default("PC")
notes String? @db.Text
attributes Json?
categoryId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
partNumbers EmexPartNumber[]
catalog EmexCatalog @relation(fields: [catalogId], references: [id], onDelete: Cascade)
category EmexCategory? @relation(fields: [categoryId], references: [id])
vehicleLinks EmexVehiclePart[]
@@unique([catalogId, partNumber])
@@index([catalogId])
@@index([partNumber])
@@index([categoryId])
@@map("emex_parts")
}
model EmexVehiclePart {
id String @id @default(cuid())
vehicleId String
partId String
categoryId String?
quantity Float @default(1)
position String?
createdAt DateTime @default(now())
category EmexCategory? @relation(fields: [categoryId], references: [id])
part EmexPart @relation(fields: [partId], references: [id], onDelete: Cascade)
vehicle EmexVehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
@@unique([vehicleId, partId, categoryId])
@@index([vehicleId])
@@index([partId])
@@index([categoryId])
@@map("emex_vehicle_parts")
}
model EmexVehicleCategory {
id String @id @default(cuid())
vehicleId String
categoryId String
isScraped Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
category EmexCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
vehicle EmexVehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
@@unique([vehicleId, categoryId])
@@index([vehicleId])
@@index([categoryId])
@@map("emex_vehicle_categories")
}
model EmexPartNumber {
id String @id @default(cuid())
partId String
number String @db.VarChar(50)
numberType String @default("ALTERNATE")
brand String?
priority Int @default(0)
createdAt DateTime @default(now())
part EmexPart @relation(fields: [partId], references: [id], onDelete: Cascade)
@@unique([partId, number])
@@index([partId])
@@index([number])
@@map("emex_part_numbers")
}
model EmexPartImage {
id String @id @default(cuid())
categoryId String?
imageType String @default("DIAGRAM")
originalUrl String @db.Text
localPath String?
isPrimary Boolean @default(false)
sortOrder Int @default(0)
downloadedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
category EmexCategory? @relation(fields: [categoryId], references: [id])
@@index([categoryId])
@@index([originalUrl(length: 255)])
@@map("emex_part_images")
}
model EmexScrapeQueue {
id String @id @default(cuid())
catalogId String
taskType String
vehicleId String?
vehicleSsd String? @db.VarChar(500)
vehicleName String?
categoryId String?
groupId String?
status String @default("PENDING")
workerId String?
retryCount Int @default(0)
maxRetries Int @default(3)
errorMessage String? @db.Text
priority Int @default(5)
startedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([catalogId])
@@index([vehicleId])
@@index([priority])
@@map("emex_scrape_queue")
}
model EmexScrapeSession {
id String @id @default(cuid())
catalogId String?
brandCode String
modelName String?
status String @default("PENDING")
totalItems Int @default(0)
processedItems Int @default(0)
failedItems Int @default(0)
lastSsd String? @db.VarChar(500)
lastError String? @db.Text
stats Json?
startedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([brandCode])
@@map("emex_scrape_sessions")
}
model EmexCategoryTranslation {
id String @id @default(cuid())
nameEn String @unique
nameTr String
synonyms Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("emex_category_translations")
}
enum Role {
USER
MODERATOR
@@ -46,85 +497,6 @@ enum AuthProvider {
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
@@ -134,137 +506,6 @@ enum SubscriptionStatus {
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
@@ -273,23 +514,3 @@ enum PaymentStatus {
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")
}