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>
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>
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>
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>
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>
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>
The prod docker build runs `nest build`, which compiles spec files too —
the untyped `vi.fn(async () => undefined)` mock made `queue.add.mock.calls[i]`
a zero-length tuple, so the destructures tripped TS2493/TS2352 and failed
`pnpm build` (tsc --noEmit had skipped the spec). Type the mock args as
unknown[]. No behaviour change; tests still 7/7, `pnpm build` green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
The hourly catalog-backfill scan had gone effectively dead on prod: its
job sat behind a ~430k-deep wait list and, even when it ran, self-skipped
because the queue backlog (431k) was far over the maxBacklog ceiling
(1000). Net result: newly-decoded / zero-parts vehicles were never
onboarded — they starved behind the deep-drill backlog (ic stuck at 28
for days, 0 backfill-scan jobs ever completed).
Root constraint (verified against bullmq 5.68 lua): moveToActive drains
the `wait` list (RPOPLPUSH from the tail) BEFORE the `prioritized` ZSET,
so a `priority` job is starved behind an already-deep wait queue — the
opposite of what's wanted. The lever that works is `lifo`: it RPUSHes to
the tail, where the very next RPOPLPUSH picks it, ahead of the FIFO
backlog. addJobFromScheduler honours lifo too, so the scan job itself can
jump the queue.
Changes:
- Thread a `fast` flag through the init→children→parts chain; fast jobs
are enqueued with `lifo:true` so the whole chain jumps the backlog.
- Backfill scan: Phase-1 (zero-parts vehicles) now runs every wave in the
fast lane even when the backlog is over the ceiling; only Phase-2 (the
rolling rescan that piles on) is suspended while the queue is deep.
- Register the hourly scan job with `lifo:true` so it fires on the next
tick instead of being buried for days.
Tested: new prefetch-worker.service.spec (lifo wiring + Phase-1/Phase-2
gating), full api suite green (248 passed), typecheck + biome clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
@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>
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>
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>
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>
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>
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>
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>
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>
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>
dev.sase.tr and sase.tr both run NODE_ENV=production (and ship the same
production Vite build), so every Sentry event — api, worker, and browser — was
tagged environment=production, making dev errors indistinguishable from prod.
Resolve the environment from the canonical prod host instead: COOLIFY_FQDN /
BETTER_AUTH_URL on the server (the same signal isCatalogBackfillEnabled uses),
window.location.hostname on the web. So dev now tags as "staging" and stays
filterable. Explicit SENTRY_ENVIRONMENT / VITE_SENTRY_ENVIRONMENT still win.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The existing CAPI only sent CompleteRegistration (signup). For a B2B funnel where
trials are cheap (~25 TRY) but paid is rare (~1% of trials), the highest-value
signal Meta can optimize on is the realized-revenue Purchase. Add
MetaCapiService.sendPurchase and fire it from activateSubscription — the shared
chokepoint for BOTH Stripe (webhook) and EFT/manual activation — so all paid
revenue is sent regardless of method. Hashed-email Advanced Matching (no browser
fbp/fbc in the webhook); event_id = purchase_<subscriptionId> dedupes a browser
Purchase. Awaited so it ships before the short request returns; fail-open.
This is the "teach Meta to find payers, not end-users" lever from adsOpt.md Phase 0.
Still gated on activating CAPI in prod (merge + META_CAPI_PIXEL_ID/ACCESS_TOKEN env).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The subscription-expiry cron filtered status="active" only, so trials
never transitioned out of "trial" after end_date. Every access gate
keys off status, so trials past end_date kept full product access for
free (revenue leak) and inflated the active-trial count. On prod: 105
stuck trials, 3528 brand grants still live.
- expiry processor now sweeps status IN (active, trial) past end_date
(lt() still skips NULL end_date, so perpetual subs are untouched)
- add "trial" to SubscriptionStatus union — it was used in the DB and
code but missing from the type (both subscription.ts and user.ts)
Proven read-only on prod: old WHERE caught 0, fixed catches 105.
Revocation uses the existing set-expired + delete-userBrands path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mock @sentry/nestjs and assert that a getCategoryWithParts loadError drives the
redis dedup key + Sentry.captureMessage("…drill-load-error…"), so the silent-
failure reporting can't regress unnoticed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The catalog failures that hurt UX — a drill/parts fetch that fails into an empty
"couldn't load" panel, or a decoded vehicle whose category tree comes back empty
("model var ama parça yok") — all return HTTP 200 with a degraded body. Nothing
throws, so the global Sentry exception filter never sees them and they go
unnoticed (serkan's complaint was exactly this class). Report them explicitly.
- new common/catalog-degradation.ts: reportCatalogDegradation(kind, ctx),
fingerprinted by kind+source+brand so each failure mode collapses into one
countable Sentry issue (e.g. "drill-load-error · pl24/Ford — N events, M users").
- categories.service: capture on getCategoryWithParts loadError and on an empty
getCategoryTree, Redis-deduped to <=1 event/hour per category/vehicle so a
broken catalog can't flood the stream; telemetry never throws into the request.
tsc + biome clean, categories suite 10/10.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>