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:
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user