feat: sase.tr v2 full application implementation

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

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

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

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

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

View File

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