diff --git a/PROJECT_INDEX.json b/PROJECT_INDEX.json new file mode 100644 index 0000000..ab63603 --- /dev/null +++ b/PROJECT_INDEX.json @@ -0,0 +1,127 @@ +{ + "project": "sase-v2 (sase.tr)", + "description": "Turkish B2B SaaS: VIN decode + OEM parts-catalog platform for parts dealers & repair shops", + "generated": "2026-06-10", + "generator": "/sc:index-repo", + "humanReadable": "PROJECT_INDEX.md", + "stack": { + "packageManager": "pnpm@10.29.3", + "node": "22 (alpine in Docker)", + "monorepo": "pnpm workspaces + turbo", + "language": "TypeScript", + "lint": "biome" + }, + "workspaces": { + "apps/api": { "name": "api", "framework": "NestJS ^10.4", "role": "REST API :4000 + BullMQ worker (worker.ts)" }, + "apps/web": { "name": "web", "framework": "React ^19 + TanStack Router ^1.120 + Vite", "role": "SPA dashboard + marketing pages" }, + "packages/shared": { "name": "@sase/shared", "role": "zod schemas, types, constants (plans, error-codes, regex), utils (vin-validator, formatters, currency)" }, + "packages/config": { "name": "@sase/config", "role": "shared config" }, + "packages/ui": { "name": "@sase/ui", "role": "shadcn-style component library" } + }, + "entryPoints": { + "api": "apps/api/src/main.ts", + "worker": "apps/api/src/worker.ts", + "web": "apps/web/src/main.tsx", + "migrations": "apps/api/src/database/migrate.ts" + }, + "apiModules": { + "vehicles": "VIN decode orchestration; fallback chain Corgi→PartsCatalogs→PL24→EMEX→NHTSA; slim response + Redis candidate stash (vin-cache-keys.ts)", + "integrations/pl24": "PartsLink24 — shared P4 core + brand services: pl24.service, pl24-psa, pl24-volvo, pl24-ford, pl24-hyundai-kia, pl24-opel, pl24-ford-legacy, pl24-auth, parsers/", + "integrations/emex": "emexdwc.ae scrape — HTTP (undici ProxyAgent) primary, Playwright fallback (emex.browser.ts), emex-tree.parser", + "integrations/parts-catalogs": "PartsCatalogs API (pcat)", + "integrations/p": "OEM cross-reference 'P' — TecDoc-derived MySQL (P_DB_* env): p.controller, p-source-db.service", + "integrations/corgi": "offline WMI lookup (instant)", + "integrations/vin-api": "NHTSA fallback decoder", + "integrations/catalog-source-db": "upstream source-DB bridge", + "catalog": "catalog browse — catalog/pcat-catalog/emex-catalog controllers+services", + "categories": "category tree: partCount rollup, lazy drill/heal, PSA parent gates", + "parts": "part listings", + "brands": "brand canonicalization (case-insensitive lookup)", + "auth": "better-auth cookie sessions", + "users": "user management", + "referrals": "referral program", + "payments": "Stripe", + "subscriptions": "subscription lifecycle", + "plans": "plan definitions", + "jobs": "BullMQ — queues: catalog-prefetch, emex-scrape, translation, lifecycle-email, subscription-expiry, query-cleanup; processors mirror queues; prefetch-worker.service", + "posthog": "server-side feature-flag eval + kill-source-* switches", + "meta-capi": "Meta Conversions API (event_id dedup, fail-open)", + "notifications": "Novu lifecycle triggers", + "email": "Postal HTTP API ops mail", + "chatwoot": "support-desk integration", + "analytics": "product analytics endpoints", + "telemetry": "OpenTelemetry → OTLP exporters", + "common": "shared infra incl. catalog-degradation → Sentry (fingerprinted, Redis-deduped)", + "redis": "ioredis provider", + "storage": "S3/MinIO", + "translations": "OpenAI-backed translation", + "admin": "admin endpoints", + "internal-admin": "internal admin", + "blog": "blog content", + "changelog": "changelog content", + "demo": "public demo flow", + "contact": "contact form", + "config": "configuration.ts + env.validation.ts (zod; empty-string trap)", + "database": "Drizzle provider; schema split: core, pl24, emex, parts-catalogs, relations" + }, + "webRoutes": { + "public": ["index", "pricing", "blog", "blog_/$slug", "demo", "demo_/categories_/$categoryId", "about", "contact", "kvkk", "privacy", "terms"], + "auth": ["login", "register", "forgot-password", "reset-password", "email-verified"], + "dashboard": [ + "index", "search", "history", "settings", "billing", "changelog", + "vehicles_/$id", "vehicles_/$id/categories_/$categoryId", + "catalog/index", "catalog_/$brandName", "catalog_/$brandName_/$modelId", "catalog_/$brandName_/$modelId/categories_/$categoryId", + "catalog_/pcat/$catalogId[/_$modelId/_$carId[/groups/$groupId]]", + "catalog_/emex/$catalogCode[/_$vehicleId[/groups/$groupId]]", + "oem.$code", "subscription", "service-test" + ], + "admin": ["admin/index", "admin/users", "admin/analytics", "admin/referrals", "admin/copy-logs"] + }, + "webLib": ["api-client", "auth-client", "posthog", "surveys", "meta-pixel", "faro", "sentry", "chatwoot", "i18n", "vehicle", "toast", "user-settings", "keys", "category-icons", "emex-group-hierarchy", "recently-used-brands"], + "configFiles": { + "turbo.json": "task pipeline", + "biome.json": "lint/format", + "docker-compose.coolify.yml": "prod/staging: api + worker + sase-redis; env vars must be referenced as ${VAR} per service block", + "Dockerfile": "multi-stage node:22-alpine + Chromium; runtime imports must be in dependencies", + "ecosystem.config.js": "PM2", + "apps/api/src/config/env.validation.ts": "zod env gate" + }, + "database": { + "primary": "PostgreSQL via drizzle-orm ^0.41", + "migrations": "apps/api/drizzle/ — 14 SQL files (0000–0013) + meta/_journal.json; custom SHA256-tracked runner (src/database/migrate.ts)", + "schemas": ["core", "pl24", "emex", "parts-catalogs", "relations"], + "secondary": "MySQL (mysql2) — TecDoc-derived 'P' OEM cross-ref db", + "cache": "Redis (sase-redis container) — BullMQ + VIN candidate stash + dedup" + }, + "tests": { + "api": { "framework": "vitest", "count": 30, "pattern": "*.spec.ts", "run": "pnpm --filter api test" }, + "web": { "framework": "vitest (no Playwright e2e)", "count": 24, "pattern": "*.test.ts(x)", "run": "pnpm --filter web test" }, + "qa": "qa/post-deploy smoke checks" + }, + "keyDependencies": { + "api": ["@nestjs/* ^10.4", "drizzle-orm + postgres", "better-auth", "bullmq + ioredis", "stripe", "posthog-node", "openai", "undici", "@sentry/nestjs", "@opentelemetry/*", "mysql2", "zod"], + "web": ["react ^19", "@tanstack/react-router + react-query", "zustand", "posthog-js", "@grafana/faro-web-sdk", "@sentry/react", "remotion", "better-auth", "zod"] + }, + "commands": { + "install": "pnpm install", + "dev": "pnpm dev", + "build": "pnpm build", + "lint": "pnpm lint", + "typecheck": "pnpm typecheck", + "test": "pnpm test", + "dbMigrate": "pnpm --filter api db:migrate", + "dbStudio": "pnpm --filter api db:studio" + }, + "deployment": { + "platform": "Coolify (cool.semih.ai)", + "staging": { "branch": "dev", "fqdn": "dev.sase.tr", "rule": "verify every change here first" }, + "production": { "branch": "main", "fqdn": "sase.tr", "rule": "promote only after dev verified" }, + "policy": "never edit on server/container; push-to-deploy via Gitea webhooks" + }, + "docs": { + "assistant": ["CLAUDE.md", "AGENTS.md"], + "internal": "docs/ (INDEX.md, 00-testing.md, design-specs/, pl24-catalog/, product/, clarification/)", + "rootReports": ["upstream-prob.md", "undecoded-vin-rca.md", "posthog-max-leverage.md", "posthog-feature-roadmap.md", "katalogayapi.md", "knowledge.md", "ralpRapor.md", "emex-hierarchy-plan.md", "MANIFEST.md", "MANIFEST-FN-403.md", "RECOVERY-FN-412.md", "SCOPING-FN-411.md", "SASE_PRD_Hesap_Paylasimi_Onleme_v1.1.md"], + "bizWorkspace": "/home/s/ss/INDEX.md" + } +} diff --git a/PROJECT_INDEX.md b/PROJECT_INDEX.md new file mode 100644 index 0000000..938228f --- /dev/null +++ b/PROJECT_INDEX.md @@ -0,0 +1,88 @@ +# Project Index: sase.tr (sase-v2) + +Generated: 2026-06-10 · regenerate via `/sc:index-repo` · machine-readable twin: `PROJECT_INDEX.json` + +Turkish B2B SaaS: VIN decode + OEM parts-catalog platform for parts dealers & repair shops. +pnpm 10.29.3 workspaces + Turbo · Node 22 · TypeScript · Biome. + +## Structure + +``` +apps/api NestJS 10 backend — REST API (:4000) + separate BullMQ worker +apps/web React 19 + TanStack Router/Query + Vite SPA (Remotion video, i18n) +packages/shared @sase/shared — zod schemas, types, constants (plans, error-codes), utils (vin-validator, formatters) +packages/config @sase/config — shared config +packages/ui @sase/ui — shadcn-style components +scripts/ one-off ops/debug (emex scrapers, backfills, deploy.sh, pl24-catalog-explorer) +qa/post-deploy post-deploy smoke checks +docs/ internal docs — see docs/INDEX.md (testing, design-specs, pl24-catalog, product) +docker/ local dev compose (minio, nginx) +``` + +## Entry Points + +- `apps/api/src/main.ts` — API bootstrap (also serves built web via ServeStatic; no nginx in prod) +- `apps/api/src/worker.ts` — BullMQ worker process (separate container) +- `apps/api/src/database/migrate.ts` — custom SHA256-tracked migration runner over `apps/api/drizzle/` (14 SQL migrations + `meta/_journal.json`) +- `apps/web/src/main.tsx` — SPA bootstrap (`routeTree.gen.ts` generated by `tsr generate`) + +## API Modules (`apps/api/src/`) + +- **vehicles/** — VIN decode orchestration; fallback chain Corgi → PartsCatalogs → PL24 → EMEX → NHTSA; slim decode response + Redis candidate stash (`vin-cache-keys.ts`) +- **integrations/pl24/** — PartsLink24: shared P4 core + per-brand services (`psa`, `volvo`, `ford`, `hyundai-kia`, `opel`, `ford-legacy`) + `parsers/` +- **integrations/emex/** — emexdwc.ae scrape: HTTP via undici ProxyAgent, Playwright fallback (`emex.browser.ts`), tree parser +- **integrations/parts-catalogs/** — PartsCatalogs API ("pcat") +- **integrations/p/** — OEM cross-reference ("P", TecDoc-derived MySQL db; `P_DB_*` env) +- **integrations/corgi/**, **vin-api/** — offline WMI lookup; NHTSA fallback +- **integrations/catalog-source-db/** — upstream source-DB bridge +- **catalog/** — catalog browse endpoints (`catalog`, `pcat-catalog`, `emex-catalog` controllers/services) +- **categories/**, **parts/**, **brands/** — category tree (partCount rollup, lazy drill/heal), parts, brand canonicalization +- **auth/** (better-auth cookie sessions), **users/**, **referrals/** +- **payments/** (Stripe), **subscriptions/**, **plans/** +- **jobs/** — BullMQ queues + processors: `catalog-prefetch`, `emex-scrape`, `translation`, `lifecycle-email`, `subscription-expiry`, `query-cleanup` +- **posthog/** — server-side flag eval + `kill-source-*` switches · **meta-capi/** — Meta Conversions API +- **notifications/** (Novu), **email/** (Postal), **chatwoot/** +- **analytics/**, **telemetry/** (OTel → OTLP), **common/** (incl. catalog-degradation → Sentry), **redis/**, **storage/** (S3/MinIO), **translations/** (OpenAI), **admin/**, **internal-admin/**, **blog/**, **changelog/**, **demo/**, **contact/** +- **config/** — `env.validation.ts` zod gate (beware `""` vs undefined), `configuration.ts` +- **database/** — Drizzle provider + `schema/` split: `core`, `pl24`, `emex`, `parts-catalogs`, `relations` + +## Web (`apps/web/src/`) + +- **routes/** — public: `index`, `pricing`, `blog`, `demo`, `about/contact/kvkk/privacy/terms` · `_auth/`: login, register, forgot/reset password, email-verified · `dashboard/`: `search` (main VIN flow), `vehicles_/$id[/categories_/$categoryId]`, catalog trees (`catalog_/$brandName/...`, `catalog_/pcat/...`, `catalog_/emex/...`), `oem.$code` (cross-ref), `subscription`, `billing`, `history`, `settings`, `service-test` (admin), `admin/*` (users, analytics, referrals, copy-logs) +- **lib/** — `api-client`, `auth-client`, `posthog`, `surveys` (API-mode survey renderer), `meta-pixel`, `faro`, `sentry`, `chatwoot`, `i18n`, `vehicle` +- **stores/** (zustand), **components/**, **remotion/**, **messages/** (i18n), **hooks/** + +## Configuration + +- `turbo.json`, `biome.json`, `tsconfig.json` — build/lint/typecheck pipeline +- `docker-compose.coolify.yml` — prod/staging: `api` + `worker` + `sase-redis` (Coolify env reaches a service ONLY if its `environment:` block references `${VAR}`) +- `Dockerfile` — multi-stage node:22-alpine + Chromium (runtime imports MUST be in `dependencies`) +- `ecosystem.config.js` — PM2 config + +## Tests + +- API: 30 `*.spec.ts` (Vitest) → `pnpm --filter api test` +- Web: 24 `*.test.ts(x)` (Vitest only; **no Playwright e2e**) → `pnpm --filter web test`; plus per-package `index.spec.ts` +- `qa/post-deploy/` smoke checks + +## Key Dependencies + +NestJS 10 · drizzle-orm 0.41 + postgres · better-auth · bullmq + ioredis · stripe · posthog-node · openai (translations) · undici (proxied fetch — global fetch lacks `dispatcher`) · Sentry + OpenTelemetry · mysql2 (P/TecDoc) ·· React 19 · TanStack Router/Query · zustand · posthog-js · @grafana/faro · remotion · zod throughout. + +## Quick Start + +1. `pnpm install` +2. `pnpm dev` (turbo: api :4000 + web :3000) +3. `pnpm lint` / `pnpm typecheck` / `pnpm test` +4. DB: `pnpm --filter api db:migrate` (custom runner) · `db:studio` · `db:seed` + +## Deployment (Coolify — NEVER edit on server) + +- Push `dev` → auto-deploys staging **dev.sase.tr** — verify every change here first +- Merge to `main` → auto-deploys prod **sase.tr** (only after dev verified) + +## Docs Map + +- `CLAUDE.md` / `AGENTS.md` — assistant instructions · `docs/INDEX.md` — internal docs index +- Root reports: `upstream-prob.md`, `undecoded-vin-rca.md`, `posthog-max-leverage.md`, `posthog-feature-roadmap.md`, `katalogayapi.md`, `knowledge.md`, `ralpRapor.md`, `emex-hierarchy-plan.md`, `MANIFEST*.md`, `RECOVERY-FN-412.md`, `SCOPING-FN-411.md`, `SASE_PRD_Hesap_Paylasimi_Onleme_v1.1.md` +- Business/ops workspace knowledge base: `/home/s/ss/INDEX.md` diff --git a/apps/api/drizzle/0013_fix_brand_casing.sql b/apps/api/drizzle/0013_fix_brand_casing.sql new file mode 100644 index 0000000..6ccc514 --- /dev/null +++ b/apps/api/drizzle/0013_fix_brand_casing.sql @@ -0,0 +1,28 @@ +-- Canonicalise vehicle brand casing + backfill missing brand_id. +-- +-- Root cause: the decode persist (vehicles.service.ts) looked brands up with a +-- case-SENSITIVE `eq(brands.name, brandName)`. A decode source that yields an +-- uppercase brand string (e.g. "FORD", "MERCEDES-BENZ") missed the canonical +-- "Ford" / "Mercedes-Benz" row → brand_id stayed NULL and the raw uppercase +-- string was stored as brand_name. That split the same brand across casing +-- variants in analytics + the catalog ("Ford" vs "FORD"). The companion code +-- fix makes the lookup case-insensitive and stores the canonical name; this +-- migration repairs the rows already written. +-- +-- Backfill every vehicle whose brand_name matches a canonical brand +-- case-insensitively: set the FK and the canonical-cased name. (catalog_vehicles +-- is intentionally untouched — its brand_name namespace does not match the +-- brands table, so 0 rows would qualify.) +UPDATE "vehicles" v +SET brand_id = b.id, brand_name = b.name +FROM "brands" b +WHERE v.brand_id IS NULL AND lower(v.brand_name) = lower(b.name); +--> statement-breakpoint + +-- Defensive: realign any vehicle whose brand_id is set but whose denormalised +-- brand_name has drifted from the canonical brands.name (none today, but keeps +-- the FK and the denormalised name consistent going forward). +UPDATE "vehicles" v +SET brand_name = b.name +FROM "brands" b +WHERE v.brand_id = b.id AND v.brand_name <> b.name; diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 4352627..d8222f7 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1780581755559, "tag": "0012_lifecycle_email_sent", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1780600000000, + "tag": "0013_fix_brand_casing", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index c3e0289..e25ba89 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -29,6 +29,7 @@ import { DemoModule } from "./demo/demo.module"; import { EmailModule } from "./email/email.module"; import { HealthController } from "./health.controller"; import { EmexModule } from "./integrations/emex/emex.module"; +import { PModule } from "./integrations/p/p.module"; import { InternalAdminModule } from "./internal-admin/internal-admin.module"; import { JobsModule } from "./jobs/jobs.module"; import { MetaCapiModule } from "./meta-capi/meta-capi.module"; @@ -86,6 +87,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module"; CategoriesModule, DemoModule, PartsModule, + PModule, JobsModule, EmexModule, TranslationsModule, diff --git a/apps/api/src/config/configuration.ts b/apps/api/src/config/configuration.ts index 534616f..8316ad4 100644 --- a/apps/api/src/config/configuration.ts +++ b/apps/api/src/config/configuration.ts @@ -88,6 +88,14 @@ export default () => ({ .map((s) => s.trim()) .filter(Boolean), }, + p: { + // Read-only lookup against the imported P snapshot (db `td`). When + // enabled + url set, the OEM detail page resolves a part's OEM code to + // P aftermarket equivalents + OE cross-references. Disabled → endpoint + // returns { matched: false } and the UI shows an empty state. + enabled: process.env.P_DB_ENABLED === "true", + url: process.env.P_DB_URL, + }, otel: { enabled: process.env.OTEL_ENABLED === "true", endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, diff --git a/apps/api/src/database/schema/core.ts b/apps/api/src/database/schema/core.ts index a0c3717..3b63ec9 100644 --- a/apps/api/src/database/schema/core.ts +++ b/apps/api/src/database/schema/core.ts @@ -184,7 +184,9 @@ export const userBrands = pgTable( ], ); -// ─── Bank Accounts (EFT/Havale destination accounts, one active at a time) ──── +// ─── Bank Accounts ─────────────────────────────────── +// @deprecated EFT/Havale was retired (Stripe is the sole payment method). Table +// kept only to preserve historical FK integrity; no rows are created anymore. export const bankAccounts = pgTable( "bank_accounts", { @@ -225,7 +227,9 @@ export const payments = pgTable( iyzicoPaymentId: text("iyzico_payment_id"), stripeSessionId: text("stripe_session_id"), stripePaymentIntentId: text("stripe_payment_intent_id"), + /** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */ bankAccountId: uuid("bank_account_id").references(() => bankAccounts.id), + /** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */ eftReceiptUrl: text("eft_receipt_url"), adminNote: text("admin_note"), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), diff --git a/apps/api/src/integrations/emex/emex.types.ts b/apps/api/src/integrations/emex/emex.types.ts index 4062616..cfe0181 100644 --- a/apps/api/src/integrations/emex/emex.types.ts +++ b/apps/api/src/integrations/emex/emex.types.ts @@ -348,6 +348,7 @@ export const CATALOG_MAP: Record = { // Opel W0L: { code: "GM_OP201809", brand: "Opel" }, W0V: { code: "GM_OP201809", brand: "Opel" }, // newer Stellantis-era WMI + VXK: { code: "GM_OP201809", brand: "Opel" }, // PSA/Stellantis-platform Opel (Corsa F, Mokka B) // Chevrolet KL1: { code: "GM_C201809", brand: "Chevrolet" }, // SsangYong diff --git a/apps/api/src/integrations/p/p-source-db.service.ts b/apps/api/src/integrations/p/p-source-db.service.ts new file mode 100644 index 0000000..b441119 --- /dev/null +++ b/apps/api/src/integrations/p/p-source-db.service.ts @@ -0,0 +1,256 @@ +import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import postgres, { type Sql } from "postgres"; +import type { PArticle, PCompatible, POeNumber, POemResult } from "./p.types"; + +/** + * Read-only lookup against the imported P snapshot (db `td` — a selective + * copy of articles + OE numbers + aftermarket compatibility + images/eans; the + * 29 GB vehicle-fitment table is intentionally excluded). Given an OEM code from + * the sase catalog, returns the P articles that carry it as an OE number, + * with their aftermarket equivalents and OE cross-references. + * + * Matching is normalisation-based, not exact: P stores OE codes with + * spaces/dashes (`1J0 973 702`) while the catalog gives `1J0973702`, so both + * sides are reduced to `[A-Z0-9]` uppercase before comparison (a precomputed + * `code_norm` column, indexed, holds the P side). Exact matching recovers + * almost nothing — verified ~1/10 vs normalised ~5/10 on real catalog codes. + * + * Never throws: disabled feature, too-short code, connection blip or no match + * all collapse to `matched: false` so the UI has a single empty-state path. + */ +@Injectable() +export class PSourceDbService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(PSourceDbService.name); + private sql: Sql | null = null; + private enabled = false; + + // Short normalised codes (e.g. "NA" from "N/A", single digits) collide across + // unrelated parts — refuse to match below this length. Real OE numbers are 5+. + private static readonly MIN_NORM_LEN = 5; + private static readonly MAX_ARTICLES = 60; + private static readonly MAX_AGG = 300; + + constructor(private readonly config: ConfigService) {} + + onModuleInit() { + const enabled = this.config.get("p.enabled"); + const url = this.config.get("p.url"); + if (!enabled || !url) { + this.logger.log(`[p] disabled (enabled=${enabled}, urlSet=${Boolean(url)})`); + return; + } + this.sql = postgres(url, { + max: 5, + idle_timeout: 30, + connect_timeout: 10, + prepare: false, + }); + this.enabled = true; + this.logger.log("[p] connected, OEM cross-reference lookup enabled"); + } + + async onModuleDestroy() { + if (this.sql) { + await this.sql.end({ timeout: 5 }); + this.sql = null; + } + } + + /** `1J0 973 702` / `1j0-973-702` → `1J0973702`. Used for both the input code + * and JS-side dedupe; the P side is matched against the stored + * `code_norm` (built with the identical rule at import time). */ + private static norm(code: string): string { + return code.toUpperCase().replace(/[^A-Z0-9]/g, ""); + } + + /** + * Batch membership test for the schema page: given the OEM codes in a parts + * list, return the subset that has at least one OE cross-reference in the + * snapshot. The panel links only these (and renders the rest as plain text), + * so a click never lands on an empty "no equivalents" detail page. One indexed + * query (`code_norm` btree). Fail-open to [] (→ no links) when disabled / on + * error, echoing back the caller's original spelling for the matched codes. + */ + async matchedCodes(rawCodes: string[]): Promise { + if (!this.enabled || !this.sql || !rawCodes?.length) return []; + const rawByNorm = new Map(); + for (const raw of rawCodes) { + const n = PSourceDbService.norm(raw ?? ""); + if (n.length >= PSourceDbService.MIN_NORM_LEN && !rawByNorm.has(n)) rawByNorm.set(n, raw); + } + if (rawByNorm.size === 0) return []; + try { + const norms = [...rawByNorm.keys()]; + const rows = await this.sql>` + SELECT DISTINCT code_norm FROM article_oe_numbers + WHERE code_norm IN ${this.sql(norms)} + `; + return rows + .map((r) => rawByNorm.get(r.code_norm)) + .filter((c): c is string => c !== undefined); + } catch (err) { + this.logger.warn( + `[p] matchedCodes failed (${rawByNorm.size} codes): ${(err as Error).message}`, + ); + return []; + } + } + + async lookupByOem(rawCode: string): Promise { + const query = (rawCode ?? "").trim(); + const queryNorm = PSourceDbService.norm(query); + const miss: POemResult = { + query, + queryNorm, + matched: false, + articles: [], + aftermarketParts: [], + oeCrossReferences: [], + truncated: false, + }; + + if (!this.enabled || !this.sql) return miss; + if (queryNorm.length < PSourceDbService.MIN_NORM_LEN) return miss; + + try { + const rows = await this.sql< + Array<{ + id: string; + brand: string; + article_number: string; + name: string | null; + spare_info: string | null; + oe_numbers: POeNumber[]; + compatible: PCompatible[]; + images: Array<{ url: string; thumb: string | null }>; + }> + >` + WITH hit AS ( + SELECT DISTINCT article_id + FROM article_oe_numbers + WHERE code_norm = ${queryNorm} + LIMIT ${PSourceDbService.MAX_ARTICLES} + ) + SELECT + a.id::text AS id, + b.name AS brand, + a.article_number AS article_number, + a.name AS name, + a.spare_info AS spare_info, + COALESCE(( + SELECT json_agg(json_build_object('brand', o.brand, 'code', o.code)) + FROM ( + SELECT DISTINCT brand, code FROM article_oe_numbers + WHERE article_id = a.id ORDER BY brand LIMIT 200 + ) o + ), '[]') AS oe_numbers, + COALESCE(( + SELECT json_agg(json_build_object('brand', c.compatible_brand, 'article', c.compatible_article)) + FROM ( + SELECT DISTINCT compatible_brand, compatible_article FROM article_compatibility + WHERE article_id = a.id ORDER BY compatible_brand LIMIT 200 + ) c + ), '[]') AS compatible, + COALESCE(( + SELECT json_agg(json_build_object('url', i.image_url, 'thumb', i.thumb_url) ORDER BY i.sort_order) + FROM ( + -- Only publicly-resolvable URLs. The current snapshot stores + -- scrape-local '/_debug/...' paths (404 off-host) — filtering them + -- here keeps the API contract honest so the UI shows no broken + -- thumbnails; real CDN URLs surface automatically once present. + SELECT image_url, thumb_url, sort_order FROM article_images + WHERE article_id = a.id AND image_url LIKE 'http%' + ORDER BY sort_order LIMIT 8 + ) i + ), '[]') AS images + FROM hit + JOIN articles a ON a.id = hit.article_id + JOIN article_brands b ON b.id = a.brand_id + ORDER BY b.name, a.article_number + `; + + if (rows.length === 0) return miss; + + let truncated = rows.length >= PSourceDbService.MAX_ARTICLES; + + const articles: PArticle[] = rows.map((r) => { + if (r.oe_numbers.length >= 200 || r.compatible.length >= 200) truncated = true; + return { + id: r.id, + brand: r.brand, + articleNumber: r.article_number, + name: r.name, + spareInfo: r.spare_info, + images: r.images, + oeNumbers: r.oe_numbers, + compatible: r.compatible, + }; + }); + + // ── Aggregate: buyable aftermarket part numbers ────────────────────── + // The matched articles are themselves aftermarket parts; their + // compatibility rows add equivalent numbers from other supplier brands. + const afterSeen = new Set(); + const aftermarketParts: POemResult["aftermarketParts"] = []; + const pushAfter = (brand: string, articleNumber: string, thumb: string | null) => { + const key = `${brand.toUpperCase().trim()}␟${PSourceDbService.norm(articleNumber)}`; + if (afterSeen.has(key) || !articleNumber.trim()) return; + afterSeen.add(key); + if (aftermarketParts.length < PSourceDbService.MAX_AGG) { + aftermarketParts.push({ brand, articleNumber, thumb }); + } else { + truncated = true; + } + }; + for (const a of articles) { + pushAfter(a.brand, a.articleNumber, a.images[0]?.thumb ?? a.images[0]?.url ?? null); + } + for (const a of articles) { + for (const c of a.compatible) pushAfter(c.brand, c.article, null); + } + + // ── Aggregate: OE cross-references (same part, other makes) ─────────── + // Exclude restatements of the queried code itself (same normalised code). + const oeSeen = new Set(); + const oeCrossReferences: POeNumber[] = []; + for (const a of articles) { + for (const oe of a.oeNumbers) { + const codeNorm = PSourceDbService.norm(oe.code); + if (codeNorm === queryNorm) continue; + const key = `${oe.brand.toUpperCase().trim()}␟${codeNorm}`; + if (oeSeen.has(key)) continue; + oeSeen.add(key); + if (oeCrossReferences.length < PSourceDbService.MAX_AGG) { + oeCrossReferences.push(oe); + } else { + truncated = true; + } + } + } + + return { + query, + queryNorm, + matched: true, + // Ship lean articles — the per-article oeNumbers/compatible lists were + // only needed above to build the aggregates; sending them too bloated + // heavy responses ~30x (370 KB → ~12 KB for a 60-article code). + articles: articles.map((a) => ({ + id: a.id, + brand: a.brand, + articleNumber: a.articleNumber, + name: a.name, + spareInfo: a.spareInfo, + images: a.images, + })), + aftermarketParts, + oeCrossReferences, + truncated, + }; + } catch (err) { + this.logger.warn(`[p] lookup failed (oem=${query}): ${(err as Error).message}`); + return miss; + } + } +} diff --git a/apps/api/src/integrations/p/p.controller.ts b/apps/api/src/integrations/p/p.controller.ts new file mode 100644 index 0000000..174f03d --- /dev/null +++ b/apps/api/src/integrations/p/p.controller.ts @@ -0,0 +1,28 @@ +import { Body, Controller, Get, Post, Query } from "@nestjs/common"; +import { PSourceDbService } from "./p-source-db.service"; + +@Controller("p") +export class PController { + constructor(private readonly p: PSourceDbService) {} + + /** + * Resolve an OEM code from the catalog to its P equivalents. + * `GET /p/oem?code=1J0973702` → { matched, articles, aftermarketParts, + * oeCrossReferences }. Always 200 with `matched: false` on any miss. + */ + @Get("oem") + async oem(@Query("code") code: string) { + return this.p.lookupByOem(code ?? ""); + } + + /** + * Batch: which of these OEM codes have a P cross-reference. The schema page + * sends the codes it's about to render and links only the returned ones, so + * unmatched codes stay plain text and never open an empty detail page. + * `POST /p/matched { codes: [...] }` → { matched: [...] }. + */ + @Post("matched") + async matched(@Body("codes") codes: string[]) { + return { matched: await this.p.matchedCodes(Array.isArray(codes) ? codes : []) }; + } +} diff --git a/apps/api/src/integrations/p/p.module.ts b/apps/api/src/integrations/p/p.module.ts new file mode 100644 index 0000000..3f2d695 --- /dev/null +++ b/apps/api/src/integrations/p/p.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; +import { PSourceDbService } from "./p-source-db.service"; +import { PController } from "./p.controller"; + +/** + * OEM cross-reference lookup against the imported P snapshot (db `td`). + * Raw read-only queries — intentionally no Drizzle schema modelling, mirroring + * CatalogSourceDbModule. Self-disables when P_DB_* env is unset. + */ +@Module({ + controllers: [PController], + providers: [PSourceDbService], + exports: [PSourceDbService], +}) +export class PModule {} diff --git a/apps/api/src/integrations/p/p.types.ts b/apps/api/src/integrations/p/p.types.ts new file mode 100644 index 0000000..05efed7 --- /dev/null +++ b/apps/api/src/integrations/p/p.types.ts @@ -0,0 +1,57 @@ +/** An OE (original-equipment) number cross-reference: the same physical part as + * catalogued by a vehicle manufacturer (e.g. VAG `1J0 973 702`). */ +export interface POeNumber { + brand: string; + code: string; +} + +/** An aftermarket equivalent: a buyable part number from a supplier brand + * (e.g. FEBI BILSTEIN `171903`). */ +export interface PCompatible { + brand: string; + article: string; +} + +export interface PImage { + url: string; + thumb: string | null; +} + +/** One P article whose OE number list contains the queried OEM code. Internal + * shape — `oeNumbers`/`compatible` feed the aggregates and are NOT shipped to + * the client (they're large and unused there; see PArticleSummary). */ +export interface PArticle { + id: string; + brand: string; + articleNumber: string; + name: string | null; + spareInfo: string | null; + images: PImage[]; + oeNumbers: POeNumber[]; + compatible: PCompatible[]; +} + +/** What the OEM detail page actually renders per matched article — without the + * heavy per-article OE/compat lists (those are deduped into the top-level + * aggregates). Trimming them cut a 60-article payload from ~370 KB to ~12 KB. */ +export type PArticleSummary = Omit; + +/** Response of the OEM detail lookup. `matched: false` covers every miss — + * feature disabled, code too short, or no P article carries that OE + * number — so the UI has a single empty-state path. */ +export interface POemResult { + query: string; + queryNorm: string; + matched: boolean; + /** Distinct articles whose OE list contains the queried code (lean — per-article + * OE/compat lists are aggregated below, not duplicated here). */ + articles: PArticleSummary[]; + /** Deduped buyable aftermarket part numbers across all matched articles + * (the matched articles themselves + their compatibility entries). */ + aftermarketParts: Array<{ brand: string; articleNumber: string; thumb: string | null }>; + /** Deduped OE cross-references across all matched articles, excluding the + * queried code itself — i.e. the same part's numbers under other makes. */ + oeCrossReferences: POeNumber[]; + /** True when any per-article list or the article set hit its cap. */ + truncated: boolean; +} diff --git a/apps/api/src/integrations/pl24/pl24.types.ts b/apps/api/src/integrations/pl24/pl24.types.ts index c76a54f..23836af 100644 --- a/apps/api/src/integrations/pl24/pl24.types.ts +++ b/apps/api/src/integrations/pl24/pl24.types.ts @@ -565,6 +565,7 @@ export const PL24_WMI_SERVICE_MAP: Record = { // Opel / Vauxhall W0L: "opel_parts", // Opel AG (Germany) W0V: "opel_parts", // Opel (newer Stellantis-era WMI) + VXK: "opel_parts", // PSA/Stellantis-platform Opel (Corsa F, Mokka B — France/Spain plants) // Citroën (PSA) VF7: "citroen_parts", // Citroën SA (France) diff --git a/apps/api/src/parts/parts.controller.ts b/apps/api/src/parts/parts.controller.ts index 26aa1b7..ad86144 100644 --- a/apps/api/src/parts/parts.controller.ts +++ b/apps/api/src/parts/parts.controller.ts @@ -15,6 +15,13 @@ export class PartsController { return this.partsService.searchByOem(oem); } + // Reverse catalog for the OEM detail page: which of the user's vehicles use + // this OEM code. Declared before ":id" so the literal path wins the match. + @Get("oem-vehicles") + async vehiclesByOem(@Query("code") code: string) { + return this.partsService.vehiclesByOem(code ?? ""); + } + @Get(":id") async getById(@Param("id") id: string) { return this.partsService.getById(id); diff --git a/apps/api/src/parts/parts.service.ts b/apps/api/src/parts/parts.service.ts index d3175dc..42e0519 100644 --- a/apps/api/src/parts/parts.service.ts +++ b/apps/api/src/parts/parts.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; -import { eq, like } from "drizzle-orm"; +import { eq, like, sql } from "drizzle-orm"; import { DATABASE, type Database } from "../database/database.provider"; import { categories, parts, schemaPics, vehicles } from "../database/schema/core"; import { PL24Service } from "../integrations/pl24/pl24.service"; @@ -142,4 +142,32 @@ export class PartsService { if (!part) throw new NotFoundException("Parça bulunamadı"); return part; } + + /** + * Reverse catalog: the user's own decoded vehicles whose parts list contains + * this exact OEM code. Powers the "bu kod kataloğunuzda şu araçlarda var" + * section of the OEM detail page — pure sase data, no TecDoc/vehicle-structure + * dependency. Exact match on the indexed `oem_code` (the code came from a real + * part row, so the spelling matches). One representative `categoryId` per + * vehicle lets the UI deep-link straight to a schema page showing the part. + */ + async vehiclesByOem(oemCode: string) { + const code = (oemCode ?? "").trim(); + if (!code) return []; + return this.db + .select({ + vehicleId: vehicles.id, + brandName: vehicles.brandName, + model: vehicles.model, + year: vehicles.year, + categoryId: sql`(array_agg(${parts.categoryId} ORDER BY ${parts.categoryId}))[1]`, + occurrences: sql`count(*)::int`, + }) + .from(parts) + .innerJoin(vehicles, eq(parts.vehicleId, vehicles.id)) + .where(eq(parts.oemCode, code)) + .groupBy(vehicles.id, vehicles.brandName, vehicles.model, vehicles.year) + .orderBy(vehicles.brandName, vehicles.model) + .limit(50); + } } diff --git a/apps/api/src/payments/payments.service.ts b/apps/api/src/payments/payments.service.ts index 629e65d..678f827 100644 --- a/apps/api/src/payments/payments.service.ts +++ b/apps/api/src/payments/payments.service.ts @@ -16,9 +16,9 @@ export class PaymentsService { /** * Payment history for the billing page. Returns a curated projection — never * the raw row — so internal columns (adminNote, iyzicoPaymentId, - * bankAccountId, session/intent ids) are not leaked to the client. planName - * is joined from the subscription's plan; Stripe receipt availability is - * surfaced as a boolean rather than exposing the payment intent id. + * session/intent ids) are not leaked to the client. planName is joined from + * the subscription's plan; Stripe receipt availability is surfaced as a + * boolean rather than exposing the payment intent id. */ async getMyPayments(userId: string) { const rows = await this.db @@ -29,7 +29,6 @@ export class PaymentsService { status: payments.status, createdAt: payments.createdAt, planName: plans.name, - eftReceiptUrl: payments.eftReceiptUrl, stripePaymentIntentId: payments.stripePaymentIntentId, }) .from(payments) @@ -45,16 +44,15 @@ export class PaymentsService { status: row.status, createdAt: row.createdAt, planName: row.planName ?? null, - eftReceiptUrl: row.eftReceiptUrl ?? null, hasStripeReceipt: row.method === "stripe" && row.status === "completed" && !!row.stripePaymentIntentId, })); } /** - * Resolve a receipt URL for one of the current user's payments. - * EFT receipts are stored locally; Stripe receipts are fetched live from the - * payment intent's latest charge. Returns { url: null } when none exists. + * Resolve a receipt URL for one of the current user's payments. Stripe is the + * sole payment method; receipts are fetched live from the payment intent's + * latest charge. Returns { url: null } when none exists. */ async getReceiptUrl(userId: string, paymentId: string): Promise<{ url: string | null }> { const [payment] = await this.db @@ -67,10 +65,6 @@ export class PaymentsService { throw new NotFoundException("Payment not found"); } - if (payment.eftReceiptUrl) { - return { url: payment.eftReceiptUrl }; - } - if (payment.method === "stripe" && payment.stripePaymentIntentId) { return { url: await this.stripeService.getReceiptUrl(payment.stripePaymentIntentId) }; } diff --git a/apps/api/src/payments/stripe/stripe.service.ts b/apps/api/src/payments/stripe/stripe.service.ts index 0519e56..590b7d2 100644 --- a/apps/api/src/payments/stripe/stripe.service.ts +++ b/apps/api/src/payments/stripe/stripe.service.ts @@ -143,6 +143,10 @@ export class StripeService { const session = await this.stripe.checkout.sessions.create({ mode: "payment", + // Render Stripe's hosted page in Turkish. The audience is Turkish B2B; a + // foreign-language checkout is a known abandonment driver (~60% of sessions + // reached the page but never started a payment intent). + locale: "tr", payment_method_types: ["card"], customer_email: userEmail, line_items: [ @@ -257,6 +261,14 @@ export class StripeService { const paymentIntentId = typeof session.payment_intent === "string" ? session.payment_intent : null; + // Activate FIRST, then mark the payment completed. If activation throws, the + // payment stays 'pending', so a Stripe webhook retry re-runs this handler + // cleanly instead of hitting the completed-guard above and skipping + // activation forever — which would leave a paid-but-never-activated + // subscription. activateSubscription is idempotent, so a retry after a + // successful activate but failed mark-completed won't double-count revenue. + const activated = await this.subscriptionsService.activateSubscription(payment.subscriptionId); + await this.db .update(payments) .set({ @@ -266,8 +278,6 @@ export class StripeService { }) .where(eq(payments.id, paymentId)); - const activated = await this.subscriptionsService.activateSubscription(payment.subscriptionId); - this.posthog.captureForUser(payment.userId, "payment_success", { method: "stripe", payment_id: paymentId, diff --git a/apps/api/src/subscriptions/subscriptions.service.spec.ts b/apps/api/src/subscriptions/subscriptions.service.spec.ts index 354488b..f18880a 100644 --- a/apps/api/src/subscriptions/subscriptions.service.spec.ts +++ b/apps/api/src/subscriptions/subscriptions.service.spec.ts @@ -288,4 +288,43 @@ describe("SubscriptionsService", () => { ); }); }); + + describe("activateSubscription", () => { + it("is idempotent: skips re-activation when already active (no revenue double-count)", async () => { + // A Stripe webhook retry must not re-fire revenue events, re-consume + // referral credit, or re-insert brands on an already-active subscription. + const activeSub = { + id: "sub-1", + userId: "user-1", + planId: "plan-1", + status: "active", + billingPeriod: "monthly", + endDate: new Date(), + }; + const db = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnValue([activeSub]), + }), + update: vi.fn(), + insert: vi.fn(), + }; + const posthog = { + captureForUser: vi.fn(), + capture: vi.fn(), + flush: vi.fn().mockResolvedValue(undefined), + }; + const metaCapi = { sendPurchase: vi.fn().mockResolvedValue(undefined) }; + const service = new SubscriptionsService(db as any, posthog as any, metaCapi as any); + + const result = await service.activateSubscription("sub-1"); + + expect(result).toBe(activeSub); + expect(db.update).not.toHaveBeenCalled(); + expect(db.insert).not.toHaveBeenCalled(); + expect(posthog.captureForUser).not.toHaveBeenCalled(); + expect(metaCapi.sendPurchase).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/api/src/subscriptions/subscriptions.service.ts b/apps/api/src/subscriptions/subscriptions.service.ts index 45c0e92..ef29f91 100644 --- a/apps/api/src/subscriptions/subscriptions.service.ts +++ b/apps/api/src/subscriptions/subscriptions.service.ts @@ -3,6 +3,7 @@ import { ConflictException, Inject, Injectable, + Logger, NotFoundException, } from "@nestjs/common"; import { and, desc, eq, inArray, or } from "drizzle-orm"; @@ -20,6 +21,8 @@ import { PostHogService } from "../posthog/posthog.service"; @Injectable() export class SubscriptionsService { + private readonly logger = new Logger(SubscriptionsService.name); + constructor( @Inject(DATABASE) private db: Database, private posthog: PostHogService, @@ -123,6 +126,17 @@ export class SubscriptionsService { if (!sub) throw new NotFoundException("Abonelik bulunamadı"); + // Idempotency guard. A Stripe webhook retry (or any double-delivery) must + // not re-activate an already-active subscription: that would reset the + // period from now, re-consume referral credit, double-insert Full-plan + // brands, and double-count revenue (PostHog subscription_activated + Meta + // CAPI Purchase). Legitimate first activations always run on a 'pending' + // sub; admin manual activation pre-rejects 'active' before reaching here. + if (sub.status === "active") { + this.logger.warn(`activateSubscription: ${subscriptionId} already active — skipping`); + return sub; + } + const now = new Date(); const endDate = new Date(now); if (sub.billingPeriod === "yearly") { diff --git a/apps/api/src/vehicles/vehicles.service.ts b/apps/api/src/vehicles/vehicles.service.ts index 044a73d..3b17044 100644 --- a/apps/api/src/vehicles/vehicles.service.ts +++ b/apps/api/src/vehicles/vehicles.service.ts @@ -232,16 +232,22 @@ export class VehiclesService { // 3. Brand access check let brandId: string | null = null; - const brandName = resolved.brandName; + // Match the brands table case-insensitively and adopt the CANONICAL name: + // decode sources emit the brand with inconsistent casing ("FORD" vs "Ford"), + // and a case-sensitive lookup left brand_id NULL + stored the raw uppercase + // string, splitting one brand across casing variants. See migration + // 0013_fix_brand_casing for the backfill of pre-existing rows. + let brandName = resolved.brandName; if (brandName) { const [brand] = await this.db .select() .from(brands) - .where(eq(brands.name, brandName)) + .where(sql`lower(${brands.name}) = lower(${brandName})`) .limit(1); if (brand) { brandId = brand.id; + brandName = brand.name; await this.checkBrandAccess(userId, brandId); } } @@ -773,8 +779,21 @@ export class VehiclesService { // Propagate pcat's transient signal to the shared outcome (neg-cache gate). if (pcatOutcome.transient && outcome) outcome.transient = true; pcatResolved = true; - if (ctx && ctx.timings.pcat === undefined) { - ctx.timings.pcat = Date.now() - pcatStart; + if (ctx) { + if (ctx.timings.pcat === undefined) { + ctx.timings.pcat = Date.now() - pcatStart; + } + // pcat never definitively evaluated this VIN. Without these markers a + // "No catalog — identified as X" produced while pcat was skipped/flaky + // is indistinguishable from a true coverage gap, and the panel's + // catalog-gap detector raises false brand-gap insights (seen + // 2026-06-11: Audi flagged from cold-pool 1-2ms skips). + if (pcatOutcome.coldSkip) { + ctx.timings.pcat_skipped = 1; + } else if (pcatOutcome.transient && !pcatCircuitOpen) { + // Circuit-open skips already carry pcat_circuit_open. + ctx.timings.pcat_transient = 1; + } } }); diff --git a/apps/api/src/vehicles/vin-cache-keys.spec.ts b/apps/api/src/vehicles/vin-cache-keys.spec.ts index d7e991f..d6bb752 100644 --- a/apps/api/src/vehicles/vin-cache-keys.spec.ts +++ b/apps/api/src/vehicles/vin-cache-keys.spec.ts @@ -16,7 +16,8 @@ describe("vinResolveCacheKeys (Q4)", () => { it("keeps the in-flight lock key version-independent (cross-deploy dedupe)", () => { const { lockKey } = vinResolveCacheKeys(VIN); expect(lockKey).toBe(`vin:lock:${VIN}`); - expect(lockKey).not.toContain(DECODE_CHAIN_VERSION); + // The VIN itself may contain the version digit — check the segment, not the char. + expect(lockKey).not.toContain(`:${DECODE_CHAIN_VERSION}:`); }); it("bumping the version changes the namespace (old entries become unreachable)", () => { diff --git a/apps/api/src/vehicles/vin-cache-keys.ts b/apps/api/src/vehicles/vin-cache-keys.ts index b4598b2..0477bf3 100644 --- a/apps/api/src/vehicles/vin-cache-keys.ts +++ b/apps/api/src/vehicles/vin-cache-keys.ts @@ -13,7 +13,7 @@ * Both the decode path (VehiclesService) and the admin cache-buster * (InternalVehiclesService) MUST build keys through here so they stay in sync. */ -export const DECODE_CHAIN_VERSION = "2"; +export const DECODE_CHAIN_VERSION = "3"; export function vinResolveCacheKeys(vin: string): { cacheKey: string; diff --git a/apps/web/src/components/catalog/report-catalog-issue.tsx b/apps/web/src/components/catalog/report-catalog-issue.tsx new file mode 100644 index 0000000..3f39fc6 --- /dev/null +++ b/apps/web/src/components/catalog/report-catalog-issue.tsx @@ -0,0 +1,79 @@ +import { useTranslation } from "@/lib/i18n"; +import { capture } from "@/lib/posthog"; +import { + type CatalogIssueContext, + type CatalogIssueKind, + reportCatalogDegradation, +} from "@/lib/sentry"; +import { Button } from "@sase/ui"; +import { Check, Flag } from "lucide-react"; +import { useEffect, useState } from "react"; + +/** + * Fire-and-forget: when `active` becomes true, report the catalog degradation the + * user is looking at to Sentry (with a flushed session replay so it's watchable). + * Deduped per session in the lib, so re-renders are safe. + */ +export function useReportCatalogDegradation( + kind: CatalogIssueKind, + active: boolean, + ctx: CatalogIssueContext, +): void { + const { vehicleId, categoryId, vehicleLabel, categoryName, source, vin } = ctx; + useEffect(() => { + if (!active) return; + void reportCatalogDegradation(kind, { + vehicleId, + categoryId, + vehicleLabel, + categoryName, + source, + vin, + }); + }, [active, kind, vehicleId, categoryId, vehicleLabel, categoryName, source, vin]); +} + +/** + * "Çalışmadı mı? Bildir" — one-click report, no form. Captures a + * `catalog_issue_reported` PostHog event carrying the vehicle/category context + * (the identified person already carries the user's email), which the Süper + * Panel surfaces under /analytics/reported-categories. The Sentry degradation + * report with session replay still fires automatically via the hook above — + * this button is the explicit "a human cared enough to press it" signal. + */ +export function ReportCatalogIssueButton({ + ctx, + className, +}: { + ctx: CatalogIssueContext; + className?: string; +}) { + const { t } = useTranslation(); + const [reported, setReported] = useState(false); + return ( + + ); +} diff --git a/apps/web/src/components/schema/parts-panel.tsx b/apps/web/src/components/schema/parts-panel.tsx index 8ec2b8c..1a2fabd 100644 --- a/apps/web/src/components/schema/parts-panel.tsx +++ b/apps/web/src/components/schema/parts-panel.tsx @@ -1,3 +1,4 @@ +import { ReportCatalogIssueButton } from "@/components/catalog/report-catalog-issue"; import type { Part } from "@/hooks/use-parts"; import { api } from "@/lib/api-client"; import { capture } from "@/lib/posthog"; @@ -36,6 +37,34 @@ export function PartsPanel({ const viewedKeyRef = useRef(null); const [copiedId, setCopiedId] = useState(null); const [resolvingCode, setResolvingCode] = useState(null); + // OEM codes that resolve to a non-empty cross-reference page. Only these are + // rendered as links — unmatched codes stay plain text so a click never lands + // on an empty "no equivalents" page. One batch lookup per parts list. + const [matchedOemCodes, setMatchedOemCodes] = useState>(new Set()); + + const oemCodes = useMemo( + () => [...new Set(parts.map((p) => p.oemCode).filter((c) => c && c !== "N/A"))], + [parts], + ); + + useEffect(() => { + if (oemCodes.length === 0) { + setMatchedOemCodes(new Set()); + return; + } + let cancelled = false; + api + .post<{ matched: string[] }>("/p/matched", { codes: oemCodes }) + .then((res) => { + if (!cancelled) setMatchedOemCodes(new Set(res?.matched ?? [])); + }) + .catch(() => { + if (!cancelled) setMatchedOemCodes(new Set()); + }); + return () => { + cancelled = true; + }; + }, [oemCodes]); // PL24 "bk. tablo:NNN-NNN" cross-reference jump. Resolved at load → open the // target illustration directly. Unresolved (target branch not seeded yet) → @@ -204,23 +233,30 @@ export function PartsPanel({ {parts.length === 0 ? (

Bu kategori için parça bulunamadı.

- +
+ + {/* User-driven report: a parts shop knows if this category SHOULD + have parts. One click → structured Sentry feedback + replay. */} + +
) : ( @@ -344,7 +380,7 @@ export function PartsPanel({ diff --git a/apps/web/src/components/survey-popover.tsx b/apps/web/src/components/survey-popover.tsx new file mode 100644 index 0000000..73559e2 --- /dev/null +++ b/apps/web/src/components/survey-popover.tsx @@ -0,0 +1,284 @@ +import { + type SurveyResponses, + closeActiveSurvey, + dismissActiveSurvey, + submitActiveSurvey, + subscribeActiveSurvey, +} from "@/lib/surveys"; +import { Button } from "@sase/ui"; +import { X } from "lucide-react"; +import type { Survey } from "posthog-js"; +import { useEffect, useRef, useState } from "react"; + +// Self-rendered PostHog survey popover (API-mode surveys). Question copy comes +// from the survey definition in PostHog; this component only knows how to draw +// the three question shapes we use (single choice, open text, rating/NPS). + +const OTHER_CHOICE = "__other__"; + +// Rating scales render as one equal-width row. Static map so Tailwind's JIT +// keeps these classes — a template-literal `grid-cols-${n}` would get purged. +// (NPS = 0–10 → 11 buttons; CSAT/CES → 3/5/7.) +const RATING_COLS: Record = { + 3: "grid-cols-3", + 5: "grid-cols-5", + 7: "grid-cols-7", + 11: "grid-cols-11", +}; + +export function SurveyPopover() { + const [survey, setSurvey] = useState(null); + useEffect(() => subscribeActiveSurvey(setSurvey), []); + if (!survey) return null; + return ; +} + +function SurveyCard({ survey }: { survey: Survey }) { + const [step, setStep] = useState(0); + const [answers, setAnswers] = useState({}); + const [choice, setChoice] = useState(null); + const [otherText, setOtherText] = useState(""); + const [openText, setOpenText] = useState(""); + const [rating, setRating] = useState(null); + const [done, setDone] = useState(false); + const [shown, setShown] = useState(false); + const advanceTimer = useRef | null>(null); + const dismissRef = useRef<() => void>(() => {}); + + const questions = survey.questions; + const q = questions[step]; + + useEffect(() => { + if (!done) return; + const t = setTimeout(() => closeActiveSurvey(), 6000); + return () => clearTimeout(t); + }, [done]); + + // Cancel a pending rating auto-advance if the card unmounts mid-debounce. + useEffect(() => () => clearTimeout(advanceTimer.current ?? undefined), []); + + // Entrance: mount hidden, flip on the next frame so the CSS transition runs + // (slide-up + fade). prefers-reduced-motion users skip straight to the end + // state via the motion-reduce: utilities on the card. + useEffect(() => { + const id = requestAnimationFrame(() => setShown(true)); + return () => cancelAnimationFrame(id); + }, []); + + // Esc closes the popover (counts as a dismissal). The listener is attached + // once; dismissRef keeps it pointed at the latest closure so it captures the + // current answers/step rather than a stale first-render one. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") dismissRef.current(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + const qid = q?.id ?? `q${step}`; + const choices = q && "choices" in q && Array.isArray(q.choices) ? q.choices : []; + const hasOpenChoice = Boolean(q && "hasOpenChoice" in q && q.hasOpenChoice); + const scale = q && "scale" in q && typeof q.scale === "number" ? q.scale : 5; + const lowerLabel = q && "lowerBoundLabel" in q ? (q.lowerBoundLabel ?? "") : ""; + const upperLabel = q && "upperBoundLabel" in q ? (q.upperBoundLabel ?? "") : ""; + const optional = Boolean(q && "optional" in q && q.optional); + + function currentValue(): string | number | null { + if (!q) return null; + if (q.type === "single_choice") { + if (choice === OTHER_CHOICE) return otherText.trim() || null; + return choice; + } + if (q.type === "rating") return rating; + if (q.type === "open") return openText.trim() || null; + return null; + } + + const canContinue = optional || currentValue() !== null; + + function collect(): SurveyResponses { + const value = currentValue(); + return value === null && !(qid in answers) + ? { ...answers, [qid]: null } + : { ...answers, [qid]: value }; + } + + function proceed(next: SurveyResponses) { + setAnswers(next); + if (step + 1 < questions.length) { + setStep(step + 1); + setChoice(null); + setOtherText(""); + setOpenText(""); + setRating(null); + return; + } + submitActiveSurvey(next); + setDone(true); + } + + function advance() { + proceed(collect()); + } + + // Rating questions auto-advance — tapping a score IS the answer, so there's no + // separate submit button. A short debounce lets the user change their pick + // (and re-tapping clears the prior timer) before the card moves on. + function selectRating(v: number) { + setRating(v); + clearTimeout(advanceTimer.current ?? undefined); + advanceTimer.current = setTimeout(() => proceed({ ...answers, [qid]: v }), 450); + } + + function dismiss() { + if (done) { + closeActiveSurvey(); + return; + } + dismissActiveSurvey(collect()); + } + dismissRef.current = dismiss; + + const buttonLabel = + (q && "buttonText" in q && q.buttonText) || (step + 1 < questions.length ? "Devam" : "Gönder"); + const ratingValues = Array.from({ length: scale === 10 ? 11 : scale }, (_, i) => + scale === 10 ? i : i + 1, + ); + + return ( +
+ + + {done ? ( +
+

+ {survey.appearance?.thankYouMessageHeader || "Teşekkürler!"} +

+ {survey.appearance?.thankYouMessageDescription ? ( +

+ {survey.appearance.thankYouMessageDescription} +

+ ) : null} + +
+ ) : q ? ( +
+ {questions.length > 1 ? ( +

+ {step + 1} / {questions.length} +

+ ) : null} +

{q.question}

+ + {q.type === "single_choice" ? ( +
+ {choices.map((c) => ( + + ))} + {hasOpenChoice ? ( + <> + + {choice === OTHER_CHOICE ? ( +
- {part.oemCode && ( + {part.oemCode && part.oemCode !== "N/A" && ( {part.quantity}