Commit Graph

1023 Commits

Author SHA1 Message Date
5628cbceaf Merge pull request 'fix(vinpin): claim warm only on full 3-window session' (#237) from dev into main 2026-07-15 13:25:46 +03:00
aa798ec03d fix(vinpin): claim warm ONLY on a full 3-window session (Fiat+Rpartstore+Dialogys)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
A partial warm session (e.g. Rpartstore DOWN → only Fiat+Dialogys) is proven
unstable: re-warm cycles + the missing catalog's launch-error keep knocking the
Fiat window off its VIN panel, so warm Fiat decodes thrash to the 180s budget →
not_found. Require all three windows before this.warm=true; otherwise stay on the
reliable cold path (which decodes Fiat/Renault + cross-brand cleanly). The daemon
backs off + retries, so warm auto-resumes once Rpartstore recovers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:25:39 +03:00
0011c80ec3 Merge pull request 'fix(vinpin): warm-Fiat decode (Поиск false-match + silent-null cold-fallback + strict not-found)' (#236) from dev into main 2026-07-15 13:03:30 +03:00
46f0210ba6 fix(vinpin): strict not-found for warm-Fiat full-frame garble check
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Review finding: warmDecode's genuine-not-found decision used VINPIN_OCR.notFound
(/…|Catalogue/i) against the FULL frame, where the 'Spare Parts Catalogue' header
always matches → every on-panel garble was flagged a genuine miss and null'd out
instead of falling back to the cold retry. Add notFoundStrict (no Catalogue token)
for the full-frame check so a transient on-panel garble (VIN exists) recovers via
the cold path; keep notFound for the runVinFlow modal-region settle poll.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:03:23 +03:00
31aabfc28f fix(vinpin): warm-path Fiat silent not_found (wrong-window raise + silent null)
When the warm daemon is up and Rpartstore is DOWN, a partial-warm session
(Fiat opened, Rpartstore launch-error, Dialogys opened last → foreground)
made a Fiat warm decode fail silently: the Fiat foreground regex shared the
`Поиск` token with the Dialogys "ПОИСК" button, so raiseWarmWindow reported
success without raising Fiat and the VIN was typed into Dialogys → garbage;
warmDecode's Fiat branch then did a bare `return null` (no log, no fallback).

- FIX 1: drop the ambiguous `Поиск` from VINPIN_WINDOW_FOREGROUND.fiat; keep
  Fiat-only chrome (Dealer/ePER) + VIN-panel model tokens.
- FIX 2: warmDecode Fiat unusable-parse no longer returns a silent null —
  OCR the frame; on-panel + genuine not-found → null (real miss), otherwise
  warn (cold-path parity) and fall back to the proven cold decodeFiatLocked.
- FIX 3: ensureWarmWindow panel-verifies a raised Fiat window (catalogueReady);
  if up but off the VIN panel, re-navigate via establishSession (bounded/never-throw).
- FIX 4: _warmUp records per-window availability (warmWindows) so a Fiat VIN
  routes straight to cold when no Fiat window opened; and dismisses a leftover
  Rpartstore launch-error modal before opening Dialogys so it can't dirty the
  desktop / drive the wrong-window state.

Adds ocrFrame() test seam + 4 unit tests. tsc clean; 114 vinpin tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 12:55:10 +03:00
0de05e5515 Merge pull request 'fix(vinpin): cheap in-session retry on ambiguous Renault decode' (#235) from dev into main 2026-07-15 10:28:57 +03:00
9cb58c583c fix(vinpin): cheap in-session grid reset on ambiguous Renault retry (no relaunch)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
An AMBIGUOUS Renault outcome is transient/state-dependent, but decodeRenaultLocked
retried it with a full browser teardown (this.close()). The next attempt then
re-launched chromium + re-did the web login + re-established from scratch
(~60-90s each), and each re-establish re-hit the seat's dirty-resume ("catalog
window resumed open" -> closeStrayRunningApps), so 3 attempts blew the 180s budget
-> not_found. Proven live: VF1RFE00653633190 decoded cleanly to KADJAR earlier
when the desktop state was favorable, then thrashed to not_found on relaunch.

Fix: on the ambiguous path, reset to a clean VinPower brand grid on the SAME live
session via ensureBrandGrid (closeStrayRunningApps DOM recovery first, canvas
tab-X fallback) instead of tearing the browser down. Keep the browser + authed so
the next iteration's ensureAuthenticated is a no-op (no relaunch, no web login),
and re-run runRenaultFlow from the clean grid (~30-40s). Graduated safety: if the
cheap reset can't confirm a clean grid or the session is broken (page
closed/disconnected), fall back to the old close() + cold re-establish. The reset
runs under the wall-clock deadline so an overrun still routes to the existing
VinpinBudgetError teardown+poison path. never-throw + budget/poison paths
unchanged; maxAttempts semantics unchanged.

Tests: +3 (ambiguous -> in-session ensureBrandGrid reset re-runs runRenaultFlow
with NO close(); graduated fallback close()s when the reset can't reach a grid;
broken session skips straight to close()). 110 vinpin tests green; tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 10:27:17 +03:00
d55a2b570f Merge pull request 'fix(vinpin): DOM Running-panel recovery for dirty-resume + fail-safe stray close' (#234) from dev into main 2026-07-15 09:49:20 +03:00
cf36da07af fix(vinpin): fail-safe stray-app close — never terminate a row on an unreadable name
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Review finding: closeStrayRunningApps classified an unreadable/empty VinPower
running-app row as a stray and closed it (`/VinPower/i.test('')` is false),
needlessly killing+relaunching a healthy VinPower on a transient name-read miss.
Only close rows POSITIVELY identified as non-VinPower (non-empty name that fails
the VinPower match); treat unreadable names as keep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:48:48 +03:00
814d11d03e fix(vinpin): DOM-based dirty-resume recovery via Horizon Running panel
On login the RDS seat resumes DIRTY (e.g. a Renault Rpartstore launch-error
modal + its taskbar window over the brand grid). The old recovery failed: the
canvas tab-✕ (93,45) only hits a TABBED catalog window's ✕, which a resumed
stray doesn't have, so 6 tries did nothing and escalated to logout+relogin —
counterproductive, since the seat publishes apps-only and the RDS session ends
only on server-side idle timeout, so a Connection-Server logout+relogin PROVABLY
resumes the same dirty window.

New state-agnostic recovery `closeStrayRunningApps`: reveal the Horizon sidebar
(#sidebar-toggler), enumerate ul.running-app rows, and terminate every app whose
name != VinPower via its per-app ✕ (li.icon-close-app-image) — real DOM outside
the Blast canvas, so it closes a window regardless of its modal/spinner/loading
state. Collapse the sidebar, OCR-confirm the brand grid; relaunch VinPower via
#available-VINPIN (or the vinpinApp canvas coord) if the app itself was gone.

Wired as the PRIMARY recovery in ensureBrandGrid — both the resumed-catalog
branch (before the canvas tab-✕ fallback) and the end-of-loop escalation, which
NO LONGER calls logout+relogin (method retired). Every DOM op is guarded
(try/catch + presence check) so a missing selector / canvas-only render degrades
gracefully to the existing dismissBlockingModal + tab-✕ / OCR path instead of
throwing. Bounded loop; the launch-error modal dismissal (OK 868,530 / Escape)
is kept as a fast pre-step and fallback.

Preserves the never-throw contract, 180s budget/poison, spinner-guard, acquire
cap, launch-error cooldown, ensureRpartstore fast-bail, and the Fiat ePER path.
tsc clean; 107 vinpin tests green (adds closeStrayRunningApps close/degrade tests
and the ensureBrandGrid-uses-closeStrayRunningApps escalation tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:43:53 +03:00
ee56dec2d5 Merge pull request 'fix(vinpin): ffmpeg OCR root-cause + robust DOWN-Rpartstore Renault decode (defense-in-depth + hardening)' (#233) from dev into main 2026-07-15 08:47:46 +03:00
8137845198 fix(vinpin): bail a DOWN Rpartstore on iteration 1, don't re-click the flyout 6×
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
When Rpartstore is DOWN, its hard launch-error modal ("Ошибка запуска каталога",
title "Renault Rpartstore") renders OVER the brand grid. The grid tiles stay
OCR-visible behind the small centered modal, so the full-frame read matches both
`brandGrid` and `renaultSubmenu` — making ensureRpartstore's flyout branch fire on
EVERY iteration, re-clicking renaultRpartstore(584,779) + langOk + a 12s full-frame
poll for all ~6 iterations (~72s) before finally returning false. That wastes ~60s on
the first cold DOWN decode AND repeatedly actuates coordinates on a wedged desktop.

Detect the launch-error modal (upscaled crop via the existing
`rpartstoreLaunchErrorPresent`) at the top of the per-iteration loop, BEFORE the
flyout branch: if present, return false on iteration 1 so the acquire loop's
`!present` path dismisses it, sets the down-cooldown, and routes straight to Dialogys.
Depends on the crop OCR being legible (ffmpeg upscale, added in 281c54a); when
illegible it's false and behaviour is exactly as before.

Adds two robustness tests: (1) modal-over-grid → false on the first iteration with no
flyout re-clicks; (2) illegible crop → flyout path still runs (unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:46:40 +03:00
c8d9e45207 fix(vinpin): don't cool down Rpartstore on transient born-stuck streak; don't relaunch on clean-logout launcher
Two review follow-ups to 281c54a:

1. born-stuck cooldown regression: acquireLoadedRpartstoreInner set the 10m
   down-cooldown when maxOpens was exhausted by a born-stuck-spinner streak — a
   TRANSIENT, reopen-recoverable blip, not a server outage. Because the cooldown
   can only self-clear from INSIDE the acquire loop (skipped while cooling down),
   one spinner streak suppressed the richer Rpartstore catalog for every Renault
   decode for 10m. Reserve the cooldown for the confirmed launch-error DOWN signal
   (unchanged at the two launch-error sites); the born-stuck give-up now just falls
   back to Dialogys for that one VIN and retries Rpartstore fresh next VIN.

2. clean-logout false disconnect-recovery: cleanTeardown's disconnect recovery
   gated on VINPIN_OCR.sessionDropped, whose broad "HTML Access" token also matches
   the clean-logout Horizon HTML-Access launcher. A clean log-off could then click
   disconnectedClose + RELAUNCH VinPower right before close(), leaving the exact
   dirty resumed session the teardown prevents (+~17s wasted). Veto the recovery
   with !VINPIN_OCR.launcher so it fires only on a real Disconnected drop.

Keeps never-throw, budget, spinner-guard, Fiat/Dialogys fallbacks intact. Adds a
born-stuck-no-cooldown test and a clean-logout-launcher-no-relaunch test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:41:35 +03:00
281c54a423 fix(vinpin): Renault-decode robustness for a DOWN Rpartstore (ffmpeg + flyout/close/logout/cooldown)
Root cause: ffmpeg is absent in the prod worker, so vinpin.ocr cropScale silently
returns the full 1600x900 frame — the tiny centered Rpartstore launch-error modal
is illegible, so a DOWN Rpartstore is misread as a spinner and thrashes the seat.

- Dockerfile: install ffmpeg so cropScale actually crops+3x-upscales (restores all
  clipped OCR: modal-crop error detect, Fiat modal, Renault header).
- ensureRpartstore: gate the "already open" short-circuit on a CONTENT token
  (rpartstoreLoaded), not rpartstoreOpen which false-matches the flyout/title word
  "Rpartstore"; click the flyout entry when the submenu is up over the grid; keep a
  late open-window branch so a spinner/vehicle-page window still counts as present.
- closeRpartstoreTab: never click windowClose(1298,14) (it hits the language
  selector and wedges the grid); gate the retry on a content token; Escape after.
- cleanTeardown: dismiss any blocking modal BEFORE "Çıkış yap" so logout is a clean
  RDS log-off (not a dirty channel disconnect); recover a Disconnected dialog via
  disconnectedClose(953,505) + relaunch VINPIN app for a fresh grid.
- Rpartstore-down cooldown (10m): a launch-error / repeated load-failure routes
  Renault decodes straight to Dialogys (skip reopening a down catalog); a confirmed
  load clears it.
- ensureBrandGrid: dismiss a wedging launch-error modal + short-retry instead of
  burning the 84s dead-wait.
- parseRenaultHeader: ignore the status-bar license-expiry date when reading the
  model year; sessionAlive matches RDST01/RDST02 (seat load-balances).
- Keeps never-throw, VINPIN_DECODE_BUDGET_MS, sessionPoisoned, the acquire cap,
  spinner-guard, relogin-cap and the Fiat/Dialogys fallbacks intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:32:39 +03:00
5b93864dd5 Merge pull request 'fix(vinpin): dismiss leftover launch-error modal + cap relogin per decode (browser-disconnect loop)' (#232) from dev into main 2026-07-15 07:01:23 +03:00
f54511d393 fix(vinpin): clear leftover launch-error modal on acquire give-up + cap relogin
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The Renault decode fell to not_found when Rpartstore is DOWN (hard
launch-error modal) even though Dialogys should take over. Root cause:

- acquireLoadedRpartstore's budget-exhausted / never-loaded return-false
  paths left the centered launch-error modal on screen (only the flaky
  OCR error-detect branch dismissed it). The modal then blocked the
  Dialogys grid-return.
- returnToBrandGrid's tab-✕ can't close a centered dialog, so every
  "not on brand grid (try N/4)" wedged and re-entered ensureBrandGrid,
  which escalated to logoutAndRelogin → close()+launch() ("browser
  disconnected — will relaunch") on EVERY iteration, thrashing on a stale
  page ref until the 180s budget → not_found.

Fixes (conservative, all safety nets intact):
1. Wrap acquireLoadedRpartstore so EVERY false return runs a best-effort
   defensive dismiss (Escape → click launch-error OK 868,530 → Escape),
   unconditional of the OCR read. Harmless when no modal is up.
2. returnToBrandGrid + ensureBrandGrid dismiss a possible centered modal
   before the tab-✕ close so a leftover dialog can't wedge the loop.
3. Cap the logout+relogin escalation to ONE attempt per decode/warm-up
   (reloginUsedThisDecode) — a capped exhaustion poisons the seat for a
   clean cold restart instead of looping close()+launch() until budget.

Keeps the OCR fast-path branch, maxOpens/acquireBudgetMs=14s, fcc0298,
61b5769, Fiat path, never-throw/budget/poison all intact.

Tests: +3 (budget-exhausted defensive dismiss; grid-return modal-clear
before tab-✕; relogin capped to one attempt) — 88 vinpin tests green,
tsc + biome clean. Needs prod validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 06:47:28 +03:00
9f6693e833 Merge pull request 'fix(vinpin): cap Rpartstore acquire at ~1 open via 14s sub-budget' (#231) from dev into main
Reviewed-on: #231
2026-07-15 06:25:45 +03:00
e0b3de8dc7 fix(vinpin): cap Rpartstore acquire at ~1 open via 14s sub-budget
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Rpartstore's launch-error modal OCR-detection is unreliable across renderings,
so a DOWN Rpartstore was grinding all 3 reopens (~54s) + dirtying the seat →
Dialogys fallback couldn't finish inside the decode budget → not_found.
Lower acquireBudgetMs 90s→14s so the loop bails after the first open+poll (~22s)
straight to Dialogys on a still-clean seat. Healthy Rpartstore loads on the first
open and is used as before; maxOpens kept so tests still exercise the reopen path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 06:18:55 +03:00
6c5f8401f8 Merge pull request 'fix(vinpin): detect Rpartstore launch-error via upscaled modal crop, not full 1x frame' (#230) from dev into main
Reviewed-on: #230
2026-07-15 06:01:39 +03:00
542ab0cb2b fix(vinpin): detect Rpartstore launch-error via upscaled modal crop, not full 1x frame
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The prod Rpartstore→Dialogys bail regressed: a DOWN Rpartstore was still
misclassified as a "born-stuck spinner", burning 3 reopens (~54s) and pushing
the decode past the 180s budget → not_found. The rpartstoreLaunchError regex
never matched because acquireLoadedRpartstore's load-poll OCR'd the FULL
1600x900 frame at 1x — at which the small centered Cyrillic modal ("Ошибка
запуска каталога") is illegible to tesseract (it returns the surrounding
brand-grid tiles and drops the modal text). The regex text was actually fine;
the modal was never fed to it.

Root cause (verified live 2026-07-15, seat trvinpin47828): wrong OCR
resolution/region, not wrong regex.

Fix:
- New VINPIN_RPARTSTORE_ERROR_REGION (centered modal crop); OCR it UPSCALED (3x)
  so "Ошибка запуска каталога" reads as "Owwu6ka 3anycka KaTanora" and matches.
- pollRpartstoreState(): each poll reads LOADED off the full frame (large Latin
  text, unchanged) AND the launch-error off the upscaled modal crop → bail on the
  FIRST open, no wasted reopens. Genuine-spinner reopen path preserved.
- Also catch the launch error when ensureRpartstore/raiseWarmWindow can't confirm
  a window (modal is over the grid, no catalog chrome) → dismiss + bail.
- rpartstoreLaunchError regex: add the verbatim live transliterations
  (Owwu6ka/OwwbKa); 3anycka+KaTanora remain the stable anchors.

Live verification (seat trvinpin47828, exclusive night access):
- Rpartstore is DOWN server-side (hard launch error, NOT a spinner).
- New detection returns launchError on open 1 → bail, no reopens.
- Dialogys fallback decoded VF1RFE00653633190 → RENAULT Kadjar (HFE) in 52.2s
  (well under the 180s budget).

Budget/poison/livelock/Fiat paths untouched. 85 vinpin unit tests green;
typecheck + biome clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 05:53:22 +03:00
74c718fb18 Merge pull request 'fix(vinpin): detect Rpartstore hard launch-error → bail straight to Dialogys' (#229) from dev into main
Reviewed-on: #229
2026-07-15 05:16:15 +03:00
0ad3f114f3 fix(vinpin): detect Rpartstore hard launch-error → bail straight to Dialogys
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Rpartstore currently throws an immediate hard launch-error modal ("Ошибка
запуска каталога" / title "Renault Rpartstore") within ~8-10s of every open —
a server-side/entitlement failure that reopening never fixes. The old code
misclassified it: the error dialog's title matched rpartstoreOpen so
ensureRpartstore returned true, but it lacked the rpartstoreLoaded content
markers, so acquireLoadedRpartstore judged it a born-stuck spinner and burned
all 3 reopens (~54s) before falling back to Dialogys — which then ran out of
the 150s decode budget → not_found. Dialogys itself decodes correctly (~25s).

Fix (detect-and-bail on the FIRST open, no wasted reopens):
- constants: add VINPIN_COORDS.rpartstoreLaunchErrorOk (868,530) + the
  VINPIN_OCR.rpartstoreLaunchError pattern (matches the real Cyrillic AND its
  stable eng-OCR transliteration "Owwnbka 3anycka KaTanora").
- acquireLoadedRpartstore step 2: poll now stops on loaded OR launch-error and
  classifies via the returned OCR text; a launch error dismisses the modal and
  returns false immediately → straight to Dialogys (reopen loop untouched for
  genuine spinners).
- ensureRpartstore: submenu-open poll also stops fast on the launch error
  instead of dead-waiting afterRenaultCatalogOpen.
- bump VINPIN_DECODE_BUDGET_MS 150s→180s (cheap safety margin).
- tests: launch-error → false after ONE open (no reopens, dismiss clicked);
  genuine spinner still reopens; OCR-pattern matches Cyrillic + transliteration.

Never-throw contract, sessionPoisoned, budget teardown, cold/Dialogys
fallbacks, the fcc0298 spinner-guard, the 61b5769 establish/teardown fix and
the Fiat ePER path are all intact. Skipped the warm-path Dialogys field-clear
tweak — runDialogysSearch is shared with the cold path and switching its clear
step would change cold behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 04:24:02 +03:00
68e3eb4a61 Merge pull request 'dev' (#228) from dev into main
Reviewed-on: #228
2026-07-15 03:12:22 +03:00
61b5769e48 fix(vinpin): reliable warm-daemon establish (clean teardown + tab-close + backoff)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Root cause of warm-establish failures was resume-into-dirty-session +
broken window-close + no backoff, not OCR/detection. Four composing fixes:

A. Clean teardown (cleanTeardown): before the browser close(), close each
   open catalog window via the corrected tab-✕ then click "Çıkış yap" logout
   to END the RDS session, so the next warm-up starts from a fresh login/grid
   instead of resuming into the last-open 3-window desktop. Wired into
   teardownWarm() and both failed-warmUp exits. Bounded + never-throw.

B. Fix close coords + tab-✕ primary. catalogTabClose {135,45}→{93,45}
   (validated live). In ensureBrandGrid/returnToBrandGrid the tab-✕ is now the
   PRIMARY close; the windowClose {1298,14} click (which opens the HTML-Access
   language dropdown) is no longer used there. Escape pressed after each close
   to dismiss an accidental dropdown before re-OCR. After N failed closes,
   ensureBrandGrid escalates to logout+relogin (one-shot, no recursion) instead
   of limping into the ePER-open loop.

C. Realistic grid wait. afterLogin 20_000→45_000 (real grid render ~32-46s).

D. Backoff between re-warm attempts. A failed warmUp sets a cooldown (60s,
   exponential to 5min) that BOTH reconcile() and decode()'s warm-on-demand
   honour; a successful warm resets it — so a failing seat is no longer
   hammered every ~60s leaving fresh dirty windows.

Safety nets preserved: never-throw contract, VINPIN_DECODE_BUDGET_MS,
sessionPoisoned, cold/Dialogys fallbacks; fcc0298 Rpartstore spinner-guard,
Fiat ePER path, and Russian-dialog dismissal (746,454) untouched. Cannot be
exercised in dev (single seat on prod) — needs prod validation on a rested seat.

Tests: +8 unit tests (clean-teardown ordering, tab-✕ primary + relogin
escalation, warm-up backoff respected by reconcile + warm-on-demand + reset).
81 vinpin tests green; tsc + biome clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:11:21 +03:00
8e10ebc883 feat(web): redirect path-style /dashboard/settings/<tab> to ?tab= search param
Unsubscribe confirmations (and any stale links) used the path form which
had no route and fell to the SPA not-found screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:47:24 +03:00
29e10b432f feat(notifications): visible unsubscribe footer link in lifecycle mails
- inject signed unsubscribeUrl into every optional-workflow Novu payload
  (templates render it via {{#if unsubscribeUrl}} footer)
- add 'conversion' campaign workflow to OPTIONAL_WORKFLOWS + email_marketing
  category so its one-click tokens validate and opt-outs suppress it
- declare UNSUBSCRIBE_SECRET/URL_BASE/EMAIL in env schema (""→undefined
  preprocess against the url().optional() boot-crash trap), .env.example and
  both compose env blocks
- fix confirmation-page settings link (?tab=notifications)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 16:35:47 +03:00
55dd34a450 Merge pull request 'dev' (#227) from dev into main
Reviewed-on: #227
2026-07-14 15:50:40 +03:00
66b39862c8 fix(part-prices): araç-marka etiketlerini de orijinal say (FORD/GM/BMW/Mercedes/VW)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Kullanıcı düzeltmesi: kokpit tur ham araç-marka etiketlerini "yansanayi"
sayıyordu — yanlış. FORD/GM/BMW/Mercedes/Volkswagen/Renault… OE etiketidir.
Orijinal tespiti artık kokpit tur='orjinal' setine değil, OE aile üyeliğine
dayanır: OE_SUPPLY_LABELS OE_FAMILIES'ten TÜRETİLİR (+ jenerik ORJINAL), böylece
OE-tespiti ile aile-filtresi drift etmez ve tüm araç markaları orijinal sayılır.
Gerçek yan sanayi PARÇA markaları (Bosch/Febi/Valeo/TRW) hiçbir ailede yok →
orijinal değil. GM Opel ailesine eklendi (TR'de Opel OE'si). Redis v3→v4 (eski
"miss" değerleri bayat). kokpit'e DOKUNULMADI.

Ölçülen etki (dev): grafikli kod %0,77→%0,84; artış sase kataloğu ile takip
beslemesinin bu markalardaki kod kesişimiyle sınırlı (ör. takip'te 4.859 Ford-
stokta koddan 335'i sase kataloğunda).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:34:10 +03:00
fcc02984dd fix(vinpin): Rpartstore acquire-with-spinner-guard for Renault decode
A freshly-opened Rpartstore instance sometimes gets "born stuck" on an
infinite spinner (survives raise/maximize). The only reliable fix, proven
in a live spike, is to CLOSE the stuck instance and reopen a FRESH one.

Replace the old "raise Rpartstore → if not focusable reopen → else fall to
Dialogys" with a bounded load-verify-else-reopen loop applied to BOTH the
warm-daemon Renault path (warmRenaultDecode) and the cold per-decode path
(runRenaultFlow):

- acquireLoadedRpartstore(): bring a Rpartstore window forward, OCR-verify it
  actually rendered its search-home landing markers (new rpartstoreLoaded set —
  content tokens the spinner lacks), and if still spinning close the tab and
  reopen a fresh instance. Retries up to VINPIN_RPARTSTORE.maxOpens (3) times.
- Bounded inside the decode wall-clock budget AND a tighter acquireBudgetMs
  (90s) sub-cap, so a permanently-stuck Rpartstore still leaves headroom to
  fall back to Dialogys — never a livelock.
- Rpartstore stays PRIMARY (richer Turkish catalog); Dialogys only as a
  last resort once reopen attempts are exhausted or it genuinely misses.
- Fiat ePER path unchanged; sessionPoisoned / never-throw contract preserved.

Adds unit tests for reuse / spinner→reopen→loaded / exhausted→false /
budget-bail / Rpartstore-primary-on-hit / exhausted→Dialogys-fallback.

Could not live-test (single Vinpin seat is held on prod) — needs prod
validation after promote.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 15:29:12 +03:00
edde2dc3b4 fix(part-prices): OEM fiyatını kodun araç markasına göre filtrele (PSA vs OPAR)
Aynı OEM koduna farklı OE dağıtıcıları düşebiliyor (Stellantis: PSA ₺5.531 vs
OPAR ₺3.459) → OE-only havuz bile iki dağıtıcıyı medyanlayıp hayalet fiyat
veriyordu. Artık kart, kodun kendi araç markasına göre tek OE ailesine süzer.

- part-prices.logic: marka→OE-dağıtıcı-ailesi haritası (OE_FAMILIES: PSA=
  Peugeot/Citroen/DS, FIAT=Fiat/OPAR/Tofaş, RENAULT=Renault/Dacia/MAIS, ...).
  filterOffersForBrand: araç markası → yalnız o ailenin orijinal teklifleri
  (jenerik ORJINAL aile-belirsiz olduğu için elenir); yan sanayi markası çipi
  → yalnız etiket-uyumlular; markasız → tüm OE aileleri (taban).
- Web: OEM kartı kodun araç markasını oem-vehicles'tan (en çok geçen brandName)
  türetip geçirir; başlık "Orijinal (OE) fiyat analizi · <Marka>". Kart marka
  çözümü için oem-vehicles'ı bekler.
- vehiclesByOem NORMALIZE eşleşmeye geçti + parts_oem_code_norm_idx functional
  index (migration 0033): PSA kodları boşluklu saklandığından ("9827 622 780")
  exact match onları kaçırıyordu — marka bu yüzden çözülemiyordu. Reverse
  "kataloğunuzda" bölümü de artık reformatlı kodları buluyor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:07:55 +03:00
f52cafd622 Merge pull request 'dev' (#226) from dev into main
Reviewed-on: #226
2026-07-14 13:51:22 +03:00
d0caf57e89 fix(part-prices): OEM ana kartında yalnız orijinal (OE) fiyatları göster
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
OEM detay ana fiyat kartı seriyi markasız istiyordu; allowBrandless uzun
kodlarda orijinal + yan sanayi tekliflerini tek havuzda medyanlıyordu →
hiçbir satıcının fiyatı olmayan "hayalet medyan" ve marka stok girip-
çıkmasından fiyat değişmeden sahte grafik sıçraması (2026-07-14 ölçümü:
9827622780 pg p50 4.495₺ = PSA 5.531 ile OPAR 3.459'un ortası).

- filterOffersForBrand: markasız istek artık yalnız orijinal (OE/dağıtıcı
  etiketli) teklifleri kullanır; araç-markalı OE çapraz-ref çipleri de
  OE-only'ye iner, yan sanayi markasına orijinal fallback'i kaldırıldı.
- Orijinal sınıflaması otoritesi = kokpit marka tur='orjinal' seti (39
  marka, statik; PSA/MAIS/OPAR/ORJINAL... — FORD/GM/BMW/Mercedes/VW kokpit'te
  yansanayi, bilinçli dışarıda). Runtime kokpit bağımlılığı yok.
- Web: kart başlığı "Orijinal (OE) fiyat analizi" + açıklama/dipnot orijinal
  bilgisini yazıyor; ana OEM kodunun ölü markasız batch push'u kaldırıldı.
- Redis cache v2→v3 (anahtar üreticileri export'lanıp worker ile paylaşıldı);
  migration 0032 eski karma-havuz part_price satırlarını siler → OE-only
  kurallarla lazy re-backfill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:28:27 +03:00
32655ead7b chore(vinpin): add VINPIN_WARM_DAEMON compose env ref (api+worker) so Coolify can inject the warm-daemon flag
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:21:35 +03:00
98376747cd feat(vinpin): persistent warm-session daemon (taskbar-raise decode) with cold fallback
Replace the per-decode "launch browser + login + open catalog" model with a
persistent warm Vinpin seat that eliminates cold-start, brand-switch cost and the
seat livelock. Additive + fail-safe: every warm operation degrades to the proven
cold per-decode path, so behaviour never regresses.

Warm daemon (VinpinDaemonService, worker-process singleton):
- Scheduler warms the seat at 08:00 and tears it down at 21:00 Europe/Istanbul
  (proper TZ via Intl, no hardcoded offset); warms on worker start if inside hours;
  reconcile() every 60s with a reentrancy guard.
- Keepalive nudges the RDS session (mouse.move) every ~75s while warm+idle; it
  SKIPS during any active seat op (busy flag) and never takes a lock that blocks a
  decode.
- decode(vin): inside hours ensure warm (warm-on-demand once) then delegate to the
  driver; off-hours delegate straight to the cold path. Never throws.

Driver warm path (VinpinDriverService):
- warmUp() launches+logs in ONCE and opens Fiat ePER + Renault Rpartstore + Renault
  Dialogys windows without closing each other, then OCR-binds each taskbar button
  (order read via OCR, not hardcoded; raise self-heals by probing slots + OCR
  verify). isWarm()/teardownWarm()/keepalivePing() added.
- warmDecode(): taskbar-raise the brand window (Fiat→ePER, Renault→Rpartstore w/
  Dialogys fallback), run the EXISTING in-catalog decode on the warm window, OCR
  the modal, parse, then Escape to ready the field for the next VIN. Runs under the
  wall-clock budget; a hang still aborts.
- Health-recovery: a dropped seat (Disconnected/no-free-sessions OCR marker) →
  teardown + re-warm ONCE, then retry the decode once.
- Refactored runDialogys into openDialogysSubmenu + runDialogysSearch so the warm
  path searches without a window-closing reopen; cold Dialogys flow unchanged.

Safety nets retained: VINPIN_DECODE_BUDGET_MS + sessionPoisoned breaker (warm
budget abort → teardown+poison+null), single-seat serialization (runExclusive),
decode() never throws. Gated by VINPIN_ENABLED; VINPIN_WARM_DAEMON=false forces the
legacy cold path (kill-switch). Widened VINPIN_MODAL_REGION to ~900px.

Wiring: processor calls getVinpinDaemon().decode(); worker starts the daemon on
boot and stops it (releasing the seat) on shutdown. BullMQ concurrency 1 +
attempts:1 unchanged.

Tests: business-hours warm/teardown scheduling (injected clock/TZ), brand→taskbar
routing, taskbar OCR-order binding, keepalive-skips-during-decode, and
session-drop→re-warm→retry recovery. All existing vinpin/queue tests stay green.

NOTE: un-dev-testable (prod holds the single seat) — pixel/taskbar coords are
OCR-verified + marked TUNE and need live prod validation; warm falls back to cold
until confirmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:19:20 +03:00
ccf2417cc8 Merge pull request 'fix(vinpin): break the single-seat decode livelock (attempts:1 + budget + poison)' (#225) from dev into main
Reviewed-on: #225
2026-07-14 11:32:46 +03:00
d253a43ad2 fix(vinpin): break the single-seat decode livelock (attempts:1 + budget + poison)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
A bad/unresolvable VIN could leave a catalog window open on the shared Vinpin
seat; the next decode's ensureBrandGrid found it "resumed open" and looped
(close→brand-grid-not-confirmed→ePER-open→browser-disconnect→relaunch) forever.
BullMQ attempts:2 + 30s backoff auto-re-fed every failure straight back into the
stuck seat, starving all real decodes for minutes.

- queue: attempts:1, drop the 30s exponential backoff (extract VINPIN_DECODE_JOB_
  OPTIONS). The driver already runs its own bounded internal retries; a BullMQ
  retry on top is what compounded the livelock. Null decode still persists as
  not_found; a hard infra throw stays user-retriable (failed).
- driver: hard per-decode wall-clock budget (VINPIN_DECODE_BUDGET_MS, 150s) via
  withDeadline() racing each attempt; on abort → close() + poison seat + return
  null (no retry into the stuck state).
- driver: sessionPoisoned flag — set at nav-loop exhaustion (ensureBrandGrid /
  establishSession), budget abort, and failed post-decode cleanup; the NEXT
  decode forces a full cold re-establish instead of reconnecting to the resumed
  desktop. Cleared on any confirmed-clean grid/catalogue.
- driver: finally-cleanup after every decode — on failure/not-found return the
  seat to a clean brand grid; if that can't reach the grid, poison + tear down.
- driver: basic VIN sanity (17 alphanumerics) before touching the seat.

Healthy Fiat/Renault happy paths are byte-identical when nothing is stuck.
Cannot be live-tested (seat is on prod) — needs prod validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:29:43 +03:00
c2bb1e7382 Merge pull request 'fix(vinpin): prefer exact model over wrong-market catalog in matcher' (#224) from dev into main
Reviewed-on: #224
2026-07-14 10:55:06 +03:00
1fade89df4 fix(vinpin): prefer exact model over wrong-market catalog in matcher
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The Vinpin catalog matcher could route a European Renault VIN
(VF1RFE00653633190, decoded "KADJAR") onto the China-market catalog
"KADJAR ÇİN" (source XZH, 22 categories) instead of the correct European
"KADJAR" (XFE, 44 categories), serving wrong parts. Root cause: the
Turkish "ÇİN" lost its Ç/İ to the ASCII token strip and collapsed to a
dropped 1-char "N", so "KADJAR ÇİN" tokenized identically to "KADJAR" —
the market qualifier was invisible to the scorer.

Fixes:
- modelTokens now folds diacritics (NFD + combining-mark strip) so
  "ÇİN" survives as the ASCII token "CIN".
- Scorer prefers an EXACT normalized model match (no extra tokens) over
  a superset, via a bonus kept smaller than the year-range swing so
  multi-generation routing (2022 TIPO-EGEA → MCA) is unaffected.
- New MARKET_QUALIFIERS set (ÇİN/CIN, CHINA, CHINE, RUSYA, ... grounded
  in the real Renault/Dacia catalog rows) heavily penalizes candidates
  whose EXTRA tokens are region qualifiers; generation tokens are not
  penalized, so generations stay matchable.
- Richer-catalog (categoryCount) tiebreak among equal-score candidates;
  match() query selects the category count via a correlated subquery.

Adds unit tests incl. the KADJAR case, CLIO/DUSTER generation cases, and
diacritic-folding. Fiat behavior unchanged; all 39 vinpin tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:47:55 +03:00
7387c02da0 Merge pull request 'fix(web): cover cold Vinpin decode + drop hardcoded Fiat no-catalog copy' (#223) from dev into main
Reviewed-on: #223
2026-07-14 10:22:48 +03:00
7069ceaeae fix(web): cover cold Vinpin decode + drop hardcoded Fiat no-catalog copy
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Two Vinpin decode-flow UX bugs in the search page:

1. Poll window (VINPIN_MAX_POLLS 6→30, still 3s interval) now covers a
   cold Vinpin VDI decode (~60-100s) instead of bailing at 18s. On
   poll-exhaust we no longer fall through to the no-catalog dead-end;
   instead a reassuring brand-neutral "still working, ready shortly"
   card (decodePendingHint) — the decode caches server-side so a later
   re-submit returns instantly.

2. The poll-exhaust fallback previously forced brandName:"Fiat" onto the
   no-catalog prompt, so a Renault VIN read "Fiat". Removed. The
   no-catalog copy is brand-aware via the API's noCatalog.brandName, with
   brand-neutral fallback keys (noCatalogHintNeutral / browseCatalogCtaNeutral)
   when no brand is available. A Renault VIN never says "Fiat".

Web-only, no flag. New i18n keys added to tr.json + en.json. No API change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:21:43 +03:00
cf9454482f Merge pull request 'perf(vinpin): in-session Renault→Fiat swap + OCR-poll waits + input micro-trims' (#222) from dev into main
Reviewed-on: #222
2026-07-14 10:09:38 +03:00
eaa500ddd5 perf(vinpin): in-session Renault→Fiat swap + OCR-poll waits + input micro-trims
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Latency optimizations in the flag-gated (VINPIN_ENABLED) Vinpin decode driver.
Every change keeps the existing fixed-wait value as a fallback cap, so
worst-case behaviour and robustness are unchanged — only the common case is
faster. No live seat session was run (prod holds the single Horizon seat).

#2 Renault→Fiat symmetric swap: ensureReady no longer tears the whole browser
down on a Renault→Fiat flow switch (was a ~60-80s cold restart). It now keeps
the authenticated Horizon/VinPower session, returns to the brand grid via the
existing ensureBrandGrid (which closes a resumed foreign catalog window) and
reopens Fiat via the normal tile flow — symmetric with the Fiat→Renault
direction. Guarded fallback: if the in-session swap can't reach the grid /
throws, it falls back to close()+cold re-establish.

#3 OCR-poll-until-ready: replaced big fixed post-action sleeps whose completion
is OCR-detectable with a capped poll (screenshot→ocrRegion→regex every ~700ms,
return on match, cap == old fixed wait). Converted: afterFiatOpen(12s),
afterVinSubmit(7s, Fiat modal), afterRpartstoreSubmit(8s),
afterRenaultCatalogOpen(12s, ×2), plus cold-path afterLogin(20s) and
afterVinPowerLogin(18s). Left afterVinpinLaunch(14s) fixed — the VinPower login
dialog has no reliable OCR marker (detected via DOM), and it doubles as a
generic connecting-screen settle. Left afterDialogysSubmit fixed (out of scope).

#4 Micro-trims: field-clear Backspace burst 40→5 (VINPIN_FIELD_CLEAR_BACKSPACES),
key-type delay 45-50ms→20ms (VINPIN_TYPE_DELAY_MS, 17-char VIN ~850→~340ms),
afterAlertDismiss 700→250ms.

New pure/injectable pollForText helper in vinpin.ocr.ts (unit-tested:
early-return, cap-never-exceeded, first-read match). typecheck + biome clean;
31 vinpin unit tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 09:57:40 +03:00
e47e795a09 Merge pull request 'dev' (#221) from dev into main
Reviewed-on: #221
2026-07-14 09:13:52 +03:00
77c93af9a0 feat(vinpin): Renault/Dacia decode akışı (Rpartstore + Dialogys)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
VINPIN köprüsüne Fiat'ın yanına Renault/Dacia desteği eklendi.

- Marka yönlendirme: selectVinpinBrandFlow(vin) WMI'den akışı seçer
  (Renault/Dacia → Rpartstore/Dialogys, geri kalan → mevcut Fiat ePER,
  Fiat davranışı byte-identical). isVinpinBrandAllowed allowlist'i
  fiat + renault + dacia'ya genişletildi.
- Renault akışı durum-toleranslı: koltuk son katalogu (Rpartstore)
  sunucu-taraflı hatırlayıp açık resume ediyor; ensureRpartstore grid /
  submenu / açık-pencere / yükleniyor durumlarını tanıyıp kendini
  toparlıyor. Rpartstore ana akış, Dialogys best-effort fallback.
- Ortak ensureBrandGrid artık resume olmuş yabancı katalog penceresini
  (Rpartstore/Dialogys/ePER) kapatıp grid'e dönüyor → Renault↔Fiat
  ardışık decode'ları (tek koltuk) artık kırılmıyor.
- parseRenaultHeader/isUsableRenaultParse: OCR başlığından model+marka
  (RENAULT/DACIA) + yıl; bilinen-token allowlist ile contiguous model
  koşusu. Matcher + processor kararlaştırılan markaya göre PL24
  catalog_vehicle eşliyor (Fiat varsayılan korunur).
- Testler: Renault parser + marka-yönlendirme/allowlist birim testleri.

Flag-gated (VINPIN_ENABLED). Canlı doğrulama: 5/5 Renault/Dacia VIN
(Kadjar, Clio II, Clio IV, Dacia Duster, Latitude) doğru decode; Fiat
Egea spot-check korunuyor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 08:44:09 +03:00
24716d03a9 Merge pull request 'revert(build): drop ineffective cache-bust (#219)' (#220) from revert/build-cache-web into dev 2026-07-14 08:13:46 +03:00
6de7839107 revert(build): drop ineffective per-commit cache-bust (#219)
SOURCE_COMMIT isn't substituted into docker-compose build args by Coolify
(resolved to a constant "unknown"), so the cache-bust never fired. Removing the
dead ARG/RUN + compose arg. Web-only deploys use a Coolify force-rebuild instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 08:13:44 +03:00
edcd168d48 Merge pull request 'fix(build): per-commit cache-bust for web-only deploys' (#219) from fix/build-cache-web into dev 2026-07-14 07:43:07 +03:00
c20fc5462a fix(build): per-commit cache-bust so web-only changes actually deploy
Auto-deploys (docker build with cache) were reusing a stale build layer, so
web-only commits shipped an old bundle — the switcher removal (#216) and the
canonical fix (#217) only appeared after a manual --no-cache/force rebuild.
Reference SOURCE_COMMIT (substituted by Coolify) right before `COPY . .`, so the
build layer's cache key changes every commit and COPY + `pnpm build` re-run. The
deps stage stays cached on the lockfile, so installs aren't repeated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 07:43:05 +03:00
4bba42fc13 Merge pull request 'promote: Fiat eper-default katalog (Orijinal→ePER, Birleşik→kanonik)' (#218) from dev into main 2026-07-14 07:35:32 +03:00
2bf7525fe2 Merge pull request 'fix(catalog): Fiat Birleşik = canonical view (not raw pcat)' (#217) from feat/fiat-birlesik-canonical into dev
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
2026-07-14 07:11:53 +03:00