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:
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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?{" "}
|
||||
|
||||
@@ -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>
|
||||
|
||||
652
docs/INDEX.md
652
docs/INDEX.md
@@ -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 |
Reference in New Issue
Block a user