feat: propagate PL24 part metadata, improve auth UX with Google sign-in prominence

- Add unavailable/remark/modelCodes/presel fields to categories and parts DB schema
- Pass PL24 unavailable flag through pipeline instead of filtering out records
- Show unavailable categories/parts at reduced opacity in grid, tree, and parts panel
- Display part remark and model codes as secondary info in parts table
- Move Google sign-in button above email form on login/register pages with branded icon
- Add public email existence check endpoint for better login error messages
- Update INDEX.md documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-02-20 11:11:28 +00:00
parent 87f03f66ac
commit f3f3f57756
15 changed files with 561 additions and 328 deletions

View File

@@ -250,6 +250,7 @@ export class CategoriesService {
externalId: sg.code,
linkPath: sg.linkPath || null,
linkWid: sg.linkWid || null,
unavailable: sg.unavailable || false,
source: "pl24" as const,
}));
@@ -435,6 +436,10 @@ export class CategoriesService {
const val = parseInt(p.hotspotId!, 10);
return (val > 0 && val <= 2147483647) ? val : null;
})() : null,
unavailable: p.unavailable || false,
remark: p.remark || null,
modelCodes: p.modelCodes || null,
presel: p.presel || false,
source: "pl24" as const,
}));

View File

@@ -274,6 +274,7 @@ export const categories = pgTable(
externalId: varchar("external_id", { length: 100 }),
linkPath: text("link_path"),
linkWid: varchar("link_wid", { length: 100 }),
unavailable: boolean("unavailable").default(false).notNull(),
source: varchar("source", { length: 20 }).default("pl24").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
@@ -320,6 +321,10 @@ export const parts = pgTable(
quantity: integer("quantity"),
position: varchar("position", { length: 100 }),
hotspotIndex: integer("hotspot_index"),
unavailable: boolean("unavailable").default(false).notNull(),
remark: text("remark"),
modelCodes: varchar("model_codes", { length: 500 }),
presel: boolean("presel").default(false).notNull(),
source: varchar("source", { length: 20 }).default("pl24").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},

View File

@@ -908,8 +908,7 @@ export class PL24Service {
records = responseData.groups as Array<Record<string, unknown>>;
}
const availableRecords = records.filter((record) => {
if (record.unavailable) return false;
const validRecords = records.filter((record) => {
const link = (record.link as Record<string, unknown>) || {};
// Servicepart records have no link.path but can use bomBaseLink
if (!link.path && !bomBasePath) return false;
@@ -918,7 +917,7 @@ export class PL24Service {
return true;
});
return availableRecords.map((record) => {
return validRecords.map((record) => {
const values = (record.values as Record<string, string>) || {};
const link = (record.link as Record<string, unknown>) || {};
@@ -950,11 +949,21 @@ export class PL24Service {
);
let name = separatorMatch ? separatorMatch[1].trim() : rawCaption;
const remarks = values.remarks || "";
const remarks = (values.remarks || "").replace(/\r?\n/g, " ").trim();
if (remarks) {
name = `${name} (${remarks})`;
}
const modelDesc = (values.modelDescriptions || "").replace(/\r?\n/g, " ").trim();
if (modelDesc) {
name = `${name} [${modelDesc}]`;
}
const illusNum = (values.illustrationNumber || "").replace(/\\-/g, "-").trim();
if (illusNum) {
name = `${name} {${illusNum}}`;
}
if (!name) name = code;
// Construct linkPath: use record's own link.path, or bomBaseLink + record id
@@ -972,6 +981,7 @@ export class PL24Service {
partCount: undefined,
linkPath: constructedPath,
linkWid: (link.wid as string) || (bomBasePath ? "servicePartsItemsTable" : undefined),
unavailable: !!record.unavailable,
};
});
}
@@ -1042,6 +1052,8 @@ export class PL24Service {
positionCode: String(part.pos || values.pos || ""),
modelCodes,
notes: modelCodes,
unavailable: !!part.unavailable,
presel: !!part.presel,
superseded,
hotspotId: (part.hotspotId as string) || undefined,
linkPath: (part.link as Record<string, string>)?.path,
@@ -1324,6 +1336,7 @@ export class PL24Service {
hotspotId: record.hotspotId || record.pos || undefined,
quantity: parseInt(values.qty || "1", 10) || 1,
modelCodes: values.restrictions || undefined,
presel: !!record.presel,
};
});
}

View File

@@ -462,6 +462,7 @@ export interface PL24MainGroup {
subGroups?: PL24SubGroup[];
linkPath?: string;
linkWid?: string;
unavailable?: boolean;
}
export interface PL24SubGroup {
@@ -471,6 +472,7 @@ export interface PL24SubGroup {
description?: string;
imageUrl?: string;
partCount?: number;
unavailable?: boolean;
}
export interface PL24Part {
@@ -484,6 +486,8 @@ export interface PL24Part {
positionCode?: string;
modelCodes?: string;
notes?: string;
unavailable?: boolean;
presel?: boolean;
superseded?: {
oldCode: string;
newCode: string;

View File

@@ -67,6 +67,10 @@ export class PartsService {
const val = parseInt(p.hotspotId!, 10);
return (val > 0 && val <= 2147483647) ? val : null;
})() : null,
unavailable: p.unavailable || false,
remark: p.remark || null,
modelCodes: p.modelCodes || null,
presel: p.presel || false,
source: "pl24" as const,
}));

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Patch, Post, Delete, Body, Param, Query, UseGuards } from "@nestjs/common";
import { UsersService } from "./users.service";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { Public } from "../common/decorators/public.decorator";
import { Roles } from "../common/decorators/roles.decorator";
import { RolesGuard } from "../common/guards/roles.guard";
@@ -8,6 +9,13 @@ import { RolesGuard } from "../common/guards/roles.guard";
export class UsersController {
constructor(private usersService: UsersService) {}
@Public()
@Post("check-email")
async checkEmail(@Body() body: { email: string }) {
const user = await this.usersService.findByEmail(body.email);
return { exists: !!user };
}
@Get("me")
async getMe(@CurrentUser("id") userId: string) {
return this.usersService.findById(userId);

View File

@@ -13,6 +13,7 @@ interface Category {
partCount?: number;
schemaImageUrl?: string | null;
parentId?: string | null;
unavailable?: boolean;
}
interface CategoryGridProps {
@@ -252,6 +253,7 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
key={category.id}
to="/dashboard/vehicles/$id/categories/$categoryId"
params={{ id: vehicleId, categoryId: category.id }}
className={category.unavailable ? "opacity-40" : undefined}
>
<CategoryCard
name={category.name}
@@ -269,7 +271,7 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
key={category.id}
type="button"
onClick={() => handleDrillDown(category)}
className="text-left"
className={category.unavailable ? "text-left opacity-40" : "text-left"}
>
<CategoryCard
name={category.name}

View File

@@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef } from "react";
import { Link } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronRight, ChevronDown, Loader2 } from "lucide-react";
import { cn } from "@sase/ui";
import { api } from "@/lib/api-client";
import { getCategoryIcon } from "@/lib/category-icons";
@@ -12,6 +13,7 @@ interface Category {
partCount?: number;
schemaImageUrl?: string | null;
parentId?: string | null;
unavailable?: boolean;
}
export function CategoryTree({ categories, vehicleId }: { categories: Category[]; vehicleId: string }) {
@@ -108,7 +110,10 @@ function CategoryNode({ category, vehicleId, level, parentPrefetching }: {
return (
<div>
<div
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent"
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent",
category.unavailable && "opacity-40",
)}
style={{ paddingLeft: `${level * 16 + 8}px` }}
>
{loading ? (

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Check, Copy } from "lucide-react";
import { useSchemaStore } from "@/stores/schema.store";
import { cn } from "@sase/ui";
@@ -18,6 +18,19 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const [copiedId, setCopiedId] = useState<string | null>(null);
// Map group → IDs of available (non-unavailable) parts
const availableByGroup = useMemo(() => {
const map = new Map<number, string[]>();
for (const part of parts) {
if (part.hotspotIndex != null && !part.unavailable) {
const ids = map.get(part.hotspotIndex) || [];
ids.push(part.id);
map.set(part.hotspotIndex, ids);
}
}
return map;
}, [parts]);
const copyOemCode = useCallback((e: React.MouseEvent, partId: string, code: string) => {
e.stopPropagation();
navigator.clipboard.writeText(code);
@@ -81,13 +94,21 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
key={part.id}
data-faro-user-action-name="select-part"
ref={(el) => {
// Store ref for the first part in each group (for scroll-to)
if (group != null && el && !rowRefs.current.has(group)) {
if (group == null || !el) return;
const availableIds = availableByGroup.get(group);
if (availableIds?.length === 1) {
// Single available part — scroll directly to it
if (part.id === availableIds[0]) {
rowRefs.current.set(group, el);
}
} else if (!rowRefs.current.has(group)) {
// Multiple or zero available — first part in group
rowRefs.current.set(group, el);
}
}}
className={cn(
"cursor-pointer border-b border-border/50 transition-colors duration-150",
part.unavailable && "opacity-40",
isSelected &&
"bg-primary/10 ring-1 ring-inset ring-primary/20",
isHighlighted && !isSelected && "bg-accent",
@@ -104,7 +125,14 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
<td className="px-3 py-2 text-muted-foreground">
{part.hotspotIndex}
</td>
<td className="px-3 py-2 font-medium">{part.name}</td>
<td className="px-3 py-2">
<span className="font-medium">{part.name}</span>
{(part.remark || part.modelCodes) && (
<span className="block text-xs text-muted-foreground">
{[part.remark, part.modelCodes].filter(Boolean).join(" · ")}
</span>
)}
</td>
<td className="px-3 py-2 font-mono text-xs">
<span className="inline-flex items-center gap-1">
{part.oemCode && (

View File

@@ -8,6 +8,10 @@ export interface Part {
quantity: number | null;
position: string | null;
hotspotIndex: number | null;
unavailable?: boolean;
remark?: string;
modelCodes?: string;
presel?: boolean;
price?: number;
note?: string;
}

View File

@@ -3,6 +3,7 @@ import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Label } from "@sase/ui";
import { api } from "@/lib/api-client";
import { signIn } from "@/lib/auth-client";
import { startAction } from "@/lib/faro";
import { capture } from "@/lib/posthog";
@@ -23,15 +24,27 @@ function LoginPage() {
startAction("login", { method: "email" });
setLoading(true);
try {
await signIn.email({ email, password });
const { error } = await signIn.email({ email, password });
if (error) {
try {
const { exists } = await api.post<{ exists: boolean }>("/users/check-email", { email });
if (!exists) {
toast.error("Kullanıcı bulunamadı", {
description: "Bu e-posta adresi ile kayıtlı bir hesap yok.",
});
} else {
toast.error("E-posta veya şifre hatalı");
}
} catch {
toast.error("E-posta veya şifre hatalı");
}
} else {
capture("user_logged_in", { method: "email" });
navigate({ to: "/dashboard/search" });
} catch {
toast.error("Giriş başarısız. E-posta veya şifre hatalı.");
} finally {
setLoading(false);
}
setLoading(false);
}
return (
@@ -46,6 +59,37 @@ function LoginPage() {
</p>
</div>
{/* Google */}
<Button
variant="outline"
className="w-full"
onClick={() => {
startAction("login", { method: "google" });
capture("user_logged_in", { method: "google" });
signIn.social({ provider: "google", callbackURL: "/dashboard/search" });
}}
>
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
</svg>
Google ile Giriş Yap
</Button>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
veya
</span>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
@@ -92,32 +136,6 @@ function LoginPage() {
</Button>
</form>
{/* Divider + Google */}
<div className="space-y-3">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
veya
</span>
</div>
</div>
<Button
variant="outline"
className="w-full"
onClick={() => {
startAction("login", { method: "google" });
capture("user_logged_in", { method: "google" });
signIn.social({ provider: "google", callbackURL: "/dashboard/search" });
}}
>
Google ile Giriş Yap
</Button>
</div>
{/* Register link */}
<p className="text-center text-sm">
Hesabınız yok mu?{" "}

View File

@@ -61,6 +61,37 @@ function RegisterPage() {
</div>
</div>
{/* Google */}
<Button
variant="outline"
className="w-full"
onClick={() => {
startAction("register", { method: "google" });
capture("user_signed_up", { method: "google" });
signIn.social({ provider: "google", callbackURL: redirectUrl });
}}
>
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
</svg>
Google ile Kayıt Ol
</Button>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
veya
</span>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
@@ -102,32 +133,6 @@ function RegisterPage() {
</Button>
</form>
{/* Divider + Google */}
<div className="space-y-3">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
veya
</span>
</div>
</div>
<Button
variant="outline"
className="w-full"
onClick={() => {
startAction("register", { method: "google" });
capture("user_signed_up", { method: "google" });
signIn.social({ provider: "google", callbackURL: redirectUrl });
}}
>
Google ile Kayıt Ol
</Button>
</div>
<p className="text-center text-xs text-muted-foreground">
Kayıt olunca hemen VIN aramaya başlayın
</p>

View File

@@ -1,6 +1,9 @@
# Sase.tr - Project Documentation Index
> **Automotive parts search platform** for the Turkish market with VIN decoding, subscription-based access, and multi-source parts catalog integration.
> **URL:** https://sase.tr | **Repo:** `/home/s/ss`
Generated: 2026-02-17
---
@@ -9,25 +12,29 @@
- [Architecture Overview](#architecture-overview)
- [Tech Stack](#tech-stack)
- [Project Structure](#project-structure)
- [Entry Points](#entry-points)
- [Backend API](#backend-api)
- [Modules & Services](#modules--services)
- [API Endpoints](#api-endpoints)
- [Authentication](#authentication)
- [Guards, Interceptors & Pipes](#guards-interceptors--pipes)
- [Common Infrastructure](#common-infrastructure)
- [Integrations](#integrations)
- [Database Schema](#database-schema)
- [Job Queues](#job-queues)
- [Integrations](#integrations)
- [Telemetry](#telemetry)
- [API Endpoints](#api-endpoints)
- [Authentication](#authentication)
- [Frontend Web](#frontend-web)
- [Routes & Pages](#routes--pages)
- [Components](#components)
- [State Management](#state-management)
- [Data Fetching](#data-fetching)
- [Hooks & Stores](#hooks--stores)
- [Lib Utilities](#lib-utilities)
- [Internationalization](#internationalization)
- [Shared Packages](#shared-packages)
- [Testing](#testing)
- [Infrastructure & Deployment](#infrastructure--deployment)
- [Environment Variables](#environment-variables)
- [Testing](#testing)
- [Scripts & Commands](#scripts--commands)
- [Key Commands](#key-commands)
- [Key Dependencies](#key-dependencies)
- [Quick Start](#quick-start)
---
@@ -70,11 +77,13 @@
| **Payments** | Iyzico (card), EFT (bank transfer with receipt upload) |
| **Storage** | MinIO (S3-compatible) |
| **Jobs** | BullMQ (Redis-backed queues) |
| **Email** | Postal (transactional email) |
| **Analytics** | PostHog (product analytics) |
| **Observability** | OpenTelemetry (API), Grafana Faro (frontend) |
| **Testing** | Vitest 3, Playwright 1.50 |
| **Linting** | Biome |
| **CI/CD** | GitHub Actions |
| **Process Mgmt** | PM2 |
| **Package Mgmt** | pnpm 10.29, Turborepo |
| **Linting** | Biome (2-space, double quotes, semicolons, trailing commas) |
| **CI/CD** | GitHub Actions → SSH deploy → PM2 |
| **Package Mgmt** | pnpm 10.29, Turborepo 2 |
---
@@ -88,6 +97,7 @@ sase.tr/
│ │ │ ├── main.ts # Bootstrap (global prefix /api, CORS, Helmet, rate limiting)
│ │ │ ├── app.module.ts # Root module (global guards, interceptors, filters)
│ │ │ ├── worker.ts # Standalone worker process
│ │ │ ├── health.controller.ts # Health check endpoint
│ │ │ ├── auth/ # Better Auth integration
│ │ │ ├── users/ # User account management
│ │ │ ├── brands/ # Brand CRUD
@@ -100,52 +110,53 @@ sase.tr/
│ │ │ ├── parts/ # Auto parts catalog
│ │ │ ├── translations/ # Automotive term translations
│ │ │ ├── admin/ # Admin dashboard endpoints
│ │ │ ├── analytics/ # Usage analytics tracking
│ │ │ ├── common/ # Shared guards, pipes, interceptors, filters, decorators
│ │ │ ├── config/ # Runtime configuration
│ │ │ ├── database/ # Drizzle ORM setup + schemas
│ │ │ ├── redis/ # Redis client module
│ │ │ ├── storage/ # MinIO/S3 service
│ │ │ ├── email/ # Email service
│ │ │ ├── email/ # Postal email service
│ │ │ ├── jobs/ # BullMQ queues + processors
│ │ │ ├── telemetry/ # OpenTelemetry SDK (tracing, metrics)
│ │ │ └── integrations/ # External API integrations
│ │ │ ├── corgi/ # Offline VIN WMI decoder
│ │ │ ├── pl24/ # PL24 parts catalog API
│ │ │ ├── emex/ # EMEX scraper (Puppeteer)
│ │ │ ├── pl24/ # PL24 parts catalog API + parsers
│ │ │ ├── emex/ # EMEX scraper (Playwright)
│ │ │ └── vin-api/ # NHTSA VIN API fallback
│ │ ├── drizzle.config.ts
│ │ └── vitest.config.ts
│ │
│ └── web/ # Vite + React frontend
│ └── src/
│ ├── main.tsx # Entry point (RouterProvider, QueryClient)
│ ├── routeTree.gen.ts # Auto-generated TanStack route tree
│ ├── main.tsx # Entry point (RouterProvider, QueryClient, Faro, PostHog)
│ ├── routeTree.gen.ts # Auto-generated TanStack route tree (DO NOT EDIT)
│ ├── routes/ # TanStack Router file-based routes
│ │ ├── __root.tsx # Root layout
│ │ ├── __root.tsx # Root layout (theme, toaster, PostHog tracking)
│ │ ├── index.tsx # Landing page
│ │ ├── _auth.tsx # Auth layout (login, register, etc.)
│ │ ├── dashboard.tsx # Dashboard layout (protected)
│ │ └── dashboard/ # Dashboard subroutes
│ ├── components/ # React components
│ │ ├── admin/ # DailyChart
│ │ ├── schema/ # SchemaViewer, HotspotOverlay, PartsPanel
│ │ ├── schema/ # SchemaViewer, HotspotOverlay, PartsPanel, SchemaToolbar
│ │ ├── subscription/ # BrandSelector
│ │ ├── vehicles/ # VehicleCard, VinInput
│ │ ├── categories/ # CategoryTree, CategoryGrid
│ │ ├── payment/ # PaymentContent
│ │ └── settings/ # SettingsContent
│ ├── hooks/ # Custom hooks (useAuth, useParts, useSchemaInteraction)
│ ├── lib/ # api-client, auth-client, i18n, toast, user-settings, category-icons
│ ├── hooks/ # useAuth, useParts, useSchemaInteraction
│ ├── lib/ # api-client, auth-client, i18n, posthog, faro, toast, user-settings, category-icons
│ ├── stores/ # Zustand stores (auth, schema)
── messages/ # i18n JSON (tr.json, en.json)
│ └── remotion/ # Animated demo video generation
── messages/ # i18n JSON (tr.json, en.json)
├── packages/
│ ├── shared/ # Shared types, Zod schemas, utilities
│ ├── config/ # Environment validation (Zod)
│ └── ui/ # Reusable UI components (shadcn/ui)
│ ├── shared/ # @sase/shared types, Zod schemas, constants, utils
│ ├── config/ # @sase/config — Zod env validation
│ └── ui/ # @sase/ui — shadcn/Radix component library
├── docker/ # Docker Compose (PostgreSQL, Redis, MinIO, nginx)
├── scripts/ # Build/deployment scripts
├── scripts/ # Build/deployment/debug scripts
├── .github/workflows/ # CI (lint, typecheck, test, build) + Deploy (SSH, PM2)
├── ecosystem.config.js # PM2 process configuration
├── turbo.json # Turborepo task pipeline
@@ -155,32 +166,194 @@ sase.tr/
---
## Entry Points
| Process | Path | Description |
|---------|------|-------------|
| API Server | `apps/api/src/main.ts` | NestJS bootstrap (Helmet, CORS, rate limiting) |
| Root Module | `apps/api/src/app.module.ts` | Global guards, interceptors, filters |
| Worker | `apps/api/src/worker.ts` | BullMQ background job processor |
| Health | `apps/api/src/health.controller.ts` | Health check endpoint |
| Frontend | `apps/web/src/main.tsx` | React 19 + TanStack Router + Query + Faro + PostHog |
| Root Layout | `apps/web/src/routes/__root.tsx` | Theme, Toaster, PostHog tracking |
---
## Backend API
### Modules & Services
| Module | Service | Purpose |
|--------|---------|---------|
| **AuthModule** | AuthService | Better Auth instance, session management |
| **UsersModule** | UsersService | Profile CRUD, password change, account deletion, OAuth connections |
| **BrandsModule** | BrandsService | Brand CRUD (cached, admin-managed) |
| **PlansModule** | PlansService | Pricing plan CRUD (cached, admin-managed) |
| **SubscriptionsModule** | SubscriptionsService | Create, activate, cancel, resume, extend subscriptions |
| **PaymentsModule** | PaymentsService | Iyzico card payments, EFT with receipt upload, admin approval |
| **ReferralsModule** | ReferralsService | Referral code generation, application, tier-based rewards |
| **VehiclesModule** | VehiclesService | VIN decode (multi-source fallback), vehicle history, brand access check |
| **CategoriesModule** | CategoriesService | Hierarchical category tree, schema pictures |
| **PartsModule** | PartsService | Parts by category, OEM code search |
| **TranslationsModule** | TranslationsService | Automotive term translation (Redis → DB → Dictionary fallback) |
| **AdminModule** | AdminService | Dashboard stats, user management, payment approval, analytics |
| **EmailModule** | EmailService | Password reset, welcome, payment confirmation emails |
| **StorageModule** | StorageService | S3/MinIO file upload/download |
| **RedisModule** | RedisService | Key-value cache operations |
| **JobsModule** | — | BullMQ queue registration + processors |
| **CorgiModule** | CorgiService | Offline WMI-based VIN decoder |
| **PL24Module** | PL24Service | PL24 parts catalog API (with brand-specific parsers) |
| **EmexModule** | EmexService | EMEX browser scraper via Puppeteer |
| **VinApiModule** | VinApiService | NHTSA VIN API (fallback decoder) |
| Module | Files | Purpose |
|--------|-------|---------|
| **AuthModule** | module, service, controller, auth.ts | Better Auth (email/password + Google OAuth) |
| **UsersModule** | module, service, controller, spec | Profile CRUD, password change, account deletion, OAuth connections |
| **BrandsModule** | module, service, controller, spec | Brand CRUD (cached, admin-managed) |
| **PlansModule** | module, service, controller, spec | Pricing plan CRUD (cached, admin-managed) |
| **SubscriptionsModule** | module, service, controller, spec | Create, activate, cancel, resume, extend subscriptions |
| **PaymentsModule** | module, service, controller, spec | Iyzico card payments, EFT with receipt upload, admin approval |
| **ReferralsModule** | module, service, controller, spec | Referral code generation, application, tier-based rewards |
| **VehiclesModule** | module, service, controller, spec | VIN decode (multi-source fallback), vehicle history, brand access check |
| **CategoriesModule** | module, service, controller, spec | Hierarchical category tree, schema pictures |
| **PartsModule** | module, service, controller, spec | Parts by category, OEM code search |
| **TranslationsModule** | module, service, controller, spec | Automotive term translation (Redis → DB → Dictionary fallback) |
| **AdminModule** | module, service, controller, spec | Dashboard stats, user management, payment approval, analytics |
| **AnalyticsModule** | module, service, controller | Usage analytics tracking |
| **EmailModule** | module, service | Postal transactional emails (password reset, welcome, payment confirmation) |
| **StorageModule** | module, service | S3/MinIO file upload/download |
| **RedisModule** | module, service, provider | Key-value cache operations |
### Common Infrastructure
| Type | Name | Behavior |
|------|------|----------|
| **Guard** | `AuthGuard` (global) | Validates Better Auth session; skip with `@Public()` |
| **Guard** | `RolesGuard` (global) | Checks `@Roles("admin")` metadata against `user.role` |
| **Guard** | `BrandAccessGuard` (per-route) | Verifies user's subscription includes the target brand |
| **Guard** | `ThrottlerGuard` (global) | Rate limiting (100/min default) |
| **Interceptor** | `TransformInterceptor` (global) | Wraps responses: `{success: true, data: ...}` |
| **Interceptor** | `LoggingInterceptor` (global) | Logs method, URL, status, response time |
| **Interceptor** | `TimeoutInterceptor` (global) | 30s request timeout |
| **Pipe** | `VinValidationPipe` (per-route) | Validates VIN: 17 chars, alphanumeric, no I/O/Q |
| **Filter** | `HttpExceptionFilter` (global) | Returns `{success: false, error: {code, message}}` |
| **Filter** | `DrizzleExceptionFilter` (global) | Catches unique constraint violations → 409 Conflict |
| **Middleware** | `FileUploadValidation` | PNG/JPG/PDF only, max 5MB |
**Custom Decorators:**
- `@Public()` — Skip authentication
- `@CurrentUser(field?)` — Inject authenticated user (or specific field)
- `@Roles(...roles)` — Require role(s)
- `@ThrottleAuth()` — 5 req/min
- `@ThrottleVinDecode()` — 20 req/min
- `@ThrottleGeneral()` — 100 req/min
**Shared DTOs:** `ApiResponseDto`, `PaginationDto`
### Integrations
**VIN Decode Fallback Chain:** Corgi (offline WMI) → PL24 API → EMEX Scraper → NHTSA VIN API
| Integration | Type | Path | Notes |
|-------------|------|------|-------|
| **Corgi** | Offline DB | `corgi/` | WMI database for brand identification (+ spec) |
| **PL24** | REST API | `pl24/` | Multi-brand catalog API + auth + parsers (BMW, Mercedes, Generic, Ford Legacy) |
| **EMEX** | Browser scraper | `emex/` | Playwright-based (emexdwc.ae), async via BullMQ. Files: service, browser, mapper, types |
| **VIN-API** | REST API | `vin-api/` | NHTSA VIN decoder (last-resort fallback) |
| **Iyzico** | Payment API | — | Turkish payment processor for card payments |
| **MinIO** | S3 API | — | Receipt uploads, schema images |
### Database Schema
**ORM:** Drizzle ORM 0.41 with PostgreSQL
**Schema files:** `apps/api/src/database/schema/`
- `core.ts` — Main application tables
- `emex.ts` — EMEX scraper cache tables
- `pl24.ts` — PL24 catalog cache tables
- `relations.ts` — Drizzle ORM relationships
#### Core Tables
```
users
├── id (uuid, PK)
├── name, email (unique), emailVerified, image
├── role (default: "user"), referralCode (unique), referredBy
└── createdAt, updatedAt
sessions / accounts / verifications
└── Better Auth managed tables
brands
├── id (uuid, PK), name, slug (unique), logoUrl, isActive
└── createdAt, updatedAt
plans
├── id (uuid, PK), name, brandCount
├── priceMonthly, priceYearly, isActive
└── createdAt, updatedAt
userSubscriptions
├── id (uuid, PK), userId → users, planId → plans
├── status (pending/active/cancelled/expired)
├── billingPeriod (monthly/yearly), startDate, endDate, cancelledAt
└── Indexes: userId, status
userBrands (junction)
├── userId → users, subscriptionId → userSubscriptions, brandId → brands
└── Unique: (userId, subscriptionId, brandId)
payments
├── id (uuid, PK), userId → users, subscriptionId → userSubscriptions
├── amount, currency, method (iyzico/eft), status (pending/completed/failed/refunded)
├── iyzicoPaymentId, eftReceiptUrl, adminNote
└── Indexes: userId, status
vehicles
├── id (uuid, PK), userId → users, vin, brandId → brands
├── brandName, model, year, engine, transmission, bodyType, market
├── rawData (jsonb), source
└── Indexes: userId, vin, unique(userId, vin)
categories
├── id (uuid, PK), vehicleId → vehicles, name, nameOriginal
├── parentId (self-ref), externalId, source
└── Indexes: vehicleId, parentId
parts
├── id (uuid, PK), vehicleId → vehicles, categoryId → categories
├── oemCode, name, nameOriginal, description, quantity, position, hotspotIndex
└── Indexes: vehicleId, categoryId, oemCode
schemaPics
├── id (uuid, PK), categoryId → categories
├── imageUrl, hotspots (jsonb), source
└── Indexes: categoryId
queryLogs
├── id (uuid, PK), userId → users, vin, brandId → brands
├── source, success, errorMessage, responseTimeMs
└── Indexes: (userId, createdAt), vin
referrals
├── id (uuid, PK), referrerId → users, referredId → users (unique)
├── rewardApplied, createdAt
└── Indexes: referrerId, referredId
passwordResetTokens
└── id, userId → users, token (unique), expiresAt, usedAt
emexCategoryTranslations
└── id, originalName (unique), translatedName, isManual
```
#### Integration Tables
- `pl24_*` — PL24 catalog cache (catalogs, vehicles, VINs, part groups, parts, schemas)
- `emex_*` — EMEX scraper cache (similar structure with translations)
### Job Queues
**Framework:** BullMQ with Redis
| Queue | Trigger | Schedule | Action |
|-------|---------|----------|--------|
| `EMEX_SCRAPE` | On-demand (VIN decode) | — | Scrapes EMEX via Playwright, stores results |
| `SUBSCRIPTION_EXPIRY` | Cron | Daily 3:00 AM | Expires ended subscriptions, removes brand access |
| `QUERY_CLEANUP` | Cron | Weekly Sun 4:00 AM | Cleans old query log entries |
**Files:** `apps/api/src/jobs/``jobs.module.ts`, `bull.config.ts`, `processors/`, `queues/`
### Telemetry
**Location:** `apps/api/src/telemetry/`
OpenTelemetry SDK with:
- `sdk-factory.ts` — SDK initialization
- `tracing.ts` — Distributed tracing (API)
- `worker-tracing.ts` — Worker process tracing
- `metrics.ts` — Prometheus metrics
- `__tests__/telemetry.spec.ts` — Tests
Instrumentation: Express, HTTP, ioredis, NestJS Core, BullMQ, Drizzle ORM
### API Endpoints
@@ -302,149 +475,18 @@ Flow:
**User model extensions:** `role` (default: "user"), `referralCode`, `referredBy`
**Frontend middleware** (`proxy.ts`): Checks session cookie, redirects `/dashboard/*` to `/login` if missing.
### Guards, Interceptors & Pipes
| Type | Name | Scope | Behavior |
|------|------|-------|----------|
| **Guard** | `AuthGuard` | Global | Validates Better Auth session; skip with `@Public()` |
| **Guard** | `RolesGuard` | Global | Checks `@Roles("admin")` metadata against `user.role` |
| **Guard** | `BrandAccessGuard` | Per-route | Verifies user's subscription includes the target brand |
| **Guard** | `ThrottlerGuard` | Global | Rate limiting (100/min default) |
| **Interceptor** | `TransformInterceptor` | Global | Wraps responses: `{success: true, data: ...}` |
| **Interceptor** | `LoggingInterceptor` | Global | Logs method, URL, status, response time |
| **Interceptor** | `TimeoutInterceptor` | Global | 30s request timeout |
| **Pipe** | `VinValidationPipe` | Per-route | Validates VIN: 17 chars, alphanumeric, no I/O/Q |
| **Filter** | `HttpExceptionFilter` | Global | Returns `{success: false, error: {code, message}}` |
| **Filter** | `DrizzleExceptionFilter` | Global | Catches unique constraint violations → 409 Conflict |
**Custom Decorators:**
- `@Public()` — Skip authentication
- `@CurrentUser(field?)` — Inject authenticated user (or specific field)
- `@Roles(...roles)` — Require role(s)
- `@ThrottleAuth()` — 5 req/min
- `@ThrottleVinDecode()` — 20 req/min
- `@ThrottleGeneral()` — 100 req/min
### Database Schema
**ORM:** Drizzle ORM 0.41 with PostgreSQL
#### Core Tables
```
users
├── id (uuid, PK)
├── name, email (unique), emailVerified, image
├── role (default: "user"), referralCode (unique), referredBy
└── createdAt, updatedAt
sessions / accounts / verifications
└── Better Auth managed tables
brands
├── id (uuid, PK), name, slug (unique), logoUrl, isActive
└── createdAt, updatedAt
plans
├── id (uuid, PK), name, brandCount
├── priceMonthly, priceYearly, isActive
└── createdAt, updatedAt
userSubscriptions
├── id (uuid, PK), userId → users, planId → plans
├── status (pending/active/cancelled/expired)
├── billingPeriod (monthly/yearly), startDate, endDate, cancelledAt
└── Indexes: userId, status
userBrands (junction)
├── userId → users, subscriptionId → userSubscriptions, brandId → brands
└── Unique: (userId, subscriptionId, brandId)
payments
├── id (uuid, PK), userId → users, subscriptionId → userSubscriptions
├── amount, currency, method (iyzico/eft), status (pending/completed/failed/refunded)
├── iyzicoPaymentId, eftReceiptUrl, adminNote
└── Indexes: userId, status
vehicles
├── id (uuid, PK), userId → users, vin, brandId → brands
├── brandName, model, year, engine, transmission, bodyType, market
├── rawData (jsonb), source
└── Indexes: userId, vin, unique(userId, vin)
categories
├── id (uuid, PK), vehicleId → vehicles, name, nameOriginal
├── parentId (self-ref), externalId, source
└── Indexes: vehicleId, parentId
parts
├── id (uuid, PK), vehicleId → vehicles, categoryId → categories
├── oemCode, name, nameOriginal, description, quantity, position, hotspotIndex
└── Indexes: vehicleId, categoryId, oemCode
schemaPics
├── id (uuid, PK), categoryId → categories
├── imageUrl, hotspots (jsonb), source
└── Indexes: categoryId
queryLogs
├── id (uuid, PK), userId → users, vin, brandId → brands
├── source, success, errorMessage, responseTimeMs
└── Indexes: (userId, createdAt), vin
referrals
├── id (uuid, PK), referrerId → users, referredId → users (unique)
├── rewardApplied, createdAt
└── Indexes: referrerId, referredId
passwordResetTokens
└── id, userId → users, token (unique), expiresAt, usedAt
emexCategoryTranslations
└── id, originalName (unique), translatedName, isManual
```
#### Integration Tables
- `pl24_*` — PL24 catalog cache (catalogs, vehicles, VINs, part groups, parts, schemas)
- `emex_*` — EMEX scraper cache (similar structure with translations)
### Job Queues
**Framework:** BullMQ with Redis
| Queue | Trigger | Schedule | Action |
|-------|---------|----------|--------|
| `EMEX_SCRAPE` | On-demand (VIN decode) | — | Scrapes EMEX via Puppeteer, stores results |
| `SUBSCRIPTION_EXPIRY` | Cron | Daily 3:00 AM | Expires ended subscriptions, removes brand access |
| `QUERY_CLEANUP` | Cron | Weekly Sun 4:00 AM | Cleans old query log entries |
### Integrations
**VIN Decode Fallback Chain:** Corgi (offline WMI) → PL24 API → EMEX Scraper → NHTSA VIN API
| Integration | Type | Caching | Notes |
|-------------|------|---------|-------|
| **Corgi** | Offline DB | — | WMI database for brand identification |
| **PL24** | REST API | 24h vehicles, 1h catalogs | Brand-specific parsers (BMW, Mercedes, generic) |
| **EMEX** | Browser scraper | Redis + DB | Puppeteer-based, async via BullMQ |
| **NHTSA VIN API** | REST API | — | Last-resort fallback |
| **Iyzico** | Payment API | — | Turkish payment processor for card payments |
| **MinIO** | S3 API | — | Receipt uploads, schema images |
---
## Frontend Web
### Routes & Pages
**Router:** TanStack Router (file-based, auto-generated route tree)
**Router:** TanStack Router (file-based, auto-generated route tree — 31 files)
#### Public
| Path | Route File | Description |
|------|------------|-------------|
| `/` | `routes/index.tsx` | Landing page with features and pricing CTA |
| `/` | `routes/index.tsx` | Landing page with VIN decode + features + pricing CTA |
| `/pricing` | `routes/pricing.tsx` | Plan comparison (1/2/3 brand, full package) |
| `/about` | `routes/about.tsx` | About page |
| `/contact` | `routes/contact.tsx` | Contact page |
@@ -483,6 +525,7 @@ emexCategoryTranslations
| `/dashboard/admin/payments` | `routes/dashboard/admin/payments.tsx` | EFT approval workflow |
| `/dashboard/admin/referrals` | `routes/dashboard/admin/referrals.tsx` | Referral program tracking |
| `/dashboard/admin/analytics` | `routes/dashboard/admin/analytics.tsx` | Daily query statistics |
| `/dashboard/admin/copy-logs` | `routes/dashboard/admin/copy-logs.tsx` | OEM code copy tracking |
### Components
@@ -503,31 +546,39 @@ emexCategoryTranslations
**UI primitives** from `@sase/ui`: Button, Card, Input, Label, Badge, Dialog, Tabs, Separator, Skeleton
### State Management
### Hooks & Stores
**Framework:** Zustand
**Hooks:**
| Hook | Purpose |
|------|---------|
| `useAuth()` | Auth state + Better Auth client (`user`, `isAdmin`, `signIn`, `signUp`, `signOut`, `session`) |
| `useCategoryParts(vehicleId, categoryId)` | TanStack Query for schema + parts + hotspots |
| `useSchemaInteraction()` | Pan/zoom/pinch event handlers for schema viewer |
| Store | File | State |
|-------|------|-------|
| **Auth** | `stores/auth.store.ts` | `user`, `isLoading` |
| **Schema** | `stores/schema.store.ts` | `highlightedPartId`, `selectedPartId`, `zoom`, `panX/Y`, `isFullscreen` |
**Stores (Zustand):**
| Store | State |
|-------|-------|
| `useAuthStore()` | `{ user: User | null, isLoading }` |
| `useSchemaStore()` | `{ highlightedGroup, selectedGroup, zoom, panX, panY, isFullscreen }` |
**i18n** (`lib/i18n.ts`): `locale` ("tr" / "en"), persisted to localStorage
### Data Fetching
**Client:** Custom `ApiClient` class (`lib/api-client.ts`)
**Data Fetching:** Custom `ApiClient` class (`lib/api-client.ts`)
- Base URL: `/api` (proxied via Vite dev server)
- Methods: `get<T>`, `post<T>`, `patch<T>`, `delete<T>`, `upload<T>`
- Credentials: cookies (automatic)
- Error handling: `ApiError` with code + status
- Caching: TanStack React Query 5 (`staleTime: 60s`, `retry: 1`, no refetch on focus)
**Caching:** TanStack React Query 5 (`staleTime: 60s`, `retry: 1`, no refetch on focus)
### Lib Utilities
**Key hooks:**
- `useAuth()` — Session + Zustand sync, exposes `user`, `isAdmin`, `signIn`, `signUp`, `signOut`
- `useCategoryParts()` — Fetches category schema, parts, hotspots
- `useSchemaInteraction()` — Mouse/touch events for zoom, pan, part selection
| File | Purpose |
|------|---------|
| `api-client.ts` | HTTP client wrapper with Faro error integration |
| `auth-client.ts` | Better Auth client (signIn, signUp, signOut, useSession) |
| `i18n.ts` | Zustand-based i18n (tr/en), `useTranslation()` hook |
| `posthog.ts` | PostHog analytics initialization and tracking |
| `faro.ts` | Grafana Faro frontend observability |
| `user-settings.ts` | Theme/settings persistence |
| `toast.ts` | Toast notification export |
| `category-icons.ts` | Category icon mappings |
### Internationalization
@@ -535,8 +586,8 @@ emexCategoryTranslations
| Locale | File | Coverage |
|--------|------|----------|
| Turkish (default) | `messages/tr.json` | Full (268 lines) |
| English | `messages/en.json` | Full (268 lines) |
| Turkish (default) | `messages/tr.json` | Full |
| English | `messages/en.json` | Full |
**Usage:** `const { t, locale, setLocale } = useTranslation()``t("nav.search")`
@@ -546,11 +597,53 @@ emexCategoryTranslations
## Shared Packages
| Package | Path | Contents |
|---------|------|----------|
| `@sase/shared` | `packages/shared/` | Zod schemas, TypeScript types, utility functions (formatters, currency, VIN validator) |
| `@sase/config` | `packages/config/` | Environment variable validation schemas (Zod) |
| `@sase/ui` | `packages/ui/` | Reusable React components (shadcn/ui based) |
### @sase/shared (`packages/shared/src/`)
| Directory | Exports |
|-----------|---------|
| `types/` | User, UserProfile, UserSubscriptionSummary, Vehicle, VinDecodeResult, CategoryNode, VehicleSource, Brand, Plan, Subscription, UserBrand, CreateSubscriptionInput, SubscriptionStatus, Payment, PaymentMethod, PaymentStatus, IyzicoInitializeInput, EftPaymentInput, Part, PartSource, PartSearchResult, Category, CategoryWithSchema, SchemaPic, Hotspot, ApiResponse, ApiError, PaginationMeta, PaginationInput, PaginatedResult |
| `schemas/` | loginSchema, registerSchema, forgotPasswordSchema, resetPasswordSchema, vinSchema, paginationSchema (Zod) |
| `constants/` | ERROR_CODES (30+, prefixed AUTH/VIN/SUB/PAY), PLANS (Single/Double/Triple/Full), REFERRAL_REWARDS (Tier 1: 3→7d, Tier 2: 5→30d), VIN_REGEX, EMAIL_REGEX, OEM_CODE_REGEX, CURRENCY |
| `utils/` | VIN validator (check digit, WMI extraction, model year decode), currency (formatTRY, kurus↔lira), formatters (VIN, date, datetime, Turkish slug, referral code) |
**Dependency:** `zod ^3.24.0`
### @sase/config (`packages/config/src/index.ts`)
Zod env schema exporting `envSchema`, `Env` type, `validateEnv()`.
Groups: DATABASE_URL, REDIS_*, BETTER_AUTH_*, GOOGLE_*, MINIO_*, CORS_ORIGIN, IYZICO_*, PL24_*, EMEX_*, ML_PREDICTION_ENABLED, POSTAL_*, OTEL_*
### @sase/ui (`packages/ui/src/`)
Components: Button (CVA variants), Input, Card (6 compound parts), Badge (CVA), Label, Skeleton, Separator, Dialog (10 compound parts), Tabs (4 compound parts), `cn()` utility
Dependencies: Radix UI (dialog, dropdown-menu, label, popover, select, separator, slot, tabs, tooltip), class-variance-authority, clsx, tailwind-merge, lucide-react
---
## Testing
### Unit Tests (Vitest 3)
| Category | Files | Total Lines |
|----------|-------|-------------|
| **Service specs** | 11 (admin, brands, categories, parts, payments, plans, referrals, subscriptions, translations, users, vehicles) | 2,521 |
| **Guard specs** | 3 (auth, roles, brand-access) | 380 |
| **Pipe specs** | 1 (vin-validation) | 77 |
| **Integration specs** | 1 (corgi) | 121 |
| **Telemetry specs** | 1 | 97 |
| **Total API** | **17 test files** | **3,196 lines** |
**Test Config:**
- API: `apps/api/vitest.config.ts``src/**/*.spec.ts`, v8 coverage (text + lcov)
- Web: `apps/web/vitest.config.ts``src/**/*.{test,spec}.{ts,tsx}`, jsdom, `@/` alias, test-setup (jest-dom)
**Test Pattern:** Mock Drizzle DB with chainable query builder, `vi.mock()` for external deps
### Playwright 1.50
- Installed at root level (`package.json`)
- Used by EMEX integration (`emex.browser.ts`) for web scraping
- Test scripts in `scripts/` (vin-e2e-test.js)
---
@@ -560,19 +653,24 @@ emexCategoryTranslations
| Service | Image | Port | Volume |
|---------|-------|------|--------|
| PostgreSQL 17 | `postgres:17-alpine` | 5432 | `pg_data` |
| Redis 7.4 | `redis:7.4-alpine` | 6379 | `redis_data` |
| PostgreSQL 17 | `postgres:17-alpine` | 127.0.0.1:5432 | `pg_data` |
| Redis 7.4 | `redis:7.4-alpine` | 127.0.0.1:6379 | `redis_data` |
| MinIO | `minio/minio` | 9000 (API), 9001 (Console) | `minio_data` |
### Nginx (`docker/nginx/sites/`)
- `sase.tr.conf` — Frontend SPA + `/api` proxy + `/collect/` Faro telemetry CORS proxy + gzip (level 6) + 1-year asset cache + security headers
- `api.sase.tr.conf` — NestJS proxy (60s timeout for VIN decode) + SSL + blocked paths (.git, .env, node_modules)
### CI/CD (GitHub Actions)
**`ci.yml`** — Runs on all branches & PRs to main:
**`ci.yml`** — Runs on all branches & PRs to main (15min timeout):
1. Biome lint
2. TypeScript type check
3. Vitest unit tests
4. Full build
**`deploy.yml`** — Runs on push to `main`:
**`deploy.yml`** — Runs on push to `main` (10min timeout):
1. SSH into production
2. `git pull origin main`
3. `pnpm install`
@@ -582,11 +680,11 @@ emexCategoryTranslations
### PM2 Configuration
| Process | Instances | Mode | Port | Memory |
|---------|-----------|------|------|--------|
| `sase-api` | 1 | fork | 4000 | 512MB |
| `sase-web` | 1 | fork | 3000 | 512MB |
| `sase-worker` | 1 | fork | — | 256MB |
| Process | Command | Port | Memory |
|---------|---------|------|--------|
| `sase-api` | `pnpm dev` (dev) / `dist/main.js` (prod) | 4000 | 512MB |
| `sase-web` | `pnpm dev` (dev) / serve dist (prod) | 3000 | 512MB |
| `sase-worker` | `dist/worker.js` | — | 256MB |
---
@@ -604,14 +702,14 @@ emexCategoryTranslations
| `MINIO_ACCESS_KEY` | MinIO access key |
| `MINIO_SECRET_KEY` | MinIO secret key |
| `MINIO_PUBLIC_URL` | Public URL for stored files |
| `CORS_ORIGIN` | Comma-separated allowed origins |
| `CORS_ORIGIN` | Allowed origins |
### Optional
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | 4000 | API server port |
| `REDIS_HOST` | localhost | Redis host |
| `REDIS_HOST` | 127.0.0.1 | Redis host |
| `REDIS_PORT` | 6379 | Redis port |
| `MINIO_BUCKET_NAME` | sase-schemas | Storage bucket |
| `MINIO_USE_SSL` | false | HTTPS for MinIO |
@@ -619,67 +717,94 @@ emexCategoryTranslations
| `GOOGLE_CLIENT_SECRET` | — | Google OAuth secret |
| `IYZICO_API_KEY` | — | Iyzico payment API key |
| `IYZICO_SECRET_KEY` | — | Iyzico payment secret |
| `PL24_API_URL` | — | PL24 catalog API URL |
| `IYZICO_BASE_URL` | — | Iyzico API base URL |
| `PL24_BASE_URL` | — | PL24 catalog API URL |
| `PL24_COMPANY_CODE` | — | PL24 company code |
| `PL24_USERNAME` | — | PL24 credentials |
| `PL24_PASSWORD` | — | PL24 credentials |
| `EMEX_USERNAME` | — | EMEX scraper credentials |
| `EMEX_PASSWORD` | — | EMEX scraper credentials |
| `ML_PREDICTION_ENABLED` | false | Enable ML predictions |
| `POSTAL_API_URL` | — | Postal email service URL |
| `POSTAL_API_KEY` | — | Postal API key |
| `POSTAL_FROM_ADDRESS` | noreply@sase.tr | Sender email |
| `POSTAL_FROM_NAME` | Sase.tr | Sender name |
| `OTEL_ENABLED` | false | Enable OpenTelemetry |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | — | OTLP collector endpoint |
| `OTEL_EXPORTER_OTLP_HEADERS` | — | OTLP auth headers |
| `OTEL_SERVICE_NAME` | sase-api | OTel service name |
| `OTEL_TRACE_SAMPLE_RATE` | 1.0 | Trace sampling rate (0-1) |
Full schema: `packages/config/src/index.ts`
---
## Testing
**Framework:** Vitest 3 (all apps)
| App | Config | Location | Count |
|-----|--------|----------|-------|
| API | `apps/api/vitest.config.ts` | `src/**/*.spec.ts` | ~19 spec files |
| Web | `apps/web/vitest.config.ts` | `src/**/*.test.ts` | Store tests + component tests |
| E2E | Playwright | `apps/web/` | Browser tests |
**Coverage:** V8 provider, lcov + text reporters
### Test Categories (API)
- **Service tests:** admin, categories, translations, payments, vehicles, subscriptions, plans, brands, users, referrals, corgi integration
- **Guard tests:** auth, roles, brand-access
- **Pipe tests:** VIN validation
---
## Scripts & Commands
## Key Commands
```bash
# Development
pnpm dev # Start all apps in dev mode (Turbo)
pnpm dev --filter=api # Start API only
pnpm dev --filter=web # Start web only
pnpm dev # Start all apps (Turbo)
pnpm dev --filter=api # API only
pnpm dev --filter=web # Web only
# Build
pnpm build # Build all packages + apps
# Build & Quality
pnpm build # Build all packages + apps
pnpm lint # Biome lint check
pnpm typecheck # TypeScript --noEmit
pnpm test # Run all tests (Vitest)
# Testing
pnpm test # Run all tests (Vitest)
pnpm test:watch # Watch mode
# Database (run from apps/api/)
pnpm db:push # Push schema to DB (Drizzle)
pnpm db:studio # Open Drizzle Studio
pnpm db:seed # Seed database
pnpm db:generate # Generate migration
# Linting
pnpm lint # Biome lint check
pnpm format # Biome format
# Database
pnpm db:push # Push schema to database (Drizzle)
pnpm db:studio # Open Drizzle Studio
# Type Checking
pnpm typecheck # TypeScript --noEmit
# Route generation
pnpm --filter web exec tsr generate
# Production
pm2 start ecosystem.config.js # Start all processes
pm2 reload all # Zero-downtime reload
pm2 start ecosystem.config.js
pm2 reload all
```
---
## Key Dependencies
| Package | Version | Purpose |
|---------|---------|---------|
| NestJS | 10.4 | Backend framework |
| Drizzle ORM | 0.41 | Database ORM |
| Better Auth | 1.2 | Cookie-based auth |
| React | 19 | Frontend UI |
| TanStack Router | 1.120 | File-based routing |
| TanStack Query | 5 | Server state management |
| Zustand | 5 | Client state management |
| Tailwind CSS | 4 | Styling |
| Vite | 6.3 | Build tool |
| Vitest | 3.x | Unit testing |
| Playwright | 1.50 | Browser automation |
| BullMQ | 5.30 | Job queues |
| Biome | latest | Linting/formatting |
| Turborepo | 2.x | Monorepo orchestration |
| OpenTelemetry | 0.212 | Backend observability |
| Grafana Faro | 2.2 | Frontend observability |
| PostHog | latest | Product analytics |
---
## Quick Start
1. `docker compose -f docker/docker-compose.yml up -d` — Start PostgreSQL, Redis, MinIO
2. `cp apps/api/.env.example apps/api/.env` — Configure env vars
3. `pnpm install` — Install dependencies
4. `pnpm --filter api db:push && pnpm --filter api db:seed` — Setup database
5. `pnpm dev` — Start all services
6. Open `http://localhost:3000` — Frontend
7. Admin login: `admin@sase.tr` / `Sase2026`
---
## Pricing Model
| Plan | Brands | Monthly | Yearly |
@@ -695,4 +820,11 @@ pm2 reload all # Zero-downtime reload
---
*Generated: 2026-02-15 | Branch: v2*
## Documentation
| File | Topic |
|------|-------|
| `docs/INDEX.md` | This file — comprehensive project reference |
| `CLAUDE.md` | Claude Code project guide |
| `.claude/product-marketing-context.md` | Marketing context |
| `docs/00-overview.md``docs/13-analytics-posthog.md` | Detailed topic docs |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 332 KiB