Commit Graph

428 Commits

Author SHA1 Message Date
cc1d22a7d4 feat(catalog): kök-seviye canonical fallback + araç sayfası "Birleşik" toggle
Backend: groupCategoriesByCanonical'a requireParts opsiyonu; her iki serving
metodu artık parça-içeren yaprak yoksa (taze/drill-edilmemiş araç) kök
kategorileri kovalayarak boş dönmüyor.

Frontend: araç sayfasına "Katalog | Birleşik" toggle'ı; Birleşik seçilince
CanonicalView aracın kataloğunu 18 kanonik başlık altında gösterir, her kova
CategoryGrid ile normal drill'e bağlanır. canonical_view_opened event'i.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:28:07 +03:00
af2b35e6e2 feat(catalog): pl24 catalog-vehicle canonical gezinme variantı
GET /api/catalog/vehicles/:id/canonical → catalog_vehicle_id akışının canonical
karşılığı; pl24 markaları (VW/Opel/Renault) emex/pcat ile aynı 18 başlıktan
gezilir. Parça sayısı category_id ile join'lenir (pl24 parçaları çoğu zaman
vehicle_id taşır, catalog_vehicle_id değil). Gruplama mantığı iki servisin
paylaştığı canonical-grouping.ts helper'ına çıkarıldı (categories.service da
bunu kullanacak şekilde refactor edildi).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:08:59 +03:00
3c26f00a19 feat(categories): canonical taksonomiyle birleşik gezinme endpoint'i
GET /categories/canonical → 18 kanonik üst kova (browse landing).
GET /categories/canonical/:vehicleId → aracın parça-içeren leaf kategorilerini
18 kova altında gruplar; VW ve Opel gibi kökten farklı OEM ağaçları tek/aynı
başlık setinden gezilir. Kovalama materyalize canonical_category_id'yi
kullanır, son backfill'den sonra decode edilmiş kategoriler için ortak
canonical-lexicon ile on-the-fly fallback yapar; kovasız olanlar "Diğer"e düşer.
foldName() NORMALIZE_SQL'in JS karşılığı olarak eklendi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:58:17 +03:00
cd66947303 feat(canonical-backfill): reset/prune modu (global re-run için)
reset:true → non-manual keyword katmanını sıfırdan kurar: kapsamdaki
materyalize id'leri NULL'lar + stale non-manual map satırlarını siler,
sonra yeniden eşler. Manuel satırlar korunur. Sadece GLOBAL run'da (marka
scope'suz) geçerli — map isim-düzeyi/marka-bağımsız olduğu için marka-scope'lu
silme başka markaların paylaşılan eşlemelerini bozardı. Böylece lexicon/
sınıflandırıcı değişince eski eşlemeler dangling kalmaz.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:36:35 +03:00
c3d0dbaa0b fix(canonical-backfill): ">>" marker'ını yapısal sayma
Gerçek VW parça adları geçerlilik-tarihi marker'ı taşıyor ("pencere camlari
d >> - 14.07.2019"); bare ">>" onları yanlışlıkla yapısal işaretliyordu (VW
kapsamı düşüyordu). Gerçek VIN düğümlerini zaten VIN_LIKE yakalıyor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:29:29 +03:00
8b88ccd238 feat(canonical-backfill): ağırlıklı skorlama + genişletilmiş lexicon
- Belirsizlik skorlaması: strong head-noun ×3 / weak ×1; kazanan skor
  marjıyla belirlenir, ≤1 marj = ambiguous (eski eşit-sayı tie-break'i
  fazla ambiguous işaretliyordu). confidence marja göre (0.5–0.95).
- Lexicon genişletildi: egzoz/egz/konvertor/katalitik/susturucu (yakıt),
  karoser (panjur-panel), bcm/ecu (elektrik) + strong/weak ayrımı.
- classifyNode sıkılaştırıldı: VIN markerları (wvwzzz, >>), bare motor/
  sipariş kodları (z16dt[lfu]) artık yapısal.
- map.confidence artık per-eşleşme marjdan yazılıyor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:24:42 +03:00
754f1af61c fix(canonical-backfill): brand/source filtresini inArray ile kur
`= ANY($1)` postgres-js'te parametreyi Postgres array olarak bağlamıyor
("op ANY/ALL (array) requires array on right side") → job'lar fail'liyordu.
inArray() IN-listesine açar, sürücü-güvenli.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:12:49 +03:00
0ae7c3ceb6 fix(worker): canonical-backfill Worker'ını worker.ts'e bağla
Önceki commit'te (7b5e678) src/worker.ts git add kapsamı dışında kaldı
(apps/api/src/jobs/ eklendi ama worker.ts src/ altında). Queue/processor
deploy oldu ama tüketici Worker yoktu → enqueue edilen job'lar 'waiting'de
takıldı. Bu commit Worker instance'ını ekler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:02:37 +03:00
7b5e678c55 feat(catalog): kanonik taksonomi şeması + backfill job'ı
Kaynak/marka-bağımsız tek parça taksonomisi. Ham OEM ağaçları (categories.parent_id
+ pl24/emex/pcat aynaları) dokunulmadan kalır; kanonik omurga onlara dik.

- migration 0024: canonical_categories (18 üst kova seed) + category_canonical_map
  (isim-düzeyi eşleme) + categories.canonical_category_id kolonu
- canonical-lexicon.ts: iki-aşamalı eşleme (classifyNode: parça/yapısal/çöp;
  mapToCanonical: 18-kova keyword). VW+Opel pilotunda kanıtlandı — Opel ağacının
  %87'si model-seçici düğüm, bunlar kovaya girmez.
- canonical-backfill BullMQ job'ı (on-demand): distinct foldlanmış adları
  sınıflandırır, category_canonical_map'e upsert eder (is_manual korumalı),
  categories.canonical_category_id'yi join ile materyalize eder. brands/sources/
  dryRun parametreli.
- 8 unit test (classifyNode/mapToCanonical)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:54:24 +03:00
8f6894eecb feat(api): api-key admin uçları — PATCH güncelleme, Redis kota usage, global liste
Süper Panel'den tam anahtar yönetimi için:
- GET /internal/admin/api-keys artık userId/email'siz TÜM anahtarları
  kullanıcı e-postasıyla birlikte döner
- GET /internal/admin/api-keys/usage — günlük decode kotasının anlık
  Redis durumu (limit/used/remaining), ids ile filtrelenebilir
- PATCH /internal/admin/api-keys/:id — name, dailyDecodeLimit(null=varsayılan),
  domains (yalnız publishable), rateLimitMax(null=plugin varsayılanı),
  expiresInDays(null=süresiz)
- PublicApiQuotaService internal-admin'e provider olarak eklendi

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 06:20:05 +03:00
cd30278ad9 feat(pcat): JWT capture proxy-outage resilience + pool cap
JWT sitelerinin (parts-catalogs v3 widget'ını gömen partner mağazalar) sağlık
incelemesinden çıkan iki iyileştirme:

1) Direct-capture fallback (PCAT_CAPTURE_ALLOW_DIRECT, default açık): proxied
   capture tünel/connect hatasıyla ölürse (Floxy bakiye 402 / tünel down,
   DataImpulse portları ölü — 2026-06-11 ve 2026-07-03'te yaşandı) aynı siteyi
   sunucu IP'sinden DIRECT yeniden dener. Güvenli: TWS- token IP-bound DEĞİL +
   capture düşük frekans (≤MAX_POOL_SIZE refresh/~8.5dk). Doğrulandı: Floxy down
   iken direct 1.4s'de gerçek TWS- token yakaladı → warm pool artık tam proxy
   kesintisinde bile açlığa girmez.

2) Sert havuz üst-sınırı (enforcePoolCap): refresh yolundaki slot-muhasebe
   yarışı havuzu MAX_POOL_SIZE=5'in üstüne şişiriyordu (prod'da 32-39 gözlendi
   → gereksiz capture yükü + partner-site ban riski). Her push'tan sonra en eski
   slot(lar) çıkarılıp cap uygulanıyor, timer'ları temizleniyor.

6 JWT sitesinin hepsi canlı (direct 1.3-2s) — liste budanmadı. Birim testler +
canlı Floxy-down doğal deneyiyle doğrulandı.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:33:43 +03:00
f586b2f9c2 fix(api): widget şema görseli schemaPics[0].url okusun
schemaPics öğeleri {id,url,label} nesneleri; src'ye nesnenin kendisi
verilince kırık görsel ikonu çıkıyordu. + yüklenemeyen görsel gizlenir.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:09:38 +03:00
da2eaecd47 fix(api): public API parça cevabını beyaz-listeye indir + widget pozisyon alanı
- Kategori-detay parçalarında iç alanlar (source=upstream sağlayıcı adı,
  presel, ham price/currency, catalogVehicleId...) public API'den sızıyordu —
  decode kontratı disipliniyle beyaz-liste: id/name/description/oemCode/
  position/quantity/remark/unavailable/categoryId
- Widget parça tablosu positionNumber yerine gerçek alanı (position ??
  hotspotIndex) okuyor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:05:21 +03:00
d46a463076 fix(api): widget Sorgula butonu MouseEvent'i candidate sanıyordu
onclick handler'ına doğrudan decode verilince click event'i candidate
parametresine düşüyor, sunucu da onu aday anahtarı sanıp 'araç seçimi
zaman aşımı' dönüyordu. Sarmalayıcı + typeof string guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:00:56 +03:00
e952687710 fix(api): widget loader.js global response-interceptor'a takılmasın
String dönüş {success,data} JSON'una sarılıyordu; Content-Type JS ama gövde
JSON olunca tarayıcı ERR_BLOCKED_BY_ORB ile script'i blokladı. @Res ile ham
send'e geçildi (shell zaten öyleydi).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:55:52 +03:00
ae61ac055e feat(api): gömülebilir katalog widget'ı (Faz 2 POC)
- GET /widget/loader.js: müşterinin yapıştırdığı tek script — iframe kurar,
  postMessage köprüsü (yükseklik + sase:partClick/sase:decoded olayları),
  parça tıklamasını data-part-click-url şablonuna ({code},{brand}) yönlendirir,
  window.saseWidget.onPartClick/onDecode callback'leri; CORP cross-origin
  (helmet same-origin varsayılanı müşteri sitesinde script'i bloklardı)
- GET /widget?key=sase_pk_...: iframe shell — vanilla JS katalog uygulaması
  (VIN formu, çok-aday seçimi, kategori grid + breadcrumb, parça tablosu,
  şema görseli, açık/koyu tema). Domain kilidi = anahtarın metadata.domains
  listesinden üretilen frame-ancestors CSP'si (tarayıcı zorlar); publishable
  olmayan anahtar 403; X-Frame-Options bu cevapta kaldırılır
- internal-admin: publishable:true + domains[] ile sase_pk_ anahtar üretimi
- api.sase.tr kısa yolu: /widget* → /api/widget*

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:50:29 +03:00
acadd84e4a fix(api): createApiKey expiresIn saniye cinsinden (ms değil)
30 günlük pilot anahtarı üretimi 'expiresIn is larger than the predefined
maximum value' hatasıyla düşüyordu — plugin expiresIn'i saniye bekliyor,
gün→ms çevriliyordu.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:27:59 +03:00
773e9c35fa feat(api): public API v1 katalog endpoint'leri
- GET /v1/vehicles/:vehicleId/categories — kategori ağacı (rollup parça sayılı)
- GET /v1/vehicles/:vehicleId/categories/:categoryId — kategori detayı + parça
  listesi + şema görselleri (lazy drill/heal aynen çalışır)
- GET /v1/vehicles/:vehicleId/search?q= — araç kataloğunda serbest arama
Hepsi gezinme sayılır, decode kotasından düşmez; Swagger anotasyonlu.
Dashboard'daki kuralla aynı: decode edilmiş araçlar paylaşımlı, varlık
kontrolü 404 için.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:25:38 +03:00
06f247beb7 feat(api): public API docs (Swagger) + api.sase.tr kısa yolları
- Swagger/OpenAPI yalnız PublicApiModule'ü kapsar (iç dashboard yüzeyi spec'e
  girmez); UI /api/v1/docs, JSON /api/v1/docs-json; bearer auth + kota
  açıklamaları TR
- api.sase.tr host'unda /v1/* → /api/v1/* ve /docs* → /api/v1/docs* rewrite —
  müşteriye dokümante edilen taban https://api.sase.tr/v1
- Controller'lara ApiOperation/ApiBody/ApiOkResponse anotasyonları (decode
  tek-eşleşme + çok-aday şemaları, OEM xref)
- Coolify: api.sase.tr dev app'in api servisine ikinci domain olarak eklendi
  (docker_compose_domains)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:40:17 +03:00
7a4fe51d09 feat(api): public API POC — API-key auth + /api/v1 decode & OEM xref
Kapalı-beta public API temeli (rapor: api-widget-analizi-2026-07-03):
- better-auth apiKey plugin (defaultPrefix sase_, enableMetadata, anahtar
  başına 120 istek/dk); enableSessionForAPIKeys KAPALI — anahtar dashboard
  oturumu yerine geçmez, cookie AuthGuard davranışı değişmez
- apikeys tablosu (migration 0023, plugin şemasının birebir karşılığı)
- ApiKeyGuard: Authorization Bearer / x-api-key → verifyApiKey → kullanıcı
  yüklenir (status kontrolü), request.user + request.apiKey doldurulur
- /api/v1/vin/decode: mevcut slim kontrat + aday akışı; günlük başarılı-decode
  kotası (Redis, TR günü, aynı VIN idempotent; limit: key metadata
  dailyDecodeLimit → PUBLIC_API_DAILY_DECODE_LIMIT → 100) ve
  X-Decode-Quota-* header'ları
- /api/v1/oem/:code: P çapraz-referans (kotasız)
- internal/admin/api-keys: x-internal-token ile anahtar üret/listele/kapat
  (düz metin yalnız create cevabında)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:15:17 +03:00
8735eece13 Merge branch 'feat/trial-7-day' into dev
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Shorten the free trial from 30 to 7 days: backend source of truth
(subscriptions.service TRIAL_DAYS), in-app banner rescale to the 7-day
window, tr/en copy (17 keys each), hardcoded CTAs, and the trial-progress
test. Existing trials keep their stored endDate — new sign-ups only.

Clean 3-way merge with the discount-code billing feature: trial and promo
changes touched disjoint regions of subscription/index.tsx and the message
catalogs. Post-merge verified — web+api typecheck clean, web tests 92/93
(the 1 red is the pre-existing landing index.test.tsx, unrelated to trial).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 16:26:02 +03:00
5763e7fbff fix(vinpin): launch VinPower from the Horizon app-launcher on the permanent seat
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The permanent (paid) Vinpin seat does NOT auto-launch VinPower after login —
it lands on the Horizon HTML-Access app launcher ("Available: VINPIN"), so the
old brand-grid→Fiat flow failed with "Fiat ePER did not open". Add an
OCR-gated ensureBrandGrid step before the ePER-open loop that:

  - detects the launcher (Available / Running / No Running Items) and clicks the
    VINPIN app tile to launch VinPower;
  - satisfies VinPower's own secondary login dialog (username pre-filled,
    password blank) via the webclient-frame DOM password field, with a
    coordinate fallback;
  - dismisses a stray Horizon "Disconnected" dialog / waits through the blank
    connecting screen;
  - no-ops when the brand grid is already up (trial-style auto-launch), keeping
    it backward-compatible.

New coords/waits tuned for the permanent seat live in vinpin.constants.ts.
Never throws (failures fall through to the caller's retry → null). Verified
live on the permanent account: NM426300006A69963 → DOBLO, NM435600006H43436 →
TIPO-EGEA.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 09:52:33 +03:00
346ebaa0e6 feat(billing): indirim kodu sistemi (admin panel + Stripe promo + ödeme)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Admin paneli (admin@sase.tr) kod üretir/takip eder, ödeme sayfası kodu
uygular. Kodlar Stripe Coupon + Promotion Code olarak yaşar (limit/süre/
kullanım Stripe redemption'da enforce edilir); yerel discount_codes aynası
panel listeleme + checkout-anı preview içindir.

- migration 0022_discount_codes + schema (discount_codes)
- DiscountCodesModule: admin CRUD (@Roles admin) + /discount-codes/validate (login)
- StripeService: createPromotion / deactivatePromotion / getPromotionRedemptions
  / previewDiscount + resolvePlanAmount; createCheckoutSession opsiyonel
  discountCode -> discounts[{promotion_code}] + metadata.discount_code; webhook
  gerçek tahsilatı (session.amount_total) yazar + ayna times_redeemed++
- web: /dashboard/admin/discount-codes paneli + ödeme adımı indirim alanı
  (validate preview -> indirimli özet -> kod checkout'a); tr/en çeviriler
- 7 birim test (previewDiscount: % / sabit / süre / geçersiz/pasif/dolu/limit)

Not: 2026-04-22 Stripe API promotionCodes.create `promotion:{type:'coupon'}`
kullanıyor (üst-seviye `coupon` değil). dev'de Stripe key yok -> CREATE dev'de
503; E2E için dev'e Stripe TEST key gerekir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 03:40:18 +03:00
a4e53f3cfd feat(trial): shorten free trial from 30 to 7 days
Backend source of truth (subscriptions.service.startFullTrial) now grants a
7-day trial via a named TRIAL_DAYS constant. Existing trials keep their stored
endDate — only new sign-ups are affected, so no migration is needed.

In-app conversion UX rescaled to the 7-day window, preserving the two-banner
no-overlap design:
  - urgency banner: visible the last 3 days (soft d3 / urgent d2 / critical d1)
  - value-upsell: owns days > 3 (the opening days), still proven-value gated
  - progress bars now derive the total span from real start->end dates, so both
    7-day and any remaining legacy 30-day trials render an accurate bar
Subscription-page thresholds (<=3 / >3) and the analytics gate updated to match.

Copy: 17 trial strings each in tr.json/en.json plus hardcoded CTAs in
site-header, _auth, register and demo-footer-cta -> "7 gun / 7 Gun / 7-Day".

Lifecycle emails need no change: trial-ending fires 3-4 days before endDate
and win-back after expiry (both already relative to endDate), and templates
use the dynamic {{daysLeft}} variable (no hardcoded 30).

Test: trial-progress-banner spec updated to 7-day scenarios.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:37:21 +03:00
fff873a64d fix(vinpin): BullMQ jobId cannot contain ':' — use 'vinpin-<vin>'
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:02:30 +03:00
8e45d00173 feat(vinpin): vision-loop decode driver (OCR) — reliable VDI automation
Replace the brittle clipboard/fixed-coord extraction with an OCR vision loop:
screenshot -> detect state (login/grid/portal/eper/modal via tesseract keywords)
-> act -> verify -> retry/recover. Adds portal->catalogue detection (the seat
non-deterministically opens a multi-brand portal with no VIN field), not-found
handling, and ffmpeg crop+upscale -> tesseract.js for the decode modal. Only the
model token + year are needed (matcher-tolerant), so OCR garbling is harmless.

Validated live against a trial seat: 6/6 of the ePER-present Fiat VINs decoded to
the correct model (EGEA/DOBLO); 2 genuine "vehicle not found" coverage gaps
reported as null. 8/8 correct outcomes. Still flag-gated off by default; tesseract
is dynamically imported only inside the worker. Warm-session persistence under
swiftshader is the remaining hardening item (currently re-logins per VIN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:00:07 +03:00
dfe90fb968 feat(vehicles): automatic Vinpin VIN-decode fallback (flag-gated, off by default)
When PL24/pcat/emex can't decode a Fiat VIN, decode it via the Vinpin ePER
web catalog (warm-session Playwright worker, single seat, BullMQ concurrency 1),
cache the exact vehicle in vinpin_decodes, match it to PL24's existing
catalog_vehicle for that model, and serve the parts from there. Vinpin = decode
oracle only; PL24 already holds the parts (e.g. Egea/Linea/Doblo).

Strictly gated behind VINPIN_ENABLED (default false) + a Fiat-only brand
allowlist: with the flag off, decodeVin behaviour is byte-identical and the
queue is never touched (covered by tests). Coordinates/selectors in
vinpin.constants.ts are marked TUNE-AGAINST-LIVE-PAID-SEAT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:36:39 +03:00
3096d35113 perf(prefetch): pcat rate 8→20/dk + pace 15s→5s (ramp adım 1)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
pcat kuyruğun %67'si (~26.5k) ve en yavaş drenajı. Proxy = DataImpulse rotating
gateway (74.81.81.81:10000-10999, ~1000 port-rotated çıkış IP — 2026-06-25
doğrulandı: 5 port→5 farklı Alman residential IP), per-IP ban yükü dağılıyor →
8/dk shared cap gereksiz muhafazakâr. Adım 1: 20/dk + pace 5s. (Coolify env yolu
duplike-girdi yüzünden container'a inmedi; kod-default güvenilir yol.) Env override
hâlâ mümkün (PREFETCH_RATE_PCAT/_PCAT_DELAY_MS). 429/403/tunnel izlenecek.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:56:04 +03:00
9232e40942 feat(vehicles): model-browse fallback for no-catalog VINs
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
When a VIN can't be decoded by any source but its WMI brand is known and
has a browse-able catalog (SERVICE_TO_BRAND), return a structured
{ noCatalog: { brandName, display }, vin } 200 response instead of
dead-ending. The web surfaces a "kataloğunu modelden incele" CTA that
deep-links into the existing /dashboard/catalog browse, where the parts
usually exist (Fiat Egea NM4356 -> PL24 TIPO-EGEA; old Renault VF1 -> 147k
emex parts) but aren't reachable by the specific VIN's index entry.

Brands with no browse catalog (Honda, Maserati, Alfa, ...) keep the
existing informative dead-end. New analytics: vin_decode_no_catalog +
vin_no_catalog_browse_clicked (this case no longer emits vin_decode_error).

Tests: 2 api (browseable -> fallback, non-browseable -> throws) + 1 web
(CTA renders, no error banner). RCA writeup:
/home/s/ss/katalogsiz-vin-rca-2026-06-23.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 08:58:58 +03:00
b3dd119c99 fix(prefetch/faz6): generic-model skip'i KALDIR — PSA'yı yanlış zehirliyordu
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Tespit: generic-model (model==brand) sadece Opel'i değil PSA'yı (Peugeot/Citroën)
da kapsıyor; ama PSA generic-model PATLAMIYOR (~200 kat) ve PARÇA ALIYOR (512/290).
Skip onları yanlışlıkla poison'lar + prefetch'i keserdi. Tek doğru guard =
CATEGORY_CAP (marka-bağımsız; yalnız gerçek patlamada >3000 tetiklenir → Opel'i
durdurur, küçük PSA'yı korur). isGenericModel + processInit gate kaldırıldı,
spec sadeleşti (cap + scan-skip kaldı).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 06:35:59 +03:00
95061dfa53 feat(prefetch/faz6): anti-poison — generic-model skip + per-araç kategori tavanı
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
5 Opel aracı (model="Opel" decode çözememiş) generic ROOT kataloğa düşüp
419k/102k/21k kategori + 0 PARÇA üretti → 97k kuyruğu zehirledi. İki guard:
- isGenericModel (model==brand veya boş) → processInit skip + markPoison
- CATEGORY_CAP (3000, env PREFETCH_CATEGORY_CAP): processChildren progress.total
  bu tavanı aşınca drill'i durdurur + markPoison
markPoison → prefetch:poison:<id> (30g TTL) + scheduled/progress temizler;
processInit & tryPick poison'ı atlar. +3 spec (generic skip / cap / scan skip).
Full api suite 321 pass. Çöp kategori temizliği ayrıca yapılacak.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 06:33:04 +03:00
5200d5a61d feat(prefetch/faz3): per-source paralel rate limit (PL24/EMEX/pcat bağımsız)
Tek global 20/dk limiter tüm kaynakları paylaştırıyordu → yavaş pcat PL24/emex'i
açlığa düşürüyordu. Artık her kaynak Redis fixed-window ile BAĞIMSIZ kısılıyor
(SOURCE_RATE_MAX: pl24=20, emex=20, pcat=8; env PREFETCH_RATE_PL24/_EMEX/_PCAT).
Global WORKER_RATE_MAX güvenlik tavanı (20→60). RateLimitError yeni cause
"source-rate" → process() per-job defer eder (emex/pcat PL24'ü beklemez); cooldown
hâlâ tüm worker'ı duraklatır. PL24 default'u 20'de KALDI (ban riski; yüksek-rate
Faz 4 rotating proxy ile). +spec: under/over ceiling + unknown-source unlimited.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:25:41 +03:00
7c715af0c5 feat(prefetch/faz2): tamamlanan araçları işaretle + Phase-2 rescan'de atla
Chain parça ile tamamlanınca prefetch:complete:<id> set edilir (TTL 21g,
env PREFETCH_COMPLETE_TTL_DAYS). tryPick bu işaretliyi atlar → Phase-2 bitmiş
ağaçları her dalga yeniden gezmez (eski "sonsuz churn" durur). Başarısız job
isFinished'e ulaştırmaz → kısmi ağaç asla complete işaretlenmez, gap-fill korunur.
TTL periyodik re-validasyon sağlar. +spec: complete-marker'lı araç Phase-2'de atlanır.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:22:02 +03:00
61436db48f feat(prefetch/faz1): tam-derinlik (MAX_DEPTH 2→12) + no-op children job üretimini durdur
Hedef = decode edilmiş tüm araçların TAM kategori+parça ağacı DB'de. Cap artık
hedef değil güvenlik tavanı (gerçek ağaçlar ~5-7 derin; doğal yaprak tespiti
durduruyor). queueCategoryJob non-leaf dalı `depth < MAX_DEPTH` ile kapılandı:
processChildren'ın early-return'ünü aynalar → kuyruğun ~%85'ini oluşturan
boş depth-job'ları (rate-limit slotu harcayan no-op) hiç üretilmez.
+spec: depth>=cap'te children enqueue YOK, cap altında VAR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:17:36 +03:00
3cadf80286 Merge pull request 'Backfill BFS derinligini 5->2'ye indir (sig pre-warm)' (#143) from dev into main 2026-06-21 16:59:54 +03:00
50cef50289 feat(prefetch): backfill BFS derinliğini 5→2'ye indir (sığ pre-warm + tembel derin)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Exhaustive depth-5 pre-warm ~460k'lık sınırsız BFS fan-out üretiyordu, hiç
drenaj olmuyor ve faydalı sığ + reactive işi açlığa düşürüyordu. Üst ~2
seviyeyi her decode edilmiş araç için pre-warm etmek sınırlı iş (günlerde
biter), boş katalog sayfasını öldürür, ilk tıklamaları anında yapar. Derin
kategoriler on-view (getCategoryTree) tembel çekilip kalıcı cache'lenir.
Env-tunable: PREFETCH_MAX_DEPTH. MAX_DEPTH guard mevcut derin job'ları no-op'a
çevirir (PL24'e yük yok).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:05:31 +03:00
34d68510e3 Merge pull request 'PL24 scrape penceresini devre disi birak (7/24 akis)' (#142) from dev into main 2026-06-21 09:24:46 +03:00
bee68b6a34 feat(prefetch): PL24 scrape penceresini varsayılan olarak devre dışı bırak (7/24 akış)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
PL24 prefetch artık 09:00-18:00 İstanbul penceresine bağlı değil; backfill
backlog'unun ~%96.7'si PL24 children olduğundan 9 saatlik pencere drenajı
boğuyordu. Pencere env ile geri daraltılabilir (PREFETCH_PL24_START/_END).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:14:06 +03:00
5aad3e8b22 Merge pull request 'feat(proxy): otomatik Floxy→DataImpulse failover (decode regresyonu)' (#140) from dev into main
Reviewed-on: #140
2026-06-13 18:30:45 +00:00
b992c0075b feat(proxy): otomatik Floxy→DataImpulse failover (decode regresyonu)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Floxy residential proxy bitince/çökünce (402 bakiye veya tünel reddi —
ERR_TUNNEL_CONNECTION_FAILED), EMEX + pcat decode istekleri ölü proxy'ye
çarpıp decode başarı oranını çökertiyordu (2026-06-13: ~17k tünel hatası/24s,
decode %78→%45). Önceki "DataImpulse last-ditch fallback" tarayıcı yolunda hiç
yoktu ve HTTP yolunda her istekte 2 ölü Floxy denemesi ziyan ediyordu.

Paylaşılan ProxyHealthService kapısı (in-memory cooldown): herhangi bir tüketici
bir Floxy bağlantı hatası görünce kapıyı tetikler; cooldown boyunca TÜM tüketiciler
DataImpulse'a düşer. Floxy'den ilk başarılı yanıt veya cooldown bitişi kapıyı
temizler (kendi kendini iyileştirir, periyodik yeniden-deneme). FLOXY_FAILOVER_COOLDOWN_MS
ile ayarlanır (varsayılan 180s).

Bağlanan tüketiciler:
- EMEX HTTP (fetchEmexHtml): kapı açıkken DataImpulse-öncelikli zamanlama
- EMEX tarayıcı (Playwright): launch'ta dinamik sağlayıcı seçimi + ensureSession
  health-gate'i (Floxy ölünce DataImpulse'a relaunch, kapı temizlenince Floxy'i
  yeniden dene); scrape-içi tünel ölümünde tripFloxyFailover
- pcat (buildProxy): hem call hem capture (Playwright JWT) bacakları DataImpulse'a düşer

isProxyConnectFailure(): yalnız gerçek bağlantı hatalarında tetikler — yavaş-ama-
canlı exit'in nav timeout'u failover'ı tetiklemez.

Test: ProxyHealthService + isProxyConnectFailure birim testleri; tüm api suite (303) yeşil.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 21:03:23 +03:00
97c1dbcece Merge pull request 'fix(pcat): robust year extraction across key spelling + date format' (#138) from dev into main
Reviewed-on: #138
2026-06-12 13:11:22 +00:00
d5362a5390 fix(pcat): robust year extraction across key spelling + date format
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Broadened extractYearFromPcatCar after auditing 176 null-year prod pcat
vehicles: the build date sits under "production date" (space) far more often
than "production_date" (underscore), and values come both year-first
(2006/02/27) and day-first (08/03/2016). Match the date key by spelling-agnostic
substring and pull the 4-digit year via regex so parseInt no longer reads the
day. Concrete Year param still wins over build date.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:09:14 +03:00
7571438a6b Merge pull request 'dev' (#137) from dev into main 2026-06-12 13:08:00 +00:00
e57a9968b2 fix(pcat): extract real Year param, not the year=All filter axis
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
pcat decode emits a faceting axis param key="year" value="All" BEFORE the
concrete per-car key="Year" value="2021". extractYearFromPcatCar used
find(includes("year")) which grabbed the axis → parseInt("All")=NaN → null,
so pcat-sourced vehicles (e.g. Opel ASTRA-J W0VPD5EC1MG063839) saved with a
blank year despite the year being present in raw_data. Scan all year-ish
params for a plausible value, then fall back to production_date.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:07:11 +03:00
6c6b74a8e5 feat(payments): recurring billing — Stripe Checkout switches to subscription mode
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Monthly/yearly purchases were one-time charges: our "subscription" was just
an end_date stamp, access silently died at period end and no renewal
machinery existed (no auto-charge, no reminder) — every paying customer had
to notice the lockout and re-buy by hand.

- Checkout now mode:"subscription" with inline recurring price_data; the
  Stripe customer is stored on first purchase and reused (saved card +
  invoice history on one record, with a stale-customer retry guard)
- invoice.paid webhook: extends end_date to the billing-line period end,
  records a completed payment (deduped on stripe_invoice_id against webhook
  retries), captures subscription_renewed with $revenue, mails the receipt;
  late dunning recovery re-activates the row and re-grants Full-plan brands
- invoice.payment_failed webhook: dunning mail with Stripe's next retry
  date; access is NOT cut — end_date governs and the nightly cron closes it
  if every retry fails. Product rule: mail on success, mail on failure,
  never a pre-charge reminder
- customer.subscription.deleted: stamps cancelledAt; renewals stop and
  access runs out at end_date naturally
- cancel()/resume() sync cancel_at_period_end to Stripe (forwardRef pair) —
  an in-app cancel that leaves the card being charged was unacceptable
- subscription_create invoices only enrich the checkout's payment row
  (payment intent + invoice id for receipts/panel refunds); activation,
  revenue and the receipt stay on checkout.session.completed
- migration 0020: users.stripe_customer_id,
  user_subscriptions.stripe_subscription_id (+idx),
  payments.stripe_invoice_id (+idx)

Legacy one-time subs (3 live payers) are untouched: they expire at their
end_date as before and board recurring on their next manual checkout.

Promote checklist: add invoice.paid / invoice.payment_failed /
customer.subscription.deleted to the prod webhook endpoint; verify Stripe
"Customer emails" upcoming-renewal reminders stay OFF.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:55:44 +03:00
4879f7cdef fix(subscriptions): keep trial alive until payment actually lands
Clicking "Öde" ran create(), which expired the user's live trial BEFORE
any money moved. Anyone who then abandoned the Stripe page lost their
remaining trial days and was fully locked out (brand-access requires
active|trial) — every abandoned checkout became silent churn (3 real
victims on prod: 06-02 and 2x 06-10, incl. a 9.990 TRY yearly intent).

- create(): no longer touches the trial; only stale pending rows are
  expired at checkout start
- activateSubscription(): supersedes the trial here instead, once the
  payment has actually landed (single legitimate early-end point;
  idempotency guard unaffected)
- getMySubscription(): status-priority pick (active > pending > trial >
  cancelled > rest) so the expired remains of an abandoned checkout —
  always the newest row — no longer eclipse a live trial on the
  subscription page

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:15:47 +03:00
f394897a08 fix(part-prices): fiyat kimliği marka+kod oldu — kısa kodlarda marka karışması giderildi
Kısa sayısal kodlar markalar arası çakışıyor ve FARKLI fiziksel parçaların
fiyatları tek havuzda karışıyordu (vaka: FEBI 27155 ₺160 iken çip GROS+İBRAŞ+
NIFEA karışımı ₺566 medyan gösterdi).

- sku_map'e brand_norm eklendi (takip p10 kolonunun marka kısmı; yoksa sku
  öneki) — vmi'de yeniden kuruldu (6,8M satır, %99,4 markalı)
- filterOffersForBrand: önek/sıralı-altdizi marka eşleme (BCH→BOSCH,
  BLP→BLUEPRINT, BRA→İBRAŞ); markasız sorgu yalnızca uzun/benzersiz kodlarda
  (≥8 ya da ≥5+harf+rakam) tüm teklifleri kullanır; kısa salt-sayısal kod
  uyumsuzsa boş döner (yanlış veri göstermek yok)
- pg kimliği (code_norm, brand_norm) — 0019 drop+recreate (yalnız 3 test kodu
  vardı, lazy-backfill yeniden doldurur)
- API: series?code&brand; current-batch artık { parts: [{code, brand}] } alır,
  cevap "CODE::BRAND" anahtarlı; Redis anahtarları v2
- Web: satır çipleri ve dialog markayı geçirir; ana OEM kodu markasız kalır
  (uzun-kod kuralıyla eşleşir); OE çiplerinde dağıtıcı etiketi fallback'i
  (MAIS taşıyan tekliflere RENAULT isteği uzun kodda izinli)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:55:44 +03:00
532e1ad9ef feat(part-prices): parça kodu bazlı tedarikçi fiyat geçmişi + OEM sayfasında p50/p95/p99 grafiği
- pg: part_price_tracks + part_price_daily (0018) — (kod, kaynak, gün) başına
  stoktaki tekliflerin p50/p95/p99 + teklif sayısı; source='supplier' şimdilik,
  perakende ileride aynı tabloya 'retail' olarak girer. Tedarikçi kimliği yok.
- API: GET /part-prices/series (ilk istekte takip history'sinden lazy-backfill,
  sonrası salt-pg + Redis) ve POST /part-prices/current-batch (sayfadaki kodlar
  için canlı güncel istatistik). P-servisi sözleşmesi: asla throw yok, fail-open.
- Worker: part-price-refresh cron'u 19:30 Europe/Istanbul (takip sync'i 19:05'te
  bitiyor) — izlenen kodlara bugünün satırını upsert eder, sku_map'i artımlı
  bakar, Redis cache düşürür. SUPPLIER_PRICE_DB_* yoksa sessiz no-op.
- Kaynak köprüsü: takip.sku_map (code_norm → product_id; tam sku / ilk-boşluk /
  ilk-tire sonrası normalize adayları) vmi MySQL'inde kurulu; 6,8M satır.
- Web: OEM detayında "Tedarikçi fiyat analizi" kartı (güncel medyan + P95/P99 +
  teklif sayısı + 30g delta, 30G/90G/Tümü aralıklı step grafik, recharts) ve
  article/muadil/OE satırlarında fiyat çipi → dialog'da tam geçmiş.
- Fix(p): td snapshot'ında gerçek üretici kodu articles.name'de (article_number
  %96 upstream sayısal ID) — sayfa artık kopyalanabilir gerçek kodu gösteriyor.
- compose: SUPPLIER_PRICE_DB_ENABLED/URL api+worker bloklarına eklendi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:33:37 +03:00
7d10107f4b fix(backfill): run hourly scan on an in-process timer (cron marker gets buried)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The BullMQ cron scan never fired on prod (ic frozen at 28 for days, 0
scans completed) even after the lifo change. Root cause, confirmed live:
a cron scheduler enqueues a DELAYED marker, and BullMQ promotes delayed
jobs to the wait-list HEAD via LPUSH — `promoteDelayedJobs` ignores lifo —
while the worker pops from the tail. Behind the ~444k deep-drill backlog
the scan marker sat at wait position 1 (head) with ~444k jobs ahead of it
→ unreachable for weeks. So scheduler-level lifo cannot work; the marker
must not go through the queue at all.

Run the scan on an in-process hourly timer in PrefetchWorkerService
instead (kickoff 60s after boot, then hourly), prod-host gated and
single-fired via a Redis NX lock. The scan's Phase-1 still enqueues
NON-delayed `lifo` init jobs, which DO jump the wait list (RPUSH tail →
popped next), so newly-decoded / zero-parts vehicles get onboarded ahead
of the backlog while it drains. jobs.module now removes the old cron
scheduler so stale markers stop being produced; the backfill-scan job
handler stays so any already-queued legacy marker runs harmlessly.

Tested: spec covers the NX lock (run when acquired / skip when held) plus
the existing lifo + Phase-1/Phase-2 gating; full api suite 272 passed,
`pnpm build` + biome green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:14:09 +03:00
a304420456 Merge pull request 'promote: sezon kazananları admin'e maillenir (otomatik üyelik uzatma kaldırıldı)' (#135) from dev into main 2026-06-12 08:57:03 +00:00