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

View File

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

View File

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