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:
47
.github/workflows/ci.yml
vendored
Normal file
47
.github/workflows/ci.yml
vendored
Normal 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
51
.github/workflows/deploy.yml
vendored
Normal 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
16
apps/api/.env.example
Normal 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
|
||||||
12
apps/api/drizzle.config.ts
Normal file
12
apps/api/drizzle.config.ts
Normal 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
10
apps/api/nest-cli.json
Normal 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
57
apps/api/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
55
apps/api/src/admin/admin.controller.ts
Normal file
55
apps/api/src/admin/admin.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/admin/admin.module.ts
Normal file
9
apps/api/src/admin/admin.module.ts
Normal 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 {}
|
||||||
251
apps/api/src/admin/admin.service.ts
Normal file
251
apps/api/src/admin/admin.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
77
apps/api/src/app.module.ts
Normal file
77
apps/api/src/app.module.ts
Normal 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 {}
|
||||||
14
apps/api/src/auth/auth.controller.ts
Normal file
14
apps/api/src/auth/auth.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
apps/api/src/auth/auth.module.ts
Normal file
21
apps/api/src/auth/auth.module.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/api/src/auth/auth.service.ts
Normal file
15
apps/api/src/auth/auth.service.ts
Normal 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
70
apps/api/src/auth/auth.ts
Normal 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;
|
||||||
|
}
|
||||||
33
apps/api/src/brands/brands.controller.ts
Normal file
33
apps/api/src/brands/brands.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/brands/brands.module.ts
Normal file
10
apps/api/src/brands/brands.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { BrandsController } from "./brands.controller";
|
||||||
|
import { BrandsService } from "./brands.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [BrandsController],
|
||||||
|
providers: [BrandsService],
|
||||||
|
exports: [BrandsService],
|
||||||
|
})
|
||||||
|
export class BrandsModule {}
|
||||||
42
apps/api/src/brands/brands.service.ts
Normal file
42
apps/api/src/brands/brands.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/api/src/categories/categories.controller.ts
Normal file
17
apps/api/src/categories/categories.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/api/src/categories/categories.module.ts
Normal file
12
apps/api/src/categories/categories.module.ts
Normal 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 {}
|
||||||
102
apps/api/src/categories/categories.service.ts
Normal file
102
apps/api/src/categories/categories.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/common/decorators/current-user.decorator.ts
Normal file
7
apps/api/src/common/decorators/current-user.decorator.ts
Normal 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;
|
||||||
|
});
|
||||||
4
apps/api/src/common/decorators/public.decorator.ts
Normal file
4
apps/api/src/common/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from "@nestjs/common";
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = "isPublic";
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
4
apps/api/src/common/decorators/roles.decorator.ts
Normal file
4
apps/api/src/common/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from "@nestjs/common";
|
||||||
|
|
||||||
|
export const ROLES_KEY = "roles";
|
||||||
|
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
16
apps/api/src/common/decorators/throttle.decorator.ts
Normal file
16
apps/api/src/common/decorators/throttle.decorator.ts
Normal 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 } });
|
||||||
24
apps/api/src/common/dto/api-response.dto.ts
Normal file
24
apps/api/src/common/dto/api-response.dto.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
41
apps/api/src/common/dto/pagination.dto.ts
Normal file
41
apps/api/src/common/dto/pagination.dto.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
36
apps/api/src/common/filters/drizzle-exception.filter.ts
Normal file
36
apps/api/src/common/filters/drizzle-exception.filter.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
apps/api/src/common/filters/http-exception.filter.ts
Normal file
52
apps/api/src/common/filters/http-exception.filter.ts
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
154
apps/api/src/common/guards/auth.guard.spec.ts
Normal file
154
apps/api/src/common/guards/auth.guard.spec.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
42
apps/api/src/common/guards/auth.guard.ts
Normal file
42
apps/api/src/common/guards/auth.guard.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
apps/api/src/common/guards/brand-access.guard.ts
Normal file
56
apps/api/src/common/guards/brand-access.guard.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
apps/api/src/common/guards/roles.guard.ts
Normal file
28
apps/api/src/common/guards/roles.guard.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
apps/api/src/common/interceptors/logging.interceptor.ts
Normal file
21
apps/api/src/common/interceptors/logging.interceptor.ts
Normal 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`);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/api/src/common/interceptors/timeout.interceptor.ts
Normal file
25
apps/api/src/common/interceptors/timeout.interceptor.ts
Normal 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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
apps/api/src/common/interceptors/transform.interceptor.ts
Normal file
39
apps/api/src/common/interceptors/transform.interceptor.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
77
apps/api/src/common/pipes/vin-validation.pipe.spec.ts
Normal file
77
apps/api/src/common/pipes/vin-validation.pipe.spec.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
21
apps/api/src/common/pipes/vin-validation.pipe.ts
Normal file
21
apps/api/src/common/pipes/vin-validation.pipe.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/api/src/config/configuration.ts
Normal file
40
apps/api/src/config/configuration.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
11
apps/api/src/config/env.validation.ts
Normal file
11
apps/api/src/config/env.validation.ts
Normal 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;
|
||||||
|
}
|
||||||
11
apps/api/src/database/database.module.ts
Normal file
11
apps/api/src/database/database.module.ts
Normal 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 {}
|
||||||
34
apps/api/src/database/database.provider.ts
Normal file
34
apps/api/src/database/database.provider.ts
Normal 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],
|
||||||
|
};
|
||||||
373
apps/api/src/database/schema/core.ts
Normal file
373
apps/api/src/database/schema/core.ts
Normal 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)],
|
||||||
|
);
|
||||||
209
apps/api/src/database/schema/emex.ts
Normal file
209
apps/api/src/database/schema/emex.ts
Normal 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),
|
||||||
|
],
|
||||||
|
);
|
||||||
202
apps/api/src/database/schema/pl24.ts
Normal file
202
apps/api/src/database/schema/pl24.ts
Normal 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)],
|
||||||
|
);
|
||||||
115
apps/api/src/database/schema/relations.ts
Normal file
115
apps/api/src/database/schema/relations.ts
Normal 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",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
103
apps/api/src/database/seed.ts
Normal file
103
apps/api/src/database/seed.ts
Normal 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);
|
||||||
|
});
|
||||||
9
apps/api/src/email/email.module.ts
Normal file
9
apps/api/src/email/email.module.ts
Normal 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 {}
|
||||||
69
apps/api/src/email/email.service.ts
Normal file
69
apps/api/src/email/email.service.ts
Normal 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.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/api/src/health.controller.ts
Normal file
11
apps/api/src/health.controller.ts
Normal 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() };
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/api/src/integrations/corgi/corgi.module.ts
Normal file
8
apps/api/src/integrations/corgi/corgi.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { CorgiService } from "./corgi.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [CorgiService],
|
||||||
|
exports: [CorgiService],
|
||||||
|
})
|
||||||
|
export class CorgiModule {}
|
||||||
113
apps/api/src/integrations/corgi/corgi.service.spec.ts
Normal file
113
apps/api/src/integrations/corgi/corgi.service.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
101
apps/api/src/integrations/corgi/corgi.service.ts
Normal file
101
apps/api/src/integrations/corgi/corgi.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/api/src/integrations/emex/browser.d.ts
vendored
Normal file
22
apps/api/src/integrations/emex/browser.d.ts
vendored
Normal 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>;
|
||||||
|
};
|
||||||
178
apps/api/src/integrations/emex/emex-parser.service.ts
Normal file
178
apps/api/src/integrations/emex/emex-parser.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
136
apps/api/src/integrations/emex/emex-queue.service.ts
Normal file
136
apps/api/src/integrations/emex/emex-queue.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
419
apps/api/src/integrations/emex/emex-scraper.service.ts
Normal file
419
apps/api/src/integrations/emex/emex-scraper.service.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/api/src/integrations/emex/emex.module.ts
Normal file
11
apps/api/src/integrations/emex/emex.module.ts
Normal 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 {}
|
||||||
371
apps/api/src/integrations/emex/emex.service.ts
Normal file
371
apps/api/src/integrations/emex/emex.service.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
apps/api/src/integrations/emex/emex.types.ts
Normal file
74
apps/api/src/integrations/emex/emex.types.ts
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
21
apps/api/src/integrations/pl24/parsers/base-parser.ts
Normal file
21
apps/api/src/integrations/pl24/parsers/base-parser.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/api/src/integrations/pl24/parsers/bmw-parser.ts
Normal file
17
apps/api/src/integrations/pl24/parsers/bmw-parser.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
apps/api/src/integrations/pl24/parsers/generic-parser.ts
Normal file
56
apps/api/src/integrations/pl24/parsers/generic-parser.ts
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/api/src/integrations/pl24/parsers/mercedes-parser.ts
Normal file
17
apps/api/src/integrations/pl24/parsers/mercedes-parser.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/api/src/integrations/pl24/parsers/parser-factory.ts
Normal file
15
apps/api/src/integrations/pl24/parsers/parser-factory.ts
Normal 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);
|
||||||
|
}
|
||||||
61
apps/api/src/integrations/pl24/pl24-auth.service.ts
Normal file
61
apps/api/src/integrations/pl24/pl24-auth.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/api/src/integrations/pl24/pl24.constants.ts
Normal file
6
apps/api/src/integrations/pl24/pl24.constants.ts
Normal 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;
|
||||||
9
apps/api/src/integrations/pl24/pl24.module.ts
Normal file
9
apps/api/src/integrations/pl24/pl24.module.ts
Normal 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 {}
|
||||||
189
apps/api/src/integrations/pl24/pl24.service.ts
Normal file
189
apps/api/src/integrations/pl24/pl24.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
apps/api/src/integrations/pl24/pl24.types.ts
Normal file
74
apps/api/src/integrations/pl24/pl24.types.ts
Normal 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;
|
||||||
|
}
|
||||||
8
apps/api/src/integrations/vin-api/vin-api.module.ts
Normal file
8
apps/api/src/integrations/vin-api/vin-api.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { VinApiService } from "./vin-api.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [VinApiService],
|
||||||
|
exports: [VinApiService],
|
||||||
|
})
|
||||||
|
export class VinApiModule {}
|
||||||
44
apps/api/src/integrations/vin-api/vin-api.service.ts
Normal file
44
apps/api/src/integrations/vin-api/vin-api.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/api/src/jobs/bull.config.ts
Normal file
15
apps/api/src/jobs/bull.config.ts
Normal 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;
|
||||||
64
apps/api/src/jobs/jobs.module.ts
Normal file
64
apps/api/src/jobs/jobs.module.ts
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
132
apps/api/src/jobs/processors/emex-scrape.processor.ts
Normal file
132
apps/api/src/jobs/processors/emex-scrape.processor.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/api/src/jobs/processors/query-cleanup.processor.ts
Normal file
31
apps/api/src/jobs/processors/query-cleanup.processor.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
23
apps/api/src/jobs/queues/emex-scrape.queue.ts
Normal file
23
apps/api/src/jobs/queues/emex-scrape.queue.ts
Normal 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 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
23
apps/api/src/jobs/queues/query-cleanup.queue.ts
Normal file
23
apps/api/src/jobs/queues/query-cleanup.queue.ts
Normal 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 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
23
apps/api/src/jobs/queues/subscription-expiry.queue.ts
Normal file
23
apps/api/src/jobs/queues/subscription-expiry.queue.ts
Normal 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
56
apps/api/src/main.ts
Normal 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();
|
||||||
22
apps/api/src/parts/parts.controller.ts
Normal file
22
apps/api/src/parts/parts.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/api/src/parts/parts.module.ts
Normal file
12
apps/api/src/parts/parts.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PartsController } from "./parts.controller";
|
||||||
|
import { PartsService } from "./parts.service";
|
||||||
|
import { PL24Module } from "../integrations/pl24/pl24.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PL24Module],
|
||||||
|
controllers: [PartsController],
|
||||||
|
providers: [PartsService],
|
||||||
|
exports: [PartsService],
|
||||||
|
})
|
||||||
|
export class PartsModule {}
|
||||||
107
apps/api/src/parts/parts.service.ts
Normal file
107
apps/api/src/parts/parts.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
apps/api/src/payments/payments.controller.ts
Normal file
95
apps/api/src/payments/payments.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/api/src/payments/payments.module.ts
Normal file
12
apps/api/src/payments/payments.module.ts
Normal 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 {}
|
||||||
186
apps/api/src/payments/payments.service.ts
Normal file
186
apps/api/src/payments/payments.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/api/src/plans/plans.controller.ts
Normal file
35
apps/api/src/plans/plans.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/plans/plans.module.ts
Normal file
10
apps/api/src/plans/plans.module.ts
Normal 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 {}
|
||||||
40
apps/api/src/plans/plans.service.ts
Normal file
40
apps/api/src/plans/plans.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/redis/redis.module.ts
Normal file
10
apps/api/src/redis/redis.module.ts
Normal 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 {}
|
||||||
32
apps/api/src/redis/redis.provider.ts
Normal file
32
apps/api/src/redis/redis.provider.ts
Normal 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],
|
||||||
|
};
|
||||||
63
apps/api/src/redis/redis.service.ts
Normal file
63
apps/api/src/redis/redis.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
apps/api/src/referrals/referrals.controller.ts
Normal file
21
apps/api/src/referrals/referrals.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/api/src/referrals/referrals.module.ts
Normal file
12
apps/api/src/referrals/referrals.module.ts
Normal 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 {}
|
||||||
102
apps/api/src/referrals/referrals.service.ts
Normal file
102
apps/api/src/referrals/referrals.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/storage/storage.module.ts
Normal file
9
apps/api/src/storage/storage.module.ts
Normal 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 {}
|
||||||
81
apps/api/src/storage/storage.service.ts
Normal file
81
apps/api/src/storage/storage.service.ts
Normal 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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
apps/api/src/subscriptions/subscriptions.controller.ts
Normal file
54
apps/api/src/subscriptions/subscriptions.controller.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/subscriptions/subscriptions.module.ts
Normal file
13
apps/api/src/subscriptions/subscriptions.module.ts
Normal 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
Reference in New Issue
Block a user