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>
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>
Subscription/checkout flow rewritten end-to-end. The plan-card "Devam Et"
button silently wiped brand selection on re-click and the actual proceed
button lived offscreen — fixed by collapsing plan/brands/payment/confirm
into a single vertical stepper with one sticky CTA.
Backend
- Stripe Hosted Checkout (`/payments/stripe/checkout`) + webhook
(`/payments/stripe/webhook`, raw body) replacing the stubbed Iyzico
module. Webhook activates subscription on `checkout.session.completed`,
expires the pending subscription on cancel/expire so users can retry.
- New `bank_accounts` table — multiple rows, single `is_active` enforced
by a partial unique index. Admin CRUD under `/admin/bank-accounts`
with multipart QR upload to MinIO; atomic `/activate` swap in a
transaction; `GET /payments/bank-info` returns the active row.
- `payments` gains `stripe_session_id`, `stripe_payment_intent_id`,
`bank_account_id`. EFT flow now reads the active bank account at
payment time and stores the FK for reconciliation.
- Env: `IYZICO_*` removed, `STRIPE_*` added (validated by zod schema).
- `main.ts` `rawBody: true` for Stripe signature verification.
- Drizzle 0003 snapshot id collision fixed (VIEW-only migration shared
prevId with 0002, blocking new generates).
Frontend
- `/dashboard/subscription` rewritten as a 4-step vertical stepper with
step-aware sticky bottom CTA; plan re-selection is idempotent and
preserves brand state. `/dashboard/subscription/pay` deleted; Stripe
returns to the same page via `?stripe=success|cancelled` and the UI
polls `/subscriptions/me` until the webhook activates the row.
- New components: `bank-transfer-card.tsx` (DB-driven IBAN + Kolay Adres
+ uploaded QR image + receipt upload) and `stripe-checkout-button.tsx`.
- Active subscription view, trial onboarding/urgency banner, downgrade
and cancel dialogs preserved.
- TR/EN i18n: new `subscription.steps.*`, `subscription.stickyCta.*`,
`payment.stripe.*`, `payment.bank.*`; provider label updated.
PostHog: `method: "iyzico"` → `"stripe"`; new events `iban_copied`,
`kolay_adres_copied`, `qr_viewed`, `eft_initiated`,
`stripe_redirect_returned`.
Deploy runs `db:migrate` which applies 0004_hot_quicksilver
(additive: new table + nullable columns; safe to apply on prod).
Operator must add `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`,
`STRIPE_WEBHOOK_SECRET` to env and create the first
`bank_accounts` row via the admin endpoint before the bank tab works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
c4f6cc1 added this.redis.exists/setNx/incr/expire calls to vehicles.service
(dedup lock + circuit breaker) but vehicles.service.spec.ts redisService
mock was not updated. Result: 1 failing test broke pnpm test, which broke
Fusion merger's in-merge build verification on every subsequent task.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For continuous optimization we need to attribute slow/failed decodes to
the right cause. query_logs.timings jsonb is now a structured decode-meta
blob, not just stage timings:
- wmi: first 3 chars of VIN (per-brand aggregation)
- result_kind: vehicle / pcat_candidates / emex_candidates / unknown / aborted
- cache_source: db_hit / redis_positive / redis_negative / lock_wait / miss
- candidate_pick: pcat / emex / none (when user picks from candidate modal)
- pcat_car_count, emex_candidate_count (cardinality, drives candidate-modal rate)
- pl24_circuit_open, pl24_skipped (CB state at request time)
- vin_api_used, vin_api timing (NHTSA fallback frequency)
Migration 0003 adds a query_log_insights VIEW that flattens these keys
into typed columns, so ad-hoc SQL doesn't need json operators. New meta
keys appear automatically as NULL; the VIEW stays stable.
docs/analytics-queries.sql has 8 starter queries: cache hit ratio,
per-source latency, slowest WMIs, stage breakdowns, CB/abort frequency,
candidate-modal rate, top failing VINs, dedup effectiveness.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Production p99 was 18min for failed decodes and 15min for PL24 successes;
same VIN could trigger N parallel 10+ min decodes. Three layered fixes:
- In-flight dedup via Redis SETNX (vin🔒*); concurrent same-VIN requests
poll the resolve cache instead of re-firing the upstream chain.
- 24h positive cache (vin:resolve:*) and 6h negative cache
(vin:resolve:neg:*); previously 5min positive / no negative.
- 25s hard abort budget via AbortController; PCAT gets the signal natively
(AbortSignal.any), PL24/EMEX wrapped with raceWithSignal at the boundary.
Aborted decodes don't poison the negative cache.
- PCAT/EMEX real race: first definitive single-result wins; the slower
source is skipped (previously PCAT was always awaited first).
- PL24 circuit breaker: 3 consecutive failures opens a 30s cooldown
(pl24:cb:cooldown_until); successes reset the counter.
- Stage-level timings in query_logs.timings (jsonb): pcat/emex/pl24/
lock_wait/cache_hit/aborted. Failed source now logged as "none" or
"aborted" instead of misleading "corgi".
Verified locally with 3 parallel decodes of a fresh VIN: 1 real decode
(3.59s), 2 lock-waits (3.53s) sharing the result, 4th request 23ms cache
hit. Previously this would have been 3 separate 10+ min PL24 decodes.
Migration 0002 adds query_logs.timings jsonb (NULL default). Must be
applied manually before deploy (deploy.sh does not run db:push).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>