13 Commits

Author SHA1 Message Date
c20fc5462a fix(build): per-commit cache-bust so web-only changes actually deploy
Auto-deploys (docker build with cache) were reusing a stale build layer, so
web-only commits shipped an old bundle — the switcher removal (#216) and the
canonical fix (#217) only appeared after a manual --no-cache/force rebuild.
Reference SOURCE_COMMIT (substituted by Coolify) right before `COPY . .`, so the
build layer's cache key changes every commit and COPY + `pnpm build` re-run. The
deps stage stays cached on the lockfile, so installs aren't repeated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 07:43:05 +03:00
2bf7525fe2 Merge pull request 'fix(catalog): Fiat Birleşik = canonical view (not raw pcat)' (#217) from feat/fiat-birlesik-canonical into dev
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
2026-07-14 07:11:53 +03:00
bf49900b57 fix(catalog): Fiat "Birleşik" stays the canonical view, not the raw pcat tree
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Correction to #216: "Birleşik" must fold the catalog into OUR canonical 20-bucket
structure (pcat + eper), not show parts-catalogs' raw tree. Only "Orijinal
Katalog" swaps to the native ePER tree for Fiat.

- treeSource → eper-pekidi for Fiat (Orijinal); canonical tab renders CanonicalView
- reverts #216's "canonical tab → pcat source tree"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 07:11:50 +03:00
ff96dcac1c Merge pull request 'feat(catalog): Fiat tabs → eper/pcat, drop extra switcher' (#216) from feat/fiat-eper-simplify into dev 2026-07-14 06:56:14 +03:00
5a3e1a2299 feat(catalog): map the two Fiat catalog tabs onto eper/pcat, drop the extra switcher
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Simplify the previous change: instead of a third source-switcher pill, reuse the
two tabs that are already there. For an eper (Fiat) vehicle:
  "Orijinal Katalog" → the native ePER tree (default)
  "Birleşik"         → parts-catalogs
Non-eper vehicles keep the original source/canonical tabs untouched.

- web: derive treeSource from browseMode when the vehicle has eper; canonical
  tab renders the pcat source tree (not CanonicalView) for eper vehicles
- removes the ePER/Diğer switcher, SOURCE_LABELS, and the activeSource state
Net −27 lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 06:56:12 +03:00
24495d8970 Merge pull request 'feat(catalog): eper as default Fiat catalog source (pcat switchable)' (#215) from feat/fiat-eper-default into dev 2026-07-14 06:42:06 +03:00
de960a0dd4 feat(catalog): eper as the default Fiat catalog source, pcat one tap away
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Fiat dealers know the native ePER tree; parts-catalogs' broader-but-lazy folders
are where they hit gaps (a motor OEM missing under "Motor" — the reason we
crawled pekidi ePER). So for a multi-source decoded vehicle, default the
"Orijinal Katalog" view to eper-pekidi — fully populated with parts + diagrams —
instead of showing every source's roots mixed together.

- categories.service.getCategoryTree(vehicleId, source?): prefer eper-pekidi when
  present; ?source= overrides; cache keyed per source
- categories: getCatalogSources(vehicleId) → sources present, default (eper) first
- controller: tree/:id/sources endpoint + ?source query
- web: source switcher pill (ePER ⇄ Diğer) in source mode when >1 source;
  defaults to the first (eper). pcat never hidden, just one tap away.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 06:42:04 +03:00
cee11b5769 Merge pull request 'fix(vinpin): reject stale-breadcrumb decodes + harden matcher' (#214) from fix/vinpin-false-positive into dev 2026-07-13 12:09:49 +03:00
70d901f9f5 fix(vinpin): reject stale-breadcrumb decodes (require SINCOM) + harden matcher
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
A failed ePER VIN lookup ("vehicle not found") whose Cyrillic "не найден" alert
OCRs to garbage ("He HaaeHs") slipped the not-found check; the parser then
grabbed the operator's last-browsed catalog from the breadcrumb
("FIAT » TIPO-EGEA") and false-mapped it to a catalog — NM4131 landed on the
ancient "Tipo 1100-1370-1580 (1987-1993)", plus 2× PANDA. Every genuine decode
carries a SINCOM; the breadcrumb false positives never do.

- parser: isUsableParse now requires a SINCOM (a model alone is not a decode)
- matcher: modelTokens keeps 4-digit engine displacements (1100/1370/1580) —
  only 1950-2039 count as years — so ancient catalogs no longer collapse to
  ["TIPO"] and win the fewest-extra-tokens tiebreak
- matcher: recency tiebreak on equal score — never fall back to an ancient
  generation for a bare / year-less model

15 unit tests (parser + matcher) pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:08:40 +03:00
731ac93a55 Merge pull request 'public-api: katalog gezinme (VIN'siz araç ağacı) endpoint'leri /v1/catalog/* — marka→model→motor→kategori, API-key auth' (#212) from dev into main
Reviewed-on: #212
2026-07-10 12:44:09 +03:00
oyp
0e8b2ab294 public-api: katalog gezinme (VIN'siz araç ağacı) endpoint'leri /v1/catalog/* — marka→model→motor→kategori, API-key auth
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:27:36 +03:00
814b798b3f Merge pull request 'feat(internal-admin): extend limiti 90 → 3650 gün' (#211) from fix/extend-days-cap into main
Reviewed-on: #211
2026-07-09 11:27:23 +03:00
c51b7b4e78 feat(internal-admin): extend limiti 90 → 3650 gün
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Panelin 'Bonus süre ekle (goodwill)' aksiyonu 360 gün gibi süreler
gerektiriyor; 90 gün sınırı iş ihtiyacını karşılamıyordu. Üst sınır
3650 (10 yıl) parmak-kayması koruması olarak kalıyor. Panel tarafı
(sp) aynı limite güncellendi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 10:35:28 +03:00
13 changed files with 307 additions and 32 deletions

View File

@@ -21,6 +21,14 @@ COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=deps /app/packages/config/node_modules ./packages/config/node_modules COPY --from=deps /app/packages/config/node_modules ./packages/config/node_modules
COPY --from=deps /app/packages/ui/node_modules ./packages/ui/node_modules COPY --from=deps /app/packages/ui/node_modules ./packages/ui/node_modules
# Cache-bust: web-only commits were sticking to a cached build layer, so
# auto-deploys shipped a stale bundle (only --no-cache/force rebuilds picked
# them up). Referencing the commit SHA *before* the source COPY makes this layer
# (and therefore COPY + `pnpm build`) invalidate on every commit; the deps stage
# above stays cached on the lockfile, so installs aren't repeated.
ARG SOURCE_COMMIT=unknown
RUN echo "Building commit ${SOURCE_COMMIT}"
COPY . . COPY . .
# Vite env vars (baked at build time) # Vite env vars (baked at build time)

View File

@@ -5,9 +5,19 @@ import { CategoriesService } from "./categories.service";
export class CategoriesController { export class CategoriesController {
constructor(private categoriesService: CategoriesService) {} constructor(private categoriesService: CategoriesService) {}
// Sources this vehicle has (default/eper first) — drives the source switcher.
// Declared before "tree/:vehicleId" so the literal segment wins the match.
@Get("tree/:vehicleId/sources")
async getCatalogSources(@Param("vehicleId") vehicleId: string) {
return this.categoriesService.getCatalogSources(vehicleId);
}
@Get("tree/:vehicleId") @Get("tree/:vehicleId")
async getCategoryTree(@Param("vehicleId") vehicleId: string) { async getCategoryTree(
return this.categoriesService.getCategoryTree(vehicleId); @Param("vehicleId") vehicleId: string,
@Query("source") source?: string,
) {
return this.categoriesService.getCategoryTree(vehicleId, source);
} }
@Get("search/:vehicleId") @Get("search/:vehicleId")

View File

@@ -50,8 +50,8 @@ export class CategoriesService {
private emexSourceDb: EmexSourceDbService, private emexSourceDb: EmexSourceDbService,
) {} ) {}
async getCategoryTree(vehicleId: string) { async getCategoryTree(vehicleId: string, sourceFilter?: string) {
const cacheKey = `cat:tree:${vehicleId}`; const cacheKey = `cat:tree:${vehicleId}${sourceFilter ? `:${sourceFilter}` : ""}`;
const cached = await this.redis.getJson<any[]>(cacheKey); const cached = await this.redis.getJson<any[]>(cacheKey);
if (cached) return cached; if (cached) return cached;
@@ -457,8 +457,23 @@ export class CategoriesService {
partCountRows.map((r) => [r.categoryId, r.count]), partCountRows.map((r) => [r.categoryId, r.count]),
); );
// Default catalog source for a multi-source vehicle: prefer eper-pekidi — the
// native OEM ePER tree Fiat dealers already know, fully populated with parts +
// diagrams — over parts-catalogs' broader-but-lazy (drill-on-demand) folders,
// which is where dealers hit gaps (a motor OEM missing under "Motor"). pcat is
// never dropped: the ?source override + /sources endpoint keep it one tap away.
const presentSources = [...new Set(dbCategories.map((c) => c.source))];
const activeSource =
sourceFilter && presentSources.includes(sourceFilter)
? sourceFilter
: presentSources.includes("eper-pekidi")
? "eper-pekidi"
: null;
const forTree = activeSource
? dbCategories.filter((c) => c.source === activeSource)
: dbCategories;
// Build tree // Build tree
const tree = this.buildTree(dbCategories, directPartCounts); const tree = this.buildTree(forTree, directPartCounts);
// Cache a populated tree for an hour; an EMPTY tree (transient decode/proxy // Cache a populated tree for an hour; an EMPTY tree (transient decode/proxy
// failure) only for 60s so a blip doesn't poison the catalog for an hour — // failure) only for 60s so a blip doesn't poison the catalog for an hour —
// it self-heals on the next request after the source recovers, while still // it self-heals on the next request after the source recovers, while still
@@ -480,6 +495,25 @@ export class CategoriesService {
return tree; return tree;
} }
/**
* The catalog sources a decoded vehicle has categories from, ordered with the
* default first (eper-pekidi — the dealer-familiar native ePER catalog). Drives
* the per-vehicle source switcher (ePER ⇄ Diğer); a single source → no switcher.
*/
async getCatalogSources(vehicleId: string): Promise<string[]> {
const rows = await this.db
.selectDistinct({ source: categories.source })
.from(categories)
.where(eq(categories.vehicleId, vehicleId));
const present = rows.map((r) => r.source);
const order = ["eper-pekidi", "parts-catalogs", "emex", "pl24"];
return present.sort((a, b) => {
const ia = order.indexOf(a);
const ib = order.indexOf(b);
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
});
}
/** /**
* The unified canonical taxonomy (20 top buckets), source/brand-independent. * The unified canonical taxonomy (20 top buckets), source/brand-independent.
* Used for the browse landing + as the fixed set of headings every vehicle's * Used for the browse landing + as the fixed set of headings every vehicle's

View File

@@ -12,6 +12,18 @@ describe("modelTokens", () => {
expect(modelTokens("Tipo-Egea MCA (2020-....)")).toEqual(["TIPO", "EGEA", "MCA"]); expect(modelTokens("Tipo-Egea MCA (2020-....)")).toEqual(["TIPO", "EGEA", "MCA"]);
expect(modelTokens("DOBLO REST. 2005 (2005-2016)")).toEqual(["DOBLO", "REST"]); expect(modelTokens("DOBLO REST. 2005 (2005-2016)")).toEqual(["DOBLO", "REST"]);
}); });
it("keeps engine displacements as tokens (only 1950-2039 count as years)", () => {
// The 4-digit engine sizes must survive — dropping them made the ancient
// "TIPO 1100-1370-1580" collapse to just ["TIPO"] and win the tiebreak.
expect(modelTokens("TIPO 1100-1370-1580 (1987-1993)")).toEqual([
"TIPO",
"1100",
"1370",
"1580",
]);
expect(modelTokens("TIPO 1750-2000 (1990-1993)")).toEqual(["TIPO", "1750"]); // 2000 = year-range
});
}); });
describe("parseYearRange", () => { describe("parseYearRange", () => {
@@ -52,4 +64,25 @@ describe("pickBestCatalogMatch", () => {
it("returns null for an empty model", () => { it("returns null for an empty model", () => {
expect(pickBestCatalogMatch({ model: null, modelYear: "2018" }, candidates)).toBeNull(); expect(pickBestCatalogMatch({ model: null, modelYear: "2018" }, candidates)).toBeNull();
}); });
it("never falls back onto an ancient catalog for a bare, year-less model", () => {
const withAncient: CatalogCandidate[] = [
...candidates,
{ id: "tipo-1987", model: "TIPO 1100-1370-1580 (1987-1993)", year: "1987-1993" },
];
// Bare "TIPO", no year (the exact shape of the NM4131 false positive that
// used to land on the 1987 Tipo). Must resolve to a modern Egea instead.
const id = pickBestCatalogMatch({ model: "TIPO", modelYear: null }, withAncient);
expect(id).not.toBe("tipo-1987");
expect(["egea", "egea-mca"]).toContain(id);
});
it("breaks a score tie toward the newer catalog", () => {
const puntos: CatalogCandidate[] = [
{ id: "punto-old", model: "PUNTO (1993-1999)", year: "1993-1999" },
{ id: "punto-new", model: "PUNTO (2012-2018)", year: "2012-2018" },
];
// Identical tokens + no year → equal score → recency decides.
expect(pickBestCatalogMatch({ model: "PUNTO", modelYear: null }, puntos)).toBe("punto-new");
});
}); });

View File

@@ -26,14 +26,18 @@ export interface DecodedForMatch {
} }
/** Tokenize a model string into upper-case model tokens, dropping parenthetical /** Tokenize a model string into upper-case model tokens, dropping parenthetical
* groups (year ranges) and bare 4-digit years. "TIPO - EGEA (2015-2021)" → * groups (year ranges) and bare 4-digit *years*. "TIPO - EGEA (2015-2021)" →
* ["TIPO","EGEA"]. Keeps "500" / "500X" (not 4-digit years). */ * ["TIPO","EGEA"]. Keeps "500"/"500X" AND engine displacements like 1100 / 1370
* / 1580 / 2400 — only 1950-2039 (plausible car years) are treated as years.
* Dropping displacements made ancient catalogs ("TIPO 1100-1370-1580") collapse
* to just ["TIPO"], looking falsely specific and winning the fewest-extra-tokens
* tiebreak over the modern Egea. */
export function modelTokens(s: string | null | undefined): string[] { export function modelTokens(s: string | null | undefined): string[] {
if (!s) return []; if (!s) return [];
return s return s
.toUpperCase() .toUpperCase()
.replace(/\([^)]*\)/g, " ") // drop parenthetical year ranges .replace(/\([^)]*\)/g, " ") // drop parenthetical year ranges
.replace(/\b\d{4}\b/g, " ") // drop bare years (keeps 3-digit "500") .replace(/\b(19[5-9]\d|20[0-3]\d)\b/g, " ") // drop bare YEARS 1950-2039; keep displacements
.replace(/[^A-Z0-9]+/g, " ") .replace(/[^A-Z0-9]+/g, " ")
.split(" ") .split(" ")
.map((t) => t.trim()) .map((t) => t.trim())
@@ -81,7 +85,7 @@ export function pickBestCatalogMatch(
if (decTokens.length === 0) return null; if (decTokens.length === 0) return null;
const decYear = decoded.modelYear ? Number.parseInt(decoded.modelYear, 10) : null; const decYear = decoded.modelYear ? Number.parseInt(decoded.modelYear, 10) : null;
let best: { id: string; score: number } | null = null; let best: { id: string; score: number; startYear: number } | null = null;
for (const cand of candidates) { for (const cand of candidates) {
const candText = `${cand.model ?? ""} ${cand.year ?? ""}`; const candText = `${cand.model ?? ""} ${cand.year ?? ""}`;
@@ -100,8 +104,12 @@ export function pickBestCatalogMatch(
const extra = candTokens.filter((t) => !decTokens.includes(t)).length; const extra = candTokens.filter((t) => !decTokens.includes(t)).length;
score -= extra; score -= extra;
if (!best || score > best.score) { // Final tiebreak on equal score: prefer the newer catalog. Without a year
best = { id: cand.id, score }; // signal an ambiguous model (e.g. bare "TIPO") must never fall back onto an
// ancient generation — modern is the overwhelmingly likelier intent.
const startYear = range?.start ?? 0;
if (!best || score > best.score || (score === best.score && startYear > best.startYear)) {
best = { id: cand.id, score, startYear };
} }
} }

View File

@@ -35,4 +35,18 @@ describe("parseVinpinModal", () => {
expect(isUsableParse(p)).toBe(false); expect(isUsableParse(p)).toBe(false);
expect(isUsableParse(parseVinpinModal(null))).toBe(false); expect(isUsableParse(parseVinpinModal(null))).toBe(false);
}); });
it("rejects a stale-breadcrumb model with no SINCOM (vehicle-not-found false positive)", () => {
// Real garbled OCR of a failed NM4131 decode: the "не найден" alert is
// mangled ("He HaaeHs") so it slips the not-found check, and the screen
// still shows the operator's last catalog ("FIAT » TIPO - EGEA"). A model
// token is present but there is NO SINCOM → must be treated as unusable so
// it is not false-mapped onto the ancient "TIPO 1987-1993" catalog.
const p = parseVinpinModal(
"Fiat Dealer Spare Parts FIAT TIPO - EGBA 500 HYBRID TIPO-EGEA MCA (2020) DOBLO FREEMONT LINEA IDEA To yKasaHHeIM N3paMETpaM 3BTOMOBHMN He HaaeHs",
);
expect(p.model).not.toBeNull(); // breadcrumb model still extracted…
expect(p.sincom).toBeNull(); // …but no SINCOM
expect(isUsableParse(p)).toBe(false); // → not a real decode
});
}); });

View File

@@ -91,7 +91,18 @@ export function parseVinpinModal(rawText: string | null | undefined): VinpinPars
return { model, sincom, trim, modelYear, engine }; return { model, sincom, trim, modelYear, engine };
} }
/** A parse is usable if we got at least a model or a SINCOM code. */ /**
* A parse is usable only when we got a SINCOM code — the trim identifier that a
* genuine ePER decode always emits ("356G37001162"). A model *alone* is a stale
* breadcrumb: when the VIN search returns "vehicle not found" (a TR NM4 VIN ePER
* can't resolve), the screen still shows whatever catalog the operator was last
* browsing (e.g. "FIAT » TIPO - EGEA"), and the OCR of the Cyrillic "не найден"
* alert garbles enough to slip past the not-found check. Grabbing that
* breadcrumb model ("TIPO"/"PANDA") and matching it to a catalog is a false
* positive that lands users on the wrong (often ancient) catalog. Requiring the
* SINCOM cleanly separates the two: every genuine decode carries one; the
* breadcrumb false positives never do.
*/
export function isUsableParse(p: VinpinParsed): boolean { export function isUsableParse(p: VinpinParsed): boolean {
return Boolean(p.model || p.sincom); return Boolean(p.sincom);
} }

View File

@@ -31,8 +31,8 @@ export class BillingService {
reason: string; reason: string;
founderId: string; founderId: string;
}) { }) {
if (!Number.isFinite(input.days) || input.days <= 0 || input.days > 90) { if (!Number.isFinite(input.days) || input.days <= 0 || input.days > 3650) {
throw new BadRequestException("days must be 1..90"); throw new BadRequestException("days must be 1..3650");
} }
const [sub] = await this.db const [sub] = await this.db
.select() .select()

View File

@@ -21,6 +21,7 @@ import {
ApiTags, ApiTags,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import type { Response } from "express"; import type { Response } from "express";
import { CatalogService } from "../catalog/catalog.service";
import { CategoriesService } from "../categories/categories.service"; import { CategoriesService } from "../categories/categories.service";
import { Public } from "../common/decorators/public.decorator"; import { Public } from "../common/decorators/public.decorator";
import { ApiKeyGuard, type VerifiedApiKey } from "../common/guards/api-key.guard"; import { ApiKeyGuard, type VerifiedApiKey } from "../common/guards/api-key.guard";
@@ -55,6 +56,7 @@ export class PublicApiController {
constructor( constructor(
private readonly vehicles: VehiclesService, private readonly vehicles: VehiclesService,
private readonly categories: CategoriesService, private readonly categories: CategoriesService,
private readonly catalog: CatalogService,
private readonly p: PSourceDbService, private readonly p: PSourceDbService,
private readonly quota: PublicApiQuotaService, private readonly quota: PublicApiQuotaService,
) {} ) {}
@@ -312,6 +314,149 @@ export class PublicApiController {
return this.categories.searchCatalog(vehicleId, q ?? ""); return this.categories.searchCatalog(vehicleId, q ?? "");
} }
// ==========================================================================
// Katalog gezinme (VIN'siz araç ağacı). VIN decode ile AYNI kategori/parça
// akışına ulaşır; fark: araç, marka→model→(kasa/motor/şanzıman) seçimiyle
// belirlenir. Marka erişimi API-anahtarı sahibinin (user.id) aboneliğine
// bağlıdır. Gezinme kotadan düşmez (yalnız başarılı VIN identification düşer).
// ==========================================================================
/** Katalog markaları (anahtar sahibinin erişebildiği). */
@Get("catalog/brands")
@ApiTags("Katalog Gezinme")
@ApiOperation({ summary: "Markalar", description: "Araç ağacı wizard'ı için marka listesi." })
async catalogBrands(@Req() req: ApiKeyRequest) {
return this.catalog.getBrands(req.user.id);
}
/** Bir markanın modelleri (araçları). */
@Get("catalog/brands/:brandName/models")
@ApiTags("Katalog Gezinme")
@ApiOperation({ summary: "Modeller", description: "Seçilen markanın model/araç listesi." })
@ApiParam({ name: "brandName", example: "Fiat" })
@ApiQuery({ name: "service", required: false, description: "Katalog servis adı (opsiyonel filtre)" })
async catalogModels(
@Req() req: ApiKeyRequest,
@Param("brandName") brandName: string,
@Query("service") service?: string,
) {
return this.catalog.getModels(brandName, req.user.id, service);
}
/** Araç detayı (mimari + seçim adımları için meta). */
@Get("catalog/vehicles/:id")
@ApiTags("Katalog Gezinme")
@ApiOperation({ summary: "Araç detayı" })
@ApiParam({ name: "id", format: "uuid" })
async catalogVehicle(@Req() req: ApiKeyRequest, @Param("id") id: string) {
return this.catalog.getVehicle(id, req.user.id);
}
/** Ford model konfigürasyonu (Ford mimarisi). */
@Get("catalog/vehicles/:id/ford-config")
@ApiTags("Katalog Gezinme")
async catalogFordConfig(@Req() req: ApiKeyRequest, @Param("id") id: string) {
return this.catalog.getFordModelConfig(id, req.user.id);
}
/** PSA kasa seçenekleri. */
@Get("catalog/vehicles/:id/psa-bodies")
@ApiTags("Katalog Gezinme")
async catalogPsaBodies(@Req() req: ApiKeyRequest, @Param("id") id: string) {
return this.catalog.getPsaBodies(id, req.user.id);
}
/** PSA motor seçenekleri (kasa seçimine bağlı). */
@Get("catalog/vehicles/:id/psa-engines")
@ApiTags("Katalog Gezinme")
@ApiQuery({ name: "body", description: "Seçilen kasa" })
async catalogPsaEngines(
@Req() req: ApiKeyRequest,
@Param("id") id: string,
@Query("body") body: string,
) {
return this.catalog.getPsaEngines(id, body, req.user.id);
}
/** PSA şanzıman seçenekleri (kasa + motor seçimine bağlı). */
@Get("catalog/vehicles/:id/psa-gearboxes")
@ApiTags("Katalog Gezinme")
@ApiQuery({ name: "body" })
@ApiQuery({ name: "engine" })
async catalogPsaGearboxes(
@Req() req: ApiKeyRequest,
@Param("id") id: string,
@Query("body") body: string,
@Query("engine") engine: string,
) {
return this.catalog.getPsaGearboxes(id, body, engine, req.user.id);
}
/**
* Seçilen aracın kategori ağacı (birleşik/kanonik). Public v1 kontratıyla
* aynı şekil ({ canonical, partCount, categories[], subgroups[] }); iç
* alanlar (source vb.) sanitize edilir. Kasa/motor/şanzıman gerektiren
* mimarilerde query ile verilir.
*/
@Get("catalog/vehicles/:id/categories")
@ApiTags("Katalog Gezinme")
@ApiOperation({ summary: "Kategori ağacı (araç seçiminden)" })
@ApiQuery({ name: "body", required: false })
@ApiQuery({ name: "engine", required: false })
@ApiQuery({ name: "gearbox", required: false })
@ApiQuery({ name: "mgp", required: false, description: "mainGroupsPath" })
async catalogCategoryTree(
@Req() req: ApiKeyRequest,
@Param("id") id: string,
@Query("body") body?: string,
@Query("engine") engine?: string,
@Query("gearbox") gearbox?: string,
@Query("mgp") mgp?: string,
) {
return sanitizeCanonicalTree(
(await this.catalog.getCanonicalTree(id, req.user.id, body, engine, gearbox, mgp)) as never,
);
}
/** Seçilen aracın kategori detayı + parçaları (OEM kodlarıyla). */
@Get("catalog/vehicles/:id/categories/:categoryId")
@ApiTags("Katalog Gezinme")
@ApiQuery({ name: "body", required: false })
@ApiQuery({ name: "engine", required: false })
@ApiQuery({ name: "gearbox", required: false })
async catalogCategoryParts(
@Req() req: ApiKeyRequest,
@Param("id") id: string,
@Param("categoryId") categoryId: string,
@Query("body") body?: string,
@Query("engine") engine?: string,
@Query("gearbox") gearbox?: string,
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const detail: any = await this.catalog.getCategoryWithParts(
id,
categoryId,
req.user.id,
body,
engine,
gearbox,
);
return {
...detail,
parts: (detail.parts ?? []).map((p: Record<string, unknown>) => ({
id: p.id,
name: p.name,
description: p.description,
oemCode: p.oemCode,
position: p.position ?? p.hotspotIndex ?? null,
quantity: p.quantity,
remark: p.remark,
unavailable: p.unavailable,
categoryId: p.categoryId,
})),
};
}
private setQuotaHeaders(res: Response, state: DecodeQuotaState): void { private setQuotaHeaders(res: Response, state: DecodeQuotaState): void {
res.setHeader("X-Decode-Quota-Limit", String(state.limit)); res.setHeader("X-Decode-Quota-Limit", String(state.limit));
res.setHeader("X-Decode-Quota-Used", String(state.used)); res.setHeader("X-Decode-Quota-Used", String(state.used));

View File

@@ -1,4 +1,5 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { CatalogModule } from "../catalog/catalog.module";
import { CategoriesModule } from "../categories/categories.module"; import { CategoriesModule } from "../categories/categories.module";
import { ApiKeyGuard } from "../common/guards/api-key.guard"; import { ApiKeyGuard } from "../common/guards/api-key.guard";
import { PModule } from "../integrations/p/p.module"; import { PModule } from "../integrations/p/p.module";
@@ -12,7 +13,7 @@ import { WidgetController } from "./widget.controller";
* apiKey plugin'i + ApiKeyGuard; iç servisler aynen yeniden kullanılır. * apiKey plugin'i + ApiKeyGuard; iç servisler aynen yeniden kullanılır.
*/ */
@Module({ @Module({
imports: [VehiclesModule, CategoriesModule, PModule], imports: [VehiclesModule, CategoriesModule, CatalogModule, PModule],
controllers: [PublicApiController, WidgetController], controllers: [PublicApiController, WidgetController],
providers: [ApiKeyGuard, PublicApiQuotaService], providers: [ApiKeyGuard, PublicApiQuotaService],
}) })

View File

@@ -185,20 +185,13 @@ export class SubscriptionsService {
const allBrands = await this.db.select().from(brands).where(eq(brands.isActive, true)); const allBrands = await this.db.select().from(brands).where(eq(brands.isActive, true));
if (allBrands.length > 0) { if (allBrands.length > 0) {
// Full-plan trials already carry the complete userBrands set for this await this.db.insert(userBrands).values(
// same subscription row; activating them must not re-insert (unique
// (userId, subscriptionId, brandId) would abort AFTER the status
// update above, leaving a half-activated sub and a 500 to the panel).
await this.db
.insert(userBrands)
.values(
allBrands.map((b) => ({ allBrands.map((b) => ({
userId: sub.userId, userId: sub.userId,
subscriptionId: sub.id, subscriptionId: sub.id,
brandId: b.id, brandId: b.id,
})), })),
) );
.onConflictDoNothing();
} }
} }

View File

@@ -98,9 +98,24 @@ function VehicleDetailPage() {
retry: (count, err) => !(err instanceof ApiError && err.status === 404) && count < 2, retry: (count, err) => !(err instanceof ApiError && err.status === 404) && count < 2,
}); });
// Fiat (any eper) vehicle: "Orijinal Katalog" shows the native ePER tree
// dealers know; "Birleşik" stays the canonical 20-bucket view (which folds the
// catalog into OUR structure), NOT the raw pcat tree. Non-eper vehicles keep
// the default source tree (treeSource undefined).
const { data: catalogSources } = useQuery({
queryKey: ["catalog-sources", id],
queryFn: () => api.get<string[]>(`/categories/tree/${id}/sources`),
enabled: !!id && idValid,
});
const hasEper = catalogSources?.includes("eper-pekidi") ?? false;
const treeSource = hasEper ? "eper-pekidi" : undefined;
const { data: categoryTree, isLoading: categoriesLoading } = useQuery({ const { data: categoryTree, isLoading: categoriesLoading } = useQuery({
queryKey: ["category-tree", id], queryKey: ["category-tree", id, treeSource],
queryFn: () => api.get<CategoryNode[]>(`/categories/tree/${id}`), queryFn: () =>
api.get<CategoryNode[]>(
`/categories/tree/${id}${treeSource ? `?source=${encodeURIComponent(treeSource)}` : ""}`,
),
enabled: !!id && idValid, enabled: !!id && idValid,
}); });

View File

@@ -15,6 +15,9 @@ services:
- VITE_SENTRY_DSN=${VITE_SENTRY_DSN:-} - VITE_SENTRY_DSN=${VITE_SENTRY_DSN:-}
- VITE_SENTRY_ENVIRONMENT=${VITE_SENTRY_ENVIRONMENT:-production} - VITE_SENTRY_ENVIRONMENT=${VITE_SENTRY_ENVIRONMENT:-production}
- VITE_SENTRY_RELEASE=${VITE_SENTRY_RELEASE:-} - VITE_SENTRY_RELEASE=${VITE_SENTRY_RELEASE:-}
# Per-commit cache-bust (see Dockerfile) — Coolify substitutes the SHA so
# every commit rebuilds the bundle instead of reusing a stale cached layer.
- SOURCE_COMMIT=${SOURCE_COMMIT:-unknown}
- VITE_GOOGLE_ADS_ID=${VITE_GOOGLE_ADS_ID:-} - VITE_GOOGLE_ADS_ID=${VITE_GOOGLE_ADS_ID:-}
- VITE_GOOGLE_ADS_SIGNUP_LABEL=${VITE_GOOGLE_ADS_SIGNUP_LABEL:-} - VITE_GOOGLE_ADS_SIGNUP_LABEL=${VITE_GOOGLE_ADS_SIGNUP_LABEL:-}
environment: environment: