Commit Graph

246 Commits

Author SHA1 Message Date
013cd43e7e fix: Orijinal Katalog varsayılan + canonical duplike-kök dedupe
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
(1) Araç sayfası varsayılanı "Orijinal Katalog" (source) — canonical view'ın
edge-case'leri (duplike/drill-edilmemiş kökler) çözülene kadar.
(2) canonical grouping ad-bazlı dedupe: bazı decode'lar kökü iki kez üretiyor
("Motor, Motor"; Seat Ateca'da her kök 2×) → aynı adı en zengin kopyayla
(parça, sonra çocuk) birleştir. Önceki boş-kök filtresi GERİ ALINDI (drill-
edilmemiş kökleri de siliyordu = "2 kategori" regresyonu).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 08:06:08 +03:00
b547140eb1 feat(canonical): Jant ve Lastik + Aksesuar kovaları + pcat kararları
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
migration 0025: 2 yeni kanonik kova (jant-lastik #19, aksesuar #20).
lexicon: jant/lastik/tekerlek/göbek→jant-lastik, aksesuar→aksesuar (catch-all,
en düşük öncelik). Kullanıcı kararları: Hava Sistemi→Soğutma (hava weak),
Bakım parçaları→Filtreler (bakim weak). "hava yastığı"→direksiyon için yastigi
strong'a alındı. 16/16 test. Yorumlar 18→20 kova güncellendi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 09:24:29 +03:00
b96ca78942 feat(web): birleşik görünüm varsayılan + "Orijinal Katalog" seçeneği
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Araç sayfasında browse varsayılanı "canonical" (Birleşik) oldu; toggle
"Birleşik | Orijinal Katalog" — Orijinal Katalog seçilince ham OEM ağacı
gösterilir. Kullanıcı isteği: birleşik katalog default, orijinal opsiyonel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 09:09:37 +03:00
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
98c21fa30e feat(web): Google Ads conversion tracking (gtag + Enhanced Conversions)
Add lib/google-ads.ts (lazy gtag init, gclid capture, Enhanced Conversions,
trackGoogleAdsConversion). Fire sign-up conversion on email registration and
purchase conversion on Stripe success. Gated on VITE_GOOGLE_ADS_* env; no-op
when unset. Mirrors the Meta Pixel/CAPI pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:11:44 +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
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
3de7fa4989 ux(vinpin): brand the decoding state as Sase, drop external-catalog wording
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:13:22 +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
31fbcf2821 fix(subscription): default billing period to monthly, not yearly
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Yearly upfront (₺9.990 Full) is the top checkout-abandonment point, so an
annual commitment shouldn't be the default selection. New users now land on
monthly; the yearly tab keeps its +2-month gift badge, and an existing/
pending subscriber's or marketing-preselected period still wins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:57:39 +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
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
2c848df97d change(gamification): sezon ödülleri otomatik tanımlanmaz — kazananlar admin'e maillenir
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
expert-rewards cron'u artık üyelik uzatmıyor: ay kapanışında ilk 3'ü
hesaplayıp admin@sase.tr'ye Postal üzerinden kazanan listesini yollar
(sıra, ad, e-posta, puan, ödül 30/15/7 gün, kullanıcı ID) ve
oem_expert_rewards'a denetim satırı yazar; günleri admin elle tanımlar.
Dev DB'den atılan mailler "[dev]" konu önekiyle ayrışır. Mail-önce-satır-
sonra sırası retry'da mail kaybını önler (nadir çift mail kabul edilir).
Abonelik/kredi dokunuşu tamamen kaldırıldı.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:53:02 +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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
a89eac1820 feat(web): soft B2B segment qualifier on register (adsOpt Phase 1)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Paid ads pull many end-users who trial but never pay (~1% trial→paid in the DB
funnel). Add a soft qualifier before the register form: the visitor picks their
segment (parts dealer / wholesaler / e-commerce / service-fleet / vehicle-owner).
Business segments proceed; "vehicle owner" gets a gentle B2B-only note with a
"continue anyway" escape hatch (soft, no hard gate — protects trial volume).

The choice is captured (signup_segment_selected event + b2b_segment person
property) so qualified-trial ratio is measurable and channel→segment→paid becomes
queryable; later it can seed a Meta audience / CAPI qualified signal. Copy follows
the B2B framing rule (no "kendi aracınız"; "sınırsız şase sorgulama"). Persisted in
localStorage so a reload / OAuth round-trip doesn't re-ask.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:19:26 +03:00
3482bb86e3 feat(catalog): part-count signposting + drill hint for deep category trees
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
pcat/emex vehicles decode into a tree whose top level is ~100% folders with
zero parts (6746/9458 root nodes are parents, 0 direct leaves across 477
vehicles). Users — serkan filazi among them — saw an opaque wall of identical
folder cards, assumed the parts feature was missing, and bounced without ever
drilling (his session: 3 view-mode toggles, 0 category clicks; his data was
2-3 clicks deep and fully reachable).

Two changes, no upstream/proxy cost:
- getCategoryTree now rolls up a subtree partCount per node (one grouped parts
  read + JS post-order sum). The grid already renders "N parça", so folders
  now read "Fren sistemi · 142 parça" and steer drilling toward the branches
  that actually contain parts; empty branches stay quiet. Counts reflect
  drilled data and grow as branches are visited.
- A one-line drill hint on a "cold" tree (nothing drilled yet → no counts) so
  first-visit users know parts live inside the subcategories instead of reading
  the folder wall as a broken/empty catalog. Drops away once any branch is hit.

Verified: subtree roll-up unit test + existing suite (10/10), tsc + biome clean
across api/web/shared.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:46:35 +03:00
145689a391 feat(capi): server-side Meta Conversions API for signup (CompleteRegistration)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The browser pixel under-counts signups badly: ~96% of paid traffic is mobile
in-app browsers where iOS ITP / ad-blockers drop client events, and the OAuth
path never fired it reliably. Meta recorded ~0 registrations for a 7.5K-spend
campaign while PostHog saw 98 facebook signups — so Meta could neither optimize
toward nor attribute signups, which is the main driver of the low signup rate.

This adds a server-side CAPI CompleteRegistration:
- MetaCapiService + @Global module. Fail-open: no-ops unless META_CAPI_PIXEL_ID
  + META_CAPI_ACCESS_TOKEN are set; never throws (signup must not break).
  SHA-256 hashed email + fbp/fbc/IP/UA.
- Fired from the better-auth user.create.after hook for ALL signups (reliable,
  covers Google OAuth which the browser pixel missed entirely).
- A session-gated POST /analytics/meta/complete-registration endpoint adds
  fbp/fbc/IP/UA (ad-click attribution) for the email path.
- The browser pixel now passes a shared event_id (signup_<userId>); the
  premature Google client-pixel fire (fired on click, before completion) is
  removed.
- All sources dedupe via event_id=signup_<userId>.

Activate by setting META_CAPI_PIXEL_ID + META_CAPI_ACCESS_TOKEN (Events Manager)
in the api env; META_CAPI_TEST_EVENT_CODE routes to Test Events for verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:19:58 +03:00
3325747428 feat(landing): 3-way CRO copy A/B/n on the hero (exp-landing-copy)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Meta Ads traffic converts to signup well below expectation. Same design, three
hero-copy variants (lossPill + title + subtitle) behind the exp-landing-copy
multivariate flag, measuring user_signed_up (experiment 83160):
- control:   current "find the right part instantly" (feature / accuracy)
- variant_a: "end wrong-part returns" (ROI / business-outcome + risk reversal)
- variant_b: "find it in seconds, free, no card" (risk reversal / signup friction)

useFeatureFlag on the index hero, undefined -> control. B2B-safe copy
("customer vehicle", not "your car"). Stays draft until prod, then launch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:29:37 +03:00
5de2d12ad5 feat(activation): A/B a guided one-click first decode (exp-activation-guided-decode)
New users land on an empty search box with only a tiny fill-only "try example"
link; activation (first vin_decode_success) sits at ~32%. This adds a prominent
one-click "decode a sample vehicle" card that autoDecodes the sample VIN ->
straight to the vehicle page (the aha-moment).

Gated behind the exp-activation-guided-decode multivariate flag (control/guided),
read ONLY for unactivated users (empty history) so power-user decodes don't
dilute the metric; undefined -> control (status quo). PostHog experiment 83153
measures vin_decode_success. B2B-safe copy (sample vehicle, not "your car").

The experiment stays in draft until this ships to prod (web flags need the prod
VITE_POSTHOG_KEY), then launch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:07:53 +03:00
37aa4dfd02 fix(catalog): kill "Modeller" empty-card on vehicle page + unify drill headers
The PL24 vehicle page rendered a Card titled `t("catalog.models")` ("Modeller")
whose content was the *selected vehicle's* specs (motor/kasa/vites/pazar). When
all four spec fields were null — common for sparse decodes — the user saw an
empty card with a bogus "Modeller" heading just below the model name.

Replace the misleading card with a flat spec-chip row (icon + label : value),
hidden entirely when no specs exist. Use the shared CatalogHeader so brand →
vehicle has a proper breadcrumb back to "/catalog/$brandName" and uses the same
back-button affordance as the rest of the catalog surface. Use the shared
ViewModeToggle so view-mode buttons stop having hardcoded TR title attrs
("Izgara", "Agac", "Sutun") and inherit the i18n + a11y from the component.

Same cleanup for pcat/$catalogId_/$modelId and emex/$catalogCode_/$vehicleId:
both rolled their own header — both now use CatalogHeader with full crumbs and
i18n'd search/count/empty-state strings (catalog.pcat.*, catalog.emex.*).

Why now: catalog flow audit caught the empty-card bug ("model seçilince
yukarıda boş bir alan kalıyor"), plus drift between drill levels (some pages
used CatalogHeader, vehicle/pcat/emex did not). One unified pattern across
brands → models → vehicle → categories.

i18n: +catalog.categoryCount, +catalog.vehicleSpecs.{engine,body,transmission,
market}, +catalog.pcat.{searchPlaceholder,vehicleCount,schemaCount},
+catalog.emex.{searchPlaceholder,optionCount,variantCount,noResults,
noPartsTitle,noPartsHint,loadError}.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 03:01:09 +03:00
20cd4fda01 feat(catalog): wire Fiat (p5fiat) browse — families→models two-step + de routing
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Fiat catalog browse returned 0 models because the generic single-endpoint P5
model-list flow can't express Fiat's two-level hierarchy and the drill defaulted
to the tr account (Fiat is licensed only on de-708171).

- fetchVehicleList: dispatch fiatp_parts/fiatt_parts to new fetchFiatVehicleList,
  which expands modelOverview (34 families) → models?modelFamily=N (model codes +
  year ranges) into flat catalog vehicles whose catalogPath is the maingroups
  endpoint. Verified live: 34 families → 123 models, end-to-end drill to parts+image.
- fetchMainGroups / fetchP5Restrictions: account-aware (resolveAccount → de + DE
  proxy for Fiat; tr unchanged for every other P5 brand) so browse maingroups no
  longer hit the tr demo/empty page.
- web: case-insensitive "/maingroup" gate so Fiat (lowercase /mdl/maingroups,
  already a maingroups endpoint) skips the empty restriction selector and loads
  categories directly.
- formatFiatYear: "(2016,2020)" → "2016-2020"; +unit tests.

Subgroups/parts/images already resolve account→de for Fiat (unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:52:14 +03:00
Claude (audit §9.3)
f5cd5be933 feat(notifications): settings UI for per-workflow opt-out (audit §9.3 #14 follow-on)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Lands the user-facing half of the unsubscribe preferences work. The
one-click endpoint already shipped in this PR's main commit; this adds
the proactive self-service path at /dashboard/settings?tab=notifications
so users don't have to wait for a mail to land before tuning their
preferences.

Backend
-------
New EmailPreferencesController at /api/email/preferences:
  GET  → returns one row per OPTIONAL_WORKFLOWS entry, each with current
         optedOut boolean (false when no DB row exists).
  POST → body {workflow, optedOut} flips the row; source='settings_page'
         captured for the audit trail.
Auth+payment workflows are deliberately not exposed — the server's
OPTIONAL_WORKFLOWS set stays the single source of truth.

Frontend
--------
Adds a 'notifications' tab to /dashboard/settings (between 'preferences'
and 'security'). One toggle row per optional workflow with TR copy that
explains what each mail is for. Optimistic update — switch flips
instantly and reverts on failure; PostHog event captures accept/reject.

Static footer note clarifies that auth + payment mail keeps coming
regardless of the switches above (so users don't think they've
unsubscribed from password-reset).

i18n
----
Added settings.tabs.notifications + settings.notifications.{title,
description} to both tr.json and en.json. Body copy is hard-coded TR
(matches audit §9.3 #11 TR-only decision).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 14:51:10 +03:00
Claude (audit §9.3)
327d698945 feat(notifications): TR-only templates + name canonicalisation + MTA-STS + 2048-bit DKIM + unsubscribe (audit §9.3)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Lands the §9.3 "compliance + brand" tier of mailAudit.md as one PR. Six
changes share enough surface (notifications, shared utils, infrastructure)
that splitting them would require multiple stacked PRs.

#9 — Turkish-locale title-case for names at signup
   • New `normalizeName()` in @sase/shared, locale-aware (İ/ı pairs handled
     via toLocaleLowerCase('tr-TR') + matching toLocaleUpperCase). Hyphen-
     aware, collapses whitespace, idempotent.
   • Wired into better-auth's `user.create.before` hook so every new signup
     gets canonicalised before the row lands in Postgres.
   • 28 unit tests in packages/shared/src/index.spec.ts.
   • Backfill script at `scripts/backfill-user-names.ts` (already run
     against prod + dev — 210/402 prod users and 72/153 dev users
     canonicalised, plus 71 Novu subscribers).

#10 — Email typo correction at signup
   • New `suggestEmailFix()` in @sase/shared: exact-match typo dictionary
     for the addresses we've actually suppressed (icould.com, gmial.com,
     xn--gmail-bgd.com, …) plus Levenshtein ≤ 2 fallback against popular
     providers.
   • Inline UI hint on the register form — "Bunu mu demek istedin? <link>"
     that swaps the email on click; PostHog event tracks acceptance.

#11 — Strip EN branches (decision: TR-only)
   • 0/205 prod subscribers have locale='en' and there's no marketing in
     English — the {{#equals subscriber.locale "en"}}…{{else}}…{{/equals}}
     framework was dead code in all 10 templates.
   • Templates updated in-place (avg ~30 % smaller). Renamed
     `novu-welcome-tr.html` → `novu-welcome.html` for consistency with the
     other 9 files.
   • Novu workflow definitions in both Dev + Prod envs updated via Mongo:
     subjects collapsed to TR-only, content replaced with new HTML
     (mongodump/restore-safe).
   • App code: `NovuRecipient.locale` and `NovuUser.locale` removed; the
     `...(user.locale === "en" ? { locale: "en" } : {})` spread in NovuService
     is gone.

#12 — DKIM rotated to 2048-bit RSA
   • Postal default was 1024-bit (selector `postal-YeIm3w`). Generated new
     2048-bit key, added DNS TXT `postal-2k260604._domainkey.sase.tr`,
     atomically swapped `domains.dkim_identifier_string` +
     `dkim_private_key` in Postal MariaDB, restarted Postal SMTP.
   • Verified: outgoing welcome mail now signs with `s=postal-2k260604`
     and a 256-byte signature body (vs the previous 128-byte 1024-bit
     signature). Pubkey on DNS matches the private key.
   • OLD TXT record (`postal-YeIm3w._domainkey`) stays in DNS for ~7 days
     as a grace window for in-flight mail.

#13 — MTA-STS + TLS-RPT
   • Extended the existing mailtrack Cloudflare Worker to also serve
     `mta-sts.sase.tr/.well-known/mta-sts.txt` (`mode: enforce, mx:
     mx.postal.sase.tr, max_age: 604800`). Workers Domain bound to the
     mailtrack service via Cloudflare API.
   • DNS:
       `_mta-sts.sase.tr`        TXT  "v=STSv1; id=20260604111347"
       `_smtp._tls.sase.tr`      TXT  "v=TLSRPTv1; rua=mailto:dmarc@sase.tr"
   • Verified policy fetch returns 200 with the expected body; cert valid
     (sase.tr SAN issued by GTS).

#14 — Unsubscribe preferences + RFC 8058 one-click endpoint
   • New `email_preferences` table (migration 0011) keyed
     (user_id, workflow), captures source for audit
     (one_click / manual_link / settings_page).
   • New `UnsubscribeController` at `/api/email/unsubscribe`:
       - POST: Gmail/Yahoo one-click bot path (200 fast)
       - GET:  human-visit, renders a Turkish confirmation page
     Both validate an HMAC-SHA256(`userId|workflow`) token under
     `UNSUBSCRIBE_SECRET` — stateless, no DB lookup to validate, secret
     rotation invalidates all outstanding tokens.
   • `triggerNovu()` now mints the per-call `overrides.email.headers`:
       `List-Unsubscribe: <https://…?u=&w=&t=>, <mailto:unsubscribe@…>`
       `List-Unsubscribe-Post: List-Unsubscribe=One-Click`
     Auth + payment workflows opt out via NO_UNSUBSCRIBE_WORKFLOWS so the
     unsubscribe URL never appears on transactional mail.
   • `NovuService.trigger()` pre-flight-checks `isOptedOut()` and skips the
     trigger entirely if the user opted out. Fail-open on DB error so a
     transient blip can't swallow auth mail.
   • `lifecycle-email.processor.ts` (standalone BullMQ worker — no NestJS
     DI) does the same check inline via a LEFT JOIN on
     `email_preferences WHERE opted_out IS NULL`.
   • Coolify env wired in both Prod and Dev apps:
       `UNSUBSCRIBE_SECRET` (32-byte hex, distinct per env)
       `UNSUBSCRIBE_URL_BASE` = `https://(dev.)sase.tr/api/email/unsubscribe`

## Companion sibling changes (already applied, NOT in this PR)

- Cloudflare worker `mailtrack` redeployed with mta-sts.sase.tr custom domain.
- Postal MariaDB `domains.dkim_identifier_string` + `dkim_private_key`
  updated to the new 2k260604 selector (live since 2026-06-04 11:18).
- `postal-2k260604._domainkey.sase.tr` TXT record live at Cloudflare.
- `_mta-sts.sase.tr` + `_smtp._tls.sase.tr` TXT records live at Cloudflare.
- Novu Mongo notification + message templates updated to TR-only.
- 282 user names canonicalised across prod + dev + Novu subscribers.

## Verification snapshot

- Postal raw_headers (ID 157, post-rotation): `s=postal-2k260604` + 256-byte b=
- `dig +short TXT _mta-sts.sase.tr @1.1.1.1` ⇒ live id=20260604111347
- `curl https://mta-sts.sase.tr/.well-known/mta-sts.txt` ⇒ 200 with policy
- 28 unit tests (normalizeName + suggestEmailFix) all green via Node sanity.

## Deploy notes

- Re-run `pnpm db:generate` to regenerate the drizzle snapshot for 0011
  (added the journal entry manually because no drizzle-kit on this box).
- Run `pnpm tsx scripts/backfill-user-names.ts --apply` against any DB not
  yet canonicalised (already done for prod + dev today).
- The host-side Novu nodemailer-headers patch at
  `postal/novu-patches/apply-headers-patch.sh` must be re-run after every
  Novu container redeploy or the List-Unsubscribe header is silently dropped
  before reaching Postal (see audit §9.1 #3 for the upstream cause).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 14:28:39 +03:00