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:
53
apps/api/src/translations/translations.controller.ts
Normal file
53
apps/api/src/translations/translations.controller.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/translations/translations.module.ts
Normal file
10
apps/api/src/translations/translations.module.ts
Normal 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 {}
|
||||
179
apps/api/src/translations/translations.service.spec.ts
Normal file
179
apps/api/src/translations/translations.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
337
apps/api/src/translations/translations.service.ts
Normal file
337
apps/api/src/translations/translations.service.ts
Normal 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, "\\$&");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user