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,53 @@
import {
Controller,
Get,
Post,
Put,
Param,
Body,
Query,
UseGuards,
} from "@nestjs/common";
import { TranslationsService } from "./translations.service";
import { Public } from "../common/decorators/public.decorator";
import { Roles } from "../common/decorators/roles.decorator";
import { RolesGuard } from "../common/guards/roles.guard";
@Controller("translations")
export class TranslationsController {
constructor(private translationsService: TranslationsService) {}
@Get("search")
@UseGuards(RolesGuard)
@Roles("admin")
async search(@Query("q") query: string) {
return this.translationsService.searchTranslations(query || "");
}
@Get(":key")
@Public()
async getTranslation(@Param("key") key: string) {
return this.translationsService.getTranslation(key);
}
@Post("batch")
async translateBatch(
@Body() body: { items: { key: string; sourceText: string }[] },
) {
return this.translationsService.translateBatch(body.items);
}
@Put(":key")
@UseGuards(RolesGuard)
@Roles("admin")
async setTranslation(
@Param("key") key: string,
@Body() body: { sourceText: string; translatedText: string },
) {
return this.translationsService.setTranslation(
key,
body.sourceText,
body.translatedText,
);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { TranslationsController } from "./translations.controller";
import { TranslationsService } from "./translations.service";
@Module({
controllers: [TranslationsController],
providers: [TranslationsService],
exports: [TranslationsService],
})
export class TranslationsModule {}

View File

@@ -0,0 +1,179 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TranslationsService } from "./translations.service";
/**
* Create a mock DB and Redis to isolate the dictionary-based translation logic.
*/
function createMockDeps() {
const insertChain = {
values: vi.fn().mockReturnThis(),
onConflictDoNothing: vi.fn().mockResolvedValue(undefined),
onConflictDoUpdate: vi.fn().mockReturnThis(),
returning: vi.fn().mockReturnValue([]),
};
const db = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([]),
}),
insert: vi.fn().mockReturnValue(insertChain),
};
const redis = {
getJson: vi.fn().mockResolvedValue(null),
setJson: vi.fn().mockResolvedValue(undefined),
del: vi.fn().mockResolvedValue(undefined),
};
return { db, redis };
}
function createService(
db: unknown,
redis: unknown,
): TranslationsService {
return new TranslationsService(db as any, redis as any);
}
describe("TranslationsService", () => {
let service: TranslationsService;
let db: ReturnType<typeof createMockDeps>["db"];
let redis: ReturnType<typeof createMockDeps>["redis"];
beforeEach(() => {
vi.clearAllMocks();
const deps = createMockDeps();
db = deps.db;
redis = deps.redis;
service = createService(db, redis);
});
describe("dictionary lookup for common terms", () => {
it("should translate 'engine' to 'Motor'", async () => {
const result = await service.translate("cat:engine", "engine");
expect(result.translatedText).toBe("Motor");
expect(result.source).toBe("dictionary");
expect(result.isAutoTranslated).toBe(true);
});
it("should translate 'brake pad' to 'Fren Balatas\u0131'", async () => {
const result = await service.translate("cat:brake-pad", "brake pad");
expect(result.translatedText).toBe("Fren Balatas\u0131");
expect(result.source).toBe("dictionary");
});
it("should translate 'spark plug' to 'Buji'", async () => {
const result = await service.translate("cat:spark-plug", "spark plug");
expect(result.translatedText).toBe("Buji");
expect(result.source).toBe("dictionary");
});
it("should be case-insensitive for dictionary lookup", async () => {
const result = await service.translate("cat:engine", "ENGINE");
expect(result.translatedText).toBe("Motor");
expect(result.source).toBe("dictionary");
});
it("should translate 'Steering Wheel' (mixed case)", async () => {
const result = await service.translate(
"cat:steering-wheel",
"Steering Wheel",
);
expect(result.translatedText).toBe("Direksiyon Simidi");
expect(result.source).toBe("dictionary");
});
});
describe("word-by-word replacement for compound phrases", () => {
it("should translate compound phrase with known words", async () => {
// "engine filter" contains "engine" and "filter" individually
const result = await service.translate(
"cat:engine-filter",
"engine filter",
);
// Should match individual words and replace them
expect(result.source).toBe("dictionary");
expect(result.translatedText).toContain("Motor");
expect(result.translatedText).toContain("Filtre");
});
it("should prefer longer dictionary entries over shorter ones", async () => {
// "oil filter" is a specific entry (Yag Filtresi), not "oil" + "filter"
const result = await service.translate("cat:oil-filter", "oil filter");
expect(result.translatedText).toBe("Ya\u011f Filtresi");
expect(result.source).toBe("dictionary");
});
});
describe("no match returns original text", () => {
it("should return original text when no dictionary match found", async () => {
const result = await service.translate(
"cat:unknown-part",
"xylophone bracket",
);
expect(result.translatedText).toBe("xylophone bracket");
expect(result.source).toBe("none");
expect(result.isAutoTranslated).toBe(false);
});
it("should return original text for completely unknown term", async () => {
const result = await service.translate("cat:foo", "foobarbaz");
expect(result.translatedText).toBe("foobarbaz");
expect(result.source).toBe("none");
});
});
describe("cache and DB integration", () => {
it("should return cached result when available", async () => {
const cachedResult = {
key: "cat:engine",
sourceText: "engine",
translatedText: "Motor",
source: "db" as const,
isAutoTranslated: false,
};
redis.getJson.mockResolvedValue(cachedResult);
const result = await service.translate("cat:engine", "engine");
expect(result.source).toBe("cache");
expect(result.translatedText).toBe("Motor");
expect(db.select).not.toHaveBeenCalled();
});
it("should return DB result when cache misses but DB has it", async () => {
redis.getJson.mockResolvedValue(null);
db.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([
{
originalName: "engine",
translatedName: "Motor",
isManual: true,
},
]),
}),
}),
});
const result = await service.translate("cat:engine", "engine");
expect(result.source).toBe("db");
expect(result.translatedText).toBe("Motor");
expect(result.isAutoTranslated).toBe(false);
});
});
});

View File

@@ -0,0 +1,337 @@
import { Inject, Injectable, Logger } from "@nestjs/common";
import { eq, ilike, or } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider";
import { emexCategoryTranslations } from "../database/schema/core";
import { RedisService } from "../redis/redis.service";
/** 30 days in seconds */
const CACHE_TTL = 30 * 24 * 60 * 60;
const CACHE_PREFIX = "tr:";
/** Common EN → TR automotive dictionary */
const AUTOMOTIVE_DICTIONARY: Record<string, string> = {
"engine": "Motor",
"brake": "Fren",
"steering": "Direksiyon",
"suspension": "Süspansiyon",
"exhaust": "Egzoz",
"transmission": "Şanzıman",
"radiator": "Radyatör",
"battery": "Akü",
"filter": "Filtre",
"clutch": "Debriyaj",
"shock absorber": "Amortisör",
"alternator": "Alternatör",
"starter": "Marş Motoru",
"spark plug": "Buji",
"fuel pump": "Yakıt Pompası",
"water pump": "Su Pompası",
"oil pump": "Yağ Pompası",
"timing belt": "Triger Kayışı",
"fan belt": "Vantilatör Kayışı",
"gasket": "Conta",
"piston": "Piston",
"cylinder": "Silindir",
"crankshaft": "Krank Mili",
"camshaft": "Eksantrik Mili",
"valve": "Supap",
"turbocharger": "Turbo",
"intercooler": "Intercooler",
"catalytic converter": "Katalitik Konvertör",
"muffler": "Susturucu",
"bumper": "Tampon",
"fender": "Çamurluk",
"hood": "Kaput",
"trunk": "Bagaj",
"windshield": "Ön Cam",
"mirror": "Ayna",
"headlight": "Far",
"tail light": "Stop Lambası",
"wiper": "Silecek",
"door": "Kapı",
"wheel": "Jant",
"tire": "Lastik",
"axle": "Aks",
"bearing": "Rulman",
"caliper": "Kaliper",
"brake pad": "Fren Balatası",
"brake disc": "Fren Diski",
"air filter": "Hava Filtresi",
"oil filter": "Yağ Filtresi",
"fuel filter": "Yakıt Filtresi",
"cabin filter": "Polen Filtresi",
"thermostat": "Termostat",
"sensor": "Sensör",
"relay": "Röle",
"fuse": "Sigorta",
"compressor": "Kompresör",
"condenser": "Kondenser",
"evaporator": "Evaporatör",
"hose": "Hortum",
"belt": "Kayış",
"spring": "Yay",
"strut": "Amortisör Bacağı",
"control arm": "Salıncak",
"tie rod": "Rot Başı",
"ball joint": "Rotil",
"cv joint": "Aks Kafası",
"drive shaft": "Şaft",
"differential": "Diferansiyel",
"gearbox": "Vites Kutusu",
"flywheel": "Volan",
"injector": "Enjektör",
"throttle body": "Gaz Kelebeği",
"manifold": "Manifold",
"oxygen sensor": "Oksijen Sensörü",
"abs sensor": "ABS Sensörü",
"ignition coil": "Ateşleme Bobini",
"distributor": "Distribütör",
"voltage regulator": "Voltaj Regülatörü",
"window regulator": "Cam Krikosu",
"door lock": "Kapı Kilidi",
"seat": "Koltuk",
"dashboard": "Gösterge Paneli",
"steering wheel": "Direksiyon Simidi",
"gear lever": "Vites Kolu",
"handbrake": "El Freni",
"pedal": "Pedal",
"radiator hose": "Radyatör Hortumu",
"coolant": "Antifriz",
"brake fluid": "Fren Hidroliği",
"engine oil": "Motor Yağı",
"power steering": "Hidrolik Direksiyon",
};
export interface TranslationResult {
key: string;
sourceText: string;
translatedText: string;
source: "cache" | "db" | "dictionary" | "none";
isAutoTranslated: boolean;
}
@Injectable()
export class TranslationsService {
private readonly logger = new Logger(TranslationsService.name);
constructor(
@Inject(DATABASE) private db: Database,
private redis: RedisService,
) {}
/**
* Translate a single key: Redis cache → DB → dictionary → original
*/
async translate(key: string, sourceText: string): Promise<TranslationResult> {
// 1. Check Redis cache
const cacheKey = `${CACHE_PREFIX}${key}`;
const cached = await this.redis.getJson<TranslationResult>(cacheKey);
if (cached) {
return { ...cached, source: "cache" };
}
// 2. Check DB
const dbResult = await this.db
.select()
.from(emexCategoryTranslations)
.where(eq(emexCategoryTranslations.originalName, sourceText))
.limit(1);
if (dbResult.length > 0) {
const result: TranslationResult = {
key,
sourceText,
translatedText: dbResult[0].translatedName,
source: "db",
isAutoTranslated: !dbResult[0].isManual,
};
await this.redis.setJson(cacheKey, result, CACHE_TTL);
return result;
}
// 3. Try dictionary-based translation
const dictTranslation = this.translateWithDictionary(sourceText);
if (dictTranslation !== null) {
const result: TranslationResult = {
key,
sourceText,
translatedText: dictTranslation,
source: "dictionary",
isAutoTranslated: true,
};
// Persist to DB for future lookups
await this.db
.insert(emexCategoryTranslations)
.values({
originalName: sourceText,
translatedName: dictTranslation,
isManual: false,
})
.onConflictDoNothing();
await this.redis.setJson(cacheKey, result, CACHE_TTL);
return result;
}
// 4. No translation found — return original with flag
const result: TranslationResult = {
key,
sourceText,
translatedText: sourceText,
source: "none",
isAutoTranslated: false,
};
// Cache "miss" with shorter TTL (1 day) so it gets re-checked sooner
await this.redis.setJson(cacheKey, result, 24 * 60 * 60);
return result;
}
/**
* Batch translate multiple items
*/
async translateBatch(
items: { key: string; sourceText: string }[],
): Promise<TranslationResult[]> {
const results = await Promise.all(
items.map((item) => this.translate(item.key, item.sourceText)),
);
return results;
}
/**
* Admin override: save translation to DB and invalidate cache
*/
async setTranslation(
key: string,
sourceText: string,
translatedText: string,
): Promise<TranslationResult> {
// Upsert into DB
const [row] = await this.db
.insert(emexCategoryTranslations)
.values({
originalName: sourceText,
translatedName: translatedText,
isManual: true,
})
.onConflictDoUpdate({
target: emexCategoryTranslations.originalName,
set: {
translatedName: translatedText,
isManual: true,
updatedAt: new Date(),
},
})
.returning();
// Invalidate cache
const cacheKey = `${CACHE_PREFIX}${key}`;
await this.redis.del(cacheKey);
this.logger.log(`Translation set: "${sourceText}" → "${translatedText}"`);
return {
key,
sourceText,
translatedText: row.translatedName,
source: "db",
isAutoTranslated: false,
};
}
/**
* Get a single translation by key (from cache or DB)
*/
async getTranslation(key: string): Promise<TranslationResult | null> {
// Check cache first
const cacheKey = `${CACHE_PREFIX}${key}`;
const cached = await this.redis.getJson<TranslationResult>(cacheKey);
if (cached) {
return { ...cached, source: "cache" };
}
// Check DB by treating key as the original name
const dbResult = await this.db
.select()
.from(emexCategoryTranslations)
.where(eq(emexCategoryTranslations.originalName, key))
.limit(1);
if (dbResult.length > 0) {
const result: TranslationResult = {
key,
sourceText: dbResult[0].originalName,
translatedText: dbResult[0].translatedName,
source: "db",
isAutoTranslated: !dbResult[0].isManual,
};
await this.redis.setJson(cacheKey, result, CACHE_TTL);
return result;
}
return null;
}
/**
* Search translations by query string (admin)
*/
async searchTranslations(query: string): Promise<
{
id: string;
originalName: string;
translatedName: string;
isManual: boolean;
createdAt: Date;
updatedAt: Date;
}[]
> {
const pattern = `%${query}%`;
return this.db
.select()
.from(emexCategoryTranslations)
.where(
or(
ilike(emexCategoryTranslations.originalName, pattern),
ilike(emexCategoryTranslations.translatedName, pattern),
),
)
.limit(50);
}
/**
* Dictionary-based translation for common automotive terms.
* Performs case-insensitive full-text and word-level replacement.
*/
private translateWithDictionary(text: string): string | null {
const lowerText = text.toLowerCase().trim();
// Exact match first
if (AUTOMOTIVE_DICTIONARY[lowerText]) {
return AUTOMOTIVE_DICTIONARY[lowerText];
}
// Try word-by-word replacement for compound phrases
let translated = text;
let hasMatch = false;
// Sort dictionary entries by key length descending so longer phrases match first
const sortedEntries = Object.entries(AUTOMOTIVE_DICTIONARY).sort(
(a, b) => b[0].length - a[0].length,
);
for (const [en, tr] of sortedEntries) {
const regex = new RegExp(`\\b${this.escapeRegex(en)}\\b`, "gi");
if (regex.test(translated)) {
translated = translated.replace(regex, tr);
hasMatch = true;
}
}
return hasMatch ? translated : null;
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
}