feat: sase.tr v2 full application implementation

Complete rewrite of sase.tr VIN lookup platform with modern stack:

Backend (NestJS 10 + Drizzle ORM + PostgreSQL + Redis + BullMQ):
- 34 DB models (core + PL24 + EMEX schemas)
- Auth via Better Auth (email/password + social)
- Brands, Plans, Subscriptions, Payments (iyzico + EFT)
- VIN decode orchestration (Corgi + PL24 + EMEX + NHTSA)
- Interactive schema viewer backend (MinIO storage)
- EMEX scraping integration (Puppeteer + BullMQ workers)
- Translation module (EN→TR automotive dictionary)
- Admin dashboard API (stats, user mgmt, payment approval)
- Rate limiting, Helmet security, file upload validation

Frontend (Next.js 15 + Tailwind v4 + shadcn/ui + TanStack Query + Zustand):
- 20 routes: auth, dashboard, VIN search, schema viewer, admin
- Interactive schema viewer with zoom/pan/hotspot highlighting
- Subscription management with brand selector
- Payment flow (iyzico 3D Secure + EFT with receipt upload)
- i18n support (TR/EN)
- Error boundaries, loading skeletons, 404 page

Infrastructure:
- 85 tests (52 backend + 33 frontend, Vitest)
- CI/CD (GitHub Actions: lint, typecheck, test, build, deploy)
- Zero-downtime deploy script (PM2)
- Env validation script

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-02-12 02:03:56 +00:00
parent 7fc47ce9cc
commit 56a3c8bfaa
215 changed files with 25043 additions and 0 deletions

47
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
name: Lint, Typecheck, Test & Build
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Biome lint
run: pnpm lint
- name: Type check
run: pnpm typecheck
- name: Unit tests
run: pnpm test
- name: Build
run: pnpm build

51
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: Deploy
on:
push:
branches: [main]
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
deploy:
name: Deploy to Production
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
script_stop: true
script: |
set -euo pipefail
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
cd /home/${{ secrets.SSH_USER }}/ss
echo "$(date '+%Y-%m-%d %H:%M:%S') - Starting deployment..."
echo "$(date '+%Y-%m-%d %H:%M:%S') - Pulling latest changes..."
git pull origin main
echo "$(date '+%Y-%m-%d %H:%M:%S') - Installing dependencies..."
pnpm install --frozen-lockfile
echo "$(date '+%Y-%m-%d %H:%M:%S') - Building..."
pnpm build
echo "$(date '+%Y-%m-%d %H:%M:%S') - Running database migrations..."
cd apps/api && pnpm db:push && cd ../..
echo "$(date '+%Y-%m-%d %H:%M:%S') - Reloading PM2 processes..."
pm2 reload ecosystem.config.js
echo "$(date '+%Y-%m-%d %H:%M:%S') - Deployment complete!"

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

@@ -0,0 +1,16 @@
NODE_ENV=development
PORT=4000
DATABASE_URL=postgresql://sase:YOUR_PASSWORD@127.0.0.1:5432/sase
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=YOUR_REDIS_PASSWORD
BETTER_AUTH_SECRET=YOUR_MIN_32_CHAR_RANDOM_SECRET
BETTER_AUTH_URL=http://localhost:4000
MINIO_ENDPOINT=http://127.0.0.1:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=YOUR_MINIO_PASSWORD
MINIO_BUCKET_NAME=sase-schemas
MINIO_PUBLIC_URL=https://storage.sase.tr/sase-schemas
MINIO_USE_SSL=false
CORS_ORIGIN=http://localhost:3000,https://v2.sase.tr
ML_PREDICTION_ENABLED=false

View File

@@ -0,0 +1,12 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/database/schema/*.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});

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

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

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

@@ -0,0 +1,57 @@
{
"name": "api",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main.js",
"start:prod": "node dist/main.js",
"lint": "biome check src/",
"test": "vitest run",
"test:watch": "vitest",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/database/seed.ts",
"worker": "node dist/worker.js",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@nestjs/common": "^10.4.0",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.0",
"@nestjs/platform-express": "^10.4.0",
"@nestjs/schedule": "^4.1.0",
"@nestjs/swagger": "^8.1.0",
"@nestjs/throttler": "^6.3.0",
"@sase/config": "workspace:*",
"@sase/shared": "workspace:*",
"better-auth": "^1.2.0",
"bullmq": "^5.30.0",
"dotenv": "^16.4.0",
"drizzle-orm": "^0.41.0",
"helmet": "^8.1.0",
"ioredis": "^5.4.0",
"postgres": "^3.4.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@nestjs/schematics": "^10.2.0",
"@nestjs/testing": "^10.4.0",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.0",
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^3.2.4",
"drizzle-kit": "^0.31.4",
"puppeteer": "^23.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

View File

@@ -0,0 +1,55 @@
import { Controller, Get, Param, Query } from "@nestjs/common";
import { AdminService } from "./admin.service";
import { Roles } from "../common/decorators/roles.decorator";
@Controller("admin")
@Roles("admin")
export class AdminController {
constructor(private adminService: AdminService) {}
@Get("dashboard")
async getDashboardStats() {
return this.adminService.getDashboardStats();
}
@Get("users")
async getUsers(
@Query("search") search?: string,
@Query("page") page?: string,
@Query("limit") limit?: string,
) {
return this.adminService.getUsers(
search,
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
);
}
@Get("users/:id")
async getUserDetail(@Param("id") id: string) {
return this.adminService.getUserDetail(id);
}
@Get("payments/pending")
async getPendingPayments() {
return this.adminService.getPendingPayments();
}
@Get("query-logs")
async getQueryLogs(
@Query("page") page?: string,
@Query("limit") limit?: string,
@Query("userId") userId?: string,
) {
return this.adminService.getQueryLogs(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 50,
userId,
);
}
@Get("stats/daily")
async getDailyStats() {
return this.adminService.getDailyStats();
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { AdminController } from "./admin.controller";
import { AdminService } from "./admin.service";
@Module({
controllers: [AdminController],
providers: [AdminService],
})
export class AdminModule {}

View File

@@ -0,0 +1,251 @@
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { and, count, desc, eq, gte, ilike, or, sql } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider";
import {
users,
userSubscriptions,
payments,
queryLogs,
brands,
} from "../database/schema/core";
@Injectable()
export class AdminService {
private readonly logger = new Logger(AdminService.name);
constructor(@Inject(DATABASE) private db: Database) {}
async getDashboardStats() {
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const [
totalUsersResult,
activeSubscriptionsResult,
totalRevenueResult,
totalQueriesResult,
newUsersThisMonthResult,
pendingPaymentsResult,
] = await Promise.all([
// Total users
this.db.select({ count: count() }).from(users),
// Active subscriptions
this.db
.select({ count: count() })
.from(userSubscriptions)
.where(eq(userSubscriptions.status, "active")),
// Total revenue (completed payments)
this.db
.select({
total: sql<number>`coalesce(sum(${payments.amount}), 0)`,
})
.from(payments)
.where(eq(payments.status, "completed")),
// Total queries last 30 days
this.db
.select({ count: count() })
.from(queryLogs)
.where(gte(queryLogs.createdAt, thirtyDaysAgo)),
// New users this month
this.db
.select({ count: count() })
.from(users)
.where(gte(users.createdAt, startOfMonth)),
// Pending payments
this.db
.select({ count: count() })
.from(payments)
.where(
and(eq(payments.method, "eft"), eq(payments.status, "pending")),
),
]);
return {
totalUsers: totalUsersResult[0].count,
activeSubscriptions: activeSubscriptionsResult[0].count,
totalRevenue: totalRevenueResult[0].total,
totalQueries: totalQueriesResult[0].count,
newUsersThisMonth: newUsersThisMonthResult[0].count,
pendingPayments: pendingPaymentsResult[0].count,
};
}
async getUsers(search?: string, page = 1, limit = 20) {
const offset = (page - 1) * limit;
const conditions = search
? or(
ilike(users.email, `%${search}%`),
ilike(users.name, `%${search}%`),
)
: undefined;
const [items, totalResult] = await Promise.all([
this.db
.select({
id: users.id,
name: users.name,
email: users.email,
role: users.role,
emailVerified: users.emailVerified,
createdAt: users.createdAt,
})
.from(users)
.where(conditions)
.orderBy(desc(users.createdAt))
.limit(limit)
.offset(offset),
this.db.select({ count: count() }).from(users).where(conditions),
]);
// Get subscription statuses for these users
const userIds = items.map((u) => u.id);
const subscriptions =
userIds.length > 0
? await this.db
.select({
userId: userSubscriptions.userId,
status: userSubscriptions.status,
})
.from(userSubscriptions)
.where(
and(
sql`${userSubscriptions.userId} = ANY(${userIds})`,
eq(userSubscriptions.status, "active"),
),
)
: [];
const activeSubMap = new Set(subscriptions.map((s) => s.userId));
const usersWithSub = items.map((u) => ({
...u,
subscriptionStatus: activeSubMap.has(u.id) ? "active" : "none",
}));
return {
items: usersWithSub,
total: totalResult[0].count,
page,
limit,
totalPages: Math.ceil(totalResult[0].count / limit),
};
}
async getUserDetail(userId: string) {
const [user] = await this.db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user) {
throw new NotFoundException("User not found");
}
const [subscriptions, userPayments] = await Promise.all([
this.db
.select()
.from(userSubscriptions)
.where(eq(userSubscriptions.userId, userId))
.orderBy(desc(userSubscriptions.createdAt)),
this.db
.select()
.from(payments)
.where(eq(payments.userId, userId))
.orderBy(desc(payments.createdAt)),
]);
return {
...user,
subscriptions,
payments: userPayments,
};
}
async getPendingPayments() {
const result = await this.db
.select({
id: payments.id,
userId: payments.userId,
userName: users.name,
userEmail: users.email,
subscriptionId: payments.subscriptionId,
amount: payments.amount,
currency: payments.currency,
method: payments.method,
status: payments.status,
eftReceiptUrl: payments.eftReceiptUrl,
createdAt: payments.createdAt,
})
.from(payments)
.innerJoin(users, eq(payments.userId, users.id))
.where(and(eq(payments.method, "eft"), eq(payments.status, "pending")))
.orderBy(payments.createdAt);
return result;
}
async getQueryLogs(page = 1, limit = 50, userId?: string) {
const offset = (page - 1) * limit;
const conditions = userId
? eq(queryLogs.userId, userId)
: undefined;
const [items, totalResult] = await Promise.all([
this.db
.select({
id: queryLogs.id,
userId: queryLogs.userId,
userName: users.name,
userEmail: users.email,
vin: queryLogs.vin,
brandId: queryLogs.brandId,
brandName: brands.name,
source: queryLogs.source,
success: queryLogs.success,
errorMessage: queryLogs.errorMessage,
responseTimeMs: queryLogs.responseTimeMs,
createdAt: queryLogs.createdAt,
})
.from(queryLogs)
.innerJoin(users, eq(queryLogs.userId, users.id))
.leftJoin(brands, eq(queryLogs.brandId, brands.id))
.where(conditions)
.orderBy(desc(queryLogs.createdAt))
.limit(limit)
.offset(offset),
this.db.select({ count: count() }).from(queryLogs).where(conditions),
]);
return {
items,
total: totalResult[0].count,
page,
limit,
totalPages: Math.ceil(totalResult[0].count / limit),
};
}
async getDailyStats() {
const thirtyDaysAgo = new Date(
Date.now() - 30 * 24 * 60 * 60 * 1000,
);
const result = await this.db
.select({
date: sql<string>`date(${queryLogs.createdAt})`,
count: count(),
successCount: sql<number>`sum(case when ${queryLogs.success} = true then 1 else 0 end)`,
failureCount: sql<number>`sum(case when ${queryLogs.success} = false then 1 else 0 end)`,
})
.from(queryLogs)
.where(gte(queryLogs.createdAt, thirtyDaysAgo))
.groupBy(sql`date(${queryLogs.createdAt})`)
.orderBy(sql`date(${queryLogs.createdAt})`);
return result;
}
}

View File

@@ -0,0 +1,77 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler";
import configuration from "./config/configuration";
import { validate } from "./config/env.validation";
import { DatabaseModule } from "./database/database.module";
import { RedisModule } from "./redis/redis.module";
import { AuthModule } from "./auth/auth.module";
import { UsersModule } from "./users/users.module";
import { EmailModule } from "./email/email.module";
import { BrandsModule } from "./brands/brands.module";
import { PlansModule } from "./plans/plans.module";
import { SubscriptionsModule } from "./subscriptions/subscriptions.module";
import { PaymentsModule } from "./payments/payments.module";
import { StorageModule } from "./storage/storage.module";
import { ReferralsModule } from "./referrals/referrals.module";
import { VehiclesModule } from "./vehicles/vehicles.module";
import { CategoriesModule } from "./categories/categories.module";
import { PartsModule } from "./parts/parts.module";
import { JobsModule } from "./jobs/jobs.module";
import { EmexModule } from "./integrations/emex/emex.module";
import { TranslationsModule } from "./translations/translations.module";
import { AdminModule } from "./admin/admin.module";
import { HealthController } from "./health.controller";
import { AuthGuard } from "./common/guards/auth.guard";
import { RolesGuard } from "./common/guards/roles.guard";
import { TransformInterceptor } from "./common/interceptors/transform.interceptor";
import { LoggingInterceptor } from "./common/interceptors/logging.interceptor";
import { TimeoutInterceptor } from "./common/interceptors/timeout.interceptor";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
validate,
}),
ThrottlerModule.forRoot([
{
name: "default",
ttl: 60000,
limit: 100,
},
]),
DatabaseModule,
RedisModule,
AuthModule,
UsersModule,
EmailModule,
StorageModule,
BrandsModule,
PlansModule,
SubscriptionsModule,
PaymentsModule,
ReferralsModule,
VehiclesModule,
CategoriesModule,
PartsModule,
JobsModule,
EmexModule,
TranslationsModule,
AdminModule,
],
controllers: [HealthController],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: AuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: TimeoutInterceptor },
{ provide: APP_FILTER, useClass: HttpExceptionFilter },
],
})
export class AppModule {}

View File

@@ -0,0 +1,14 @@
import { All, Controller, Req, Res } from "@nestjs/common";
import { Request, Response } from "express";
import { getAuth } from "./auth";
import { toNodeHandler } from "better-auth/node";
@Controller("auth")
export class AuthController {
@All("*path")
async handleAuth(@Req() req: Request, @Res() res: Response) {
const auth = getAuth();
const handler = toNodeHandler(auth);
return handler(req, res);
}
}

View File

@@ -0,0 +1,21 @@
import { Module, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { createAuth } from "./auth";
@Module({
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule implements OnModuleInit {
constructor(private configService: ConfigService) {}
onModuleInit() {
const databaseUrl = this.configService.get<string>("database.url")!;
const secret = this.configService.get<string>("auth.secret")!;
const baseUrl = this.configService.get<string>("auth.url")!;
createAuth(databaseUrl, secret, baseUrl);
}
}

View File

@@ -0,0 +1,15 @@
import { Injectable } from "@nestjs/common";
import { getAuth } from "./auth";
@Injectable()
export class AuthService {
getAuthInstance() {
return getAuth();
}
async getSession(headers: Headers) {
const auth = getAuth();
const session = await auth.api.getSession({ headers });
return session;
}
}

70
apps/api/src/auth/auth.ts Normal file
View File

@@ -0,0 +1,70 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "../database/schema/core";
let authInstance: ReturnType<typeof betterAuth> | null = null;
export function createAuth(databaseUrl: string, secret: string, baseUrl: string) {
if (authInstance) return authInstance;
const client = postgres(databaseUrl, { max: 5 });
const db = drizzle(client, { schema });
authInstance = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
usePlural: true,
}),
secret,
baseURL: baseUrl,
basePath: "/api/auth",
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID || "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
enabled: !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET),
},
},
session: {
cookieCache: {
enabled: true,
maxAge: 60 * 5, // 5 minutes
},
},
user: {
additionalFields: {
role: {
type: "string",
defaultValue: "user",
input: false,
},
referralCode: {
type: "string",
required: false,
input: false,
},
referredBy: {
type: "string",
required: false,
input: false,
},
},
},
trustedOrigins: (process.env.CORS_ORIGIN || "http://localhost:3000").split(","),
});
return authInstance;
}
export function getAuth() {
if (!authInstance) {
throw new Error("Auth not initialized. Call createAuth first.");
}
return authInstance;
}

View File

@@ -0,0 +1,33 @@
import { Controller, Get, Post, Patch, Param, Body, UseGuards } from "@nestjs/common";
import { BrandsService } from "./brands.service";
import { Public } from "../common/decorators/public.decorator";
import { Roles } from "../common/decorators/roles.decorator";
import { RolesGuard } from "../common/guards/roles.guard";
@Controller("brands")
export class BrandsController {
constructor(private brandsService: BrandsService) {}
@Get()
@Public()
async findAll() {
return this.brandsService.findAll();
}
@Post()
@UseGuards(RolesGuard)
@Roles("admin")
async create(@Body() body: { name: string; slug: string; logoUrl?: string }) {
return this.brandsService.create(body);
}
@Patch(":id")
@UseGuards(RolesGuard)
@Roles("admin")
async update(
@Param("id") id: string,
@Body() body: { name?: string; slug?: string; logoUrl?: string; isActive?: boolean },
) {
return this.brandsService.update(id, body);
}
}

View File

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

View File

@@ -0,0 +1,42 @@
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
import { eq } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider";
import { brands } from "../database/schema/core";
@Injectable()
export class BrandsService {
constructor(@Inject(DATABASE) private db: Database) {}
async findAll(activeOnly = true) {
if (activeOnly) {
return this.db.select().from(brands).where(eq(brands.isActive, true)).orderBy(brands.name);
}
return this.db.select().from(brands).orderBy(brands.name);
}
async findById(id: string) {
const result = await this.db.select().from(brands).where(eq(brands.id, id)).limit(1);
if (result.length === 0) throw new NotFoundException("Brand not found");
return result[0];
}
async findBySlug(slug: string) {
const result = await this.db.select().from(brands).where(eq(brands.slug, slug)).limit(1);
return result[0] || null;
}
async create(data: { name: string; slug: string; logoUrl?: string }) {
const [brand] = await this.db.insert(brands).values(data).returning();
return brand;
}
async update(id: string, data: { name?: string; slug?: string; logoUrl?: string; isActive?: boolean }) {
const [brand] = await this.db
.update(brands)
.set({ ...data, updatedAt: new Date() })
.where(eq(brands.id, id))
.returning();
if (!brand) throw new NotFoundException("Brand not found");
return brand;
}
}

View File

@@ -0,0 +1,17 @@
import { Controller, Get, Param } from "@nestjs/common";
import { CategoriesService } from "./categories.service";
@Controller("categories")
export class CategoriesController {
constructor(private categoriesService: CategoriesService) {}
@Get("tree/:vehicleId")
async getCategoryTree(@Param("vehicleId") vehicleId: string) {
return this.categoriesService.getCategoryTree(vehicleId);
}
@Get(":id")
async getById(@Param("id") id: string) {
return this.categoriesService.getById(id);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { CategoriesController } from "./categories.controller";
import { CategoriesService } from "./categories.service";
import { PL24Module } from "../integrations/pl24/pl24.module";
@Module({
imports: [PL24Module],
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService],
})
export class CategoriesModule {}

View File

@@ -0,0 +1,102 @@
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
import { eq } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider";
import { categories, vehicles, schemaPics } from "../database/schema/core";
import { RedisService } from "../redis/redis.service";
import { PL24Service } from "../integrations/pl24/pl24.service";
@Injectable()
export class CategoriesService {
constructor(
@Inject(DATABASE) private db: Database,
private redis: RedisService,
private pl24Service: PL24Service,
) {}
async getCategoryTree(vehicleId: string) {
const cacheKey = `cat:tree:${vehicleId}`;
const cached = await this.redis.getJson<any[]>(cacheKey);
if (cached) return cached;
// Get vehicle info
const [vehicle] = await this.db
.select()
.from(vehicles)
.where(eq(vehicles.id, vehicleId))
.limit(1);
if (!vehicle) throw new NotFoundException("Vehicle not found");
// Check DB first
let dbCategories = await this.db
.select()
.from(categories)
.where(eq(categories.vehicleId, vehicleId));
// If no categories in DB, fetch from PL24
if (dbCategories.length === 0 && vehicle.rawData) {
const rawData = vehicle.rawData as any;
const pl24VehicleId = rawData.vehicleId;
if (pl24VehicleId && vehicle.brandName) {
const pl24Categories = await this.pl24Service.getCategories(pl24VehicleId, vehicle.brandName);
if (pl24Categories.length > 0) {
// Save to DB
const insertData = pl24Categories.map((c) => ({
vehicleId,
name: c.name,
nameOriginal: c.name,
parentId: null as string | null,
externalId: c.groupId,
source: "pl24" as const,
}));
dbCategories = await this.db.insert(categories).values(insertData).returning();
}
}
}
// Build tree
const tree = this.buildTree(dbCategories);
await this.redis.setJson(cacheKey, tree, 3600);
return tree;
}
async getById(categoryId: string) {
const [category] = await this.db
.select()
.from(categories)
.where(eq(categories.id, categoryId))
.limit(1);
if (!category) throw new NotFoundException("Category not found");
const pics = await this.db
.select()
.from(schemaPics)
.where(eq(schemaPics.categoryId, categoryId));
return { ...category, schemaPics: pics };
}
private buildTree(items: any[]): any[] {
const map = new Map<string, any>();
const roots: any[] = [];
for (const item of items) {
map.set(item.id, { ...item, children: [] });
}
for (const item of items) {
const node = map.get(item.id)!;
if (item.parentId && map.has(item.parentId)) {
map.get(item.parentId)!.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
}

View File

@@ -0,0 +1,7 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
export const CurrentUser = createParamDecorator((data: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
});

View File

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

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from "@nestjs/common";
export const ROLES_KEY = "roles";
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,16 @@
import { Throttle } from "@nestjs/throttler";
/**
* Auth routes: 5 requests per 60 seconds
*/
export const ThrottleAuth = () => Throttle({ default: { limit: 5, ttl: 60000 } });
/**
* VIN decode routes: 20 requests per 60 seconds
*/
export const ThrottleVinDecode = () => Throttle({ default: { limit: 20, ttl: 60000 } });
/**
* General routes: 100 requests per 60 seconds
*/
export const ThrottleGeneral = () => Throttle({ default: { limit: 100, ttl: 60000 } });

View File

@@ -0,0 +1,24 @@
export class ApiResponseDto<T = unknown> {
success: boolean;
data: T;
meta?: Record<string, unknown>;
constructor(data: T, meta?: Record<string, unknown>) {
this.success = true;
this.data = data;
this.meta = meta;
}
}
export class ApiErrorDto {
success: false;
error: {
code: string;
message: string;
};
constructor(code: string, message: string) {
this.success = false;
this.error = { code, message };
}
}

View File

@@ -0,0 +1,41 @@
import { z } from "zod";
export const paginationSchema = z.object({
page: z
.string()
.optional()
.transform((val) => {
const parsed = val ? parseInt(val, 10) : 1;
return Number.isNaN(parsed) || parsed < 1 ? 1 : parsed;
}),
limit: z
.string()
.optional()
.transform((val) => {
const parsed = val ? parseInt(val, 10) : 20;
if (Number.isNaN(parsed) || parsed < 1) return 20;
return Math.min(parsed, 100);
}),
});
export type PaginationDto = z.infer<typeof paginationSchema>;
export class PaginatedResponseDto<T> {
items: T[];
meta: {
page: number;
limit: number;
total: number;
totalPages: number;
};
constructor(items: T[], total: number, page: number, limit: number) {
this.items = items;
this.meta = {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
};
}
}

View File

@@ -0,0 +1,36 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus, Logger } from "@nestjs/common";
import { Response } from "express";
// Drizzle/postgres unique violation error
@Catch()
export class DrizzleExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger("DrizzleExceptionFilter");
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
if (this.isUniqueViolation(exception)) {
response.status(HttpStatus.CONFLICT).json({
success: false,
error: {
code: "GEN_005",
message: "Resource already exists",
},
});
return;
}
// Re-throw if not a Drizzle-specific error
throw exception;
}
private isUniqueViolation(exception: unknown): boolean {
if (exception && typeof exception === "object") {
const err = exception as Record<string, unknown>;
// PostgreSQL unique violation code
return err.code === "23505";
}
return false;
}
}

View File

@@ -0,0 +1,52 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from "@nestjs/common";
import { Response } 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>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = "Internal server error";
let code = "GEN_003";
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === "string") {
message = exceptionResponse;
} else if (typeof exceptionResponse === "object" && exceptionResponse !== null) {
const resp = exceptionResponse as Record<string, unknown>;
message = (resp.message as string) || exception.message;
code = (resp.code as string) || this.getCodeFromStatus(status);
}
} else if (exception instanceof Error) {
message = exception.message;
this.logger.error(`Unhandled error: ${exception.message}`, exception.stack);
}
response.status(status).json({
success: false,
error: {
code,
message,
},
});
}
private getCodeFromStatus(status: number): string {
switch (status) {
case 400: return "GEN_002";
case 401: return "AUTH_004";
case 403: return "AUTH_005";
case 404: return "GEN_001";
case 409: return "GEN_005";
case 429: return "GEN_004";
default: return "GEN_003";
}
}
}

View File

@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { UnauthorizedException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { AuthGuard } from "./auth.guard";
vi.mock("../../auth/auth", () => ({
getAuth: vi.fn(),
}));
import { getAuth } from "../../auth/auth";
const mockedGetAuth = vi.mocked(getAuth);
function createMockExecutionContext(overrides: {
isPublic?: boolean;
headers?: Record<string, string>;
}) {
const request: Record<string, unknown> = {
headers: overrides.headers ?? {},
};
const context = {
getHandler: vi.fn(),
getClass: vi.fn(),
switchToHttp: vi.fn().mockReturnValue({
getRequest: vi.fn().mockReturnValue(request),
}),
};
return { context, request };
}
describe("AuthGuard", () => {
let guard: AuthGuard;
let reflector: Reflector;
beforeEach(() => {
vi.clearAllMocks();
reflector = {
getAllAndOverride: vi.fn(),
} as unknown as Reflector;
guard = new AuthGuard(reflector);
});
it("should allow access for public routes", async () => {
vi.mocked(reflector.getAllAndOverride).mockReturnValue(true);
const { context } = createMockExecutionContext({ isPublic: true });
const result = await guard.canActivate(context as any);
expect(result).toBe(true);
expect(context.switchToHttp).not.toHaveBeenCalled();
});
it("should throw UnauthorizedException when session is missing", async () => {
vi.mocked(reflector.getAllAndOverride).mockReturnValue(false);
mockedGetAuth.mockReturnValue({
api: {
getSession: vi.fn().mockResolvedValue(null),
},
} as any);
const { context } = createMockExecutionContext({
headers: { authorization: "Bearer token123" },
});
await expect(guard.canActivate(context as any)).rejects.toThrow(
UnauthorizedException,
);
});
it("should throw UnauthorizedException when session has no user", async () => {
vi.mocked(reflector.getAllAndOverride).mockReturnValue(false);
mockedGetAuth.mockReturnValue({
api: {
getSession: vi.fn().mockResolvedValue({ user: null }),
},
} as any);
const { context } = createMockExecutionContext({
headers: { authorization: "Bearer token123" },
});
await expect(guard.canActivate(context as any)).rejects.toThrow(
UnauthorizedException,
);
});
it("should set user and session on request for valid session", async () => {
vi.mocked(reflector.getAllAndOverride).mockReturnValue(false);
const mockUser = { id: "user-1", email: "test@example.com", role: "user" };
const mockSession = { id: "session-1", userId: "user-1" };
mockedGetAuth.mockReturnValue({
api: {
getSession: vi.fn().mockResolvedValue({
user: mockUser,
session: mockSession,
}),
},
} as any);
const { context, request } = createMockExecutionContext({
headers: { authorization: "Bearer validtoken" },
});
const result = await guard.canActivate(context as any);
expect(result).toBe(true);
expect(request.user).toEqual(mockUser);
expect(request.session).toEqual(mockSession);
});
it("should throw UnauthorizedException when getAuth throws", async () => {
vi.mocked(reflector.getAllAndOverride).mockReturnValue(false);
mockedGetAuth.mockImplementation(() => {
throw new Error("Auth not initialized");
});
const { context } = createMockExecutionContext({
headers: {},
});
await expect(guard.canActivate(context as any)).rejects.toThrow(
UnauthorizedException,
);
});
it("should re-throw UnauthorizedException from inner code", async () => {
vi.mocked(reflector.getAllAndOverride).mockReturnValue(false);
mockedGetAuth.mockReturnValue({
api: {
getSession: vi.fn().mockRejectedValue(
new UnauthorizedException("Custom auth error"),
),
},
} as any);
const { context } = createMockExecutionContext({
headers: { authorization: "Bearer token" },
});
await expect(guard.canActivate(context as any)).rejects.toThrow(
UnauthorizedException,
);
});
});

View File

@@ -0,0 +1,42 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { IS_PUBLIC_KEY } from "../decorators/public.decorator";
import { getAuth } from "../../auth/auth";
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private reflector: Reflector) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest();
try {
const auth = getAuth();
const headers = new Headers();
for (const [key, value] of Object.entries(request.headers)) {
if (typeof value === "string") {
headers.set(key, value);
}
}
const session = await auth.api.getSession({ headers });
if (!session?.user) {
throw new UnauthorizedException("Authentication required");
}
request.user = session.user;
request.session = session.session;
return true;
} catch (error) {
if (error instanceof UnauthorizedException) throw error;
throw new UnauthorizedException("Authentication required");
}
}
}

View File

@@ -0,0 +1,56 @@
import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable } from "@nestjs/common";
import { eq, and } from "drizzle-orm";
import { DATABASE, Database } from "../../database/database.provider";
import { userSubscriptions, userBrands } from "../../database/schema/core";
@Injectable()
export class BrandAccessGuard implements CanActivate {
constructor(@Inject(DATABASE) private db: Database) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const user = request.user;
const brandId = request.params.brandId || request.body?.brandId;
if (!user) {
throw new ForbiddenException("Authentication required");
}
if (user.role === "admin") return true;
if (!brandId) return true;
// Check if user has an active subscription with access to this brand
const activeSub = await this.db
.select()
.from(userSubscriptions)
.where(and(eq(userSubscriptions.userId, user.id), eq(userSubscriptions.status, "active")))
.limit(1);
if (activeSub.length === 0) {
throw new ForbiddenException("No active subscription");
}
// Check if the subscription includes this brand (brandCount=0 means all brands)
const subscription = activeSub[0];
// Check userBrands junction
const brandAccess = await this.db
.select()
.from(userBrands)
.where(
and(
eq(userBrands.userId, user.id),
eq(userBrands.subscriptionId, subscription.id),
eq(userBrands.brandId, brandId),
),
)
.limit(1);
if (brandAccess.length === 0) {
throw new ForbiddenException("Bu markaya erişim yok. Aboneliğinizi güncelleyin.");
}
return true;
}
}

View File

@@ -0,0 +1,28 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { ROLES_KEY } from "../decorators/roles.decorator";
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) return true;
const { user } = context.switchToHttp().getRequest();
if (!user) {
throw new ForbiddenException("Access denied");
}
if (!requiredRoles.includes(user.role)) {
throw new ForbiddenException("Insufficient permissions");
}
return true;
}
}

View File

@@ -0,0 +1,21 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from "@nestjs/common";
import { Observable, tap } from "rxjs";
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger("HTTP");
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest();
const { method, url } = request;
const now = Date.now();
return next.handle().pipe(
tap(() => {
const response = context.switchToHttp().getResponse();
const elapsed = Date.now() - now;
this.logger.log(`${method} ${url} ${response.statusCode} ${elapsed}ms`);
}),
);
}
}

View File

@@ -0,0 +1,25 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
RequestTimeoutException,
} from "@nestjs/common";
import { Observable, throwError, timeout, catchError, TimeoutError } from "rxjs";
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
constructor(private readonly timeoutMs: number = 30000) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(
timeout(this.timeoutMs),
catchError((err) => {
if (err instanceof TimeoutError) {
return throwError(() => new RequestTimeoutException("Request timed out"));
}
return throwError(() => err);
}),
);
}
}

View File

@@ -0,0 +1,39 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from "@nestjs/common";
import { Observable, map } from "rxjs";
export interface TransformedResponse<T> {
success: boolean;
data: T;
meta?: Record<string, unknown>;
}
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, TransformedResponse<T>> {
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<TransformedResponse<T>> {
return next.handle().pipe(
map((data) => {
// If already wrapped, pass through
if (data && typeof data === "object" && "success" in data) {
return data;
}
// Handle pagination response
if (data && typeof data === "object" && "items" in data && "meta" in data) {
return {
success: true,
data: data.items,
meta: data.meta,
};
}
return {
success: true,
data,
};
}),
);
}
}

View File

@@ -0,0 +1,68 @@
import type { Request, Response, NextFunction } from "express";
const ALLOWED_MIME_TYPES = [
"image/png",
"image/jpeg",
"image/jpg",
"application/pdf",
];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
/**
* Middleware to validate file uploads:
* - Only allows PNG, JPG, and PDF files
* - Maximum file size: 5MB
*/
export function fileUploadValidation(req: Request, res: Response, next: NextFunction): void {
if (req.method !== "POST" && req.method !== "PUT" && req.method !== "PATCH") {
next();
return;
}
const contentType = req.headers["content-type"] || "";
if (!contentType.includes("multipart/form-data")) {
next();
return;
}
const contentLength = Number.parseInt(req.headers["content-length"] || "0", 10);
if (contentLength > MAX_FILE_SIZE) {
res.status(413).json({
statusCode: 413,
message: `File too large. Maximum size is ${MAX_FILE_SIZE / (1024 * 1024)}MB`,
error: "Payload Too Large",
});
return;
}
// Validate file type on multer-processed files
const file = (req as Request & { file?: Express.Multer.File }).file;
const files = (req as Request & { files?: Express.Multer.File[] }).files;
const filesToCheck: Express.Multer.File[] = [];
if (file) filesToCheck.push(file);
if (Array.isArray(files)) filesToCheck.push(...files);
for (const f of filesToCheck) {
if (!ALLOWED_MIME_TYPES.includes(f.mimetype)) {
res.status(415).json({
statusCode: 415,
message: `File type '${f.mimetype}' is not allowed. Allowed types: PNG, JPG, PDF`,
error: "Unsupported Media Type",
});
return;
}
if (f.size > MAX_FILE_SIZE) {
res.status(413).json({
statusCode: 413,
message: `File '${f.originalname}' exceeds the ${MAX_FILE_SIZE / (1024 * 1024)}MB limit`,
error: "Payload Too Large",
});
return;
}
}
next();
}

View File

@@ -0,0 +1,77 @@
import { describe, it, expect, beforeEach } from "vitest";
import { BadRequestException } from "@nestjs/common";
import { VinValidationPipe } from "./vin-validation.pipe";
describe("VinValidationPipe", () => {
let pipe: VinValidationPipe;
beforeEach(() => {
pipe = new VinValidationPipe();
});
it("should pass a valid 17-character VIN and return uppercase", () => {
const result = pipe.transform("WBAPH5C55BA123456");
expect(result).toBe("WBAPH5C55BA123456");
});
it("should transform lowercase VIN to uppercase", () => {
const result = pipe.transform("wbaph5c55ba123456");
expect(result).toBe("WBAPH5C55BA123456");
});
it("should trim whitespace from VIN", () => {
const result = pipe.transform(" WBAPH5C55BA123456 ");
expect(result).toBe("WBAPH5C55BA123456");
});
it("should throw BadRequestException for VIN shorter than 17 characters", () => {
expect(() => pipe.transform("WBA123")).toThrow(BadRequestException);
expect(() => pipe.transform("WBA123")).toThrow(
"Invalid VIN. Must be 17 characters, letters I, O, Q are not allowed.",
);
});
it("should throw BadRequestException for VIN longer than 17 characters", () => {
expect(() => pipe.transform("WBAPH5C55BA12345678")).toThrow(
BadRequestException,
);
});
it("should throw BadRequestException for VIN containing letter I", () => {
expect(() => pipe.transform("WBAPH5C55IA123456")).toThrow(
BadRequestException,
);
});
it("should throw BadRequestException for VIN containing letter O", () => {
expect(() => pipe.transform("WBAPH5C55OA123456")).toThrow(
BadRequestException,
);
});
it("should throw BadRequestException for VIN containing letter Q", () => {
expect(() => pipe.transform("WBAPH5C55QA123456")).toThrow(
BadRequestException,
);
});
it("should throw BadRequestException for empty string", () => {
expect(() => pipe.transform("")).toThrow(BadRequestException);
expect(() => pipe.transform("")).toThrow("VIN is required");
});
it("should throw BadRequestException for null/undefined value", () => {
expect(() => pipe.transform(null as unknown as string)).toThrow(
BadRequestException,
);
expect(() => pipe.transform(undefined as unknown as string)).toThrow(
BadRequestException,
);
});
it("should throw BadRequestException for non-string value", () => {
expect(() => pipe.transform(12345 as unknown as string)).toThrow(
BadRequestException,
);
});
});

View File

@@ -0,0 +1,21 @@
import { PipeTransform, Injectable, BadRequestException } from "@nestjs/common";
import { isValidVin } from "@sase/shared";
@Injectable()
export class VinValidationPipe implements PipeTransform<string, string> {
transform(value: string): string {
if (!value || typeof value !== "string") {
throw new BadRequestException("VIN is required");
}
const vin = value.toUpperCase().trim();
if (!isValidVin(vin)) {
throw new BadRequestException(
"Invalid VIN. Must be 17 characters, letters I, O, Q are not allowed.",
);
}
return vin;
}
}

View File

@@ -0,0 +1,40 @@
export default () => ({
port: parseInt(process.env.PORT || "4000", 10),
database: {
url: process.env.DATABASE_URL,
},
redis: {
host: process.env.REDIS_HOST || "127.0.0.1",
port: parseInt(process.env.REDIS_PORT || "6379", 10),
password: process.env.REDIS_PASSWORD,
},
auth: {
secret: process.env.BETTER_AUTH_SECRET,
url: process.env.BETTER_AUTH_URL,
},
minio: {
endpoint: process.env.MINIO_ENDPOINT,
accessKey: process.env.MINIO_ACCESS_KEY,
secretKey: process.env.MINIO_SECRET_KEY,
bucketName: process.env.MINIO_BUCKET_NAME || "sase-schemas",
publicUrl: process.env.MINIO_PUBLIC_URL,
useSSL: process.env.MINIO_USE_SSL === "true",
},
cors: {
origin: (process.env.CORS_ORIGIN || "http://localhost:3000").split(","),
},
iyzico: {
apiKey: process.env.IYZICO_API_KEY,
secretKey: process.env.IYZICO_SECRET_KEY,
baseUrl: process.env.IYZICO_BASE_URL,
},
pl24: {
apiUrl: process.env.PL24_API_URL,
username: process.env.PL24_USERNAME,
password: process.env.PL24_PASSWORD,
},
emex: {
username: process.env.EMEX_USERNAME,
password: process.env.EMEX_PASSWORD,
},
});

View File

@@ -0,0 +1,11 @@
import { envSchema } from "@sase/config";
export function validate(config: Record<string, unknown>) {
const result = envSchema.safeParse(config);
if (!result.success) {
const errors = result.error.format();
console.error("Environment validation failed:", JSON.stringify(errors, null, 2));
throw new Error("Invalid environment variables");
}
return result.data;
}

View File

@@ -0,0 +1,11 @@
import { Global, Module } from "@nestjs/common";
import { DatabaseProvider } from "./database.provider";
export const DATABASE = "DATABASE";
@Global()
@Module({
providers: [DatabaseProvider],
exports: [DatabaseProvider],
})
export class DatabaseModule {}

View File

@@ -0,0 +1,34 @@
import { Provider } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { drizzle, PostgresJsDatabase } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as core from "./schema/core";
import * as pl24 from "./schema/pl24";
import * as emex from "./schema/emex";
import * as relations from "./schema/relations";
export const DATABASE = "DATABASE";
export type DatabaseSchema = typeof core & typeof pl24 & typeof emex & typeof relations;
export type Database = PostgresJsDatabase<DatabaseSchema>;
export const DatabaseProvider: Provider = {
provide: DATABASE,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>("database.url")!;
const client = postgres(databaseUrl, {
max: 20,
idle_timeout: 20,
connect_timeout: 10,
});
const db = drizzle(client, {
schema: { ...core, ...pl24, ...emex, ...relations },
});
console.log("Database connected");
return db;
},
inject: [ConfigService],
};

View File

@@ -0,0 +1,373 @@
import {
pgTable,
uuid,
varchar,
text,
boolean,
integer,
timestamp,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
// ─── Users ───────────────────────────────────────────
export const users = pgTable(
"users",
{
id: uuid("id").primaryKey().defaultRandom(),
name: varchar("name", { length: 255 }).notNull(),
email: varchar("email", { length: 255 }).notNull(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
role: varchar("role", { length: 20 }).default("user").notNull(),
referralCode: varchar("referral_code", { length: 20 }),
referredBy: uuid("referred_by"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("users_email_idx").on(table.email),
uniqueIndex("users_referral_code_idx").on(table.referralCode),
],
);
// ─── Better Auth: Sessions ──────────────────────────
export const sessions = pgTable(
"sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
token: text("token").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
ipAddress: varchar("ip_address", { length: 45 }),
userAgent: text("user_agent"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [uniqueIndex("sessions_token_idx").on(table.token)],
);
// ─── Better Auth: Accounts ──────────────────────────
export const accounts = pgTable(
"accounts",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
accountId: text("account_id").notNull(),
providerId: varchar("provider_id", { length: 50 }).notNull(),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at", { withTimezone: true }),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { withTimezone: true }),
scope: text("scope"),
idToken: text("id_token"),
password: text("password"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index("accounts_user_id_idx").on(table.userId)],
);
// ─── Better Auth: Verifications ─────────────────────
export const verifications = pgTable(
"verifications",
{
id: uuid("id").primaryKey().defaultRandom(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index("verifications_identifier_idx").on(table.identifier)],
);
// ─── Brands ─────────────────────────────────────────
export const brands = pgTable(
"brands",
{
id: uuid("id").primaryKey().defaultRandom(),
name: varchar("name", { length: 100 }).notNull(),
slug: varchar("slug", { length: 100 }).notNull(),
logoUrl: text("logo_url"),
isActive: boolean("is_active").default(true).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [uniqueIndex("brands_slug_idx").on(table.slug)],
);
// ─── Plans ──────────────────────────────────────────
export const plans = pgTable("plans", {
id: uuid("id").primaryKey().defaultRandom(),
name: varchar("name", { length: 100 }).notNull(),
brandCount: integer("brand_count").notNull(),
priceMonthly: integer("price_monthly").notNull(),
priceYearly: integer("price_yearly").notNull(),
isActive: boolean("is_active").default(true).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
// ─── User Subscriptions ─────────────────────────────
export const userSubscriptions = pgTable(
"user_subscriptions",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
planId: uuid("plan_id")
.notNull()
.references(() => plans.id),
status: varchar("status", { length: 20 }).default("pending").notNull(),
billingPeriod: varchar("billing_period", { length: 10 }).default("monthly").notNull(),
startDate: timestamp("start_date", { withTimezone: true }),
endDate: timestamp("end_date", { withTimezone: true }),
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("user_subscriptions_user_id_idx").on(table.userId),
index("user_subscriptions_status_idx").on(table.status),
],
);
// ─── User Brands (junction) ─────────────────────────
export const userBrands = pgTable(
"user_brands",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
subscriptionId: uuid("subscription_id")
.notNull()
.references(() => userSubscriptions.id, { onDelete: "cascade" }),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("user_brands_user_id_idx").on(table.userId),
uniqueIndex("user_brands_unique_idx").on(table.userId, table.subscriptionId, table.brandId),
],
);
// ─── Payments ───────────────────────────────────────
export const payments = pgTable(
"payments",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
subscriptionId: uuid("subscription_id")
.notNull()
.references(() => userSubscriptions.id),
amount: integer("amount").notNull(),
currency: varchar("currency", { length: 3 }).default("TRY").notNull(),
method: varchar("method", { length: 20 }).notNull(),
status: varchar("status", { length: 20 }).default("pending").notNull(),
iyzicoPaymentId: text("iyzico_payment_id"),
eftReceiptUrl: text("eft_receipt_url"),
adminNote: text("admin_note"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("payments_user_id_idx").on(table.userId),
index("payments_status_idx").on(table.status),
],
);
// ─── Query Logs ─────────────────────────────────────
export const queryLogs = pgTable(
"query_logs",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
vin: varchar("vin", { length: 17 }).notNull(),
brandId: uuid("brand_id").references(() => brands.id),
source: varchar("source", { length: 20 }),
success: boolean("success").default(true).notNull(),
errorMessage: text("error_message"),
responseTimeMs: integer("response_time_ms"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("query_logs_user_id_created_at_idx").on(table.userId, table.createdAt),
index("query_logs_vin_idx").on(table.vin),
],
);
// ─── Vehicles ───────────────────────────────────────
export const vehicles = pgTable(
"vehicles",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
vin: varchar("vin", { length: 17 }).notNull(),
brandId: uuid("brand_id").references(() => brands.id),
brandName: varchar("brand_name", { length: 100 }),
model: varchar("model", { length: 255 }),
year: integer("year"),
engine: varchar("engine", { length: 255 }),
transmission: varchar("transmission", { length: 100 }),
bodyType: varchar("body_type", { length: 100 }),
market: varchar("market", { length: 100 }),
rawData: jsonb("raw_data"),
source: varchar("source", { length: 20 }).default("pl24").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("vehicles_user_id_idx").on(table.userId),
index("vehicles_vin_idx").on(table.vin),
uniqueIndex("vehicles_user_vin_idx").on(table.userId, table.vin),
],
);
// ─── Categories ─────────────────────────────────────
export const categories = pgTable(
"categories",
{
id: uuid("id").primaryKey().defaultRandom(),
vehicleId: uuid("vehicle_id")
.notNull()
.references(() => vehicles.id, { onDelete: "cascade" }),
name: varchar("name", { length: 500 }).notNull(),
nameOriginal: varchar("name_original", { length: 500 }),
parentId: uuid("parent_id"),
externalId: varchar("external_id", { length: 100 }),
source: varchar("source", { length: 20 }).default("pl24").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("categories_vehicle_id_idx").on(table.vehicleId),
index("categories_parent_id_idx").on(table.parentId),
],
);
// ─── Vehicle Categories (junction) ──────────────────
export const vehicleCategories = pgTable(
"vehicle_categories",
{
id: uuid("id").primaryKey().defaultRandom(),
vehicleId: uuid("vehicle_id")
.notNull()
.references(() => vehicles.id, { onDelete: "cascade" }),
categoryId: uuid("category_id")
.notNull()
.references(() => categories.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("vehicle_categories_unique_idx").on(table.vehicleId, table.categoryId),
],
);
// ─── Parts ──────────────────────────────────────────
export const parts = pgTable(
"parts",
{
id: uuid("id").primaryKey().defaultRandom(),
vehicleId: uuid("vehicle_id")
.notNull()
.references(() => vehicles.id, { onDelete: "cascade" }),
categoryId: uuid("category_id")
.notNull()
.references(() => categories.id, { onDelete: "cascade" }),
oemCode: varchar("oem_code", { length: 100 }).notNull(),
name: varchar("name", { length: 500 }).notNull(),
nameOriginal: varchar("name_original", { length: 500 }),
description: text("description"),
quantity: integer("quantity"),
position: varchar("position", { length: 100 }),
hotspotIndex: integer("hotspot_index"),
source: varchar("source", { length: 20 }).default("pl24").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("parts_vehicle_id_idx").on(table.vehicleId),
index("parts_category_id_idx").on(table.categoryId),
index("parts_oem_code_idx").on(table.oemCode),
],
);
// ─── Schema Pics ────────────────────────────────────
export const schemaPics = pgTable(
"schema_pics",
{
id: uuid("id").primaryKey().defaultRandom(),
categoryId: uuid("category_id")
.notNull()
.references(() => categories.id, { onDelete: "cascade" }),
imageUrl: text("image_url").notNull(),
hotspots: jsonb("hotspots").default("[]").notNull(),
source: varchar("source", { length: 20 }).default("pl24").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index("schema_pics_category_id_idx").on(table.categoryId)],
);
// ─── Password Reset Tokens ──────────────────────────
export const passwordResetTokens = pgTable(
"password_reset_tokens",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
token: text("token").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
usedAt: timestamp("used_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [uniqueIndex("password_reset_tokens_token_idx").on(table.token)],
);
// ─── Referrals ──────────────────────────────────────
export const referrals = pgTable(
"referrals",
{
id: uuid("id").primaryKey().defaultRandom(),
referrerId: uuid("referrer_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
referredId: uuid("referred_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
rewardApplied: boolean("reward_applied").default(false).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("referrals_referrer_id_idx").on(table.referrerId),
uniqueIndex("referrals_referred_id_idx").on(table.referredId),
],
);
// ─── EMEX Category Translations ─────────────────────
export const emexCategoryTranslations = pgTable(
"emex_category_translations",
{
id: uuid("id").primaryKey().defaultRandom(),
originalName: varchar("original_name", { length: 500 }).notNull(),
translatedName: varchar("translated_name", { length: 500 }).notNull(),
isManual: boolean("is_manual").default(false).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [uniqueIndex("emex_translations_original_name_idx").on(table.originalName)],
);

View File

@@ -0,0 +1,209 @@
import {
pgTable,
uuid,
varchar,
text,
boolean,
integer,
timestamp,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
// ─── EMEX Catalog ───────────────────────────────────
export const emexCatalogs = pgTable(
"emex_catalogs",
{
id: uuid("id").primaryKey().defaultRandom(),
catalogId: varchar("catalog_id", { length: 100 }).notNull(),
brandName: varchar("brand_name", { length: 100 }).notNull(),
description: text("description"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [uniqueIndex("emex_catalogs_catalog_id_idx").on(table.catalogId)],
);
// ─── EMEX Vehicle ───────────────────────────────────
export const emexVehicles = pgTable(
"emex_vehicles",
{
id: uuid("id").primaryKey().defaultRandom(),
catalogId: uuid("catalog_id").references(() => emexCatalogs.id, { onDelete: "cascade" }),
vehicleId: varchar("vehicle_id", { length: 100 }).notNull(),
name: varchar("name", { length: 500 }),
modelCode: varchar("model_code", { length: 100 }),
engine: varchar("engine", { length: 255 }),
yearFrom: integer("year_from"),
yearTo: integer("year_to"),
rawData: jsonb("raw_data"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("emex_vehicles_vehicle_id_idx").on(table.vehicleId),
index("emex_vehicles_catalog_id_idx").on(table.catalogId),
],
);
// ─── EMEX Vehicle VIN ───────────────────────────────
export const emexVehicleVins = pgTable(
"emex_vehicle_vins",
{
id: uuid("id").primaryKey().defaultRandom(),
emexVehicleId: uuid("emex_vehicle_id").references(() => emexVehicles.id, {
onDelete: "cascade",
}),
vin: varchar("vin", { length: 17 }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("emex_vehicle_vins_vin_idx").on(table.vin),
index("emex_vehicle_vins_vehicle_id_idx").on(table.emexVehicleId),
],
);
// ─── EMEX Part Group ────────────────────────────────
export const emexPartGroups = pgTable(
"emex_part_groups",
{
id: uuid("id").primaryKey().defaultRandom(),
emexVehicleId: uuid("emex_vehicle_id").references(() => emexVehicles.id, {
onDelete: "cascade",
}),
groupId: varchar("group_id", { length: 100 }).notNull(),
name: varchar("name", { length: 500 }).notNull(),
nameOriginal: varchar("name_original", { length: 500 }),
parentGroupId: varchar("parent_group_id", { length: 100 }),
sortOrder: integer("sort_order"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("emex_part_groups_vehicle_id_idx").on(table.emexVehicleId),
index("emex_part_groups_group_id_idx").on(table.groupId),
],
);
// ─── EMEX Part ──────────────────────────────────────
export const emexParts = pgTable(
"emex_parts",
{
id: uuid("id").primaryKey().defaultRandom(),
emexVehicleId: uuid("emex_vehicle_id").references(() => emexVehicles.id, {
onDelete: "cascade",
}),
groupId: uuid("group_id").references(() => emexPartGroups.id, { onDelete: "cascade" }),
partId: varchar("part_id", { length: 100 }),
name: varchar("name", { length: 500 }).notNull(),
nameOriginal: varchar("name_original", { length: 500 }),
description: text("description"),
quantity: integer("quantity"),
position: varchar("position", { length: 100 }),
hotspotIndex: integer("hotspot_index"),
rawData: jsonb("raw_data"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("emex_parts_vehicle_id_idx").on(table.emexVehicleId),
index("emex_parts_group_id_idx").on(table.groupId),
],
);
// ─── EMEX Part Number ───────────────────────────────
export const emexPartNumbers = pgTable(
"emex_part_numbers",
{
id: uuid("id").primaryKey().defaultRandom(),
emexPartId: uuid("emex_part_id").references(() => emexParts.id, { onDelete: "cascade" }),
oemCode: varchar("oem_code", { length: 100 }).notNull(),
isMain: boolean("is_main").default(true).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("emex_part_numbers_part_id_idx").on(table.emexPartId),
index("emex_part_numbers_oem_code_idx").on(table.oemCode),
],
);
// ─── EMEX Vehicle Group ─────────────────────────────
export const emexVehicleGroups = pgTable(
"emex_vehicle_groups",
{
id: uuid("id").primaryKey().defaultRandom(),
catalogId: uuid("catalog_id").references(() => emexCatalogs.id, { onDelete: "cascade" }),
groupId: varchar("group_id", { length: 100 }).notNull(),
name: varchar("name", { length: 500 }).notNull(),
parentGroupId: varchar("parent_group_id", { length: 100 }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("emex_vehicle_groups_catalog_id_idx").on(table.catalogId),
index("emex_vehicle_groups_group_id_idx").on(table.groupId),
],
);
// ─── EMEX Vehicle Part ──────────────────────────────
export const emexVehicleParts = pgTable(
"emex_vehicle_parts",
{
id: uuid("id").primaryKey().defaultRandom(),
emexVehicleId: uuid("emex_vehicle_id").references(() => emexVehicles.id, {
onDelete: "cascade",
}),
emexPartId: uuid("emex_part_id").references(() => emexParts.id, { onDelete: "cascade" }),
fitmentInfo: text("fitment_info"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("emex_vehicle_parts_vehicle_id_idx").on(table.emexVehicleId),
index("emex_vehicle_parts_part_id_idx").on(table.emexPartId),
],
);
// ─── EMEX Schema Pic ────────────────────────────────
export const emexSchemaPics = pgTable(
"emex_schema_pics",
{
id: uuid("id").primaryKey().defaultRandom(),
groupId: uuid("group_id").references(() => emexPartGroups.id, { onDelete: "cascade" }),
imageUrl: text("image_url").notNull(),
originalUrl: text("original_url"),
hotspots: jsonb("hotspots").default("[]").notNull(),
width: integer("width"),
height: integer("height"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index("emex_schema_pics_group_id_idx").on(table.groupId)],
);
// ─── EMEX Part Image ────────────────────────────────
export const emexPartImages = pgTable(
"emex_part_images",
{
id: uuid("id").primaryKey().defaultRandom(),
emexPartId: uuid("emex_part_id").references(() => emexParts.id, { onDelete: "cascade" }),
imageUrl: text("image_url").notNull(),
originalUrl: text("original_url"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index("emex_part_images_part_id_idx").on(table.emexPartId)],
);
// ─── EMEX Scrape Session ────────────────────────────
export const emexScrapeSessions = pgTable(
"emex_scrape_sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
vin: varchar("vin", { length: 17 }).notNull(),
status: varchar("status", { length: 20 }).default("pending").notNull(),
jobId: varchar("job_id", { length: 100 }),
result: jsonb("result"),
errorMessage: text("error_message"),
startedAt: timestamp("started_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("emex_scrape_sessions_vin_idx").on(table.vin),
index("emex_scrape_sessions_status_idx").on(table.status),
],
);

View File

@@ -0,0 +1,202 @@
import {
pgTable,
uuid,
varchar,
text,
boolean,
integer,
timestamp,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
// ─── PL24 Catalog ───────────────────────────────────
export const pl24Catalogs = pgTable(
"pl24_catalogs",
{
id: uuid("id").primaryKey().defaultRandom(),
catalogId: varchar("catalog_id", { length: 100 }).notNull(),
brandName: varchar("brand_name", { length: 100 }).notNull(),
description: text("description"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [uniqueIndex("pl24_catalogs_catalog_id_idx").on(table.catalogId)],
);
// ─── PL24 Vehicle ───────────────────────────────────
export const pl24Vehicles = pgTable(
"pl24_vehicles",
{
id: uuid("id").primaryKey().defaultRandom(),
catalogId: uuid("catalog_id").references(() => pl24Catalogs.id, { onDelete: "cascade" }),
vehicleId: varchar("vehicle_id", { length: 100 }).notNull(),
name: varchar("name", { length: 500 }),
modelCode: varchar("model_code", { length: 100 }),
engine: varchar("engine", { length: 255 }),
transmission: varchar("transmission", { length: 100 }),
bodyType: varchar("body_type", { length: 100 }),
market: varchar("market", { length: 100 }),
yearFrom: integer("year_from"),
yearTo: integer("year_to"),
rawData: jsonb("raw_data"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("pl24_vehicles_vehicle_id_idx").on(table.vehicleId),
index("pl24_vehicles_catalog_id_idx").on(table.catalogId),
],
);
// ─── PL24 Vehicle VIN ───────────────────────────────
export const pl24VehicleVins = pgTable(
"pl24_vehicle_vins",
{
id: uuid("id").primaryKey().defaultRandom(),
pl24VehicleId: uuid("pl24_vehicle_id").references(() => pl24Vehicles.id, {
onDelete: "cascade",
}),
vin: varchar("vin", { length: 17 }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("pl24_vehicle_vins_vin_idx").on(table.vin),
index("pl24_vehicle_vins_vehicle_id_idx").on(table.pl24VehicleId),
],
);
// ─── PL24 Part Group ────────────────────────────────
export const pl24PartGroups = pgTable(
"pl24_part_groups",
{
id: uuid("id").primaryKey().defaultRandom(),
pl24VehicleId: uuid("pl24_vehicle_id").references(() => pl24Vehicles.id, {
onDelete: "cascade",
}),
groupId: varchar("group_id", { length: 100 }).notNull(),
name: varchar("name", { length: 500 }).notNull(),
parentGroupId: varchar("parent_group_id", { length: 100 }),
sortOrder: integer("sort_order"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("pl24_part_groups_vehicle_id_idx").on(table.pl24VehicleId),
index("pl24_part_groups_group_id_idx").on(table.groupId),
],
);
// ─── PL24 Part ──────────────────────────────────────
export const pl24Parts = pgTable(
"pl24_parts",
{
id: uuid("id").primaryKey().defaultRandom(),
pl24VehicleId: uuid("pl24_vehicle_id").references(() => pl24Vehicles.id, {
onDelete: "cascade",
}),
groupId: uuid("group_id").references(() => pl24PartGroups.id, {
onDelete: "cascade",
}),
partId: varchar("part_id", { length: 100 }),
name: varchar("name", { length: 500 }).notNull(),
description: text("description"),
quantity: integer("quantity"),
position: varchar("position", { length: 100 }),
hotspotIndex: integer("hotspot_index"),
rawData: jsonb("raw_data"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("pl24_parts_vehicle_id_idx").on(table.pl24VehicleId),
index("pl24_parts_group_id_idx").on(table.groupId),
],
);
// ─── PL24 Part Number ───────────────────────────────
export const pl24PartNumbers = pgTable(
"pl24_part_numbers",
{
id: uuid("id").primaryKey().defaultRandom(),
pl24PartId: uuid("pl24_part_id").references(() => pl24Parts.id, {
onDelete: "cascade",
}),
oemCode: varchar("oem_code", { length: 100 }).notNull(),
isMain: boolean("is_main").default(true).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("pl24_part_numbers_part_id_idx").on(table.pl24PartId),
index("pl24_part_numbers_oem_code_idx").on(table.oemCode),
],
);
// ─── PL24 Vehicle Group ─────────────────────────────
export const pl24VehicleGroups = pgTable(
"pl24_vehicle_groups",
{
id: uuid("id").primaryKey().defaultRandom(),
catalogId: uuid("catalog_id").references(() => pl24Catalogs.id, {
onDelete: "cascade",
}),
groupId: varchar("group_id", { length: 100 }).notNull(),
name: varchar("name", { length: 500 }).notNull(),
parentGroupId: varchar("parent_group_id", { length: 100 }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("pl24_vehicle_groups_catalog_id_idx").on(table.catalogId),
index("pl24_vehicle_groups_group_id_idx").on(table.groupId),
],
);
// ─── PL24 Vehicle Part ──────────────────────────────
export const pl24VehicleParts = pgTable(
"pl24_vehicle_parts",
{
id: uuid("id").primaryKey().defaultRandom(),
pl24VehicleId: uuid("pl24_vehicle_id").references(() => pl24Vehicles.id, {
onDelete: "cascade",
}),
pl24PartId: uuid("pl24_part_id").references(() => pl24Parts.id, {
onDelete: "cascade",
}),
fitmentInfo: text("fitment_info"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("pl24_vehicle_parts_vehicle_id_idx").on(table.pl24VehicleId),
index("pl24_vehicle_parts_part_id_idx").on(table.pl24PartId),
],
);
// ─── PL24 Schema Pic ────────────────────────────────
export const pl24SchemaPics = pgTable(
"pl24_schema_pics",
{
id: uuid("id").primaryKey().defaultRandom(),
groupId: uuid("group_id").references(() => pl24PartGroups.id, {
onDelete: "cascade",
}),
imageUrl: text("image_url").notNull(),
originalUrl: text("original_url"),
hotspots: jsonb("hotspots").default("[]").notNull(),
width: integer("width"),
height: integer("height"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index("pl24_schema_pics_group_id_idx").on(table.groupId)],
);
// ─── PL24 Part Image ────────────────────────────────
export const pl24PartImages = pgTable(
"pl24_part_images",
{
id: uuid("id").primaryKey().defaultRandom(),
pl24PartId: uuid("pl24_part_id").references(() => pl24Parts.id, {
onDelete: "cascade",
}),
imageUrl: text("image_url").notNull(),
originalUrl: text("original_url"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index("pl24_part_images_part_id_idx").on(table.pl24PartId)],
);

View File

@@ -0,0 +1,115 @@
import { relations } from "drizzle-orm";
import {
users,
sessions,
accounts,
brands,
plans,
userSubscriptions,
userBrands,
payments,
queryLogs,
vehicles,
categories,
parts,
schemaPics,
referrals,
} from "./core";
export const usersRelations = relations(users, ({ many }) => ({
sessions: many(sessions),
accounts: many(accounts),
subscriptions: many(userSubscriptions),
userBrands: many(userBrands),
payments: many(payments),
queryLogs: many(queryLogs),
vehicles: many(vehicles),
referralsGiven: many(referrals, { relationName: "referrer" }),
referralsReceived: many(referrals, { relationName: "referred" }),
}));
export const sessionsRelations = relations(sessions, ({ one }) => ({
user: one(users, { fields: [sessions.userId], references: [users.id] }),
}));
export const accountsRelations = relations(accounts, ({ one }) => ({
user: one(users, { fields: [accounts.userId], references: [users.id] }),
}));
export const plansRelations = relations(plans, ({ many }) => ({
subscriptions: many(userSubscriptions),
}));
export const userSubscriptionsRelations = relations(userSubscriptions, ({ one, many }) => ({
user: one(users, { fields: [userSubscriptions.userId], references: [users.id] }),
plan: one(plans, { fields: [userSubscriptions.planId], references: [plans.id] }),
userBrands: many(userBrands),
payments: many(payments),
}));
export const userBrandsRelations = relations(userBrands, ({ one }) => ({
user: one(users, { fields: [userBrands.userId], references: [users.id] }),
subscription: one(userSubscriptions, {
fields: [userBrands.subscriptionId],
references: [userSubscriptions.id],
}),
brand: one(brands, { fields: [userBrands.brandId], references: [brands.id] }),
}));
export const brandsRelations = relations(brands, ({ many }) => ({
userBrands: many(userBrands),
}));
export const paymentsRelations = relations(payments, ({ one }) => ({
user: one(users, { fields: [payments.userId], references: [users.id] }),
subscription: one(userSubscriptions, {
fields: [payments.subscriptionId],
references: [userSubscriptions.id],
}),
}));
export const queryLogsRelations = relations(queryLogs, ({ one }) => ({
user: one(users, { fields: [queryLogs.userId], references: [users.id] }),
brand: one(brands, { fields: [queryLogs.brandId], references: [brands.id] }),
}));
export const vehiclesRelations = relations(vehicles, ({ one, many }) => ({
user: one(users, { fields: [vehicles.userId], references: [users.id] }),
brand: one(brands, { fields: [vehicles.brandId], references: [brands.id] }),
categories: many(categories),
parts: many(parts),
}));
export const categoriesRelations = relations(categories, ({ one, many }) => ({
vehicle: one(vehicles, { fields: [categories.vehicleId], references: [vehicles.id] }),
parent: one(categories, {
fields: [categories.parentId],
references: [categories.id],
relationName: "categoryParent",
}),
children: many(categories, { relationName: "categoryParent" }),
parts: many(parts),
schemaPics: many(schemaPics),
}));
export const partsRelations = relations(parts, ({ one }) => ({
vehicle: one(vehicles, { fields: [parts.vehicleId], references: [vehicles.id] }),
category: one(categories, { fields: [parts.categoryId], references: [categories.id] }),
}));
export const schemaPicsRelations = relations(schemaPics, ({ one }) => ({
category: one(categories, { fields: [schemaPics.categoryId], references: [categories.id] }),
}));
export const referralsRelations = relations(referrals, ({ one }) => ({
referrer: one(users, {
fields: [referrals.referrerId],
references: [users.id],
relationName: "referrer",
}),
referred: one(users, {
fields: [referrals.referredId],
references: [users.id],
relationName: "referred",
}),
}));

View File

@@ -0,0 +1,103 @@
import "dotenv/config";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { brands, plans, users, accounts } from "./schema/core";
const BRANDS_DATA = [
{ name: "BMW", slug: "bmw" },
{ name: "Mercedes-Benz", slug: "mercedes-benz" },
{ name: "Audi", slug: "audi" },
{ name: "Volkswagen", slug: "volkswagen" },
{ name: "Fiat", slug: "fiat" },
{ name: "Renault", slug: "renault" },
{ name: "Peugeot", slug: "peugeot" },
{ name: "Citroen", slug: "citroen" },
{ name: "Toyota", slug: "toyota" },
{ name: "Honda", slug: "honda" },
{ name: "Hyundai", slug: "hyundai" },
{ name: "Kia", slug: "kia" },
{ name: "Ford", slug: "ford" },
{ name: "Opel", slug: "opel" },
{ name: "Skoda", slug: "skoda" },
{ name: "Seat", slug: "seat" },
{ name: "Volvo", slug: "volvo" },
{ name: "Nissan", slug: "nissan" },
{ name: "Mazda", slug: "mazda" },
{ name: "Porsche", slug: "porsche" },
{ name: "Land Rover", slug: "land-rover" },
{ name: "Jaguar", slug: "jaguar" },
{ name: "Mini", slug: "mini" },
{ name: "Dacia", slug: "dacia" },
];
const PLANS_DATA = [
{ name: "1 Marka", brandCount: 1, priceMonthly: 200_00, priceYearly: 2000_00 },
{ name: "2 Marka", brandCount: 2, priceMonthly: 350_00, priceYearly: 3500_00 },
{ name: "3 Marka", brandCount: 3, priceMonthly: 500_00, priceYearly: 5000_00 },
{ name: "Full Paket", brandCount: 0, priceMonthly: 999_00, priceYearly: 9990_00 },
];
async function seed() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error("DATABASE_URL is required");
}
const client = postgres(databaseUrl, { max: 1 });
const db = drizzle(client);
console.log("Seeding database...");
// Seed brands
console.log("Seeding brands...");
await db
.insert(brands)
.values(BRANDS_DATA.map((b) => ({ name: b.name, slug: b.slug })))
.onConflictDoNothing();
// Seed plans
console.log("Seeding plans...");
await db.insert(plans).values(PLANS_DATA).onConflictDoNothing();
// Seed admin user
console.log("Seeding admin user...");
const [adminUser] = await db
.insert(users)
.values({
name: "Admin",
email: "admin@sase.tr",
emailVerified: true,
role: "admin",
referralCode: "ADMIN001",
})
.onConflictDoNothing()
.returning();
if (adminUser) {
// Create credential account for admin (password: Admin123!)
// In production, use Better Auth's proper password hashing
await db
.insert(accounts)
.values({
userId: adminUser.id,
accountId: adminUser.id,
providerId: "credential",
password:
"$2a$10$placeholder_hash_replace_with_better_auth",
})
.onConflictDoNothing();
}
console.log("Seed completed!");
console.log(`- ${BRANDS_DATA.length} brands`);
console.log(`- ${PLANS_DATA.length} plans`);
console.log("- 1 admin user (admin@sase.tr)");
await client.end();
process.exit(0);
}
seed().catch((err) => {
console.error("Seed failed:", err);
process.exit(1);
});

View File

@@ -0,0 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { EmailService } from "./email.service";
@Global()
@Module({
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}

View File

@@ -0,0 +1,69 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
export interface SendEmailOptions {
to: string;
subject: string;
html: string;
text?: string;
}
@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
private readonly isDev: boolean;
constructor(private configService: ConfigService) {
this.isDev = configService.get<string>("NODE_ENV") !== "production";
}
async send(options: SendEmailOptions): Promise<void> {
if (this.isDev) {
this.logger.log(`[DEV EMAIL] To: ${options.to}`);
this.logger.log(`[DEV EMAIL] Subject: ${options.subject}`);
this.logger.log(`[DEV EMAIL] Body: ${options.text || options.html.substring(0, 200)}`);
return;
}
// Production: integrate with Resend/SMTP here
this.logger.warn("Production email sending not configured yet");
}
async sendPasswordReset(to: string, resetUrl: string): Promise<void> {
await this.send({
to,
subject: "Şifre Sıfırlama - Sase.tr",
html: `
<h2>Şifre Sıfırlama</h2>
<p>Şifrenizi sıfırlamak için aşağıdaki bağlantıya tıklayın:</p>
<a href="${resetUrl}">${resetUrl}</a>
<p>Bu bağlantı 1 saat geçerlidir.</p>
`,
text: `Şifrenizi sıfırlamak için bu bağlantıyı kullanın: ${resetUrl}`,
});
}
async sendWelcome(to: string, name: string): Promise<void> {
await this.send({
to,
subject: "Hoş Geldiniz - Sase.tr",
html: `
<h2>Hoş Geldiniz, ${name}!</h2>
<p>Sase.tr'ye kaydınız başarılı. Aracınızın VIN numarasıyla yedek parça aramasına başlayabilirsiniz.</p>
`,
text: `Hoş Geldiniz ${name}! Sase.tr'ye kaydınız başarılı.`,
});
}
async sendPaymentConfirmation(to: string, amount: string): Promise<void> {
await this.send({
to,
subject: "Ödeme Onayı - Sase.tr",
html: `
<h2>Ödeme Onayı</h2>
<p>${amount} tutarındaki ödemeniz onaylanmıştır. Aboneliğiniz aktif edilmiştir.</p>
`,
text: `${amount} tutarındaki ödemeniz onaylanmıştır.`,
});
}
}

View File

@@ -0,0 +1,11 @@
import { Controller, Get } from "@nestjs/common";
import { Public } from "./common/decorators/public.decorator";
@Controller("health")
export class HealthController {
@Get()
@Public()
check() {
return { status: "ok", timestamp: new Date().toISOString() };
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { CorgiService } from "./corgi.service";
@Module({
providers: [CorgiService],
exports: [CorgiService],
})
export class CorgiModule {}

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, beforeEach } from "vitest";
import { CorgiService } from "./corgi.service";
describe("CorgiService", () => {
let service: CorgiService;
beforeEach(() => {
service = new CorgiService();
});
describe("getBrandFromWmi", () => {
it("should return BMW for known WMI WBA", () => {
expect(service.getBrandFromWmi("WBA")).toBe("BMW");
});
it("should return Mercedes-Benz for WDB", () => {
expect(service.getBrandFromWmi("WDB")).toBe("Mercedes-Benz");
});
it("should return Audi for WAU", () => {
expect(service.getBrandFromWmi("WAU")).toBe("Audi");
});
it("should return null for unknown WMI", () => {
expect(service.getBrandFromWmi("ZZZ")).toBeNull();
});
it("should handle lowercase WMI input", () => {
expect(service.getBrandFromWmi("wba")).toBe("BMW");
});
});
describe("decodeVin", () => {
it("should decode a BMW VIN correctly", () => {
// WBA = BMW, position 10 (index 9) = 'K' = 2019
const result = service.decodeVin("WBAPH5C55BA123456");
expect(result).not.toBeNull();
expect(result!.brandName).toBe("BMW");
expect(result!.wmi).toBe("WBA");
expect(result!.isKnown).toBe(true);
expect(result!.modelYear).toBe(2011); // 'B' at position 10
});
it("should extract model year 'A' as 2010", () => {
// Position 10 (index 9) = 'A' = 2010
const result = service.decodeVin("WBAPH5C55AA123456");
expect(result).not.toBeNull();
expect(result!.modelYear).toBe(2010);
});
it("should extract model year 'J' as 2018", () => {
const result = service.decodeVin("WBAPH5C55JA123456");
expect(result).not.toBeNull();
expect(result!.modelYear).toBe(2018);
});
it("should extract model year '1' as 2001", () => {
const result = service.decodeVin("WBAPH5C5510123456");
expect(result).not.toBeNull();
expect(result!.modelYear).toBe(2001);
});
it("should extract model year '9' as 2009", () => {
const result = service.decodeVin("WBAPH5C5590123456");
expect(result).not.toBeNull();
expect(result!.modelYear).toBe(2009);
});
it("should return null modelYear for unrecognized year character", () => {
// Position 10 (index 9) = '0' is not in YEAR_MAP
const result = service.decodeVin("WBAPH5C550A123456");
expect(result).not.toBeNull();
expect(result!.modelYear).toBeNull();
});
it("should return isKnown=false for unknown WMI", () => {
const result = service.decodeVin("ZZZPH5C55KA123456");
expect(result).not.toBeNull();
expect(result!.brandName).toBe("Unknown");
expect(result!.isKnown).toBe(false);
});
it("should return null for VIN with wrong length", () => {
expect(service.decodeVin("WBA123")).toBeNull();
expect(service.decodeVin("")).toBeNull();
expect(service.decodeVin("WBAPH5C55KA12345678")).toBeNull();
});
it("should handle lowercase VIN input", () => {
const result = service.decodeVin("wbaph5c55ka123456");
expect(result).not.toBeNull();
expect(result!.brandName).toBe("BMW");
expect(result!.wmi).toBe("WBA");
});
it("should decode a Toyota VIN correctly", () => {
const result = service.decodeVin("JTDKN3DU5LA123456");
expect(result).not.toBeNull();
expect(result!.brandName).toBe("Toyota");
expect(result!.wmi).toBe("JTD");
expect(result!.isKnown).toBe(true);
expect(result!.modelYear).toBe(2020); // 'L' at position 10
});
it("should decode a Volkswagen VIN with numeric WMI prefix", () => {
const result = service.decodeVin("3VWFE21C55M123456");
expect(result).not.toBeNull();
expect(result!.brandName).toBe("Volkswagen");
expect(result!.wmi).toBe("3VW");
expect(result!.isKnown).toBe(true);
});
});
});

View File

@@ -0,0 +1,101 @@
import { Injectable, Logger } from "@nestjs/common";
interface CorgiDecodeResult {
brandName: string;
wmi: string;
modelYear: number | null;
isKnown: boolean;
}
const WMI_DATABASE: Record<string, string> = {
// BMW
WBA: "BMW", WBS: "BMW", WBY: "BMW", "5UX": "BMW",
// Mercedes-Benz
WDB: "Mercedes-Benz", WDC: "Mercedes-Benz", WDD: "Mercedes-Benz", WDF: "Mercedes-Benz",
// Audi
WAU: "Audi", WUA: "Audi",
// Volkswagen
WVW: "Volkswagen", WVG: "Volkswagen", "3VW": "Volkswagen",
// Toyota
JTD: "Toyota", JTE: "Toyota", JTN: "Toyota", "2T1": "Toyota", "4T1": "Toyota",
// Fiat
ZFA: "Fiat", ZFC: "Fiat",
// Renault
VF1: "Renault", VF2: "Renault",
// Peugeot
VF3: "Peugeot",
// Citroen
VF7: "Citroen",
// Honda
JHM: "Honda", SHH: "Honda", "1HG": "Honda",
// Hyundai
KMH: "Hyundai", "5NP": "Hyundai",
// Kia
KNA: "Kia", KND: "Kia",
// Ford
WF0: "Ford", "1FA": "Ford", "3FA": "Ford",
// Opel
W0L: "Opel",
// Skoda
TMB: "Skoda",
// Seat
VSS: "Seat",
// Volvo
YV1: "Volvo",
// Nissan
JN1: "Nissan", "1N4": "Nissan", "3N1": "Nissan",
// Mazda
JM1: "Mazda", JM3: "Mazda",
// Porsche
WP0: "Porsche", WP1: "Porsche",
// Land Rover
SAL: "Land Rover",
// Jaguar
SAJ: "Jaguar",
// Mini
WMW: "Mini",
// Dacia
UU1: "Dacia",
};
const YEAR_MAP: Record<string, number> = {
A: 2010, B: 2011, C: 2012, D: 2013, E: 2014, F: 2015, G: 2016, H: 2017,
J: 2018, K: 2019, L: 2020, M: 2021, N: 2022, P: 2023, R: 2024, S: 2025,
T: 2026, V: 2027, W: 2028, X: 2029, Y: 2030,
"1": 2001, "2": 2002, "3": 2003, "4": 2004, "5": 2005,
"6": 2006, "7": 2007, "8": 2008, "9": 2009,
};
@Injectable()
export class CorgiService {
private readonly logger = new Logger(CorgiService.name);
decodeVin(vin: string): CorgiDecodeResult | null {
const upper = vin.toUpperCase();
if (upper.length !== 17) return null;
const wmi = upper.substring(0, 3);
const brandName = WMI_DATABASE[wmi];
if (!brandName) {
this.logger.warn(`Unknown WMI: ${wmi}`);
return { brandName: "Unknown", wmi, modelYear: this.extractYear(upper), isKnown: false };
}
return {
brandName,
wmi,
modelYear: this.extractYear(upper),
isKnown: true,
};
}
private extractYear(vin: string): number | null {
const yearChar = vin[9];
return YEAR_MAP[yearChar] ?? null;
}
getBrandFromWmi(wmi: string): string | null {
return WMI_DATABASE[wmi.toUpperCase()] ?? null;
}
}

View File

@@ -0,0 +1,22 @@
/**
* Ambient type declarations for browser-context code used in Puppeteer evaluate().
* These functions are serialized and executed inside Chromium, not in Node.js.
* We declare minimal DOM types here to avoid adding "dom" to the global tsconfig lib.
*/
interface Element {
querySelector(selector: string): Element | null;
querySelectorAll(selector: string): NodeListOf<Element>;
getAttribute(name: string): string | null;
textContent: string | null;
}
interface NodeListOf<T> {
forEach(callback: (value: T, index: number) => void): void;
length: number;
}
declare const document: {
querySelector(selector: string): Element | null;
querySelectorAll(selector: string): NodeListOf<Element>;
};

View File

@@ -0,0 +1,178 @@
import { Injectable, Logger } from "@nestjs/common";
import type {
EmexVehicleData,
EmexCategoryData,
EmexPartData,
} from "./emex.types";
@Injectable()
export class EmexParserService {
private readonly logger = new Logger(EmexParserService.name);
parseVehicleData(data: Record<string, unknown>): EmexVehicleData | null {
try {
const vehicleId = this.extractString(data, "vehicleId", "id", "vehicle_id");
if (!vehicleId) {
this.logger.warn("No vehicleId found in EMEX vehicle data");
return null;
}
const brandName = this.extractString(data, "brandName", "brand", "make") || "";
const name = this.extractString(data, "name", "title", "vehicleName") || "";
const modelCode = this.extractString(data, "modelCode", "model", "model_code");
const engine = this.extractString(data, "engine", "engineCode", "engine_code");
const yearFrom = this.extractNumber(data, "yearFrom", "year_from", "startYear");
const yearTo = this.extractNumber(data, "yearTo", "year_to", "endYear");
const catalogId = this.extractString(data, "catalogId", "catalog_id", "catalogueId") || "";
return {
vehicleId,
catalogId,
brandName,
name,
modelCode,
engine,
yearFrom,
yearTo,
rawData: data,
};
} catch (error) {
this.logger.error("Failed to parse EMEX vehicle data", error);
return null;
}
}
parseCategoryTree(data: unknown[]): EmexCategoryData[] {
try {
if (!Array.isArray(data)) {
this.logger.warn("Invalid category data: expected array");
return [];
}
const categories: EmexCategoryData[] = [];
for (const item of data) {
if (typeof item !== "object" || item === null) continue;
const record = item as Record<string, unknown>;
const groupId = this.extractString(record, "groupId", "id", "group_id");
const name = this.extractString(record, "name", "title", "groupName");
if (!groupId || !name) continue;
const category: EmexCategoryData = {
groupId,
name,
nameOriginal: this.extractString(record, "nameOriginal", "name_original", "originalName"),
parentGroupId: this.extractString(record, "parentGroupId", "parent_group_id", "parentId"),
sortOrder: this.extractNumber(record, "sortOrder", "sort_order", "order"),
};
categories.push(category);
// Recursively parse children if present
const children = record.children || record.subGroups || record.sub_groups;
if (Array.isArray(children) && children.length > 0) {
const childCategories = this.parseCategoryTree(
children.map((child: unknown) => ({
...(child as Record<string, unknown>),
parentGroupId: groupId,
})),
);
categories.push(...childCategories);
}
}
return categories;
} catch (error) {
this.logger.error("Failed to parse EMEX category tree", error);
return [];
}
}
parsePartsTable(data: unknown[]): EmexPartData[] {
try {
if (!Array.isArray(data)) {
this.logger.warn("Invalid parts data: expected array");
return [];
}
const parts: EmexPartData[] = [];
for (const item of data) {
if (typeof item !== "object" || item === null) continue;
const record = item as Record<string, unknown>;
const name = this.extractString(record, "name", "title", "partName");
if (!name) continue;
// Extract OEM codes
const oemCodes: string[] = [];
const rawOem = record.oemCodes || record.oem_codes || record.partNumbers || record.codes;
if (Array.isArray(rawOem)) {
for (const code of rawOem) {
if (typeof code === "string" && code.trim()) {
oemCodes.push(code.trim());
} else if (typeof code === "object" && code !== null) {
const codeStr = (code as Record<string, unknown>).code || (code as Record<string, unknown>).value;
if (typeof codeStr === "string" && codeStr.trim()) {
oemCodes.push(codeStr.trim());
}
}
}
} else if (typeof rawOem === "string" && rawOem.trim()) {
oemCodes.push(rawOem.trim());
}
// Try to extract single OEM code field
const singleOem = this.extractString(record, "oemCode", "oem_code", "partNumber");
if (singleOem && !oemCodes.includes(singleOem)) {
oemCodes.unshift(singleOem);
}
parts.push({
partId: this.extractString(record, "partId", "id", "part_id"),
name,
nameOriginal: this.extractString(record, "nameOriginal", "name_original", "originalName"),
description: this.extractString(record, "description", "desc", "note"),
quantity: this.extractNumber(record, "quantity", "qty", "count"),
position: this.extractString(record, "position", "pos", "location"),
hotspotIndex: this.extractNumber(record, "hotspotIndex", "hotspot_index", "hotspot"),
oemCodes,
});
}
return parts;
} catch (error) {
this.logger.error("Failed to parse EMEX parts table", error);
return [];
}
}
private extractString(data: Record<string, unknown>, ...keys: string[]): string | null {
for (const key of keys) {
const value = data[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return null;
}
private extractNumber(data: Record<string, unknown>, ...keys: string[]): number | null {
for (const key of keys) {
const value = data[key];
if (typeof value === "number" && !isNaN(value)) {
return value;
}
if (typeof value === "string") {
const parsed = parseInt(value, 10);
if (!isNaN(parsed)) return parsed;
}
}
return null;
}
}

View File

@@ -0,0 +1,136 @@
import { Injectable, Logger, OnModuleDestroy } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Queue, type JobsOptions } from "bullmq";
import { RedisService } from "../../redis/redis.service";
import type { EmexScrapeJobData, EmexJobStatus } from "./emex.types";
const QUEUE_NAME = "emex-scrape";
const DEFAULT_JOB_OPTIONS: JobsOptions = {
attempts: 3,
backoff: {
type: "exponential",
delay: 5000,
},
removeOnComplete: {
age: 86400, // 24h
count: 1000,
},
removeOnFail: {
age: 604800, // 7 days
count: 5000,
},
};
@Injectable()
export class EmexQueueService implements OnModuleDestroy {
private readonly logger = new Logger(EmexQueueService.name);
private readonly queue: Queue<EmexScrapeJobData>;
constructor(
private configService: ConfigService,
private redis: RedisService,
) {
const redisHost = this.configService.get<string>("redis.host", "127.0.0.1");
const redisPort = this.configService.get<number>("redis.port", 6379);
const redisPassword = this.configService.get<string>("redis.password");
this.queue = new Queue<EmexScrapeJobData>(QUEUE_NAME, {
connection: {
host: redisHost,
port: redisPort,
password: redisPassword,
maxRetriesPerRequest: null,
},
defaultJobOptions: DEFAULT_JOB_OPTIONS,
});
this.logger.log(`EMEX scrape queue initialized: ${QUEUE_NAME}`);
}
async onModuleDestroy() {
await this.queue.close();
this.logger.log("EMEX scrape queue closed");
}
async addScrapeJob(vin: string, userId: string): Promise<string> {
const jobData: EmexScrapeJobData = {
vin,
userId,
type: "full-decode",
};
const job = await this.queue.add("decode-vin", jobData, {
jobId: `emex-decode:${vin}:${Date.now()}`,
priority: 1,
});
this.logger.log(`Added EMEX scrape job for VIN: ${vin}, jobId: ${job.id}`);
return job.id!;
}
async addCategoriesJob(
emexVehicleId: string,
vin: string,
userId: string,
): Promise<string> {
const jobData: EmexScrapeJobData = {
vin,
userId,
type: "categories",
emexVehicleId,
};
const job = await this.queue.add("scrape-categories", jobData, {
jobId: `emex-categories:${emexVehicleId}:${Date.now()}`,
priority: 2,
});
this.logger.log(`Added EMEX categories job for vehicleId: ${emexVehicleId}, jobId: ${job.id}`);
return job.id!;
}
async addPartsJob(
emexVehicleId: string,
groupId: string,
vin: string,
userId: string,
): Promise<string> {
const jobData: EmexScrapeJobData = {
vin,
userId,
type: "parts",
emexVehicleId,
groupId,
};
const job = await this.queue.add("scrape-parts", jobData, {
jobId: `emex-parts:${emexVehicleId}:${groupId}:${Date.now()}`,
priority: 3,
});
this.logger.log(
`Added EMEX parts job for vehicleId: ${emexVehicleId}, groupId: ${groupId}, jobId: ${job.id}`,
);
return job.id!;
}
async getJobStatus(jobId: string): Promise<EmexJobStatus | null> {
const job = await this.queue.getJob(jobId);
if (!job) return null;
const state = await job.getState();
return {
jobId: job.id!,
status: state as EmexJobStatus["status"],
progress: typeof job.progress === "number" ? job.progress : 0,
result: state === "completed" ? (job.returnvalue as EmexJobStatus["result"]) : null,
failedReason: job.failedReason || null,
};
}
getQueue(): Queue<EmexScrapeJobData> {
return this.queue;
}
}

View File

@@ -0,0 +1,419 @@
import { Injectable, Logger, OnModuleDestroy } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Browser, Page } from "puppeteer";
import puppeteer from "puppeteer";
import { EmexParserService } from "./emex-parser.service";
import {
EmexCaptchaError,
EmexScraperError,
type EmexVehicleData,
type EmexCategoryData,
type EmexPartData,
type EmexCredentials,
} from "./emex.types";
const MAX_CONCURRENT_PAGES = 3;
const MAX_RETRIES = 3;
const PAGE_TIMEOUT = 30_000;
const EMEX_BASE_URL = "https://emex.ru";
@Injectable()
export class EmexScraperService implements OnModuleDestroy {
private readonly logger = new Logger(EmexScraperService.name);
private browser: Browser | null = null;
private activePagesCount = 0;
private readonly pageQueue: Array<{
resolve: (page: Page) => void;
reject: (error: Error) => void;
}> = [];
private readonly credentials: EmexCredentials;
constructor(
private configService: ConfigService,
private parser: EmexParserService,
) {
this.credentials = {
username: this.configService.get<string>("emex.username") || "",
password: this.configService.get<string>("emex.password") || "",
};
}
async onModuleDestroy() {
await this.closeBrowser();
}
async scrapeVehicle(vin: string): Promise<EmexVehicleData | null> {
return this.withRetry(`scrapeVehicle(${vin})`, async () => {
const page = await this.acquirePage();
try {
await this.ensureLoggedIn(page);
await page.goto(`${EMEX_BASE_URL}/catalogs/decode?vin=${encodeURIComponent(vin)}`, {
waitUntil: "networkidle2",
timeout: PAGE_TIMEOUT,
});
this.detectCaptcha(page);
const vehicleData = await page.evaluate(this.extractVehicleFromPage);
if (!vehicleData) {
this.logger.warn(`No vehicle data found for VIN: ${vin}`);
return null;
}
return this.parser.parseVehicleData(vehicleData);
} finally {
await this.releasePage(page);
}
});
}
async scrapeCategories(emexVehicleId: string): Promise<EmexCategoryData[]> {
return this.withRetry(`scrapeCategories(${emexVehicleId})`, async () => {
const page = await this.acquirePage();
try {
await this.ensureLoggedIn(page);
await page.goto(
`${EMEX_BASE_URL}/catalogs/vehicle/${encodeURIComponent(emexVehicleId)}/groups`,
{
waitUntil: "networkidle2",
timeout: PAGE_TIMEOUT,
},
);
this.detectCaptcha(page);
const categoriesData = await page.evaluate(this.extractCategoriesFromPage);
return this.parser.parseCategoryTree(categoriesData);
} finally {
await this.releasePage(page);
}
});
}
async scrapeParts(emexVehicleId: string, groupId: string): Promise<EmexPartData[]> {
return this.withRetry(`scrapeParts(${emexVehicleId}, ${groupId})`, async () => {
const page = await this.acquirePage();
try {
await this.ensureLoggedIn(page);
await page.goto(
`${EMEX_BASE_URL}/catalogs/vehicle/${encodeURIComponent(emexVehicleId)}/groups/${encodeURIComponent(groupId)}/parts`,
{
waitUntil: "networkidle2",
timeout: PAGE_TIMEOUT,
},
);
this.detectCaptcha(page);
const partsData = await page.evaluate(this.extractPartsFromPage);
return this.parser.parsePartsTable(partsData);
} finally {
await this.releasePage(page);
}
});
}
/**
* Browser-context function: extracts vehicle data from the EMEX decode page.
* Serialized and sent to Puppeteer's evaluate — runs inside Chromium, not Node.
*/
private extractVehicleFromPage(): Record<string, unknown> | null {
const vehicleInfo = document.querySelector(
"[data-vehicle-info], .vehicle-info, .decode-result",
);
if (!vehicleInfo) return null;
const data: Record<string, unknown> = {};
data.vehicleId = vehicleInfo.getAttribute("data-vehicle-id") || "";
data.catalogId = vehicleInfo.getAttribute("data-catalog-id") || "";
const fields = vehicleInfo.querySelectorAll("[data-field], .info-row, tr");
fields.forEach((field: Element) => {
const label =
field.querySelector(".label, th, [data-label]")?.textContent?.trim().toLowerCase() || "";
const value =
field.querySelector(".value, td, [data-value]")?.textContent?.trim() || "";
if (label.includes("brand") || label.includes("marka")) data.brandName = value;
if (label.includes("model")) data.modelCode = value;
if (label.includes("name") || label.includes("ad")) data.name = value;
if (label.includes("engine") || label.includes("motor")) data.engine = value;
if (label.includes("year") || label.includes("yil") || label.includes("yıl")) {
const years = value.match(/(\d{4})/g);
if (years) {
data.yearFrom = parseInt(years[0], 10);
if (years.length > 1) data.yearTo = parseInt(years[1], 10);
}
}
});
return data;
}
/**
* Browser-context function: extracts category groups from the EMEX groups page.
*/
private extractCategoriesFromPage(): Record<string, unknown>[] {
const groups: Record<string, unknown>[] = [];
const groupElements = document.querySelectorAll(
"[data-group], .group-item, .category-item, .tree-node",
);
groupElements.forEach((el: Element, index: number) => {
const group: Record<string, unknown> = {};
group.groupId =
el.getAttribute("data-group-id") || el.getAttribute("data-id") || `group-${index}`;
group.name =
el.querySelector(".group-name, .name, .title")?.textContent?.trim() || "";
group.nameOriginal = el.getAttribute("data-original-name") || null;
group.parentGroupId = el.getAttribute("data-parent-id") || null;
group.sortOrder = index;
if (group.name) {
groups.push(group);
}
});
return groups;
}
/**
* Browser-context function: extracts parts from the EMEX parts page.
*/
private extractPartsFromPage(): Record<string, unknown>[] {
const parts: Record<string, unknown>[] = [];
const partRows = document.querySelectorAll(
"[data-part], .part-row, .parts-table tbody tr, .part-item",
);
partRows.forEach((row: Element) => {
const part: Record<string, unknown> = {};
part.partId =
row.getAttribute("data-part-id") || row.getAttribute("data-id") || null;
part.name =
row.querySelector(".part-name, .name, td:nth-child(2)")?.textContent?.trim() || "";
part.nameOriginal = row.getAttribute("data-original-name") || null;
part.description =
row.querySelector(".part-desc, .description, td:nth-child(3)")?.textContent?.trim() ||
null;
const qtyText = row
.querySelector(".part-qty, .quantity, td:nth-child(4)")
?.textContent?.trim();
part.quantity = qtyText ? parseInt(qtyText, 10) : null;
part.position =
row.querySelector(".part-position, .position")?.textContent?.trim() || null;
const hotspotAttr = row.getAttribute("data-hotspot");
part.hotspotIndex = hotspotAttr ? parseInt(hotspotAttr, 10) : null;
// Extract OEM codes
const oemElements = row.querySelectorAll(".oem-code, .part-number, [data-oem]");
const codes: string[] = [];
oemElements.forEach((el: Element) => {
const code = el.textContent?.trim();
if (code) codes.push(code);
});
part.oemCodes = codes;
// Fallback: single OEM code field
if (codes.length === 0) {
const singleOem = row.querySelector("td:first-child")?.textContent?.trim();
if (singleOem) part.oemCode = singleOem;
}
if (part.name) {
parts.push(part);
}
});
return parts;
}
private async ensureLoggedIn(page: Page): Promise<void> {
if (!this.credentials.username || !this.credentials.password) {
throw new EmexScraperError("EMEX credentials not configured", false);
}
// Check if already logged in by looking for session cookie
const cookies = await page.cookies(EMEX_BASE_URL);
const sessionCookie = cookies.find(
(c) => c.name === "session" || c.name === "PHPSESSID" || c.name === "auth_token",
);
if (sessionCookie) return;
await page.goto(`${EMEX_BASE_URL}/login`, {
waitUntil: "networkidle2",
timeout: PAGE_TIMEOUT,
});
this.detectCaptcha(page);
await page.type(
'input[name="username"], input[name="email"], input[name="login"], #username, #email',
this.credentials.username,
);
await page.type(
'input[name="password"], input[type="password"], #password',
this.credentials.password,
);
await Promise.all([
page.waitForNavigation({ waitUntil: "networkidle2", timeout: PAGE_TIMEOUT }),
page.click('button[type="submit"], input[type="submit"], .login-btn, #login-btn'),
]);
this.detectCaptcha(page);
this.logger.log("Successfully logged in to EMEX");
}
private detectCaptcha(page: Page): void {
// Synchronous check of page URL for captcha indicators
const url = page.url();
if (url.includes("captcha") || url.includes("challenge")) {
throw new EmexCaptchaError();
}
}
private async withRetry<T>(operation: string, fn: () => Promise<T>): Promise<T> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (error instanceof EmexCaptchaError) {
this.logger.error(`CAPTCHA detected during ${operation}, cannot retry`);
throw error;
}
if (error instanceof EmexScraperError && !error.retryable) {
throw error;
}
this.logger.warn(
`Attempt ${attempt}/${MAX_RETRIES} failed for ${operation}: ${lastError.message}`,
);
if (attempt < MAX_RETRIES) {
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10_000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
throw new EmexScraperError(
`${operation} failed after ${MAX_RETRIES} attempts: ${lastError?.message}`,
false,
);
}
private async getBrowser(): Promise<Browser> {
if (!this.browser || !this.browser.connected) {
this.browser = await puppeteer.launch({
headless: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-extensions",
"--single-process",
],
});
this.browser.on("disconnected", () => {
this.logger.warn("Browser disconnected");
this.browser = null;
this.activePagesCount = 0;
});
this.logger.log("Puppeteer browser launched");
}
return this.browser;
}
private async acquirePage(): Promise<Page> {
if (this.activePagesCount >= MAX_CONCURRENT_PAGES) {
return new Promise<Page>((resolve, reject) => {
this.pageQueue.push({ resolve, reject });
});
}
this.activePagesCount++;
try {
const browser = await this.getBrowser();
const page = await browser.newPage();
await page.setDefaultTimeout(PAGE_TIMEOUT);
await page.setDefaultNavigationTimeout(PAGE_TIMEOUT);
await page.setViewport({ width: 1280, height: 800 });
await page.setUserAgent(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
);
return page;
} catch (error) {
this.activePagesCount--;
this.processPageQueue();
throw error;
}
}
private async releasePage(page: Page): Promise<void> {
try {
if (!page.isClosed()) {
await page.close();
}
} catch {
// Page may already be closed
}
this.activePagesCount--;
this.processPageQueue();
}
private processPageQueue(): void {
if (this.pageQueue.length > 0 && this.activePagesCount < MAX_CONCURRENT_PAGES) {
const next = this.pageQueue.shift();
if (next) {
this.acquirePage().then(next.resolve).catch(next.reject);
}
}
}
private async closeBrowser(): Promise<void> {
// Reject queued page requests
for (const queued of this.pageQueue) {
queued.reject(new Error("Browser closing"));
}
this.pageQueue.length = 0;
if (this.browser) {
try {
await this.browser.close();
} catch {
// Browser may already be closed
}
this.browser = null;
this.activePagesCount = 0;
this.logger.log("Puppeteer browser closed");
}
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { EmexService } from "./emex.service";
import { EmexScraperService } from "./emex-scraper.service";
import { EmexParserService } from "./emex-parser.service";
import { EmexQueueService } from "./emex-queue.service";
@Module({
providers: [EmexService, EmexScraperService, EmexParserService, EmexQueueService],
exports: [EmexService],
})
export class EmexModule {}

View File

@@ -0,0 +1,371 @@
import { Inject, Injectable, Logger } from "@nestjs/common";
import { eq, and } from "drizzle-orm";
import { DATABASE, type Database } from "../../database/database.provider";
import { RedisService } from "../../redis/redis.service";
import { EmexScraperService } from "./emex-scraper.service";
import { EmexQueueService } from "./emex-queue.service";
import {
emexVehicles,
emexVehicleVins,
emexPartGroups,
emexParts,
emexPartNumbers,
emexCatalogs,
emexScrapeSessions,
} from "../../database/schema/emex";
import type {
EmexVehicleData,
EmexCategoryData,
EmexPartData,
EmexJobStatus,
} from "./emex.types";
const CACHE_PREFIX = "emex:";
const VEHICLE_CACHE_TTL = 86400; // 24h
const CATEGORY_CACHE_TTL = 3600; // 1h
const PARTS_CACHE_TTL = 3600; // 1h
@Injectable()
export class EmexService {
private readonly logger = new Logger(EmexService.name);
constructor(
@Inject(DATABASE) private db: Database,
private redis: RedisService,
private scraper: EmexScraperService,
private queue: EmexQueueService,
) {}
async decodeVin(vin: string, userId: string): Promise<{ jobId: string }> {
// Check if we already have a recent scrape session
const existingSession = await this.db
.select()
.from(emexScrapeSessions)
.where(and(eq(emexScrapeSessions.vin, vin), eq(emexScrapeSessions.status, "pending")))
.limit(1);
if (existingSession.length > 0 && existingSession[0].jobId) {
this.logger.log(`Reusing existing scrape session for VIN: ${vin}`);
return { jobId: existingSession[0].jobId };
}
const jobId = await this.queue.addScrapeJob(vin, userId);
// Create scrape session record
await this.db.insert(emexScrapeSessions).values({
vin,
status: "pending",
jobId,
startedAt: new Date(),
});
return { jobId };
}
async getJobStatus(jobId: string): Promise<EmexJobStatus | null> {
return this.queue.getJobStatus(jobId);
}
async getScrapedVehicle(vin: string): Promise<EmexVehicleData | null> {
// Redis cache check
const cacheKey = `${CACHE_PREFIX}vehicle:${vin}`;
const cached = await this.redis.getJson<EmexVehicleData>(cacheKey);
if (cached) return cached;
// Database check via VIN link
const vinRecord = await this.db
.select()
.from(emexVehicleVins)
.where(eq(emexVehicleVins.vin, vin))
.limit(1);
if (vinRecord.length === 0 || !vinRecord[0].emexVehicleId) return null;
const vehicleRecord = await this.db
.select()
.from(emexVehicles)
.where(eq(emexVehicles.id, vinRecord[0].emexVehicleId))
.limit(1);
if (vehicleRecord.length === 0) return null;
const vehicle = vehicleRecord[0];
const result: EmexVehicleData = {
vehicleId: vehicle.vehicleId,
catalogId: vehicle.catalogId || "",
brandName: "",
name: vehicle.name || "",
modelCode: vehicle.modelCode || null,
engine: vehicle.engine || null,
yearFrom: vehicle.yearFrom || null,
yearTo: vehicle.yearTo || null,
rawData: (vehicle.rawData as Record<string, unknown>) || null,
};
// Resolve brand name from catalog
if (vehicle.catalogId) {
const catalog = await this.db
.select()
.from(emexCatalogs)
.where(eq(emexCatalogs.id, vehicle.catalogId))
.limit(1);
if (catalog.length > 0) {
result.brandName = catalog[0].brandName;
}
}
await this.redis.setJson(cacheKey, result, VEHICLE_CACHE_TTL);
return result;
}
async getScrapedCategories(vehicleId: string): Promise<EmexCategoryData[]> {
const cacheKey = `${CACHE_PREFIX}categories:${vehicleId}`;
const cached = await this.redis.getJson<EmexCategoryData[]>(cacheKey);
if (cached) return cached;
// Look up the internal UUID from the emex vehicleId string
const vehicleRecord = await this.db
.select()
.from(emexVehicles)
.where(eq(emexVehicles.vehicleId, vehicleId))
.limit(1);
if (vehicleRecord.length === 0) return [];
const emexVehicleUuid = vehicleRecord[0].id;
const groups = await this.db
.select()
.from(emexPartGroups)
.where(eq(emexPartGroups.emexVehicleId, emexVehicleUuid));
const categories: EmexCategoryData[] = groups.map((g) => ({
groupId: g.groupId,
name: g.name,
nameOriginal: g.nameOriginal || null,
parentGroupId: g.parentGroupId || null,
sortOrder: g.sortOrder || null,
}));
if (categories.length > 0) {
await this.redis.setJson(cacheKey, categories, CATEGORY_CACHE_TTL);
}
return categories;
}
async getScrapedParts(vehicleId: string, groupId: string): Promise<EmexPartData[]> {
const cacheKey = `${CACHE_PREFIX}parts:${vehicleId}:${groupId}`;
const cached = await this.redis.getJson<EmexPartData[]>(cacheKey);
if (cached) return cached;
// Look up internal UUIDs
const vehicleRecord = await this.db
.select()
.from(emexVehicles)
.where(eq(emexVehicles.vehicleId, vehicleId))
.limit(1);
if (vehicleRecord.length === 0) return [];
const emexVehicleUuid = vehicleRecord[0].id;
const groupRecord = await this.db
.select()
.from(emexPartGroups)
.where(
and(
eq(emexPartGroups.emexVehicleId, emexVehicleUuid),
eq(emexPartGroups.groupId, groupId),
),
)
.limit(1);
if (groupRecord.length === 0) return [];
const groupUuid = groupRecord[0].id;
// Fetch parts for this group
const partsRecords = await this.db
.select()
.from(emexParts)
.where(
and(
eq(emexParts.emexVehicleId, emexVehicleUuid),
eq(emexParts.groupId, groupUuid),
),
);
// Fetch OEM codes for each part
const parts: EmexPartData[] = await Promise.all(
partsRecords.map(async (part) => {
const partNumbers = await this.db
.select()
.from(emexPartNumbers)
.where(eq(emexPartNumbers.emexPartId, part.id));
return {
partId: part.partId || null,
name: part.name,
nameOriginal: part.nameOriginal || null,
description: part.description || null,
quantity: part.quantity || null,
position: part.position || null,
hotspotIndex: part.hotspotIndex || null,
oemCodes: partNumbers.map((pn) => pn.oemCode),
};
}),
);
if (parts.length > 0) {
await this.redis.setJson(cacheKey, parts, PARTS_CACHE_TTL);
}
return parts;
}
async saveScrapedVehicle(vin: string, data: EmexVehicleData): Promise<string> {
// Upsert catalog
let catalogUuid: string | null = null;
if (data.catalogId) {
const existingCatalog = await this.db
.select()
.from(emexCatalogs)
.where(eq(emexCatalogs.catalogId, data.catalogId))
.limit(1);
if (existingCatalog.length > 0) {
catalogUuid = existingCatalog[0].id;
} else {
const [inserted] = await this.db
.insert(emexCatalogs)
.values({
catalogId: data.catalogId,
brandName: data.brandName,
})
.returning();
catalogUuid = inserted.id;
}
}
// Upsert vehicle
const existingVehicle = await this.db
.select()
.from(emexVehicles)
.where(eq(emexVehicles.vehicleId, data.vehicleId))
.limit(1);
let vehicleUuid: string;
if (existingVehicle.length > 0) {
vehicleUuid = existingVehicle[0].id;
await this.db
.update(emexVehicles)
.set({
catalogId: catalogUuid,
name: data.name,
modelCode: data.modelCode,
engine: data.engine,
yearFrom: data.yearFrom,
yearTo: data.yearTo,
rawData: data.rawData,
})
.where(eq(emexVehicles.id, vehicleUuid));
} else {
const [inserted] = await this.db
.insert(emexVehicles)
.values({
vehicleId: data.vehicleId,
catalogId: catalogUuid,
name: data.name,
modelCode: data.modelCode,
engine: data.engine,
yearFrom: data.yearFrom,
yearTo: data.yearTo,
rawData: data.rawData,
})
.returning();
vehicleUuid = inserted.id;
}
// Link VIN to vehicle
const existingVinLink = await this.db
.select()
.from(emexVehicleVins)
.where(eq(emexVehicleVins.vin, vin))
.limit(1);
if (existingVinLink.length === 0) {
await this.db.insert(emexVehicleVins).values({
emexVehicleId: vehicleUuid,
vin,
});
}
// Invalidate cache
await this.redis.del(`${CACHE_PREFIX}vehicle:${vin}`);
return vehicleUuid;
}
async saveScrapedCategories(
emexVehicleUuid: string,
categories: EmexCategoryData[],
): Promise<void> {
for (const category of categories) {
const existing = await this.db
.select()
.from(emexPartGroups)
.where(
and(
eq(emexPartGroups.emexVehicleId, emexVehicleUuid),
eq(emexPartGroups.groupId, category.groupId),
),
)
.limit(1);
if (existing.length === 0) {
await this.db.insert(emexPartGroups).values({
emexVehicleId: emexVehicleUuid,
groupId: category.groupId,
name: category.name,
nameOriginal: category.nameOriginal,
parentGroupId: category.parentGroupId,
sortOrder: category.sortOrder,
});
}
}
}
async saveScrapedParts(
emexVehicleUuid: string,
groupUuid: string,
parts: EmexPartData[],
): Promise<void> {
for (const part of parts) {
const [insertedPart] = await this.db
.insert(emexParts)
.values({
emexVehicleId: emexVehicleUuid,
groupId: groupUuid,
partId: part.partId,
name: part.name,
nameOriginal: part.nameOriginal,
description: part.description,
quantity: part.quantity,
position: part.position,
hotspotIndex: part.hotspotIndex,
})
.returning();
// Insert OEM codes
for (let i = 0; i < part.oemCodes.length; i++) {
await this.db.insert(emexPartNumbers).values({
emexPartId: insertedPart.id,
oemCode: part.oemCodes[i],
isMain: i === 0,
});
}
}
}
}

View File

@@ -0,0 +1,74 @@
export interface EmexVehicleData {
vehicleId: string;
catalogId: string;
brandName: string;
name: string;
modelCode: string | null;
engine: string | null;
yearFrom: number | null;
yearTo: number | null;
rawData: Record<string, unknown> | null;
}
export interface EmexCategoryData {
groupId: string;
name: string;
nameOriginal: string | null;
parentGroupId: string | null;
sortOrder: number | null;
}
export interface EmexPartData {
partId: string | null;
name: string;
nameOriginal: string | null;
description: string | null;
quantity: number | null;
position: string | null;
hotspotIndex: number | null;
oemCodes: string[];
}
export interface EmexScrapeJobData {
vin: string;
userId: string;
type: "full-decode" | "categories" | "parts";
emexVehicleId?: string;
groupId?: string;
}
export interface EmexScrapeResult {
vehicle: EmexVehicleData | null;
categories: EmexCategoryData[];
parts: EmexPartData[];
}
export interface EmexJobStatus {
jobId: string;
status: "waiting" | "active" | "completed" | "failed" | "delayed";
progress: number;
result: EmexScrapeResult | null;
failedReason: string | null;
}
export interface EmexCredentials {
username: string;
password: string;
}
export class EmexCaptchaError extends Error {
constructor(message = "CAPTCHA detected on EMEX page") {
super(message);
this.name = "EmexCaptchaError";
}
}
export class EmexScraperError extends Error {
constructor(
message: string,
public readonly retryable = true,
) {
super(message);
this.name = "EmexScraperError";
}
}

View File

@@ -0,0 +1,21 @@
import { ParsedVehicle, ParsedCategory, PL24PartResponse } from "../pl24.types";
export abstract class BasePL24Parser {
abstract readonly brandName: string;
abstract parseVehicle(raw: Record<string, unknown>): ParsedVehicle;
abstract parseCategories(raw: unknown[]): ParsedCategory[];
abstract parseParts(raw: unknown[]): PL24PartResponse[];
protected safeString(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
return String(value);
}
protected safeNumber(value: unknown): number {
if (typeof value === "number") return value;
const parsed = Number(value);
return isNaN(parsed) ? 0 : parsed;
}
}

View File

@@ -0,0 +1,17 @@
import { GenericPL24Parser } from "./generic-parser";
import { ParsedVehicle } from "../pl24.types";
export class BmwPL24Parser extends GenericPL24Parser {
constructor() {
super("BMW");
}
parseVehicle(raw: Record<string, unknown>): ParsedVehicle {
const base = super.parseVehicle(raw);
// BMW-specific: extract series from model code (E90, F30, G20, etc.)
if (base.modelCode && typeof raw.series === "string") {
base.name = `${raw.series} ${base.modelCode}`;
}
return base;
}
}

View File

@@ -0,0 +1,56 @@
import { BasePL24Parser } from "./base-parser";
import { ParsedVehicle, ParsedCategory, PL24PartResponse } from "../pl24.types";
export class GenericPL24Parser extends BasePL24Parser {
readonly brandName: string;
constructor(brandName: string) {
super();
this.brandName = brandName;
}
parseVehicle(raw: Record<string, unknown>): ParsedVehicle {
return {
vehicleId: this.safeString(raw.vehicleId || raw.id),
catalogId: this.safeString(raw.catalogId || raw.catalog_id),
name: this.safeString(raw.name || raw.description),
modelCode: this.safeString(raw.modelCode || raw.model_code || raw.model),
engine: this.safeString(raw.engine || raw.engineCode),
transmission: this.safeString(raw.transmission || raw.gearbox),
bodyType: this.safeString(raw.bodyType || raw.body_type || raw.body),
market: this.safeString(raw.market || raw.region),
yearFrom: this.safeNumber(raw.yearFrom || raw.year_from || raw.prodFrom),
yearTo: this.safeNumber(raw.yearTo || raw.year_to || raw.prodTo),
};
}
parseCategories(raw: unknown[]): ParsedCategory[] {
return raw.map((item: any, index: number) => ({
groupId: this.safeString(item.groupId || item.id || item.group_id),
name: this.safeString(item.name || item.description),
parentGroupId: item.parentGroupId || item.parent_group_id || null,
sortOrder: this.safeNumber(item.sortOrder || item.sort_order || index),
hasSchemaPic: !!item.hasSchemaPic || !!item.has_schema || !!item.imageUrl,
}));
}
parseParts(raw: unknown[]): PL24PartResponse[] {
return raw.map((item: any) => ({
partId: this.safeString(item.partId || item.id || item.part_id),
name: this.safeString(item.name || item.description),
description: this.safeString(item.description || item.additionalInfo || ""),
quantity: this.safeNumber(item.quantity || item.qty || 1),
position: this.safeString(item.position || item.pos || ""),
hotspotIndex: item.hotspotIndex ?? item.hotspot_index ?? item.callout ?? null,
oemCodes: this.extractOemCodes(item),
}));
}
private extractOemCodes(item: any): string[] {
if (Array.isArray(item.oemCodes)) return item.oemCodes;
if (Array.isArray(item.partNumbers)) return item.partNumbers.map((p: any) => p.code || p);
if (item.oemCode) return [item.oemCode];
if (item.partNumber) return [item.partNumber];
return [];
}
}

View File

@@ -0,0 +1,17 @@
import { GenericPL24Parser } from "./generic-parser";
import { ParsedVehicle } from "../pl24.types";
export class MercedesPL24Parser extends GenericPL24Parser {
constructor() {
super("Mercedes-Benz");
}
parseVehicle(raw: Record<string, unknown>): ParsedVehicle {
const base = super.parseVehicle(raw);
// Mercedes-specific: extract class (W205, W213, etc.)
if (typeof raw.baumuster === "string") {
base.modelCode = raw.baumuster;
}
return base;
}
}

View File

@@ -0,0 +1,15 @@
import { BasePL24Parser } from "./base-parser";
import { BmwPL24Parser } from "./bmw-parser";
import { MercedesPL24Parser } from "./mercedes-parser";
import { GenericPL24Parser } from "./generic-parser";
const PARSER_MAP: Record<string, () => BasePL24Parser> = {
"BMW": () => new BmwPL24Parser(),
"Mercedes-Benz": () => new MercedesPL24Parser(),
};
export function createParser(brandName: string): BasePL24Parser {
const factory = PARSER_MAP[brandName];
if (factory) return factory();
return new GenericPL24Parser(brandName);
}

View File

@@ -0,0 +1,61 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { RedisService } from "../../redis/redis.service";
import { PL24_DEFAULTS } from "./pl24.constants";
@Injectable()
export class PL24AuthService {
private readonly logger = new Logger(PL24AuthService.name);
private readonly cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}auth_token`;
constructor(
private configService: ConfigService,
private redis: RedisService,
) {}
async getToken(): Promise<string> {
// Check Redis cache
const cached = await this.redis.get(this.cacheKey);
if (cached) return cached;
// Authenticate with PL24
const token = await this.authenticate();
await this.redis.set(this.cacheKey, token, PL24_DEFAULTS.AUTH_TOKEN_TTL);
return token;
}
private async authenticate(): Promise<string> {
const apiUrl = this.configService.get<string>("pl24.apiUrl");
const username = this.configService.get<string>("pl24.username");
const password = this.configService.get<string>("pl24.password");
if (!apiUrl || !username || !password) {
this.logger.warn("PL24 credentials not configured");
throw new Error("PL24 credentials not configured");
}
try {
const response = await fetch(`${apiUrl}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
signal: AbortSignal.timeout(PL24_DEFAULTS.REQUEST_TIMEOUT),
});
if (!response.ok) {
throw new Error(`PL24 auth failed: ${response.status}`);
}
const data = (await response.json()) as { token: string };
this.logger.log("PL24 authenticated successfully");
return data.token;
} catch (error) {
this.logger.error("PL24 authentication failed", error);
throw error;
}
}
async invalidateToken(): Promise<void> {
await this.redis.del(this.cacheKey);
}
}

View File

@@ -0,0 +1,6 @@
export const PL24_DEFAULTS = {
AUTH_TOKEN_TTL: 3600, // 1 hour in seconds
CACHE_PREFIX: "pl24:",
REQUEST_TIMEOUT: 30000,
MAX_RETRIES: 3,
} as const;

View File

@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { PL24Service } from "./pl24.service";
import { PL24AuthService } from "./pl24-auth.service";
@Module({
providers: [PL24Service, PL24AuthService],
exports: [PL24Service],
})
export class PL24Module {}

View File

@@ -0,0 +1,189 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PL24AuthService } from "./pl24-auth.service";
import { RedisService } from "../../redis/redis.service";
import { StorageService } from "../../storage/storage.service";
import { createParser } from "./parsers/parser-factory";
import { PL24_DEFAULTS } from "./pl24.constants";
import type {
PL24VehicleResponse,
PL24CategoryResponse,
PL24PartResponse,
PL24SchemaPicResponse,
ParsedVehicle,
ParsedCategory,
} from "./pl24.types";
@Injectable()
export class PL24Service {
private readonly logger = new Logger(PL24Service.name);
private readonly apiUrl: string;
constructor(
private configService: ConfigService,
private authService: PL24AuthService,
private redis: RedisService,
private storage: StorageService,
) {
this.apiUrl = this.configService.get<string>("pl24.apiUrl") || "";
}
async decodeVin(vin: string, brandName: string): Promise<ParsedVehicle | null> {
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}vehicle:${vin}`;
const cached = await this.redis.getJson<ParsedVehicle>(cacheKey);
if (cached) return cached;
if (!this.apiUrl) {
this.logger.warn("PL24 API URL not configured");
return null;
}
try {
const token = await this.authService.getToken();
const response = await this.makeRequest<PL24VehicleResponse>(`/vehicles/decode/${vin}`, token);
if (!response) return null;
const parser = createParser(brandName);
const parsed = parser.parseVehicle(response as unknown as Record<string, unknown>);
await this.redis.setJson(cacheKey, parsed, 86400); // 24h cache
return parsed;
} catch (error) {
this.logger.error(`PL24 decode failed for ${vin}`, error);
return null;
}
}
async getCategories(vehicleId: string, brandName: string): Promise<ParsedCategory[]> {
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}categories:${vehicleId}`;
const cached = await this.redis.getJson<ParsedCategory[]>(cacheKey);
if (cached) return cached;
if (!this.apiUrl) return [];
try {
const token = await this.authService.getToken();
const response = await this.makeRequest<PL24CategoryResponse[]>(
`/vehicles/${vehicleId}/groups`,
token,
);
if (!response) return [];
const parser = createParser(brandName);
const parsed = parser.parseCategories(response as unknown as unknown[]);
await this.redis.setJson(cacheKey, parsed, 3600); // 1h cache
return parsed;
} catch (error) {
this.logger.error(`PL24 get categories failed for ${vehicleId}`, error);
return [];
}
}
async getParts(vehicleId: string, groupId: string, brandName: string): Promise<PL24PartResponse[]> {
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}parts:${vehicleId}:${groupId}`;
const cached = await this.redis.getJson<PL24PartResponse[]>(cacheKey);
if (cached) return cached;
if (!this.apiUrl) return [];
try {
const token = await this.authService.getToken();
const response = await this.makeRequest<PL24PartResponse[]>(
`/vehicles/${vehicleId}/groups/${groupId}/parts`,
token,
);
if (!response) return [];
const parser = createParser(brandName);
const parsed = parser.parseParts(response as unknown as unknown[]);
await this.redis.setJson(cacheKey, parsed, 3600);
return parsed;
} catch (error) {
this.logger.error(`PL24 get parts failed`, error);
return [];
}
}
async getSchemaImage(vehicleId: string, groupId: string): Promise<PL24SchemaPicResponse | null> {
// Check if already uploaded to MinIO
const minioKey = `schemas/${vehicleId}/${groupId}.png`;
const existingUrl = this.storage.getPublicUrl(minioKey);
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}schema:${vehicleId}:${groupId}`;
const cached = await this.redis.getJson<PL24SchemaPicResponse>(cacheKey);
if (cached) return cached;
if (!this.apiUrl) return null;
try {
const token = await this.authService.getToken();
const response = await this.makeRequest<PL24SchemaPicResponse>(
`/vehicles/${vehicleId}/groups/${groupId}/schema`,
token,
);
if (!response) return null;
// Download image and upload to MinIO
if (response.imageUrl) {
try {
const imageResponse = await fetch(response.imageUrl, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(PL24_DEFAULTS.REQUEST_TIMEOUT),
});
if (imageResponse.ok) {
const buffer = Buffer.from(await imageResponse.arrayBuffer());
const uploadedUrl = await this.storage.upload(minioKey, buffer, "image/png");
response.imageUrl = uploadedUrl;
}
} catch (imgError) {
this.logger.warn(`Failed to upload schema image to MinIO`, imgError);
}
}
await this.redis.setJson(cacheKey, response, 86400);
return response;
} catch (error) {
this.logger.error(`PL24 get schema failed`, error);
return null;
}
}
private async makeRequest<T>(path: string, token: string): Promise<T | null> {
try {
const response = await fetch(`${this.apiUrl}${path}`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
signal: AbortSignal.timeout(PL24_DEFAULTS.REQUEST_TIMEOUT),
});
if (response.status === 401) {
await this.authService.invalidateToken();
const newToken = await this.authService.getToken();
const retry = await fetch(`${this.apiUrl}${path}`, {
headers: {
Authorization: `Bearer ${newToken}`,
"Content-Type": "application/json",
},
signal: AbortSignal.timeout(PL24_DEFAULTS.REQUEST_TIMEOUT),
});
if (!retry.ok) return null;
return (await retry.json()) as T;
}
if (!response.ok) return null;
return (await response.json()) as T;
} catch (error) {
this.logger.error(`PL24 request failed: ${path}`, error);
return null;
}
}
}

View File

@@ -0,0 +1,74 @@
export interface PL24AuthResponse {
token: string;
expiresIn: number;
}
export interface PL24VehicleResponse {
vehicleId: string;
catalogId: string;
name: string;
modelCode: string;
engine: string;
transmission: string;
bodyType: string;
market: string;
yearFrom: number;
yearTo: number;
raw: Record<string, unknown>;
}
export interface PL24CategoryResponse {
groupId: string;
name: string;
parentGroupId: string | null;
sortOrder: number;
hasSchemaPic: boolean;
}
export interface PL24PartResponse {
partId: string;
name: string;
description: string;
quantity: number;
position: string;
hotspotIndex: number | null;
oemCodes: string[];
}
export interface PL24SchemaPicResponse {
imageUrl: string;
hotspots: PL24Hotspot[];
width: number;
height: number;
}
export interface PL24Hotspot {
index: number;
x: number;
y: number;
width: number;
height: number;
shape: "rect" | "circle" | "polygon";
points?: { x: number; y: number }[];
}
export interface ParsedVehicle {
vehicleId: string;
catalogId: string;
name: string;
modelCode: string;
engine: string;
transmission: string;
bodyType: string;
market: string;
yearFrom: number;
yearTo: number;
}
export interface ParsedCategory {
groupId: string;
name: string;
parentGroupId: string | null;
sortOrder: number;
hasSchemaPic: boolean;
}

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { VinApiService } from "./vin-api.service";
@Module({
providers: [VinApiService],
exports: [VinApiService],
})
export class VinApiModule {}

View File

@@ -0,0 +1,44 @@
import { Injectable, Logger } from "@nestjs/common";
interface NHTSAResult {
make: string;
model: string;
modelYear: string;
bodyClass: string;
engineModel: string;
transmissionStyle: string;
plantCountry: string;
}
@Injectable()
export class VinApiService {
private readonly logger = new Logger(VinApiService.name);
async decodeVin(vin: string): Promise<NHTSAResult | null> {
try {
const response = await fetch(
`https://vpic.nhtsa.dot.gov/api/vehicles/decodevinvalues/${vin}?format=json`,
{ signal: AbortSignal.timeout(10000) },
);
if (!response.ok) return null;
const data = (await response.json()) as { Results?: Record<string, string>[] };
const results = data.Results?.[0];
if (!results) return null;
return {
make: results.Make || "",
model: results.Model || "",
modelYear: results.ModelYear || "",
bodyClass: results.BodyClass || "",
engineModel: results.EngineModel || "",
transmissionStyle: results.TransmissionStyle || "",
plantCountry: results.PlantCountry || "",
};
} catch (error) {
this.logger.warn(`NHTSA decode failed for ${vin}`, error);
return null;
}
}
}

View File

@@ -0,0 +1,15 @@
import { ConnectionOptions } from "bullmq";
export function getBullConnection(): ConnectionOptions {
return {
host: process.env.REDIS_HOST || "localhost",
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
};
}
export const QUEUE_NAMES = {
EMEX_SCRAPE: "emex-scrape",
SUBSCRIPTION_EXPIRY: "subscription-expiry",
QUERY_CLEANUP: "query-cleanup",
} as const;

View File

@@ -0,0 +1,64 @@
import { Module, OnModuleInit, Inject, OnModuleDestroy } from "@nestjs/common";
import { Queue } from "bullmq";
import { EmexScrapeQueueProvider, EMEX_SCRAPE_QUEUE } from "./queues/emex-scrape.queue";
import {
SubscriptionExpiryQueueProvider,
SUBSCRIPTION_EXPIRY_QUEUE,
} from "./queues/subscription-expiry.queue";
import { QueryCleanupQueueProvider, QUERY_CLEANUP_QUEUE } from "./queues/query-cleanup.queue";
@Module({
providers: [
EmexScrapeQueueProvider,
SubscriptionExpiryQueueProvider,
QueryCleanupQueueProvider,
],
exports: [EMEX_SCRAPE_QUEUE, SUBSCRIPTION_EXPIRY_QUEUE, QUERY_CLEANUP_QUEUE],
})
export class JobsModule implements OnModuleInit, OnModuleDestroy {
constructor(
@Inject(SUBSCRIPTION_EXPIRY_QUEUE) private subscriptionExpiryQueue: Queue,
@Inject(QUERY_CLEANUP_QUEUE) private queryCleanupQueue: Queue,
) {}
async onModuleInit() {
// Register repeatable cron jobs
// Subscription expiry check: every day at 3:00 AM
await this.subscriptionExpiryQueue.upsertJobScheduler(
"subscription-expiry-daily",
{ pattern: "0 3 * * *" },
{
name: "subscription-expiry-check",
data: {},
opts: {
removeOnComplete: { count: 30 },
removeOnFail: { count: 100 },
},
},
);
console.log("[jobs] Registered subscription-expiry cron: 0 3 * * *");
// Query cleanup: every Sunday at 4:00 AM
await this.queryCleanupQueue.upsertJobScheduler(
"query-cleanup-weekly",
{ pattern: "0 4 * * 0" },
{
name: "query-cleanup-run",
data: {},
opts: {
removeOnComplete: { count: 10 },
removeOnFail: { count: 50 },
},
},
);
console.log("[jobs] Registered query-cleanup cron: 0 4 * * 0");
}
async onModuleDestroy() {
await Promise.all([
this.subscriptionExpiryQueue.close(),
this.queryCleanupQueue.close(),
]);
}
}

View File

@@ -0,0 +1,132 @@
import { Job } from "bullmq";
import { eq } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import {
emexCatalogs,
emexVehicles,
emexVehicleVins,
emexPartGroups,
emexParts,
emexPartNumbers,
emexScrapeSessions,
} from "../../database/schema/emex";
import type { EmexScrapeJobData } from "../../integrations/emex/emex.types";
type Database = PostgresJsDatabase<Record<string, unknown>>;
export async function processEmexScrape(
job: Job<EmexScrapeJobData>,
db: Database,
): Promise<{ success: boolean; vehicleId?: string; categoriesCount: number; partsCount: number }> {
const { vin, userId } = job.data;
console.log(`[emex-scrape] Processing job ${job.id} for VIN: ${vin}, user: ${userId}`);
// Update scrape session to active
const [session] = await db
.select()
.from(emexScrapeSessions)
.where(eq(emexScrapeSessions.jobId, job.id!))
.limit(1);
if (session) {
await db
.update(emexScrapeSessions)
.set({ status: "active", startedAt: new Date() })
.where(eq(emexScrapeSessions.id, session.id));
}
await job.updateProgress(0);
try {
// ── Step 1: Resolve vehicle from VIN ──────────────────
let emexVehicleRecord = await db
.select({ id: emexVehicles.id, vehicleId: emexVehicles.vehicleId })
.from(emexVehicles)
.innerJoin(emexVehicleVins, eq(emexVehicleVins.emexVehicleId, emexVehicles.id))
.where(eq(emexVehicleVins.vin, vin))
.limit(1)
.then((rows) => rows[0] ?? null);
if (!emexVehicleRecord) {
// Vehicle not yet in EMEX tables — placeholder for scraper integration
// In production, this would call EmexScraperService.scrapeVehicle(vin)
console.log(`[emex-scrape] No cached vehicle for VIN ${vin}, scraper integration pending`);
if (session) {
await db
.update(emexScrapeSessions)
.set({
status: "completed",
completedAt: new Date(),
result: { vehicle: null, categories: [], parts: [] },
})
.where(eq(emexScrapeSessions.id, session.id));
}
await job.updateProgress(100);
return { success: true, categoriesCount: 0, partsCount: 0 };
}
await job.updateProgress(25);
console.log(`[emex-scrape] Vehicle resolved: ${emexVehicleRecord.vehicleId}`);
// ── Step 2: Fetch categories (part groups) ────────────
const categoriesResult = await db
.select()
.from(emexPartGroups)
.where(eq(emexPartGroups.emexVehicleId, emexVehicleRecord.id));
await job.updateProgress(50);
console.log(`[emex-scrape] Found ${categoriesResult.length} categories`);
// ── Step 3: Fetch parts ───────────────────────────────
const partsResult = await db
.select()
.from(emexParts)
.where(eq(emexParts.emexVehicleId, emexVehicleRecord.id));
await job.updateProgress(100);
console.log(`[emex-scrape] Found ${partsResult.length} parts`);
// Update scrape session as completed
if (session) {
await db
.update(emexScrapeSessions)
.set({
status: "completed",
completedAt: new Date(),
result: {
vehicleId: emexVehicleRecord.vehicleId,
categoriesCount: categoriesResult.length,
partsCount: partsResult.length,
},
})
.where(eq(emexScrapeSessions.id, session.id));
}
return {
success: true,
vehicleId: emexVehicleRecord.vehicleId,
categoriesCount: categoriesResult.length,
partsCount: partsResult.length,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`[emex-scrape] Job ${job.id} failed: ${errorMessage}`);
// Update scrape session as failed
if (session) {
await db
.update(emexScrapeSessions)
.set({
status: "failed",
completedAt: new Date(),
errorMessage,
})
.where(eq(emexScrapeSessions.id, session.id));
}
throw error;
}
}

View File

@@ -0,0 +1,31 @@
import { Job } from "bullmq";
import { lt } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { queryLogs } from "../../database/schema/core";
type Database = PostgresJsDatabase<Record<string, unknown>>;
const RETENTION_DAYS = 90;
export async function processQueryCleanup(
job: Job,
db: Database,
): Promise<{ deletedCount: number }> {
console.log(`[query-cleanup] Processing job ${job.id}`);
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - RETENTION_DAYS);
const deleted = await db
.delete(queryLogs)
.where(lt(queryLogs.createdAt, cutoffDate))
.returning({ id: queryLogs.id });
const deletedCount = deleted.length;
console.log(
`[query-cleanup] Deleted ${deletedCount} query log(s) older than ${RETENTION_DAYS} days (before ${cutoffDate.toISOString()})`,
);
return { deletedCount };
}

View File

@@ -0,0 +1,61 @@
import { Job } from "bullmq";
import { and, eq, lt } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { userSubscriptions, userBrands } from "../../database/schema/core";
type Database = PostgresJsDatabase<Record<string, unknown>>;
export async function processSubscriptionExpiry(
job: Job,
db: Database,
): Promise<{ expiredCount: number; brandsRemovedCount: number }> {
console.log(`[subscription-expiry] Processing job ${job.id}`);
const now = new Date();
// Find active subscriptions where endDate has passed
const expiredSubs = await db
.select({ id: userSubscriptions.id, userId: userSubscriptions.userId })
.from(userSubscriptions)
.where(
and(
eq(userSubscriptions.status, "active"),
lt(userSubscriptions.endDate, now),
),
);
if (expiredSubs.length === 0) {
console.log("[subscription-expiry] No expired subscriptions found");
return { expiredCount: 0, brandsRemovedCount: 0 };
}
console.log(`[subscription-expiry] Found ${expiredSubs.length} expired subscription(s)`);
let brandsRemovedCount = 0;
for (const sub of expiredSubs) {
// Update subscription status to expired
await db
.update(userSubscriptions)
.set({ status: "expired", updatedAt: now })
.where(eq(userSubscriptions.id, sub.id));
// Remove associated userBrands entries
const removedBrands = await db
.delete(userBrands)
.where(eq(userBrands.subscriptionId, sub.id))
.returning({ id: userBrands.id });
brandsRemovedCount += removedBrands.length;
console.log(
`[subscription-expiry] Expired subscription ${sub.id} for user ${sub.userId}, removed ${removedBrands.length} brand(s)`,
);
}
console.log(
`[subscription-expiry] Completed: ${expiredSubs.length} subscription(s) expired, ${brandsRemovedCount} brand(s) removed`,
);
return { expiredCount: expiredSubs.length, brandsRemovedCount };
}

View File

@@ -0,0 +1,23 @@
import { Provider } from "@nestjs/common";
import { Queue } from "bullmq";
import { getBullConnection, QUEUE_NAMES } from "../bull.config";
export const EMEX_SCRAPE_QUEUE = "EMEX_SCRAPE_QUEUE";
export const EmexScrapeQueueProvider: Provider = {
provide: EMEX_SCRAPE_QUEUE,
useFactory: () => {
return new Queue(QUEUE_NAMES.EMEX_SCRAPE, {
connection: getBullConnection(),
defaultJobOptions: {
attempts: 3,
backoff: {
type: "exponential",
delay: 5000,
},
removeOnComplete: { count: 1000 },
removeOnFail: { count: 5000 },
},
});
},
};

View File

@@ -0,0 +1,23 @@
import { Provider } from "@nestjs/common";
import { Queue } from "bullmq";
import { getBullConnection, QUEUE_NAMES } from "../bull.config";
export const QUERY_CLEANUP_QUEUE = "QUERY_CLEANUP_QUEUE";
export const QueryCleanupQueueProvider: Provider = {
provide: QUERY_CLEANUP_QUEUE,
useFactory: () => {
return new Queue(QUEUE_NAMES.QUERY_CLEANUP, {
connection: getBullConnection(),
defaultJobOptions: {
attempts: 2,
backoff: {
type: "fixed",
delay: 30000,
},
removeOnComplete: { count: 100 },
removeOnFail: { count: 500 },
},
});
},
};

View File

@@ -0,0 +1,23 @@
import { Provider } from "@nestjs/common";
import { Queue } from "bullmq";
import { getBullConnection, QUEUE_NAMES } from "../bull.config";
export const SUBSCRIPTION_EXPIRY_QUEUE = "SUBSCRIPTION_EXPIRY_QUEUE";
export const SubscriptionExpiryQueueProvider: Provider = {
provide: SUBSCRIPTION_EXPIRY_QUEUE,
useFactory: () => {
return new Queue(QUEUE_NAMES.SUBSCRIPTION_EXPIRY, {
connection: getBullConnection(),
defaultJobOptions: {
attempts: 3,
backoff: {
type: "exponential",
delay: 10000,
},
removeOnComplete: { count: 500 },
removeOnFail: { count: 1000 },
},
});
},
};

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

@@ -0,0 +1,56 @@
import { NestFactory } from "@nestjs/core";
import { ConfigService } from "@nestjs/config";
import helmet from "helmet";
import type { Request, Response, NextFunction } from "express";
import { AppModule } from "./app.module";
import { fileUploadValidation } from "./common/middleware/file-upload-validation.middleware";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
const port = configService.get<number>("port", 4000);
const corsOrigins = configService.get<string[]>("cors.origin", ["http://localhost:3000"]);
app.setGlobalPrefix("api");
// Security headers
app.use(helmet());
app.enableCors({
origin: corsOrigins,
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "Cookie"],
exposedHeaders: ["set-cookie"],
maxAge: 86400,
});
// File upload validation middleware (PNG/JPG/PDF only, max 5MB)
app.use(fileUploadValidation);
// Cache-Control headers middleware
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.method !== "GET") {
return next();
}
const path = req.originalUrl;
if (path.startsWith("/api/brands")) {
res.setHeader("Cache-Control", "public, max-age=1800");
} else if (path.startsWith("/api/plans")) {
res.setHeader("Cache-Control", "public, max-age=1800");
} else if (path.startsWith("/api/health")) {
res.setHeader("Cache-Control", "no-cache");
} else {
res.setHeader("Cache-Control", "no-store");
}
next();
});
await app.listen(port);
console.log(`API running on http://localhost:${port}`);
}
bootstrap();

View File

@@ -0,0 +1,22 @@
import { Controller, Get, Param, Query } from "@nestjs/common";
import { PartsService } from "./parts.service";
@Controller("parts")
export class PartsController {
constructor(private partsService: PartsService) {}
@Get("category/:categoryId")
async getByCategory(@Param("categoryId") categoryId: string) {
return this.partsService.getByCategory(categoryId);
}
@Get("search")
async searchByOem(@Query("oem") oem: string) {
return this.partsService.searchByOem(oem);
}
@Get(":id")
async getById(@Param("id") id: string) {
return this.partsService.getById(id);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { PartsController } from "./parts.controller";
import { PartsService } from "./parts.service";
import { PL24Module } from "../integrations/pl24/pl24.module";
@Module({
imports: [PL24Module],
controllers: [PartsController],
providers: [PartsService],
exports: [PartsService],
})
export class PartsModule {}

View File

@@ -0,0 +1,107 @@
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
import { eq, like } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider";
import { parts, categories, vehicles } from "../database/schema/core";
import { PL24Service } from "../integrations/pl24/pl24.service";
@Injectable()
export class PartsService {
constructor(
@Inject(DATABASE) private db: Database,
private pl24Service: PL24Service,
) {}
async getByCategory(categoryId: string) {
// Check DB first
let dbParts = await this.db
.select()
.from(parts)
.where(eq(parts.categoryId, categoryId));
if (dbParts.length > 0) return dbParts;
// Fetch from PL24 on-demand
const [category] = await this.db
.select()
.from(categories)
.where(eq(categories.id, categoryId))
.limit(1);
if (!category) throw new NotFoundException("Category not found");
const [vehicle] = await this.db
.select()
.from(vehicles)
.where(eq(vehicles.id, category.vehicleId))
.limit(1);
if (!vehicle) throw new NotFoundException("Vehicle not found");
const rawData = vehicle.rawData as any;
const pl24VehicleId = rawData?.vehicleId;
const groupId = category.externalId;
if (pl24VehicleId && groupId && vehicle.brandName) {
const pl24Parts = await this.pl24Service.getParts(pl24VehicleId, groupId, vehicle.brandName);
if (pl24Parts.length > 0) {
const insertData = pl24Parts.flatMap((p) =>
p.oemCodes.length > 0
? p.oemCodes.map((oem) => ({
vehicleId: vehicle.id,
categoryId,
oemCode: oem,
name: p.name,
nameOriginal: p.name,
description: p.description || null,
quantity: p.quantity || null,
position: p.position || null,
hotspotIndex: p.hotspotIndex ?? null,
source: "pl24" as const,
}))
: [
{
vehicleId: vehicle.id,
categoryId,
oemCode: "N/A",
name: p.name,
nameOriginal: p.name,
description: p.description || null,
quantity: p.quantity || null,
position: p.position || null,
hotspotIndex: p.hotspotIndex ?? null,
source: "pl24" as const,
},
],
);
dbParts = await this.db.insert(parts).values(insertData).returning();
}
}
return dbParts;
}
async searchByOem(oemCode: string) {
return this.db
.select({
part: parts,
vehicle: {
vin: vehicles.vin,
brandName: vehicles.brandName,
model: vehicles.model,
year: vehicles.year,
},
})
.from(parts)
.innerJoin(vehicles, eq(parts.vehicleId, vehicles.id))
.where(like(parts.oemCode, `%${oemCode}%`))
.limit(50);
}
async getById(id: string) {
const [part] = await this.db.select().from(parts).where(eq(parts.id, id)).limit(1);
if (!part) throw new NotFoundException("Part not found");
return part;
}
}

View File

@@ -0,0 +1,95 @@
import {
Controller,
Get,
Post,
Patch,
Param,
Body,
UseGuards,
UseInterceptors,
UploadedFile,
BadRequestException,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { PaymentsService } from "./payments.service";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { Roles } from "../common/decorators/roles.decorator";
import { RolesGuard } from "../common/guards/roles.guard";
@Controller("payments")
export class PaymentsController {
constructor(private paymentsService: PaymentsService) {}
@Post("iyzico/initialize")
async initializeIyzico(
@CurrentUser("id") userId: string,
@Body() body: { subscriptionId: string },
) {
return this.paymentsService.initializeIyzico(userId, body.subscriptionId);
}
@Post("iyzico/callback")
async iyzicoCallback(
@Body() body: { paymentId: string; iyzicoPaymentId: string; status: string },
) {
return this.paymentsService.handleIyzicoCallback(
body.paymentId,
body.iyzicoPaymentId,
body.status,
);
}
@Post("eft")
async createEft(
@CurrentUser("id") userId: string,
@Body() body: { subscriptionId: string },
) {
return this.paymentsService.createEftPayment(userId, body.subscriptionId);
}
@Post("eft/:id/receipt")
@UseInterceptors(FileInterceptor("file"))
async uploadReceipt(
@Param("id") paymentId: string,
@CurrentUser("id") userId: string,
@UploadedFile() file: Express.Multer.File,
) {
if (!file) throw new BadRequestException("File is required");
const allowedTypes = ["image/png", "image/jpeg", "application/pdf"];
if (!allowedTypes.includes(file.mimetype)) {
throw new BadRequestException("Only PNG, JPG, and PDF files are allowed");
}
if (file.size > 5 * 1024 * 1024) {
throw new BadRequestException("File size must be less than 5MB");
}
return this.paymentsService.uploadEftReceipt(paymentId, userId, file.buffer, file.originalname);
}
@Patch("eft/:id/approve")
@UseGuards(RolesGuard)
@Roles("admin")
async approveEft(@Param("id") id: string, @Body() body: { adminNote?: string }) {
return this.paymentsService.approveEft(id, body.adminNote);
}
@Patch("eft/:id/reject")
@UseGuards(RolesGuard)
@Roles("admin")
async rejectEft(@Param("id") id: string, @Body() body: { adminNote?: string }) {
return this.paymentsService.rejectEft(id, body.adminNote);
}
@Get("me")
async getMyPayments(@CurrentUser("id") userId: string) {
return this.paymentsService.getMyPayments(userId);
}
@Get("pending")
@UseGuards(RolesGuard)
@Roles("admin")
async getPendingEft() {
return this.paymentsService.getPendingEftPayments();
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { PaymentsController } from "./payments.controller";
import { PaymentsService } from "./payments.service";
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
@Module({
imports: [SubscriptionsModule],
controllers: [PaymentsController],
providers: [PaymentsService],
exports: [PaymentsService],
})
export class PaymentsModule {}

View File

@@ -0,0 +1,186 @@
import {
BadRequestException,
Inject,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { eq, and, desc } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider";
import { payments, userSubscriptions } from "../database/schema/core";
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
import { StorageService } from "../storage/storage.service";
@Injectable()
export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name);
constructor(
@Inject(DATABASE) private db: Database,
private configService: ConfigService,
private subscriptionsService: SubscriptionsService,
private storageService: StorageService,
) {}
async initializeIyzico(userId: string, subscriptionId: string) {
// Validate subscription belongs to user
const [sub] = await this.db
.select()
.from(userSubscriptions)
.where(and(eq(userSubscriptions.id, subscriptionId), eq(userSubscriptions.userId, userId)))
.limit(1);
if (!sub) throw new NotFoundException("Subscription not found");
if (sub.status === "active") throw new BadRequestException("Subscription already active");
// Create payment record
const amount = sub.billingPeriod === "yearly" ? 0 : 0; // Will be calculated from plan
const [payment] = await this.db
.insert(payments)
.values({
userId,
subscriptionId,
amount,
currency: "TRY",
method: "iyzico",
status: "pending",
})
.returning();
// TODO: Integrate with actual iyzico API
// For now, return a mock payment initialization
this.logger.log(`iyzico payment initialized for subscription ${subscriptionId}`);
return {
paymentId: payment.id,
status: "pending",
// In production: checkoutFormContent, paymentPageUrl, etc.
};
}
async handleIyzicoCallback(paymentId: string, iyzicoPaymentId: string, status: string) {
const [payment] = await this.db
.select()
.from(payments)
.where(eq(payments.id, paymentId))
.limit(1);
if (!payment) throw new NotFoundException("Payment not found");
const newStatus = status === "success" ? "completed" : "failed";
await this.db
.update(payments)
.set({
status: newStatus,
iyzicoPaymentId,
updatedAt: new Date(),
})
.where(eq(payments.id, paymentId));
if (newStatus === "completed") {
await this.subscriptionsService.activateSubscription(payment.subscriptionId);
}
return { status: newStatus };
}
async createEftPayment(userId: string, subscriptionId: string) {
const [sub] = await this.db
.select()
.from(userSubscriptions)
.where(and(eq(userSubscriptions.id, subscriptionId), eq(userSubscriptions.userId, userId)))
.limit(1);
if (!sub) throw new NotFoundException("Subscription not found");
const [payment] = await this.db
.insert(payments)
.values({
userId,
subscriptionId,
amount: 0, // Will be set from plan pricing
currency: "TRY",
method: "eft",
status: "pending",
})
.returning();
return {
paymentId: payment.id,
bankInfo: {
bankName: "İş Bankası",
iban: "TR00 0000 0000 0000 0000 0000 00",
accountHolder: "Sase Teknoloji Ltd.",
description: `SASE-${payment.id.substring(0, 8).toUpperCase()}`,
},
};
}
async uploadEftReceipt(paymentId: string, userId: string, file: Buffer, filename: string) {
const [payment] = await this.db
.select()
.from(payments)
.where(and(eq(payments.id, paymentId), eq(payments.userId, userId)))
.limit(1);
if (!payment) throw new NotFoundException("Payment not found");
if (payment.method !== "eft") throw new BadRequestException("Not an EFT payment");
const key = `receipts/${paymentId}/${filename}`;
const url = await this.storageService.upload(key, file, "application/pdf");
await this.db
.update(payments)
.set({ eftReceiptUrl: url, updatedAt: new Date() })
.where(eq(payments.id, paymentId));
return { receiptUrl: url };
}
async approveEft(paymentId: string, adminNote?: string) {
const [payment] = await this.db
.select()
.from(payments)
.where(eq(payments.id, paymentId))
.limit(1);
if (!payment) throw new NotFoundException("Payment not found");
if (payment.method !== "eft") throw new BadRequestException("Not an EFT payment");
await this.db
.update(payments)
.set({ status: "completed", adminNote, updatedAt: new Date() })
.where(eq(payments.id, paymentId));
await this.subscriptionsService.activateSubscription(payment.subscriptionId);
return { status: "completed" };
}
async rejectEft(paymentId: string, adminNote?: string) {
await this.db
.update(payments)
.set({ status: "failed", adminNote, updatedAt: new Date() })
.where(eq(payments.id, paymentId));
return { status: "failed" };
}
async getMyPayments(userId: string) {
return this.db
.select()
.from(payments)
.where(eq(payments.userId, userId))
.orderBy(desc(payments.createdAt));
}
async getPendingEftPayments() {
return this.db
.select()
.from(payments)
.where(and(eq(payments.method, "eft"), eq(payments.status, "pending")))
.orderBy(payments.createdAt);
}
}

View File

@@ -0,0 +1,35 @@
import { Controller, Get, Post, Patch, Param, Body, UseGuards } from "@nestjs/common";
import { PlansService } from "./plans.service";
import { Public } from "../common/decorators/public.decorator";
import { Roles } from "../common/decorators/roles.decorator";
import { RolesGuard } from "../common/guards/roles.guard";
@Controller("plans")
export class PlansController {
constructor(private plansService: PlansService) {}
@Get()
@Public()
async findAll() {
return this.plansService.findAll();
}
@Post()
@UseGuards(RolesGuard)
@Roles("admin")
async create(
@Body() body: { name: string; brandCount: number; priceMonthly: number; priceYearly: number },
) {
return this.plansService.create(body);
}
@Patch(":id")
@UseGuards(RolesGuard)
@Roles("admin")
async update(
@Param("id") id: string,
@Body() body: { name?: string; brandCount?: number; priceMonthly?: number; priceYearly?: number; isActive?: boolean },
) {
return this.plansService.update(id, body);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { PlansController } from "./plans.controller";
import { PlansService } from "./plans.service";
@Module({
controllers: [PlansController],
providers: [PlansService],
exports: [PlansService],
})
export class PlansModule {}

View File

@@ -0,0 +1,40 @@
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
import { eq } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider";
import { plans } from "../database/schema/core";
@Injectable()
export class PlansService {
constructor(@Inject(DATABASE) private db: Database) {}
async findAll(activeOnly = true) {
if (activeOnly) {
return this.db.select().from(plans).where(eq(plans.isActive, true)).orderBy(plans.priceMonthly);
}
return this.db.select().from(plans).orderBy(plans.priceMonthly);
}
async findById(id: string) {
const result = await this.db.select().from(plans).where(eq(plans.id, id)).limit(1);
if (result.length === 0) throw new NotFoundException("Plan not found");
return result[0];
}
async create(data: { name: string; brandCount: number; priceMonthly: number; priceYearly: number }) {
const [plan] = await this.db.insert(plans).values(data).returning();
return plan;
}
async update(
id: string,
data: { name?: string; brandCount?: number; priceMonthly?: number; priceYearly?: number; isActive?: boolean },
) {
const [plan] = await this.db
.update(plans)
.set({ ...data, updatedAt: new Date() })
.where(eq(plans.id, id))
.returning();
if (!plan) throw new NotFoundException("Plan not found");
return plan;
}
}

View File

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

View File

@@ -0,0 +1,32 @@
import { Provider } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import Redis from "ioredis";
export const REDIS_CLIENT = "REDIS_CLIENT";
export const RedisProvider: Provider = {
provide: REDIS_CLIENT,
useFactory: (configService: ConfigService) => {
const client = new Redis({
host: configService.get<string>("redis.host", "127.0.0.1"),
port: configService.get<number>("redis.port", 6379),
password: configService.get<string>("redis.password"),
maxRetriesPerRequest: 3,
retryStrategy(times) {
const delay = Math.min(times * 200, 5000);
return delay;
},
});
client.on("connect", () => {
console.log("Redis connected");
});
client.on("error", (err) => {
console.error("Redis error:", err.message);
});
return client;
},
inject: [ConfigService],
};

View File

@@ -0,0 +1,63 @@
import { Inject, Injectable, OnModuleDestroy } from "@nestjs/common";
import Redis from "ioredis";
import { REDIS_CLIENT } from "./redis.provider";
@Injectable()
export class RedisService implements OnModuleDestroy {
constructor(@Inject(REDIS_CLIENT) private readonly client: Redis) {}
async onModuleDestroy() {
await this.client.quit();
}
async get(key: string): Promise<string | null> {
return this.client.get(key);
}
async getJson<T>(key: string): Promise<T | null> {
const value = await this.client.get(key);
if (!value) return null;
return JSON.parse(value) as T;
}
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 setJson(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
await this.set(key, JSON.stringify(value), ttlSeconds);
}
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 ttl(key: string): Promise<number> {
return this.client.ttl(key);
}
async incr(key: string): Promise<number> {
return this.client.incr(key);
}
async expire(key: string, ttlSeconds: number): Promise<void> {
await this.client.expire(key, ttlSeconds);
}
async keys(pattern: string): Promise<string[]> {
return this.client.keys(pattern);
}
getClient(): Redis {
return this.client;
}
}

View File

@@ -0,0 +1,21 @@
import { Controller, Get, Post, Body } from "@nestjs/common";
import { ReferralsService } from "./referrals.service";
import { CurrentUser } from "../common/decorators/current-user.decorator";
@Controller("referrals")
export class ReferralsController {
constructor(private referralsService: ReferralsService) {}
@Get("me")
async getMyReferrals(@CurrentUser("id") userId: string) {
return this.referralsService.getMyReferrals(userId);
}
@Post("apply")
async applyCode(
@CurrentUser("id") userId: string,
@Body() body: { code: string },
) {
return this.referralsService.applyReferralCode(userId, body.code);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { ReferralsController } from "./referrals.controller";
import { ReferralsService } from "./referrals.service";
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
@Module({
imports: [SubscriptionsModule],
controllers: [ReferralsController],
providers: [ReferralsService],
exports: [ReferralsService],
})
export class ReferralsModule {}

View File

@@ -0,0 +1,102 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from "@nestjs/common";
import { eq, and, sql } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider";
import { users, referrals } from "../database/schema/core";
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
import { REFERRAL_REWARDS } from "@sase/shared";
import { generateReferralCode } from "@sase/shared";
@Injectable()
export class ReferralsService {
constructor(
@Inject(DATABASE) private db: Database,
private subscriptionsService: SubscriptionsService,
) {}
async getMyReferrals(userId: string) {
const user = await this.db.select().from(users).where(eq(users.id, userId)).limit(1);
if (user.length === 0) throw new NotFoundException("User not found");
const myReferrals = await this.db
.select()
.from(referrals)
.where(eq(referrals.referrerId, userId));
return {
referralCode: user[0].referralCode,
totalReferrals: myReferrals.length,
referrals: myReferrals,
};
}
async applyReferralCode(userId: string, code: string) {
// Find referrer by code
const [referrer] = await this.db
.select()
.from(users)
.where(eq(users.referralCode, code))
.limit(1);
if (!referrer) throw new NotFoundException("Invalid referral code");
if (referrer.id === userId) throw new BadRequestException("Cannot use own referral code");
// Check if already referred
const existing = await this.db
.select()
.from(referrals)
.where(eq(referrals.referredId, userId))
.limit(1);
if (existing.length > 0) {
throw new BadRequestException("Already used a referral code");
}
// Create referral
await this.db.insert(referrals).values({
referrerId: referrer.id,
referredId: userId,
});
// Update referred user
await this.db
.update(users)
.set({ referredBy: referrer.id, updatedAt: new Date() })
.where(eq(users.id, userId));
// Count total referrals for reward check
const totalReferrals = await this.db
.select({ count: sql<number>`count(*)` })
.from(referrals)
.where(eq(referrals.referrerId, referrer.id));
const count = Number(totalReferrals[0]?.count || 0);
// Apply rewards
if (count === REFERRAL_REWARDS.TIER_2.count) {
await this.subscriptionsService.extendSubscription(
referrer.id,
REFERRAL_REWARDS.TIER_2.extensionDays,
);
} else if (count === REFERRAL_REWARDS.TIER_1.count) {
await this.subscriptionsService.extendSubscription(
referrer.id,
REFERRAL_REWARDS.TIER_1.extensionDays,
);
}
return { success: true };
}
async ensureReferralCode(userId: string): Promise<string> {
const [user] = await this.db.select().from(users).where(eq(users.id, userId)).limit(1);
if (user?.referralCode) return user.referralCode;
const code = generateReferralCode();
await this.db
.update(users)
.set({ referralCode: code, updatedAt: new Date() })
.where(eq(users.id, userId));
return code;
}
}

View File

@@ -0,0 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { StorageService } from "./storage.service";
@Global()
@Module({
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View File

@@ -0,0 +1,81 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
} from "@aws-sdk/client-s3";
@Injectable()
export class StorageService {
private readonly logger = new Logger(StorageService.name);
private readonly s3: S3Client;
private readonly bucketName: string;
private readonly publicUrl: string;
constructor(private configService: ConfigService) {
const endpoint = configService.get<string>("minio.endpoint")!;
const useSSL = configService.get<boolean>("minio.useSSL", false);
this.s3 = new S3Client({
endpoint,
region: "us-east-1",
credentials: {
accessKeyId: configService.get<string>("minio.accessKey")!,
secretAccessKey: configService.get<string>("minio.secretKey")!,
},
forcePathStyle: true,
...(useSSL ? {} : { tls: false }),
});
this.bucketName = configService.get<string>("minio.bucketName", "sase-schemas");
this.publicUrl = configService.get<string>("minio.publicUrl")!;
}
async upload(key: string, body: Buffer | Uint8Array, contentType: string): Promise<string> {
await this.s3.send(
new PutObjectCommand({
Bucket: this.bucketName,
Key: key,
Body: body,
ContentType: contentType,
}),
);
return this.getPublicUrl(key);
}
async getBuffer(key: string): Promise<Buffer | null> {
try {
const response = await this.s3.send(
new GetObjectCommand({
Bucket: this.bucketName,
Key: key,
}),
);
const stream = response.Body;
if (!stream) return null;
const chunks: Uint8Array[] = [];
for await (const chunk of stream as AsyncIterable<Uint8Array>) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
} catch {
return null;
}
}
async delete(key: string): Promise<void> {
await this.s3.send(
new DeleteObjectCommand({
Bucket: this.bucketName,
Key: key,
}),
);
}
getPublicUrl(key: string): string {
return `${this.publicUrl}/${key}`;
}
}

View File

@@ -0,0 +1,54 @@
import { Controller, Get, Post, Patch, Body, Query, UseGuards } from "@nestjs/common";
import { SubscriptionsService } from "./subscriptions.service";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { Roles } from "../common/decorators/roles.decorator";
import { RolesGuard } from "../common/guards/roles.guard";
@Controller("subscriptions")
export class SubscriptionsController {
constructor(private subscriptionsService: SubscriptionsService) {}
@Post()
async create(
@CurrentUser("id") userId: string,
@Body() body: { planId: string; brandIds: string[]; billingPeriod: "monthly" | "yearly" },
) {
const subscription = await this.subscriptionsService.create(userId, body);
// Add selected brands
if (body.brandIds.length > 0) {
await this.subscriptionsService.addBrandsToSubscription(
subscription.id,
userId,
body.brandIds,
);
}
return subscription;
}
@Get("me")
async getMySubscription(@CurrentUser("id") userId: string) {
return this.subscriptionsService.getMySubscription(userId);
}
@Patch("cancel")
async cancel(@CurrentUser("id") userId: string) {
return this.subscriptionsService.cancel(userId);
}
@Patch("resume")
async resume(@CurrentUser("id") userId: string) {
return this.subscriptionsService.resume(userId);
}
@Get()
@UseGuards(RolesGuard)
@Roles("admin")
async findAll(@Query("page") page?: string, @Query("limit") limit?: string) {
return this.subscriptionsService.findAll(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { SubscriptionsController } from "./subscriptions.controller";
import { SubscriptionsService } from "./subscriptions.service";
import { BrandsModule } from "../brands/brands.module";
import { PlansModule } from "../plans/plans.module";
@Module({
imports: [BrandsModule, PlansModule],
controllers: [SubscriptionsController],
providers: [SubscriptionsService],
exports: [SubscriptionsService],
})
export class SubscriptionsModule {}

Some files were not shown because too many files have changed in this diff Show More