The vehicle category flow had two competing navigation models: the grid
drilled in place (no URL change, own breadcrumb) while the category route
had a separate breadcrumb reconstructed from the cached tree plus a back
button with different semantics. The route breadcrumb collapsed on
refresh/deep-link and for lazily-fetched deep nodes, and browser-back from
an in-grid drill ejected the user out of the whole flow.
Make the URL + a server-provided ancestor trail the single source of truth:
- API: getCategoryWithParts now returns `ancestors[]` (root→parent) via a
recursive CTE, so breadcrumbs are complete regardless of client cache.
- New shared <CategoryBreadcrumb> (Arama → vehicle → ancestors → current)
used by both the vehicle and category pages.
- Category page back button derives the parent from `ancestors` (no more
cache-path race); cache reconstruction (findCategoryPath) removed.
- CategoryGrid drills via route navigation per level (each level a URL +
history entry), seeding the query cache for instant render. Browser-back
now goes up one level and deep levels are shareable/refreshable.
- Tree & Columns intentionally keep their in-place paradigm.
- a11y: aria-labels on back buttons and the breadcrumb nav.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reward engine:
- Recurring milestones (every 3 → +7d, every 5 → +14d) instead of one-time
tiers capped at 5; idempotent + transactional grants serialised per
referrer so concurrent qualifications can't double-count.
- Rewards now gated on the referred user's email verification
(afterEmailVerification hook); already-verified referees (OAuth) qualify
at apply time.
- Reward days banked as users.referral_credit_days when the referrer has no
live subscription, consumed on next trial start / activation (no more
silently lost rewards).
- Accurate cumulative rewardDays in stats; getMyReferrals returns referee
name/masked email/status.
Hardening / cleanup:
- onConflictDoNothing makes apply idempotent (no unhandled unique violation).
- Anti-fraud: normalizeEmail blocks self-referral via gmail dot/+tag aliases.
- Collision-safe referral code generation at signup.
- Single apply path (welcome onboarding modal); removed duplicate calls in
register + subscription pages. Input validation on the apply code.
Email verification UX:
- Verification link now lands on a dedicated /email-verified confirmation
page instead of the deep-linked VIN/search page.
Schema: referrals.status + qualified_at, users.referral_credit_days (0009).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A category whose PL24 drill/parts fetch errors (e.g. a broken catalog snapshot
returning HTTP 500) was indistinguishable from a genuinely empty leaf — both
came back with empty parts, so the UI showed a misleading "no parts found".
getCategoryWithParts now: (1) for a pl24 group node (link_wid marks a groups
table) with no children, drills via getChildren — returns the subgroups if PL24
serves them, otherwise flags loadError (these group nodes are never legitimately
empty); (2) sets loadError when the leaf parts fetch throws. loadError is only
true when the lists are actually empty, so genuine empty leaves stay clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The DataImpulse proxy has dead ports that stall ~10s on connect, and undici's
ProxyAgent connect.timeout does NOT bound the proxy connection (verified: runtime
still 10s despite the Phase-2 setting). With the 30s request timeout, 2-3 dead
ports blow the 25s decode budget → false misses + 12-32s p95 (prod). /car/info
answers in <1s on a healthy proxy, so bound it at the fetch level instead: a 6s
per-call AbortSignal + 4 retries (both env-tunable: PCAT_DECODE_TIMEOUT_MS /
PCAT_DECODE_MAX_RETRIES) make a stuck port abort fast and rotate to a live one
within budget. Helps every PCAT decode, not just Renault.
Band-aid for proxy flakiness; proper fix is proxy port health-tracking (follow-up).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PL24 has suspended Renault VIN identification ("...belirsiz bir süre için mevcut
olmayacağını üzülerek bildiririz") — renault_parts authorizes but every decode
throws that message. In the race that's a wasted ~1s call AND, worse, it counts
as a PL24 failure: 3 strikes opens the PL24 circuit breaker, which then skips
PL24 for ALL brands (Opel/Ford/PSA…) for 30s. Prod data: VF1 (Renault) was the
single biggest decode-failure WMI, much of it repeat hits on a poisoned cache.
Comment out the renault_parts WMIs (VF1/VF6/VNE) so PL24 isn't tried for Renault;
PCAT + EMEX cover it. Re-enable when PL24 restores Renault VIN decode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PL24 account JWTs were in-memory only, so every (re)deploy forced a fresh login
on the next decode, and api + worker each logged in independently — fighting over
PL24's one-session-per-account (squeezeOut). Persist account tokens to Redis
(keyed by account, TTL = JWT exp) and hydrate from Redis before logging in.
Best-effort: any Redis error falls through to a normal login, so auth never
depends on Redis. Service tokens stay in-memory (warmed by the boot pre-warm).
Complements the OnModuleInit pre-warm — now even the base login survives restarts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When no catalog source can decode a VIN, the decode dead-ended with "Şase
numarası tanınamadı. Marka desteklenmiyor." even for valid, recognizable cars.
Now, before that error, run a best-effort identification (offline Corgi WMI +
NHTSA); if it yields a brand/model/year, tell the dealer what the car is and that
the request was logged ("Bu araç X olarak tanındı, ancak ... katalog henüz yok.
Talebiniz kaydedildi."). Logged distinctly (identified_no_catalog) so the coverage
backlog can be mined. Skipped on budget-abort.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PL24 auth tokens (account JWT + per-service tokens) are cached in-memory, so a
(re)deploy clears them and the first VIN decode of each service-family pays the
~10s login + service-authorize handshake on the request path. Measured on dev: a
cold opel_parts decode took 12s vs ~2s once the auth was warm.
Add OnModuleInit to PL24AuthService that warms, in the background (fire-and-forget,
never blocking boot), both base account logins (the shared dominant cost) plus the
common legacy + top Turkish-market service tokens. allSettled throughout so a
slow/down PL24 degrades gracefully; gated on credential presence; PL24_PREWARM=false
disables. The pre-decode (search page) already hides this from users mid-session —
this closes the one remaining gap: the first dealer right after a deploy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the capped-EMEX-then-sequential-PL24 fallback (incl. the Phase-3 8s EMEX
cap) with a dynamic first-definitive race. PCAT + EMEX fire in parallel; the
instant PCAT settles without a single car, PL24 joins the race (a definitive EMEX
would already have returned). Whichever source yields a definitive result FIRST
wins — no artificial wait. At the parts counter the dealer gets the OEM match as
fast as any one source can answer.
- Removes EMEX_RACE_MS (reverts the 8s cap from 9b13c4a).
- PL24 still gated by isDecodeable + circuit breaker, raced against the 25s budget.
- Preserves Phase-1 transient tagging (no negative-cache poisoning), ctx timings,
and the candidate fallback.
- Adds race tests: PCAT-1-car wins (no PL24); PCAT-miss + EMEX vehicle;
PCAT-miss + EMEX-miss -> PL24 wins; all-miss -> throws.
Supersedes the Phase-3 EMEX-cap approach.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prod decode failures clustered on French/Stellantis VINs that no source even
tried: the post-merger WMIs VR3/VR7 (Peugeot), VR1 (Citroën/DS) and W0V (Opel)
were missing from every map, so getServiceName/isSupported returned null and the
VIN was rejected as "Marka desteklenmiyor" before any scrape ran.
Add them to all three maps, matching the existing VF3/VF7/W0L routing:
- Corgi WMI_DATABASE (display brand)
- PL24_WMI_SERVICE_MAP (peugeot_parts / citroen_parts / opel_parts)
- EMEX CATALOG_MAP (PSA -> PEUGEOT00, Opel -> GM_OP201809)
Lets EMEX scrape and PL24 fall back for these mass-market TR brands instead of
hard-failing at the routing step. Phase 4 of 4 on decode reliability.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EMEX HTTP scrapes typically answer in ~4-5s, but the decode race capped EMEX at
3s (EMEX_RACE_MS) and fell back to PL24 — which doesn't cover EMEX-only brands.
Prod logs show the exact loss: "PL24 fallback triggered (EMEX: timeout)" at the
3s mark, immediately followed by "EMEX HTTP: found 334 categories". The win was
thrown away.
Raise the default cap to 8s and make it env-tunable (EMEX_RACE_MS), still well
inside the 25s decode budget. Only adds latency on the harder VINs where
PartsCatalogs didn't already resolve. Phase 3 of 4 on decode reliability.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The PartsCatalogs ProxyAgent had no connect timeout, so a dead DataImpulse proxy
port stalled for undici's 10s default before the retry rotated to a fresh port
(prod: "ConnectTimeoutError ... 74.81.81.81:10628, timeout: 10000ms"). Three such
stalls exceed the caller's 25s decode budget, turning a transient bad port into a
hard decode failure.
Set an explicit 6s proxy connect timeout so retries rotate to a live port within
budget, and make the request timeout / retry count / connect timeout env-tunable
(PCAT_REQUEST_TIMEOUT_MS, PCAT_MAX_RETRIES, PCAT_PROXY_CONNECT_TIMEOUT_MS) with the
current values as defaults. Phase 2 of 4 on decode reliability.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A decode that failed from a transient proxy/network blip (PartsCatalogs
DataImpulse ConnectTimeout, EMEX timeout, PL24 transport error) was written to
the 6h negative cache identically to a genuine "brand not supported" miss. A
user who hit a blip then got "Şase tanınamadı" for 6h with no way to retry out —
and prod analytics showed this hitting SUPPORTED brands (Fiat/Toyota/Hyundai
decode fine once the proxy responds).
Thread a `transient` outcome flag through resolveVin -> doResolveVin:
- PartsCatalogs.decodeVin sets it when it swallows a transport error (vs a clean
"no cars" miss), via a new optional outcome param.
- EMEX surfaces it via its existing {type:"error"} result.
- PL24 sets it on transport/timeout errors in the fallback catch.
resolveVin then skips the negative cache when transient=true; genuine misses
still cache for 6h. Phase 1 of 4 on decode reliability.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generated for the blog_posts schema added in the previous commit. Applied on
deploy by start.sh (db:migrate). CREATE TABLE + 3 indexes only; no changes to
existing tables.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Makes the blog publishable from the Süper Panel content pipeline (via n8n).
Backend mirrors the changelog module; frontend keeps the existing hand-authored
posts and merges in API-backed ones (no content migration, no regression).
API (apps/api):
- blog_posts Drizzle table (slug unique, title, meta_description, body_markdown,
tags, cover_image, status, source, published_at)
- blog module: GET /blog/posts (public list), GET /blog/posts/:slug (public),
POST /blog/posts/internal (Bearer BLOG_AUTOMATION_TOKEN, mirrors
changelog/internal) — returns { ...post, url }
- BlogService: Drizzle + Redis cache, defensive Turkish-aware slugify +
uniqueness; registered in app.module
Web (apps/web):
- use-blog hooks (list + by-slug, react-query, 30m staleTime)
- blog list: static + API merged, dedup by slug, newest first
- blog detail: static post renders as before; API post renders body_markdown
via react-markdown (remark-gfm) styled to match existing prose
- add react-markdown + remark-gfm
Deploy: set BLOG_AUTOMATION_TOKEN (and optional PUBLIC_WEB_URL) on the api,
run drizzle-kit db:push to create blog_posts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The parts-catalogs HTTP client only retried request timeouts and 401/403;
undici network errors ("TypeError: fetch failed" — a dropped/reset
DataImpulse proxy connection) were thrown on the first attempt. During the
cold-JWT window (outside 09:00-19:00 Istanbul) the proxy drops frequently,
so a single click would surface an empty/slow category that returns full
parts on a manual retry — and could even mark a populated parent group
unavailable when child discovery hit the blip.
Retry transient transport errors (timeout + undici network failures) with a
short backoff, re-acquiring a session each attempt. Definitive HTTP
responses (re-thrown as `Error: HTTP <code> …`, e.g. 400 "list of parts is
empty") are still thrown immediately — they are real answers, not blips.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
parts-catalogs (pcat) catalogs are deep trees, but every pcat node was
stored as an apparent leaf (pcat: linkPath, no children) until drilled.
Opening an intermediate group called parts2, which returns HTTP 400
("The list of parts is empty"); the drill-to-children fallback only fired
on that exact error string, so any other failure (cold JWT capture,
timeout, 5xx) left both parts and children empty → the UI rendered
"0 parça / bulunamadı" on a node whose parts live 1-3 levels deeper.
#1 Persist the groups2 hasSubgroups/hasParts flags on categories
(new nullable columns, migration 0007) and use them to classify:
- getCategoryWithParts routes a known parent (hasSubgroups, !hasParts)
straight to getChildren, never calling parts2.
- enrichWithSchemaImages treats flagged parents as expandable, flagged
parts-leaves as leaves, and only falls back to the "any pcat: is a
leaf" heuristic for pre-migration rows (flags null).
#2 Make discovery resilient: drill to sub-groups whenever the parts fetch
produces nothing (empty 200 OR a thrown error), not only on HTTP 400.
Mark a node unavailable only on a definitive HTTP 400 with no
sub-groups; transient failures are left for a later retry.
Verified live on prod (VW Passat, "Süspansiyon Çarpanlar"): full path is
Süspansiyon Çarpanlar → Süspansiyon → Süspansiyon 1…5 → 74 parts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fbevents.js was blocked by script-src; tracking pings to
www.facebook.com/tr/ also need connect-src + img-src entries
(Meta fires both XHR and image-pixel tracking).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Worker was updating parts.name but not parts.description, so PCAT part
notices stayed raw English forever once cached. Match on raw value still
being present — once translated, the row no longer matches and we stop
touching it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PCAT was missing parity with EMEX in two places:
- Part `description` (notice) was stored raw English alongside translated `name`.
Now batched into translateMany so users see Turkish notices.
- Vehicle body/engine/transmission attrs from VIN decode were raw upstream
values. Wire up the existing emex.mapper dictionaries on the PCAT
single-car, PCAT resolveById, and EMEX single-vehicle result paths.
- emex.mapper translateToTurkish now falls back to the original term on
dictionary miss instead of null — upstream values are heterogeneous
(engine codes, multi-word descriptors); losing them was worse than
leaving them untranslated.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MAN truck (p5man) main-vin and sub-vin endpoints return human-readable
names in values.mainGroupDescription and values.groupDescription. Our
parsers only checked the standard caption/description fields used by
VW/Renault/etc., so MAN categories were stored with raw numeric codes
("0", "1", ..., layoutIds like "2884606") as names.
After this change, the user sees proper Turkish names like
"MOTOR, SOĞUTMA SİSTEMLERİ" and "ANTEN". Existing brands fall through
the same field chain as before — captionMatch[2] takes precedence,
keeping their behavior unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
For the Süper Panel VIN management table — founder needs to be able to
flush a stale or wrong decode result and (rarely) blow away the shared
vehicles row so the next decode runs the full chain again.
POST /internal/admin/vehicles/:vin/cache-clear { reason, founderId }
Deletes vin:resolve:<vin>, vin:resolve:neg:<vin>, vin🔒<vin>.
Returns { clearedKeys: [...], totalKeysChecked }. Safe no-op when
nothing exists. Logs founder + reason.
DELETE /internal/admin/vehicles/:vin { reason, founderId }
Looks up the shared vehicles row by VIN; 404 if missing. Hard-deletes
it — user_vehicles rows cascade via the existing FK on delete cascade.
query_logs is intentionally NOT touched: it's audit history.
Also clears the three Redis keys so the next decode starts fresh.
Returns { vehicleId, brandName, model, source, cascadedUserLinks }.
Wired into InternalAdminModule. Reuses InternalTokenGuard + the public
decorator pattern the rest of the module uses.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reverts PR #32 (commit 0b1f033 / 4645c27).
Reason: the backend telemetry was duplicating data that already lives
in query_logs. Every field the new PostHog events carried — provider
chain, response_time_ms, success/error, cache_source, pl24_circuit_open,
brandId, aborted, partial_result — is captured per-row in:
query_logs { userId, vin, brandId, source, success, errorMessage,
responseTimeMs, timings (jsonb), createdAt }
Süper Panel already has read-only access to the Sase.tr Postgres via
DATABASE_URL_SASE_RO; the VIN Decode Observability Module (SP-VIN-001)
will read query_logs directly. PostHog hop adds latency (5min poll),
event-volume cost, KVKK weight (even sanitized VINs leaving the
service), and architectural duplication for zero observability gain.
Frontend PostHog events (vin_decode_candidates, candidate_selected,
search_input_validation_failed, vin_decode_retry_clicked) stay — those
are user-interaction signals query_logs doesn't capture.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Süper Panel VIN Decode Observability Module (SP-VIN-001) prerequisite.
The dashboard needs provider chain + cache + fallback context the
frontend can't see — emit those events from the backend.
VehiclesService.decodeVin now emits, at every terminal path:
1. provider_response_received (one per provider that ran)
- provider, response_time_ms, status (success/no_data), cached,
vin_brand, vin_sanitized, attempt_in_chain
- Derived from ctx.timings.{pcat,emex,pl24,vin_api}; only providers
that actually executed get an event.
2. provider_fallback_triggered (between consecutive attempts)
- from_provider, to_provider, reason (timeout if budget aborted,
else no_data), auto, attempt_number.
3. vin_decode_succeeded — winning provider, cache_hit, cache_source,
response_time_ms, partial_result, fallback_used, provider_attempts,
pl24_circuit_open, vin_sanitized.
4. vin_decode_failed — error_code (BUDGET_EXCEEDED | UNKNOWN_VIN),
error_message, provider_attempted, response_time_ms, vin_sanitized.
Wired at three terminal points:
- DB cache hit (existing vehicle, no chain run)
- Unknown VIN failure (chain returned null)
- Full chain success (savedVehicle return)
Notes:
- VINs are sanitized (`WAUZZZ8K****`) before leaving the backend.
- Emission is wrapped in try/catch; a PostHog hiccup never breaks a
user-facing decode.
- Frontend's legacy `vin_decoded` / `vin_decode_success` /
`vin_decode_error` events stay as-is. The new backend events live
alongside them with richer props.
- ML decoder fields default to false — VAG ML pipeline lands later.
Spec updated to pass the new PostHogService mock.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
POST /internal/admin/subscriptions/:id/brands { brandIds[], reason, founderId }
- Only operates on active or trial subscriptions.
- Refuses Full plan (brandCount=0) — that tier auto-grants all brands.
- brandIds.length must exactly match plan.brandCount, no duplicates.
- Each brand ID must exist and be active.
- Replaces the user_brands rows for the subscription atomically (delete
+ insert; same-row contention is microseconds, panel calls are serial
per founder).
- Logs the old → new brand sets for auditability.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PCAT auth pool warm-up always started with e-acca.com, which doesn't
load through the new DataImpulse rotating proxy — page.goto sits on
the 30s navigation timeout and only then falls through to the next
candidate. The remaining sites (alkatalog, auto-komplekt, autotrade,
…) reach the upstream widget in 3-10s through the same proxy, so an
on-demand acquireSession() that lands during a cold start spends 30s
blocked on the dead first hop before any retry can succeed.
Move e-acca to the end of the list. New cold-pool capture finishes
in ~4s on the first reachable site (alkatalog) instead of 30s+9s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refund (Stripe API)
- StripeService.refundPayment({ paymentId, amount?, reason, founderId })
is a new public method that wraps stripe.refunds.create:
* Requires the payment to have a stripePaymentIntentId (post-Iyzico).
* Refuses payments not in completed/partially_refunded status.
* Partial refund: amount must be in 1..payment.amount (kuruş).
* Sends panel_* metadata to Stripe for the founder/reason audit trail.
* Flips payments.status to refunded / partially_refunded.
* Appends a dated reason line to payments.admin_note.
* Captures a `payment_refunded` PostHog event (via:'super_panel').
* Does NOT cancel the subscription — that's a separate decision.
- New endpoint POST /internal/admin/payments/:id/refund behind the
InternalTokenGuard, body { amount?, reason, founderId }.
- Wired through PaymentsAdminController in InternalAdminModule;
StripeModule imported.
Extend (goodwill / bonus time)
- BillingService.extendTrial now accepts both trial AND active
subscriptions (was trial-only). Same end-date semantics
(base = max(now, current endDate)). Response now also returns
subscriptionStatus so the panel can surface the right copy.
- Endpoint URL kept as /trial/extend for backward compatibility; the
panel decides the user-facing label ("Trial uzat" vs "Bonus süre
ekle / Goodwill") based on current status.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Probe (scripts/dev) confirmed that everything PartsPanel reads from an
emexdwc.ae leaf — parts table, hotspot coordinates, and the schema image
URL — is fully server-rendered in Unit.aspx HTML. Parts arrive as
`<tr name>` rows with `td[name=c_oem|c_pnc|c_name]`; hotspots are
`<div class="dragger g_highlight" name=N style="margin-top:Ypx; margin-left:Xpx; ...">`
with the coords already in image-natural pixel space; the image URL is
in `<img class="dragger" src=...laximo...>` and its native dims can be
read from the first 24 bytes of the GIF/PNG via a Range GET.
Behaviour:
1. fetchCategoryParts now tries fetchCategoryPartsViaHttp first — two
sequential GETs (QuickDetails → Unit) + a Range GET for image dims.
2. If the HTML yields ≥1 part, we return it.
3. If the HTML returns no Unit.aspx anchor, or 0 parts, we fall back to
the existing Playwright scraper (same code path as before).
The plain-HTTP path skips the ~1-2s browser launch, sidesteps the
3-page semaphore in EmexBrowserService (concurrency cap was throttling
prefetch fan-out), and uses no chromium memory. Measured on dev with 5
fresh-ssd Renault Espace IV leaves: 4.9-5.5s wall per leaf (vs 6-7s on
the Tier 1 browser path, vs 12-14s pre-Tier-1). The 6th sample
(stale-ssd Fren Kaliyeri) failed both paths identically — confirms the
plain-HTTP path doesn't introduce new failure modes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new endpoints on /internal/admin/subscriptions/:id complete the
Süper Panel billing surface.
POST .../change-plan { newPlanId, reason, founderId }
- Only for active or trial subscriptions.
- Refuses no-op (already on that plan) and inactive plans.
- Updates planId; leaves userBrands intact so the founder can decide.
- Response includes brandReassignmentNeeded flag when the new plan's
brandCount diverges from the current user-brand count (the panel
surfaces a warning so the founder reaches out).
POST .../cancel { reason, founderId }
- Active or trial → cancelled (sets cancelledAt = now).
- Refuses already-cancelled or expired.
POST .../resume { reason, founderId }
- Cancelled → active (clears cancelledAt).
- All other states rejected.
Controller cleanup: factored requireFounder + requireReason guards so
every endpoint enforces the same validation contract uniformly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two new endpoints on /internal/admin/subscriptions/:id behind the
InternalTokenGuard.
POST .../trial/extend { days, reason, founderId }
- Only operates on status='trial' subscriptions.
- 1..90 day clamp; new endDate = max(now, current endDate) + days
(never shrinks the trial window).
- Returns previous/new endDate + daysAdded.
POST .../activate { reason, founderId }
- Wraps SubscriptionsService.activateSubscription which handles
status transition, startDate/endDate by billing period, and
brand auto-assignment for Full plan.
- Refuses already-active, cancelled, or expired subscriptions.
Wiring
- BillingService + BillingController added to InternalAdminModule.
- SubscriptionsModule imported so we can call activateSubscription.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Süper Panel Phase 7 — Phase B. Founder can suspend, reactivate, or ban a
Sase user from the panel. Status enforced in the AuthGuard so blocked
users can no longer make authenticated requests.
Schema (migration 0006)
- users.status varchar(20) default 'active' — active|suspended|banned
- users.status_reason text — free-text reason set on transition
- users.status_changed_at, status_changed_by uuid — audit metadata
- users_status_idx
Auth
- AuthGuard rejects 'suspended' / 'banned' with TR-localized message.
- auth.ts: declared `status` as a Better Auth additionalField so the
session.user object exposes it (matches how `role` is wired).
Endpoints (InternalTokenGuard)
- POST /internal/admin/users/:id/suspend { reason, founderId }
- POST /internal/admin/users/:id/reactivate { founderId }
- POST /internal/admin/users/:id/ban { reason, founderId }
Service
- LifecycleService.setStatus():
- refuses to touch admin-role users
- refuses no-op transitions (already in target state)
- refuses suspended→banned→suspended downgrade path (must reactivate first)
- on suspend/ban: deletes all sessions for the user (immediate sign-out)
- returns { from, to, sessionsKilled, changedAt }
Wiring
- LifecycleService + LifecycleController added to InternalAdminModule.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EMEX-decoded vehicles surfaced empty category pages on prod because two
runtime prerequisites were absent from the production image:
1. `scripts/emex-vin-scraper.js` was never copied — the build stage's
`COPY . .` brings it in but the production stage only cherry-picks
`apps/api/dist`, `drizzle`, and `start.sh`. Every EMEX leaf hit
therefore failed with "Scraper file not found at: /app/scripts/…"
and the category page rendered "Bu kategori icin parca bulunamadi."
2. `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser` was
exported in the Dockerfile but never reached `chromium.launch()` —
that env var is consumed by `playwright install`, not at runtime.
Playwright fell back to its bundled headless-shell cache path
(`/root/.cache/ms-playwright/chromium_headless_shell-*/…`) which
does not exist on the alpine image, so even with the scraper file
present the browser pool init would have kept failing.
Fix:
- Dockerfile: `COPY --from=build /app/scripts ./scripts`.
- `emex.browser.ts` + `parts-catalogs-auth.service.ts`: read
`process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` and pass it as
`executablePath` to `chromium.launch()` when set.
Verified on prod container: `ls /app/scripts` → missing pre-fix; the
binary at `/usr/bin/chromium-browser` exists, so the env-var hand-off
will resolve cleanly once the new image lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets the founder open a target user's session in a new tab from the
panel for debugging. Read-only enforced server-side — any non-GET
request from an impersonated session returns 403.
Schema
- sessions.impersonated_by (uuid, nullable) — founder Better Auth user id
- sessions.impersonation_readonly (bool, default false)
- index on impersonated_by
Service
- ImpersonationService.createReadonlySession({ targetUserId, founderId,
ttlMinutes, reason, ipAddress, userAgent }):
- Random sessionId + token (32 bytes hex each)
- TTL clamped 1..60 min, default 15
- Refuses to impersonate admin users
- Inserts sessions row; signs cookie value with HMAC-SHA256(BETTER_AUTH_SECRET)
matching better-call's signCookieValue format
- Returns { cookieName, cookieValue, expiresAt, sessionId }
Guard
- ImpersonationReadonlyGuard runs after AuthGuard, before RolesGuard.
- GET/HEAD/OPTIONS pass through.
- For other methods: looks up sessions.impersonated_by + impersonation_readonly
by request.session.id; throws ForbiddenException if both truthy.
Endpoints (InternalAdminModule)
- POST /internal/admin/users/:id/impersonate-readonly [InternalTokenGuard]
body: { ttlMinutes, reason, founderId }
returns: { redirectUrl, expiresAt, sessionIdPrefix }
Hand-off is via signed consume URL (cross-origin Set-Cookie limitations).
- GET /admin/impersonate/consume?t=<signed> [@Public]
Verifies HMAC-signed payload (<=60s validity), sets the Better Auth session
cookie on sase.tr, redirects to /. One-shot.
Wiring
- InternalAdminModule imported in AppModule.
- ImpersonationReadonlyGuard registered as APP_GUARD between Auth and Roles.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Foundation guard for /internal/admin/* endpoints called from Süper Panel
(sp.semih.ai) over Coolify internal network. Verifies X-Internal-Token
header against INTERNAL_API_TOKEN env with constant-time compare.
Not yet wired to any endpoint — internal-admin module/controllers will
land in follow-up commits as panel-side mutation features ship.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Removes the EFT (havale) payment surface across API, web, i18n, and docs.
Card payment via Stripe is now the only checkout path.
API (apps/api/src):
- Delete payments/bank-accounts/ module (controller + service + module)
- payments.service: drop EFT methods (createEftPayment, uploadEftReceipt,
approveEft, rejectEft, getActiveBankInfo, getPendingEftPayments) and the
BankAccountsService dependency
- payments.controller: drop /payments/eft, /payments/eft/:id/{receipt,approve,reject},
/payments/bank-info, /payments/pending; keep /payments/me
- payments.module: drop BankAccountsModule import
- admin.service: drop pending EFT counter from getDashboardStats; drop
getPendingPayments (admin EFT approval list)
- admin.controller: drop /admin/payments/pending endpoint
- admin.service.spec: drop the getPendingPayments + pendingPayments assertions
Web (apps/web/src):
- Delete components/payment/bank-transfer-card.tsx
- Delete routes/dashboard/admin/payments.tsx (EFT approval page); regenerate
routeTree.gen.ts
- subscription/index.tsx: drop EFT tab from PaymentMethodSection, drop the
paymentMethod state + handleEftCompleted, simplify ConfirmationCard to the
Stripe-only path, drop unused imports (Tabs, BankTransferCard, Building2)
- billing.tsx: drop "eft" from method filter chips; keep methodLabels.eft and
the legacy receipt download so historical EFT records still display
- dashboard.tsx + admin/index.tsx: drop /dashboard/admin/payments from the
sidebar + admin quick-link grid; trim unused icon imports; KEYS_6 → KEYS_5
for the now-5-card admin stats skeleton
- messages/{tr,en}.json: strip every EFT-only key under payment.* (eftTransfer,
eftConfirmationDescription, eftPaid, eftStatus, bank.*, uploadReceipt* etc.)
Schema (apps/api/src/database/schema):
- bankAccounts table + payments.bankAccountId column are kept as-is: legacy
EFT payments remain visible on the billing page and the schema preserves
historical records.
Docs:
- README.md: "Stripe (kart) + EFT" → "Stripe (kart)"
- CLAUDE.md: stack table + PaymentsModule row + payments table note + route
list updated; admin EFT approval route removed
Verification:
- pnpm typecheck: green (api + web + shared + config + ui)
- pnpm lint: green
- pnpm test: 20 web tests pass, 173 api tests pass
The leaf-path checks only matched /servicepart/vin_items literally, but
PL24's P5 modern catalog ships the same shape under /chemicals/vin_items,
/accessories/vin_items, /chemicals/mdl_items, etc. When user clicked a
"Rötuşkalemseti" (touch-up paint set) category whose linkPath was
/p5vwag/extern/chemicals/vin_items, the code drilled in, treated each
paint chemical's per-part URL (?partno=LLSMAX010) as a sub-category,
and inserted 228 ghost rows under it. Replace the literal substring
match with a regex that covers the whole /extern/{kind}/(vin|mdl)_items
pattern; apply to both the user-vehicle (categories.service) and the
catalog (catalog.service) flows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
getChildren had a leaf-detection whitelist that covered BOM/partinfo paths
but missed PL24's image-board.action (and the VIN variant). When users
clicked into a vin-image-board.action leaf, fetchSubGroupsByPath ran on
its HTML, parsed the BOM rows' jsonUrl="...json-vin-bom-detail.action..."
attributes as if they were sub-groups, and inserted 10+ ghost category
rows under the leaf — each pointing to a per-part endpoint that's not
a sub-group at all. Drilling into one of those ghosts then surfaced the
"0 parça listeleniyor" empty state instead of the real BOM table.
Add both image-board.action and json-vin-bom-detail.action to the leaf
detection list so the flow short-circuits and getCategoryWithParts
handles them via fetchPartsByPath as designed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fetchFordVinBomParts was forwarding the relative HTML jsonUrl with
isFullUrl=true, which made undici try to parse \`/ford/fordp_parts/…\`
as an absolute URL and throw "Failed to parse URL" for every PNC row.
The Hyundai BOM expander next door calls fetchP4Page with the default
isFullUrl=false (relative path) — match that.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VIN-decoded Ford P/T and Volvo legacy vehicles used to land on an empty
category page: the .action HTML the decoder scraped only carried header
breadcrumbs (Portal / Model seçimi / VIN). The real catalog hangs off
three JSON endpoints that the partslink24 UI calls in the background
once a VIN session is established. None of them need mode/upds/JSESSIONID
beyond the standard PL24TOKEN cookie. Plumb the whole chain so a user
who decoded a VIN sees real Turkish part categories and OEM part
numbers in the user-vehicle flow:
json-vin-main-group.action → real top-level groups (8 for Mondeo)
json-vin-sub-group.action → 58 leaf subgroups (filters subheaders)
vin-image-board.action → BOM table with pncHierCode + jsonUrl
json-vin-bom-detail.action → final OEM partno entries (per variant)
Schema image fetch reuses the existing image-ticket extractor since the
ticket URL lives in the same jsIlluData payload as Hyundai/Opel/Volvo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VIN-group HTML for Ford/Volvo legacy services exposes the header
breadcrumb links (Portal / Model seçimi / current VIN) via the same
.action pattern the parser relied on, so they were being persisted as
"part categories" — users saw three useless rows instead of real groups.
The PSA flow already documented this trap and bypassed rawData; extend
the same defense to the rest of P4 legacy (parser-side + categories
insert fallback).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diagnostic served its purpose: confirmed that PL24 bomOverviewlist
URLs (e.g. illustrationId=4336125, "Alternatöre genel bakış" with
PR-code filters) return only a records array with no images field —
upstream genuinely has no schema illustration for these overview
tables. Frontend UX for this case is handled separately.
Decoded vehicle data is shared across the platform — once any user
decodes a VIN, the vehicle, its categories, and its parts should be
visible to every authenticated user. The previous getById enforced a
user↔vehicle junction via inner join, returning 404 "Araç bulunamadı"
when a user tried to view a vehicle they hadn't decoded themselves.
This surfaced as "Veriler yüklenirken bir hata oluştu" on the category
detail page for any vehicle the current user wasn't linked to.
Drop the ownership filter from getById; the userVehicles junction is
now used only for per-user history listing and delete operations.
Verified with Playwright: GET /api/vehicles/.../categories/... was
returning 404 from the ownership check for non-owner users.
CSP was blocking PostHog scripts/connections (t.sase.tr) and the theme-FOUC
inline script in index.html. Faro /collect/ requests were aborting because
the bare-metal nginx route disappeared during the Coolify migration.
- Add https://t.sase.tr to scriptSrc + connectSrc
- Add sha256 hash for the theme-FOUC inline script in index.html
- Exclude /collect/* from the /api global prefix
- Add FaroCollectController that forwards POST /collect/:id to
faro-collector-prod-eu-west-2.grafana.net (overridable via FARO_UPSTREAM)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A user who picked a plan + brands, clicked the checkout button to
create the pending subscription, then closed the tab without paying
came back to a frozen "Mevcut Plan · Bekliyor" card with no payment
button and no way to edit. Status was "pending", which my conditional
treated as an active subscription and routed away from the stepper.
Frontend
- hasActiveSub narrowed to active|cancelled only. Pending users now
see the stepper again.
- New effect pre-fills selectedPlanKey, selectedBrandIds,
billingPeriod from the pending row on first render and jumps
straight to the payment step.
- New PendingPaymentBanner at the top of the stepper with
"Odemeye devam et" (scrolls + sets step=payment) and "Vazgec"
(calls cancel-pending then resets selection).
Backend
- subscriptions.service.create() auto-expires existing pending
rows for the user before inserting a new one — abandoned checkouts
no longer accumulate and a fresh attempt with a different plan
doesn't 409.
- New PATCH /subscriptions/cancel-pending + service method for
the banner's explicit cancel action.
i18n: subscription.pendingBanner.* (tr/en).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>