75 Commits

Author SHA1 Message Date
8c1d247c45 Merge remote-tracking branch 'origin/dev'
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
2026-06-12 11:16:18 +03:00
579ee8d275 tweak(gamification): uzman programı kriteri 2/2 → 5/5
En az 5 FARKLI başarılı VIN decode VE en az 5 FARKLI OEM kodu kopyası.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:16:01 +03:00
4df06ab3a1 Merge remote-tracking branch 'origin/dev' 2026-06-12 11:05:41 +03:00
58a0faeb99 Reapply "Merge pull request 'dev' (#130) from dev into main"
This reverts commit 5c7803e12a.
2026-06-12 11:05:41 +03:00
6c414f9a59 feat(gamification): Parça Uzmanları programı feature-flag + kullanım kriteri kapısı
Program (oylama + muadil önerisi + liderlik) artık iki katmanlı kapının
arkasında: PostHog "oem-expert-program" ana şalteri (local-eval, fail-open)
VE kullanım kriteri — en az 2 FARKLI başarılı VIN decode VE en az 2 FARKLI
OEM kodu kopyası. Karar sunucuda tek noktada (ExpertAccessService.check);
web GET /oem-votes/access ile bir kez sorar, kriterler istemciye sızmaz.

Kapalıyken: sidebar nav linki görünmez, OEM detayındaki oy kartı + öneri
bölümü render edilmez, /dashboard/uzmanlar kademeli-açılış mesajı gösterir
ve liderlik sorgusu atılmaz. Yazma uçları (oy, öneri) sunucu tarafında da
ForbiddenException ile korunur — UI gizlemek tek başına güven sınırı değil.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:04:20 +03:00
fe35bbd826 feat(gamification): aylık uzman sezonları — ilk 3'e otomatik üyelik uzatması
Parça Uzmanları sıralaması artık aylık sezon: liderlik tablosu içinde
bulunulan TR-ayının (Europe/Istanbul) puanlarını gösterir ve her ayın
1'i 00:00 TR'de kendiliğinden sıfırlanır. expert-rewards cron'u
(BullMQ scheduler, "0 0 1 * *" tz=Europe/Istanbul) aynı anda biten
sezonu kapatır ve ilk 3 oylayıcıya üyelik uzatması verir:
1. → 30 gün, 2. → 15 gün, 3. → 7 gün (EXPERT_REWARD_LADDER).

Ödül mekaniği referral'la birebir: canlı active/trial abonelik endDate
+gün uzar, yoksa günler users.referral_credit_days'e bankalanır (sonraki
trial/aktivasyonda tüketilir). oem_expert_rewards (migration 0017,
period+rank UNIQUE) hem denetim kaydı hem run-once garantisi — retry ya
da elle tetik çift ödül veremez. Sıralama ölçütü job ve leaderboard'da
birebir aynı (puan desc, eşitlikte puana erken ulaşan önde).

Web: uzmanlar sayfasına sezon şeridi ("Haziran 2026 sezonu" + 🥇1 ay ·
🥈15 gün · 🥉7 gün rozetleri); alt küçük-punto kural satırına aylık
sıfırlama + ödül notu eklendi. Leaderboard cevabına periodStart eklendi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:48:36 +03:00
55894bec0e polish(web): oy butonlarından sayaçları kaldır
Uyumlu/Uyumsuz butonlarında oy sayıları artık görünmüyor (çoğunluğu ele
vermek oyu yönlendiriyordu); özet yine çekilir — kullanıcının kendi oyu
butonda işaretli kalır.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:34:44 +03:00
4068091a21 Merge pull request 'Revert PR #130 (erken prod merge geri alındı — dev'de kalacak, sonra promote)' (#131) from revert-pr130 into main 2026-06-11 21:50:14 +00:00
5c7803e12a Revert "Merge pull request 'dev' (#130) from dev into main"
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
This reverts commit eccecf73e6, reversing
changes made to 4bcac424e0.
2026-06-12 00:48:58 +03:00
24e668ea01 polish(web): oy kartı header'a — OEM kodunun sağ simetriğine, araç+parça bağlamıyla
Topluluk oyu kartının koyu dolgusu kalktı (sayfa arkaplanıyla aynı, yalnız
çerçeve); desktop'ta başlıkta OEM kodunun sağına yerleşti, mobilde kodun
altına iner. Katalogdan gelişte araç etiketi + parça adı search-param'la
taşınır (v/p/vid) ve kartta butonların üstünde "araç · parça" satırı olarak
gösterilir — neyle neyin uyumlu olduğu tek alanda. vid oy kaydına analitik
bağlam olarak geri eklendi; doğrudan ziyarette genel soru metnine düşülür.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:47:06 +03:00
eccecf73e6 Merge pull request 'dev' (#130) from dev into main
Reviewed-on: #130
2026-06-11 21:45:35 +00:00
6575c611e8 polish(web): puan kuralları yalnız Uzmanlar sayfası altında, oyunlaştırılmış dille
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Üstteki üç-kartlı kural bloğu ve oy kartındaki kural cümlesi kaldırıldı;
kurallar tek satır küçük puntoyla sayfanın en altında ("Oy ver +1 ·
çoğunluğu tuttur +2 bonus · kodu ilk değerlendiren 3 puanı kapar").
Başlık ve boş-durum metinleri de oyunlaştırıldı; oy kartında yalnız
"Her oy puan kazandırır · Parça Uzmanları sıralaması" linki kaldı.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:33:37 +03:00
4072c6e736 feat(catalog): OEM oyları detay sayfasına taşındı + topluluk muadil önerileri
Parça tablosu sadeleşti: Uyum ve Adet kolonları kalktı, liste açılışındaki
toplu istekler (/oem-votes/lookup ve /p/matched) tamamen kaldırıldı. Her OEM
kodu artık koşulsuz /dashboard/oem/$code'a linklenir — P eşleşmesi olmayan
kodda da sayfa dolu: topluluk oyu kartı, muadil önerileri ve ters katalog.

OEM detay sayfası: OemVoteCard (uyumlu/uyumsuz, sayaçlar, puan toast'ı,
puanlama özeti + Parça Uzmanları linki) ve OemSuggestionsSection — eşleşme
bulunamayan kodlar için kullanıcıdan marka + parça kodu önerisi toplar.
Öneriler oem_suggestions tablosunda (kullanıcı+kod+normalize öneri başına
tek satır, ON CONFLICT yutulur), markaya+normalize koda göre gruplanıp
"× N kullanıcı" rozetiyle listelenir; önerilen kod kendi detayına linklenir.
Şimdilik öneri puan kazandırmaz; status kolonu moderasyon kancası.

API: oem-suggestions modülü (POST 10/dk throttle, GET ?code=), migration
0016_oem_suggestions. Eski tablo-içi OemVoteButtons bileşeni silindi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:26:20 +03:00
d922fb7a02 test(catalog): cover P4 VIN group-node gate (children + loadError)
Mirrors the PSA VIN-scope tests for the new …group.action parent gate: a Ford
json-vin-sub-group.action node with a positional link_wid routes to children
(not a silent empty leaf), and an empty drill surfaces loadError.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:02:50 +03:00
8fddd09087 feat(catalog): OEM uyumluluk oylaması + Parça Uzmanları liderlik tablosu
Parça satırlarına topluluk oyu eklendi: her OEM kodu için uyumlu/uyumsuz
ikilisi (👍/👎, sayaçlı). Oylar oem_votes'a (kullanıcı+kod başına tek oy,
fikir değişikliği günceller, puan üretmez), ödüller oem_vote_points
ledger'ına yazılır: oy +1, kesin çoğunlukla aynı yönde +2 (kodu ilk
oylayan her zaman 3 alır); ödüller oy anında kesinleşir, çoğunluk sonra
dönse de geri alınmaz. Aynı koda eşzamanlı oylar advisory lock ile
sıralanır.

/dashboard/uzmanlar: Trophy Gamification UI Kit'ten (ui.trophy.so, MIT)
uyarlanan kürsü + sıralama + puan rozetiyle "Parça Uzmanları" liderlik
sayfası; adlar KVKK-maskeli (S*** Y***), cevap kullanıcı id sızdırmaz.

Not: sidebar nav linki, tr/en i18n anahtarları ve routeTree 09a9487'de
gitmişti; bu commit eksik kalan rota/bileşen/API dosyalarını tamamlayarak
dev build'ini düzeltir. Migration: 0015_oem_votes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:00:51 +03:00
09a9487564 remove changelog page and sidebar link
Drop /dashboard/changelog route, ChangelogTab component, useChangelog
hook, the sidebar nav item, and the related tr/en translation keys.
API-side changelog schemas are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:51:54 +03:00
e6c8c9c32e fix(catalog): drill P4 VIN group nodes on live click (Ford/Nissan/Opel parent-gate gap)
getCategoryWithPartsInner had parent gates for PSA (psa::/json-vin-main-groups),
pcat (hasSubgroups), pl24 link_wid *Group* tables, Volvo (vin-group.action) and
emex — but P4 VIN/legacy group nodes whose link_wid is a positional code
("1","2","CAP1"…) and whose path is a *…group.action* drill (Ford
json-vin-sub-group, Nissan/Opel json-sub-group, etc.) matched none of them. They
fell through to the leaf parts path, fetched no parts and rendered a silent empty
panel — the dominant current '0 parça' cluster (mekanik/kaporta/Elektrikli aksam…
across many vehicles; PostHog-confirmed). Add a gate that drills any pl24
…group.action node (excluding Volvo's own vin-group.action branch) → subgroups or
retryable loadError. Lazy-heals existing flag-less rows, no reseed.

Also set has_subgroups at seed time: PSA scopes → true (always parents);
P4-legacy cats → true when linkPath is a …group.action drill. Fixes folder
signposting + lets prefetch pre-drill them instead of treating them as leaves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:48:04 +03:00
4bcac424e0 Merge pull request 'dev' (#126) from dev into main
Reviewed-on: #126
2026-06-11 14:34:37 +00:00
e91159003e chore(web): remove 'click to reopen' hint under recent searches
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:33:49 +03:00
45f7e3f5a4 feat(proxy): proxy_logs telemetry — per-attempt provider/ban/latency tracking
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
One row per proxied upstream attempt (pcat call/capture/validate, emex http)
written fire-and-forget by the new ProxyTelemetryService (buffered, capped,
errors swallowed — telemetry can never hurt the request path).

- banned = upstream 403/429 (IP-block signal), distinct from auth/data errors
- sticky legs (pcat capture, emex floxy) carry a session_key; pcat capture
  also resolves the actual residential exit IP via a parallel ipify probe
  through the same sticky session → concrete banned-IP tracking
- rotating legs log provider + outcome (ban *rate* instead of per-IP)
- 30-day retention piggybacked on the query-cleanup job

Feeds the Süper Panel /analytics/proxy page (provider grading + banned IPs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:27:48 +03:00
9f47d15312 feat(web): hide Chatwoot launcher bubble on mobile
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Floating launcher covered content on small screens. Bubble is hidden
below 768px via injected CSS; programmatic openChatwoot() callers
(subscription page, onboarding modal) still open the fullscreen widget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:26:04 +03:00
3c9245bd8b feat(web): keep sidebar on blog & contact for logged-in users
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Sidebar linked /blog and /contact pointed at public marketing pages, so
clicking them dropped the user out of the dashboard shell. Extract page
content into shared components (blog-content, contact-content) and add
dashboard-wrapped routes /dashboard/blog, /dashboard/blog/$slug and
/dashboard/contact; sidebar now links to those. Public SEO pages stay
unchanged and remain the canonical URLs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:25:14 +03:00
ffcd417939 Merge pull request 'hotfix(pcat): widen JWT capture for slow residential IPs' (#129) from hotfix-pcat-capture into main 2026-06-11 13:59:37 +00:00
75d5b11475 fix(pcat): widen JWT capture for slow residential IPs (domcontentloaded + 12s poll)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
After the Floxy account rotation, the new residential exit IPs are slower:
measured DOM-ready ~7s and the widget's /v3/api/proxy token call firing ~11.8s —
past the 10s PAGE_TIMEOUT + networkidle, so every capture timed out ("No token
after 12s") and the warm pool stayed empty (pcat dead despite valid creds).

- waitUntil networkidle → domcontentloaded (reliable ~7s; networkidle often never
  settles on JS catalog sites through a slow proxy → goto times out pre-token).
- PAGE_TIMEOUT 10s → 25s (env PCAT_PAGE_TIMEOUT_MS); token fired at ~11.8s.
- CAPTURE_POLL_AFTER_OK 5s → 12s (env PCAT_CAPTURE_POLL_OK) to cover the ~5s
  gap between DOM-ready and the token call. Capture is background (warm pool,
  cold-pool fast-fail never blocks users), so the longer cap is free insurance.

Verified via standalone Playwright probe through the new Floxy: token captured at
11842ms (DOM 7023ms). typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:59:34 +03:00
4a6e673af1 fix(pcat): widen JWT capture for slow residential IPs (domcontentloaded + 12s poll)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
After the Floxy account rotation, the new residential exit IPs are slower:
measured DOM-ready ~7s and the widget's /v3/api/proxy token call firing ~11.8s —
past the 10s PAGE_TIMEOUT + networkidle, so every capture timed out ("No token
after 12s") and the warm pool stayed empty (pcat dead despite valid creds).

- waitUntil networkidle → domcontentloaded (reliable ~7s; networkidle often never
  settles on JS catalog sites through a slow proxy → goto times out pre-token).
- PAGE_TIMEOUT 10s → 25s (env PCAT_PAGE_TIMEOUT_MS); token fired at ~11.8s.
- CAPTURE_POLL_AFTER_OK 5s → 12s (env PCAT_CAPTURE_POLL_OK) to cover the ~5s
  gap between DOM-ready and the token call. Capture is background (warm pool,
  cold-pool fast-fail never blocks users), so the longer cap is free insurance.

Verified via standalone Playwright probe through the new Floxy: token captured at
11842ms (DOM 7023ms). typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:59:17 +03:00
d82bcb560b Merge pull request 'hotfix(proxy): rotate Floxy creds (402 balance) — restore pcat' (#128) from hotfix-floxy-creds into main 2026-06-11 13:49:58 +00:00
46e4b7cfcb fix(proxy): rotate Floxy creds (prior account hit HTTP 402 / balance exhausted)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The hardcoded Floxy account (d739255e819b) ran out of balance → 402 Payment
Required on every CONNECT → pcat decode dead on prod since ~07:16 UTC 2026-06-11
(pcat has no proxy fallback; EMEX limped on its DataImpulse fallback). New funded
account cac4b0d96a80 verified working (call leg 401-reachable, capture leg 200).
Updates the hardcoded defaults in all 3 Floxy consumers (pcat auth, emex http,
emex browser); env overrides PCAT_FLOXY_USER/PASS + EMEX_FLOXY_USER/PASS unchanged.

Follow-up: wire FLOXY_USER/PASS into docker-compose env so future rotations are
Coolify-env-only (no code change / rebuild).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:49:56 +03:00
2e6c8ac751 fix(proxy): rotate Floxy creds (prior account hit HTTP 402 / balance exhausted)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The hardcoded Floxy account (d739255e819b) ran out of balance → 402 Payment
Required on every CONNECT → pcat decode dead on prod since ~07:16 UTC 2026-06-11
(pcat has no proxy fallback; EMEX limped on its DataImpulse fallback). New funded
account cac4b0d96a80 verified working (call leg 401-reachable, capture leg 200).
Updates the hardcoded defaults in all 3 Floxy consumers (pcat auth, emex http,
emex browser); env overrides PCAT_FLOXY_USER/PASS + EMEX_FLOXY_USER/PASS unchanged.

Follow-up: wire FLOXY_USER/PASS into docker-compose env so future rotations are
Coolify-env-only (no code change / rebuild).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:49:37 +03:00
2f6a0e3158 Merge pull request 'Merge dev → main: surveys, P/OEM page, trial upsell, catalog-issue report, pcat timings' (#127) from merge-dev-to-main into main 2026-06-11 13:28:15 +00:00
b6e4deb7bd Merge dev into main: catalog-issue report + pcat skip/transient timings
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Resolves the recurring promote-by-cherry-pick divergence (decode/pcat/emex live
on both branches with different SHAs — all auto-merged). Net-new to main:
- fix(decode) fd78957: mark pcat skip/transient in timings (stop false catalog-gap reads)
- feat(catalog) c972a17: one-click report on empty/error catalog states
- compose: P_DB_ENABLED/P_DB_URL env (inert until set in Coolify)

Conflict: docker-compose.coolify.yml — both sides added env at the same spot;
took dev's superset (main's Directus/Blog vars + dev's P_DB vars).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	docker-compose.coolify.yml
2026-06-11 16:25:57 +03:00
c972a17670 feat(catalog): one-click report on empty/error catalog states — no form
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
'Çalışmadı mı? Bildir' opened the Sentry feedback dialog; most users
bail at a form. Now a single click captures a catalog_issue_reported
PostHog event with the vehicle/category context (vin, vehicle_label,
category_name, source, page) and flips to 'Bildirildi — teşekkürler'.
The identified person already carries the reporter's email; the Süper
Panel lists these under /analytics/reported-categories. The automatic
Sentry degradation report (with session replay) still fires via
useReportCatalogDegradation — unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:00:23 +03:00
fd789574fd fix(decode): mark pcat skip/transient in timings — stop false catalog-gap reads
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Cold-pool fast-fail (and transport blips) make pcat return null without
ever evaluating the VIN; EMEX then identifies the car and the row lands
as 'No catalog — identified as X' (pcat sub-timing 1-2ms). Downstream
catalog-gap detection can't tell these from true coverage gaps and
flags covered brands (Audi, 2026-06-11, 7 retries by one user).

- timings.pcat_skipped=1 on cold-pool fast-fail
- timings.pcat_transient=1 on transport failure (circuit-open skips
  already carry pcat_circuit_open)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:50:36 +03:00
4b5f3bb41e Merge pull request 'promote(emex): Floxy residential primary transport' (#125) from promote-emex-floxy into main 2026-06-10 19:24:21 +00:00
63908b1fca perf(emex): Floxy residential primary transport (was DataImpulse-first)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
EMEX egress was DataImpulse-first: fetchEmexHtml tried 3 dead-prone DataImpulse
ports before falling to the Floxy fallback, and the browser scraper launched on a
random DataImpulse port. With ~50% of DataImpulse ports dead, this burned the 25s
decode budget before reaching Floxy (observed: a Ford VIN where pcat correctly
returned null fast, then EMEX's DataImpulse retries ate the budget).

ssd tokens are replayed from emex's own HTML and are NOT strictly IP-bound on the
.aspx endpoints (the prior random-port-per-call primary proved that), so a sticky
Floxy IP across the flow is strictly safer.

- EMEX_PROXY_PROVIDER (default "floxy" | "dataimpulse" | "none"). floxy →
  fetchEmexHtml schedule = [floxy, floxy, dataimpulse] (Floxy sticky primary,
  rolls IP on transport failure, single DataImpulse last-ditch). dataimpulse →
  legacy [di,di,di,floxy,floxy]. Default HTTP agent (image-dims) follows provider.
- EmexBrowserService launches chromium on a sticky Floxy session (one residential
  exit IP for the browser lifetime; fresh id per relaunch) under the same flag.
- Reuses the existing EMEX_FLOXY_* config (same Floxy account pcat now uses).

Rollback: EMEX_PROXY_PROVIDER=dataimpulse. typecheck+biome+11 emex tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:23:36 +03:00
ef4ab9bcd5 perf(emex): Floxy residential primary transport (was DataImpulse-first)
EMEX egress was DataImpulse-first: fetchEmexHtml tried 3 dead-prone DataImpulse
ports before falling to the Floxy fallback, and the browser scraper launched on a
random DataImpulse port. With ~50% of DataImpulse ports dead, this burned the 25s
decode budget before reaching Floxy (observed: a Ford VIN where pcat correctly
returned null fast, then EMEX's DataImpulse retries ate the budget).

ssd tokens are replayed from emex's own HTML and are NOT strictly IP-bound on the
.aspx endpoints (the prior random-port-per-call primary proved that), so a sticky
Floxy IP across the flow is strictly safer.

- EMEX_PROXY_PROVIDER (default "floxy" | "dataimpulse" | "none"). floxy →
  fetchEmexHtml schedule = [floxy, floxy, dataimpulse] (Floxy sticky primary,
  rolls IP on transport failure, single DataImpulse last-ditch). dataimpulse →
  legacy [di,di,di,floxy,floxy]. Default HTTP agent (image-dims) follows provider.
- EmexBrowserService launches chromium on a sticky Floxy session (one residential
  exit IP for the browser lifetime; fresh id per relaunch) under the same flag.
- Reuses the existing EMEX_FLOXY_* config (same Floxy account pcat now uses).

Rollback: EMEX_PROXY_PROVIDER=dataimpulse. typecheck+biome+11 emex tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:18:29 +03:00
1392a642a9 Merge pull request 'promote(pcat): rotating Floxy residential transport + resilience' (#124) from promote-pcat-floxy into main 2026-06-10 18:58:32 +00:00
b886a05b5b perf(pcat): rotating Floxy residential transport + resilience (decode latency)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Prod decode was dominated by the DataImpulse proxy: ~50% of ports dead at any
moment, 57% of captured tokens discarded by validation, the dead-port slot
never evicted (invalidateSession only fired on 401/403, never on transport
timeout), and a cold pool blocked the user inline on a multi-second capture.
pcat sub-timing was p50 8.3s / p90 56s, blowing the 25s decode budget.

Verified on prod: the TWS- token is NOT request-time IP-bound (0 auth 401/403
across thousands of calls; a token captured on one IP replays 200 through
arbitrary fresh residential IPs). So the proxy can rotate freely.

- Transport swap to Floxy residential (PCAT_PROXY_PROVIDER=floxy|dataimpulse|none).
  Call/validate leg = a fresh exit IP per request (max IP diversity → min per-IP
  ban); capture leg = a sticky session per Playwright attempt. DataImpulse +
  direct kept as env fallbacks. Token decoupled from the slot (proxyPort removed).
- Prune 3 zero-yield JWT sites (knkauto/autodo.kz/flynestauto — 0 captures, ~80%
  of attempts); add PCAT_JWT_SITES env override.
- Lower catalog-scrape timeout 30s→10s (env-tunable) so a slow IP aborts within
  budget and the retry rotates to a fresh IP.
- Cold-pool fast-fail on the decode race: warm in background, let EMEX/PL24
  answer instead of blocking inline; treated as a transient miss (no neg-cache).
- Evict a token after N consecutive transport failures on rotating IPs (the
  dead-slot bug); reset on any 2xx.
- PCAT circuit breaker mirroring PL24 (transient-only; cold-skip neutral; skip
  marks outcome.transient so a pcat-only VIN isn't negative-cached 30m).

Typecheck + Biome + unit tests (categories/vehicles) green. Default flips pcat
to Floxy on next deploy; instant rollback via PCAT_PROXY_PROVIDER.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:56:45 +03:00
25ba37103d perf(pcat): rotating Floxy residential transport + resilience (decode latency)
Prod decode was dominated by the DataImpulse proxy: ~50% of ports dead at any
moment, 57% of captured tokens discarded by validation, the dead-port slot
never evicted (invalidateSession only fired on 401/403, never on transport
timeout), and a cold pool blocked the user inline on a multi-second capture.
pcat sub-timing was p50 8.3s / p90 56s, blowing the 25s decode budget.

Verified on prod: the TWS- token is NOT request-time IP-bound (0 auth 401/403
across thousands of calls; a token captured on one IP replays 200 through
arbitrary fresh residential IPs). So the proxy can rotate freely.

- Transport swap to Floxy residential (PCAT_PROXY_PROVIDER=floxy|dataimpulse|none).
  Call/validate leg = a fresh exit IP per request (max IP diversity → min per-IP
  ban); capture leg = a sticky session per Playwright attempt. DataImpulse +
  direct kept as env fallbacks. Token decoupled from the slot (proxyPort removed).
- Prune 3 zero-yield JWT sites (knkauto/autodo.kz/flynestauto — 0 captures, ~80%
  of attempts); add PCAT_JWT_SITES env override.
- Lower catalog-scrape timeout 30s→10s (env-tunable) so a slow IP aborts within
  budget and the retry rotates to a fresh IP.
- Cold-pool fast-fail on the decode race: warm in background, let EMEX/PL24
  answer instead of blocking inline; treated as a transient miss (no neg-cache).
- Evict a token after N consecutive transport failures on rotating IPs (the
  dead-slot bug); reset on any 2xx.
- PCAT circuit breaker mirroring PL24 (transient-only; cold-skip neutral; skip
  marks outcome.transient so a pcat-only VIN isn't negative-cached 30m).

Typecheck + Biome + unit tests (categories/vehicles) green. Default flips pcat
to Floxy on next deploy; instant rollback via PCAT_PROXY_PROVIDER.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:19:14 +03:00
a237e55874 docs: add repository index (PROJECT_INDEX.md + PROJECT_INDEX.json)
Token-efficient session bootstrap: module map, entry points, routes,
jobs, deps, test/deploy conventions. Regenerate via /sc:index-repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:25:35 +03:00
8b7dc83bd2 feat(p): reverse catalog on OEM detail — your vehicles using this code
New "Bu kod kataloğunuzda" section lists the user's decoded vehicles
whose parts include the queried OEM code, each linking to the schema
page that shows it. Pure sase data (parts.oem_code → vehicles, indexed
exact match) — no TecDoc / vehicle-structure dependency.
GET /parts/oem-vehicles?code=.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:26:48 +03:00
c2dfa5cd2e fix(decode): recognize VXK WMI as Opel (PSA/Stellantis-platform Corsa F, Mokka B)
Customer entered VXKUPHNKSP4032040 (2023 Corsa F) and got a bare
'Marka desteklenmiyor' — VXK was missing from all three WMI maps, so
identifyBasic could not even name the brand. PL24's opel_parts catalog
ends at CORSA-E (2015-2019, verified via the model-picker page), so the
catalog itself stays a gap, but the user now gets the honest
'Opel 2023 olarak tanındı, katalog henüz mevcut değil' message and the
miss is logged for the coverage backlog.

- WMI_BRAND_MAP + PL24_WMI_SERVICE_MAP + EMEX CATALOG_MAP: VXK -> Opel
- DECODE_CHAIN_VERSION 2 -> 3 (brand-mapping change; invalidates the
  stale negative cache for the customer's VIN)
- vin-cache-keys.spec: lock-key assertion checked the bare version char,
  which now appears inside the test VIN — assert the ✌️ segment instead

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:36:51 +03:00
0652fb3a18 Merge pull request 'promote(decode): opaque candidate keys + slim decode response' (#122) from promote-decode into main 2026-06-10 09:04:20 +00:00
7aafdc92b6 perf(decode): slim decode response to id + display summary
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
POST /vehicles/decode returned the full vehicles row while every consumer
(search, landing, service-test) only navigates by id and re-fetches via
GET /vehicles/:id. That shipped the raw upstream decode payload to the
client on every decode — rawData is 19KB avg / 127KB p95 / 207KB max in
prod — plus the provider name, for nothing.

The response is now { id, brandName, model, year } on both the DB-hit and
fresh-decode paths. Frontend drops the (now absent) source property from
the vin_decode_success event; decode source remains queryable server-side
via query_logs.source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 02c8321023)
2026-06-10 12:01:40 +03:00
6154656c3e refactor(decode): keep decode-source details server-side, opaque candidate keys
The multi-candidate decode response leaked provider internals (source name,
pcat car ids, EMEX _ssd/_vid/_quickGroupsUrl/catalogId) and made the client
carry them between requests: the frontend stored candidateSource and echoed
pcatCarId/emexCarIndex back on selection.

Now the candidate list returned to the client carries only display fields
(name, description, parameters) plus an opaque key, and the provider mapping
is stashed in Redis (vin:candidates:*, 30m TTL, resolve-cache fallback). The
pick request sends just { vin, candidate }. Legacy pcatCarId/emexCarIndex
body params still work for already-loaded bundles.

Also drops `source` from the public /vehicles/preview response — no consumer
used it, and provider names must never be public (same policy as
teaser-stats).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 2077a9724a)
2026-06-10 12:01:40 +03:00
02c8321023 perf(decode): slim decode response to id + display summary
POST /vehicles/decode returned the full vehicles row while every consumer
(search, landing, service-test) only navigates by id and re-fetches via
GET /vehicles/:id. That shipped the raw upstream decode payload to the
client on every decode — rawData is 19KB avg / 127KB p95 / 207KB max in
prod — plus the provider name, for nothing.

The response is now { id, brandName, model, year } on both the DB-hit and
fresh-decode paths. Frontend drops the (now absent) source property from
the vin_decode_success event; decode source remains queryable server-side
via query_logs.source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 02:52:43 +03:00
2077a9724a refactor(decode): keep decode-source details server-side, opaque candidate keys
The multi-candidate decode response leaked provider internals (source name,
pcat car ids, EMEX _ssd/_vid/_quickGroupsUrl/catalogId) and made the client
carry them between requests: the frontend stored candidateSource and echoed
pcatCarId/emexCarIndex back on selection.

Now the candidate list returned to the client carries only display fields
(name, description, parameters) plus an opaque key, and the provider mapping
is stashed in Redis (vin:candidates:*, 30m TTL, resolve-cache fallback). The
pick request sends just { vin, candidate }. Legacy pcatCarId/emexCarIndex
body params still work for already-loaded bundles.

Also drops `source` from the public /vehicles/preview response — no consumer
used it, and provider names must never be public (same policy as
teaser-stats).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 02:37:02 +03:00
43dd4d38ac feat(surveys): entrance animation, step progress, Esc-to-close
Polish pass on the survey popover:
- Entrance: slide-up + fade on mount (300ms ease-out, respects
  prefers-reduced-motion via motion-reduce:transition-none).
- Step progress: "1 / N" indicator on multi-question surveys so users know
  how much is left.
- Esc closes the card (counts as a dismissal); listener attached once with a
  dismissRef so it captures current answers, not a stale first-render closure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 01:28:45 +03:00
34cb361b67 fix(surveys): one-row rating scale, rating auto-advance, mobile spacing
Design review of the live survey popover surfaced three issues:
1. NPS 0–10 wrapped to two rows (flex-wrap + flex-1 made the 8/9/10 row
   stretch absurdly wide) — now a single equal-width grid row (grid-cols-N
   by scale; static map so Tailwind keeps the classes).
2. Single-question ratings forced a redundant "Devam" tap — tapping a score
   now auto-advances (450ms debounce to allow changing the pick) and the
   submit button is hidden on rating questions.
3. On mobile the card crowded the Chatwoot launcher — bumped to bottom-28
   (sm:bottom-24 keeps desktop unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 01:19:59 +03:00
c18c513139 feat(p): drop EAN from the OEM detail page
Remove the EAN line from the matched-article cards and stop fetching/
shipping eans in /p/oem (its only consumer was that line). Snapshot
still holds the data; the endpoint just no longer queries it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 01:17:22 +03:00
5a13f13e8e perf(p): instant OEM nav + trim 30x-bloated payload
OEM detail "slow load" was two things, not the query (DB 135ms / API
13-120ms):
1. The parts-panel link opened a NEW TAB → full SPA cold boot every
   click. Switch to in-app client navigation on plain click (real href
   kept, so ctrl/cmd/middle-click still opens a new tab).
2. The /p/oem response shipped each article's oeNumbers + compatible
   lists (up to 200 each × 60 articles) that the UI never renders — 96%
   of a 370 KB payload. Ship lean articles; aggregates already carry
   the cross-refs. 60-article code: 370 KB → ~12 KB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 01:06:29 +03:00
f29730fae0 fix(surveys): hold captures until the survey event receiver is hooked
trial_urgency_banner_viewed fires ~1s into the page load and raced the lazy
surveys extension — events captured before its hook exists can never activate
an event-triggered survey (affects real users, not just e2e). capture() now
awaits surveysReceiverReady() (onSurveysLoaded, 3s safety timeout) and runs the
eligibility check after the event has gone through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:55:06 +03:00
c1ae18e5f1 feat(p): brand/code search box on the OEM detail page
Long cross-reference lists (some parts have 90+ aftermarket numbers,
160+ OE codes) were hard to scan. Add a filter input that matches
across all three sections (matched articles, aftermarket equivalents,
OE cross-refs) by brand (substring) or code (normalised, so "1j0 973"
finds "1J0 973 702"). Clear button + "no results" state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:51:13 +03:00
7f5db3234b fix(posthog): disable bot UA filter on staging so e2e can exercise surveys
posthog-js silently drops every capture when userAgentData.brands contains
HeadlessChrome (UA string/webdriver masking doesn't help), which also starves
event-triggered surveys in Playwright runs against dev.sase.tr. Production
keeps the bot filter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:49:20 +03:00
72c3fe0dc2 feat(surveys): replace Formbricks with self-rendered PostHog API surveys
Formbricks CE turned out to gate person-based targeting (setUserId/attributes
→ 403 enterprise) — the exact flexibility surveys need. PostHog already holds
the person properties, so surveys move back there in API mode: posthog-js
evaluates eligibility (event triggers, targeting flags like
subscription_status=active, wait periods, per-distinct_id dedup) via
getActiveMatchingSurveys, and we render the popover ourselves — zero PostHog
branding, sase.tr dark-theme styling.

- lib/surveys.ts: display manager + capture payload builders that mirror
  posthog-js's own "survey shown/dismissed/sent" shapes exactly
  ($survey_response_<qid>, $set $survey_dismissed/<id>[/iter], seenSurvey_*,
  lastSeenSurveyDate) so the PostHog Surveys results UI works unchanged
- components/survey-popover.tsx: single_choice (+Diğer), open text, rating/NPS
- posthog.ts: capture/pageview/identify now schedule survey checks; register
  deploy_env (dev.sase.tr ships the key now → staging traffic is filterable)
- remove @formbricks/js, its CSP entries and build args

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:40:18 +03:00
7ce8e34d81 Merge pull request 'feat(blog): CMS cover images via MinIO, instant cache purge, drop embedded posts' (#121) from blog-cms-assets into main 2026-06-09 21:22:51 +00:00
4e67f30d6d feat(blog): CMS cover images via MinIO, instant cache purge, drop embedded posts
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
- cover_file uploads in Directus land in the public blog-assets MinIO bucket;
  API resolves them to storage.sase.tr URLs (Directus itself is Tailscale-only)
- POST /blog/cache/purge (automation token) lets a Directus Flow drop the
  30-min list cache the moment a post changes in the CMS
- blog list + detail pages now render purely from the API; the 4 hand-authored
  posts were migrated to Directus earlier and the JSX copies are removed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:22:49 +03:00
4ffc3577a5 feat(p): link only OEM codes with a cross-reference match
Every OEM code was a link → unmatched codes (the majority — fasteners,
clips, body parts TecDoc doesn't carry) opened an empty "no
equivalents" page. Now the schema page batch-checks its codes against
the snapshot (POST /p/matched, one indexed query) and links ONLY the
matched ones; unmatched codes render as plain text. Copy stays for all.
No more dead-end empty pages from the parts panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:21:25 +03:00
572337ba62 feat(blog): CMS cover images via MinIO, instant cache purge, drop embedded posts
- cover_file uploads in Directus land in the public blog-assets MinIO bucket;
  API resolves them to storage.sase.tr URLs (Directus itself is Tailscale-only)
- POST /blog/cache/purge (automation token) lets a Directus Flow drop the
  30-min list cache the moment a post changes in the CMS
- blog list + detail pages now render purely from the API; the 4 hand-authored
  posts were migrated to Directus earlier and the JSX copies are removed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:17:40 +03:00
07cf710bc3 fix(csp): allow Formbricks SDK in script-src
@formbricks/js is a loader shim that injects the real SDK as an external
script from anket.sase.tr — connect-src alone wasn't enough; the browser
blocked /js/formbricks.umd.cjs and no survey could render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:03:05 +03:00
f955bb7ef8 fix(vehicles): canonicalise brand casing (case-insensitive brand match)
Decode persist looked brands up with a case-sensitive eq(), so an
uppercase decode string ("FORD") missed canonical "Ford" → brand_id
NULL + raw uppercase stored as brand_name, splitting one brand across
casing variants in analytics/catalog. Now matches brands
case-insensitively and stores the canonical name. Migration
0013_fix_brand_casing backfills existing rows (60 on prod, 1 on dev).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:47:33 +03:00
597391bbdf feat(surveys): Formbricks in-app survey bridge over PostHog events
Self-hosted Formbricks (anket.sase.tr) replaces PostHog surveys (free-tier
branding). PostHog stays the single instrumentation source: capture() forwards
allowlisted trigger events (trial_urgency_banner_viewed, subscription_cancelled,
onboarding_completed, vin_decode_error, empty_catalog_cta_clicked) to the
Formbricks SDK, identify/reset/people-properties mirror into Formbricks
attributes, and $pageview registers SPA route changes for no-code triggers.

- apps/web/src/lib/formbricks.ts: lazy fire-and-forget wrapper (inert without
  VITE_FORMBRICKS_APP_URL + VITE_FORMBRICKS_ENV_ID)
- CSP: allow anket.sase.tr in connect-src/img-src
- Dockerfile + compose: bake the two VITE_ build args

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:44:45 +03:00
4716232398 fix(p): natural empty-state copy (drop literal "P" from user text)
"P karşılığı bulunamadı" / "P kapsamında" read as nonsense — "P" is
the internal name, not a user-facing word. Use plain Turkish: "Bu OEM
kodu için uyumlu parça bulunamadı" and describe the catalog gap
without a brand or single-letter label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:37:15 +03:00
e91af4b953 refactor(p): complete TecDoc → P content rename
Finishes 3b14431 (which only captured the file renames): apply the
identifier/endpoint/env/UI changes so the code matches the new paths
— PModule/PController/PSourceDbService, @Controller("p"), /p/oem,
config key `p`, P_DB_ENABLED/P_DB_URL, "P kataloğundan…" copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:30:46 +03:00
3b144313b3 refactor(p): rename TecDoc → P (parts) across the cross-reference feature
Drop the third-party brand name from the product surface and code.
The OEM detail feature is now "P" (short for parts) everywhere:
endpoint /p/oem, PModule/PController/PSourceDbService, config key
`p`, env P_DB_ENABLED/P_DB_URL, UI copy ("P kataloğundan…"), and the
unused tab label. Physical snapshot DB stays `td` (server-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:29:40 +03:00
25ffec5789 Merge pull request 'feat(blog): back blog module with central Directus CMS' (#120) from blog-directus-main into main 2026-06-09 18:22:29 +00:00
aeb438e442 feat(blog): back blog module with central Directus CMS
Blog posts now live in the shared Directus instance (Coolify / Süper Panel
project) instead of the per-env blog_posts table, so prod and staging serve
identical content. API response shape is unchanged; Redis list cache and the
n8n automation endpoint keep working as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 21:13:03 +03:00
24e44a49f8 fix(tecdoc): only surface publicly-resolvable part images
The snapshot stores scrape-local '/_debug/...' image paths that 404
off-host. Filter the OEM lookup to http(s) image URLs and render the
thumbnail only when one exists, so the detail page shows clean
code-focused cards instead of a grid of broken-image placeholders.
Real CDN URLs will surface automatically once present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:00:12 +03:00
eae373c553 fix(web): accuracy sweep — drop unbuilt-feature claims, VIN→şase, B2B framing
Full marketing-copy audit. Removes price-comparison claims (pricing
step, hero variant B, blog tip) that aren't built; normalises VIN→şase
across TR copy (plan features, e-commerce section, FAQ, blog, about) —
keeping VIN only in the educational "VIN nedir" post and the EN file;
de-consumerises framing (FAQ "aracınız", about "bireysel kullanıcılar").
Fabricated metrics/testimonials left untouched pending review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:00:08 +03:00
fb2c28faf1 feat(tecdoc): OEM detail page with TecDoc cross-references
Resolve a catalog OEM code to its TecDoc equivalents on a new
/dashboard/oem/$code page: the aftermarket parts that carry it
(brand + article number + image + EAN), buyable supplier
substitutes, and OE cross-references (same part under other makes).

- API: TecdocModule (read-only postgres-js client to the imported
  `td` snapshot), GET /tecdoc/oem?code=. Normalisation-based match
  (TecDoc stores `1J0 973 702`, catalog gives `1J0973702`); exact
  match recovers ~1/10 vs normalised ~5/10 on real codes. Self-
  disables without TECDOC_DB_* env → { matched: false }.
- Web: OEM code in the parts panel is now a link (new tab) to the
  detail page; "N/A" stays plain text.
- Mirrors CatalogSourceDbModule (raw queries, no Drizzle modelling).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:52:51 +03:00
5e1675f95a fix(web): replace unbuilt feature claims with real capabilities
The landing OEM card, the manual-vs-Sase comparison table, and a blog
post advertised "price comparison" / "order history & part tracking" —
features that aren't built. Replaced with real capabilities
(exploded-diagram matching, interactive part diagrams, brand catalogs)
in both tr + en. Mirrors the same accuracy fix already shipped to the
Novu lifecycle emails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:44:58 +03:00
d3ae278c75 feat(trial): value-moment upsell to convert engaged trials earlier
Only ~1.6% of trials reach checkout: the 30-day full-access trial shows the
(urgency) banner solely in its final 7 days, leaving ~3 weeks with no in-app
push, and the value moment (first few queries) goes unconverted.

Add TrialValueUpsell — a calm, value-framed banner that fires on PROVEN VALUE
(user has decoded >= 3 customer vehicles, read server-truth via /vehicles/history)
and only OUTSIDE the urgency window (days > 7), so it fills the gap without ever
overlapping or competing with the urgency banner. B2B copy: frames around
"müşteri araçlarını sınırsız sorgulama", never personal-vehicle wording.
Tracks viewed/cta_clicked/dismissed in PostHog; dismissal persists per trial.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:33:10 +03:00
cd54cf9443 fix(observability): harden Sentry init for catalog-degradation reporting
- initSentry() is now idempotent via a cached promise, and the degradation
  reporter / feedback opener await it — so an empty/error state that renders
  before the SDK's dynamic import resolves no longer drops the event (not missing
  the failure is the whole point of the auto-capture).
- environment now resolves hostname-first (sase.tr → production, dev.sase.tr →
  staging), so the compose-baked VITE_SENTRY_ENVIRONMENT default ("production")
  no longer makes dev browser events indistinguishable from prod.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:32:22 +03:00
f02f97f69f fix(payments): prevent paid-but-not-activated on Stripe webhook retry
handleCheckoutCompleted marked the payment 'completed' BEFORE activating the
subscription, but the idempotency guard keys off payment.status === 'completed'.
So if activateSubscription threw after the payment was marked completed, the
Stripe webhook retry would hit the guard, skip activation, and leave a
paid-but-never-activated subscription.

Fix: activate FIRST, then mark completed — a mid-activation failure leaves the
payment 'pending' so the retry re-runs cleanly. Guard activateSubscription with
an idempotency check (return early when already active) so a retry after a
successful activate but failed mark-completed cannot re-fire revenue events
(PostHog subscription_activated + Meta CAPI Purchase), re-consume referral
credit, or double-insert Full-plan brands. Admin manual-activate already
pre-rejects 'active', so legitimate first activations are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:30:15 +03:00
11975d8b5a feat(payments): remove EFT/havale, Stripe-only checkout + Turkish locale
EFT/Havale was retired; Stripe is now the sole payment method. Remove the
EFT code surface (shared PaymentMethod "eft" + EftPaymentInput + eftReceiptUrl,
EFT_RECEIPT_REQUIRED error code, billing UI receipt/filter/label paths,
payments.service eft read paths). DB columns (eft_receipt_url, bank_account_id,
bank_accounts) are kept and marked @deprecated to preserve historical records
and avoid a destructive migration — same pattern as the retired iyzico column.

Faz 3 conversion lever: set locale "tr" on the Stripe Checkout session. The
audience is Turkish B2B and ~60% of sessions reached the foreign-language
hosted page but never started a payment intent (pure abandonment, not decline).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:17:10 +03:00
d6c88ede73 feat(observability): session-replay + in-app feedback on catalog UX failures
The two things only the browser can add on top of the backend catalog-degradation
reporting:

- Replay-on-failure: when the catalog UI renders empty-tree (decoded vehicle, no
  categories) or a drill loadError, capture a browser Sentry warning and flush the
  Session Replay → you can WATCH the user hit the dead-end (serkan's session,
  reproducible). Per-session deduped (one replay/session covers the whole journey).
- In-app feedback: a "Çalışmadı mı? Bildir" button on the empty-parts, empty-tree
  and loadError states opens the Sentry feedback dialog pre-tagged with the
  vehicle/category (+ session replay) — turns a parts shop's complaint into a
  structured, triageable report instead of an email.

Browser events are fingerprinted source="browser" so they form their own
"what users actually saw" issues (carrying replays) next to the server-side
detections. tsc + biome + web build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:07:49 +03:00
113 changed files with 6853 additions and 1905 deletions

127
PROJECT_INDEX.json Normal file
View File

@@ -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 (00000013) + 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"
}
}

88
PROJECT_INDEX.md Normal file
View File

@@ -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`

View File

@@ -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;

View File

@@ -0,0 +1,23 @@
-- Proxy telemetry: one row per proxied upstream attempt (pcat call/capture/
-- validate, emex http). Lets the Süper Panel grade proxy providers per service
-- (success rate, ban rate, latency) and track banned residential exit IPs /
-- sticky sessions. Written fire-and-forget by ProxyTelemetryService; 30-day
-- retention enforced by the query-cleanup job.
CREATE TABLE "proxy_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"service" varchar(30) NOT NULL,
"provider" varchar(20) NOT NULL,
"session_key" varchar(60),
"exit_ip" varchar(45),
"target_host" varchar(120),
"status_code" integer,
"error_kind" varchar(30),
"success" boolean DEFAULT false NOT NULL,
"banned" boolean DEFAULT false NOT NULL,
"duration_ms" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "proxy_logs_created_at_idx" ON "proxy_logs" ("created_at");
--> statement-breakpoint
CREATE INDEX "proxy_logs_banned_created_at_idx" ON "proxy_logs" ("banned","created_at");

View File

@@ -0,0 +1,37 @@
-- OEM community voting + gamification ledger.
-- oem_votes: one row per (user, oem_code) uyumlu/uyumsuz verdict cast from the
-- catalog part rows. Votes pool globally per code; part/vehicle/category are
-- analytics breadcrumbs only (catalog routes pass catalog_vehicles ids → no FK,
-- mirrors oem_code_copies). Re-votes update the row in place.
-- oem_vote_points: append-only, exactly one row per vote (vote_id unique),
-- written in the same transaction. points = 1 (vote) + 2 (agreed with strict
-- majority at cast time; first voter always 3). Never retro-adjusted.
CREATE TABLE "oem_votes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"oem_code" varchar(100) NOT NULL,
"vote" varchar(12) NOT NULL,
"part_id" uuid,
"vehicle_id" uuid,
"category_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "oem_vote_points" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"vote_id" uuid NOT NULL,
"oem_code" varchar(100) NOT NULL,
"points" integer NOT NULL,
"correct" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "oem_votes" ADD CONSTRAINT "oem_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "oem_vote_points" ADD CONSTRAINT "oem_vote_points_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "oem_vote_points" ADD CONSTRAINT "oem_vote_points_vote_id_oem_votes_id_fk" FOREIGN KEY ("vote_id") REFERENCES "public"."oem_votes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "oem_votes_user_oem_idx" ON "oem_votes" USING btree ("user_id","oem_code");--> statement-breakpoint
CREATE INDEX "oem_votes_oem_code_idx" ON "oem_votes" USING btree ("oem_code");--> statement-breakpoint
CREATE UNIQUE INDEX "oem_vote_points_vote_id_idx" ON "oem_vote_points" USING btree ("vote_id");--> statement-breakpoint
CREATE INDEX "oem_vote_points_user_id_idx" ON "oem_vote_points" USING btree ("user_id");

View File

@@ -0,0 +1,18 @@
-- Community OEM cross-reference suggestions, collected on the OEM detail page
-- ("muadil parça öner": brand + code). Primarily for codes the P snapshot has
-- no match for. suggested_code_norm (upper alphanumerics) powers grouping and
-- the per-user dedup; status is a moderation hook (active|hidden).
CREATE TABLE "oem_suggestions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"oem_code" varchar(100) NOT NULL,
"brand" varchar(80) NOT NULL,
"suggested_code" varchar(100) NOT NULL,
"suggested_code_norm" varchar(100) NOT NULL,
"status" varchar(16) DEFAULT 'active' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "oem_suggestions" ADD CONSTRAINT "oem_suggestions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "oem_suggestions_user_oem_code_idx" ON "oem_suggestions" USING btree ("user_id","oem_code","suggested_code_norm");--> statement-breakpoint
CREATE INDEX "oem_suggestions_oem_code_idx" ON "oem_suggestions" USING btree ("oem_code");

View File

@@ -0,0 +1,18 @@
-- Monthly Parça Uzmanları prizes: one row per (TR-month, rank). Written by the
-- expert-rewards cron (1st of month 00:00 Europe/Istanbul) when it closes the
-- finished season and grants the top 3 voters a subscription extension
-- (30/15/7 days). The unique index is the run-once guard — a re-run for the
-- same period inserts nothing, so days are never granted twice.
CREATE TABLE "oem_expert_rewards" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"period_start" timestamp with time zone NOT NULL,
"user_id" uuid NOT NULL,
"rank" integer NOT NULL,
"points" integer NOT NULL,
"reward_days" integer NOT NULL,
"granted_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "oem_expert_rewards" ADD CONSTRAINT "oem_expert_rewards_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "oem_expert_rewards_period_rank_idx" ON "oem_expert_rewards" USING btree ("period_start","rank");--> statement-breakpoint
CREATE INDEX "oem_expert_rewards_user_id_idx" ON "oem_expert_rewards" USING btree ("user_id");

View File

@@ -92,6 +92,41 @@
"when": 1780581755559, "when": 1780581755559,
"tag": "0012_lifecycle_email_sent", "tag": "0012_lifecycle_email_sent",
"breakpoints": true "breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1780600000000,
"tag": "0013_fix_brand_casing",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1781136000000,
"tag": "0014_proxy_logs",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1781222400000,
"tag": "0015_oem_votes",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1781308800000,
"tag": "0016_oem_suggestions",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1781395200000,
"tag": "0017_oem_expert_rewards",
"breakpoints": true
} }
] ]
} }

View File

@@ -29,10 +29,13 @@ import { DemoModule } from "./demo/demo.module";
import { EmailModule } from "./email/email.module"; import { EmailModule } from "./email/email.module";
import { HealthController } from "./health.controller"; import { HealthController } from "./health.controller";
import { EmexModule } from "./integrations/emex/emex.module"; import { EmexModule } from "./integrations/emex/emex.module";
import { PModule } from "./integrations/p/p.module";
import { InternalAdminModule } from "./internal-admin/internal-admin.module"; import { InternalAdminModule } from "./internal-admin/internal-admin.module";
import { JobsModule } from "./jobs/jobs.module"; import { JobsModule } from "./jobs/jobs.module";
import { MetaCapiModule } from "./meta-capi/meta-capi.module"; import { MetaCapiModule } from "./meta-capi/meta-capi.module";
import { NotificationsModule } from "./notifications/notifications.module"; import { NotificationsModule } from "./notifications/notifications.module";
import { OemSuggestionsModule } from "./oem-suggestions/oem-suggestions.module";
import { OemVotesModule } from "./oem-votes/oem-votes.module";
import { PartsModule } from "./parts/parts.module"; import { PartsModule } from "./parts/parts.module";
import { PaymentsModule } from "./payments/payments.module"; import { PaymentsModule } from "./payments/payments.module";
import { PlansModule } from "./plans/plans.module"; import { PlansModule } from "./plans/plans.module";
@@ -86,11 +89,14 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
CategoriesModule, CategoriesModule,
DemoModule, DemoModule,
PartsModule, PartsModule,
PModule,
JobsModule, JobsModule,
EmexModule, EmexModule,
TranslationsModule, TranslationsModule,
AdminModule, AdminModule,
AnalyticsModule, AnalyticsModule,
OemVotesModule,
OemSuggestionsModule,
CatalogModule, CatalogModule,
ChangelogModule, ChangelogModule,
ChatwootModule, ChatwootModule,

View File

@@ -4,6 +4,7 @@ import {
Controller, Controller,
Get, Get,
Headers, Headers,
HttpCode,
Param, Param,
Post, Post,
ServiceUnavailableException, ServiceUnavailableException,
@@ -13,6 +14,25 @@ import { Public } from "../common/decorators/public.decorator";
import type { CreateBlogPost } from "./blog.dto"; import type { CreateBlogPost } from "./blog.dto";
import { BlogService } from "./blog.service"; import { BlogService } from "./blog.service";
// Bearer-token check shared by the automation endpoints (n8n pipeline and the
// Directus cache-purge Flow). Mirrors the changelog/internal token pattern.
function assertAutomationToken(authHeader: string | undefined): void {
const token = process.env.BLOG_AUTOMATION_TOKEN;
if (!token) {
throw new ServiceUnavailableException("Blog automation endpoint is not configured");
}
if (!authHeader || !authHeader.startsWith("Bearer ")) {
throw new UnauthorizedException("Missing or invalid Authorization header");
}
const provided = authHeader.slice(7);
if (
provided.length !== token.length ||
!timingSafeEqual(Buffer.from(provided), Buffer.from(token))
) {
throw new UnauthorizedException("Invalid token");
}
}
@Controller("blog") @Controller("blog")
export class BlogController { export class BlogController {
constructor(private readonly blogService: BlogService) {} constructor(private readonly blogService: BlogService) {}
@@ -30,30 +50,27 @@ export class BlogController {
} }
// Automation endpoint — published by the Süper Panel content pipeline via n8n. // Automation endpoint — published by the Süper Panel content pipeline via n8n.
// Mirrors the changelog/internal Bearer-token pattern.
@Post("posts/internal") @Post("posts/internal")
@Public() @Public()
async createInternal( async createInternal(
@Headers("authorization") authHeader: string, @Headers("authorization") authHeader: string,
@Body() body: CreateBlogPost, @Body() body: CreateBlogPost,
) { ) {
const token = process.env.BLOG_AUTOMATION_TOKEN; assertAutomationToken(authHeader);
if (!token) {
throw new ServiceUnavailableException("Blog automation endpoint is not configured");
}
if (!authHeader || !authHeader.startsWith("Bearer ")) {
throw new UnauthorizedException("Missing or invalid Authorization header");
}
const provided = authHeader.slice(7);
if (
provided.length !== token.length ||
!timingSafeEqual(Buffer.from(provided), Buffer.from(token))
) {
throw new UnauthorizedException("Invalid token");
}
if (!body?.title || !body?.body_markdown) { if (!body?.title || !body?.body_markdown) {
throw new UnauthorizedException("title and body_markdown are required"); throw new UnauthorizedException("title and body_markdown are required");
} }
return this.blogService.create(body); return this.blogService.create(body);
} }
// Called by a Directus Flow whenever a post is created/updated/deleted, so
// edits made in the CMS admin appear immediately instead of after cache TTL.
@Post("cache/purge")
@Public()
@HttpCode(200)
async purgeCache(@Headers("authorization") authHeader: string) {
assertAutomationToken(authHeader);
await this.blogService.purgeListCache();
return { purged: true };
}
} }

View File

@@ -18,6 +18,13 @@ const DIRECTUS_URL = process.env.DIRECTUS_URL?.replace(/\/+$/, "");
const DIRECTUS_TOKEN = process.env.DIRECTUS_TOKEN; const DIRECTUS_TOKEN = process.env.DIRECTUS_TOKEN;
const DIRECTUS_PROJECT = process.env.DIRECTUS_PROJECT ?? "sase"; const DIRECTUS_PROJECT = process.env.DIRECTUS_PROJECT ?? "sase";
// Files uploaded in the Directus admin land in the shared MinIO `blog-assets`
// bucket (public read), served from the MinIO domain — NOT from Directus,
// which is Tailscale-only and unreachable for site visitors.
const BLOG_ASSETS_BASE_URL = (
process.env.BLOG_ASSETS_BASE_URL ?? "https://storage.sase.tr/blog-assets"
).replace(/\/+$/, "");
// Row shape of the shared `posts` collection in Directus. // Row shape of the shared `posts` collection in Directus.
interface DirectusPost { interface DirectusPost {
id: string; id: string;
@@ -27,6 +34,7 @@ interface DirectusPost {
body_markdown: string; body_markdown: string;
tags: string[] | null; tags: string[] | null;
cover_image: string | null; cover_image: string | null;
cover_file: { filename_disk: string } | null;
status: string; status: string;
source: string; source: string;
published_at: string; published_at: string;
@@ -34,6 +42,16 @@ interface DirectusPost {
date_updated: string | null; date_updated: string | null;
} }
// cover_image (external URL, e.g. from the content pipeline) wins over
// cover_file (admin upload, resolved to its public MinIO URL).
function resolveCover(row: Pick<DirectusPost, "cover_image" | "cover_file">): string | null {
if (row.cover_image) return row.cover_image;
if (row.cover_file?.filename_disk) {
return `${BLOG_ASSETS_BASE_URL}/${row.cover_file.filename_disk}`;
}
return null;
}
// URL-safe slug, Turkish-aware. The content_blog prompt already emits a clean // URL-safe slug, Turkish-aware. The content_blog prompt already emits a clean
// slug, but we slugify defensively (and to derive one if it's missing). // slug, but we slugify defensively (and to derive one if it's missing).
function slugify(input: string): string { function slugify(input: string): string {
@@ -91,7 +109,7 @@ export class BlogService {
metaDescription: row.meta_description, metaDescription: row.meta_description,
bodyMarkdown: row.body_markdown, bodyMarkdown: row.body_markdown,
tags: row.tags ?? [], tags: row.tags ?? [],
coverImage: row.cover_image, coverImage: resolveCover(row),
status: row.status as BlogPost["status"], status: row.status as BlogPost["status"],
source: row.source, source: row.source,
publishedAt: row.published_at, publishedAt: row.published_at,
@@ -100,6 +118,12 @@ export class BlogService {
}; };
} }
// Called by the Directus Flow webhook when a post changes, so both envs
// drop their 30-minute list cache immediately.
async purgeListCache(): Promise<void> {
await this.redis.del(LIST_CACHE_KEY);
}
async findAllPublished(): Promise<BlogPostListItem[]> { async findAllPublished(): Promise<BlogPostListItem[]> {
const cached = await this.redis.getJson<BlogPostListItem[]>(LIST_CACHE_KEY); const cached = await this.redis.getJson<BlogPostListItem[]>(LIST_CACHE_KEY);
if (cached) return cached; if (cached) return cached;
@@ -112,7 +136,7 @@ export class BlogService {
"filter[project][_eq]": DIRECTUS_PROJECT, "filter[project][_eq]": DIRECTUS_PROJECT,
"filter[status][_eq]": "published", "filter[status][_eq]": "published",
sort: "-published_at", sort: "-published_at",
fields: "slug,title,meta_description,tags,cover_image,published_at", fields: "slug,title,meta_description,tags,cover_image,cover_file.filename_disk,published_at",
limit: "-1", limit: "-1",
}), }),
); );
@@ -127,7 +151,7 @@ export class BlogService {
title: r.title, title: r.title,
metaDescription: r.meta_description, metaDescription: r.meta_description,
tags: r.tags ?? [], tags: r.tags ?? [],
coverImage: r.cover_image, coverImage: resolveCover(r),
publishedAt: r.published_at, publishedAt: r.published_at,
})); }));
@@ -144,6 +168,7 @@ export class BlogService {
"filter[project][_eq]": DIRECTUS_PROJECT, "filter[project][_eq]": DIRECTUS_PROJECT,
"filter[slug][_eq]": slug, "filter[slug][_eq]": slug,
"filter[status][_eq]": "published", "filter[status][_eq]": "published",
fields: "*,cover_file.filename_disk",
limit: "1", limit: "1",
}), }),
); );

View File

@@ -320,6 +320,84 @@ describe("CategoriesService", () => {
"drill-load-error", "drill-load-error",
); );
}); });
it("routes a P4 Ford VIN node (json-vin-sub-group.action, positional link_wid) to children, not an empty leaf", async () => {
// link_wid "1" is a positional code (not a *Group* table) so the link_wid
// group-drill branch misses it; the path is …group.action (not /psa/, not
// vin-group.action) so the PSA / Volvo branches miss it too. Before the
// dedicated gate these fell through to the leaf parts path → silent empty
// panel (Ford/Nissan/Opel analog of the PSA-parent-gate gap).
const category = {
id: "ford1",
name: "elektrik sistemi",
nameOriginal: "elektrik sistemi",
parentId: null,
vehicleId: "v1",
source: "pl24",
linkPath: "/ford/fordp_parts/json-vin-sub-group.action?mainGroupId=CAP1&vin=WF0FXX",
linkWid: "1",
hasSubgroups: null,
hasParts: null,
};
let selectCall = 0;
const db = {
select: vi.fn().mockImplementation(() => {
selectCall++;
const captured = selectCall;
const c: Record<string, any> = {};
c.from = vi.fn().mockReturnValue(c);
c.where = vi.fn().mockImplementation(() => (captured === 2 ? [] : c));
c.limit = vi.fn().mockReturnValue([category]);
return c;
}),
execute: vi.fn().mockResolvedValue([]),
};
const { service } = createService(db);
const discovered = [{ id: "sg1", name: "kablolar", children: undefined }];
vi.spyOn(service, "getChildren").mockResolvedValue(discovered as any);
const result = await service.getCategoryWithParts("ford1");
expect(service.getChildren).toHaveBeenCalledWith("ford1");
expect(result.parts).toEqual([]);
expect((result as { children?: unknown }).children).toEqual(discovered);
});
it("surfaces loadError when a P4 group.action drill comes back empty (not a silent leaf)", async () => {
const category = {
id: "ford2",
name: "kaporta",
nameOriginal: "kaporta",
parentId: null,
vehicleId: "v1",
source: "pl24",
linkPath: "/ford/fordp_parts/json-vin-sub-group.action?mainGroupId=GDB&vin=WF0FXX",
linkWid: "2",
hasSubgroups: null,
hasParts: null,
};
let selectCall = 0;
const db = {
select: vi.fn().mockImplementation(() => {
selectCall++;
const captured = selectCall;
const c: Record<string, any> = {};
c.from = vi.fn().mockReturnValue(c);
c.where = vi.fn().mockImplementation(() => (captured === 2 ? [] : c));
c.limit = vi.fn().mockReturnValue([category]);
return c;
}),
execute: vi.fn().mockResolvedValue([]),
};
const { service } = createService(db);
vi.spyOn(service, "getChildren").mockResolvedValue([] as any);
const result = await service.getCategoryWithParts("ford2");
expect(service.getChildren).toHaveBeenCalledWith("ford2");
expect((result as { loadError?: boolean }).loadError).toBe(true);
expect((result as { children?: unknown }).children).toBeUndefined();
});
}); });
describe("getAncestors", () => { describe("getAncestors", () => {

View File

@@ -120,6 +120,10 @@ export class CategoriesService {
externalId: s.code, externalId: s.code,
linkPath: s.linkPath || null, linkPath: s.linkPath || null,
linkWid: null as string | null, linkWid: null as string | null,
// PSA scopes are always parents (they hold main-groups, never direct
// parts) — flag them so the tree UI signposts them as folders and the
// prefetch worker pre-drills them instead of treating them as leaves.
hasSubgroups: true,
source: "pl24" as const, source: "pl24" as const,
})); }));
dbCategories = await this.db dbCategories = await this.db
@@ -181,6 +185,10 @@ export class CategoriesService {
externalId: c.code, externalId: c.code,
linkPath: c.linkPath || null, linkPath: c.linkPath || null,
linkWid: c.linkWid || null, linkWid: c.linkWid || null,
// Mark P4 VIN/legacy group-drill nodes (…group.action) as parents so
// the tree UI shows them as folders and prefetch pre-drills them. Leaf
// pages (image-board.action/bom) stay null (unknown→leaf downstream).
hasSubgroups: c.linkPath?.includes("group.action") ? true : null,
source: "pl24" as const, source: "pl24" as const,
})); }));
@@ -1123,6 +1131,39 @@ export class CategoriesService {
// No subgroups → leaf; fall through to the parts path below. // No subgroups → leaf; fall through to the parts path below.
} }
// PL24 P4 VIN / legacy group node whose link_wid is a positional code
// ("1","2","CAP1"…) rather than a *Group* table, so the link_wid branch above
// misses it, and which is neither PSA- nor Volvo-special. Its linkPath is a
// sub-group / main-group drill action (…group.action — never a parts leaf like
// image-board.action). This is the Ford / Nissan / Opel / Hyundai-Kia analog of
// the PSA-parent-gate gap: without it these VIN scopes ("mekanik", "kaporta",
// "Elektrikli aksam"…) fell through every parent branch to the leaf parts path,
// fetched no parts and rendered a silent empty panel — confirmed the dominant
// current "0 parça" cluster. Volvo's vin-group.action keeps its own branch above
// (it may legitimately bottom out in parts), so exclude it here. Drill on demand:
// subgroups → parent; an empty drill is a transient upstream failure → retryable
// load error (a group node is never legitimately a parts leaf).
if (
category.source === "pl24" &&
category.linkPath?.includes("group.action") &&
!category.linkPath.includes("vin-group.action") &&
category.vehicleId
) {
const p4Children = await this.getChildren(categoryId);
const base = {
id: category.id,
name: category.name,
description: category.nameOriginal || null,
parentId: category.parentId || null,
parts: [],
schemaPics: [],
hotspots: [],
};
return p4Children.length > 0
? { ...base, children: p4Children }
: { ...base, loadError: true };
}
// EMEX Vehicle.aspx group node (linkWid="emex-group") — a parent, never a // EMEX Vehicle.aspx group node (linkWid="emex-group") — a parent, never a
// parts leaf. Return its children (seeded sub-groups, or Unit leaves drilled // parts leaf. Return its children (seeded sub-groups, or Unit leaves drilled
// on demand by getChildren). Mirrors the pl24 group-node branch above; an // on demand by getChildren). Mirrors the pl24 group-node branch above; an

View File

@@ -88,6 +88,14 @@ export default () => ({
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean), .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: { otel: {
enabled: process.env.OTEL_ENABLED === "true", enabled: process.env.OTEL_ENABLED === "true",
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,

View File

@@ -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( export const bankAccounts = pgTable(
"bank_accounts", "bank_accounts",
{ {
@@ -225,7 +227,9 @@ export const payments = pgTable(
iyzicoPaymentId: text("iyzico_payment_id"), iyzicoPaymentId: text("iyzico_payment_id"),
stripeSessionId: text("stripe_session_id"), stripeSessionId: text("stripe_session_id"),
stripePaymentIntentId: text("stripe_payment_intent_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), bankAccountId: uuid("bank_account_id").references(() => bankAccounts.id),
/** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */
eftReceiptUrl: text("eft_receipt_url"), eftReceiptUrl: text("eft_receipt_url"),
adminNote: text("admin_note"), adminNote: text("admin_note"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
@@ -613,3 +617,156 @@ export const emexCategoryTranslations = pgTable(
}, },
(table) => [uniqueIndex("emex_translations_original_name_idx").on(table.originalName)], (table) => [uniqueIndex("emex_translations_original_name_idx").on(table.originalName)],
); );
// ─── Proxy Logs ──────────────────────────────────────
// One row per proxied upstream attempt (pcat call/capture/validate, emex http).
// Written fire-and-forget by ProxyTelemetryService so the Süper Panel can grade
// proxy providers (success/ban/latency per service) and track banned exit IPs.
// `banned` = HTTP 403/429 from the upstream (IP-level block signal); `session_key`
// identifies a sticky Floxy session or a DataImpulse port so bans are attributable
// even when the exact exit IP is unknown (rotating call legs).
export const proxyLogs = pgTable(
"proxy_logs",
{
id: uuid("id").primaryKey().defaultRandom(),
// Which integration leg made the request: pcat_call | pcat_capture |
// pcat_validate | emex_http
service: varchar("service", { length: 30 }).notNull(),
// floxy | dataimpulse | none (direct)
provider: varchar("provider", { length: 20 }).notNull(),
// Sticky Floxy session id ("s-ab12cd34") or DataImpulse port ("di:10042");
// null on per-request rotating legs (exit IP changes every call).
sessionKey: varchar("session_key", { length: 60 }),
// Resolved residential exit IP (sticky sessions only — one cheap ipify probe
// per session). Null when rotation makes the IP unknowable.
exitIp: varchar("exit_ip", { length: 45 }),
targetHost: varchar("target_host", { length: 120 }),
statusCode: integer("status_code"),
// Transport failure class when no HTTP answer arrived:
// timeout | connect | reset | dns | aborted | transport
errorKind: varchar("error_kind", { length: 30 }),
success: boolean("success").default(false).notNull(),
banned: boolean("banned").default(false).notNull(),
durationMs: integer("duration_ms"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("proxy_logs_created_at_idx").on(table.createdAt),
index("proxy_logs_banned_created_at_idx").on(table.banned, table.createdAt),
],
);
// ─── OEM Votes (community compatibility verdicts) ─────
// One row per (user, oemCode): a parts seller's uyumlu/uyumsuz verdict on an
// OEM code, cast from the catalog part rows. Votes pool globally per code —
// the part/vehicle/category columns are analytics breadcrumbs only (mirrors
// oem_code_copies: catalog routes pass catalog_vehicles ids here, so no FK).
// Re-voting updates `vote` in place; points are only ever granted on the
// first insert (see oem_vote_points).
export const oemVotes = pgTable(
"oem_votes",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
oemCode: varchar("oem_code", { length: 100 }).notNull(),
// compatible | incompatible
vote: varchar("vote", { length: 12 }).notNull(),
partId: uuid("part_id"),
vehicleId: uuid("vehicle_id"),
categoryId: uuid("category_id"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("oem_votes_user_oem_idx").on(table.userId, table.oemCode),
index("oem_votes_oem_code_idx").on(table.oemCode),
],
);
// ─── OEM Vote Points (gamification ledger) ────────────
// Append-only: exactly one row per oem_votes row (vote_id unique), written in
// the same transaction as the vote insert. `points` = 1 for voting +2 when
// the vote agreed with the strict majority at cast time (first voter on a
// code always agrees with themselves → 3). Awards are never retro-adjusted
// when the majority later flips. Leaderboard = SUM(points) per user.
export const oemVotePoints = pgTable(
"oem_vote_points",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
voteId: uuid("vote_id")
.notNull()
.references(() => oemVotes.id, { onDelete: "cascade" }),
oemCode: varchar("oem_code", { length: 100 }).notNull(),
points: integer("points").notNull(),
correct: boolean("correct").default(false).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("oem_vote_points_vote_id_idx").on(table.voteId),
index("oem_vote_points_user_id_idx").on(table.userId),
],
);
// ─── OEM Suggestions (community cross-references) ─────
// Crowd-sourced equivalents for an OEM code: "this code's compatible part is
// <brand> <suggestedCode>", collected on the OEM detail page — primarily for
// codes the P snapshot can't match. suggestedCodeNorm (upper, alphanumerics
// only) powers grouping and the per-user dedup constraint; display keeps the
// submitted casing. `status` is a moderation hook (active|hidden) — no rows
// are hidden automatically today.
export const oemSuggestions = pgTable(
"oem_suggestions",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
oemCode: varchar("oem_code", { length: 100 }).notNull(),
brand: varchar("brand", { length: 80 }).notNull(),
suggestedCode: varchar("suggested_code", { length: 100 }).notNull(),
suggestedCodeNorm: varchar("suggested_code_norm", { length: 100 }).notNull(),
status: varchar("status", { length: 16 }).default("active").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("oem_suggestions_user_oem_code_idx").on(
table.userId,
table.oemCode,
table.suggestedCodeNorm,
),
index("oem_suggestions_oem_code_idx").on(table.oemCode),
],
);
// ─── OEM Expert Rewards (monthly leaderboard prizes) ──
// One row per (TR-month, rank): the expert-rewards cron (1st of month 00:00
// Europe/Istanbul) closes the finished season and grants the top 3 voters a
// subscription extension (30/15/7 days — EXPERT_REWARD_LADDER). The unique
// index doubles as the run-once guard: a second run for the same period
// inserts nothing, so days are never granted twice. Granting mirrors the
// referral mechanic: extend a live active/trial sub, else bank the days in
// users.referral_credit_days (consumed at next trial/activation).
export const oemExpertRewards = pgTable(
"oem_expert_rewards",
{
id: uuid("id").primaryKey().defaultRandom(),
// TR-ayının başlangıcı (UTC timestamptz) — ödüllendirilen sezon.
periodStart: timestamp("period_start", { withTimezone: true }).notNull(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
rank: integer("rank").notNull(),
points: integer("points").notNull(),
rewardDays: integer("reward_days").notNull(),
grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("oem_expert_rewards_period_rank_idx").on(table.periodStart, table.rank),
index("oem_expert_rewards_user_id_idx").on(table.userId),
],
);

View File

@@ -61,22 +61,44 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
private launching: Promise<void> | null = null; private launching: Promise<void> | null = null;
private readonly useProxy: boolean; private readonly useProxy: boolean;
// "floxy" residential (default) | "dataimpulse" legacy | "none".
private readonly proxyProvider: "floxy" | "dataimpulse" | "none";
private readonly proxyHost: string; private readonly proxyHost: string;
private readonly proxyPortStart: number; private readonly proxyPortStart: number;
private readonly proxyPortEnd: number; private readonly proxyPortEnd: number;
private readonly proxyUsername: string; private readonly proxyUsername: string;
private readonly proxyPassword: string; private readonly proxyPassword: string;
// Floxy residential — a sticky session pins ONE exit IP for the browser's whole
// lifetime (relaunch mints a fresh session id), which suits the chained scrape.
private readonly floxyHost: string;
private readonly floxyPort: number;
private readonly floxyUser: string;
private readonly floxyPass: string;
private readonly floxyLifetime: number;
private startedAt = 0; private startedAt = 0;
constructor(private configService: ConfigService) { constructor(private configService: ConfigService) {
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES); this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
this.useProxy = this.configService.get<string>("EMEX_USE_PROXY", "true") === "true"; this.useProxy = this.configService.get<string>("EMEX_USE_PROXY", "true") === "true";
const rawProvider = this.configService
.get<string>("EMEX_PROXY_PROVIDER", "floxy")
.toLowerCase();
this.proxyProvider = !this.useProxy
? "none"
: rawProvider === "dataimpulse" || rawProvider === "none"
? (rawProvider as "dataimpulse" | "none")
: "floxy";
this.proxyHost = this.configService.get<string>("EMEX_PROXY_HOST", "74.81.81.81"); this.proxyHost = this.configService.get<string>("EMEX_PROXY_HOST", "74.81.81.81");
this.proxyPortStart = this.configService.get<number>("EMEX_PROXY_PORT_START", 10001); this.proxyPortStart = this.configService.get<number>("EMEX_PROXY_PORT_START", 10001);
this.proxyPortEnd = this.configService.get<number>("EMEX_PROXY_PORT_END", 10099); this.proxyPortEnd = this.configService.get<number>("EMEX_PROXY_PORT_END", 10099);
this.proxyUsername = this.configService.get<string>("EMEX_PROXY_USER", "1726bbe361918676d44e"); this.proxyUsername = this.configService.get<string>("EMEX_PROXY_USER", "1726bbe361918676d44e");
this.proxyPassword = this.configService.get<string>("EMEX_PROXY_PASS", "f11c7b6128cc86c6"); this.proxyPassword = this.configService.get<string>("EMEX_PROXY_PASS", "f11c7b6128cc86c6");
this.floxyHost = this.configService.get<string>("EMEX_FLOXY_HOST", "residential.floxy.io");
this.floxyPort = Number(this.configService.get("EMEX_FLOXY_PORT", 12321)) || 12321;
this.floxyUser = this.configService.get<string>("EMEX_FLOXY_USER", "cac4b0d96a80");
this.floxyPass = this.configService.get<string>("EMEX_FLOXY_PASS", "dfc38fe467a3");
this.floxyLifetime = Number(this.configService.get("EMEX_FLOXY_LIFETIME", 300)) || 300;
} }
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
@@ -184,14 +206,30 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
if (executablePath) launchOptions.executablePath = executablePath; if (executablePath) launchOptions.executablePath = executablePath;
if (this.useProxy) { if (this.proxyProvider === "floxy") {
// Sticky Floxy session for the browser's lifetime → one residential exit IP
// for the whole chained scrape; a fresh id is minted on each (re)launch.
const sid = Math.random().toString(36).slice(2, 10);
const pass =
this.floxyLifetime > 0
? `${this.floxyPass}_session-${sid}_lifetime-${this.floxyLifetime}`
: this.floxyPass;
launchOptions.proxy = {
server: `http://${this.floxyHost}:${this.floxyPort}`,
username: this.floxyUser,
password: pass,
};
this.logger.log(
`Using Floxy residential proxy: ${this.floxyHost}:${this.floxyPort} (sticky ${this.floxyLifetime}s)`,
);
} else if (this.proxyProvider === "dataimpulse") {
const port = this.randomProxyPort(); const port = this.randomProxyPort();
launchOptions.proxy = { launchOptions.proxy = {
server: `http://${this.proxyHost}:${port}`, server: `http://${this.proxyHost}:${port}`,
username: this.proxyUsername, username: this.proxyUsername,
password: this.proxyPassword, password: this.proxyPassword,
}; };
this.logger.log(`Using proxy: ${this.proxyHost}:${port}`); this.logger.log(`Using DataImpulse proxy: ${this.proxyHost}:${port}`);
} }
this.browser = await chromium.launch(launchOptions); this.browser = await chromium.launch(launchOptions);

View File

@@ -1,8 +1,10 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ProxyTelemetryModule } from "../proxy-telemetry/proxy-telemetry.module";
import { EmexBrowserService } from "./emex.browser"; import { EmexBrowserService } from "./emex.browser";
import { EmexService } from "./emex.service"; import { EmexService } from "./emex.service";
@Module({ @Module({
imports: [ProxyTelemetryModule],
providers: [EmexBrowserService, EmexService], providers: [EmexBrowserService, EmexService],
exports: [EmexService], exports: [EmexService],
}) })

View File

@@ -18,6 +18,7 @@ function makeConfig(overrides: Record<string, string>): ConfigService {
const browser = {} as never; const browser = {} as never;
const redis = { set: vi.fn() } as never; const redis = { set: vi.fn() } as never;
const posthog = {} as never; const posthog = {} as never;
const proxyTelemetry = { record: vi.fn() } as never;
describe("EmexService proxy-port coercion (regression)", () => { describe("EmexService proxy-port coercion (regression)", () => {
it("does not throw 'Invalid URL' when ports arrive as strings over a real range", () => { it("does not throw 'Invalid URL' when ports arrive as strings over a real range", () => {
@@ -28,7 +29,7 @@ describe("EmexService proxy-port coercion (regression)", () => {
EMEX_PROXY_PORT_END: "10099", EMEX_PROXY_PORT_END: "10099",
}); });
// Pre-fix: threw "Invalid URL" here (port "4510001" > 65535). // Pre-fix: threw "Invalid URL" here (port "4510001" > 65535).
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow(); expect(() => new EmexService(config, browser, redis, posthog, proxyTelemetry)).not.toThrow();
}); });
it("constructs with a single-port range (string env)", () => { it("constructs with a single-port range (string env)", () => {
@@ -37,7 +38,7 @@ describe("EmexService proxy-port coercion (regression)", () => {
EMEX_PROXY_PORT_START: "823", EMEX_PROXY_PORT_START: "823",
EMEX_PROXY_PORT_END: "823", EMEX_PROXY_PORT_END: "823",
}); });
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow(); expect(() => new EmexService(config, browser, redis, posthog, proxyTelemetry)).not.toThrow();
}); });
it("falls back to a valid default when the port env is garbage", () => { it("falls back to a valid default when the port env is garbage", () => {
@@ -46,11 +47,11 @@ describe("EmexService proxy-port coercion (regression)", () => {
EMEX_PROXY_PORT_START: "not-a-number", EMEX_PROXY_PORT_START: "not-a-number",
EMEX_PROXY_PORT_END: "999999", EMEX_PROXY_PORT_END: "999999",
}); });
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow(); expect(() => new EmexService(config, browser, redis, posthog, proxyTelemetry)).not.toThrow();
}); });
it("constructs cleanly with the proxy disabled", () => { it("constructs cleanly with the proxy disabled", () => {
const config = makeConfig({ EMEX_USE_PROXY: "false" }); const config = makeConfig({ EMEX_USE_PROXY: "false" });
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow(); expect(() => new EmexService(config, browser, redis, posthog, proxyTelemetry)).not.toThrow();
}); });
}); });

View File

@@ -23,6 +23,10 @@ import { ProxyAgent } from "undici";
import { isBackfillContext } from "../../jobs/prefetch-context"; import { isBackfillContext } from "../../jobs/prefetch-context";
import { PostHogService } from "../../posthog/posthog.service"; import { PostHogService } from "../../posthog/posthog.service";
import { RedisService } from "../../redis/redis.service"; import { RedisService } from "../../redis/redis.service";
import {
ProxyTelemetryService,
classifyTransportError,
} from "../proxy-telemetry/proxy-telemetry.service";
import { parseUnitLeaves, parseVehicleTree } from "./emex-tree.parser"; import { parseUnitLeaves, parseVehicleTree } from "./emex-tree.parser";
import { EmexBrowserService } from "./emex.browser"; import { EmexBrowserService } from "./emex.browser";
import { createEmptyDecodedVehicle, mapEmexResponse } from "./emex.mapper"; import { createEmptyDecodedVehicle, mapEmexResponse } from "./emex.mapper";
@@ -132,12 +136,17 @@ export class EmexService {
private floxySessionId = Math.random().toString(36).slice(2, 10); private floxySessionId = Math.random().toString(36).slice(2, 10);
private floxySessionBornMs = Date.now(); private floxySessionBornMs = Date.now();
private readonly emexDirectFallback: boolean; private readonly emexDirectFallback: boolean;
// Transport provider for EMEX egress (HTTP + image-dims). "floxy" residential
// is primary by default (sticky session pins one exit IP across a chained flow),
// DataImpulse is the last-ditch fallback. See constructor.
private readonly emexProxyProvider: "floxy" | "dataimpulse" | "none";
constructor( constructor(
private configService: ConfigService, private configService: ConfigService,
private browserService: EmexBrowserService, private browserService: EmexBrowserService,
private redis: RedisService, private redis: RedisService,
private posthog: PostHogService, private posthog: PostHogService,
private proxyTelemetry: ProxyTelemetryService,
) { ) {
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/ // __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js // Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
@@ -169,15 +178,11 @@ export class EmexService {
portStart: toPort("EMEX_PROXY_PORT_START", 10001), portStart: toPort("EMEX_PROXY_PORT_START", 10001),
portEnd: toPort("EMEX_PROXY_PORT_END", 10099), portEnd: toPort("EMEX_PROXY_PORT_END", 10099),
}; };
// Default agent for the low-stakes image-dims path; fetchEmexHtml builds a
// fresh agent per request so a flaky port can't pin every call.
this.proxyAgent = this.newProxyAgent();
this.logger.log( this.logger.log(
`EMEX HTTP proxy enabled: ${this.emexProxy.host}:${this.emexProxy.portStart}-${this.emexProxy.portEnd}`, `EMEX DataImpulse fallback enabled: ${this.emexProxy.host}:${this.emexProxy.portStart}-${this.emexProxy.portEnd}`,
); );
} else { } else {
this.emexProxy = null; this.emexProxy = null;
this.proxyAgent = null;
} }
// Floxy residential fallback for when the DataImpulse pool flakes (connect // Floxy residential fallback for when the DataImpulse pool flakes (connect
@@ -189,8 +194,8 @@ export class EmexService {
this.emexFloxy = { this.emexFloxy = {
host: this.configService.get<string>("EMEX_FLOXY_HOST", "residential.floxy.io"), host: this.configService.get<string>("EMEX_FLOXY_HOST", "residential.floxy.io"),
port: Number.isInteger(fport) && fport >= 1 && fport <= 65535 ? fport : 12321, port: Number.isInteger(fport) && fport >= 1 && fport <= 65535 ? fport : 12321,
user: this.configService.get<string>("EMEX_FLOXY_USER", "d739255e819b"), user: this.configService.get<string>("EMEX_FLOXY_USER", "cac4b0d96a80"),
pass: this.configService.get<string>("EMEX_FLOXY_PASS", "9092873ba4e0"), pass: this.configService.get<string>("EMEX_FLOXY_PASS", "dfc38fe467a3"),
lifetime: Number.isInteger(flife) && flife >= 0 ? flife : 300, lifetime: Number.isInteger(flife) && flife >= 0 ? flife : 300,
}; };
this.logger.log( this.logger.log(
@@ -200,6 +205,28 @@ export class EmexService {
this.emexFloxy = null; this.emexFloxy = null;
} }
// Transport provider. EMEX_PROXY_PROVIDER: "floxy" (default) → Floxy residential
// sticky primary + a single DataImpulse last-ditch fallback; "dataimpulse" →
// legacy DataImpulse-first order; "none" → direct. ssd tokens are replayed from
// emex's own HTML and aren't strictly IP-bound on the .aspx endpoints (the old
// random-port-per-call primary proved that), but a sticky Floxy IP across the
// chained flow is still the safest choice — and avoids DataImpulse's ~50% dead
// ports that were eating the decode budget before reaching the Floxy fallback.
this.emexProxyProvider = !useProxy
? "none"
: (() => {
const p = this.configService.get<string>("EMEX_PROXY_PROVIDER", "floxy").toLowerCase();
return p === "dataimpulse" || p === "none" ? p : "floxy";
})();
// Default agent for the low-stakes image-dims path; fetchEmexHtml builds a
// fresh agent per request so a flaky exit can't pin every call.
this.proxyAgent =
this.emexProxyProvider === "none"
? null
: (this.newProxyAgent(this.emexProxyProvider === "dataimpulse" ? "dataimpulse" : "floxy") ??
this.newProxyAgent("dataimpulse"));
this.logger.log(`EMEX transport provider: ${this.emexProxyProvider}`);
this.logger.log(`EMEX Service initialized with scraper path: ${this.scraperPath}`); this.logger.log(`EMEX Service initialized with scraper path: ${this.scraperPath}`);
} }
@@ -347,25 +374,45 @@ export class EmexService {
} }
private async fetchEmexHtml(url: string): Promise<string> { private async fetchEmexHtml(url: string): Promise<string> {
// Attempt schedule: DataImpulse (rotating port, fresh agent each try to dodge // Attempt schedule by provider. floxy (default): Floxy residential sticky
// a flaky sticky port — ~42% blip rate, undecoded-vin-rca.md EMEX #1) first, // primary (one exit IP across the flow; rolls to a fresh IP on transport
// then the Floxy residential fallback when the DataImpulse pool throws // failure), then a single DataImpulse last-ditch try — this avoids DataImpulse's
// transport errors. A definitive HTTP answer (e.g. 404) stops the schedule — // ~50% dead ports that used to burn the decode budget before reaching Floxy.
// it's a real result, and a different proxy IP must not "retry" it away. // dataimpulse (legacy): DataImpulse-first (rotating port each try), Floxy after.
// A definitive HTTP answer (e.g. 404) stops the schedule — it's a real result,
// and a different proxy IP must not "retry" it away.
const schedule: Array<"dataimpulse" | "floxy"> = []; const schedule: Array<"dataimpulse" | "floxy"> = [];
if (this.emexProxy) schedule.push("dataimpulse", "dataimpulse", "dataimpulse"); if (this.emexProxyProvider === "floxy") {
if (this.emexFloxy) schedule.push("floxy", "floxy"); if (this.emexFloxy) schedule.push("floxy", "floxy");
if (this.emexProxy) schedule.push("dataimpulse");
} else if (this.emexProxyProvider === "dataimpulse") {
if (this.emexProxy) schedule.push("dataimpulse", "dataimpulse", "dataimpulse");
if (this.emexFloxy) schedule.push("floxy", "floxy");
}
const maxAttempts = schedule.length || 1; // 0 → single proxy-less attempt const maxAttempts = schedule.length || 1; // 0 → single proxy-less attempt
let lastErr: unknown; let lastErr: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) { for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const provider = schedule[attempt - 1]; // undefined when no proxy → direct const provider = schedule[attempt - 1]; // undefined when no proxy → direct
const agent = provider ? this.newProxyAgent(provider) : null; const agent = provider ? this.newProxyAgent(provider) : null;
const attemptStart = Date.now();
// Sticky Floxy sessions are the attributable identity here; DataImpulse
// rotates a random port per agent, so there is nothing stable to pin.
const sessionKey = provider === "floxy" ? `s-${this.floxySessionId}` : null;
try { try {
const res = await fetch(url, { const res = await fetch(url, {
headers: { "User-Agent": EMEX_UA, Accept: "text/html,application/xhtml+xml" }, headers: { "User-Agent": EMEX_UA, Accept: "text/html,application/xhtml+xml" },
signal: AbortSignal.timeout(this.timeout), signal: AbortSignal.timeout(this.timeout),
...(agent ? { dispatcher: agent } : {}), ...(agent ? { dispatcher: agent } : {}),
} as RequestInit); } as RequestInit);
this.proxyTelemetry.record({
service: "emex_http",
provider: provider ?? "none",
sessionKey,
targetHost: new URL(url).hostname,
statusCode: res.status,
success: res.ok,
durationMs: Date.now() - attemptStart,
});
if (!res.ok) { if (!res.ok) {
throw new Error(`EMEX HTTP ${res.status} for ${url}`); throw new Error(`EMEX HTTP ${res.status} for ${url}`);
} }
@@ -378,6 +425,19 @@ export class EmexService {
const e = err as Error & { cause?: unknown }; const e = err as Error & { cause?: unknown };
// A real HTTP response ("EMEX HTTP 404") is a definitive answer — never retry. // A real HTTP response ("EMEX HTTP 404") is a definitive answer — never retry.
const httpAnswer = /^EMEX HTTP \d/.test(e.message); const httpAnswer = /^EMEX HTTP \d/.test(e.message);
// HTTP answers were already recorded right after fetch; only transport
// failures (no answer at all) still need a row.
if (!httpAnswer) {
this.proxyTelemetry.record({
service: "emex_http",
provider: provider ?? "none",
sessionKey,
targetHost: new URL(url).hostname,
errorKind: classifyTransportError(e),
success: false,
durationMs: Date.now() - attemptStart,
});
}
const transient = const transient =
!httpAnswer && !httpAnswer &&
(e.name === "TimeoutError" || (e.name === "TimeoutError" ||

View File

@@ -348,6 +348,7 @@ export const CATALOG_MAP: Record<string, CatalogEntry> = {
// Opel // Opel
W0L: { code: "GM_OP201809", brand: "Opel" }, W0L: { code: "GM_OP201809", brand: "Opel" },
W0V: { code: "GM_OP201809", brand: "Opel" }, // newer Stellantis-era WMI 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 // Chevrolet
KL1: { code: "GM_C201809", brand: "Chevrolet" }, KL1: { code: "GM_C201809", brand: "Chevrolet" },
// SsangYong // SsangYong

View File

@@ -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<boolean>("p.enabled");
const url = this.config.get<string>("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<string[]> {
if (!this.enabled || !this.sql || !rawCodes?.length) return [];
const rawByNorm = new Map<string, string>();
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<Array<{ code_norm: string }>>`
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<POemResult | null> {
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<string>();
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<string>();
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;
}
}
}

View File

@@ -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 : []) };
}
}

View File

@@ -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 {}

View File

@@ -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<PArticle, "oeNumbers" | "compatible">;
/** 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;
}

View File

@@ -1,12 +1,16 @@
/** /**
* Parts-Catalogs Auth Service — v3 token warm pool via Playwright + DataImpulse proxy * Parts-Catalogs Auth Service — v3 token warm pool via Playwright + rotating proxy
* *
* Tokens are captured by navigating to partner sites that embed the v3 widget. * Tokens are captured by navigating to partner sites that embed the v3 widget.
* The widget calls /v3/api/proxy/* with `x-api-key: TWS-{UUID}` and four other * The widget calls /v3/api/proxy/* with `x-api-key: TWS-{UUID}` and four other
* X-* headers (api-path, gui-version, user-id, origin, referer); we intercept * X-* headers (api-path, gui-version, user-id, origin, referer); we intercept
* all of them so backend requests can replay the exact header set. * all of them so backend requests can replay the exact header set.
* *
* Tokens are IP-bound — same proxy port must be reused for the API calls. * Transport (PCAT_PROXY_PROVIDER): "floxy" residential (default) rotates a fresh
* exit IP per call (the token is NOT IP-bound — see parts-catalogs.types.ts — so
* max IP diversity = min per-IP ban), with a sticky IP only per Playwright
* capture; "dataimpulse" is the legacy port-bound datacenter proxy; "none" calls
* direct from the server IP.
* *
* Warm pool behavior: * Warm pool behavior:
* 24/7 → proactive: maintain >= 1 slot, auto-refresh ~9.5min before expiry. * 24/7 → proactive: maintain >= 1 slot, auto-refresh ~9.5min before expiry.
@@ -19,29 +23,37 @@ import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@ne
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Browser, BrowserContext } from "playwright"; import { Browser, BrowserContext } from "playwright";
import { RedisService } from "../../redis/redis.service"; import { RedisService } from "../../redis/redis.service";
import {
ProxyTelemetryService,
classifyTransportError,
resolveExitIp,
} from "../proxy-telemetry/proxy-telemetry.service";
import { JwtSlot, PcatJwtToken, PcatSession } from "./parts-catalogs.types"; import { JwtSlot, PcatJwtToken, PcatSession } from "./parts-catalogs.types";
// Shared single-slot cache so dev + prod (and any restarted container) can // Shared single-slot cache so dev + prod (and any restarted container) can
// inherit a freshly-captured warm slot instead of paying the Playwright // inherit a freshly-captured warm token instead of paying the Playwright
// capture cost from cold. Token is still IP-bound to its proxyPort; if a // capture cost from cold. The token is portable (not IP-bound), so any
// reader on a different IP gets 401/403, invalidateSession's existing // container can serve it through its own rotating call-leg IPs; if it's dead
// fallback re-captures locally. So worst case = today's behavior. // at the source, invalidateSession drops it and the next read re-captures.
const REDIS_SLOT_KEY = "pcat:jwt:slot"; const REDIS_SLOT_KEY = "pcat:jwt:slot";
const REDIS_SAFETY_BUFFER_S = 60; // don't serve from Redis if <60s of life left const REDIS_SAFETY_BUFFER_S = 60; // don't serve from Redis if <60s of life left
const TOKEN_TTL = 600; // seconds — TWS- has no built-in expiry, refresh aggressively const TOKEN_TTL = 600; // seconds — TWS- has no built-in expiry, refresh aggressively
const REFRESH_BUFFER = 90; // Refresh 90s before expiry const REFRESH_BUFFER = 90; // Refresh 90s before expiry
const CAPTURE_POLL_INTERVAL = 500; // ms const CAPTURE_POLL_INTERVAL = 500; // ms
// Two-armed post-goto poll: a healthy goto means the widget JS already loaded // Post-goto poll. We now navigate with `domcontentloaded` (reliable, doesn't
// and the API call typically fires within ~1-2s — short cap is enough. A // depend on networkidle settling on JS-heavy catalog sites), then poll for the
// failed goto means we lost the page, but the widget request may still have // widget's token call. Measured through Floxy residential 2026-06-11: DOM ready
// gone through before the navigation timeout — give a brief grace window // ~7s, the /v3/api/proxy token call fires ~11.8s (≈5s after DOM). The poll must
// and bail. Old single-armed 20s cap was the dominant cost on failed-site // comfortably cover that gap, so 12s — too short and every capture misses the
// attempts (e.g. 10s page timeout + 20s blind poll = 30s wasted). // token (seen at the old 5s cap: "No token after 12s", whole pool starved).
const CAPTURE_POLL_AFTER_OK = 10; // 10 × 500ms = 5s after a healthy goto const CAPTURE_POLL_AFTER_OK = Number(process.env.PCAT_CAPTURE_POLL_OK) || 24; // 24 × 500ms = 12s
const CAPTURE_POLL_AFTER_FAIL = 4; // 4 × 500ms = 2s grace after goto failure const CAPTURE_POLL_AFTER_FAIL = 8; // 8 × 500ms = 4s grace after goto failure
const PAGE_TIMEOUT = 10_000; // 10s — healthy partner sites load in <5s through // 25s — residential exit IPs are slow; the token call fired at ~11.8s in the
// the proxy; a longer wait only prolongs dead-port connects in capture retries. // 2026-06-11 measurement, so 10s timed out before it. Capture is background
// (warm pool, never blocks a user with cold-pool fast-fail), so a longer cap is
// cheap insurance against residential variance. Env-tunable for future rotation.
const PAGE_TIMEOUT = Number(process.env.PCAT_PAGE_TIMEOUT_MS) || 25_000;
const CONTEXT_CLOSE_TIMEOUT = 5_000; const CONTEXT_CLOSE_TIMEOUT = 5_000;
const SITE_COOLDOWN = 10 * 60 * 1000; // 10 min per site const SITE_COOLDOWN = 10 * 60 * 1000; // 10 min per site
const MAX_POOL_SIZE = 5; const MAX_POOL_SIZE = 5;
@@ -62,30 +74,48 @@ const RPM_PER_SLOT = 6; // 1 token per 6 req/min
* Each site uses a different proxy port (IP) to avoid rate limiting. * Each site uses a different proxy port (IP) to avoid rate limiting.
*/ */
// Order matters: round-robin starts from index 0, so the first site is the one // Order matters: round-robin starts from index 0, so the first site is the one
// a cold pool waits on. Sites that consistently load in <10s through the proxy // a cold pool waits on. Sites that consistently load fast stay at the top so
// stay at the top so initial JWT capture finishes in seconds. // initial JWT capture finishes in seconds.
// (e-acca.com was previously included last but removed — the current DataImpulse // Pruned 2026-06-10 to the 6 sites that actually yielded captures on prod —
// rotating proxy 74.81.81.81:10000-10999 cannot reach it; page.goto always // knkauto.ru / autodo.kz / flynestauto.com produced 0 captures yet consumed
// blocked until PAGE_TIMEOUT instead of failing fast like the other sites.) // ~80% of capture attempts (the per-site cooldown kept forcing the retry loop
const JWT_SITES = [ // onto them). Override the list at runtime with PCAT_JWT_SITES (comma-separated)
// without a deploy, e.g. to give dev a disjoint subset.
const DEFAULT_JWT_SITES = [
"https://www.alkatalog.com/cats/#/catalogs", "https://www.alkatalog.com/cats/#/catalogs",
"https://auto-komplekt.ru/goodvin#/catalogs", "https://auto-komplekt.ru/goodvin#/catalogs",
"https://www.autotrade.md/cats/#/catalogs", "https://www.autotrade.md/cats/#/catalogs",
"https://www.e-trak.ru/cats/#/catalogs", "https://www.e-trak.ru/cats/#/catalogs",
"https://www.autopolyus.ru/cats/#/catalogs", "https://www.autopolyus.ru/cats/#/catalogs",
"https://knkauto.ru/goodvin#/catalogs",
"https://www.autodo.kz/#/catalogs",
"https://avtoman124.ru/goodvin#/catalogs", "https://avtoman124.ru/goodvin#/catalogs",
"https://flynestauto.com/auto-parts-oem-catalog",
]; ];
// DataImpulse proxy defaults (port-based IP rotation) // DataImpulse proxy defaults (port-based IP rotation) — legacy. Half its ports
// were dead at any moment (measured 50% connect-timeout), which drove the bulk of
// pcat decode latency + failures. Kept selectable via PCAT_PROXY_PROVIDER for
// instant rollback, but Floxy is the default.
const DI_HOST = "gw.dataimpulse.com"; const DI_HOST = "gw.dataimpulse.com";
const DI_PORT_MIN = 10000; const DI_PORT_MIN = 10000;
const DI_PORT_MAX = 10999; const DI_PORT_MAX = 10999;
const DI_DEFAULT_USER = "1726bbe361918676d44e"; const DI_DEFAULT_USER = "1726bbe361918676d44e";
const DI_DEFAULT_PASS = "78ebc3d881de6ec0"; const DI_DEFAULT_PASS = "78ebc3d881de6ec0";
// Floxy residential defaults (session-based IP rotation; same account EMEX uses).
// Call leg → plain creds = a fresh residential exit IP per request (max IP
// diversity → minimal per-IP ban exposure, since each IP sees ~1 request).
// Capture leg → a sticky session id pins one IP for the duration of a single
// Playwright capture so the partner site sees a coherent session.
const FLOXY_HOST = "residential.floxy.io";
const FLOXY_PORT = 12321;
// Floxy account creds (overridable via PCAT_FLOXY_USER/PASS). Rotated 2026-06-11
// after the prior account (d739255e819b) hit HTTP 402 (balance exhausted).
const FLOXY_DEFAULT_USER = "cac4b0d96a80";
const FLOXY_DEFAULT_PASS = "dfc38fe467a3";
const FLOXY_CAPTURE_LIFETIME_S = 60; // sticky window for one capture attempt
type ProxyProvider = "floxy" | "dataimpulse" | "none";
type ProxyLeg = "call" | "capture";
/** Simple counting semaphore (same pattern as EmexBrowserService) */ /** Simple counting semaphore (same pattern as EmexBrowserService) */
class Semaphore { class Semaphore {
private current = 0; private current = 0;
@@ -119,18 +149,20 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
private browser: Browser | null = null; private browser: Browser | null = null;
private launching: Promise<void> | null = null; private launching: Promise<void> | null = null;
// Concurrent captures capped at JWT_SITES.length — the real bottleneck is // Active JWT capture sites (PCAT_JWT_SITES override or DEFAULT_JWT_SITES),
// how many distinct partner sites we can drive in parallel (one capture per // resolved in the constructor.
// site, since each site = a different IP / cookie origin). Ports are private readonly jwtSites: string[];
// ~unlimited (10000-10999) and Playwright contexts are isolated; the // Concurrent captures capped at jwtSites.length — the real bottleneck is how
// synchronous siteIndex round-robin hands each concurrent capture a // many distinct partner sites we can drive in parallel (one capture per site,
// different site so they don't collide. // since each site = a different cookie origin). Playwright contexts are
private readonly semaphore = new Semaphore(JWT_SITES.length); // isolated; the synchronous siteIndex round-robin hands each concurrent
// capture a different site so they don't collide. Initialized in constructor.
private readonly semaphore: Semaphore;
// Pool state // Pool state
private pool: JwtSlot[] = []; private pool: JwtSlot[] = [];
private siteLastUsedAt = new Map<string, number>(); private siteLastUsedAt = new Map<string, number>();
private siteIndex = 0; // round-robin across JWT_SITES private siteIndex = 0; // round-robin across jwtSites
private requestRoundRobin = 0; // round-robin across pool slots private requestRoundRobin = 0; // round-robin across pool slots
// Business hours scheduling // Business hours scheduling
@@ -139,20 +171,117 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
// RPM tracking // RPM tracking
private requestTimestamps: number[] = []; private requestTimestamps: number[] = [];
// Config // Transport config — see ProxyProvider. The TWS- token is NOT request-time
private readonly useProxy: boolean; // IP-bound (parts-catalogs.types.ts), so the call leg rotates exit IPs per
private readonly proxyHost: string; // request and only the capture leg holds a sticky IP per attempt.
private readonly proxyUser: string; private readonly proxyProvider: ProxyProvider;
private readonly proxyPass: string; // Floxy residential
private readonly floxyHost: string;
private readonly floxyPort: number;
private readonly floxyUser: string;
private readonly floxyPass: string;
private readonly floxyCaptureLifetime: number;
// DataImpulse legacy (port-rotated)
private readonly diHost: string;
private readonly diUser: string;
private readonly diPass: string;
constructor( constructor(
private configService: ConfigService, private configService: ConfigService,
private readonly redis: RedisService, private readonly redis: RedisService,
private readonly proxyTelemetry: ProxyTelemetryService,
) { ) {
this.useProxy = this.configService.get<string>("PCAT_USE_PROXY", "true") === "true"; const cfg = this.configService;
this.proxyHost = this.configService.get<string>("PCAT_PROXY_HOST", DI_HOST);
this.proxyUser = this.configService.get<string>("PCAT_PROXY_USER", DI_DEFAULT_USER); // Active capture sites: PCAT_JWT_SITES override (comma-separated) or the
this.proxyPass = this.configService.get<string>("PCAT_PROXY_PASS", DI_DEFAULT_PASS); // pruned default. Empty/blank entries are dropped; falls back to default if
// the override resolves to nothing.
const sitesRaw = cfg.get<string>("PCAT_JWT_SITES", "");
const parsedSites = sitesRaw
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
this.jwtSites = parsedSites.length > 0 ? parsedSites : DEFAULT_JWT_SITES;
this.semaphore = new Semaphore(this.jwtSites.length);
// Provider selection. Back-compat: PCAT_USE_PROXY=false still forces direct.
const rawProvider = cfg.get<string>("PCAT_PROXY_PROVIDER", "floxy").toLowerCase();
const useProxy = cfg.get<string>("PCAT_USE_PROXY", "true") === "true";
this.proxyProvider = !useProxy
? "none"
: rawProvider === "dataimpulse" || rawProvider === "none"
? (rawProvider as ProxyProvider)
: "floxy";
const toInt = (key: string, def: number): number => {
const n = Number(cfg.get(key, def));
return Number.isInteger(n) && n >= 0 ? n : def;
};
const toPort = (key: string, def: number): number => {
const n = Number(cfg.get(key, def));
return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : def;
};
this.floxyHost = cfg.get<string>("PCAT_FLOXY_HOST", FLOXY_HOST);
this.floxyPort = toPort("PCAT_FLOXY_PORT", FLOXY_PORT);
this.floxyUser = cfg.get<string>("PCAT_FLOXY_USER", FLOXY_DEFAULT_USER);
this.floxyPass = cfg.get<string>("PCAT_FLOXY_PASS", FLOXY_DEFAULT_PASS);
this.floxyCaptureLifetime = toInt("PCAT_FLOXY_CAPTURE_LIFETIME", FLOXY_CAPTURE_LIFETIME_S);
this.diHost = cfg.get<string>("PCAT_PROXY_HOST", DI_HOST);
this.diUser = cfg.get<string>("PCAT_PROXY_USER", DI_DEFAULT_USER);
this.diPass = cfg.get<string>("PCAT_PROXY_PASS", DI_DEFAULT_PASS);
this.logger.log(
this.proxyProvider === "floxy"
? `PCAT transport: floxy residential ${this.floxyHost}:${this.floxyPort} (call=rotating, capture=sticky ${this.floxyCaptureLifetime}s)`
: this.proxyProvider === "dataimpulse"
? `PCAT transport: dataimpulse ${this.diHost}:${DI_PORT_MIN}-${DI_PORT_MAX}`
: "PCAT transport: direct (no proxy)",
);
}
/**
* Build a proxy for one leg. "call" rotates the exit IP per invocation (the
* token is not IP-bound, so each decode/scrape/validate request gets a fresh
* residential IP → max IP diversity, min ban). "capture" pins a sticky IP for
* the lifetime of a single Playwright capture so the partner site sees a
* coherent session. Returns nulls when provider is "none" (direct).
*/
private buildProxy(leg: ProxyLeg): {
proxyUrl: string | null;
proxyConfig: { server: string; username: string; password: string } | null;
// Telemetry identity: sticky Floxy session id or DataImpulse port. Null on
// rotating legs (exit IP changes per request — nothing stable to attribute).
sessionKey: string | null;
} {
if (this.proxyProvider === "none")
return { proxyUrl: null, proxyConfig: null, sessionKey: null };
if (this.proxyProvider === "dataimpulse") {
const port = this.allocatePort();
const server = `http://${this.diHost}:${port}`;
return {
proxyUrl: `http://${this.diUser}:${this.diPass}@${this.diHost}:${port}`,
proxyConfig: { server, username: this.diUser, password: this.diPass },
sessionKey: `di:${port}`,
};
}
// floxy
let password = this.floxyPass;
let sessionKey: string | null = null;
if (leg === "capture" && this.floxyCaptureLifetime > 0) {
const sid = Math.random().toString(36).slice(2, 10);
password = `${this.floxyPass}_session-${sid}_lifetime-${this.floxyCaptureLifetime}`;
sessionKey = `s-${sid}`;
}
const server = `http://${this.floxyHost}:${this.floxyPort}`;
return {
proxyUrl: `http://${this.floxyUser}:${password}@${this.floxyHost}:${this.floxyPort}`,
proxyConfig: { server, username: this.floxyUser, password },
sessionKey,
};
} }
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
@@ -193,10 +322,14 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
// ─── Public API ─────────────────────────────────────────── // ─── Public API ───────────────────────────────────────────
/** /**
* Acquire a matched JWT + proxy session from the pool. * Acquire a JWT session from the pool.
* Returns a valid slot (round-robin) or captures on-demand if pool is empty. * Returns a valid slot (round-robin) or, if the pool is empty, captures
* on-demand. With `blockOnEmpty: false` it does NOT block on a cold pool —
* it kicks off a background warm-up and returns null so a latency-sensitive
* caller (the decode race) can let a faster source answer instead of eating
* the multi-second capture cost inline.
*/ */
async acquireSession(): Promise<PcatSession> { async acquireSession(opts?: { blockOnEmpty?: boolean }): Promise<PcatSession | null> {
this.trackRequest(); this.trackRequest();
// Try to find a valid slot in the pool // Try to find a valid slot in the pool
@@ -207,14 +340,26 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
return this.slotToSession(slot); return this.slotToSession(slot);
} }
// No valid slot — capture on-demand // No valid slot. Latency-sensitive callers fast-fail and warm in background.
if (opts?.blockOnEmpty === false) {
this.logger.log("JWT pool empty — fast-fail (warming in background)");
this.captureToPool().catch((err) => {
this.logger.warn(`Background warm capture failed: ${(err as Error).message}`);
});
return null;
}
// Capture on-demand (blocks the caller).
this.logger.log("JWT pool empty — capturing on-demand..."); this.logger.log("JWT pool empty — capturing on-demand...");
const newSlot = await this.captureToPool(); const newSlot = await this.captureToPool();
return this.slotToSession(newSlot); return this.slotToSession(newSlot);
} }
/** /**
* Invalidate a session after 401/403, remove the slot, and capture a replacement. * Invalidate a session (401/403, or N consecutive transport failures on
* rotating IPs = a token that's dead at the source), remove the slot, and
* capture a replacement only if the pool is now empty — a surviving sibling
* plus the scheduled refresh/scale-up covers the rest without a capture storm.
*/ */
async invalidateSession(session: PcatSession): Promise<void> { async invalidateSession(session: PcatSession): Promise<void> {
const idx = this.pool.indexOf(session._slot); const idx = this.pool.indexOf(session._slot);
@@ -222,17 +367,18 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
this.clearSlotTimer(this.pool[idx]); this.clearSlotTimer(this.pool[idx]);
this.pool.splice(idx, 1); this.pool.splice(idx, 1);
this.logger.log( this.logger.log(
`JWT pool: slot invalidated (port ${session._slot.proxyPort}), ${this.pool.length} remaining`, `JWT pool: slot invalidated (${new URL(session._slot.siteUsed).hostname}), ${this.pool.length} remaining`,
); );
} }
// The Redis-shared slot may point at a now-401'd JWT (e.g. IP rotated // The Redis-shared slot may point at this same now-dead token. Drop it so
// away on the proxy). Drop it so other containers re-capture instead of // a cold-booting container re-captures instead of hydrating the dead token.
// inheriting the same dead slot.
this.deleteFromRedis().catch(() => {}); this.deleteFromRedis().catch(() => {});
// Capture replacement in background (don't block the caller's retry) // Only force an immediate replacement when the pool emptied; otherwise let
if (this.isBusinessHours() || this.pool.length === 0) { // the surviving slot serve and the refresh/scale-up timers backfill, so a
// burst of evictions can't stampede the capture pipeline.
if (this.pool.length === 0) {
this.captureToPool().catch((err) => { this.captureToPool().catch((err) => {
this.logger.error(`Replacement capture failed: ${(err as Error).message}`); this.logger.error(`Replacement capture failed: ${(err as Error).message}`);
}); });
@@ -262,16 +408,10 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
} }
private slotToSession(slot: JwtSlot): PcatSession { private slotToSession(slot: JwtSlot): PcatSession {
const proxyUrl = this.useProxy // Fresh rotating call-leg proxy per request — and per retry, since
? `http://${this.proxyUser}:${this.proxyPass}@${this.proxyHost}:${slot.proxyPort}` // fetchWithAuth re-acquires the session each attempt → a new exit IP each
: null; // time, so a slow/blocked residential IP is shed on the next try.
const proxyConfig = this.useProxy const { proxyUrl, proxyConfig } = this.buildProxy("call");
? {
server: `http://${this.proxyHost}:${slot.proxyPort}`,
username: this.proxyUser,
password: this.proxyPass,
}
: null;
return { return {
apiKey: slot.jwt.raw, apiKey: slot.jwt.raw,
apiPath: slot.jwt.apiPath, apiPath: slot.jwt.apiPath,
@@ -294,17 +434,16 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
const maxRetries = 4; const maxRetries = 4;
for (let attempt = 0; attempt < maxRetries; attempt++) { for (let attempt = 0; attempt < maxRetries; attempt++) {
const port = this.allocatePort();
const siteUrl = this.getAvailableSite(); const siteUrl = this.getAvailableSite();
const jwt = await this.attemptCapture(siteUrl, port); const jwt = await this.attemptCapture(siteUrl);
if (jwt) { if (jwt) {
// Validate the JWT+proxy combo against the real upstream before // Validate the token against the real upstream (on a fresh rotating
// publishing the slot — see VALIDATION_VIN comment above. // call-leg IP) before publishing the slot — see VALIDATION_VIN.
const valid = await this.validateSlot(jwt, port); const valid = await this.validateSlot(jwt);
if (!valid) { if (!valid) {
this.logger.warn( this.logger.warn(
`JWT capture attempt ${attempt + 1}/${maxRetries} validation failed (port ${port}, ${new URL(siteUrl).hostname}), trying next site...`, `JWT capture attempt ${attempt + 1}/${maxRetries} validation failed (${new URL(siteUrl).hostname}), trying next site...`,
); );
// Mark site as used anyway so we rotate away rather than retry it. // Mark site as used anyway so we rotate away rather than retry it.
this.siteLastUsedAt.set(siteUrl, Date.now()); this.siteLastUsedAt.set(siteUrl, Date.now());
@@ -313,10 +452,10 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
const slot: JwtSlot = { const slot: JwtSlot = {
jwt, jwt,
proxyPort: port,
siteUsed: siteUrl, siteUsed: siteUrl,
capturedAt: Date.now(), capturedAt: Date.now(),
refreshTimer: null, refreshTimer: null,
failCount: 0,
}; };
// Mark site as used // Mark site as used
@@ -352,13 +491,14 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
} }
/** /**
* Probe the just-captured token + proxy port with a single cheap upstream * Probe the just-captured token with a single cheap upstream call (/car/info
* call (/car/info with a public demo VIN). Returns true iff the upstream * with a public demo VIN) on a fresh rotating call-leg IP — the SAME transport
* accepts the combo with 2xx within VALIDATION_TIMEOUT_MS. A failure here * a real request will use. Returns true iff upstream accepts it with 2xx within
* (proxy dead, IP blocked, token 401/403) is far cheaper to absorb at * VALIDATION_TIMEOUT_MS. Validating on the call-leg transport (not the capture
* capture time than to inherit when a real user clicks a category. * IP) is what makes the check meaningful: it rejects a token only if it can't
* be served the way it will actually be served.
*/ */
private async validateSlot(jwt: PcatJwtToken, port: number): Promise<boolean> { private async validateSlot(jwt: PcatJwtToken): Promise<boolean> {
const url = `${PCAT_API_BASE}/car/info?q=${VALIDATION_VIN}`; const url = `${PCAT_API_BASE}/car/info?q=${VALIDATION_VIN}`;
const fetchOptions: RequestInit & { dispatcher?: any } = { const fetchOptions: RequestInit & { dispatcher?: any } = {
method: "GET", method: "GET",
@@ -375,17 +515,35 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
}, },
signal: AbortSignal.timeout(VALIDATION_TIMEOUT_MS), signal: AbortSignal.timeout(VALIDATION_TIMEOUT_MS),
}; };
if (this.useProxy) { const { proxyUrl } = this.buildProxy("call");
if (proxyUrl) {
const { ProxyAgent } = await import("undici"); const { ProxyAgent } = await import("undici");
fetchOptions.dispatcher = new ProxyAgent({ fetchOptions.dispatcher = new ProxyAgent({
uri: `http://${this.proxyUser}:${this.proxyPass}@${this.proxyHost}:${port}`, uri: proxyUrl,
connect: { timeout: 6_000 }, connect: { timeout: 6_000 },
}); });
} }
const start = Date.now();
try { try {
const r = await fetch(url, fetchOptions); const r = await fetch(url, fetchOptions);
this.proxyTelemetry.record({
service: "pcat_validate",
provider: this.proxyProvider,
targetHost: "gui.parts-catalogs.com",
statusCode: r.status,
success: r.ok,
durationMs: Date.now() - start,
});
return r.ok; return r.ok;
} catch (err) { } catch (err) {
this.proxyTelemetry.record({
service: "pcat_validate",
provider: this.proxyProvider,
targetHost: "gui.parts-catalogs.com",
errorKind: classifyTransportError(err),
success: false,
durationMs: Date.now() - start,
});
this.logger.debug(`Slot validation threw: ${(err as Error).message}`); this.logger.debug(`Slot validation threw: ${(err as Error).message}`);
return false; return false;
} }
@@ -539,15 +697,16 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
private getAvailableSite(): string { private getAvailableSite(): string {
const now = Date.now(); const now = Date.now();
const sites = this.jwtSites;
// Try round-robin, preferring sites not on cooldown // Try round-robin, preferring sites not on cooldown
for (let i = 0; i < JWT_SITES.length; i++) { for (let i = 0; i < sites.length; i++) {
const idx = (this.siteIndex + i) % JWT_SITES.length; const idx = (this.siteIndex + i) % sites.length;
const site = JWT_SITES[idx]; const site = sites[idx];
const lastUsed = this.siteLastUsedAt.get(site) || 0; const lastUsed = this.siteLastUsedAt.get(site) || 0;
if (now - lastUsed >= SITE_COOLDOWN) { if (now - lastUsed >= SITE_COOLDOWN) {
this.siteIndex = (idx + 1) % JWT_SITES.length; this.siteIndex = (idx + 1) % sites.length;
return site; return site;
} }
} }
@@ -555,15 +714,15 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
// All on cooldown — pick the one with oldest usage // All on cooldown — pick the one with oldest usage
let oldestIdx = 0; let oldestIdx = 0;
let oldestTime = Number.POSITIVE_INFINITY; let oldestTime = Number.POSITIVE_INFINITY;
for (let i = 0; i < JWT_SITES.length; i++) { for (let i = 0; i < sites.length; i++) {
const lastUsed = this.siteLastUsedAt.get(JWT_SITES[i]) || 0; const lastUsed = this.siteLastUsedAt.get(sites[i]) || 0;
if (lastUsed < oldestTime) { if (lastUsed < oldestTime) {
oldestTime = lastUsed; oldestTime = lastUsed;
oldestIdx = i; oldestIdx = i;
} }
} }
this.siteIndex = (oldestIdx + 1) % JWT_SITES.length; this.siteIndex = (oldestIdx + 1) % sites.length;
return JWT_SITES[oldestIdx]; return sites[oldestIdx];
} }
private allocatePort(): number { private allocatePort(): number {
@@ -572,19 +731,37 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
// ─── JWT capture via Playwright ─────────────────────────── // ─── JWT capture via Playwright ───────────────────────────
private async attemptCapture(siteUrl: string, port: number): Promise<PcatJwtToken | null> { private async attemptCapture(siteUrl: string): Promise<PcatJwtToken | null> {
let context: BrowserContext | null = null; let context: BrowserContext | null = null;
const startTime = Date.now(); const startTime = Date.now();
// Sticky capture proxy: one residential exit IP pinned for the lifetime of
// this capture so the partner site + widget see a coherent session.
const { proxyConfig, proxyUrl, sessionKey } = this.buildProxy("capture");
// The session is sticky, so a parallel ipify probe exits from the SAME IP
// the capture will use — that's what makes banned-IP tracking concrete.
// Resolves in ~1-2s while the capture itself takes 7s+; never throws.
const exitIpPromise = proxyUrl ? resolveExitIp(proxyUrl) : Promise.resolve(null);
const logCapture = (ok: boolean, errorKind?: string): void => {
void exitIpPromise.then((exitIp) =>
this.proxyTelemetry.record({
service: "pcat_capture",
provider: this.proxyProvider,
sessionKey,
exitIp,
targetHost: new URL(siteUrl).hostname,
errorKind: errorKind ?? null,
success: ok,
durationMs: Date.now() - startTime,
}),
);
};
try { try {
// Build context options with proxy
const contextOptions: Record<string, unknown> = {}; const contextOptions: Record<string, unknown> = {};
if (this.useProxy) { if (proxyConfig) {
contextOptions.proxy = { contextOptions.proxy = proxyConfig;
server: `http://${this.proxyHost}:${port}`,
username: this.proxyUser,
password: this.proxyPass,
};
} }
if (!this.browser) throw new Error("PCAT browser not initialized"); if (!this.browser) throw new Error("PCAT browser not initialized");
@@ -640,12 +817,16 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
return route.continue(); return route.continue();
}); });
// Navigate — networkidle waits for widget JS to load + make API calls // Navigate with domcontentloaded (not networkidle): on JS-heavy catalog
// sites through a slow residential proxy networkidle often never settles
// and the goto times out before the widget fires its token call. DOM-ready
// is reliable (~7s measured); the request listener + post-goto poll then
// catch the /v3/api/proxy token call (~11.8s measured).
let gotoOk = true; let gotoOk = true;
try { try {
await page.goto(siteUrl, { await page.goto(siteUrl, {
timeout: PAGE_TIMEOUT, timeout: PAGE_TIMEOUT,
waitUntil: "networkidle", waitUntil: "domcontentloaded",
}); });
} catch (navErr) { } catch (navErr) {
// Navigation may timeout but JWT could still be captured if the // Navigation may timeout but JWT could still be captured if the
@@ -665,13 +846,18 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
if (capturedToken) { if (capturedToken) {
logCapture(true);
this.logger.log(`Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`); this.logger.log(`Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`);
return capturedToken; return capturedToken;
} }
// Widget never fired its token call — on a sticky residential IP this is
// the classic "partner site blocked/ratelimited this exit IP" signature.
logCapture(false, "capture_timeout");
this.logger.debug(`No token after ${elapsed}ms from ${siteUrl}`); this.logger.debug(`No token after ${elapsed}ms from ${siteUrl}`);
return null; return null;
} catch (err) { } catch (err) {
logCapture(false, classifyTransportError(err));
this.logger.warn(`Token capture error: ${(err as Error).message}`); this.logger.warn(`Token capture error: ${(err as Error).message}`);
return null; return null;
} finally { } finally {

View File

@@ -1,8 +1,10 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ProxyTelemetryModule } from "../proxy-telemetry/proxy-telemetry.module";
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service"; import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
import { PartsCatalogsService } from "./parts-catalogs.service"; import { PartsCatalogsService } from "./parts-catalogs.service";
@Module({ @Module({
imports: [ProxyTelemetryModule],
providers: [PartsCatalogsAuthService, PartsCatalogsService], providers: [PartsCatalogsAuthService, PartsCatalogsService],
exports: [PartsCatalogsService], exports: [PartsCatalogsService],
}) })

View File

@@ -2,14 +2,20 @@
* Parts-Catalogs API Service — HTTP client for parts-catalogs.com v3 * Parts-Catalogs API Service — HTTP client for parts-catalogs.com v3
* *
* Calls the v3 widget proxy (gui.parts-catalogs.com/v3/api/proxy/*) with the * Calls the v3 widget proxy (gui.parts-catalogs.com/v3/api/proxy/*) with the
* captured TWS- token + supporting X-* headers. Requests must go through the * captured TWS- token + supporting X-* headers. The token is portable (NOT
* same DataImpulse proxy port that captured the token (IP-bound). * IP-bound), so each request rotates a fresh exit IP via the auth service's
* call-leg proxy (PartsCatalogsAuthService.buildProxy).
*/ */
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { isBackfillContext } from "../../jobs/prefetch-context"; import { isBackfillContext } from "../../jobs/prefetch-context";
import { PostHogService } from "../../posthog/posthog.service"; import { PostHogService } from "../../posthog/posthog.service";
import { RedisService } from "../../redis/redis.service"; import { RedisService } from "../../redis/redis.service";
import {
ProxyTelemetryService,
classifyTransportError,
describeProxyUrl,
} from "../proxy-telemetry/proxy-telemetry.service";
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service"; import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
import { import {
PcatCar, PcatCar,
@@ -20,19 +26,38 @@ import {
} from "./parts-catalogs.types"; } from "./parts-catalogs.types";
const API_BASE = "https://gui.parts-catalogs.com/v3/api/proxy"; const API_BASE = "https://gui.parts-catalogs.com/v3/api/proxy";
const REQUEST_TIMEOUT = Number(process.env.PCAT_REQUEST_TIMEOUT_MS) || 30_000; // Catalog scrape (groups2/parts2). Lowered 30s→10s (2026-06-10): the old 30s
// default could blow the caller's 25s decode budget on a single slow attempt,
// and through a rotating residential proxy a healthy call answers in 1-4s. A
// slow/blocked exit IP now aborts at 10s and the retry re-acquires a fresh IP.
const REQUEST_TIMEOUT = Number(process.env.PCAT_REQUEST_TIMEOUT_MS) || 10_000;
const MAX_RETRIES = Number(process.env.PCAT_MAX_RETRIES) || 2; const MAX_RETRIES = Number(process.env.PCAT_MAX_RETRIES) || 2;
// A dead DataImpulse proxy port otherwise stalls for undici's 10s default connect // undici's ProxyAgent connect.timeout bounds the connect to the proxy entry.
// timeout before the retry rotates to a fresh port — three of those blow the
// caller's 25s decode budget. Fail fast so retries reach a live port in time.
const PROXY_CONNECT_TIMEOUT = Number(process.env.PCAT_PROXY_CONNECT_TIMEOUT_MS) || 6_000; const PROXY_CONNECT_TIMEOUT = Number(process.env.PCAT_PROXY_CONNECT_TIMEOUT_MS) || 6_000;
// VIN decode (/car/info) answers in <1s on a healthy proxy, so a long timeout only // VIN decode (/car/info) answers in 1-4s through the rotating residential proxy.
// prolongs dead-port connects. undici's ProxyAgent connect.timeout does NOT bound the // A short per-call timeout + retries means a slow exit IP aborts fast and the
// connection to the proxy itself (it stays at undici's 10s default), so we bound it at // next attempt re-acquires the session → a fresh IP, all within the 25s budget.
// the fetch level: a short per-call timeout + extra retries makes a stuck DataImpulse
// port abort fast and rotate to a live one within the caller's 25s decode budget.
const DECODE_REQUEST_TIMEOUT = Number(process.env.PCAT_DECODE_TIMEOUT_MS) || 6_000; const DECODE_REQUEST_TIMEOUT = Number(process.env.PCAT_DECODE_TIMEOUT_MS) || 6_000;
const DECODE_MAX_RETRIES = Number(process.env.PCAT_DECODE_MAX_RETRIES) || 4; const DECODE_MAX_RETRIES = Number(process.env.PCAT_DECODE_MAX_RETRIES) || 4;
// Evict a pooled token after this many consecutive transport failures (reset on
// any 2xx). Through the rotating residential proxy each attempt hits a different
// exit IP, so N failures in a row on N different IPs ⇒ the token is dead at the
// source, not a flaky IP. Pre-rotation this was the "dead slot never evicted"
// bug: invalidateSession only fired on 401/403, so a bad token was served until TTL.
const TRANSPORT_FAIL_EVICT_THRESHOLD = Number(process.env.PCAT_TRANSPORT_FAIL_EVICT) || 2;
/**
* Thrown when a fast-fail (blockOnEmpty:false) caller hits a cold pool. NOT a
* "VIN not found" — pcat was skipped while the pool warmed, so the caller must
* treat it as a transient miss (don't poison the negative cache) and let
* another source answer.
*/
class PcatColdPoolError extends Error {
constructor() {
super("pcat cold pool — skipped to avoid inline capture");
this.name = "PcatColdPoolError";
}
}
@Injectable() @Injectable()
export class PartsCatalogsService { export class PartsCatalogsService {
@@ -42,6 +67,7 @@ export class PartsCatalogsService {
private authService: PartsCatalogsAuthService, private authService: PartsCatalogsAuthService,
private redis: RedisService, private redis: RedisService,
private posthog: PostHogService, private posthog: PostHogService,
private proxyTelemetry: ProxyTelemetryService,
) {} ) {}
/** Mark parts-catalogs as actively used (5min TTL) to defer prefetch worker */ /** Mark parts-catalogs as actively used (5min TTL) to defer prefetch worker */
@@ -62,7 +88,8 @@ export class PartsCatalogsService {
async decodeVin( async decodeVin(
vin: string, vin: string,
signal?: AbortSignal, signal?: AbortSignal,
outcome?: { transient: boolean }, outcome?: { transient: boolean; coldSkip?: boolean },
opts?: { blockOnEmpty?: boolean },
): Promise<PcatVinResult | null> { ): Promise<PcatVinResult | null> {
if (!(await this.posthog.isSourceLive("parts-catalogs"))) { if (!(await this.posthog.isSourceLive("parts-catalogs"))) {
this.logger.warn("parts-catalogs disabled by kill switch (kill-source-parts-catalogs)"); this.logger.warn("parts-catalogs disabled by kill switch (kill-source-parts-catalogs)");
@@ -72,6 +99,7 @@ export class PartsCatalogsService {
const data = await this.fetchWithAuth("/car/info", { q: vin }, signal, { const data = await this.fetchWithAuth("/car/info", { q: vin }, signal, {
timeoutMs: DECODE_REQUEST_TIMEOUT, timeoutMs: DECODE_REQUEST_TIMEOUT,
maxRetries: DECODE_MAX_RETRIES, maxRetries: DECODE_MAX_RETRIES,
blockOnEmpty: opts?.blockOnEmpty,
}); });
if (!data || typeof data !== "object") { if (!data || typeof data !== "object") {
@@ -105,9 +133,23 @@ export class PartsCatalogsService {
return { cars }; return { cars };
} catch (err) { } catch (err) {
const e = err as Error & { cause?: unknown }; const e = err as Error & { cause?: unknown };
// Distinguish a transient transport blip (proxy timeout / dropped DataImpulse // Cold-pool fast-fail: pcat was skipped (pool warming), NOT a definitive
// connection) from a genuine "not found". The caller uses `outcome.transient` // miss. Mark transient so the orchestrator doesn't negative-cache a VIN
// to decide whether to poison the negative cache — a blip must not. // pcat hasn't actually evaluated, and stay quiet (this is expected).
if (e instanceof PcatColdPoolError) {
if (outcome) {
outcome.transient = true;
// A deliberate cold-pool skip is not a transport fault — flag it so the
// circuit breaker doesn't count it (the pool is already warming).
outcome.coldSkip = true;
}
this.logger.debug(`pcat decode skipped for ${vin}: cold pool (warming)`);
return null;
}
// Distinguish a transient transport blip (proxy timeout / dropped
// connection) from a genuine "not found". The caller uses
// `outcome.transient` to decide whether to poison the negative cache — a
// blip must not.
if ( if (
outcome && outcome &&
(e.name === "TimeoutError" || (e.name === "TimeoutError" ||
@@ -212,7 +254,7 @@ export class PartsCatalogsService {
endpoint: string, endpoint: string,
params?: Record<string, string>, params?: Record<string, string>,
externalSignal?: AbortSignal, externalSignal?: AbortSignal,
opts?: { timeoutMs?: number; maxRetries?: number }, opts?: { timeoutMs?: number; maxRetries?: number; blockOnEmpty?: boolean },
): Promise<any> { ): Promise<any> {
const maxRetries = opts?.maxRetries ?? MAX_RETRIES; const maxRetries = opts?.maxRetries ?? MAX_RETRIES;
const timeoutMs = opts?.timeoutMs ?? REQUEST_TIMEOUT; const timeoutMs = opts?.timeoutMs ?? REQUEST_TIMEOUT;
@@ -223,7 +265,12 @@ export class PartsCatalogsService {
if (externalSignal?.aborted) { if (externalSignal?.aborted) {
throw new Error("Request aborted by caller"); throw new Error("Request aborted by caller");
} }
session = await this.authService.acquireSession(); session = await this.authService.acquireSession({ blockOnEmpty: opts?.blockOnEmpty });
if (!session) {
// Cold pool + fast-fail: warming in the background; don't retry, let the
// caller fall through to another source.
throw new PcatColdPoolError();
}
const url = new URL(`${API_BASE}${endpoint}`); const url = new URL(`${API_BASE}${endpoint}`);
if (params) { if (params) {
@@ -232,6 +279,7 @@ export class PartsCatalogsService {
} }
} }
const attemptStart = Date.now();
try { try {
const signals = [AbortSignal.timeout(timeoutMs)]; const signals = [AbortSignal.timeout(timeoutMs)];
if (externalSignal) signals.push(externalSignal); if (externalSignal) signals.push(externalSignal);
@@ -262,7 +310,20 @@ export class PartsCatalogsService {
const response = await fetch(url.toString(), fetchOptions); const response = await fetch(url.toString(), fetchOptions);
// An HTTP answer arrived — the proxy leg worked at transport level;
// log the upstream's verdict (403/429 are the IP-ban signals).
this.proxyTelemetry.record({
service: "pcat_call",
...describeProxyUrl(session.proxyUrl),
targetHost: url.hostname,
statusCode: response.status,
success: response.ok,
durationMs: Date.now() - attemptStart,
});
if (response.ok) { if (response.ok) {
// Token proven good on this IP — clear any accrued failure streak.
session._slot.failCount = 0;
return await response.json(); return await response.json();
} }
@@ -280,6 +341,18 @@ export class PartsCatalogsService {
throw new Error(`HTTP ${response.status} from ${endpoint}: ${text.slice(0, 200)}`); throw new Error(`HTTP ${response.status} from ${endpoint}: ${text.slice(0, 200)}`);
} catch (err) { } catch (err) {
const e = err as Error & { cause?: unknown }; const e = err as Error & { cause?: unknown };
// Transport failure (no HTTP answer) — the try block already logged any
// real HTTP response before rethrowing it as `Error: HTTP <code> …`.
if (!/^HTTP \d/.test(e.message ?? "")) {
this.proxyTelemetry.record({
service: "pcat_call",
...describeProxyUrl(session.proxyUrl),
targetHost: url.hostname,
errorKind: classifyTransportError(e),
success: false,
durationMs: Date.now() - attemptStart,
});
}
// Retry transient TRANSPORT failures only: request timeouts and undici // Retry transient TRANSPORT failures only: request timeouts and undici
// network errors ("TypeError: fetch failed" — a dropped/reset DataImpulse // network errors ("TypeError: fetch failed" — a dropped/reset DataImpulse
// proxy connection). A definitive HTTP response (e.g. 400 "list of parts // proxy connection). A definitive HTTP response (e.g. 400 "list of parts
@@ -291,16 +364,28 @@ export class PartsCatalogsService {
/fetch failed|ECONNRESET|ECONNREFUSED|EAI_AGAIN|socket hang up|other side closed|terminated|UND_ERR/i.test( /fetch failed|ECONNRESET|ECONNREFUSED|EAI_AGAIN|socket hang up|other side closed|terminated|UND_ERR/i.test(
`${e.message} ${String(e.cause ?? "")}`, `${e.message} ${String(e.cause ?? "")}`,
); );
if (transient && attempt < maxRetries) { if (transient) {
this.logger.warn( // A token that keeps failing transport across rotating IPs is dead at
`Transient transport error on ${endpoint} (attempt ${attempt + 1}/${maxRetries + 1}): ${e.message}${ // the source — evict it so it stops being round-robin-served (the old
e.cause ? ` [cause: ${String(e.cause)}]` : "" // 401/403-only eviction never caught this). Only when a proxy is in use;
}`, // direct-mode transport errors are upstream, not token/IP problems.
); if (session.proxyUrl) {
// Brief backoff so a momentarily-flaky proxy port can recover; the next session._slot.failCount = (session._slot.failCount ?? 0) + 1;
// loop iteration re-acquires a session (round-robin across the pool). if (session._slot.failCount >= TRANSPORT_FAIL_EVICT_THRESHOLD) {
await new Promise((r) => setTimeout(r, 400 * (attempt + 1))); await this.authService.invalidateSession(session);
continue; }
}
if (attempt < maxRetries) {
this.logger.warn(
`Transient transport error on ${endpoint} (attempt ${attempt + 1}/${maxRetries + 1}): ${e.message}${
e.cause ? ` [cause: ${String(e.cause)}]` : ""
}`,
);
// Brief backoff so a momentarily-flaky exit IP can recover; the next
// loop iteration re-acquires a session (a fresh rotating IP).
await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
continue;
}
} }
throw err; throw err;
} }

View File

@@ -1,7 +1,12 @@
/** /**
* Captured from a parts-catalogs.com v3 widget request. * Captured from a parts-catalogs.com v3 widget request.
* Token + the supporting X-* headers the widget sends with every API call. * Token + the supporting X-* headers the widget sends with every API call.
* IP-bound (must be reused with the same proxy port that captured it). *
* NOT request-time IP-bound. Verified on prod 2026-06-10: 0 auth 401/403 across
* thousands of proxied calls, and a token captured on one exit IP replays with a
* 200 through arbitrary other IPs (incl. fresh residential ones). Upstream issues
* a stable shared token per origin. So the call leg rotates exit IPs freely; only
* the capture (Playwright) leg holds a sticky IP for the duration of one capture.
*/ */
export interface PcatJwtToken { export interface PcatJwtToken {
raw: string; // x-api-key value, e.g. "TWS-016EA7BE-..." raw: string; // x-api-key value, e.g. "TWS-016EA7BE-..."
@@ -15,10 +20,14 @@ export interface PcatJwtToken {
export interface JwtSlot { export interface JwtSlot {
jwt: PcatJwtToken; jwt: PcatJwtToken;
proxyPort: number;
siteUsed: string; siteUsed: string;
capturedAt: number; capturedAt: number;
refreshTimer: ReturnType<typeof setTimeout> | null; refreshTimer: ReturnType<typeof setTimeout> | null;
// Consecutive transport failures across requests (reset on any 2xx). The slot
// is evicted once this crosses a threshold — a token that keeps failing on
// fresh rotating IPs is dead at the source, not a flaky exit IP. Optional so
// slots persisted to Redis by older builds still hydrate.
failCount?: number;
} }
export interface PcatSession { export interface PcatSession {

View File

@@ -565,6 +565,7 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
// Opel / Vauxhall // Opel / Vauxhall
W0L: "opel_parts", // Opel AG (Germany) W0L: "opel_parts", // Opel AG (Germany)
W0V: "opel_parts", // Opel (newer Stellantis-era WMI) 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) // Citroën (PSA)
VF7: "citroen_parts", // Citroën SA (France) VF7: "citroen_parts", // Citroën SA (France)

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { ProxyTelemetryService } from "./proxy-telemetry.service";
// Imported by PartsCatalogsModule and EmexModule; Nest module caching makes the
// service a singleton (one shared flush buffer) across both.
@Module({
providers: [ProxyTelemetryService],
exports: [ProxyTelemetryService],
})
export class ProxyTelemetryModule {}

View File

@@ -0,0 +1,151 @@
import { Inject, Injectable, Logger, OnModuleDestroy } from "@nestjs/common";
import { DATABASE, type Database } from "../../database/database.provider";
import { proxyLogs } from "../../database/schema/core";
/**
* Proxy telemetry — fire-and-forget logging of every proxied upstream attempt
* (pcat call/capture/validate, emex http) into `proxy_logs`, so the Süper Panel
* can grade proxy providers per service and track banned exit IPs / sessions.
*
* Design constraints:
* - MUST never affect the request path: record() only pushes to an in-memory
* buffer; a timer flushes batches, and every DB error is swallowed.
* - Bounded memory: the buffer is capped; when full, oldest rows are dropped.
*/
export type ProxyServiceLeg = "pcat_call" | "pcat_capture" | "pcat_validate" | "emex_http";
export type ProxyProviderName = "floxy" | "dataimpulse" | "none";
export interface ProxyLogEvent {
service: ProxyServiceLeg;
provider: ProxyProviderName;
sessionKey?: string | null;
exitIp?: string | null;
targetHost?: string | null;
statusCode?: number | null;
errorKind?: string | null;
success: boolean;
durationMs?: number | null;
}
const FLUSH_INTERVAL_MS = 5_000;
const FLUSH_BATCH_SIZE = 50;
const BUFFER_CAP = 500;
// Upstream statuses that signal an IP-level block (not an auth/data problem):
// 403 = forbidden (WAF/geo/IP ban), 429 = rate limited. 402 is the proxy
// provider itself rejecting (balance exhausted — Floxy did this 2026-06-10);
// counted as banned=false but worth surfacing via status_code.
export function isBanStatus(status: number | null | undefined): boolean {
return status === 403 || status === 429;
}
/** Classify a transport error (no HTTP answer) into a coarse error_kind. */
export function classifyTransportError(err: unknown): string {
const e = err as Error & { cause?: unknown };
const text = `${e?.name ?? ""} ${e?.message ?? ""} ${String(e?.cause ?? "")}`;
if (/TimeoutError|UND_ERR_CONNECT_TIMEOUT|UND_ERR_HEADERS_TIMEOUT/i.test(text)) return "timeout";
if (/AbortError|aborted/i.test(text)) return "aborted";
if (/ECONNREFUSED|UND_ERR_SOCKET|connect/i.test(text)) return "connect";
if (/ECONNRESET|socket hang up|other side closed|terminated/i.test(text)) return "reset";
if (/EAI_AGAIN|ENOTFOUND/i.test(text)) return "dns";
return "transport";
}
/**
* Derive provider + session key from a proxy URL the way the integrations
* build them: Floxy passwords may embed `_session-<id>_lifetime-<n>` (sticky);
* DataImpulse rotates by port, so the port IS the session identity.
*/
export function describeProxyUrl(proxyUrl: string | null | undefined): {
provider: ProxyProviderName;
sessionKey: string | null;
} {
if (!proxyUrl) return { provider: "none", sessionKey: null };
try {
const u = new URL(proxyUrl);
if (/floxy/i.test(u.hostname)) {
const m = decodeURIComponent(u.password).match(/_session-([A-Za-z0-9]+)/);
return { provider: "floxy", sessionKey: m ? `s-${m[1]}` : null };
}
return { provider: "dataimpulse", sessionKey: `di:${u.port}` };
} catch {
return { provider: "none", sessionKey: null };
}
}
/**
* Resolve the residential exit IP of a (sticky) proxy session with one cheap
* probe through the same proxy. Only meaningful for sticky sessions — on a
* rotating leg the probe and the real request exit from different IPs.
*/
export async function resolveExitIp(
proxyUrl: string,
timeoutMs = 5_000,
): Promise<string | null> {
try {
const { ProxyAgent } = await import("undici");
const res = await fetch("https://api.ipify.org?format=text", {
dispatcher: new ProxyAgent({ uri: proxyUrl, connect: { timeout: timeoutMs } }),
signal: AbortSignal.timeout(timeoutMs),
} as unknown as RequestInit);
if (!res.ok) return null;
const ip = (await res.text()).trim();
return /^[0-9a-fA-F.:]{3,45}$/.test(ip) ? ip : null;
} catch {
return null;
}
}
@Injectable()
export class ProxyTelemetryService implements OnModuleDestroy {
private readonly logger = new Logger(ProxyTelemetryService.name);
private buffer: ProxyLogEvent[] = [];
private flushTimer: NodeJS.Timeout | null = null;
private flushing = false;
constructor(@Inject(DATABASE) private readonly db: Database) {
this.flushTimer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
// Never keep the process alive just to flush telemetry.
this.flushTimer.unref?.();
}
/** Queue one proxied-attempt record. Never throws, never blocks. */
record(event: ProxyLogEvent): void {
if (this.buffer.length >= BUFFER_CAP) this.buffer.shift();
this.buffer.push(event);
if (this.buffer.length >= FLUSH_BATCH_SIZE) void this.flush();
}
private async flush(): Promise<void> {
if (this.flushing || this.buffer.length === 0) return;
this.flushing = true;
const batch = this.buffer.splice(0, FLUSH_BATCH_SIZE * 2);
try {
await this.db.insert(proxyLogs).values(
batch.map((e) => ({
service: e.service,
provider: e.provider,
sessionKey: e.sessionKey ?? null,
exitIp: e.exitIp ?? null,
targetHost: e.targetHost?.slice(0, 120) ?? null,
statusCode: e.statusCode ?? null,
errorKind: e.errorKind ?? null,
success: e.success,
banned: isBanStatus(e.statusCode),
durationMs: e.durationMs != null ? Math.round(e.durationMs) : null,
})),
);
} catch (err) {
// Telemetry must never matter more than the product. Drop the batch.
this.logger.debug(`proxy_logs flush failed (${batch.length} rows): ${(err as Error).message}`);
} finally {
this.flushing = false;
}
}
async onModuleDestroy(): Promise<void> {
if (this.flushTimer) clearInterval(this.flushTimer);
await this.flush();
}
}

View File

@@ -27,4 +27,5 @@ export const QUEUE_NAMES = {
CATALOG_PREFETCH: "catalog-prefetch", CATALOG_PREFETCH: "catalog-prefetch",
TRANSLATION: "translation", TRANSLATION: "translation",
LIFECYCLE_EMAIL: "lifecycle-email", LIFECYCLE_EMAIL: "lifecycle-email",
EXPERT_REWARDS: "expert-rewards",
} as const; } as const;

View File

@@ -8,6 +8,7 @@ import {
CatalogPrefetchQueueProvider, CatalogPrefetchQueueProvider,
} from "./queues/catalog-prefetch.queue"; } from "./queues/catalog-prefetch.queue";
import { EMEX_SCRAPE_QUEUE, EmexScrapeQueueProvider } from "./queues/emex-scrape.queue"; import { EMEX_SCRAPE_QUEUE, EmexScrapeQueueProvider } from "./queues/emex-scrape.queue";
import { EXPERT_REWARDS_QUEUE, ExpertRewardsQueueProvider } from "./queues/expert-rewards.queue";
import { LIFECYCLE_EMAIL_QUEUE, LifecycleEmailQueueProvider } from "./queues/lifecycle-email.queue"; import { LIFECYCLE_EMAIL_QUEUE, LifecycleEmailQueueProvider } from "./queues/lifecycle-email.queue";
import { QUERY_CLEANUP_QUEUE, QueryCleanupQueueProvider } from "./queues/query-cleanup.queue"; import { QUERY_CLEANUP_QUEUE, QueryCleanupQueueProvider } from "./queues/query-cleanup.queue";
import { import {
@@ -23,6 +24,7 @@ import {
QueryCleanupQueueProvider, QueryCleanupQueueProvider,
CatalogPrefetchQueueProvider, CatalogPrefetchQueueProvider,
LifecycleEmailQueueProvider, LifecycleEmailQueueProvider,
ExpertRewardsQueueProvider,
PrefetchWorkerService, PrefetchWorkerService,
], ],
exports: [ exports: [
@@ -31,6 +33,7 @@ import {
QUERY_CLEANUP_QUEUE, QUERY_CLEANUP_QUEUE,
CATALOG_PREFETCH_QUEUE, CATALOG_PREFETCH_QUEUE,
LIFECYCLE_EMAIL_QUEUE, LIFECYCLE_EMAIL_QUEUE,
EXPERT_REWARDS_QUEUE,
], ],
}) })
export class JobsModule implements OnModuleInit, OnModuleDestroy { export class JobsModule implements OnModuleInit, OnModuleDestroy {
@@ -39,6 +42,7 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
@Inject(QUERY_CLEANUP_QUEUE) private queryCleanupQueue: Queue, @Inject(QUERY_CLEANUP_QUEUE) private queryCleanupQueue: Queue,
@Inject(CATALOG_PREFETCH_QUEUE) private catalogPrefetchQueue: Queue, @Inject(CATALOG_PREFETCH_QUEUE) private catalogPrefetchQueue: Queue,
@Inject(LIFECYCLE_EMAIL_QUEUE) private lifecycleEmailQueue: Queue, @Inject(LIFECYCLE_EMAIL_QUEUE) private lifecycleEmailQueue: Queue,
@Inject(EXPERT_REWARDS_QUEUE) private expertRewardsQueue: Queue,
) {} ) {}
async onModuleInit() { async onModuleInit() {
@@ -124,6 +128,24 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
await this.catalogPrefetchQueue.removeJobScheduler("catalog-backfill-hourly").catch(() => {}); await this.catalogPrefetchQueue.removeJobScheduler("catalog-backfill-hourly").catch(() => {});
console.log("[jobs] Skipped catalog-backfill cron (not prod host)"); console.log("[jobs] Skipped catalog-backfill cron (not prod host)");
} }
// Parça Uzmanları sezon kapanışı: her ayın 1'i 00:00 Türkiye saati —
// biten ayın ilk 3 oylayıcısına üyelik uzatması (30/15/7 gün). Ödül
// yalnız DB'ye yazar (dış yan etki yok), o yüzden dev'de de çalışır;
// (period, rank) unique index'i çift vermeyi zaten engeller.
await this.expertRewardsQueue.upsertJobScheduler(
"expert-rewards-monthly",
{ pattern: "0 0 1 * *", tz: "Europe/Istanbul" },
{
name: "expert-rewards-grant",
data: {},
opts: {
removeOnComplete: { count: 24 },
removeOnFail: { count: 50 },
},
},
);
console.log("[jobs] Registered expert-rewards cron: 0 0 1 * * (Europe/Istanbul)");
} }
async onModuleDestroy() { async onModuleDestroy() {
@@ -132,6 +154,7 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
this.queryCleanupQueue.close(), this.queryCleanupQueue.close(),
this.catalogPrefetchQueue.close(), this.catalogPrefetchQueue.close(),
this.lifecycleEmailQueue.close(), this.lifecycleEmailQueue.close(),
this.expertRewardsQueue.close(),
]); ]);
} }
} }

View File

@@ -0,0 +1,114 @@
import { Job } from "bullmq";
import { and, asc, desc, eq, gte, lt, or, sql } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import {
oemExpertRewards,
oemVotePoints,
userSubscriptions,
users,
} from "../../database/schema/core";
import { EXPERT_REWARD_LADDER, previousTrMonthWindow } from "../../oem-votes/expert-period";
type Database = PostgresJsDatabase<Record<string, unknown>>;
// Parça Uzmanları sezon kapanışı: her ayın 1'i 00:00 TR'de biten ayın ilk 3
// oylayıcısına üyelik uzatması (30/15/7 gün). oem_expert_rewards'taki
// (period, rank) unique index run-once garantisidir — yeniden çalıştırma
// (retry, elle tetik) hiçbir şeyi ikinci kez vermez.
export async function processExpertRewards(
job: Job,
db: Database,
): Promise<{ period: string; granted: number; skipped: boolean }> {
const { start, end } = previousTrMonthWindow(new Date());
const periodLabel = start.toISOString();
console.log(`[expert-rewards] Processing job ${job.id} for period ${periodLabel}`);
const [alreadyGranted] = await db
.select({ id: oemExpertRewards.id })
.from(oemExpertRewards)
.where(eq(oemExpertRewards.periodStart, start))
.limit(1);
if (alreadyGranted) {
console.log(`[expert-rewards] Period ${periodLabel} already granted — skipping`);
return { period: periodLabel, granted: 0, skipped: true };
}
// Biten sezonun ilk 3'ü — liderlik tablosuyla aynı sıralama: puan desc,
// eşitlikte puana daha erken ulaşan önde.
const totalPoints = sql<number>`sum(${oemVotePoints.points})::int`;
const top = await db
.select({ userId: oemVotePoints.userId, points: totalPoints })
.from(oemVotePoints)
.where(and(gte(oemVotePoints.createdAt, start), lt(oemVotePoints.createdAt, end)))
.groupBy(oemVotePoints.userId)
.orderBy(desc(totalPoints), asc(sql`min(${oemVotePoints.createdAt})`))
.limit(EXPERT_REWARD_LADDER.length);
if (top.length === 0) {
console.log(`[expert-rewards] No votes in period ${periodLabel} — nothing to grant`);
return { period: periodLabel, granted: 0, skipped: false };
}
let granted = 0;
for (let i = 0; i < top.length; i++) {
const winner = top[i];
const { rank, days } = EXPERT_REWARD_LADDER[i];
await db.transaction(async (tx) => {
const inserted = await tx
.insert(oemExpertRewards)
.values({
periodStart: start,
userId: winner.userId,
rank,
points: winner.points,
rewardDays: days,
})
.onConflictDoNothing()
.returning({ id: oemExpertRewards.id });
// Yarış/yeniden-deneme: kayıt zaten varsa gün de verilmiş demektir.
if (inserted.length === 0) return;
// Referral ödül mekaniğinin birebir kopyası (worker Nest DI'sız çalıştığı
// için ReferralsService.grantRewardDays buradan çağrılamıyor): canlı
// active/trial aboneliği uzat, yoksa günleri krediye banka et — kredi bir
// sonraki trial/aktivasyonda otomatik tüketilir.
const [sub] = await tx
.select({ id: userSubscriptions.id, endDate: userSubscriptions.endDate })
.from(userSubscriptions)
.where(
and(
eq(userSubscriptions.userId, winner.userId),
or(eq(userSubscriptions.status, "active"), eq(userSubscriptions.status, "trial")),
),
)
.orderBy(desc(userSubscriptions.endDate))
.limit(1);
if (sub?.endDate) {
const newEnd = new Date(sub.endDate);
newEnd.setDate(newEnd.getDate() + days);
await tx
.update(userSubscriptions)
.set({ endDate: newEnd, updatedAt: new Date() })
.where(eq(userSubscriptions.id, sub.id));
} else {
await tx
.update(users)
.set({
referralCreditDays: sql`${users.referralCreditDays} + ${days}`,
updatedAt: new Date(),
})
.where(eq(users.id, winner.userId));
}
granted++;
console.log(
`[expert-rewards] rank=${rank} user=${winner.userId} points=${winner.points} +${days}d`,
);
});
}
console.log(`[expert-rewards] Period ${periodLabel}: granted ${granted} reward(s)`);
return { period: periodLabel, granted, skipped: false };
}

View File

@@ -67,17 +67,11 @@ async function sendTrialEnding(db: Database, now: Date): Promise<number> {
.innerJoin(users, eq(userSubscriptions.userId, users.id)) .innerJoin(users, eq(userSubscriptions.userId, users.id))
.leftJoin( .leftJoin(
emailPreferences, emailPreferences,
and( and(eq(emailPreferences.userId, users.id), eq(emailPreferences.workflow, "trial-ending")),
eq(emailPreferences.userId, users.id),
eq(emailPreferences.workflow, "trial-ending"),
),
) )
.leftJoin( .leftJoin(
lifecycleEmailSent, lifecycleEmailSent,
and( and(eq(lifecycleEmailSent.userId, users.id), eq(lifecycleEmailSent.workflow, "trial-ending")),
eq(lifecycleEmailSent.userId, users.id),
eq(lifecycleEmailSent.workflow, "trial-ending"),
),
) )
.where( .where(
and( and(
@@ -96,11 +90,7 @@ async function sendTrialEnding(db: Database, now: Date): Promise<number> {
{ subscriberId: r.userId, email: r.email, firstName: firstNameOf(r.name) }, { subscriberId: r.userId, email: r.email, firstName: firstNameOf(r.name) },
{ {
daysLeft: 3, daysLeft: 3,
ctaUrl: buildTrackedUrl( ctaUrl: buildTrackedUrl("trial-ending", r.email, webUrl("/dashboard/subscription")),
"trial-ending",
r.email,
webUrl("/dashboard/subscription"),
),
...(trackPixel ? { trackPixel } : {}), ...(trackPixel ? { trackPixel } : {}),
}, },
); );
@@ -134,17 +124,11 @@ async function sendWinBack(db: Database, now: Date): Promise<number> {
.innerJoin(users, eq(userSubscriptions.userId, users.id)) .innerJoin(users, eq(userSubscriptions.userId, users.id))
.leftJoin( .leftJoin(
emailPreferences, emailPreferences,
and( and(eq(emailPreferences.userId, users.id), eq(emailPreferences.workflow, "win-back")),
eq(emailPreferences.userId, users.id),
eq(emailPreferences.workflow, "win-back"),
),
) )
.leftJoin( .leftJoin(
lifecycleEmailSent, lifecycleEmailSent,
and( and(eq(lifecycleEmailSent.userId, users.id), eq(lifecycleEmailSent.workflow, "win-back")),
eq(lifecycleEmailSent.userId, users.id),
eq(lifecycleEmailSent.workflow, "win-back"),
),
) )
.where( .where(
and( and(

View File

@@ -1,11 +1,14 @@
import { Job } from "bullmq"; import { Job } from "bullmq";
import { lt } from "drizzle-orm"; import { lt } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { queryLogs } from "../../database/schema/core"; import { proxyLogs, queryLogs } from "../../database/schema/core";
type Database = PostgresJsDatabase<Record<string, unknown>>; type Database = PostgresJsDatabase<Record<string, unknown>>;
const RETENTION_DAYS = 90; const RETENTION_DAYS = 90;
// proxy_logs is high-volume per-attempt telemetry; 30 days is plenty for
// grading providers and spotting ban waves.
const PROXY_LOG_RETENTION_DAYS = 30;
export async function processQueryCleanup( export async function processQueryCleanup(
job: Job, job: Job,
@@ -27,5 +30,15 @@ export async function processQueryCleanup(
`[query-cleanup] Deleted ${deletedCount} query log(s) older than ${RETENTION_DAYS} days (before ${cutoffDate.toISOString()})`, `[query-cleanup] Deleted ${deletedCount} query log(s) older than ${RETENTION_DAYS} days (before ${cutoffDate.toISOString()})`,
); );
const proxyCutoff = new Date();
proxyCutoff.setDate(proxyCutoff.getDate() - PROXY_LOG_RETENTION_DAYS);
const proxyDeleted = await db
.delete(proxyLogs)
.where(lt(proxyLogs.createdAt, proxyCutoff))
.returning({ id: proxyLogs.id });
console.log(
`[query-cleanup] Deleted ${proxyDeleted.length} proxy log(s) older than ${PROXY_LOG_RETENTION_DAYS} days`,
);
return { deletedCount }; return { deletedCount };
} }

View File

@@ -0,0 +1,25 @@
import { Provider } from "@nestjs/common";
import { Queue } from "bullmq";
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
export const EXPERT_REWARDS_QUEUE = "EXPERT_REWARDS_QUEUE";
export const ExpertRewardsQueueProvider: Provider = {
provide: EXPERT_REWARDS_QUEUE,
useFactory: () => {
const telemetry = getBullTelemetry();
return new Queue(QUEUE_NAMES.EXPERT_REWARDS, {
connection: getBullConnection(),
...(telemetry ? { telemetry } : {}),
defaultJobOptions: {
attempts: 3,
backoff: {
type: "exponential",
delay: 10000,
},
removeOnComplete: { count: 100 },
removeOnFail: { count: 200 },
},
});
},
};

View File

@@ -0,0 +1,35 @@
import { BadRequestException, Body, Controller, Get, Post, Query } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { ExpertAccessService } from "../oem-votes/expert-access.service";
import { createOemSuggestionSchema } from "./oem-suggestions.dto";
import { OemSuggestionsService } from "./oem-suggestions.service";
@Controller("oem-suggestions")
export class OemSuggestionsController {
constructor(
private readonly oemSuggestionsService: OemSuggestionsService,
private readonly expertAccess: ExpertAccessService,
) {}
// Spam koruması: dakikada en fazla 10 öneri.
@Post()
@Throttle({ default: { limit: 10, ttl: 60_000 } })
async create(@CurrentUser("id") userId: string, @Body() body: unknown) {
await this.expertAccess.assert(userId);
const parsed = createOemSuggestionSchema.safeParse(body);
if (!parsed.success) {
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz öneri");
}
return this.oemSuggestionsService.create(userId, parsed.data);
}
@Get()
async list(@CurrentUser("id") userId: string, @Query("code") code: string) {
const oemCode = (code ?? "").trim();
if (oemCode.length < 2 || oemCode.length > 100) {
throw new BadRequestException("OEM kodu geçersiz");
}
return { suggestions: await this.oemSuggestionsService.list(userId, oemCode) };
}
}

View File

@@ -0,0 +1,13 @@
import { z } from "zod";
export const createOemSuggestionSchema = z.object({
oemCode: z.string().trim().min(2, "OEM kodu geçersiz").max(100, "OEM kodu çok uzun"),
brand: z.string().trim().min(2, "Marka en az 2 karakter olmalı").max(80, "Marka çok uzun"),
suggestedCode: z
.string()
.trim()
.min(2, "Parça kodu en az 2 karakter olmalı")
.max(100, "Parça kodu çok uzun")
.refine((value) => /[a-zA-Z0-9]{2,}/.test(value), "Parça kodu geçersiz"),
});
export type CreateOemSuggestionInput = z.infer<typeof createOemSuggestionSchema>;

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { aggregateSuggestions, normalizeSuggestedCode } from "./oem-suggestions.logic";
describe("normalizeSuggestedCode", () => {
it("boşluk/tire söker, büyütür", () => {
expect(normalizeSuggestedCode("1j0 973-702")).toBe("1J0973702");
expect(normalizeSuggestedCode("8V0 615 423 E")).toBe("8V0615423E");
});
});
describe("aggregateSuggestions", () => {
const row = (userId: string, brand: string, code: string) => ({
userId,
brand,
suggestedCode: code,
suggestedCodeNorm: normalizeSuggestedCode(code),
});
it("aynı marka+normalize kodu tek satırda toplar, çok önerilen üste çıkar", () => {
const rows = [
row("u1", "Bosch", "0 986 478 123"),
row("u2", "bosch", "0986478123"),
row("u3", "TRW", "DF4823"),
];
const out = aggregateSuggestions(rows, "u3");
expect(out).toHaveLength(2);
expect(out[0]).toMatchObject({ brand: "Bosch", code: "0 986 478 123", count: 2, mine: false });
expect(out[1]).toMatchObject({ brand: "TRW", count: 1, mine: true });
});
it("görünen yazım ilk gönderenin yazımıdır", () => {
const out = aggregateSuggestions(
[row("u1", "VALEO", "PHC123"), row("u2", "Valeo", "phc-123")],
"x",
);
expect(out[0].brand).toBe("VALEO");
expect(out[0].code).toBe("PHC123");
expect(out[0].count).toBe(2);
});
it("farklı marka aynı kod ayrı satırdır", () => {
const out = aggregateSuggestions([row("u1", "Bosch", "X1"), row("u2", "TRW", "X1")], "u1");
expect(out).toHaveLength(2);
});
});

View File

@@ -0,0 +1,45 @@
// Kod normalizasyonu: gruplama ve kullanıcı-başına dedup anahtarı. Görselde
// kullanıcının yazdığı hâl korunur ("1J0 973 702" ↔ "1j0973702" aynı öneri).
export function normalizeSuggestedCode(code: string): string {
return code.toUpperCase().replace(/[^A-Z0-9]/g, "");
}
export interface SuggestionRow {
userId: string;
brand: string;
suggestedCode: string;
suggestedCodeNorm: string;
}
export interface AggregatedSuggestion {
brand: string;
code: string;
count: number;
mine: boolean;
}
// Aynı (marka, normalize kod) önerisini tek satırda toplar: en çok önerilen
// üstte, eşitlikte ilk gönderilen önde (rows createdAt sıralı gelir). Görünen
// marka/kod ilk gönderenin yazımıdır.
export function aggregateSuggestions(
rows: SuggestionRow[],
userId: string,
): AggregatedSuggestion[] {
const groups = new Map<string, AggregatedSuggestion>();
for (const row of rows) {
const key = `${row.brand.trim().toLocaleLowerCase("tr-TR")}|${row.suggestedCodeNorm}`;
const existing = groups.get(key);
if (existing) {
existing.count += 1;
existing.mine ||= row.userId === userId;
} else {
groups.set(key, {
brand: row.brand.trim(),
code: row.suggestedCode.trim(),
count: 1,
mine: row.userId === userId,
});
}
}
return [...groups.values()].sort((a, b) => b.count - a.count);
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { OemVotesModule } from "../oem-votes/oem-votes.module";
import { OemSuggestionsController } from "./oem-suggestions.controller";
import { OemSuggestionsService } from "./oem-suggestions.service";
@Module({
imports: [OemVotesModule],
controllers: [OemSuggestionsController],
providers: [OemSuggestionsService],
exports: [OemSuggestionsService],
})
export class OemSuggestionsModule {}

View File

@@ -0,0 +1,60 @@
import { Inject, Injectable } from "@nestjs/common";
import { and, asc, eq } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { oemSuggestions } from "../database/schema/core";
import {
type AggregatedSuggestion,
aggregateSuggestions,
normalizeSuggestedCode,
} from "./oem-suggestions.logic";
export interface CreateSuggestionResult {
created: boolean; // false = bu kullanıcı aynı öneriyi zaten göndermiş
suggestions: AggregatedSuggestion[];
}
@Injectable()
export class OemSuggestionsService {
constructor(@Inject(DATABASE) private db: Database) {}
async create(
userId: string,
input: { oemCode: string; brand: string; suggestedCode: string },
): Promise<CreateSuggestionResult> {
const oemCode = input.oemCode.trim();
// Aynı kullanıcının aynı (kod, normalize öneri) tekrarı sessizce yutulur —
// sayaç şişirme yok; farklı kullanıcılar aynı öneriyi destekleyerek büyütür.
const inserted = await this.db
.insert(oemSuggestions)
.values({
userId,
oemCode,
brand: input.brand.trim(),
suggestedCode: input.suggestedCode.trim(),
suggestedCodeNorm: normalizeSuggestedCode(input.suggestedCode),
})
.onConflictDoNothing()
.returning({ id: oemSuggestions.id });
return {
created: inserted.length > 0,
suggestions: await this.list(userId, oemCode),
};
}
async list(userId: string, oemCode: string): Promise<AggregatedSuggestion[]> {
const rows = await this.db
.select({
userId: oemSuggestions.userId,
brand: oemSuggestions.brand,
suggestedCode: oemSuggestions.suggestedCode,
suggestedCodeNorm: oemSuggestions.suggestedCodeNorm,
})
.from(oemSuggestions)
.where(and(eq(oemSuggestions.oemCode, oemCode.trim()), eq(oemSuggestions.status, "active")))
.orderBy(asc(oemSuggestions.createdAt))
.limit(500);
return aggregateSuggestions(rows, userId);
}
}

View File

@@ -0,0 +1,49 @@
import { ForbiddenException, Inject, Injectable } from "@nestjs/common";
import { and, countDistinct, eq } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { oemCodeCopies, queryLogs } from "../database/schema/core";
import { PostHogService } from "../posthog/posthog.service";
import { meetsExpertCriteria } from "./oem-votes.logic";
// Parça Uzmanları programının ana şalteri (PostHog, local-eval). Kapatınca
// program herkesten gizlenir; yüzdeli rollout gerekirse oradan daraltılır.
export const EXPERT_PROGRAM_FLAG = "oem-expert-program";
@Injectable()
export class ExpertAccessService {
constructor(
@Inject(DATABASE) private db: Database,
private readonly posthog: PostHogService,
) {}
// Erişim = flag AÇIK VE kullanım kriteri: en az 5 FARKLI başarılı VIN
// decode + en az 5 FARKLI OEM kodu kopyası (kayıtlı, kataloğu gerçekten
// kullanan üyeler — eşikler oem-votes.logic.ts'te). Flag local-eval
// edilemezse fail-open — kitleyi kriter zaten daraltır, PostHog kesintisi
// programı söndürmesin.
async check(userId: string): Promise<{ enabled: boolean }> {
const flagOn = await this.posthog.isEnabled(EXPERT_PROGRAM_FLAG, userId, true);
if (!flagOn) return { enabled: false };
const [vinRows, copyRows] = await Promise.all([
this.db
.select({ n: countDistinct(queryLogs.vin) })
.from(queryLogs)
.where(and(eq(queryLogs.userId, userId), eq(queryLogs.success, true))),
this.db
.select({ n: countDistinct(oemCodeCopies.oemCode) })
.from(oemCodeCopies)
.where(eq(oemCodeCopies.userId, userId)),
]);
return { enabled: meetsExpertCriteria(vinRows[0]?.n ?? 0, copyRows[0]?.n ?? 0) };
}
// Yazma uçları (oy, öneri) UI gizlense bile doğrudan istekle delinemesin.
async assert(userId: string): Promise<void> {
const { enabled } = await this.check(userId);
if (!enabled) {
throw new ForbiddenException("Parça Uzmanları programı hesabınızda henüz aktif değil");
}
}
}

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { EXPERT_REWARD_LADDER, previousTrMonthWindow, trMonthStart } from "./expert-period";
describe("trMonthStart", () => {
it("ay ortasında TR-ayının başını UTC olarak verir (TR 00:00 = UTC 21:00 önceki gün)", () => {
const now = new Date("2026-06-15T10:00:00Z");
expect(trMonthStart(now).toISOString()).toBe("2026-05-31T21:00:00.000Z");
});
it("UTC gece TR'de yeni aya geçmişse TR gününü baz alır", () => {
// UTC 30 Haziran 22:30 = TR 1 Temmuz 01:30 → içinde bulunulan TR-ayı Temmuz
const now = new Date("2026-06-30T22:30:00Z");
expect(trMonthStart(now).toISOString()).toBe("2026-06-30T21:00:00.000Z");
expect(trMonthStart(now, 1).toISOString()).toBe("2026-05-31T21:00:00.000Z");
});
it("yıl devrini doğru çözer (TR ocak → geriye aralık)", () => {
const now = new Date("2026-01-10T12:00:00Z");
expect(trMonthStart(now, 1).toISOString()).toBe("2025-11-30T21:00:00.000Z");
});
});
describe("previousTrMonthWindow", () => {
it("cron anında (TR 1'i 00:00) biten ayı kapsar", () => {
// TR 1 Temmuz 00:00 = UTC 30 Haziran 21:00
const cronMoment = new Date("2026-06-30T21:00:00Z");
const { start, end } = previousTrMonthWindow(cronMoment);
expect(start.toISOString()).toBe("2026-05-31T21:00:00.000Z"); // TR 1 Haziran 00:00
expect(end.toISOString()).toBe("2026-06-30T21:00:00.000Z"); // TR 1 Temmuz 00:00
});
});
describe("EXPERT_REWARD_LADDER", () => {
it("1. → 30 gün, 2. → 15 gün, 3. → 7 gün", () => {
expect(EXPERT_REWARD_LADDER.map((r) => r.days)).toEqual([30, 15, 7]);
});
});

View File

@@ -0,0 +1,23 @@
// Parça Uzmanları aylık sezon takvimi. Sınırlar Türkiye saatine göredir
// (Europe/Istanbul, 2016'dan beri sabit UTC+3 — DST yok); sıralama her ayın
// 1'i 00:00 TR'de sıfırlanır, biten ayın ilk 3'üne üyelik uzatması verilir.
const TR_OFFSET_MS = 3 * 60 * 60 * 1000;
// Ödül merdiveni: 1. → 1 ay (30 gün), 2. → 15 gün, 3. → 7 gün üyelik.
export const EXPERT_REWARD_LADDER = [
{ rank: 1, days: 30 },
{ rank: 2, days: 15 },
{ rank: 3, days: 7 },
] as const;
// `monthsBack` ay geriden TR-ayının başlangıcını UTC Date olarak döndürür
// (0 = içinde bulunulan ay). Date.UTC ay taşmalarını (ocak-1 → aralık) çözer.
export function trMonthStart(now: Date, monthsBack = 0): Date {
const tr = new Date(now.getTime() + TR_OFFSET_MS);
return new Date(Date.UTC(tr.getUTCFullYear(), tr.getUTCMonth() - monthsBack, 1) - TR_OFFSET_MS);
}
// Biten sezonun penceresi: [önceki TR-ay başı, bu TR-ay başı).
export function previousTrMonthWindow(now: Date): { start: Date; end: Date } {
return { start: trMonthStart(now, 1), end: trMonthStart(now, 0) };
}

View File

@@ -0,0 +1,47 @@
import { BadRequestException, Body, Controller, Get, Post } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { ExpertAccessService } from "./expert-access.service";
import { castOemVoteSchema, lookupOemVotesSchema } from "./oem-votes.dto";
import { OemVotesService } from "./oem-votes.service";
@Controller("oem-votes")
export class OemVotesController {
constructor(
private readonly oemVotesService: OemVotesService,
private readonly expertAccess: ExpertAccessService,
) {}
// Program kapısı: flag + kullanım kriteri (web açılışta bir kez sorar).
@Get("access")
async access(@CurrentUser("id") userId: string) {
return this.expertAccess.check(userId);
}
// Puan çiftliğine karşı insan-hızı sınırı: dakikada en fazla 30 oy.
@Post()
@Throttle({ default: { limit: 30, ttl: 60_000 } })
async cast(@CurrentUser("id") userId: string, @Body() body: unknown) {
await this.expertAccess.assert(userId);
const parsed = castOemVoteSchema.safeParse(body);
if (!parsed.success) {
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz oy verisi");
}
return this.oemVotesService.castVote(userId, parsed.data);
}
// Kod listesi URL sınırına sığmayacak kadar uzayabildiği için POST (bkz. /p/matched).
@Post("lookup")
async lookup(@CurrentUser("id") userId: string, @Body() body: unknown) {
const parsed = lookupOemVotesSchema.safeParse(body);
if (!parsed.success) {
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz kod listesi");
}
return { votes: await this.oemVotesService.lookup(userId, parsed.data.codes) };
}
@Get("leaderboard")
async leaderboard(@CurrentUser("id") userId: string) {
return this.oemVotesService.leaderboard(userId);
}
}

View File

@@ -0,0 +1,17 @@
import { z } from "zod";
export const castOemVoteSchema = z.object({
oemCode: z.string().trim().min(2, "OEM kodu geçersiz").max(100, "OEM kodu çok uzun"),
vote: z.enum(["compatible", "incompatible"]),
// Analitik bağlamı — katalog rotaları catalog_vehicles id'si yollayabilir,
// UUID olmayan değerler serviste sessizce düşürülür (oy yine kaydedilir).
partId: z.string().max(100).optional(),
vehicleId: z.string().max(100).optional(),
categoryId: z.string().max(100).optional(),
});
export type CastOemVoteInput = z.infer<typeof castOemVoteSchema>;
export const lookupOemVotesSchema = z.object({
codes: z.array(z.string().trim().min(1).max(100)).min(1).max(400),
});
export type LookupOemVotesInput = z.infer<typeof lookupOemVotesSchema>;

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { computeVoteAward, maskExpertName, meetsExpertCriteria } from "./oem-votes.logic";
describe("computeVoteAward", () => {
it("ilk oy: kimse oylamamışsa 3 puan (1 oy + 2 doğru)", () => {
expect(computeVoteAward({ compatible: 0, incompatible: 0 }, "compatible")).toEqual({
points: 3,
correct: true,
});
expect(computeVoteAward({ compatible: 0, incompatible: 0 }, "incompatible")).toEqual({
points: 3,
correct: true,
});
});
it("1 kişi uyumlu demişken uyumsuz diyen 1 puan, uyumlu diyen 3 puan alır", () => {
const prior = { compatible: 1, incompatible: 0 };
expect(computeVoteAward(prior, "incompatible")).toEqual({ points: 1, correct: false });
expect(computeVoteAward(prior, "compatible")).toEqual({ points: 3, correct: true });
});
it("beraberliği bozan oy çoğunluğu kendi tarafına çevirdiği için 3 puan alır", () => {
expect(computeVoteAward({ compatible: 1, incompatible: 1 }, "compatible")).toEqual({
points: 3,
correct: true,
});
});
it("açık çoğunluğa karşı oy yalnız taban puanı alır", () => {
expect(computeVoteAward({ compatible: 5, incompatible: 1 }, "incompatible")).toEqual({
points: 1,
correct: false,
});
expect(computeVoteAward({ compatible: 5, incompatible: 1 }, "compatible")).toEqual({
points: 3,
correct: true,
});
});
it("oyumla beraberlik oluşuyorsa çoğunluk sağlanmaz, 1 puan", () => {
// 2-1 iken uyumsuz oyu → 2-2: kesin çoğunluk yok.
expect(computeVoteAward({ compatible: 2, incompatible: 1 }, "incompatible")).toEqual({
points: 1,
correct: false,
});
});
});
describe("maskExpertName", () => {
it("ad ve soyadın yalnız ilk harfini açık bırakır", () => {
expect(maskExpertName("Semih Yılmaz")).toBe("S*** Y***");
});
it("ara adları düşürür, soyad olarak son kelimeyi alır", () => {
expect(maskExpertName("Ali Rıza Demir")).toBe("A*** D***");
});
it("tek kelimelik adı maskeler", () => {
expect(maskExpertName("Semih")).toBe("S***");
});
it("Türkçe karakterleri doğru büyütür", () => {
expect(maskExpertName("ismail çelik")).toBe("İ*** Ç***");
});
it("boş ada güvenli düşer", () => {
expect(maskExpertName(" ")).toBe("Üye");
});
});
describe("meetsExpertCriteria", () => {
it("en az 5 farklı VIN VE en az 5 farklı OEM kopyası ister", () => {
expect(meetsExpertCriteria(5, 5)).toBe(true);
expect(meetsExpertCriteria(12, 7)).toBe(true);
expect(meetsExpertCriteria(4, 5)).toBe(false);
expect(meetsExpertCriteria(5, 4)).toBe(false);
expect(meetsExpertCriteria(0, 0)).toBe(false);
});
});

View File

@@ -0,0 +1,46 @@
export type OemVoteChoice = "compatible" | "incompatible";
export interface VoteCounts {
compatible: number;
incompatible: number;
}
export interface VoteAward {
points: number;
correct: boolean;
}
export const VOTE_BASE_POINTS = 1;
export const VOTE_MAJORITY_BONUS = 2;
// "Doğru oy" = kendi oyu da sayıldığında kesin çoğunlukla aynı tarafta olmak.
// İlk oy (0-0 → 1-0) ve beraberliği bozan oy çoğunluğu kendi tarafına
// çevirdiği için doğrudur (3 puan); mevcut çoğunluğa karşı oy yalnız taban
// puanı alır (1). Ödül oy anında kesinleşir, çoğunluk sonradan dönse bile
// geriye dönük düzeltilmez.
export function computeVoteAward(prior: VoteCounts, vote: OemVoteChoice): VoteAward {
const mine = (vote === "compatible" ? prior.compatible : prior.incompatible) + 1;
const other = vote === "compatible" ? prior.incompatible : prior.compatible;
const correct = mine > other;
return { points: VOTE_BASE_POINTS + (correct ? VOTE_MAJORITY_BONUS : 0), correct };
}
// Program kitle kriteri: kataloğu gerçekten kullanan kayıtlı üyeler —
// en az 5 FARKLI başarılı VIN decode VE en az 5 FARKLI OEM kodu kopyası.
export const MIN_DISTINCT_VINS = 5;
export const MIN_DISTINCT_OEM_COPIES = 5;
export function meetsExpertCriteria(distinctVins: number, distinctOemCopies: number): boolean {
return distinctVins >= MIN_DISTINCT_VINS && distinctOemCopies >= MIN_DISTINCT_OEM_COPIES;
}
// Liderlik listesi adları KVKK-dostu: yalnız ad ve soyadın ilk harfi açık
// ("Semih Yılmaz" → "S*** Y***"). Ara adlar tamamen düşer; tek kelimelik
// adlarda o kelimenin ilk harfi kalır.
export function maskExpertName(fullName: string): string {
const words = fullName.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return "Üye";
const maskWord = (w: string) => `${w.charAt(0).toLocaleUpperCase("tr-TR")}***`;
if (words.length === 1) return maskWord(words[0]);
return `${maskWord(words[0])} ${maskWord(words[words.length - 1])}`;
}

View File

@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { ExpertAccessService } from "./expert-access.service";
import { OemVotesController } from "./oem-votes.controller";
import { OemVotesService } from "./oem-votes.service";
@Module({
controllers: [OemVotesController],
providers: [OemVotesService, ExpertAccessService],
exports: [OemVotesService, ExpertAccessService],
})
export class OemVotesModule {}

View File

@@ -0,0 +1,209 @@
import { Inject, Injectable } from "@nestjs/common";
import { and, count, desc, eq, gte, inArray, sql } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { oemVotePoints, oemVotes, users } from "../database/schema/core";
import { trMonthStart } from "./expert-period";
import {
type OemVoteChoice,
type VoteCounts,
computeVoteAward,
maskExpertName,
} from "./oem-votes.logic";
type Tx = Parameters<Parameters<Database["transaction"]>[0]>[0];
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const uuidOrNull = (value?: string) => (value && UUID_RE.test(value) ? value : null);
export interface CastVoteResult {
oemCode: string;
myVote: OemVoteChoice;
counts: VoteCounts;
pointsAwarded: number;
correct: boolean | null;
isNew: boolean;
}
export interface OemVoteSummary extends VoteCounts {
myVote: OemVoteChoice | null;
}
export interface LeaderboardEntry {
rank: number;
name: string;
points: number;
votes: number;
isMe: boolean;
}
@Injectable()
export class OemVotesService {
constructor(@Inject(DATABASE) private db: Database) {}
async castVote(
userId: string,
input: {
oemCode: string;
vote: OemVoteChoice;
partId?: string;
vehicleId?: string;
categoryId?: string;
},
): Promise<CastVoteResult> {
const oemCode = input.oemCode.trim();
const context = {
partId: uuidOrNull(input.partId),
vehicleId: uuidOrNull(input.vehicleId),
categoryId: uuidOrNull(input.categoryId),
};
return this.db.transaction(async (tx) => {
// Aynı kod üzerindeki eşzamanlı oyları sıraya sok: ilk-oy bonusu ve
// çoğunluk hesabı yarışsız, deterministik kalır. Kod bazlı kilit —
// farklı kodlar birbirini bekletmez, tx sonunda otomatik bırakılır.
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${`oem-vote:${oemCode}`}))`);
const [existing] = await tx
.select({ id: oemVotes.id, vote: oemVotes.vote })
.from(oemVotes)
.where(and(eq(oemVotes.userId, userId), eq(oemVotes.oemCode, oemCode)))
.limit(1);
if (existing) {
// Fikir değişikliği oyu günceller ama puan üretmez (puan çiftliği yok).
if (existing.vote !== input.vote) {
await tx
.update(oemVotes)
.set({ vote: input.vote, ...context, updatedAt: new Date() })
.where(eq(oemVotes.id, existing.id));
}
return {
oemCode,
myVote: input.vote,
counts: await this.countVotes(tx, oemCode),
pointsAwarded: 0,
correct: null,
isNew: false,
};
}
const prior = await this.countVotes(tx, oemCode);
const award = computeVoteAward(prior, input.vote);
const [inserted] = await tx
.insert(oemVotes)
.values({ userId, oemCode, vote: input.vote, ...context })
.returning({ id: oemVotes.id });
await tx.insert(oemVotePoints).values({
userId,
voteId: inserted.id,
oemCode,
points: award.points,
correct: award.correct,
});
return {
oemCode,
myVote: input.vote,
counts: {
compatible: prior.compatible + (input.vote === "compatible" ? 1 : 0),
incompatible: prior.incompatible + (input.vote === "incompatible" ? 1 : 0),
},
pointsAwarded: award.points,
correct: award.correct,
isNew: true,
};
});
}
// Parça listesi açılırken görünen kodların oy özetleri — tek toplu sorgu.
async lookup(userId: string, codes: string[]): Promise<Record<string, OemVoteSummary>> {
const unique = [...new Set(codes.map((c) => c.trim()).filter(Boolean))];
if (unique.length === 0) return {};
const [tallies, mine] = await Promise.all([
this.db
.select({ oemCode: oemVotes.oemCode, vote: oemVotes.vote, total: count() })
.from(oemVotes)
.where(inArray(oemVotes.oemCode, unique))
.groupBy(oemVotes.oemCode, oemVotes.vote),
this.db
.select({ oemCode: oemVotes.oemCode, vote: oemVotes.vote })
.from(oemVotes)
.where(and(eq(oemVotes.userId, userId), inArray(oemVotes.oemCode, unique))),
]);
const result: Record<string, OemVoteSummary> = {};
const entry = (code: string) => {
result[code] ??= { compatible: 0, incompatible: 0, myVote: null };
return result[code];
};
for (const row of tallies) {
const summary = entry(row.oemCode);
if (row.vote === "compatible") summary.compatible = row.total;
else summary.incompatible = row.total;
}
for (const row of mine) {
entry(row.oemCode).myVote = row.vote as OemVoteChoice;
}
return result;
}
// Parça Uzmanları: içinde bulunulan TR-ayının (sezonun) sıralaması — her
// ayın 1'i 00:00 TR'de sıfırlanır, biten ayın ilk 3'üne expert-rewards
// cron'u üyelik uzatması verir. Puana göre herkes, adlar maskeli
// ("S*** Y***"); yanıt userId sızdırmaz, istek sahibi kendi satırını isMe
// ile bulur. Sıralama ödül job'ıyla birebir aynı: puan desc, eşitlikte
// puana daha erken ulaşan önde.
async leaderboard(userId: string): Promise<{
entries: LeaderboardEntry[];
me: { rank: number | null; points: number; votes: number };
periodStart: string;
}> {
const periodStart = trMonthStart(new Date());
const totalPoints = sql<number>`sum(${oemVotePoints.points})::int`;
const rows = await this.db
.select({
userId: oemVotePoints.userId,
name: users.name,
points: totalPoints,
votes: count(),
})
.from(oemVotePoints)
.innerJoin(users, eq(users.id, oemVotePoints.userId))
.where(gte(oemVotePoints.createdAt, periodStart))
.groupBy(oemVotePoints.userId, users.name)
.orderBy(desc(totalPoints), sql`min(${oemVotePoints.createdAt}) asc`)
.limit(500);
const entries = rows.map((row, i) => ({
rank: i + 1,
name: maskExpertName(row.name),
points: row.points,
votes: row.votes,
isMe: row.userId === userId,
}));
const my = entries.find((e) => e.isMe);
return {
entries,
me: { rank: my?.rank ?? null, points: my?.points ?? 0, votes: my?.votes ?? 0 },
periodStart: periodStart.toISOString(),
};
}
private async countVotes(tx: Tx, oemCode: string): Promise<VoteCounts> {
const rows = await tx
.select({ vote: oemVotes.vote, total: count() })
.from(oemVotes)
.where(eq(oemVotes.oemCode, oemCode))
.groupBy(oemVotes.vote);
const counts: VoteCounts = { compatible: 0, incompatible: 0 };
for (const row of rows) {
if (row.vote === "compatible") counts.compatible = row.total;
else counts.incompatible = row.total;
}
return counts;
}
}

View File

@@ -15,6 +15,13 @@ export class PartsController {
return this.partsService.searchByOem(oem); 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") @Get(":id")
async getById(@Param("id") id: string) { async getById(@Param("id") id: string) {
return this.partsService.getById(id); return this.partsService.getById(id);

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; 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 { DATABASE, type Database } from "../database/database.provider";
import { categories, parts, schemaPics, vehicles } from "../database/schema/core"; import { categories, parts, schemaPics, vehicles } from "../database/schema/core";
import { PL24Service } from "../integrations/pl24/pl24.service"; import { PL24Service } from "../integrations/pl24/pl24.service";
@@ -142,4 +142,32 @@ export class PartsService {
if (!part) throw new NotFoundException("Parça bulunamadı"); if (!part) throw new NotFoundException("Parça bulunamadı");
return part; 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<string>`(array_agg(${parts.categoryId} ORDER BY ${parts.categoryId}))[1]`,
occurrences: sql<number>`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);
}
} }

View File

@@ -16,9 +16,9 @@ export class PaymentsService {
/** /**
* Payment history for the billing page. Returns a curated projection — never * Payment history for the billing page. Returns a curated projection — never
* the raw row — so internal columns (adminNote, iyzicoPaymentId, * the raw row — so internal columns (adminNote, iyzicoPaymentId,
* bankAccountId, session/intent ids) are not leaked to the client. planName * session/intent ids) are not leaked to the client. planName is joined from
* is joined from the subscription's plan; Stripe receipt availability is * the subscription's plan; Stripe receipt availability is surfaced as a
* surfaced as a boolean rather than exposing the payment intent id. * boolean rather than exposing the payment intent id.
*/ */
async getMyPayments(userId: string) { async getMyPayments(userId: string) {
const rows = await this.db const rows = await this.db
@@ -29,7 +29,6 @@ export class PaymentsService {
status: payments.status, status: payments.status,
createdAt: payments.createdAt, createdAt: payments.createdAt,
planName: plans.name, planName: plans.name,
eftReceiptUrl: payments.eftReceiptUrl,
stripePaymentIntentId: payments.stripePaymentIntentId, stripePaymentIntentId: payments.stripePaymentIntentId,
}) })
.from(payments) .from(payments)
@@ -45,16 +44,15 @@ export class PaymentsService {
status: row.status, status: row.status,
createdAt: row.createdAt, createdAt: row.createdAt,
planName: row.planName ?? null, planName: row.planName ?? null,
eftReceiptUrl: row.eftReceiptUrl ?? null,
hasStripeReceipt: hasStripeReceipt:
row.method === "stripe" && row.status === "completed" && !!row.stripePaymentIntentId, row.method === "stripe" && row.status === "completed" && !!row.stripePaymentIntentId,
})); }));
} }
/** /**
* Resolve a receipt URL for one of the current user's payments. * Resolve a receipt URL for one of the current user's payments. Stripe is the
* EFT receipts are stored locally; Stripe receipts are fetched live from the * sole payment method; receipts are fetched live from the payment intent's
* payment intent's latest charge. Returns { url: null } when none exists. * latest charge. Returns { url: null } when none exists.
*/ */
async getReceiptUrl(userId: string, paymentId: string): Promise<{ url: string | null }> { async getReceiptUrl(userId: string, paymentId: string): Promise<{ url: string | null }> {
const [payment] = await this.db const [payment] = await this.db
@@ -67,10 +65,6 @@ export class PaymentsService {
throw new NotFoundException("Payment not found"); throw new NotFoundException("Payment not found");
} }
if (payment.eftReceiptUrl) {
return { url: payment.eftReceiptUrl };
}
if (payment.method === "stripe" && payment.stripePaymentIntentId) { if (payment.method === "stripe" && payment.stripePaymentIntentId) {
return { url: await this.stripeService.getReceiptUrl(payment.stripePaymentIntentId) }; return { url: await this.stripeService.getReceiptUrl(payment.stripePaymentIntentId) };
} }

View File

@@ -143,6 +143,10 @@ export class StripeService {
const session = await this.stripe.checkout.sessions.create({ const session = await this.stripe.checkout.sessions.create({
mode: "payment", 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"], payment_method_types: ["card"],
customer_email: userEmail, customer_email: userEmail,
line_items: [ line_items: [
@@ -257,6 +261,14 @@ export class StripeService {
const paymentIntentId = const paymentIntentId =
typeof session.payment_intent === "string" ? session.payment_intent : null; 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 await this.db
.update(payments) .update(payments)
.set({ .set({
@@ -266,8 +278,6 @@ export class StripeService {
}) })
.where(eq(payments.id, paymentId)); .where(eq(payments.id, paymentId));
const activated = await this.subscriptionsService.activateSubscription(payment.subscriptionId);
this.posthog.captureForUser(payment.userId, "payment_success", { this.posthog.captureForUser(payment.userId, "payment_success", {
method: "stripe", method: "stripe",
payment_id: paymentId, payment_id: paymentId,

View File

@@ -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();
});
});
}); });

View File

@@ -3,6 +3,7 @@ import {
ConflictException, ConflictException,
Inject, Inject,
Injectable, Injectable,
Logger,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { and, desc, eq, inArray, or } from "drizzle-orm"; import { and, desc, eq, inArray, or } from "drizzle-orm";
@@ -20,6 +21,8 @@ import { PostHogService } from "../posthog/posthog.service";
@Injectable() @Injectable()
export class SubscriptionsService { export class SubscriptionsService {
private readonly logger = new Logger(SubscriptionsService.name);
constructor( constructor(
@Inject(DATABASE) private db: Database, @Inject(DATABASE) private db: Database,
private posthog: PostHogService, private posthog: PostHogService,
@@ -123,6 +126,17 @@ export class SubscriptionsService {
if (!sub) throw new NotFoundException("Abonelik bulunamadı"); 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 now = new Date();
const endDate = new Date(now); const endDate = new Date(now);
if (sub.billingPeriod === "yearly") { if (sub.billingPeriod === "yearly") {

View File

@@ -37,10 +37,14 @@ export class VehiclesController {
async decode( async decode(
@CurrentUser("id") userId: string, @CurrentUser("id") userId: string,
@Body("vin", VinValidationPipe) vin: string, @Body("vin", VinValidationPipe) vin: string,
// Deprecated: provider-specific picks from pre-stash frontend bundles.
// New clients send only the opaque `candidate` key; the provider mapping
// lives in the server-side stash.
@Body("pcatCarId") pcatCarId?: string, @Body("pcatCarId") pcatCarId?: string,
@Body("emexCarIndex") emexCarIndex?: number, @Body("emexCarIndex") emexCarIndex?: number,
@Body("candidate") candidate?: string,
) { ) {
return this.vehiclesService.decodeVin(vin, userId, pcatCarId, emexCarIndex); return this.vehiclesService.decodeVin(vin, userId, pcatCarId, emexCarIndex, candidate);
} }
@Get("history") @Get("history")

View File

@@ -166,7 +166,11 @@ describe("VehiclesService", () => {
const { service } = createService(db); const { service } = createService(db);
const result = await service.decodeVin("WBAPH5C55BA123456", "u1"); const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
expect(result).toEqual(cached); // Decode response is intentionally slim — id + display summary only;
// rawData/source never leave the API on this endpoint.
expect(result).toMatchObject({ id: "v1" });
expect(result).not.toHaveProperty("rawData");
expect(result).not.toHaveProperty("source");
}); });
it("should throw BadRequestException when corgi doesn't recognize VIN", async () => { it("should throw BadRequestException when corgi doesn't recognize VIN", async () => {
@@ -435,7 +439,8 @@ describe("VehiclesService", () => {
}); });
const result = await service.decodeVin("WBAPH5C55BA123456", "u1"); const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
expect(result).toEqual(savedVehicle); expect(result).toMatchObject({ id: "v-emex" });
expect(result).not.toHaveProperty("source");
expect(partsCatalogsService.decodeVin).toHaveBeenCalled(); expect(partsCatalogsService.decodeVin).toHaveBeenCalled();
expect(emexService.decodeVinOrCandidates).toHaveBeenCalledWith("WBAPH5C55BA123456"); expect(emexService.decodeVinOrCandidates).toHaveBeenCalledWith("WBAPH5C55BA123456");
}); });
@@ -473,7 +478,7 @@ describe("VehiclesService", () => {
emexService.decodeVinOrCandidates.mockResolvedValue({ type: "notFound" }); emexService.decodeVinOrCandidates.mockResolvedValue({ type: "notFound" });
const result = await service.decodeVin("WVWZZZ1JZ3W597935", "u1"); const result = await service.decodeVin("WVWZZZ1JZ3W597935", "u1");
expect(result).toEqual(savedVehicle); expect(result).toMatchObject({ id: "v-pcat" });
expect(pl24Service.decodeVin).not.toHaveBeenCalled(); expect(pl24Service.decodeVin).not.toHaveBeenCalled();
}); });
@@ -488,7 +493,7 @@ describe("VehiclesService", () => {
}); });
const result = await service.decodeVin("WBAPH5C55BA123456", "u1"); const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
expect(result).toEqual(savedVehicle); expect(result).toMatchObject({ id: "v-emex" });
expect(emexService.decodeVinOrCandidates).toHaveBeenCalled(); expect(emexService.decodeVinOrCandidates).toHaveBeenCalled();
}); });
@@ -511,7 +516,7 @@ describe("VehiclesService", () => {
}); });
const result = await service.decodeVin("VR3EFYHZ3PJ674071", "u1"); const result = await service.decodeVin("VR3EFYHZ3PJ674071", "u1");
expect(result).toEqual(savedVehicle); expect(result).toMatchObject({ id: "v-pl24" });
expect(pl24Service.decodeVin).toHaveBeenCalledWith("VR3EFYHZ3PJ674071", "u1"); expect(pl24Service.decodeVin).toHaveBeenCalledWith("VR3EFYHZ3PJ674071", "u1");
}); });
@@ -620,8 +625,9 @@ describe("VehiclesService", () => {
const res = await service.previewVin("NM435600006H43436"); const res = await service.previewVin("NM435600006H43436");
expect(res.source).toBe("parts-catalogs");
expect(res.brandName).toBe("Fiat"); // was null before the fix expect(res.brandName).toBe("Fiat"); // was null before the fix
// Provider/source adı public preview cevabına asla sızmamalı.
expect("source" in res).toBe(false);
}); });
}); });

View File

@@ -29,13 +29,13 @@ import {
import { EmexService } from "../integrations/emex/emex.service"; import { EmexService } from "../integrations/emex/emex.service";
import { EmexCandidate } from "../integrations/emex/emex.service"; import { EmexCandidate } from "../integrations/emex/emex.service";
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service"; import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
import { PcatCar } from "../integrations/parts-catalogs/parts-catalogs.types"; import { PcatCar, PcatVinResult } from "../integrations/parts-catalogs/parts-catalogs.types";
import { PL24Service } from "../integrations/pl24/pl24.service"; import { PL24Service } from "../integrations/pl24/pl24.service";
import { VinApiService } from "../integrations/vin-api/vin-api.service"; import { VinApiService } from "../integrations/vin-api/vin-api.service";
import { PrefetchSource } from "../jobs/prefetch.types"; import { PrefetchSource } from "../jobs/prefetch.types";
import { CATALOG_PREFETCH_QUEUE } from "../jobs/queues/catalog-prefetch.queue"; import { CATALOG_PREFETCH_QUEUE } from "../jobs/queues/catalog-prefetch.queue";
import { RedisService } from "../redis/redis.service"; import { RedisService } from "../redis/redis.service";
import { vinResolveCacheKeys } from "./vin-cache-keys"; import { vinCandidateStashKey, vinResolveCacheKeys } from "./vin-cache-keys";
/** /**
* Per-decode metadata, persisted to `query_logs.timings` jsonb. Despite the * Per-decode metadata, persisted to `query_logs.timings` jsonb. Despite the
@@ -66,6 +66,26 @@ interface VinResolveResult {
emexCandidates?: EmexCandidate[]; emexCandidates?: EmexCandidate[];
} }
/**
* Server-side record of a pending multi-candidate selection. The client only
* ever sees the opaque per-candidate key (the index, as a string); which decode
* source produced the list and how to re-address the chosen car never leave
* the API.
*/
interface CandidateStash {
source: "parts-catalogs" | "emex";
/** Indexed by candidate key — provider-specific selector for each entry. */
picks: Array<{ pcatCarId?: string; emexCarIndex?: number }>;
}
/** Display-only candidate shape returned to the client. */
export interface PublicCandidate {
id: string;
name: string;
description?: string;
parameters?: PcatCar["parameters"];
}
@Injectable() @Injectable()
export class VehiclesService { export class VehiclesService {
private readonly logger = new Logger(VehiclesService.name); private readonly logger = new Logger(VehiclesService.name);
@@ -81,8 +101,31 @@ export class VehiclesService {
private redis: RedisService, private redis: RedisService,
) {} ) {}
async decodeVin(vin: string, userId: string, pcatCarId?: string, emexCarIndex?: number) { async decodeVin(
vin: string,
userId: string,
legacyPcatCarId?: string,
legacyEmexCarIndex?: number,
candidateKey?: string,
) {
const startTime = Date.now(); const startTime = Date.now();
if (!isValidVin(vin)) {
throw new BadRequestException("Geçersiz şase numarası");
}
// Opaque candidate pick → provider-specific selection, restored from the
// server-side stash so the client never round-trips decode-source details
// (source name, pcat car ids, EMEX indexes) between requests. The legacy
// pcatCarId/emexCarIndex body params still work for old bundles.
let pcatCarId = legacyPcatCarId;
let emexCarIndex = legacyEmexCarIndex;
if (candidateKey !== undefined && pcatCarId === undefined && emexCarIndex === undefined) {
const pick = await this.resolveCandidateKey(vin, candidateKey);
pcatCarId = pick.pcatCarId;
emexCarIndex = pick.emexCarIndex;
}
const ctx: ResolveContext = { const ctx: ResolveContext = {
timings: { timings: {
wmi: vin.length >= 3 ? vin.substring(0, 3).toUpperCase() : "", wmi: vin.length >= 3 ? vin.substring(0, 3).toUpperCase() : "",
@@ -90,10 +133,6 @@ export class VehiclesService {
}, },
}; };
if (!isValidVin(vin)) {
throw new BadRequestException("Geçersiz şase numarası");
}
// 1. Check for shared vehicle config by VIN (no userId filter) // 1. Check for shared vehicle config by VIN (no userId filter)
const [existing] = await this.db.select().from(vehicles).where(eq(vehicles.vin, vin)).limit(1); const [existing] = await this.db.select().from(vehicles).where(eq(vehicles.vin, vin)).limit(1);
@@ -115,7 +154,7 @@ export class VehiclesService {
undefined, undefined,
ctx.timings, ctx.timings,
); );
return existing; return this.toDecodeResponse(existing);
} }
// 2. Resolve VIN via cached decode chain (Corgi → PartsCatalogs → PL24 → EMEX) // 2. Resolve VIN via cached decode chain (Corgi → PartsCatalogs → PL24 → EMEX)
@@ -163,46 +202,52 @@ export class VehiclesService {
throw new BadRequestException("Şase numarası tanınamadı. Marka desteklenmiyor."); throw new BadRequestException("Şase numarası tanınamadı. Marka desteklenmiyor.");
} }
// 2b. If resolveVin returned multiple candidates, return them for frontend selection // 2b. If resolveVin returned multiple candidates, return them for frontend
if (resolved.pcatCandidates && resolved.pcatCandidates.length > 1) { // selection. Provider details (source name, car ids, EMEX internals) are
// stashed server-side; the client only echoes back the opaque candidate key.
const hasPcatCandidates = !!resolved.pcatCandidates && resolved.pcatCandidates.length > 1;
const hasEmexCandidates = !!resolved.emexCandidates && resolved.emexCandidates.length > 1;
if (hasPcatCandidates || hasEmexCandidates) {
const stash = this.buildCandidateStash(resolved);
await this.redis.setJson(
vinCandidateStashKey(vin),
stash,
VehiclesService.CANDIDATE_STASH_TTL_S,
);
await this.logQuery( await this.logQuery(
userId, userId,
vin, vin,
null, null,
"parts-catalogs", stash.source,
true, true,
Date.now() - startTime, Date.now() - startTime,
undefined, undefined,
ctx.timings, ctx.timings,
); );
return { candidates: resolved.pcatCandidates, vin, source: "parts-catalogs" }; const raw = hasPcatCandidates
} ? (resolved.pcatCandidates as PcatCar[])
if (resolved.emexCandidates && resolved.emexCandidates.length > 1) { : (resolved.emexCandidates as EmexCandidate[]);
await this.logQuery( return { candidates: this.toPublicCandidates(raw), vin };
userId,
vin,
null,
"emex",
true,
Date.now() - startTime,
undefined,
ctx.timings,
);
return { candidates: resolved.emexCandidates, vin, source: "emex" };
} }
// 3. Brand access check // 3. Brand access check
let brandId: string | null = null; 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) { if (brandName) {
const [brand] = await this.db const [brand] = await this.db
.select() .select()
.from(brands) .from(brands)
.where(eq(brands.name, brandName)) .where(sql`lower(${brands.name}) = lower(${brandName})`)
.limit(1); .limit(1);
if (brand) { if (brand) {
brandId = brand.id; brandId = brand.id;
brandName = brand.name;
await this.checkBrandAccess(userId, brandId); await this.checkBrandAccess(userId, brandId);
} }
} }
@@ -263,7 +308,22 @@ export class VehiclesService {
ctx.timings, ctx.timings,
); );
return savedVehicle; return this.toDecodeResponse(savedVehicle);
}
/**
* Minimal decode response. Every consumer navigates by id and re-fetches the
* vehicle via GET /vehicles/:id; returning the full row shipped the raw
* upstream decode payload (rawData, p95 ~127KB) and the provider name to the
* client on every decode for nothing.
*/
private toDecodeResponse(v: {
id: string;
brandName: string | null;
model: string | null;
year: number | null;
}) {
return { id: v.id, brandName: v.brandName, model: v.model, year: v.year };
} }
/** /**
@@ -340,14 +400,14 @@ export class VehiclesService {
throw new BadRequestException("Geçersiz şase numarası"); throw new BadRequestException("Geçersiz şase numarası");
} }
// DB'de varsa direkt dön — dış API çağrısına gerek yok // DB'de varsa direkt dön — dış API çağrısına gerek yok.
// Source/provider adı public preview'a asla dahil edilmez.
const [existing] = await this.db const [existing] = await this.db
.select({ .select({
brandName: vehicles.brandName, brandName: vehicles.brandName,
model: vehicles.model, model: vehicles.model,
year: vehicles.year, year: vehicles.year,
engine: vehicles.engine, engine: vehicles.engine,
source: vehicles.source,
}) })
.from(vehicles) .from(vehicles)
.where(eq(vehicles.vin, vin)) .where(eq(vehicles.vin, vin))
@@ -372,7 +432,6 @@ export class VehiclesService {
model: resolved.model, model: resolved.model,
year: resolved.year, year: resolved.year,
engine: resolved.engine, engine: resolved.engine,
source: resolved.source,
}; };
} }
@@ -392,6 +451,10 @@ export class VehiclesService {
// PL24 decode (responseTimeMs=1006123) that ran long after the request was // PL24 decode (responseTimeMs=1006123) that ran long after the request was
// already aborted by TimeoutInterceptor. Anything past this is wasted work. // already aborted by TimeoutInterceptor. Anything past this is wasted work.
private static readonly RESOLVE_BUDGET_MS = 25_000; private static readonly RESOLVE_BUDGET_MS = 25_000;
// How long a multi-candidate selection stays valid. Picks normally happen
// within seconds; if the stash expires we fall back to the resolve cache
// (24h TTL) before giving up.
private static readonly CANDIDATE_STASH_TTL_S = 1_800; // 30m
// ─── PL24 circuit breaker ────────────────────────────── // ─── PL24 circuit breaker ──────────────────────────────
// PL24 had p95=12min in production. When it goes bad it stays bad — every // PL24 had p95=12min in production. When it goes bad it stays bad — every
@@ -431,6 +494,47 @@ export class VehiclesService {
await this.redis.del(VehiclesService.PL24_CB_FAILURE_KEY); await this.redis.del(VehiclesService.PL24_CB_FAILURE_KEY);
} }
// ─── PCAT circuit breaker ──────────────────────────────
// Mirrors the PL24 breaker. parts-catalogs is the #1 winning decode source, so
// when its transport degrades (proxy/upstream bad window) it drags the whole
// race. After N consecutive TRANSIENT pcat failures we open the circuit briefly
// and skip pcat so EMEX/PL24 answer fast. CRITICAL: only transient faults count
// — a definitive "no cars" miss must NOT trip it (that's a real, fast answer),
// and a skip must mark outcome.transient so a pcat-only VIN isn't negative-cached
// for 30m while the circuit self-heals in 30s.
private static readonly PCAT_CB_FAILURE_THRESHOLD = 3;
private static readonly PCAT_CB_COOLDOWN_MS = 30_000;
private static readonly PCAT_CB_COUNTER_TTL_S = 300;
private static readonly PCAT_CB_FAILURE_KEY = "pcat:cb:consec_failures";
private static readonly PCAT_CB_COOLDOWN_KEY = "pcat:cb:cooldown_until";
private async isPcatCircuitOpen(): Promise<boolean> {
const until = await this.redis.get(VehiclesService.PCAT_CB_COOLDOWN_KEY);
return until !== null && Number(until) > Date.now();
}
private async recordPcatFailure(): Promise<void> {
const c = await this.redis.incr(VehiclesService.PCAT_CB_FAILURE_KEY);
await this.redis.expire(
VehiclesService.PCAT_CB_FAILURE_KEY,
VehiclesService.PCAT_CB_COUNTER_TTL_S,
);
if (c >= VehiclesService.PCAT_CB_FAILURE_THRESHOLD) {
await this.redis.set(
VehiclesService.PCAT_CB_COOLDOWN_KEY,
String(Date.now() + VehiclesService.PCAT_CB_COOLDOWN_MS),
Math.ceil(VehiclesService.PCAT_CB_COOLDOWN_MS / 1000) + 5,
);
this.logger.warn(
`PCAT circuit opened for ${VehiclesService.PCAT_CB_COOLDOWN_MS}ms (${c} consecutive failures)`,
);
}
}
private async recordPcatSuccess(): Promise<void> {
await this.redis.del(VehiclesService.PCAT_CB_FAILURE_KEY);
}
/** /**
* Shared VIN decode chain — Corgi → PartsCatalogs → PL24 → EMEX. * Shared VIN decode chain — Corgi → PartsCatalogs → PL24 → EMEX.
* *
@@ -637,16 +741,59 @@ export class VehiclesService {
const pcatStart = Date.now(); const pcatStart = Date.now();
let pcatResolved = false; let pcatResolved = false;
const pcatPromise = this.partsCatalogsService // Circuit breaker: when pcat is in a bad transport window, skip it so the
.decodeVin(vin, signal, outcome) // race isn't dragged; EMEX/PL24 answer instead. A pcat-scoped outcome lets
// the breaker count ONLY pcat's transient faults (not another source's).
const pcatCircuitOpen = await this.isPcatCircuitOpen();
if (ctx && pcatCircuitOpen) ctx.timings.pcat_circuit_open = true;
const pcatOutcome: { transient: boolean; coldSkip?: boolean } = { transient: false };
// Fast-fail on a cold JWT pool: EMEX (already racing) or PL24 can answer
// while the pool warms in the background, instead of the user eating the
// multi-second inline capture. The pcat result self-heals on the next decode.
const basePcat: Promise<PcatVinResult | null> = pcatCircuitOpen
? (() => {
// Skipped (open) — NOT a definitive miss. Mark transient so a
// pcat-only VIN isn't negative-cached while the circuit self-heals.
this.logger.warn(`PCAT skipped for ${vin}: circuit breaker is open`);
pcatOutcome.transient = true;
return Promise.resolve(null);
})()
: this.partsCatalogsService.decodeVin(vin, signal, pcatOutcome, { blockOnEmpty: false });
const pcatPromise = basePcat
.then(async (r) => {
// Breaker bookkeeping (skip when already open — nothing was attempted):
// a transient TRANSPORT fault counts toward opening; any definitive
// answer (cars OR a clean "no cars" miss) clears the streak. A deliberate
// cold-pool skip is neutral — it isn't a fault and the pool is warming.
if (!pcatCircuitOpen && !pcatOutcome.coldSkip) {
if (pcatOutcome.transient) await this.recordPcatFailure();
else await this.recordPcatSuccess();
}
return r;
})
.catch((err: Error) => { .catch((err: Error) => {
this.logger.warn(`PartsCatalogs decode failed for ${vin}: ${err.message}`); this.logger.warn(`PartsCatalogs decode failed for ${vin}: ${err.message}`);
return null; return null;
}) })
.finally(() => { .finally(() => {
// Propagate pcat's transient signal to the shared outcome (neg-cache gate).
if (pcatOutcome.transient && outcome) outcome.transient = true;
pcatResolved = true; pcatResolved = true;
if (ctx && ctx.timings.pcat === undefined) { if (ctx) {
ctx.timings.pcat = Date.now() - pcatStart; 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;
}
} }
}); });
@@ -863,6 +1010,60 @@ export class VehiclesService {
return null; return null;
} }
// ─── Candidate stash (opaque pick keys) ────────────────
/** Build the server-side selector list for a multi-candidate resolve. */
private buildCandidateStash(resolved: VinResolveResult): CandidateStash {
if (resolved.pcatCandidates && resolved.pcatCandidates.length > 0) {
return {
source: "parts-catalogs",
picks: resolved.pcatCandidates.map((c) => ({ pcatCarId: c.id })),
};
}
return {
source: "emex",
picks: (resolved.emexCandidates ?? []).map((c) => ({
emexCarIndex: Number.isInteger(c._index) ? c._index : Number.parseInt(c.id, 10),
})),
};
}
/** Strip provider internals before the candidate list leaves the API. */
private toPublicCandidates(candidates: Array<PcatCar | EmexCandidate>): PublicCandidate[] {
return candidates.map((c, i) => ({
id: String(i),
name: c.name,
description: "description" in c ? c.description : undefined,
parameters: c.parameters,
}));
}
/**
* Map an opaque candidate key back to the provider-specific selection.
* Falls back to re-resolving the VIN (normally a Redis resolve-cache hit)
* when the stash has expired.
*/
private async resolveCandidateKey(
vin: string,
candidateKey: string,
): Promise<{ pcatCarId?: string; emexCarIndex?: number }> {
let stash = await this.redis.getJson<CandidateStash>(vinCandidateStashKey(vin));
if (!stash) {
const resolved = await this.resolveVin(vin);
if (resolved?.pcatCandidates?.length || resolved?.emexCandidates?.length) {
stash = this.buildCandidateStash(resolved);
}
}
const idx = Number(candidateKey);
const pick = stash && Number.isInteger(idx) ? stash.picks[idx] : undefined;
if (!pick) {
throw new BadRequestException(
"Araç seçimi zaman aşımına uğradı. Lütfen şase numarasını tekrar sorgulayın.",
);
}
return pick;
}
/** /**
* Resolve a specific PartsCatalogs car by ID (after user selects from candidates). * Resolve a specific PartsCatalogs car by ID (after user selects from candidates).
*/ */

View File

@@ -16,7 +16,8 @@ describe("vinResolveCacheKeys (Q4)", () => {
it("keeps the in-flight lock key version-independent (cross-deploy dedupe)", () => { it("keeps the in-flight lock key version-independent (cross-deploy dedupe)", () => {
const { lockKey } = vinResolveCacheKeys(VIN); const { lockKey } = vinResolveCacheKeys(VIN);
expect(lockKey).toBe(`vin:lock:${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)", () => { it("bumping the version changes the namespace (old entries become unreachable)", () => {

View File

@@ -13,7 +13,7 @@
* Both the decode path (VehiclesService) and the admin cache-buster * Both the decode path (VehiclesService) and the admin cache-buster
* (InternalVehiclesService) MUST build keys through here so they stay in sync. * (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): { export function vinResolveCacheKeys(vin: string): {
cacheKey: string; cacheKey: string;
@@ -29,3 +29,14 @@ export function vinResolveCacheKeys(vin: string): {
lockKey: `vin:lock:${vin}`, lockKey: `vin:lock:${vin}`,
}; };
} }
/**
* Server-side stash for a multi-candidate decode: maps the opaque candidate
* keys returned to the client back to the provider-specific selection
* (parts-catalogs car id / EMEX list index). Exists so the client never has to
* carry decode-source details between the decode and the candidate-pick
* requests.
*/
export function vinCandidateStashKey(vin: string): string {
return `vin:candidates:${DECODE_CHAIN_VERSION}:${vin}`;
}

View File

@@ -9,6 +9,7 @@ import OpenAI from "openai";
import postgres from "postgres"; import postgres from "postgres";
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "./jobs/bull.config"; import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "./jobs/bull.config";
import { processEmexScrape } from "./jobs/processors/emex-scrape.processor"; import { processEmexScrape } from "./jobs/processors/emex-scrape.processor";
import { processExpertRewards } from "./jobs/processors/expert-rewards.processor";
import { processLifecycleEmails } from "./jobs/processors/lifecycle-email.processor"; import { processLifecycleEmails } from "./jobs/processors/lifecycle-email.processor";
import { processQueryCleanup } from "./jobs/processors/query-cleanup.processor"; import { processQueryCleanup } from "./jobs/processors/query-cleanup.processor";
import { processSubscriptionExpiry } from "./jobs/processors/subscription-expiry.processor"; import { processSubscriptionExpiry } from "./jobs/processors/subscription-expiry.processor";
@@ -159,6 +160,32 @@ lifecycleEmailWorker.on("failed", (job, err) => {
workers.push(lifecycleEmailWorker); workers.push(lifecycleEmailWorker);
// Expert Rewards Worker (monthly Parça Uzmanları top-3 subscription prizes)
const expertRewardsWorker = new Worker(
QUEUE_NAMES.EXPERT_REWARDS,
async (job) => {
return processExpertRewards(job, db);
},
{
connection,
concurrency: 1,
...(telemetry ? { telemetry } : {}),
},
);
expertRewardsWorker.on("completed", (job) => {
console.log(`[worker] expert-rewards job ${job.id} completed`);
});
expertRewardsWorker.on("failed", (job, err) => {
console.error(`[worker] expert-rewards job ${job?.id} failed: ${err.message}`);
Sentry.captureException(err, {
tags: { queue: QUEUE_NAMES.EXPERT_REWARDS, jobId: job?.id },
});
});
workers.push(expertRewardsWorker);
// Translation Worker (async LLM translation for new EMEX/PCAT terms) // Translation Worker (async LLM translation for new EMEX/PCAT terms)
const openrouterApiKey = process.env.OPENROUTER_API_KEY; const openrouterApiKey = process.env.OPENROUTER_API_KEY;
if (openrouterApiKey) { if (openrouterApiKey) {

View File

@@ -0,0 +1,157 @@
import { useBlogPost, useBlogPosts } from "@/hooks/use-blog";
import { usePageMeta } from "@/hooks/use-page-meta";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
import { Link } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
// Blog content is rendered in two shells: the public marketing pages (/blog)
// and the dashboard-wrapped pages (/dashboard/blog) so logged-in users keep
// the sidebar. Only the internal link targets differ; the canonical URL always
// points at the public page.
type BlogListPath = "/blog" | "/dashboard/blog";
type BlogPostPath = "/blog/$slug" | "/dashboard/blog/$slug";
// ─── LIST ─────────────────────────────────────────────────────────────────────
export function BlogListContent({ postLinkTo }: { postLinkTo: BlogPostPath }) {
usePageMeta({
title: "Blog — Sase.tr | Şase & Yedek Parça Rehberi",
description: "Şase numarası okuma, OEM vs muadil parça, dijital dönüşüm ve daha fazlası.",
canonical: "https://sase.tr/blog",
});
// All posts come from the central Directus CMS through the API.
const { data: apiPosts, isLoading } = useBlogPosts();
const posts = (apiPosts ?? [])
.map((p) => ({
slug: p.slug,
title: p.title,
description: p.metaDescription ?? "",
date: p.publishedAt.slice(0, 10),
}))
.sort((a, b) => b.date.localeCompare(a.date));
return (
<>
<h1 className="text-4xl font-bold">Blog</h1>
<p className="mt-4 text-lg text-muted-foreground">
Yedek parça sektörü, araç bakımı ve Sase.tr hakkında güncel yazılar.
</p>
{isLoading && posts.length === 0 && (
<p className="mt-12 text-muted-foreground">Yükleniyor</p>
)}
<div className="mt-12 grid gap-6 sm:grid-cols-2">
{posts.map((post) => (
<Link key={post.slug} to={postLinkTo} params={{ slug: post.slug }}>
<Card className="h-full transition hover:border-foreground/20">
<CardHeader>
<CardDescription>{post.date}</CardDescription>
<CardTitle className="text-lg">{post.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{post.description}</p>
</CardContent>
</Card>
</Link>
))}
</div>
</>
);
}
// ─── POST ─────────────────────────────────────────────────────────────────────
// Posts live in the central Directus CMS and arrive as markdown via the API.
const mdComponents = {
h2: (props: React.ComponentProps<"h2">) => (
<h2 className="text-xl font-semibold text-foreground" {...props} />
),
h3: (props: React.ComponentProps<"h3">) => (
<h3 className="text-lg font-semibold text-foreground" {...props} />
),
ul: (props: React.ComponentProps<"ul">) => <ul className="list-disc space-y-2 pl-6" {...props} />,
ol: (props: React.ComponentProps<"ol">) => (
<ol className="list-decimal space-y-2 pl-6" {...props} />
),
strong: (props: React.ComponentProps<"strong">) => (
<strong className="text-foreground" {...props} />
),
a: (props: React.ComponentProps<"a">) => <a className="text-foreground underline" {...props} />,
};
function MarkdownBody({ markdown }: { markdown: string }) {
return (
<div className="space-y-6 leading-relaxed text-muted-foreground">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
{markdown}
</ReactMarkdown>
</div>
);
}
export function BlogPostContent({ slug, listLinkTo }: { slug: string; listLinkTo: BlogListPath }) {
const { data: post, isLoading, isError } = useBlogPost(slug);
const title = post?.title ?? "Blog";
const description = post?.metaDescription ?? "";
const date = post?.publishedAt?.slice(0, 10) ?? "";
usePageMeta({
title: `${title} | Sase.tr Blog`,
description,
canonical: `https://sase.tr/blog/${slug}`,
});
let body: React.ReactNode;
if (post) body = <MarkdownBody markdown={post.bodyMarkdown} />;
else if (isLoading) body = <p className="text-muted-foreground">Yükleniyor</p>;
else
body = (
<p className="text-muted-foreground">{isError ? "Yazı bulunamadı." : "Yazı yüklenemedi."}</p>
);
return (
<>
{/* Breadcrumb */}
<nav className="mb-8 flex items-center gap-1.5 text-sm text-muted-foreground">
<Link to={listLinkTo} className="transition hover:text-foreground">
Blog
</Link>
<ChevronRight className="size-3.5" />
<span className="text-foreground">{title}</span>
</nav>
<article>
{date && <time className="text-sm text-muted-foreground">{date}</time>}
<h1 className="mt-3 text-3xl font-bold leading-tight sm:text-4xl">{title}</h1>
{description && <p className="mt-4 text-lg text-muted-foreground">{description}</p>}
{post?.coverImage && (
<img
src={post.coverImage}
alt={title}
className="mt-8 w-full rounded-lg border object-cover"
/>
)}
<div className="mt-10">{body}</div>
</article>
{/* Back link */}
<div className="mt-16 border-t pt-8">
<Link
to={listLinkTo}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition hover:text-foreground"
>
Tüm yazılar
</Link>
</div>
</>
);
}

View File

@@ -0,0 +1,102 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const captureMock = vi.fn();
const toastSuccessMock = vi.fn();
vi.mock("@/lib/posthog", () => ({
capture: (...args: unknown[]) => captureMock(...args),
}));
vi.mock("@/lib/api-client", () => ({
api: {
post: vi.fn().mockResolvedValue({}),
get: vi.fn().mockResolvedValue({}),
},
}));
vi.mock("@/lib/toast", () => ({
toast: {
success: (...args: unknown[]) => toastSuccessMock(...args),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
},
}));
vi.mock("@tanstack/react-router", () => ({
Link: ({
children,
to,
params,
}: { children: React.ReactNode; to: string; params?: { code: string } }) => (
<a href={params ? to.replace("$code", params.code) : to}>{children}</a>
),
}));
import { api } from "@/lib/api-client";
import { OemSuggestionsSection } from "../oem-suggestions-section";
beforeEach(() => {
captureMock.mockClear();
toastSuccessMock.mockClear();
vi.mocked(api.get).mockReset().mockResolvedValue({ suggestions: [] });
vi.mocked(api.post).mockReset().mockResolvedValue({});
});
afterEach(() => {
cleanup();
});
describe("OemSuggestionsSection", () => {
it("lists aggregated suggestions with supporter counts", async () => {
vi.mocked(api.get).mockResolvedValue({
suggestions: [
{ brand: "Bosch", code: "0 986 478 123", count: 3, mine: true },
{ brand: "TRW", code: "DF4823", count: 1, mine: false },
],
});
render(<OemSuggestionsSection oemCode="8V0615423E" />);
await waitFor(() => {
expect(api.get).toHaveBeenCalledWith("/oem-suggestions?code=8V0615423E");
expect(screen.getByText("Bosch")).toBeInTheDocument();
});
expect(screen.getByRole("link", { name: "0 986 478 123" })).toBeInTheDocument();
expect(screen.getByText("sizin öneriniz")).toBeInTheDocument();
});
it("submits a brand + code suggestion and refreshes the list", async () => {
vi.mocked(api.post).mockResolvedValue({
created: true,
suggestions: [{ brand: "Valeo", code: "PHC123", count: 1, mine: true }],
});
render(<OemSuggestionsSection oemCode="X1" />);
await waitFor(() => expect(api.get).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: /Muadil öner/ }));
fireEvent.change(screen.getByPlaceholderText("Marka (ör. Bosch)"), {
target: { value: "Valeo" },
});
fireEvent.change(screen.getByPlaceholderText("Parça kodu"), {
target: { value: "PHC123" },
});
fireEvent.click(screen.getByRole("button", { name: "Gönder" }));
await waitFor(() => {
expect(api.post).toHaveBeenCalledWith("/oem-suggestions", {
oemCode: "X1",
brand: "Valeo",
suggestedCode: "PHC123",
});
expect(toastSuccessMock).toHaveBeenCalled();
expect(captureMock).toHaveBeenCalledWith(
"oem_suggestion_submitted",
expect.objectContaining({ oem_code: "X1", brand: "Valeo", created: true }),
);
expect(screen.getByText("Valeo")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,105 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const captureMock = vi.fn();
const toastSuccessMock = vi.fn();
vi.mock("@/lib/posthog", () => ({
capture: (...args: unknown[]) => captureMock(...args),
}));
vi.mock("@/lib/api-client", () => ({
api: {
post: vi.fn().mockResolvedValue({}),
get: vi.fn().mockResolvedValue({}),
},
}));
vi.mock("@/lib/toast", () => ({
toast: {
success: (...args: unknown[]) => toastSuccessMock(...args),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
},
}));
vi.mock("@tanstack/react-router", () => ({
Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
<a href={to}>{children}</a>
),
}));
import { api } from "@/lib/api-client";
import { OemVoteCard } from "../oem-vote-card";
beforeEach(() => {
captureMock.mockClear();
toastSuccessMock.mockClear();
vi.mocked(api.post).mockReset().mockResolvedValue({});
});
afterEach(() => {
cleanup();
});
describe("OemVoteCard", () => {
it("loads the summary, marks my vote and hides the tallies", async () => {
vi.mocked(api.post).mockResolvedValue({
votes: { "8V0615423E": { compatible: 4, incompatible: 1, myVote: "compatible" } },
});
render(
<OemVoteCard oemCode="8V0615423E" vehicleLabel="Audi A4 (2018)" partName="Fren balatası" />,
);
// bağlam satırı: neyle neyin uyumlu olduğu tek alanda
expect(screen.getByText("Audi A4 (2018) · Fren balatası")).toBeInTheDocument();
const upButton = screen.getByRole("button", { name: /Uyumlu/ });
const downButton = screen.getByRole("button", { name: /Uyumsuz/ });
await waitFor(() => {
expect(api.post).toHaveBeenCalledWith("/oem-votes/lookup", { codes: ["8V0615423E"] });
expect(upButton).toHaveAttribute("aria-pressed", "true");
});
// sayaçlar butonlarda görünmez (çoğunluk oyu yönlendirmesin)
expect(upButton.textContent).not.toContain("4");
expect(downButton.textContent).not.toContain("1");
});
it("casts a vote, updates the counts and reports the points", async () => {
const postMock = vi.mocked(api.post);
postMock.mockImplementation((path: string) => {
if (path === "/oem-votes") {
return Promise.resolve({
oemCode: "X1",
myVote: "incompatible",
counts: { compatible: 0, incompatible: 1 },
pointsAwarded: 3,
correct: true,
isNew: true,
});
}
return Promise.resolve({ votes: {} });
});
render(<OemVoteCard oemCode="X1" />);
const downButton = screen.getByRole("button", { name: /Uyumsuz/ });
await waitFor(() => expect(downButton).toBeEnabled());
fireEvent.click(downButton);
await waitFor(() => {
expect(postMock).toHaveBeenCalledWith(
"/oem-votes",
expect.objectContaining({ oemCode: "X1", vote: "incompatible" }),
);
expect(toastSuccessMock).toHaveBeenCalledWith("+3 puan kazandınız!", expect.anything());
expect(captureMock).toHaveBeenCalledWith(
"oem_vote_cast",
expect.objectContaining({ oem_code: "X1", vote: "incompatible", surface: "oem_detail" }),
);
});
expect(downButton).toHaveAttribute("aria-pressed", "true");
});
});

View File

@@ -0,0 +1,158 @@
import { api } from "@/lib/api-client";
import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import { Badge, Button, Input, Skeleton } from "@sase/ui";
import { Link } from "@tanstack/react-router";
import { Plus, Users } from "lucide-react";
import { useEffect, useState } from "react";
export interface AggregatedSuggestion {
brand: string;
code: string;
count: number;
mine: boolean;
}
// Topluluk muadil önerileri: P kataloğunda eşleşmesi olmayan (ya da eksik
// kalan) OEM kodları için parça satıcılarından marka + kod toplar. Aynı
// öneriyi veren kullanıcı sayısı rozetle gösterilir; önerilen kod kendi OEM
// detay sayfasına linklenir.
export function OemSuggestionsSection({ oemCode }: { oemCode: string }) {
const [suggestions, setSuggestions] = useState<AggregatedSuggestion[] | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [brand, setBrand] = useState("");
const [code, setCode] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
let cancelled = false;
setSuggestions(null);
api
.get<{ suggestions: AggregatedSuggestion[] }>(
`/oem-suggestions?code=${encodeURIComponent(oemCode)}`,
)
.then((res) => {
if (!cancelled) setSuggestions(res?.suggestions ?? []);
})
.catch(() => {
if (!cancelled) setSuggestions([]);
});
return () => {
cancelled = true;
};
}, [oemCode]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
const res = await api.post<{ created: boolean; suggestions: AggregatedSuggestion[] }>(
"/oem-suggestions",
{ oemCode, brand, suggestedCode: code },
);
setSuggestions(res.suggestions);
if (res.created) {
toast.success("Öneriniz eklendi, teşekkürler!", {
description: "Aynı öneriyi veren satıcı arttıkça öneri güçlenir.",
});
} else {
toast.info("Bu öneriyi zaten eklemişsiniz.");
}
capture("oem_suggestion_submitted", {
oem_code: oemCode,
brand,
created: res.created,
});
setBrand("");
setCode("");
setFormOpen(false);
} catch {
toast.error("Öneri kaydedilemedi", { description: "Lütfen tekrar deneyin." });
} finally {
setSubmitting(false);
}
};
return (
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="text-sm font-semibold">Topluluk muadil önerileri</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
Bu kodun muadilini biliyorsanız marka + parça kodu olarak ekleyin.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => setFormOpen((o) => !o)}>
<Plus className="mr-1.5 size-4" />
Muadil öner
</Button>
</div>
{formOpen && (
<form
onSubmit={submit}
className="flex flex-col gap-2 rounded-xl border border-border bg-background p-3 sm:flex-row"
>
<Input
value={brand}
onChange={(e) => setBrand(e.target.value)}
placeholder="Marka (ör. Bosch)"
required
minLength={2}
maxLength={80}
className="sm:max-w-48"
/>
<Input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Parça kodu"
required
minLength={2}
maxLength={100}
className="font-mono"
/>
<Button type="submit" disabled={submitting}>
{submitting ? "Gönderiliyor…" : "Gönder"}
</Button>
</form>
)}
{suggestions === null ? (
<Skeleton className="h-14 w-full rounded-xl" />
) : suggestions.length === 0 ? (
<p className="rounded-xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
Henüz öneri yok muadilini biliyorsanız ilk öneriyi siz ekleyin.
</p>
) : (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border">
{suggestions.map((s) => (
<li
key={`${s.brand.toLocaleLowerCase("tr-TR")}|${s.code}`}
className="flex items-center justify-between gap-3 bg-background px-4 py-2.5"
>
<span className="min-w-0">
<span className="text-sm font-medium">{s.brand}</span>{" "}
<Link
to="/dashboard/oem/$code"
params={{ code: s.code }}
className="font-mono text-xs underline decoration-dotted underline-offset-2 hover:decoration-solid"
>
{s.code}
</Link>
{s.mine && (
<span className="ml-2 text-[10px] font-semibold uppercase tracking-wide text-primary">
sizin öneriniz
</span>
)}
</span>
<Badge variant="secondary" title="Bu muadili öneren kullanıcı sayısı">
<Users className="mr-1 size-3" />
{s.count}
</Badge>
</li>
))}
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,170 @@
import { api } from "@/lib/api-client";
import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import { cn } from "@sase/ui";
import { Link } from "@tanstack/react-router";
import { Car, ThumbsDown, ThumbsUp } from "lucide-react";
import { useEffect, useState } from "react";
export type OemVoteChoice = "compatible" | "incompatible";
export interface OemVoteSummary {
compatible: number;
incompatible: number;
myVote: OemVoteChoice | null;
}
export interface CastOemVoteResult {
oemCode: string;
myVote: OemVoteChoice;
counts: { compatible: number; incompatible: number };
pointsAwarded: number;
correct: boolean | null;
isNew: boolean;
}
interface OemVoteCardProps {
oemCode: string;
// Katalogdan gelişte taşınan bağlam: "neyle neyin uyumlu olduğu" tek alanda
// görünsün diye butonların üstünde araç + parça adı gösterilir. Doğrudan
// ziyarette (bağlam yok) genel soru metnine düşülür.
vehicleLabel?: string;
partName?: string;
vehicleId?: string;
className?: string;
}
const EMPTY_SUMMARY: OemVoteSummary = { compatible: 0, incompatible: 0, myVote: null };
// OEM detay başlığındaki topluluk oyu kartı: uyumlu/uyumsuz + sayaçlar.
// Arkaplanı sayfayla aynı (dolgu yok, yalnız çerçeve). Puan kuralı yalnız
// Parça Uzmanları sayfasında yazılıdır; burada toast geri bildirimi yeter.
export function OemVoteCard({
oemCode,
vehicleLabel,
partName,
vehicleId,
className,
}: OemVoteCardProps) {
const [summary, setSummary] = useState<OemVoteSummary | null>(null);
const [pending, setPending] = useState(false);
const context = [vehicleLabel, partName].filter(Boolean).join(" · ");
useEffect(() => {
let cancelled = false;
setSummary(null);
api
.post<{ votes: Record<string, OemVoteSummary> }>("/oem-votes/lookup", { codes: [oemCode] })
.then((res) => {
if (!cancelled) setSummary(res?.votes?.[oemCode] ?? EMPTY_SUMMARY);
})
.catch(() => {
if (!cancelled) setSummary(EMPTY_SUMMARY);
});
return () => {
cancelled = true;
};
}, [oemCode]);
const cast = async (vote: OemVoteChoice) => {
setPending(true);
try {
const result = await api.post<CastOemVoteResult>("/oem-votes", {
oemCode,
vote,
vehicleId,
});
setSummary({ ...result.counts, myVote: result.myVote });
if (result.isNew) {
const isFirstVote = result.counts.compatible + result.counts.incompatible === 1;
toast.success(`+${result.pointsAwarded} puan kazandınız!`, {
description: isFirstVote
? "Bu OEM kodunu ilk değerlendiren sizsiniz."
: result.correct
? "Çoğunluk görüşüyle aynı yöndesiniz."
: "Oyunuz kaydedildi — çoğunluk şimdilik farklı görüşte.",
});
} else {
toast.info("Oyunuz güncellendi.");
}
capture("oem_vote_cast", {
oem_code: oemCode,
vote,
points_awarded: result.pointsAwarded,
correct: result.correct,
is_new: result.isNew,
vehicle_id: vehicleId,
surface: "oem_detail",
});
} catch {
toast.error("Oy kaydedilemedi", { description: "Lütfen tekrar deneyin." });
} finally {
setPending(false);
}
};
// Sayaçlar bilinçli olarak gösterilmiyor (çoğunluğu ele vermek oyu yönlendirir);
// özet yine de çekilir — kullanıcının kendi oyu butonda işaretli kalır.
const options = [
{
vote: "compatible" as const,
icon: ThumbsUp,
label: "Uyumlu",
activeClass: "border-green-500/50 bg-green-500/10 text-green-500",
},
{
vote: "incompatible" as const,
icon: ThumbsDown,
label: "Uyumsuz",
activeClass: "border-red-500/50 bg-red-500/10 text-red-500",
},
];
return (
<section className={cn("rounded-xl border border-border p-4", className)}>
<h2 className="text-sm font-semibold">Topluluk uyumluluk oyu</h2>
{context ? (
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<Car className="size-3.5 shrink-0" />
<span className="truncate" title={context}>
{context}
</span>
</p>
) : (
<p className="mt-1 text-xs text-muted-foreground">
Bu OEM kodu sizce doğru mu? Oyunuz diğer parça satıcılarına yol gösterir.
</p>
)}
<div className="mt-3 flex gap-2">
{options.map(({ vote, icon: Icon, label, activeClass }) => {
const isActive = summary?.myVote === vote;
return (
<button
key={vote}
type="button"
disabled={pending || summary === null}
aria-pressed={isActive}
onClick={() => cast(vote)}
className={cn(
"inline-flex flex-1 items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors disabled:opacity-50",
isActive
? activeClass
: "border-border text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
<Icon className="size-4 shrink-0" fill={isActive ? "currentColor" : "none"} />
{label}
</button>
);
})}
</div>
<p className="mt-3 text-xs text-muted-foreground">
Her oy puan kazandırır ·{" "}
<Link to="/dashboard/uzmanlar" className="font-medium text-primary hover:underline">
Parça Uzmanları sıralaması
</Link>
</p>
</section>
);
}

View File

@@ -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 (
<Button
type="button"
variant="ghost"
size="sm"
className={className}
disabled={reported}
onClick={() => {
if (reported) return;
capture("catalog_issue_reported", {
vehicle_id: ctx.vehicleId,
vehicle_label: ctx.vehicleLabel,
category_id: ctx.categoryId,
category_name: ctx.categoryName,
source: ctx.source,
vin: ctx.vin,
page: window.location.pathname + window.location.search,
});
setReported(true);
}}
data-faro-user-action-name="report-catalog-issue"
>
{reported ? <Check className="mr-1.5 h-4 w-4" /> : <Flag className="mr-1.5 h-4 w-4" />}
{reported ? t("vehicle.reportIssueDone") : t("vehicle.reportIssue")}
</Button>
);
}

View File

@@ -0,0 +1,225 @@
import { Turnstile, type TurnstileHandle } from "@/components/turnstile";
import { usePageMeta } from "@/hooks/use-page-meta";
import { ApiError, api } from "@/lib/api-client";
import { toast } from "@/lib/toast";
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from "@sase/ui";
import { Loader2 } from "lucide-react";
import { type FormEvent, useRef, useState } from "react";
import { z } from "zod";
// Contact content is rendered in two shells: the public marketing page
// (/contact) and the dashboard-wrapped page (/dashboard/contact) so logged-in
// users keep the sidebar.
// Backend (POST /contact) ile aynı kurallar
const contactSchema = z.object({
name: z.string().trim().min(2, "Ad en az 2 karakter olmalı").max(100, "Ad çok uzun"),
email: z.string().trim().email("Geçerli bir e-posta adresi girin").max(200),
subject: z.string().trim().max(200, "Konu çok uzun").optional(),
message: z.string().trim().min(10, "Mesaj en az 10 karakter olmalı").max(5000, "Mesaj çok uzun"),
});
type FieldErrors = Partial<Record<"name" | "email" | "subject" | "message", string>>;
const textareaClass =
"flex min-h-[140px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";
function ContactForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [subject, setSubject] = useState("");
const [message, setMessage] = useState("");
const [errors, setErrors] = useState<FieldErrors>({});
const [captchaToken, setCaptchaToken] = useState("");
const [loading, setLoading] = useState(false);
const turnstileRef = useRef<TurnstileHandle>(null);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const parsed = contactSchema.safeParse({ name, email, subject, message });
if (!parsed.success) {
const next: FieldErrors = {};
for (const issue of parsed.error.issues) {
const key = issue.path[0] as keyof FieldErrors;
if (key && !next[key]) next[key] = issue.message;
}
setErrors(next);
return;
}
setErrors({});
setLoading(true);
try {
await api.post("/contact", { ...parsed.data, turnstileToken: captchaToken });
toast.success("Mesajınız gönderildi", {
description: "En kısa sürede size dönüş yapacağız.",
});
setName("");
setEmail("");
setSubject("");
setMessage("");
turnstileRef.current?.reset();
setCaptchaToken("");
} catch (err) {
// Token tek kullanımlık — başarısız denemeden sonra widget'ı sıfırla
turnstileRef.current?.reset();
setCaptchaToken("");
const msg =
err instanceof ApiError ? err.message : "Mesaj gönderilemedi. Lütfen tekrar deneyin.";
toast.error(msg);
} finally {
setLoading(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Bize yazın</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="contact-name">Ad Soyad</Label>
<Input
id="contact-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Adınız"
aria-invalid={!!errors.name}
disabled={loading}
/>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-email">E-posta</Label>
<Input
id="contact-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="ornek@eposta.com"
aria-invalid={!!errors.email}
disabled={loading}
/>
{errors.email && <p className="text-sm text-destructive">{errors.email}</p>}
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-subject">Konu (opsiyonel)</Label>
<Input
id="contact-subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Mesajınızın konusu"
aria-invalid={!!errors.subject}
disabled={loading}
/>
{errors.subject && <p className="text-sm text-destructive">{errors.subject}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-message">Mesaj</Label>
<textarea
id="contact-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Size nasıl yardımcı olabiliriz?"
aria-invalid={!!errors.message}
disabled={loading}
className={textareaClass}
/>
{errors.message && <p className="text-sm text-destructive">{errors.message}</p>}
</div>
<Turnstile
ref={turnstileRef}
onVerify={setCaptchaToken}
onExpire={() => setCaptchaToken("")}
/>
<Button type="submit" disabled={loading} className="w-full sm:w-auto">
{loading ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Gönderiliyor
</>
) : (
"Gönder"
)}
</Button>
</form>
</CardContent>
</Card>
);
}
export function ContactContent() {
usePageMeta({
title: "İletişim — Sase.tr",
description: "Sase.tr destek ve iletişim — sorularınız için bize ulaşın.",
canonical: "https://sase.tr/contact",
});
return (
<>
<h1 className="text-4xl font-bold">İletişim</h1>
<p className="mt-4 text-lg text-muted-foreground">
Sorularınız, önerileriniz veya birliği talepleriniz için bize ulaşın.
</p>
<div className="mt-12 grid gap-6 lg:grid-cols-5">
{/* İletişim formu */}
<div className="lg:col-span-3">
<ContactForm />
</div>
{/* İletişim bilgileri */}
<div className="space-y-6 lg:col-span-2">
<Card>
<CardHeader>
<CardTitle className="text-lg">Destek</CardTitle>
</CardHeader>
<CardContent>
<a href="mailto:destek@sase.tr" className="text-primary underline">
destek@sase.tr
</a>
<p className="mt-2 text-sm text-muted-foreground">
Teknik sorunlar ve hesap işlemleri için.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Adres</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1 text-sm text-muted-foreground">
<p>
<span className="text-foreground">Firma Adı:</span> THINXTRA LLC
</p>
<p>
<span className="text-foreground">Adres:</span> 1209 Mountain Road PL NE #11131
</p>
<p>
<span className="text-foreground">Şehir:</span> Albuquerque, NM 87110
</p>
<p>
<span className="text-foreground">Ülke:</span> United States
</p>
<p>
<span className="text-foreground">Vergi No:</span> 36-5177177
</p>
</div>
<p className="mt-3 text-sm text-muted-foreground">
Çalışma saatleri: Pazartesi Cuma, 09:00 18:00
</p>
</CardContent>
</Card>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,96 @@
// Trophy Gamification UI Kit'in leaderboard-podium bileşeni (MIT, ui.trophy.so)
// sase'ye uyarlandı: shadcn yerine @sase/ui, cva bağımlılığı söküldü, rank
// renk token'ları somut Tailwind renklerine bağlandı, avatar servisi yerine
// maskeli adın ilk harfi gösteriliyor (liderlik listesi KVKK-maskeli).
import { cn } from "@sase/ui";
import { Crown } from "lucide-react";
export interface PodiumRanking {
userId: string;
userName: string | null;
rank: number;
value: number;
}
const PODIUM_CONFIG = {
1: { color: "text-amber-400", bg: "bg-amber-400/50", height: "h-32" },
2: { color: "text-zinc-400", bg: "bg-zinc-400/30", height: "h-24" },
3: { color: "text-orange-700", bg: "bg-orange-700/40", height: "h-20" },
} as const;
interface LeaderboardPodiumProps {
/** İlk 3 sıra (rank 1-3 beklenir) */
rankings: PodiumRanking[];
showValue?: boolean;
className?: string;
}
export function LeaderboardPodium({
rankings,
showValue = true,
className,
}: LeaderboardPodiumProps) {
// Kürsü dizilimi: 2. — 1. — 3.
const top3 = rankings.slice(0, 3);
const podiumOrder = [
top3.find((r) => r.rank === 2),
top3.find((r) => r.rank === 1),
top3.find((r) => r.rank === 3),
].filter((r): r is PodiumRanking => Boolean(r));
if (podiumOrder.length === 0) return null;
return (
<ul
className={cn("flex items-end justify-center gap-4", className)}
aria-label="İlk 3 sıralama"
>
{podiumOrder.map((ranking) => {
const config = PODIUM_CONFIG[ranking.rank as 1 | 2 | 3];
if (!config) return null;
const displayName = ranking.userName || "Üye";
return (
<li
key={ranking.userId}
aria-label={`Sıra ${ranking.rank}: ${displayName}${showValue ? `, ${ranking.value.toLocaleString("tr-TR")} puan` : ""}`}
className="flex flex-col items-center"
>
<div className="relative mb-2" aria-hidden="true">
<div
className={cn(
"flex h-14 w-14 items-center justify-center rounded-full text-lg font-semibold",
config.bg,
)}
>
{displayName.charAt(0)}
</div>
<div className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-background shadow-sm">
<Crown className={cn("h-4 w-4", config.color)} />
</div>
</div>
<span className="max-w-20 truncate text-center text-sm font-medium" title={displayName}>
{displayName}
</span>
{showValue && (
<span className="text-sm tabular-nums text-muted-foreground">
{ranking.value.toLocaleString("tr-TR")}
</span>
)}
<div
className={cn("mt-2 w-22 rounded-t-lg", config.height, config.bg)}
aria-hidden="true"
>
<div className={cn("flex h-8 items-center justify-center font-bold", config.color)}>
{ranking.rank}
</div>
</div>
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,166 @@
// Trophy Gamification UI Kit'in leaderboard-rankings bileşeni (MIT, ui.trophy.so)
// sase'ye uyarlandı: @sase/ui importları, Türkçe metinler, avatar yerine
// maskeli adın ilk harfi, currentUserId karşılaştırması yerine isCurrentUser
// bayrağı (liderlik cevabı kullanıcı id'si sızdırmaz).
import { Button, cn } from "@sase/ui";
import { ChevronLeft, ChevronRight, Crown } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
export interface LeaderboardRankingItem {
userId: string;
userName: string | null;
rank: number;
value: number;
byline?: string | null;
isCurrentUser?: boolean;
}
interface LeaderboardRankingsProps {
rankings: LeaderboardRankingItem[];
showPagination?: boolean;
defaultPageSize?: 10 | 25 | 50 | 100;
className?: string;
}
const crownColorMap = {
1: "text-amber-400",
2: "text-zinc-400",
3: "text-orange-700",
} as const;
const pageSizeOptions = [10, 25, 50, 100] as const;
function formatLeaderboardValue(value: number) {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}m`;
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
return value.toLocaleString("tr-TR");
}
export function LeaderboardRankings({
rankings,
showPagination = false,
defaultPageSize = 25,
className,
}: LeaderboardRankingsProps) {
const [pageSize, setPageSize] = useState<10 | 25 | 50 | 100>(defaultPageSize);
const [currentPage, setCurrentPage] = useState(1);
const totalPages = Math.max(1, Math.ceil(rankings.length / pageSize));
useEffect(() => {
setCurrentPage(1);
}, []);
useEffect(() => {
if (currentPage > totalPages) setCurrentPage(totalPages);
}, [currentPage, totalPages]);
const pagedRankings = useMemo(
() =>
showPagination
? rankings.slice((currentPage - 1) * pageSize, currentPage * pageSize)
: rankings,
[rankings, showPagination, currentPage, pageSize],
);
return (
<div className={cn("w-full rounded-xl border bg-card", className)}>
<ul aria-label="Parça uzmanları sıralaması" className="divide-y divide-border">
{pagedRankings.map((ranking) => {
const displayName = ranking.userName || "Üye";
const showCrown = ranking.rank <= 3;
const crownColor = crownColorMap[ranking.rank as 1 | 2 | 3];
return (
<li
key={ranking.userId}
className={cn(
"flex items-center gap-2 px-4 py-2",
ranking.isCurrentUser && "rounded-md border-2 border-primary bg-muted",
)}
>
<div className="flex w-12 items-center gap-1">
<span className="w-4 text-sm font-semibold tabular-nums">{ranking.rank}</span>
{showCrown ? (
<Crown className={cn("h-5 w-5", crownColor)} aria-hidden="true" />
) : null}
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">
{displayName.charAt(0).toLocaleUpperCase("tr-TR")}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">
{displayName}
{ranking.isCurrentUser && (
<span className="ml-2 text-xs font-semibold text-primary">(Siz)</span>
)}
</p>
{ranking.byline ? (
<p className="truncate text-sm text-muted-foreground">{ranking.byline}</p>
) : null}
</div>
<p className="font-semibold leading-none tabular-nums">
{formatLeaderboardValue(ranking.value)}
</p>
</li>
);
})}
</ul>
{showPagination ? (
<div className="flex items-center justify-between gap-3 border-t px-4 py-2">
<div className="flex items-center gap-2">
<label htmlFor="leaderboard-page-size" className="text-sm text-muted-foreground">
Göster
</label>
<select
id="leaderboard-page-size"
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value) as 10 | 25 | 50 | 100);
setCurrentPage(1);
}}
className="rounded-md border bg-background px-2 py-1 text-sm text-muted-foreground"
>
{pageSizeOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
aria-label="Önceki sayfa"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="rounded-md border p-1.5 transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm text-muted-foreground">
Sayfa {currentPage} / {totalPages}
</span>
<Button
variant="ghost"
size="icon"
aria-label="Sonraki sayfa"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="rounded-md border p-1.5 transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,44 @@
// Trophy Gamification UI Kit'in points-badge bileşeni (MIT, ui.trophy.so)
// sase'ye uyarlandı: @sase/ui importları, cva bağımlılığı söküldü.
import { cn } from "@sase/ui";
import { Sparkle } from "lucide-react";
interface PointsBadgeProps {
name: string;
total: number;
icon?: React.ComponentType<{ className?: string }>;
formatValue?: (value: number) => string;
className?: string;
}
export function PointsBadge({
name,
total,
icon: CustomIcon,
formatValue,
className,
}: PointsBadgeProps) {
const Icon = CustomIcon ?? Sparkle;
const displayValue = formatValue ? formatValue(total) : total.toLocaleString("tr-TR");
return (
<output
aria-label={`${displayValue} ${name}`}
className={cn(
"flex items-center gap-3 rounded-lg border bg-card p-4 transition-colors",
className,
)}
>
<div className="flex items-center gap-2">
<div
aria-hidden="true"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10"
>
<Icon className="h-5 w-5 text-primary" />
</div>
<span className="text-xl font-bold tabular-nums">{displayValue}</span>
</div>
<span className="truncate text-muted-foreground">{name}</span>
</output>
);
}

View File

@@ -27,6 +27,7 @@ vi.mock("@/stores/schema.store", () => ({
}), }),
})); }));
import { api } from "@/lib/api-client";
import { PartsPanel } from "../parts-panel"; import { PartsPanel } from "../parts-panel";
const buildPart = (overrides: Partial<Part> = {}): Part => ({ const buildPart = (overrides: Partial<Part> = {}): Part => ({
@@ -49,6 +50,7 @@ beforeEach(() => {
captureMock.mockClear(); captureMock.mockClear();
setSelectedGroupMock.mockClear(); setSelectedGroupMock.mockClear();
setHighlightedGroupMock.mockClear(); setHighlightedGroupMock.mockClear();
vi.mocked(api.post).mockReset().mockResolvedValue({});
}); });
afterEach(() => { afterEach(() => {
@@ -148,4 +150,27 @@ describe("PartsPanel", () => {
parts_count: parts.length, parts_count: parts.length,
}); });
}); });
it("links every OEM code to the detail page without a /p/matched gate", () => {
const postMock = vi.mocked(api.post);
render(<PartsPanel parts={[buildPart()]} vehicleId="v1" categoryId="c1" />);
const link = screen.getByRole("link", { name: "OEM-1" });
// href bağlam taşır: parça adı + vehicleId (oy kartındaki "araç · parça" satırı)
expect(link.getAttribute("href")).toContain("/dashboard/oem/OEM-1");
expect(link.getAttribute("href")).toContain("vid=v1");
// Liste açılışı artık toplu istek atmaz (/p/matched ve oy lookup'ı kalktı);
// tek istisna kopyalama anındaki fire-and-forget analytics POST'udur.
expect(postMock).not.toHaveBeenCalled();
});
it("does not render Uyum or Adet columns", () => {
render(<PartsPanel parts={[buildPart({ quantity: 4 })]} />);
expect(screen.queryByText("Adet")).not.toBeInTheDocument();
expect(screen.queryByText("Uyum")).not.toBeInTheDocument();
const headers = screen.getAllByRole("columnheader").map((th) => th.textContent);
expect(headers).toEqual(["#", "Parça Adı", "OEM Kodu", "Pozisyon"]);
});
}); });

View File

@@ -1,3 +1,4 @@
import { ReportCatalogIssueButton } from "@/components/catalog/report-catalog-issue";
import type { Part } from "@/hooks/use-parts"; import type { Part } from "@/hooks/use-parts";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { capture } from "@/lib/posthog"; import { capture } from "@/lib/posthog";
@@ -21,6 +22,17 @@ interface PartsPanelProps {
const SKELETON_ROW_KEYS = ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7"] as const; const SKELETON_ROW_KEYS = ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7"] as const;
// OEM detay linki, oy kartının "araç · parça" bağlam satırı için görüntü
// bağlamını search-param olarak taşır (v/p/vid; oem.$code validateSearch).
function oemDetailHref(code: string, search: { v?: string; p?: string; vid?: string }) {
const qs = new URLSearchParams();
if (search.v) qs.set("v", search.v);
if (search.p) qs.set("p", search.p);
if (search.vid) qs.set("vid", search.vid);
const tail = qs.toString();
return `/dashboard/oem/${encodeURIComponent(code)}${tail ? `?${tail}` : ""}`;
}
export function PartsPanel({ export function PartsPanel({
parts, parts,
vehicleId, vehicleId,
@@ -204,23 +216,30 @@ export function PartsPanel({
{parts.length === 0 ? ( {parts.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-4 py-12 text-center text-sm text-muted-foreground"> <div className="flex flex-col items-center justify-center gap-4 py-12 text-center text-sm text-muted-foreground">
<p>Bu kategori için parça bulunamadı.</p> <p>Bu kategori için parça bulunamadı.</p>
<Button <div className="flex flex-wrap items-center justify-center gap-2">
type="button" <Button
variant="outline" type="button"
onClick={() => { variant="outline"
// Demand/abandonment signal from an empty "0 parça" panel — lets onClick={() => {
// us prioritise which catalogs to backfill, with readable context. // Demand/abandonment signal from an empty "0 parça" panel — lets
capture("empty_catalog_cta_clicked", { // us prioritise which catalogs to backfill, with readable context.
vehicle_id: vehicleId, capture("empty_catalog_cta_clicked", {
category_id: categoryId, vehicle_id: vehicleId,
vehicle_label: vehicleLabel, category_id: categoryId,
category_name: categoryName, vehicle_label: vehicleLabel,
}); category_name: categoryName,
window.history.back(); });
}} window.history.back();
> }}
Geri dön >
</Button> Geri dön
</Button>
{/* User-driven report: a parts shop knows if this category SHOULD
have parts. One click → structured Sentry feedback + replay. */}
<ReportCatalogIssueButton
ctx={{ vehicleId, categoryId, vehicleLabel, categoryName }}
/>
</div>
</div> </div>
) : ( ) : (
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -229,7 +248,6 @@ export function PartsPanel({
<th className="px-3 py-2 w-10">#</th> <th className="px-3 py-2 w-10">#</th>
<th className="px-3 py-2">Parça Adı</th> <th className="px-3 py-2">Parça Adı</th>
<th className="px-3 py-2">OEM Kodu</th> <th className="px-3 py-2">OEM Kodu</th>
<th className="px-3 py-2 w-14 text-center">Adet</th>
<th className="px-3 py-2">Pozisyon</th> <th className="px-3 py-2">Pozisyon</th>
{hasPrices && <th className="px-3 py-2 text-right">Fiyat</th>} {hasPrices && <th className="px-3 py-2 text-right">Fiyat</th>}
</tr> </tr>
@@ -254,7 +272,7 @@ export function PartsPanel({
return ( return (
<tr key={part.id} className="border-b border-border/50 bg-muted/20"> <tr key={part.id} className="border-b border-border/50 bg-muted/20">
<td className="px-3 py-2 text-muted-foreground">{part.hotspotIndex}</td> <td className="px-3 py-2 text-muted-foreground">{part.hotspotIndex}</td>
<td className="px-3 py-2" colSpan={hasPrices ? 5 : 4}> <td className="px-3 py-2" colSpan={hasPrices ? 4 : 3}>
{label && <span className="font-medium">{label}</span>} {label && <span className="font-medium">{label}</span>}
<span className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1"> <span className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
{refs.map((ref) => { {refs.map((ref) => {
@@ -344,7 +362,7 @@ export function PartsPanel({
</td> </td>
<td className="px-3 py-2 font-mono text-xs"> <td className="px-3 py-2 font-mono text-xs">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
{part.oemCode && ( {part.oemCode && part.oemCode !== "N/A" && (
<button <button
type="button" type="button"
className="inline-flex shrink-0 items-center justify-center rounded p-0.5 text-muted-foreground hover:text-foreground" className="inline-flex shrink-0 items-center justify-center rounded p-0.5 text-muted-foreground hover:text-foreground"
@@ -357,10 +375,45 @@ export function PartsPanel({
)} )}
</button> </button>
)} )}
{part.oemCode} {part.oemCode && part.oemCode !== "N/A" ? (
// Every code links to the OEM detail page — without a P
// cross-reference the page still carries the community
// vote, equivalent suggestions and the reverse catalog.
// Plain left-click → in-app client navigation (no full
// SPA reload); real href kept so ctrl/cmd/middle-click
// still opens a new tab.
<a
href={oemDetailHref(part.oemCode, {
v: vehicleLabel,
p: part.name,
vid: vehicleId,
})}
title="Uyumlu parça kodlarını gör"
className="underline decoration-dotted underline-offset-2 transition-colors hover:text-foreground hover:decoration-solid"
onClick={(e) => {
e.stopPropagation();
capture("oem_detail_opened", {
oem_code: part.oemCode,
part_id: part.id,
vehicle_id: vehicleId,
category_id: categoryId,
});
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
navigate({
to: "/dashboard/oem/$code",
params: { code: part.oemCode },
search: { v: vehicleLabel, p: part.name, vid: vehicleId },
});
}}
>
{part.oemCode}
</a>
) : (
part.oemCode
)}
</span> </span>
</td> </td>
<td className="px-3 py-2 text-center">{part.quantity}</td>
<td className="px-3 py-2 text-muted-foreground">{part.position}</td> <td className="px-3 py-2 text-muted-foreground">{part.position}</td>
{hasPrices && ( {hasPrices && (
<td className="px-3 py-2 text-right text-xs"> <td className="px-3 py-2 text-right text-xs">

View File

@@ -1,118 +0,0 @@
import { useChangelog } from "@/hooks/use-changelog";
import { useTranslation } from "@/lib/i18n";
import type { ChangelogEntry } from "@sase/shared";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Skeleton,
} from "@sase/ui";
import { CalendarDays } from "lucide-react";
function formatDate(iso: string, locale: "tr" | "en"): string {
const date = new Date(iso);
const options: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "long",
year: "numeric",
};
return date.toLocaleDateString(locale === "tr" ? "tr-TR" : "en-US", options);
}
function changeTypeLabel(
changeType: ChangelogEntry["changeType"],
t: (key: string) => string,
): string {
return t(`settings.changelog.changeType.${changeType}`);
}
function ChangelogSkeleton() {
return (
<div className="space-y-0">
{[1, 2, 3].map((i) => (
<div key={i} className="relative pb-6 pl-8">
<div className="absolute left-0 top-1.5 h-2.5 w-2.5 rounded-full bg-muted-foreground/20" />
<div className="space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-16 rounded-md" />
<Skeleton className="h-5 w-48" />
</div>
<Skeleton className="h-4 w-28" />
</div>
</div>
))}
</div>
);
}
function ChangelogEmpty({ t }: { t: (key: string) => string }) {
return (
<Card className="border-dashed">
<CardHeader className="text-center">
<CalendarDays className="mx-auto h-10 w-10 text-muted-foreground" />
<CardTitle>{t("settings.changelog.emptyTitle")}</CardTitle>
<CardDescription>{t("settings.changelog.emptyDescription")}</CardDescription>
</CardHeader>
</Card>
);
}
export function ChangelogTab() {
const { data: entries, isLoading } = useChangelog();
const { t, locale } = useTranslation();
if (isLoading) {
return <ChangelogSkeleton />;
}
if (!entries || entries.length === 0) {
return <ChangelogEmpty t={t} />;
}
return (
<Card>
<CardHeader>
<CardTitle>{t("settings.changelog.title")}</CardTitle>
<CardDescription>{t("settings.changelog.description")}</CardDescription>
</CardHeader>
<CardContent>
<Accordion
type="single"
collapsible
className="relative border-l-2 border-muted-foreground/20"
>
{entries.map((entry) => (
<AccordionItem key={entry.id} value={entry.id} className="border-b-0 pl-6">
<div className="absolute left-[-5px] mt-6 h-2.5 w-2.5 rounded-full border-2 border-background bg-foreground dark:bg-primary" />
<AccordionTrigger className="py-3 hover:no-underline">
<div className="flex flex-col items-start gap-1.5 text-left">
<div className="flex flex-wrap items-center gap-2">
<Badge changeType={entry.changeType}>
{changeTypeLabel(entry.changeType, t)}
</Badge>
<span className="font-medium">{entry.title}</span>
</div>
<span className="text-xs text-muted-foreground">
{formatDate(entry.publishedAt, locale)}
</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
{entry.description}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
);
}

View File

@@ -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 = 010 → 11 buttons; CSAT/CES → 3/5/7.)
const RATING_COLS: Record<number, string> = {
3: "grid-cols-3",
5: "grid-cols-5",
7: "grid-cols-7",
11: "grid-cols-11",
};
export function SurveyPopover() {
const [survey, setSurvey] = useState<Survey | null>(null);
useEffect(() => subscribeActiveSurvey(setSurvey), []);
if (!survey) return null;
return <SurveyCard key={`${survey.id}-${survey.current_iteration ?? 0}`} survey={survey} />;
}
function SurveyCard({ survey }: { survey: Survey }) {
const [step, setStep] = useState(0);
const [answers, setAnswers] = useState<SurveyResponses>({});
const [choice, setChoice] = useState<string | null>(null);
const [otherText, setOtherText] = useState("");
const [openText, setOpenText] = useState("");
const [rating, setRating] = useState<number | null>(null);
const [done, setDone] = useState(false);
const [shown, setShown] = useState(false);
const advanceTimer = useRef<ReturnType<typeof setTimeout> | 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 (
<section
aria-label={survey.name}
className={`fixed bottom-28 right-4 z-[70] w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-border bg-card p-5 text-card-foreground shadow-2xl transition-all duration-300 ease-out motion-reduce:transition-none sm:bottom-24 ${
shown ? "translate-y-0 opacity-100" : "translate-y-3 opacity-0"
}`}
>
<button
type="button"
aria-label="Kapat"
onClick={dismiss}
className="absolute right-3 top-3 rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<X className="size-4" />
</button>
{done ? (
<div>
<p className="pr-6 text-sm font-semibold">
{survey.appearance?.thankYouMessageHeader || "Teşekkürler!"}
</p>
{survey.appearance?.thankYouMessageDescription ? (
<p className="mt-1.5 text-sm text-muted-foreground">
{survey.appearance.thankYouMessageDescription}
</p>
) : null}
<Button variant="outline" className="mt-4 w-full" onClick={closeActiveSurvey}>
Kapat
</Button>
</div>
) : q ? (
<div>
{questions.length > 1 ? (
<p className="mb-2 text-xs font-medium tabular-nums text-muted-foreground">
{step + 1} / {questions.length}
</p>
) : null}
<p className="pr-6 text-sm font-medium leading-snug">{q.question}</p>
{q.type === "single_choice" ? (
<div className="mt-3 flex flex-col gap-1.5">
{choices.map((c) => (
<button
key={c}
type="button"
onClick={() => setChoice(c)}
className={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
choice === c
? "border-brand bg-brand/10 text-foreground"
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground"
}`}
>
{c}
</button>
))}
{hasOpenChoice ? (
<>
<button
type="button"
onClick={() => setChoice(OTHER_CHOICE)}
className={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
choice === OTHER_CHOICE
? "border-brand bg-brand/10 text-foreground"
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground"
}`}
>
Diğer
</button>
{choice === OTHER_CHOICE ? (
<textarea
rows={2}
value={otherText}
onChange={(e) => setOtherText(e.target.value)}
placeholder="Yazmaya başlayın..."
// biome-ignore lint/a11y/noAutofocus: revealed by an explicit user action
autoFocus
className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-foreground/40"
/>
) : null}
</>
) : null}
</div>
) : null}
{q.type === "rating" ? (
<div className="mt-3">
<div className={`grid gap-1 ${RATING_COLS[ratingValues.length] ?? "grid-cols-5"}`}>
{ratingValues.map((v) => (
<button
key={v}
type="button"
onClick={() => selectRating(v)}
className={`flex h-9 items-center justify-center rounded-md border text-xs transition-colors ${
rating === v
? "border-brand bg-brand text-brand-foreground"
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground"
}`}
>
{v}
</button>
))}
</div>
{lowerLabel || upperLabel ? (
<div className="mt-1.5 flex justify-between text-xs text-muted-foreground">
<span>{lowerLabel}</span>
<span>{upperLabel}</span>
</div>
) : null}
</div>
) : null}
{q.type === "open" ? (
<textarea
rows={3}
value={openText}
onChange={(e) => setOpenText(e.target.value)}
placeholder="Yazmaya başlayın..."
className="mt-3 w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-foreground/40"
/>
) : null}
{q.type !== "rating" ? (
<Button
variant="brand"
className="mt-4 w-full"
disabled={!canContinue}
onClick={advance}
>
{buttonLabel}
</Button>
) : null}
</div>
) : null}
</section>
);
}

View File

@@ -0,0 +1,147 @@
import { api } from "@/lib/api-client";
import { useSession } from "@/lib/auth-client";
import { useTranslation } from "@/lib/i18n";
import { capture } from "@/lib/posthog";
import { Button } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { Sparkles, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
interface Subscription {
status: string;
plan?: { name: string; key: string };
billingPeriod: string;
endDate?: string;
}
// Early, value-framed conversion nudge that fills the dead zone the urgency
// banner leaves: the 30-day trial only shows the (red) urgency banner in its
// final week, so for ~3 weeks there is no in-app push at all — and ~98% of
// trials never reach checkout. This banner instead fires on PROVEN VALUE (the
// user has decoded >= VALUE_THRESHOLD customer vehicles) and only outside the
// urgency window, so the two never overlap and the tone stays positive.
const VALUE_THRESHOLD = 3;
// Urgency banner owns days <= 7; this one owns days > 7 (no overlap).
const URGENCY_WINDOW_DAYS = 7;
function dismissStorageKey(userId: string | null | undefined, endDate: string): string {
return `trialValueUpsellDismissed-${userId ?? "anon"}-${endDate}`;
}
export function TrialValueUpsell() {
const { t } = useTranslation();
const { data: session } = useSession();
const userId = session?.user?.id ?? null;
const [dismissed, setDismissed] = useState(false);
const [mounted, setMounted] = useState(false);
const viewedRef = useRef(false);
const { data: subData, isLoading: subLoading } = useQuery({
queryKey: ["subscription", "me"],
queryFn: () =>
api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>(
"/subscriptions/me",
),
});
const subscription = subData?.subscription;
const endDate = subscription?.endDate;
const days =
subscription?.status === "trial" && endDate
? Math.max(0, Math.ceil((new Date(endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
: null;
// Only outside the urgency window, so the urgency banner stays the sole voice
// in the final week.
const inWindow = !subLoading && days !== null && days > URGENCY_WINDOW_DAYS;
// Server-truth value signal: how many customer vehicles this trial user has
// decoded. Requesting exactly VALUE_THRESHOLD rows is enough to answer
// ">= threshold" without a COUNT endpoint. Gated so non-eligible users never
// pay for the request.
const { data: history } = useQuery({
queryKey: ["vehicles", "history", "value-probe", VALUE_THRESHOLD],
queryFn: () => api.get<unknown[]>(`/vehicles/history?limit=${VALUE_THRESHOLD}`),
enabled: inWindow,
staleTime: 5 * 60 * 1000,
});
const hasProvenValue = Array.isArray(history) && history.length >= VALUE_THRESHOLD;
const dismissKey = endDate ? dismissStorageKey(userId, endDate) : null;
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!mounted || !dismissKey) return;
if (localStorage.getItem(dismissKey) === "true") setDismissed(true);
}, [mounted, dismissKey]);
const visible = inWindow && hasProvenValue && !dismissed;
useEffect(() => {
if (!visible || viewedRef.current || days === null) return;
viewedRef.current = true;
capture("trial_value_upsell_viewed", {
days_remaining: days,
plan_name: subscription?.plan?.name,
billing_period: subscription?.billingPeriod,
});
}, [visible, days, subscription?.plan?.name, subscription?.billingPeriod]);
const handleCTAClick = useCallback(() => {
capture("trial_value_upsell_cta_clicked", {
days_remaining: days,
plan_name: subscription?.plan?.name,
billing_period: subscription?.billingPeriod,
});
}, [days, subscription?.plan?.name, subscription?.billingPeriod]);
if (!visible || !dismissKey) return null;
const handleDismiss = () => {
capture("trial_value_upsell_dismissed", {
days_remaining: days,
plan_name: subscription?.plan?.name,
billing_period: subscription?.billingPeriod,
});
localStorage.setItem(dismissKey, "true");
setDismissed(true);
};
return (
<section
aria-label={t("subscription.valueUpsell.title")}
className="flex items-start gap-3 border-b border-brand/20 bg-brand/5 px-4 py-3 sm:items-center sm:px-6"
>
<Sparkles className="mt-0.5 size-5 shrink-0 text-brand sm:mt-0" />
<div className="flex flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
<div>
<p className="text-sm font-semibold text-foreground">
{t("subscription.valueUpsell.title")}
</p>
<p className="text-xs text-muted-foreground">
{t("subscription.valueUpsell.description")}
</p>
</div>
<Link to="/dashboard/subscription" className="shrink-0" onClick={handleCTAClick}>
<Button size="sm" className="bg-brand text-white hover:bg-brand/90">
{t("subscription.valueUpsell.cta")}
</Button>
</Link>
</div>
<button
type="button"
onClick={handleDismiss}
className="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-brand/10 hover:text-foreground"
aria-label={t("common.close")}
>
<X className="size-4" />
</button>
</section>
);
}

View File

@@ -1,11 +0,0 @@
import { api } from "@/lib/api-client";
import type { ChangelogEntry } from "@sase/shared";
import { useQuery } from "@tanstack/react-query";
export function useChangelog() {
return useQuery({
queryKey: ["changelog"],
queryFn: () => api.get<ChangelogEntry[]>("/changelog"),
staleTime: 30 * 60 * 1000, // 30 min — matches Redis cache TTL
});
}

View File

@@ -0,0 +1,15 @@
import { api } from "@/lib/api-client";
import { useQuery } from "@tanstack/react-query";
// Parça Uzmanları programı kapısı: PostHog ana şalteri + kullanım kriteri
// sunucuda birlikte değerlendirilir; web yalnız sonucu sorar. Kapalıysa
// program yüzeyleri (nav linki, oy kartı, öneri bölümü, uzmanlar sayfası)
// hiç render edilmez — kriterler istemciye sızdırılmaz.
export function useExpertAccess(enabled = true) {
return useQuery({
queryKey: ["expert-access"],
queryFn: () => api.get<{ enabled: boolean }>("/oem-votes/access"),
staleTime: 5 * 60_000,
enabled,
});
}

View File

@@ -0,0 +1,73 @@
import type { Survey } from "posthog-js";
import { describe, expect, it } from "vitest";
import {
buildDismissedProperties,
buildSentProperties,
seenSurveyStorageKey,
surveyInteractionKey,
} from "../surveys";
// The capture payloads must match what posthog-js's own widget sends — the
// Surveys results UI, the internal targeting flags ($survey_dismissed/<id>)
// and cross-device dedup all key off these exact shapes.
function mkSurvey(overrides: Record<string, unknown> = {}): Survey {
return {
id: "sv1",
name: "Test Survey",
type: "api",
questions: [
{ id: "q1", type: "single_choice", question: "Neden?", choices: ["A", "B"] },
{ id: "q2", type: "open", question: "Detay?", optional: true },
],
current_iteration: null,
current_iteration_start_date: null,
...overrides,
} as unknown as Survey;
}
describe("surveyInteractionKey", () => {
it("builds the plain person-property key for one-off surveys", () => {
expect(surveyInteractionKey(mkSurvey(), "dismissed")).toBe("$survey_dismissed/sv1");
});
it("appends the iteration for recurring surveys (matches the internal targeting flag)", () => {
const s = mkSurvey({ current_iteration: 1 });
expect(surveyInteractionKey(s, "responded")).toBe("$survey_responded/sv1/1");
});
});
describe("seenSurveyStorageKey", () => {
it("matches posthog-js seenSurvey_ localStorage convention", () => {
expect(seenSurveyStorageKey(mkSurvey())).toBe("seenSurvey_sv1");
expect(seenSurveyStorageKey(mkSurvey({ current_iteration: 2 }))).toBe("seenSurvey_sv1_2");
});
});
describe("buildSentProperties", () => {
it("keys responses by question id and marks the responded person property", () => {
const props = buildSentProperties(mkSurvey(), { q1: "A", q2: null }, "sub-1");
expect(props.$survey_id).toBe("sv1");
expect(props.$survey_response_q1).toBe("A");
expect(props.$survey_response_q2).toBeNull();
expect(props.$survey_completed).toBe(true);
expect(props.$survey_submission_id).toBe("sub-1");
expect(props.$set).toEqual({ "$survey_responded/sv1": true });
expect(props.$survey_questions).toEqual([
{ id: "q1", question: "Neden?", response: "A" },
{ id: "q2", question: "Detay?", response: null },
]);
});
});
describe("buildDismissedProperties", () => {
it("flags partial completion only when something was answered", () => {
const empty = buildDismissedProperties(mkSurvey(), { q1: null });
expect(empty.$survey_partially_completed).toBe(false);
expect(empty.$set).toEqual({ "$survey_dismissed/sv1": true });
const partial = buildDismissedProperties(mkSurvey(), { q1: "B", q2: null });
expect(partial.$survey_partially_completed).toBe(true);
expect(partial.$survey_response_q1).toBe("B");
});
});

View File

@@ -68,6 +68,15 @@ export function initChatwoot(): void {
_pending = []; _pending = [];
}); });
// Hide the floating launcher bubble on mobile — it covers content on small
// screens. The widget itself stays functional: openChatwoot() (subscription
// page, onboarding modal) still opens it fullscreen with its own close button.
const style = document.createElement("style");
style.id = "chatwoot-mobile-hide";
style.textContent =
"@media (max-width: 767px) { .woot--bubble-holder { display: none !important; } }";
document.head.appendChild(style);
const script = document.createElement("script"); const script = document.createElement("script");
script.id = "chatwoot-sdk"; script.id = "chatwoot-sdk";
script.src = `${BASE_URL}/packs/js/sdk.js`; script.src = `${BASE_URL}/packs/js/sdk.js`;

View File

@@ -1,6 +1,8 @@
// PostHog — lazily loaded so it doesn't land in the initial bundle. // PostHog — lazily loaded so it doesn't land in the initial bundle.
// All exported functions are fire-and-forget; analytics loss on failure is acceptable. // All exported functions are fire-and-forget; analytics loss on failure is acceptable.
import { resetSurveySession, scheduleSurveyCheck, surveysReceiverReady } from "./surveys";
type PostHog = import("posthog-js").PostHog; type PostHog = import("posthog-js").PostHog;
let _ph: PostHog | null = null; let _ph: PostHog | null = null;
@@ -39,11 +41,19 @@ export function initPostHog(): void {
// PII leak. // PII leak.
autocapture: false, autocapture: false,
enable_heatmaps: true, enable_heatmaps: true,
// Staging only: don't drop events from automation UAs (Playwright e2e of
// surveys/flags runs against dev.sase.tr). Prod keeps the bot filter.
opt_out_useragent_filter: window.location.hostname !== "sase.tr",
session_recording: { session_recording: {
maskAllInputs: false, maskAllInputs: false,
maskInputOptions: { password: true }, maskInputOptions: { password: true },
}, },
}); });
// dev.sase.tr now ships with the key too (surveys must be testable on dev
// first) — stamp every event so staging traffic is filterable in analyses.
ph.register({
deploy_env: window.location.hostname === "sase.tr" ? "production" : "staging",
});
}); });
} }
@@ -60,14 +70,22 @@ export function identifyUser(user: {
role: user.role, role: user.role,
}); });
}); });
scheduleSurveyCheck();
} }
export function resetUser(): void { export function resetUser(): void {
load().then((ph) => ph.reset()); load().then((ph) => ph.reset());
resetSurveySession();
} }
export function capture(event: string, properties?: Record<string, unknown>): void { export function capture(event: string, properties?: Record<string, unknown>): void {
load().then((ph) => ph.capture(event, properties)); // Hold captures until the surveys extension's event receiver is hooked (≤3s)
// so event-triggered surveys never miss their trigger, then re-check
// eligibility once the event has actually gone through.
Promise.all([load(), surveysReceiverReady()]).then(([ph]) => {
ph.capture(event, properties);
scheduleSurveyCheck();
});
} }
export function capturePageView(path: string): void { export function capturePageView(path: string): void {
@@ -79,10 +97,12 @@ export function capturePageView(path: string): void {
load().then((ph) => load().then((ph) =>
ph.capture("$pageview", { $current_url: window.location.href, $pathname: path }), ph.capture("$pageview", { $current_url: window.location.href, $pathname: path }),
); );
scheduleSurveyCheck();
} }
export function setPeopleProperties(properties: Record<string, unknown>): void { export function setPeopleProperties(properties: Record<string, unknown>): void {
load().then((ph) => ph.people?.set(properties)); load().then((ph) => ph.people?.set(properties));
scheduleSurveyCheck();
} }
/** /**

View File

@@ -10,9 +10,20 @@
import type { init as SentryInit } from "@sentry/react"; import type { init as SentryInit } from "@sentry/react";
let initialized = false; let initialized = false;
// Set once init succeeds so the catalog-degradation helpers below can use the
// already-loaded SDK without re-awaiting the dynamic import on every call.
let sentryApi: typeof import("@sentry/react") | null = null;
// Cache the init promise so concurrent callers (e.g. a degradation reporter that
// fires on first paint, racing the main.tsx call) await the SAME init instead of
// double-initialising the SDK.
let initPromise: Promise<void> | null = null;
export async function initSentry() { export function initSentry(): Promise<void> {
if (initialized) return; if (!initPromise) initPromise = doInitSentry();
return initPromise;
}
async function doInitSentry(): Promise<void> {
const dsn = import.meta.env.VITE_SENTRY_DSN; const dsn = import.meta.env.VITE_SENTRY_DSN;
if (!dsn) { if (!dsn) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
@@ -21,20 +32,18 @@ export async function initSentry() {
return; return;
} }
// Both dev.sase.tr (staging) and sase.tr (prod) ship a production Vite build // Hostname is authoritative for the two real deployments — sase.tr (prod) and
// (MODE === "production"), so MODE can't separate them. Derive from the host at // dev.sase.tr (staging) — which both ship a production Vite build baking the
// runtime instead, so dev errors stay filterable from prod in Sentry. An // SAME VITE_SENTRY_ENVIRONMENT (compose default "production"), so that var can't
// explicit VITE_SENTRY_ENVIRONMENT still wins. // tell them apart. Use it only as a fallback for local/preview hosts.
const host = typeof window !== "undefined" ? window.location.hostname : ""; const host = typeof window !== "undefined" ? window.location.hostname : "";
const environment = const environment =
(import.meta.env.VITE_SENTRY_ENVIRONMENT as string | undefined) ?? host === "sase.tr" || host === "www.sase.tr"
(host === "sase.tr" || host === "www.sase.tr"
? "production" ? "production"
: host.endsWith("dev.sase.tr") : host.endsWith("dev.sase.tr")
? "staging" ? "staging"
: import.meta.env.DEV : (import.meta.env.VITE_SENTRY_ENVIRONMENT as string | undefined) ||
? "development" (import.meta.env.DEV ? "development" : "staging");
: "staging");
const release = import.meta.env.VITE_SENTRY_RELEASE; const release = import.meta.env.VITE_SENTRY_RELEASE;
try { try {
@@ -46,6 +55,9 @@ export async function initSentry() {
integrations: [ integrations: [
Sentry.browserTracingIntegration(), Sentry.browserTracingIntegration(),
Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }), Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }),
// Contextual in-app feedback (no global floating button — opened from the
// empty/error catalog states via openCatalogFeedback).
Sentry.feedbackIntegration({ autoInject: false, showBranding: false }),
], ],
// Performance: 10% trace sample rate (low volume site, can raise later). // Performance: 10% trace sample rate (low volume site, can raise later).
tracesSampleRate: 0.1, tracesSampleRate: 0.1,
@@ -75,8 +87,99 @@ export async function initSentry() {
denyUrls: [/iabjs:\/\//], denyUrls: [/iabjs:\/\//],
}); });
initialized = true; initialized = true;
sentryApi = Sentry;
console.log("[sentry] browser SDK initialized"); console.log("[sentry] browser SDK initialized");
} catch (err) { } catch (err) {
console.warn("[sentry] init failed:", (err as Error).message); console.warn("[sentry] init failed:", (err as Error).message);
} }
} }
// ─── Catalog UX-degradation reporting (browser) ───
// Two things only the browser can add on top of the backend reporter
// (apps/api/src/common/catalog-degradation.ts): a Session Replay of the user
// hitting the dead-end, and a one-click structured feedback path. Browser events
// are fingerprinted with source "browser" so they form their own "what users
// actually saw" issues (carrying replays), distinct from the server-side
// detections — same kinds, complementary views.
export type CatalogIssueKind = "empty-tree" | "empty-parts" | "drill-load-error";
export interface CatalogIssueContext {
vehicleId?: string;
vehicleLabel?: string;
categoryId?: string;
categoryName?: string;
source?: string;
vin?: string;
}
// Per-session dedup: re-renders or browsing many empty categories shouldn't spam
// the issue stream, and one replay upload already covers the whole journey.
const reportedThisSession = new Set<string>();
let replayFlushedThisSession = false;
const brandOf = (label?: string) => (label?.trim().split(/\s+/)[0] || "unknown").toLowerCase();
/**
* Report the catalog failure the user is currently looking at and flush the
* Session Replay so it's watchable in Sentry. Best-effort; never throws.
*/
export async function reportCatalogDegradation(
kind: CatalogIssueKind,
ctx: CatalogIssueContext,
): Promise<void> {
try {
const dedupKey = `${kind}:${ctx.categoryId ?? ctx.vehicleId ?? ""}`;
if (reportedThisSession.has(dedupKey)) return;
reportedThisSession.add(dedupKey);
// The empty/error state can render before the SDK's dynamic import resolves —
// wait for init so the failure is never silently dropped.
await initSentry();
const Sentry = sentryApi;
if (!Sentry) return;
const brand = brandOf(ctx.vehicleLabel);
Sentry.captureMessage(`catalog degraded: ${kind} (browser/${brand})`, {
level: "warning",
tags: { catalog_degradation: kind, catalog_source: ctx.source ?? "unknown" },
fingerprint: ["catalog-degradation", "browser", kind, brand],
extra: { ...ctx },
});
if (!replayFlushedThisSession) {
replayFlushedThisSession = true;
await Sentry.getReplay()?.flush();
}
} catch {
// telemetry must never break the UI
}
}
/**
* Open the Sentry feedback dialog pre-tagged with the vehicle/category the user
* is reporting from, so "bu araç çalışmadı" becomes a structured report with the
* session replay attached — instead of an email.
*/
export async function openCatalogFeedback(ctx: CatalogIssueContext): Promise<void> {
try {
await initSentry();
const Sentry = sentryApi;
if (!Sentry) return;
Sentry.setTag("catalog_source", ctx.source ?? "unknown");
Sentry.setContext("catalog_issue", { ...ctx });
const feedback = Sentry.getFeedback();
if (!feedback) return;
const form = await feedback.createForm({
formTitle: "Bu araç / parça çalışmadı mı?",
messagePlaceholder:
"Hangi araç ve parça eksik veya yanlış? (şase ve kategori bilgisi otomatik eklenir)",
submitButtonLabel: "Gönder",
cancelButtonLabel: "Vazgeç",
// The session replay + the catalog_issue context above ride along with this.
});
form.appendToDom();
form.open();
} catch {
// feedback is best-effort
}
}

229
apps/web/src/lib/surveys.ts Normal file
View File

@@ -0,0 +1,229 @@
// PostHog surveys in API mode — we render the popover ourselves (zero PostHog
// branding, no PostHog survey DOM), while targeting and analytics stay in
// PostHog: eligibility (event triggers, URL/device rules, wait periods and the
// person-property targeting flags, e.g. subscription_status=active) is
// evaluated by posthog-js inside getActiveMatchingSurveys, and we capture the
// same "survey shown/dismissed/sent" events with the exact property shapes the
// official widget uses, so per-distinct_id dedup and the Surveys results UI
// keep working unchanged.
import type { PostHog, Survey } from "posthog-js";
const ENABLED = Boolean(import.meta.env.VITE_POSTHOG_KEY);
function loadPh(): Promise<PostHog> {
return import("posthog-js").then((m) => m.default);
}
/** Answers keyed by question id; null = question left unanswered. */
export type SurveyResponses = Record<string, string | number | null>;
// ── capture payloads (must mirror posthog-js dist/surveys.js) ────────────────
/** Person-property key the survey's internal targeting flag filters on —
* `$survey_dismissed/<id>` plus `/<iteration>` for recurring surveys. */
export function surveyInteractionKey(survey: Survey, kind: "dismissed" | "responded"): string {
const base = `$survey_${kind}/${survey.id}`;
return survey.current_iteration && survey.current_iteration > 0
? `${base}/${survey.current_iteration}`
: base;
}
/** localStorage key posthog-js checks for client-side "already seen" gating. */
export function seenSurveyStorageKey(survey: Survey): string {
const base = `seenSurvey_${survey.id}`;
return survey.current_iteration && survey.current_iteration > 0
? `${base}_${survey.current_iteration}`
: base;
}
export function surveyResponseKey(questionId: string): string {
return `$survey_response_${questionId}`;
}
function baseProperties(survey: Survey): Record<string, unknown> {
return {
$survey_name: survey.name,
$survey_id: survey.id,
$survey_iteration: survey.current_iteration,
$survey_iteration_start_date: survey.current_iteration_start_date,
};
}
function responseProperties(survey: Survey, responses: SurveyResponses): Record<string, unknown> {
const props: Record<string, unknown> = {};
for (const q of survey.questions) {
if (!q.id) continue;
props[surveyResponseKey(q.id)] = responses[q.id] ?? null;
}
return props;
}
export function buildDismissedProperties(
survey: Survey,
responses: SurveyResponses,
): Record<string, unknown> {
const hasAnyResponse = Object.values(responses).some((v) => v !== null && v !== "");
return {
...baseProperties(survey),
...responseProperties(survey, responses),
$survey_partially_completed: hasAnyResponse,
$set: { [surveyInteractionKey(survey, "dismissed")]: true },
};
}
export function buildSentProperties(
survey: Survey,
responses: SurveyResponses,
submissionId: string,
): Record<string, unknown> {
return {
...baseProperties(survey),
...responseProperties(survey, responses),
$survey_questions: survey.questions.map((q) => ({
id: q.id,
question: q.question,
response: q.id ? (responses[q.id] ?? null) : null,
})),
$survey_submission_id: submissionId,
$survey_completed: true,
$set: { [surveyInteractionKey(survey, "responded")]: true },
};
}
// ── display manager ──────────────────────────────────────────────────────────
let _active: Survey | null = null;
let _shownThisSession = new Set<string>();
const _listeners = new Set<(survey: Survey | null) => void>();
let _checkTimer: ReturnType<typeof setTimeout> | null = null;
let _showTimer: ReturnType<typeof setTimeout> | null = null;
let _wired = false;
function notify(): void {
for (const cb of _listeners) cb(_active);
}
function markSeen(survey: Survey): void {
try {
localStorage.setItem(seenSurveyStorageKey(survey), "true");
} catch {
// localStorage unavailable — the server-side person-property dedup still applies
}
}
function wireOnce(ph: PostHog): void {
if (_wired) return;
_wired = true;
// Re-check when the lazily loaded surveys module becomes ready and whenever
// feature flags re-evaluate (identify, property changes) — both change eligibility.
ph.surveys?.onSurveysLoaded?.(() => scheduleSurveyCheck());
ph.onFeatureFlags(() => scheduleSurveyCheck());
}
function check(ph: PostHog): void {
wireOnce(ph);
if (_active || _showTimer) return;
ph.getActiveMatchingSurveys((surveys) => {
if (_active || _showTimer) return;
const candidate = surveys.find((s) => s.type === "api" && !_shownThisSession.has(s.id));
if (!candidate) return;
const delaySeconds = candidate.appearance?.surveyPopupDelaySeconds ?? 0;
_showTimer = setTimeout(() => {
_showTimer = null;
show(ph, candidate);
}, delaySeconds * 1000);
});
}
function show(ph: PostHog, survey: Survey): void {
if (_active) return;
_shownThisSession.add(survey.id);
_active = survey;
ph.capture("survey shown", baseProperties(survey));
try {
localStorage.setItem("lastSeenSurveyDate", new Date().toISOString());
} catch {
// best effort — only affects cross-survey wait-period spacing
}
notify();
}
let _readyPromise: Promise<void> | null = null;
/** Resolves once posthog-js's surveys extension has installed its capture hook
* (or after a 3s safety timeout). Events captured before that hook exists are
* invisible to event-triggered surveys — the trial survey's
* trial_urgency_banner_viewed fires ~1s into the page load and was racing the
* extension's lazy <script>, so posthog.ts awaits this before every capture. */
export function surveysReceiverReady(): Promise<void> {
if (!ENABLED || typeof window === "undefined") return Promise.resolve();
if (_readyPromise) return _readyPromise;
_readyPromise = new Promise((resolve) => {
setTimeout(resolve, 3000);
loadPh()
.then((ph) => ph.surveys?.onSurveysLoaded?.(() => resolve()))
.catch(() => resolve());
});
return _readyPromise;
}
/** Debounced eligibility check — called after captures, route changes and identify. */
export function scheduleSurveyCheck(): void {
if (!ENABLED || typeof window === "undefined") return;
if (_checkTimer) clearTimeout(_checkTimer);
_checkTimer = setTimeout(() => {
_checkTimer = null;
loadPh()
.then((ph) => check(ph))
.catch(() => {});
}, 700);
}
export function subscribeActiveSurvey(cb: (survey: Survey | null) => void): () => void {
_listeners.add(cb);
cb(_active);
return () => {
_listeners.delete(cb);
};
}
export function dismissActiveSurvey(responses: SurveyResponses): void {
const survey = _active;
if (!survey) return;
_active = null;
notify();
markSeen(survey);
loadPh()
.then((ph) => ph.capture("survey dismissed", buildDismissedProperties(survey, responses)))
.catch(() => {});
}
/** Capture the responses; the popover stays mounted to show its thank-you
* state and calls closeActiveSurvey() when it's done. */
export function submitActiveSurvey(responses: SurveyResponses): void {
const survey = _active;
if (!survey) return;
markSeen(survey);
const submissionId = crypto.randomUUID();
loadPh()
.then((ph) => ph.capture("survey sent", buildSentProperties(survey, responses, submissionId)))
.catch(() => {});
}
export function closeActiveSurvey(): void {
if (!_active) return;
_active = null;
notify();
}
/** Logout — drop in-memory survey state; localStorage/person-property dedup stays. */
export function resetSurveySession(): void {
if (_showTimer) {
clearTimeout(_showTimer);
_showTimer = null;
}
_active = null;
_shownThisSession = new Set();
notify();
}

View File

@@ -74,6 +74,7 @@
"dashboard": "Dashboard", "dashboard": "Dashboard",
"search": "Search", "search": "Search",
"history": "History", "history": "History",
"experts": "Parts Experts",
"catalog": "Catalog", "catalog": "Catalog",
"subscription": "Subscription", "subscription": "Subscription",
"billing": "Payment", "billing": "Payment",
@@ -82,7 +83,6 @@
"logout": "Log Out", "logout": "Log Out",
"contact": "Contact", "contact": "Contact",
"blog": "Blog", "blog": "Blog",
"changelog": "What's New",
"account": "Account", "account": "Account",
"sectionMain": "Main Menu", "sectionMain": "Main Menu",
"sectionAccount": "Account", "sectionAccount": "Account",
@@ -185,7 +185,7 @@
"tabPl24": "Pl24", "tabPl24": "Pl24",
"tabPcat": "Pcat", "tabPcat": "Pcat",
"tabEmex": "Emex", "tabEmex": "Emex",
"tabTecdoc": "Tecdoc", "tabTecdoc": "P",
"comingSoon": "Coming Soon", "comingSoon": "Coming Soon",
"categories": "Categories", "categories": "Categories",
"noCategories": "No categories found", "noCategories": "No categories found",
@@ -413,6 +413,11 @@
"cta": "Upgrade Plan", "cta": "Upgrade Plan",
"progressLabel": "{percent}% of trial used" "progressLabel": "{percent}% of trial used"
}, },
"valueUpsell": {
"title": "Ready to subscribe?",
"description": "You're actively using the platform. Subscribe to keep querying customer vehicles and accessing OEM parts without interruption.",
"cta": "Subscribe"
},
"trialProgress": { "trialProgress": {
"title": "{days} days left in your Full Package trial", "title": "{days} days left in your Full Package trial",
"description": "Pick a plan now to keep your access without interruption.", "description": "Pick a plan now to keep your access without interruption.",
@@ -489,8 +494,7 @@
"refunded": "Refunded" "refunded": "Refunded"
}, },
"methodLabels": { "methodLabels": {
"stripe": "Credit Card", "stripe": "Credit Card"
"eft": "EFT/Wire"
}, },
"summary": { "summary": {
"title": "Current Plan", "title": "Current Plan",
@@ -518,7 +522,6 @@
"connections": "Connections", "connections": "Connections",
"referral": "Referral", "referral": "Referral",
"account": "Account", "account": "Account",
"changelog": "Changelog",
"notifications": "Notifications" "notifications": "Notifications"
}, },
"preferences": { "preferences": {
@@ -589,17 +592,6 @@
"typeConfirm": "Type 'DELETE' to confirm", "typeConfirm": "Type 'DELETE' to confirm",
"confirmWord": "DELETE" "confirmWord": "DELETE"
}, },
"changelog": {
"title": "Changelog",
"description": "Latest platform updates, new features, and bug fixes.",
"emptyTitle": "No updates yet",
"emptyDescription": "Platform updates will appear here soon.",
"changeType": {
"fix": "Fix",
"feature": "New Feature",
"improvement": "Improvement"
}
},
"notifications": { "notifications": {
"title": "Notification preferences", "title": "Notification preferences",
"description": "Choose which lifecycle e-mails you want to receive. Account security and payment notifications keep coming." "description": "Choose which lifecycle e-mails you want to receive. Account security and payment notifications keep coming."
@@ -663,7 +655,7 @@
"s2t": "See the parts", "s2t": "See the parts",
"s2d": "OEM codes, diagrams and compatible parts appear in seconds.", "s2d": "OEM codes, diagrams and compatible parts appear in seconds.",
"s3t": "Pick the right part", "s3t": "Pick the right part",
"s3d": "Compare prices and find the right part in one go — no wrong orders." "s3d": "Copy the right OEM code and order the correct part from your supplier in one go — no wrong orders."
}, },
"faq": { "faq": {
"title": "Frequently asked questions", "title": "Frequently asked questions",
@@ -713,7 +705,6 @@
"previewHint": "Click Decode VIN to access the full parts catalog.", "previewHint": "Click Decode VIN to access the full parts catalog.",
"recent": "Recent searches", "recent": "Recent searches",
"seeAll": "See all →", "seeAll": "See all →",
"openHint": "Click to reopen the vehicle with its parts catalog",
"historyAria": "{brand} {model} — VIN {vin}, open from history", "historyAria": "{brand} {model} — VIN {vin}, open from history",
"errorInvalidVin": "Invalid VIN. Must be 17 characters; I, O, Q are not allowed.", "errorInvalidVin": "Invalid VIN. Must be 17 characters; I, O, Q are not allowed.",
"errorGeneric": "An error occurred. Please try again.", "errorGeneric": "An error occurred. Please try again.",
@@ -738,6 +729,8 @@
"noCategoriesTitle": "No categories for this vehicle", "noCategoriesTitle": "No categories for this vehicle",
"noCategoriesHint": "The vehicle was decoded but the catalog may not be ready yet. Reach out to support to expedite it.", "noCategoriesHint": "The vehicle was decoded but the catalog may not be ready yet. Reach out to support to expedite it.",
"drillHint": "Parts live inside the subcategories — open a category to drill down to its part lists.", "drillHint": "Parts live inside the subcategories — open a category to drill down to its part lists.",
"reportIssue": "Not working? Report it",
"reportIssueDone": "Reported — thanks",
"noAttrs": "No detailed info available for this vehicle.", "noAttrs": "No detailed info available for this vehicle.",
"labelModel": "Model", "labelModel": "Model",
"labelYear": "Model year", "labelYear": "Model year",
@@ -798,7 +791,7 @@
"subtitleA": "Enter the customer vehicle's VIN and find the correct OEM part the first time with cross-catalog verification. No returns, no losses — trusted by 500+ businesses. Try it free.", "subtitleA": "Enter the customer vehicle's VIN and find the correct OEM part the first time with cross-catalog verification. No returns, no losses — trusted by 500+ businesses. Try it free.",
"lossPillB": "Start looking up VINs free — no card required", "lossPillB": "Start looking up VINs free — no card required",
"titleB": "Find the Customer's Correct Part in Seconds", "titleB": "Find the Customer's Correct Part in Seconds",
"subtitleB": "Enter the 17-digit VIN and reach the right OEM codes, part diagrams and prices in seconds. 27 brands, unlimited lookups. Start free — no credit card.", "subtitleB": "Enter the 17-digit VIN and reach the right OEM codes, part diagrams in seconds. 27 brands, unlimited lookups. Start free — no credit card.",
"vinPlaceholder": "Example: WVWZZZ1JZ3W597935", "vinPlaceholder": "Example: WVWZZZ1JZ3W597935",
"searchCta": "Search", "searchCta": "Search",
"decoding": "Decoding...", "decoding": "Decoding...",
@@ -868,7 +861,7 @@
"description": "Cross-references multiple catalogs so OEM codes are always current and correct — no more wrong parts, no more returns.", "description": "Cross-references multiple catalogs so OEM codes are always current and correct — no more wrong parts, no more returns.",
"bullet1": "Multi-catalog cross-referencing", "bullet1": "Multi-catalog cross-referencing",
"bullet2": "Always-current OEM codes", "bullet2": "Always-current OEM codes",
"bullet3": "Price comparison", "bullet3": "Matched via exploded diagrams",
"mockupTitle": "OEM Catalog — Parts List", "mockupTitle": "OEM Catalog — Parts List",
"part1": "AC Compressor", "part1": "AC Compressor",
"part2": "Heater Blower Motor", "part2": "Heater Blower Motor",
@@ -897,9 +890,9 @@
"sase": "Multi-catalog cross-query" "sase": "Multi-catalog cross-query"
}, },
"3": { "3": {
"label": "Price comparison", "label": "Interactive part diagrams",
"manual": "Impossible", "manual": "Paper catalog",
"sase": "On a single screen" "sase": "Clickable diagrams"
}, },
"4": { "4": {
"label": "Monthly time saved", "label": "Monthly time saved",
@@ -1160,7 +1153,7 @@
"items": { "items": {
"0": { "0": {
"q": "Which brands do you support?", "q": "Which brands do you support?",
"a": "We currently support 27 automotive brands and the list keeps growing. Enter your VIN in the search box to see right away whether your vehicle is covered." "a": "We currently support 27 automotive brands and the list keeps growing. Enter a VIN in the search box to see right away whether a vehicle is covered."
}, },
"1": { "1": {
"q": "Is my data safe?", "q": "Is my data safe?",

View File

@@ -74,6 +74,7 @@
"dashboard": "Gösterge Paneli", "dashboard": "Gösterge Paneli",
"search": "Arama", "search": "Arama",
"history": "Geçmiş", "history": "Geçmiş",
"experts": "Parça Uzmanları",
"catalog": "Katalog", "catalog": "Katalog",
"subscription": "Abonelik", "subscription": "Abonelik",
"billing": "Ödeme", "billing": "Ödeme",
@@ -82,7 +83,6 @@
"logout": ıkış Yap", "logout": ıkış Yap",
"contact": "İletişim", "contact": "İletişim",
"blog": "Blog", "blog": "Blog",
"changelog": "Yenilikler",
"account": "Hesap", "account": "Hesap",
"sectionMain": "Ana Menü", "sectionMain": "Ana Menü",
"sectionAccount": "Hesap", "sectionAccount": "Hesap",
@@ -185,7 +185,7 @@
"tabPl24": "Pl24", "tabPl24": "Pl24",
"tabPcat": "Pcat", "tabPcat": "Pcat",
"tabEmex": "Emex", "tabEmex": "Emex",
"tabTecdoc": "Tecdoc", "tabTecdoc": "P",
"comingSoon": "Yakında", "comingSoon": "Yakında",
"categories": "Kategoriler", "categories": "Kategoriler",
"noCategories": "Kategori bulunamadı", "noCategories": "Kategori bulunamadı",
@@ -324,7 +324,7 @@
"brandSearchPlaceholder": "Marka ara — ör. Volkswagen", "brandSearchPlaceholder": "Marka ara — ör. Volkswagen",
"brandSearchNoMatch": "\"{query}\" ile eşleşen marka yok.", "brandSearchNoMatch": "\"{query}\" ile eşleşen marka yok.",
"features": { "features": {
"vinSearch": "Sınırsız VIN arama", "vinSearch": "Sınırsız şase arama",
"partsCatalog": "Parça kataloğu", "partsCatalog": "Parça kataloğu",
"schemaViewer": "Şema görüntüleyici", "schemaViewer": "Şema görüntüleyici",
"prioritySupport": "Öncelikli destek", "prioritySupport": "Öncelikli destek",
@@ -413,6 +413,11 @@
"cta": "Plana Yükselt", "cta": "Plana Yükselt",
"progressLabel": "Denemenin %{percent}'i kullanıldı" "progressLabel": "Denemenin %{percent}'i kullanıldı"
}, },
"valueUpsell": {
"title": "Aboneliğe geçmeye hazır mısın?",
"description": "Platformu aktif kullanıyorsun. Müşteri araçlarını sınırsız sorgulamaya ve OEM parça erişimine kesintisiz devam etmek için aboneliğe geç.",
"cta": "Aboneliğe geç"
},
"trialProgress": { "trialProgress": {
"title": "Full Paket denemende {days} gün kaldı", "title": "Full Paket denemende {days} gün kaldı",
"description": "Kesintisiz devam etmek için planını şimdi seç.", "description": "Kesintisiz devam etmek için planını şimdi seç.",
@@ -489,8 +494,7 @@
"refunded": "İade" "refunded": "İade"
}, },
"methodLabels": { "methodLabels": {
"stripe": "Kredi Kartı", "stripe": "Kredi Kartı"
"eft": "EFT/Havale"
}, },
"summary": { "summary": {
"title": "Mevcut Plan", "title": "Mevcut Plan",
@@ -518,7 +522,6 @@
"connections": "Bağlantılar", "connections": "Bağlantılar",
"referral": "Referans", "referral": "Referans",
"account": "Hesap", "account": "Hesap",
"changelog": "Değişiklik Günlüğü",
"notifications": "Bildirimler" "notifications": "Bildirimler"
}, },
"preferences": { "preferences": {
@@ -589,17 +592,6 @@
"typeConfirm": "Onaylamak için 'SİL' yazın", "typeConfirm": "Onaylamak için 'SİL' yazın",
"confirmWord": "SİL" "confirmWord": "SİL"
}, },
"changelog": {
"title": "Değişiklik Günlüğü",
"description": "Platformdaki son güncellemeler, yeni özellikler ve hata düzeltmeleri.",
"emptyTitle": "Henüz güncelleme yok",
"emptyDescription": "Yakında platform güncellemeleri burada görünecek.",
"changeType": {
"fix": "Düzeltme",
"feature": "Yeni Özellik",
"improvement": "Geliştirme"
}
},
"notifications": { "notifications": {
"title": "Bildirim tercihleri", "title": "Bildirim tercihleri",
"description": "Hangi lifecycle maillerini almak istediğini seç. Hesap güvenliği ve ödeme bildirimleri her zaman gelmeye devam eder." "description": "Hangi lifecycle maillerini almak istediğini seç. Hesap güvenliği ve ödeme bildirimleri her zaman gelmeye devam eder."
@@ -659,11 +651,11 @@
"how": { "how": {
"title": "Nasıl çalışır", "title": "Nasıl çalışır",
"s1t": "Şase numarasını gir", "s1t": "Şase numarasını gir",
"s1d": "17 haneli VIN/şase numarasını yapıştır.", "s1d": "17 haneli şase numarasını yapıştır.",
"s2t": "Parçaları gör", "s2t": "Parçaları gör",
"s2d": "Saniyeler içinde OEM kodları, şemalar ve uyumlu parçalar listelensin.", "s2d": "Saniyeler içinde OEM kodları, şemalar ve uyumlu parçalar listelensin.",
"s3t": "Doğru parçayı seç", "s3t": "Doğru parçayı seç",
"s3d": "Fiyatları karşılaştır, doğru parçayı tek seferde bul — yanlış sipariş yok." "s3d": "Doğru OEM kodunu kopyala, tedarikçine tek seferde doğru parçayı sipariş et — yanlış sipariş yok."
}, },
"faq": { "faq": {
"title": "Sık sorulan sorular", "title": "Sık sorulan sorular",
@@ -713,7 +705,6 @@
"previewHint": "Şase Çöz butonuna tıklayarak tam parça kataloğuna erişin.", "previewHint": "Şase Çöz butonuna tıklayarak tam parça kataloğuna erişin.",
"recent": "Son Aramalar", "recent": "Son Aramalar",
"seeAll": "Tümünü Gör →", "seeAll": "Tümünü Gör →",
"openHint": "Aracı parça kataloğuyla birlikte yeniden açmak için tıklayın",
"historyAria": "{brand} {model} — şase {vin}, geçmişten aç", "historyAria": "{brand} {model} — şase {vin}, geçmişten aç",
"errorInvalidVin": "Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.", "errorInvalidVin": "Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.",
"errorGeneric": "Bir hata oluştu. Lütfen tekrar deneyin.", "errorGeneric": "Bir hata oluştu. Lütfen tekrar deneyin.",
@@ -738,6 +729,8 @@
"noCategoriesTitle": "Bu araç için kategori bulunamadı", "noCategoriesTitle": "Bu araç için kategori bulunamadı",
"noCategoriesHint": "Araç decode edildi ama parça kataloğu henüz hazırlanmamış olabilir. Destek ekibimize bildirirseniz katalog hızlandırılır.", "noCategoriesHint": "Araç decode edildi ama parça kataloğu henüz hazırlanmamış olabilir. Destek ekibimize bildirirseniz katalog hızlandırılır.",
"drillHint": "Parçalar alt kategorilerin içinde yer alır. Bir kategoriye girerek ilerleyin — her grup, içindeki parça listesine kadar açılır.", "drillHint": "Parçalar alt kategorilerin içinde yer alır. Bir kategoriye girerek ilerleyin — her grup, içindeki parça listesine kadar açılır.",
"reportIssue": "Çalışmadı mı? Bildir",
"reportIssueDone": "Bildirildi — teşekkürler",
"noAttrs": "Bu araç için ayrıntı bilgisi bulunamadı.", "noAttrs": "Bu araç için ayrıntı bilgisi bulunamadı.",
"labelModel": "Model", "labelModel": "Model",
"labelYear": "Model yılı", "labelYear": "Model yılı",
@@ -770,7 +763,7 @@
"meta": { "meta": {
"title": "Şase Numarası Sorgulama & OEM Parça Kataloğu | Sase.tr", "title": "Şase Numarası Sorgulama & OEM Parça Kataloğu | Sase.tr",
"description": "Araç şase numarasını (VIN) girin, saniyeler içinde OEM parça kodlarına ulaşın. 27 marka, 1M+ parça. Yanlış sipariş yapmadan doğru parçayı bulun.", "description": "Araç şase numarasını (VIN) girin, saniyeler içinde OEM parça kodlarına ulaşın. 27 marka, 1M+ parça. Yanlış sipariş yapmadan doğru parçayı bulun.",
"schemaOrgDescription": "Türkiye'nin VIN/şase numarası sorgulama ve OEM yedek parça kataloğu platformu.", "schemaOrgDescription": "Türkiye'nin şase numarası sorgulama ve OEM yedek parça kataloğu platformu.",
"schemaSoftwareDescription": "Araç şase numarası ile OEM yedek parça sorgulama platformu." "schemaSoftwareDescription": "Araç şase numarası ile OEM yedek parça sorgulama platformu."
}, },
"nav": { "nav": {
@@ -798,7 +791,7 @@
"subtitleA": "Müşteri aracının şasesini girin; çapraz katalog doğrulamasıyla doğru OEM parçasını ilk seferde bulun. İade yok, kayıp yok — 500+ işletme güveniyor. Ücretsiz deneyin.", "subtitleA": "Müşteri aracının şasesini girin; çapraz katalog doğrulamasıyla doğru OEM parçasını ilk seferde bulun. İade yok, kayıp yok — 500+ işletme güveniyor. Ücretsiz deneyin.",
"lossPillB": "Şase sorgulamaya ücretsiz başlayın — kart gerekmez", "lossPillB": "Şase sorgulamaya ücretsiz başlayın — kart gerekmez",
"titleB": "Müşteri Aracının Doğru Parçasını Saniyede Bulun", "titleB": "Müşteri Aracının Doğru Parçasını Saniyede Bulun",
"subtitleB": "17 haneli şaseyi girin; doğru OEM kodlarına, parça şemalarına ve fiyatlara saniyeler içinde ulaşın. 27 marka, sınırsız sorgu. Ücretsiz başlayın, kredi kartı istemiyoruz.", "subtitleB": "17 haneli şaseyi girin; doğru OEM kodlarına, parça şemalarına saniyeler içinde ulaşın. 27 marka, sınırsız sorgu. Ücretsiz başlayın, kredi kartı istemiyoruz.",
"vinPlaceholder": "Örnek: WVWZZZ1JZ3W597935", "vinPlaceholder": "Örnek: WVWZZZ1JZ3W597935",
"searchCta": "Ara", "searchCta": "Ara",
"decoding": "Çözülüyor...", "decoding": "Çözülüyor...",
@@ -868,7 +861,7 @@
"description": "Birden fazla katalogda çapraz sorgulama yaparak her zaman en güncel ve doğru OEM kodlarını sunar — yanlış parça, iade derdi tarih olur.", "description": "Birden fazla katalogda çapraz sorgulama yaparak her zaman en güncel ve doğru OEM kodlarını sunar — yanlış parça, iade derdi tarih olur.",
"bullet1": "Çoklu katalog çapraz sorgulama", "bullet1": "Çoklu katalog çapraz sorgulama",
"bullet2": "Her zaman güncel OEM kodları", "bullet2": "Her zaman güncel OEM kodları",
"bullet3": "Fiyat karşılaştırma", "bullet3": "Patlamış parça şemasıyla eşleştirme",
"mockupTitle": "OEM Katalog — Parça Listesi", "mockupTitle": "OEM Katalog — Parça Listesi",
"part1": "Klima Kompresörü", "part1": "Klima Kompresörü",
"part2": "Kalorifer Motoru", "part2": "Kalorifer Motoru",
@@ -897,9 +890,9 @@
"sase": "Çoklu katalog çapraz sorgu" "sase": "Çoklu katalog çapraz sorgu"
}, },
"3": { "3": {
"label": "Fiyat karşılaştırma", "label": "İnteraktif parça şeması",
"manual": "İmkansız", "manual": "Kağıt katalog",
"sase": "Tek ekranda" "sase": "Tıklanabilir diyagram"
}, },
"4": { "4": {
"label": "Aylık zaman tasarrufu", "label": "Aylık zaman tasarrufu",
@@ -1047,14 +1040,14 @@
"ecommerce": { "ecommerce": {
"badge": "E-Ticaret Entegrasyonu", "badge": "E-Ticaret Entegrasyonu",
"title": "Kendi Sitenize Şase Çözme Gücü Katın", "title": "Kendi Sitenize Şase Çözme Gücü Katın",
"subtitle": "Müşterileriniz yanlış parça sipariş edip iade mi açıyor? Sase.tr entegrasyonu ile VIN bazlı filtreleme ekleyin — iadeler düşsün, dönüşüm artsın.", "subtitle": "Müşterileriniz yanlış parça sipariş edip iade mi açıyor? Sase.tr entegrasyonu ile şase bazlı filtreleme ekleyin — iadeler düşsün, dönüşüm artsın.",
"frameTitle": "otoyedekparca.co — Sase.tr Entegrasyonu", "frameTitle": "otoyedekparca.co — Sase.tr Entegrasyonu",
"loading": "Yükleniyor…", "loading": "Yükleniyor…",
"easyBadge": "Kolay Entegrasyon", "easyBadge": "Kolay Entegrasyon",
"cardTitle": "Birkaç Satır Kod, Büyük Fark", "cardTitle": "Birkaç Satır Kod, Büyük Fark",
"cardBody": "E-ticaret sitenize Sase.tr'nin VIN çözme motorunu entegre edin. Müşterileriniz şase numarasını girsin — sadece araçlarına uygun parçalar listelensin.", "cardBody": "E-ticaret sitenize Sase.tr'nin şase çözme motorunu entegre edin. Müşterileriniz şase numarasını girsin — sadece araçlarına uygun parçalar listelensin.",
"bullet1": "API & Widget — Birkaç satır kodla sitenize entegre edin", "bullet1": "API & Widget — Birkaç satır kodla sitenize entegre edin",
"bullet2": "VIN Bazlı Filtreleme — Sadece uyumlu parçalar görünsün", "bullet2": "Şase Bazlı Filtreleme — Sadece uyumlu parçalar görünsün",
"bullet3": "İade Oranını Düşürün — Doğru parça, ilk seferde", "bullet3": "İade Oranını Düşürün — Doğru parça, ilk seferde",
"bullet4": "White-Label — Widget sitenizin tasarımına uyum sağlar", "bullet4": "White-Label — Widget sitenizin tasarımına uyum sağlar",
"cta": "İletişime Geçin", "cta": "İletişime Geçin",
@@ -1090,7 +1083,7 @@
"2": { "2": {
"name": "Özge D.", "name": "Özge D.",
"role": "E-Ticaret Yöneticisi", "role": "E-Ticaret Yöneticisi",
"text": "Online mağazamıza VIN entegrasyonu ekledik. Müşteriler şase numarasını giriyor, sadece uyumlu parçalar listeleniyor. İade oranımız %42 düştü." "text": "Online mağazamıza şase entegrasyonu ekledik. Müşteriler şase numarasını giriyor, sadece uyumlu parçalar listeleniyor. İade oranımız %42 düştü."
}, },
"3": { "3": {
"name": "Burak T.", "name": "Burak T.",
@@ -1113,7 +1106,7 @@
"name": "1 Marka", "name": "1 Marka",
"description": "Tek marka için erişim", "description": "Tek marka için erişim",
"feature1": "1 marka seçimi", "feature1": "1 marka seçimi",
"feature2": "Sınırsız VIN arama", "feature2": "Sınırsız şase arama",
"feature3": "Parça kataloğu", "feature3": "Parça kataloğu",
"feature4": "Şema görüntüleyici" "feature4": "Şema görüntüleyici"
}, },
@@ -1121,7 +1114,7 @@
"name": "2 Marka", "name": "2 Marka",
"description": "İki farklı marka", "description": "İki farklı marka",
"feature1": "2 marka seçimi", "feature1": "2 marka seçimi",
"feature2": "Sınırsız VIN arama", "feature2": "Sınırsız şase arama",
"feature3": "Parça kataloğu", "feature3": "Parça kataloğu",
"feature4": "Öncelikli destek" "feature4": "Öncelikli destek"
}, },
@@ -1129,7 +1122,7 @@
"name": "3 Marka", "name": "3 Marka",
"description": "Üç marka kapsamlı", "description": "Üç marka kapsamlı",
"feature1": "3 marka seçimi", "feature1": "3 marka seçimi",
"feature2": "Sınırsız VIN arama", "feature2": "Sınırsız şase arama",
"feature3": "Parça kataloğu", "feature3": "Parça kataloğu",
"feature4": "Öncelikli destek" "feature4": "Öncelikli destek"
} }
@@ -1144,7 +1137,7 @@
"ctaFineprint": "Kart bilgisi gerekmez. İstediğin zaman iptal et.", "ctaFineprint": "Kart bilgisi gerekmez. İstediğin zaman iptal et.",
"included": "Pakete dahil", "included": "Pakete dahil",
"feature1": "Tüm 27 markaya erişim", "feature1": "Tüm 27 markaya erişim",
"feature2": "Sınırsız VIN arama", "feature2": "Sınırsız şase arama",
"feature3": "OEM parça kataloğu", "feature3": "OEM parça kataloğu",
"feature4": "İnteraktif şema görüntüleyici", "feature4": "İnteraktif şema görüntüleyici",
"feature5": "Geçmiş sorgular & favoriler", "feature5": "Geçmiş sorgular & favoriler",
@@ -1160,7 +1153,7 @@
"items": { "items": {
"0": { "0": {
"q": "Hangi markaları destekliyorsunuz?", "q": "Hangi markaları destekliyorsunuz?",
"a": "Şu an 27 otomobil markası destekleniyor ve liste sürekli genişliyor. Şase numaranızı arama kutusuna girerek aracınızın desteklenip desteklenmediğini hemen görebilirsiniz." "a": "Şu an 27 otomobil markası destekleniyor ve liste sürekli genişliyor. Şase numarasını arama kutusuna girerek bir aracın desteklenip desteklenmediğini hemen görebilirsiniz."
}, },
"1": { "1": {
"q": "Verilerim güvende mi?", "q": "Verilerim güvende mi?",

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import { InAppEscape } from "@/components/in-app-escape"; import { InAppEscape } from "@/components/in-app-escape";
import { SurveyPopover } from "@/components/survey-popover";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { resetChatwootUser, setChatwootUser } from "@/lib/chatwoot"; import { resetChatwootUser, setChatwootUser } from "@/lib/chatwoot";
@@ -156,6 +157,7 @@ function RootComponent() {
</a> </a>
<InAppEscape /> <InAppEscape />
<Outlet /> <Outlet />
<SurveyPopover />
<Toaster position="top-center" /> <Toaster position="top-center" />
</> </>
); );

View File

@@ -1,5 +1,5 @@
import { usePageMeta } from "@/hooks/use-page-meta";
import { SiteHeader } from "@/components/site-header"; import { SiteHeader } from "@/components/site-header";
import { usePageMeta } from "@/hooks/use-page-meta";
import { Button } from "@sase/ui"; import { Button } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router"; import { Link, createFileRoute } from "@tanstack/react-router";
@@ -11,7 +11,7 @@ function AboutPage() {
usePageMeta({ usePageMeta({
title: "Hakkımızda — Sase.tr | Türkiye'nin Şase Sorgulama Platformu", title: "Hakkımızda — Sase.tr | Türkiye'nin Şase Sorgulama Platformu",
description: description:
"Sase.tr, Türkiye yedek parça sektörüne yönelik VIN/şase numarası sorgulama ve OEM parça kataloğu platformudur.", "Sase.tr, Türkiye yedek parça sektörüne yönelik şase numarası sorgulama ve OEM parça kataloğu platformudur.",
canonical: "https://sase.tr/about", canonical: "https://sase.tr/about",
}); });
@@ -25,21 +25,21 @@ function AboutPage() {
<div className="mt-8 space-y-6 text-muted-foreground leading-relaxed"> <div className="mt-8 space-y-6 text-muted-foreground leading-relaxed">
<p> <p>
Sase.tr, Türkiye'nin yedek parça sektörüne yönelik geliştirilen dijital bir platformdur. Sase.tr, Türkiye'nin yedek parça sektörüne yönelik geliştirilen dijital bir platformdur.
Şase numarası (VIN) ile araç tanımlama, orijinal parça kataloğuna erişim ve interaktif Şase numarası ile araç tanımlama, orijinal parça kataloğuna erişim ve interaktif şema
şema görüntüleme hizmetlerini tek bir çatı altında sunar. görüntüleme hizmetlerini tek bir çatı altında sunar.
</p> </p>
<h2 className="text-2xl font-semibold text-foreground">Misyonumuz</h2> <h2 className="text-2xl font-semibold text-foreground">Misyonumuz</h2>
<p> <p>
Yedek parça arama sürecini hızlandırmak, doğru parçaya ilk seferde ulaşmayı sağlamak ve Yedek parça arama sürecini hızlandırmak, doğru parçaya ilk seferde ulaşmayı sağlamak ve
sektördeki bilgi asimetrisini ortadan kaldırmak. Oto yedek parçacılar, servisler ve sektördeki bilgi asimetrisini ortadan kaldırmak. Oto yedek parçacılar ve servisler için
bireysel kullanıcılar için güvenilir bir referans noktası olmayı hedefliyoruz. güvenilir bir referans noktası olmayı hedefliyoruz.
</p> </p>
<h2 className="text-2xl font-semibold text-foreground">Ne Yapıyoruz?</h2> <h2 className="text-2xl font-semibold text-foreground">Ne Yapıyoruz?</h2>
<ul className="list-disc space-y-2 pl-6"> <ul className="list-disc space-y-2 pl-6">
<li> <li>
<strong>Şase Çözme:</strong> VIN numarası ile aracın marka, model, yıl, motor tipi ve <strong>Şase Çözme:</strong> Şase numarası ile aracın marka, model, yıl, motor tipi ve
donanım bilgilerine anında erişim. donanım bilgilerine anında erişim.
</li> </li>
<li> <li>

View File

@@ -1,97 +1,18 @@
import { usePageMeta } from "@/hooks/use-page-meta"; import { BlogListContent } from "@/components/blog-content";
import { SiteHeader } from "@/components/site-header"; import { SiteHeader } from "@/components/site-header";
import { useBlogPosts } from "@/hooks/use-blog"; import { createFileRoute } from "@tanstack/react-router";
import { Button } from "@sase/ui";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/blog")({ export const Route = createFileRoute("/blog")({
component: BlogPage, component: BlogPage,
}); });
type PostCard = { title: string; description: string; date: string; slug: string };
// Original hand-authored posts. Kept so they never disappear; auto-published
// posts from the content pipeline are merged in from the API below.
const staticPosts: PostCard[] = [
{
title: "Şase Numarası (VIN) Nedir? Nasıl Okunur?",
description:
"17 haneli VIN kodunun her bir bölümünün ne anlama geldiğini, araç geçmişini nasıl ortaya çıkarabileceğinizi adım adım anlatıyoruz.",
date: "2026-02-10",
slug: "vin-nedir-nasil-okunur",
},
{
title: "Orijinal Parça mı, Muadil Parça mı?",
description:
"OEM ve aftermarket parçalar arasındaki farkları, avantaj ve dezavantajlarını karşılaştırmalı olarak inceliyoruz.",
date: "2026-01-28",
slug: "orijinal-mi-muadil-mi",
},
{
title: "Yedek Parça Aramada Dijital Dönüşüm",
description:
"Geleneksel katalog yöntemlerinden dijital platformlara geçiş sürecini ve sektöre etkilerini ele alıyoruz.",
date: "2026-01-15",
slug: "dijital-donusum",
},
{
title: "Sase.tr ile Doğru Parçayı İlk Seferde Bulun",
description:
"Platform özelliklerini kullanarak VIN bazlı arama, şema görüntüleme ve parça eşleştirme rehberi.",
date: "2026-01-05",
slug: "dogru-parcayi-bulun",
},
];
function BlogPage() { function BlogPage() {
usePageMeta({
title: "Blog — Sase.tr | Şase & Yedek Parça Rehberi",
description: "Şase numarası okuma, OEM vs muadil parça, dijital dönüşüm ve daha fazlası.",
canonical: "https://sase.tr/blog",
});
const { data: apiPosts } = useBlogPosts();
// Merge static + API posts, dedupe by slug (static wins), newest first.
const bySlug = new Map<string, PostCard>();
for (const p of staticPosts) bySlug.set(p.slug, p);
for (const p of apiPosts ?? []) {
if (bySlug.has(p.slug)) continue;
bySlug.set(p.slug, {
slug: p.slug,
title: p.title,
description: p.metaDescription ?? "",
date: p.publishedAt.slice(0, 10),
});
}
const posts = [...bySlug.values()].sort((a, b) => b.date.localeCompare(a.date));
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<SiteHeader /> <SiteHeader />
<main className="container mx-auto max-w-4xl px-4 py-16"> <main className="container mx-auto max-w-4xl px-4 py-16">
<h1 className="text-4xl font-bold">Blog</h1> <BlogListContent postLinkTo="/blog/$slug" />
<p className="mt-4 text-lg text-muted-foreground">
Yedek parça sektörü, araç bakımı ve Sase.tr hakkında güncel yazılar.
</p>
<div className="mt-12 grid gap-6 sm:grid-cols-2">
{posts.map((post) => (
<Link key={post.slug} to="/blog/$slug" params={{ slug: post.slug }}>
<Card className="h-full transition hover:border-foreground/20">
<CardHeader>
<CardDescription>{post.date}</CardDescription>
<CardTitle className="text-lg">{post.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{post.description}</p>
</CardContent>
</Card>
</Link>
))}
</div>
</main> </main>
</div> </div>
); );

View File

@@ -1,381 +1,6 @@
import { BlogPostContent } from "@/components/blog-content";
import { SiteHeader } from "@/components/site-header"; import { SiteHeader } from "@/components/site-header";
import { useBlogPost } from "@/hooks/use-blog"; import { createFileRoute } from "@tanstack/react-router";
import { usePageMeta } from "@/hooks/use-page-meta";
import { Button } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
// ─── BLOG POST DATA ───────────────────────────────────────────────────────────
interface BlogPost {
slug: string;
title: string;
description: string;
date: string;
content: React.ReactNode;
}
const POSTS: Record<string, BlogPost> = {
"vin-nedir-nasil-okunur": {
slug: "vin-nedir-nasil-okunur",
title: "Şase Numarası (VIN) Nedir? Nasıl Okunur?",
description:
"17 haneli VIN kodunun her bir bölümünün ne anlama geldiğini, araç geçmişini nasıl ortaya çıkarabileceğinizi adım adım anlatıyoruz.",
date: "2026-02-10",
content: (
<div className="space-y-6 leading-relaxed text-muted-foreground">
<p>
VIN (Vehicle Identification Number), yani Araç Tanımlama Numarası, her motorlu taşıta
üretim aşamasında atanan 17 karakterlik benzersiz bir koddur. Türkiye'de "şase numarası"
olarak da bilinen bu kod, aracın üretiminden imhasına kadar tüm yaşam döngüsünü takip
etmeye yarar.
</p>
<h2 className="text-xl font-semibold text-foreground">VIN Nereden Okunur?</h2>
<p>
VIN numarasına birçok yerden ulaşabilirsiniz: ön camın sol alt köşesindeki plaka, sürücü
kapısının iç kısmındaki etiket, motor bölmesi veya araç ruhsatı ve fatura bunların başında
gelir. Bazı araçlarda bagaj kapısı iç kısmında da bulunur.
</p>
<h2 className="text-xl font-semibold text-foreground">17 Karakterin Anlamı</h2>
<p>VIN üç ana bölüme ayrılır:</p>
<ul className="list-disc space-y-2 pl-6">
<li>
<strong className="text-foreground">
WMI (1-3. karakterler) — Dünya Üretici Kodu:
</strong>{" "}
Aracın hangi ülkede ve hangi fabrikada üretildiğini gösterir. Örneğin "WVW" Volkswagen
Almanya, "ZFA" Fiat İtalya demektir.
</li>
<li>
<strong className="text-foreground">
VDS (4-9. karakterler) — Araç Tanımlayıcı Bölüm:
</strong>{" "}
Model, kasa tipi, motor hacmi, yakıt türü ve güvenlik donanımları hakkında bilgi içerir.
9. karakter her zaman kontrol karakteridir ve matematiksel bir doğrulama amacı taşır.
</li>
<li>
<strong className="text-foreground">
VIS (10-17. karakterler) — Araç Tanımlama Bölümü:
</strong>{" "}
Model yılı (10. karakter), üretim fabrikası (11. karakter) ve üretim sıra numarası
(12-17. karakterler) bilgilerini içerir.
</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">Örnek: WVWZZZ1JZ3W597935</h2>
<p>
Bu VIN'i inceleyelim: <strong className="font-mono text-foreground">WVW</strong>
Volkswagen Almanya, <strong className="font-mono text-foreground">ZZZ</strong> pazar
tanımlayıcı, <strong className="font-mono text-foreground">1J</strong> Golf modeli,{" "}
<strong className="font-mono text-foreground">Z</strong> motor tipi,{" "}
<strong className="font-mono text-foreground">3</strong> model yılı 2003,{" "}
<strong className="font-mono text-foreground">W</strong> Wolfsburg fabrikası,{" "}
<strong className="font-mono text-foreground">597935</strong> sıra numarası.
</p>
<h2 className="text-xl font-semibold text-foreground">VIN ile Ne Öğrenebilirsiniz?</h2>
<ul className="list-disc space-y-2 pl-6">
<li>Aracın üretim yılı ve fabrikası</li>
<li>Motor hacmi, yakıt türü ve şanzıman tipi</li>
<li>Kasa tipi ve donanım paketi</li>
<li>Kaza ve hasar geçmişi (resmi kayıtlarda)</li>
<li>Kilometre manipülasyonu şüphesi</li>
<li>Orijinal parça uyumluluğu</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">VIN ile Doğru Parça Bulma</h2>
<p>
VIN'in en kritik kullanım alanlarından biri yedek parça aramadır. Aynı model arabada bile
üretim yılı, motor tipi veya donanım paketine göre farklı parçalar kullanılmış olabilir.
VIN numarasıyla sorgulama yaparak yalnızca aracınıza tam uyumlu OEM parçalara ulaşabilir,
yanlış parça sipariş etme riskini sıfıra indirebilirsiniz.
</p>
<p>
Sase.tr platformunda VIN numaranızı girerek saniyeler içinde aracınızın tam teknik
bilgilerine ve orijinal parça kataloğuna erişebilirsiniz. 50'den fazla markayı destekleyen
platformumuzla yanlış parça siparişlerine son verin.
</p>
</div>
),
},
"orijinal-mi-muadil-mi": {
slug: "orijinal-mi-muadil-mi",
title: "Orijinal Parça mı, Muadil Parça mı?",
description:
"OEM ve aftermarket parçalar arasındaki farkları, avantaj ve dezavantajlarını karşılaştırmalı olarak inceliyoruz.",
date: "2026-01-28",
content: (
<div className="space-y-6 leading-relaxed text-muted-foreground">
<p>
Araç bakımı ve onarımında en sık sorulan sorulardan biri şudur: "Orijinal mi alsam, muadil
mi?" Bu soru, hem maliyet hem de güvenlik ısından kritik öneme sahiptir. İki seçeneği
tüm boyutlarıyla inceleyelim.
</p>
<h2 className="text-xl font-semibold text-foreground">OEM Parça Nedir?</h2>
<p>
OEM (Original Equipment Manufacturer), aracın üretiminde kullanılan ya da araç
üreticisinin onayladığı orijinal parçalardır. Bu parçalar araç fabrikasında kullanılan
parçalarla aynı spesifikasyonlara sahiptir ve genellikle aynı tedarikçilerden gelir.
Üzerinde araç markasının logosu bulunabilir ya da yalnızca parça numarasıyla satılabilir.
</p>
<h2 className="text-xl font-semibold text-foreground">Aftermarket (Muadil) Parça Nedir?</h2>
<p>
Muadil parçalar, araç üreticisinden bağımsız üçüncü taraf firmalar tarafından üretilir.
Kalite seviyeleri geniş bir yelpazede değişir: premium muadil parçalar OEM kalitesine
yaklaşırken, düşük kaliteli kopya ürünler araç güvenliğini tehlikeye atabilir.
</p>
<h2 className="text-xl font-semibold text-foreground">OEM Parçaların Avantajları</h2>
<ul className="list-disc space-y-2 pl-6">
<li>Araca tam uyum garantisi montaj sorunu yaşanmaz</li>
<li>Fabrika spesifikasyonlarına uygunluk</li>
<li>Araç garantisini etkilemez</li>
<li>Standart kalite tutarlılığı</li>
<li>Orijinal parça numarasıyla kolay temin</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">OEM Parçaların Dezavantajları</h2>
<ul className="list-disc space-y-2 pl-6">
<li>Genellikle muadile göre %20-50 daha pahalı</li>
<li>Her markada ve her bölgede stok bulunmayabilir</li>
<li>Bazı modeller için yüksek bekleme süresi</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">Muadil Parçaların Avantajları</h2>
<ul className="list-disc space-y-2 pl-6">
<li>Genellikle daha düşük fiyat</li>
<li>Daha kolay erişilebilirlik</li>
<li>Bazı premium muadil markalar üstün performans sunar</li>
<li>Üretimi durdurulmuş eski araçlarda tek seçenek olabilir</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">Muadil Parçaların Riskleri</h2>
<ul className="list-disc space-y-2 pl-6">
<li>Kalite tutarsızlığı marka ve fiyata göre büyük farklar</li>
<li>Uyumsuzluk riski görünürde aynı ama teknik farklılıklar olabilir</li>
<li>Araç garantisini geçersiz kılabilir</li>
<li>Güvenlik sistemlerini olumsuz etkileyebilir</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">
Hangi Durumlarda Orijinal, Hangisinde Muadil?
</h2>
<p>
Fren sistemi, hava yastığı, direksiyon ve motor parçaları gibi güvenlik kritik
bileşenlerde kesinlikle OEM tercih edilmelidir. Kaporta, döşeme veya aksesuar
niteliğindeki parçalarda güvenilir bir muadil marka değerlendirilebilir.
</p>
<h2 className="text-xl font-semibold text-foreground">Doğru Parçayı Nasıl Bulursunuz?</h2>
<p>
İster OEM ister muadil tercih edin, en önemli adım doğru OEM parça numarasını bilmektir.
Sase.tr ile araç VIN numaranızdan yola çıkarak orijinal parça kodlarına ulaşın. Bu kodu
elinizde bulundurmak, hem servisle hem de tedarikçiyle iletişimi kolaylaştırır, yanlış
parça siparişini önler.
</p>
</div>
),
},
"dijital-donusum": {
slug: "dijital-donusum",
title: "Yedek Parça Aramada Dijital Dönüşüm",
description:
"Geleneksel katalog yöntemlerinden dijital platformlara geçiş sürecini ve sektöre etkilerini ele alıyoruz.",
date: "2026-01-15",
content: (
<div className="space-y-6 leading-relaxed text-muted-foreground">
<p>
Türkiye'nin otomotiv yedek parça sektörü onlarca yıldır basılı kataloglar, telefon
görüşmeleri ve tecrübeye dayalı bilgiyle ayakta durdu. Ancak 2020'lerin ortasına
gelindiğinde bu yöntemler artık yetersiz kalmaktadır. Dijital dönüşüm, sektörde kaçınılmaz
bir zorunluluk hâline gelmiştir.
</p>
<h2 className="text-xl font-semibold text-foreground">Geleneksel Yöntemlerin Sorunları</h2>
<ul className="list-disc space-y-2 pl-6">
<li>
<strong className="text-foreground">Zaman kaybı:</strong> Tek bir parça için birden
fazla katalog taramak ortalama 15-20 dakika sürebilir.
</li>
<li>
<strong className="text-foreground">Hata oranı:</strong> Elle yapılan arama ve
karşılaştırma işlemlerinde yanlış parça sipariş riski yüksektir.
</li>
<li>
<strong className="text-foreground">Güncellik sorunu:</strong> Basılı kataloglar yeni
model güncellemelerini yavaş takip eder.
</li>
<li>
<strong className="text-foreground">Ölçeklenemezlik:</strong> Büyüyen filo veya artan
müşteri sayısıyla geleneksel yöntemler çökmektedir.
</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">Dijital Dönüşümün Faydaları</h2>
<p>Dijital platformlar, yedek parça arama sürecini kökten değiştirmektedir:</p>
<ul className="list-disc space-y-2 pl-6">
<li>VIN bazlı anlık araç tanımlama saniyeler içinde doğru araç tespiti</li>
<li>Çoklu katalog çapraz sorgulama tek arayüzde birden fazla kaynak</li>
<li>Otomatik parça eşleştirme insan hatasını minimuma indirir</li>
<li>Gerçek zamanlı fiyat karşılaştırma</li>
<li>Sipariş geçmişi ve parça takibi</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">Sektörde Sayısal Dönüşüm</h2>
<p>Dijital platforma geçiş yapan işletmelerin deneyimlerine göre:</p>
<ul className="list-disc space-y-2 pl-6">
<li>Parça arama süresi ortalama %85 kısalmaktadır</li>
<li>Yanlış parça iade oranları %60-80 düşmektedir</li>
<li>Müşteri memnuniyeti belirgin biçimde artmaktadır</li>
<li>Personel kapasitesi daha katma değerli işlere yönlendirilebilmektedir</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">
Küçük ve Orta Ölçekli İşletmeler İçin Fırsatlar
</h2>
<p>
Dijital dönüşüm artık yalnızca büyük zincirlerin ayrıcalığı değildir. Aylık sabit
maliyetli abonelik modelleri sayesinde küçük oto yedek parçacılar ve servisler de kurumsal
araçlara erişebilmektedir. Bu durum, rekabet eşitliğini kısmen sağlamaktadır.
</p>
<h2 className="text-xl font-semibold text-foreground">Sase.tr'nin Rolü</h2>
<p>
Sase.tr, Türkiye'nin yedek parça sektörüne özgü geliştirilen bu dijital dönüşümün
öncüsüdür. VIN/şase numarası sorgulama, çoklu katalog entegrasyonu ve interaktif şema
görüntüleme özellikleriyle geleneksel yapış biçimlerini dönüştürmektedir. Platform, 27
marka ve 1 milyondan fazla OEM parça numarasıyla sektörün en kapsamlı dijital kataloğunu
sunmaktadır.
</p>
</div>
),
},
"dogru-parcayi-bulun": {
slug: "dogru-parcayi-bulun",
title: "Sase.tr ile Doğru Parçayı İlk Seferde Bulun",
description:
"Platform özelliklerini kullanarak VIN bazlı arama, şema görüntüleme ve parça eşleştirme rehberi.",
date: "2026-01-05",
content: (
<div className="space-y-6 leading-relaxed text-muted-foreground">
<p>
Yanlış parça sipariş etmek hem zaman hem para kaybettirdiği gibi, müşteri memnuniyetini de
olumsuz etkiler. Sase.tr, bu sorunu kökten çözmek için tasarlanmıştır. Bu rehberde
platformu nasıl en verimli şekilde kullanacağınızı adım adım anlatıyoruz.
</p>
<h2 className="text-xl font-semibold text-foreground">Adım 1: VIN Numarasını Girin</h2>
<p>
Ana sayfadaki arama kutusuna aracın 17 haneli şase numarasını (VIN) girin. Numara
girilirken anlık doğrulama çubuğu dolmaya başlar 17. karaktere ulaştığınızda sistem
otomatik olarak araç bilgilerini çeker.
</p>
<p>
Sistem saniyeler içinde araç markasını, modelini, yılını ve motor bilgilerini gösterir.
Bilgileri doğrulayın ve "Parça Kataloğuna Devam Et" butonuna tıklayın.
</p>
<h2 className="text-xl font-semibold text-foreground">Adım 2: Kategori Seçin</h2>
<p>
Araç tanımlandıktan sonra karşınıza o araca özgü parça kategorileri çıkar. Motor, Şasi &
Süspansiyon, Elektrik, Karoseri, Klima & Isıtma gibi ana kategorilerden ihtiyacınız olan
bölümü seçin.
</p>
<p>
Her ana kategorinin altında detaylı alt kategoriler bulunur. Örneğin "Motor" altında
Silindir Kapağı, Krank Mili, Piston, Yağ Pompası gibi bölümler yer alır.
</p>
<h2 className="text-xl font-semibold text-foreground">
Adım 3: İnteraktif Şemayı Kullanın
</h2>
<p>
Kategori seçildikten sonra o bölgеnin teknik çizimi interaktif şema ekranda ılır.
Şema üzerindeki parçalara tıklayarak OEM kodunu, ıklamasını ve gerekli miktarını
görebilirsiniz.
</p>
<p>
Şemalar zoom ve pan desteğiyle büyütülebilir, yatay veya dikey kaydırılabilir. Karmaşık
motor veya şasi bölgelerinde parça konumunu görsel olarak tespit etmek iade oranlarını
ciddi ölçüde düşürmektedir.
</p>
<h2 className="text-xl font-semibold text-foreground">Adım 4: OEM Kodunu Kopyalayın</h2>
<p>
Doğru parçayı bulduktan sonra OEM kodunu panoya kopyalayın. Bu kodu tedarikçinize, servis
faturanıza veya online sipariş formunuza yapıştırarak yanlış parça riskini tamamen ortadan
kaldırın.
</p>
<h2 className="text-xl font-semibold text-foreground">İpuçları</h2>
<ul className="list-disc space-y-2 pl-6">
<li>
Birden fazla araç için sorgu yapıyorsanız, her birini VIN ile kaydedin; geçmiş
aramalarınıza hızla dönebilirsiniz.
</li>
<li>
Aynı OEM kodu birden fazla katalogda farklı fiyatlarla listelenebilir fiyat
karşılaştırma özelliğini kullanın.
</li>
<li>
Parça bulamadığınızda kategori ağacında bir seviye yukarı çıkarak daha geniş bir arama
yapabilirsiniz.
</li>
<li>27 markanın tamamına erişmek için Full Paket aboneliği en avantajlı seçenektir.</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">Sonuç</h2>
<p>
Sase.tr ile tek bir yanlış parça iadesinden tasarruf ettiğiniz para, aylık abonelik
ücretini karşılar. 30 günlük ücretsiz deneme süresiyle platformu bugün deneyin kredi
kartı gerektirmez.
</p>
</div>
),
},
};
// ─── MARKDOWN RENDERING (API-backed posts) ─────────────────────────────────────
// Maps markdown to the same prose styling the hand-authored posts use.
const mdComponents = {
h2: (props: React.ComponentProps<"h2">) => (
<h2 className="text-xl font-semibold text-foreground" {...props} />
),
h3: (props: React.ComponentProps<"h3">) => (
<h3 className="text-lg font-semibold text-foreground" {...props} />
),
ul: (props: React.ComponentProps<"ul">) => <ul className="list-disc space-y-2 pl-6" {...props} />,
ol: (props: React.ComponentProps<"ol">) => (
<ol className="list-decimal space-y-2 pl-6" {...props} />
),
strong: (props: React.ComponentProps<"strong">) => (
<strong className="text-foreground" {...props} />
),
a: (props: React.ComponentProps<"a">) => <a className="text-foreground underline" {...props} />,
};
function MarkdownBody({ markdown }: { markdown: string }) {
return (
<div className="space-y-6 leading-relaxed text-muted-foreground">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
{markdown}
</ReactMarkdown>
</div>
);
}
// ─── ROUTE ───────────────────────────────────────────────────────────────────
export const Route = createFileRoute("/blog_/$slug")({ export const Route = createFileRoute("/blog_/$slug")({
component: BlogPostPage, component: BlogPostPage,
@@ -383,59 +8,13 @@ export const Route = createFileRoute("/blog_/$slug")({
function BlogPostPage() { function BlogPostPage() {
const { slug } = Route.useParams(); const { slug } = Route.useParams();
const staticPost = POSTS[slug];
const { data: apiPost, isLoading, isError } = useBlogPost(slug, !staticPost);
const title = staticPost?.title ?? apiPost?.title ?? "Blog";
const description = staticPost?.description ?? apiPost?.metaDescription ?? "";
const date = staticPost?.date ?? apiPost?.publishedAt?.slice(0, 10) ?? "";
usePageMeta({
title: `${title} | Sase.tr Blog`,
description,
canonical: `https://sase.tr/blog/${slug}`,
});
let body: React.ReactNode;
if (staticPost) body = staticPost.content;
else if (apiPost) body = <MarkdownBody markdown={apiPost.bodyMarkdown} />;
else if (isLoading) body = <p className="text-muted-foreground">Yükleniyor</p>;
else
body = (
<p className="text-muted-foreground">{isError ? "Yazı bulunamadı." : "Yazı yüklenemedi."}</p>
);
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<SiteHeader /> <SiteHeader />
<main className="container mx-auto max-w-3xl px-4 py-16"> <main className="container mx-auto max-w-3xl px-4 py-16">
{/* Breadcrumb */} <BlogPostContent slug={slug} listLinkTo="/blog" />
<nav className="mb-8 flex items-center gap-1.5 text-sm text-muted-foreground">
<Link to="/blog" className="transition hover:text-foreground">
Blog
</Link>
<ChevronRight className="size-3.5" />
<span className="text-foreground">{title}</span>
</nav>
<article>
{date && <time className="text-sm text-muted-foreground">{date}</time>}
<h1 className="mt-3 text-3xl font-bold leading-tight sm:text-4xl">{title}</h1>
{description && <p className="mt-4 text-lg text-muted-foreground">{description}</p>}
<div className="mt-10">{body}</div>
</article>
{/* Back link */}
<div className="mt-16 border-t pt-8">
<Link
to="/blog"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition hover:text-foreground"
>
Tüm yazılar
</Link>
</div>
</main> </main>
</div> </div>
); );

View File

@@ -1,230 +1,18 @@
import { ContactContent } from "@/components/contact-content";
import { SiteHeader } from "@/components/site-header"; import { SiteHeader } from "@/components/site-header";
import { Turnstile, type TurnstileHandle } from "@/components/turnstile";
import { usePageMeta } from "@/hooks/use-page-meta";
import { ApiError, api } from "@/lib/api-client";
import { toast } from "@/lib/toast";
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from "@sase/ui";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Loader2 } from "lucide-react";
import { type FormEvent, useRef, useState } from "react";
import { z } from "zod";
export const Route = createFileRoute("/contact")({ export const Route = createFileRoute("/contact")({
component: ContactPage, component: ContactPage,
}); });
// Backend (POST /contact) ile aynı kurallar
const contactSchema = z.object({
name: z.string().trim().min(2, "Ad en az 2 karakter olmalı").max(100, "Ad çok uzun"),
email: z.string().trim().email("Geçerli bir e-posta adresi girin").max(200),
subject: z.string().trim().max(200, "Konu çok uzun").optional(),
message: z.string().trim().min(10, "Mesaj en az 10 karakter olmalı").max(5000, "Mesaj çok uzun"),
});
type FieldErrors = Partial<Record<"name" | "email" | "subject" | "message", string>>;
const textareaClass =
"flex min-h-[140px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";
function ContactForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [subject, setSubject] = useState("");
const [message, setMessage] = useState("");
const [errors, setErrors] = useState<FieldErrors>({});
const [captchaToken, setCaptchaToken] = useState("");
const [loading, setLoading] = useState(false);
const turnstileRef = useRef<TurnstileHandle>(null);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const parsed = contactSchema.safeParse({ name, email, subject, message });
if (!parsed.success) {
const next: FieldErrors = {};
for (const issue of parsed.error.issues) {
const key = issue.path[0] as keyof FieldErrors;
if (key && !next[key]) next[key] = issue.message;
}
setErrors(next);
return;
}
setErrors({});
setLoading(true);
try {
await api.post("/contact", { ...parsed.data, turnstileToken: captchaToken });
toast.success("Mesajınız gönderildi", {
description: "En kısa sürede size dönüş yapacağız.",
});
setName("");
setEmail("");
setSubject("");
setMessage("");
turnstileRef.current?.reset();
setCaptchaToken("");
} catch (err) {
// Token tek kullanımlık — başarısız denemeden sonra widget'ı sıfırla
turnstileRef.current?.reset();
setCaptchaToken("");
const msg =
err instanceof ApiError ? err.message : "Mesaj gönderilemedi. Lütfen tekrar deneyin.";
toast.error(msg);
} finally {
setLoading(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Bize yazın</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="contact-name">Ad Soyad</Label>
<Input
id="contact-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Adınız"
aria-invalid={!!errors.name}
disabled={loading}
/>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-email">E-posta</Label>
<Input
id="contact-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="ornek@eposta.com"
aria-invalid={!!errors.email}
disabled={loading}
/>
{errors.email && <p className="text-sm text-destructive">{errors.email}</p>}
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-subject">Konu (opsiyonel)</Label>
<Input
id="contact-subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Mesajınızın konusu"
aria-invalid={!!errors.subject}
disabled={loading}
/>
{errors.subject && <p className="text-sm text-destructive">{errors.subject}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-message">Mesaj</Label>
<textarea
id="contact-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Size nasıl yardımcı olabiliriz?"
aria-invalid={!!errors.message}
disabled={loading}
className={textareaClass}
/>
{errors.message && <p className="text-sm text-destructive">{errors.message}</p>}
</div>
<Turnstile
ref={turnstileRef}
onVerify={setCaptchaToken}
onExpire={() => setCaptchaToken("")}
/>
<Button type="submit" disabled={loading} className="w-full sm:w-auto">
{loading ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Gönderiliyor
</>
) : (
"Gönder"
)}
</Button>
</form>
</CardContent>
</Card>
);
}
function ContactPage() { function ContactPage() {
usePageMeta({
title: "İletişim — Sase.tr",
description: "Sase.tr destek ve iletişim — sorularınız için bize ulaşın.",
canonical: "https://sase.tr/contact",
});
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<SiteHeader /> <SiteHeader />
<main className="container mx-auto max-w-5xl px-4 py-16"> <main className="container mx-auto max-w-5xl px-4 py-16">
<h1 className="text-4xl font-bold">İletişim</h1> <ContactContent />
<p className="mt-4 text-lg text-muted-foreground">
Sorularınız, önerileriniz veya birliği talepleriniz için bize ulaşın.
</p>
<div className="mt-12 grid gap-6 lg:grid-cols-5">
{/* İletişim formu */}
<div className="lg:col-span-3">
<ContactForm />
</div>
{/* İletişim bilgileri */}
<div className="space-y-6 lg:col-span-2">
<Card>
<CardHeader>
<CardTitle className="text-lg">Destek</CardTitle>
</CardHeader>
<CardContent>
<a href="mailto:destek@sase.tr" className="text-primary underline">
destek@sase.tr
</a>
<p className="mt-2 text-sm text-muted-foreground">
Teknik sorunlar ve hesap işlemleri için.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Adres</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1 text-sm text-muted-foreground">
<p>
<span className="text-foreground">Firma Adı:</span> THINXTRA LLC
</p>
<p>
<span className="text-foreground">Adres:</span> 1209 Mountain Road PL NE #11131
</p>
<p>
<span className="text-foreground">Şehir:</span> Albuquerque, NM 87110
</p>
<p>
<span className="text-foreground">Ülke:</span> United States
</p>
<p>
<span className="text-foreground">Vergi No:</span> 36-5177177
</p>
</div>
<p className="mt-3 text-sm text-muted-foreground">
Çalışma saatleri: Pazartesi Cuma, 09:00 18:00
</p>
</CardContent>
</Card>
</div>
</div>
</main> </main>
</div> </div>
); );

View File

@@ -1,7 +1,9 @@
import { LanguageSwitcher } from "@/components/language-switcher"; import { LanguageSwitcher } from "@/components/language-switcher";
import { SiteFooter } from "@/components/site-footer"; import { SiteFooter } from "@/components/site-footer";
import { TrialUrgencyBanner } from "@/components/trial-urgency-banner"; import { TrialUrgencyBanner } from "@/components/trial-urgency-banner";
import { TrialValueUpsell } from "@/components/trial-value-upsell";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useExpertAccess } from "@/hooks/use-expert-access";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import { KEYS_5 } from "@/lib/keys"; import { KEYS_5 } from "@/lib/keys";
@@ -30,7 +32,6 @@ import { Link, Outlet, createFileRoute, useNavigate, useRouterState } from "@tan
import { import {
BarChart3, BarChart3,
BookOpen, BookOpen,
CalendarDays,
ChevronsUpDown, ChevronsUpDown,
Copy, Copy,
CreditCard, CreditCard,
@@ -50,6 +51,7 @@ import {
Shield, Shield,
Sparkles, Sparkles,
Sun, Sun,
Trophy,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -71,6 +73,7 @@ const mainMenuItems: readonly NavItem[] = [
{ to: "/dashboard/search", labelKey: "nav.search", icon: Search }, { to: "/dashboard/search", labelKey: "nav.search", icon: Search },
{ to: "/dashboard/catalog", labelKey: "nav.catalog", icon: Library }, { to: "/dashboard/catalog", labelKey: "nav.catalog", icon: Library },
{ to: "/dashboard/history", labelKey: "nav.history", icon: History }, { to: "/dashboard/history", labelKey: "nav.history", icon: History },
{ to: "/dashboard/uzmanlar", labelKey: "nav.experts", icon: Trophy },
]; ];
const accountItems: readonly NavItem[] = [ const accountItems: readonly NavItem[] = [
@@ -80,9 +83,8 @@ const accountItems: readonly NavItem[] = [
]; ];
const supportItems: readonly NavItem[] = [ const supportItems: readonly NavItem[] = [
{ to: "/dashboard/changelog", labelKey: "nav.changelog", icon: CalendarDays }, { to: "/dashboard/contact", labelKey: "nav.contact", icon: Mail },
{ to: "/contact", labelKey: "nav.contact", icon: Mail }, { to: "/dashboard/blog", labelKey: "nav.blog", icon: BookOpen },
{ to: "/blog", labelKey: "nav.blog", icon: BookOpen },
]; ];
const adminItems: readonly NavItem[] = [ const adminItems: readonly NavItem[] = [
@@ -179,6 +181,13 @@ function DashboardLayout() {
}); });
const hasActivePlan = subData?.subscription?.status === "active"; const hasActivePlan = subData?.subscription?.status === "active";
// Parça Uzmanları programı flag+kriter kapısı: kapalıyken nav linki hiç
// görünmez (program yüzeyleri sunucu tarafında da kapalıdır).
const { data: expertAccess } = useExpertAccess(!!user);
const visibleMainMenuItems = mainMenuItems.filter(
(item) => item.to !== "/dashboard/uzmanlar" || expertAccess?.enabled === true,
);
// Redirect unauthenticated users without mutating router state during render. // Redirect unauthenticated users without mutating router state during render.
useEffect(() => { useEffect(() => {
if (!isLoading && !user) { if (!isLoading && !user) {
@@ -354,7 +363,7 @@ function DashboardLayout() {
return ( return (
<> <>
<NavSection title={t("nav.sectionMain")} collapsed={isCollapsed} /> <NavSection title={t("nav.sectionMain")} collapsed={isCollapsed} />
{mainMenuItems.map((item) => ( {visibleMainMenuItems.map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
@@ -509,6 +518,7 @@ function DashboardLayout() {
{/* Page Content */} {/* Page Content */}
<main id="main-content" className="flex-1 overflow-auto bg-muted/30"> <main id="main-content" className="flex-1 overflow-auto bg-muted/30">
<TrialUrgencyBanner /> <TrialUrgencyBanner />
<TrialValueUpsell />
<div className="p-4 sm:p-6"> <div className="p-4 sm:p-6">
<Outlet /> <Outlet />
</div> </div>

View File

@@ -8,7 +8,7 @@ import { Button } from "@sase/ui";
import { Skeleton } from "@sase/ui"; import { Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router"; import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowRight, Download, Filter, HelpCircle, Receipt, ReceiptText } from "lucide-react"; import { ArrowRight, Filter, HelpCircle, Receipt, ReceiptText } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
export const Route = createFileRoute("/dashboard/billing")({ export const Route = createFileRoute("/dashboard/billing")({
@@ -26,10 +26,11 @@ interface Subscription {
interface Payment { interface Payment {
id: string; id: string;
amount: number; amount: number;
method: "stripe" | "eft"; // Stripe is the sole method; kept as string so retired historical values
// (iyzico/eft) still render in the history table.
method: string;
status: "completed" | "pending" | "failed" | "refunded"; status: "completed" | "pending" | "failed" | "refunded";
planName?: string | null; planName?: string | null;
eftReceiptUrl?: string | null;
hasStripeReceipt?: boolean; hasStripeReceipt?: boolean;
createdAt: string; createdAt: string;
} }
@@ -213,7 +214,7 @@ function BillingPage() {
onChange={setMethodFilter} onChange={setMethodFilter}
options={[ options={[
{ value: "all", label: t("common.all") }, { value: "all", label: t("common.all") },
...["stripe", "eft"].map((m) => ({ ...["stripe"].map((m) => ({
value: m, value: m,
label: t(`billing.methodLabels.${m}`), label: t(`billing.methodLabels.${m}`),
})), })),
@@ -309,7 +310,9 @@ function BillingPage() {
</td> </td>
<td data-label={t("billing.method")} className={`${TD} sm:text-center`}> <td data-label={t("billing.method")} className={`${TD} sm:text-center`}>
<Badge variant={payment.method === "stripe" ? "secondary" : "outline"}> <Badge variant={payment.method === "stripe" ? "secondary" : "outline"}>
{t(`billing.methodLabels.${payment.method}`)} {payment.method === "stripe"
? t("billing.methodLabels.stripe")
: payment.method.toUpperCase()}
</Badge> </Badge>
</td> </td>
<td data-label={t("billing.status")} className={`${TD} sm:text-center`}> <td data-label={t("billing.status")} className={`${TD} sm:text-center`}>
@@ -329,19 +332,7 @@ function BillingPage() {
</span> </span>
</td> </td>
<td className="block pt-3 text-right sm:table-cell sm:py-4 sm:pt-4"> <td className="block pt-3 text-right sm:table-cell sm:py-4 sm:pt-4">
{payment.eftReceiptUrl ? ( {payment.hasStripeReceipt ? (
<Button variant="outline" size="sm" asChild>
<a
href={payment.eftReceiptUrl}
target="_blank"
rel="noopener noreferrer"
download
>
<Download className="mr-1 h-3 w-3" />
{t("billing.downloadReceipt")}
</a>
</Button>
) : payment.hasStripeReceipt ? (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"

View File

@@ -0,0 +1,14 @@
import { BlogListContent } from "@/components/blog-content";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard/blog")({
component: DashboardBlogPage,
});
function DashboardBlogPage() {
return (
<div className="mx-auto max-w-4xl">
<BlogListContent postLinkTo="/dashboard/blog/$slug" />
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { BlogPostContent } from "@/components/blog-content";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard/blog_/$slug")({
component: DashboardBlogPostPage,
});
function DashboardBlogPostPage() {
const { slug } = Route.useParams();
return (
<div className="mx-auto max-w-3xl">
<BlogPostContent slug={slug} listLinkTo="/dashboard/blog" />
</div>
);
}

View File

@@ -1,14 +0,0 @@
import { ChangelogTab } from "@/components/settings/changelog-tab";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard/changelog")({
component: ChangelogPage,
});
function ChangelogPage() {
return (
<div className="mx-auto max-w-3xl">
<ChangelogTab />
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { ContactContent } from "@/components/contact-content";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard/contact")({
component: DashboardContactPage,
});
function DashboardContactPage() {
return (
<div className="mx-auto max-w-5xl">
<ContactContent />
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More