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,39 @@
export const ERROR_CODES = {
// Auth
INVALID_CREDENTIALS: "AUTH_001",
EMAIL_ALREADY_EXISTS: "AUTH_002",
SESSION_EXPIRED: "AUTH_003",
UNAUTHORIZED: "AUTH_004",
FORBIDDEN: "AUTH_005",
// VIN
INVALID_VIN: "VIN_001",
VIN_DECODE_FAILED: "VIN_002",
BRAND_NOT_SUPPORTED: "VIN_003",
// Subscription
NO_ACTIVE_SUBSCRIPTION: "SUB_001",
BRAND_ACCESS_DENIED: "SUB_002",
INVALID_BRAND_COUNT: "SUB_003",
SUBSCRIPTION_ALREADY_ACTIVE: "SUB_004",
// Payment
PAYMENT_FAILED: "PAY_001",
IYZICO_ERROR: "PAY_002",
EFT_RECEIPT_REQUIRED: "PAY_003",
PAYMENT_ALREADY_PROCESSED: "PAY_004",
// General
NOT_FOUND: "GEN_001",
VALIDATION_ERROR: "GEN_002",
INTERNAL_ERROR: "GEN_003",
RATE_LIMITED: "GEN_004",
CONFLICT: "GEN_005",
// Integration
PL24_ERROR: "INT_001",
EMEX_ERROR: "INT_002",
CORGI_ERROR: "INT_003",
} as const;
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];

View File

@@ -0,0 +1,33 @@
export const PLANS = {
SINGLE: {
name: "1 Marka",
brandCount: 1,
priceMonthly: 200_00,
priceYearly: 2000_00,
},
DOUBLE: {
name: "2 Marka",
brandCount: 2,
priceMonthly: 350_00,
priceYearly: 3500_00,
},
TRIPLE: {
name: "3 Marka",
brandCount: 3,
priceMonthly: 500_00,
priceYearly: 5000_00,
},
FULL: {
name: "Full Paket",
brandCount: 0,
priceMonthly: 999_00,
priceYearly: 9990_00,
},
} as const;
export const REFERRAL_REWARDS = {
TIER_1: { count: 3, extensionDays: 7 },
TIER_2: { count: 5, extensionDays: 30 },
} as const;
export const CURRENCY = "TRY" as const;

View File

@@ -0,0 +1,5 @@
export const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/;
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const OEM_CODE_REGEX = /^[A-Z0-9\-.\s]{3,30}$/i;

View File

@@ -0,0 +1,43 @@
// Types
export type { User, UserProfile, UserSubscriptionSummary } from "./types/user.js";
export type { Vehicle, VinDecodeResult, CategoryNode, VehicleSource } from "./types/vehicle.js";
export type { Brand } from "./types/brand.js";
export type { Part, PartSearchResult, PartSource } from "./types/part.js";
export type { Category, CategoryWithSchema, SchemaPic, Hotspot } from "./types/category.js";
export type {
Plan,
Subscription,
UserBrand,
CreateSubscriptionInput,
SubscriptionStatus,
} from "./types/subscription.js";
export type {
Payment,
PaymentMethod,
PaymentStatus,
IyzicoInitializeInput,
EftPaymentInput,
} from "./types/payment.js";
export type { ApiResponse, ApiError, PaginationMeta } from "./types/api-response.js";
export type { PaginationInput, PaginatedResult } from "./types/pagination.js";
// Schemas
export { vinSchema } from "./schemas/vin.js";
export { loginSchema, registerSchema, forgotPasswordSchema, resetPasswordSchema } from "./schemas/auth.js";
export { paginationSchema } from "./schemas/pagination.js";
// Constants
export { VIN_REGEX, EMAIL_REGEX, OEM_CODE_REGEX } from "./constants/regex.js";
export { PLANS, REFERRAL_REWARDS, CURRENCY } from "./constants/plans.js";
export { ERROR_CODES } from "./constants/error-codes.js";
export type { ErrorCode } from "./constants/error-codes.js";
// Utils
export {
isValidVin,
validateVinCheckDigit,
extractWmi,
extractModelYear,
} from "./utils/vin-validator.js";
export { formatTRY, kurusToLira, liraToKurus } from "./utils/currency.js";
export { formatVin, formatDate, formatDateTime, slugify, generateReferralCode } from "./utils/formatters.js";

View File

@@ -0,0 +1,38 @@
import { z } from "zod";
export const loginSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
export const registerSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters").max(100),
email: z.string().email("Invalid email address"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.max(128)
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
.regex(/[0-9]/, "Password must contain at least one number"),
});
export const forgotPasswordSchema = z.object({
email: z.string().email("Invalid email address"),
});
export const resetPasswordSchema = z.object({
token: z.string().min(1),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.max(128)
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
.regex(/[0-9]/, "Password must contain at least one number"),
});
export type LoginInput = z.infer<typeof loginSchema>;
export type RegisterInput = z.infer<typeof registerSchema>;
export type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>;
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;

View File

@@ -0,0 +1,8 @@
import { z } from "zod";
export const paginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export type PaginationQuery = z.infer<typeof paginationSchema>;

View File

@@ -0,0 +1,10 @@
import { z } from "zod";
import { VIN_REGEX } from "../constants/regex.js";
export const vinSchema = z
.string()
.length(17, "VIN must be exactly 17 characters")
.regex(VIN_REGEX, "VIN contains invalid characters (I, O, Q are not allowed)")
.transform((v) => v.toUpperCase());
export type VinInput = z.input<typeof vinSchema>;

View File

@@ -0,0 +1,21 @@
export interface ApiResponse<T = unknown> {
success: boolean;
data: T;
meta?: PaginationMeta;
}
export interface ApiError {
success: false;
error: {
code: string;
message: string;
details?: unknown;
};
}
export interface PaginationMeta {
page: number;
limit: number;
total: number;
totalPages: number;
}

View File

@@ -0,0 +1,9 @@
export interface Brand {
id: string;
name: string;
slug: string;
logoUrl: string | null;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -0,0 +1,34 @@
export interface Category {
id: string;
vehicleId: string;
name: string;
nameOriginal: string | null;
parentId: string | null;
externalId: string | null;
source: "pl24" | "emex";
createdAt: Date;
}
export interface CategoryWithSchema extends Category {
schemaPics: SchemaPic[];
}
export interface SchemaPic {
id: string;
categoryId: string;
imageUrl: string;
hotspots: Hotspot[];
source: "pl24" | "emex";
createdAt: Date;
}
export interface Hotspot {
index: number;
x: number;
y: number;
width: number;
height: number;
partId: string | null;
shape: "rect" | "circle" | "polygon";
points?: { x: number; y: number }[];
}

View File

@@ -0,0 +1,14 @@
export interface PaginationInput {
page?: number;
limit?: number;
}
export interface PaginatedResult<T> {
items: T[];
meta: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}

View File

@@ -0,0 +1,26 @@
export interface Part {
id: string;
vehicleId: string;
categoryId: string;
oemCode: string;
name: string;
nameOriginal: string | null;
description: string | null;
quantity: number | null;
position: string | null;
hotspotIndex: number | null;
source: PartSource;
createdAt: Date;
}
export type PartSource = "pl24" | "emex";
export interface PartSearchResult {
part: Part;
vehicleInfo: {
vin: string;
brandName: string;
model: string | null;
year: number | null;
};
}

View File

@@ -0,0 +1,30 @@
export interface Payment {
id: string;
userId: string;
subscriptionId: string;
amount: number;
currency: string;
method: PaymentMethod;
status: PaymentStatus;
iyzicoPaymentId: string | null;
eftReceiptUrl: string | null;
adminNote: string | null;
createdAt: Date;
updatedAt: Date;
}
export type PaymentMethod = "iyzico" | "eft";
export type PaymentStatus = "pending" | "completed" | "failed" | "refunded";
export interface IyzicoInitializeInput {
subscriptionId: string;
cardHolderName: string;
cardNumber: string;
expireMonth: string;
expireYear: string;
cvc: string;
}
export interface EftPaymentInput {
subscriptionId: string;
}

View File

@@ -0,0 +1,38 @@
export interface Plan {
id: string;
name: string;
brandCount: number;
priceMonthly: number;
priceYearly: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface Subscription {
id: string;
userId: string;
planId: string;
status: SubscriptionStatus;
startDate: Date;
endDate: Date;
cancelledAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export type SubscriptionStatus = "pending" | "active" | "cancelled" | "expired";
export interface UserBrand {
id: string;
userId: string;
subscriptionId: string;
brandId: string;
createdAt: Date;
}
export interface CreateSubscriptionInput {
planId: string;
brandIds: string[];
billingPeriod: "monthly" | "yearly";
}

View File

@@ -0,0 +1,31 @@
export interface User {
id: string;
name: string;
email: string;
emailVerified: boolean;
image: string | null;
role: "user" | "admin";
referralCode: string | null;
referredBy: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface UserProfile {
id: string;
name: string;
email: string;
image: string | null;
role: "user" | "admin";
referralCode: string | null;
subscription: UserSubscriptionSummary | null;
}
export interface UserSubscriptionSummary {
planName: string;
status: SubscriptionStatus;
brands: string[];
expiresAt: Date;
}
export type SubscriptionStatus = "pending" | "active" | "cancelled" | "expired";

View File

@@ -0,0 +1,33 @@
export interface Vehicle {
id: string;
userId: string;
vin: string;
brandId: string;
brandName: string;
model: string | null;
year: number | null;
engine: string | null;
transmission: string | null;
bodyType: string | null;
market: string | null;
rawData: Record<string, unknown> | null;
source: VehicleSource;
createdAt: Date;
updatedAt: Date;
}
export type VehicleSource = "pl24" | "emex" | "corgi" | "vin-api";
export interface VinDecodeResult {
vehicle: Vehicle;
categories: CategoryNode[];
}
export interface CategoryNode {
id: string;
name: string;
nameOriginal: string | null;
parentId: string | null;
children: CategoryNode[];
hasSchema: boolean;
}

View File

@@ -0,0 +1,18 @@
const TRY_FORMATTER = new Intl.NumberFormat("tr-TR", {
style: "currency",
currency: "TRY",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
export function formatTRY(amountInKurus: number): string {
return TRY_FORMATTER.format(amountInKurus / 100);
}
export function kurusToLira(kurus: number): number {
return kurus / 100;
}
export function liraToKurus(lira: number): number {
return Math.round(lira * 100);
}

View File

@@ -0,0 +1,45 @@
export function formatVin(vin: string): string {
return vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, "");
}
export function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("tr-TR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
export function formatDateTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleString("tr-TR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[çÇ]/g, "c")
.replace(/[ğĞ]/g, "g")
.replace(/[ıİ]/g, "i")
.replace(/[öÖ]/g, "o")
.replace(/[şŞ]/g, "s")
.replace(/[üÜ]/g, "u")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
export function generateReferralCode(): string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let code = "";
for (let i = 0; i < 8; i++) {
code += chars[Math.floor(Math.random() * chars.length)];
}
return code;
}

View File

@@ -0,0 +1,48 @@
import { VIN_REGEX } from "../constants/regex.js";
const TRANSLITERATION: Record<string, number> = {
A: 1, B: 2, C: 3, D: 4, E: 5, F: 6, G: 7, H: 8,
J: 1, K: 2, L: 3, M: 4, N: 5, P: 7, R: 9,
S: 2, T: 3, U: 4, V: 5, W: 6, X: 7, Y: 8, Z: 9,
};
const POSITION_WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2];
export function isValidVin(vin: string): boolean {
if (!vin || vin.length !== 17) return false;
const upper = vin.toUpperCase();
return VIN_REGEX.test(upper);
}
export function validateVinCheckDigit(vin: string): boolean {
const upper = vin.toUpperCase();
if (!isValidVin(upper)) return false;
let sum = 0;
for (let i = 0; i < 17; i++) {
const char = upper[i];
const value = /\d/.test(char) ? Number.parseInt(char, 10) : TRANSLITERATION[char];
if (value === undefined) return false;
sum += value * POSITION_WEIGHTS[i];
}
const remainder = sum % 11;
const checkChar = remainder === 10 ? "X" : String(remainder);
return upper[8] === checkChar;
}
export function extractWmi(vin: string): string {
return vin.toUpperCase().slice(0, 3);
}
export function extractModelYear(vin: string): number | null {
const yearChar = vin.toUpperCase()[9];
const yearMap: Record<string, number> = {
A: 2010, B: 2011, C: 2012, D: 2013, E: 2014, F: 2015, G: 2016, H: 2017,
J: 2018, K: 2019, L: 2020, M: 2021, N: 2022, P: 2023, R: 2024, S: 2025,
T: 2026, V: 2027, W: 2028, X: 2029, Y: 2030,
"1": 2001, "2": 2002, "3": 2003, "4": 2004, "5": 2005,
"6": 2006, "7": 2007, "8": 2008, "9": 2009,
};
return yearMap[yearChar] ?? null;
}