Commit Graph

428 Commits

Author SHA1 Message Date
9ccf0863f0 fix(catalog/ford): disambiguate identical catCode buttons + relabel "Model Yılı" → "Varyant"
Two visible defects when a user picks a Ford model (variant selector step):

1. **N identical buttons.** Ford's `modelFamilyToModelList` gives every sub-catCode
   the same caption — just the family name. parseFordCatCodesForFamily returned
   `[{code:CBV,name:Kuga},{code:CBS,name:Kuga},{code:CTD,name:Kuga}]` for Kuga
   2012-2020. The UI rendered three indistinguishable "Kuga" pills with no way
   for the user to tell them apart. Same on Galaxy ("Galaxy"/"Galaxy"), every
   multi-catCode family.

   Fix: detect duplicate baseNames per family and graft a disambiguator —
   year first (friendliest), catCode as a last resort. Result:
   "Kuga (2013-2016)" / "Kuga (2016-2020)" / "Kuga (CTD)" — or, when year is
   present even for unique entries, always include it for a uniform look.

2. **Section title was lying.** UI label was `catalog.fordVariant.modelYear`
   = "Model Yılı" ("Model Year") but for Ford LEGACY these are catCodes
   (generation identifiers), not years. Rename the key value to "Varyant" /
   "Variant" — semantically correct for both Ford catCodes and Volvo years
   (Volvo flows through the same selector and its codes ARE years; "Varyant"
   covers both).

Cleared the affected Redis cache (`pl24:ford:config:*`) on dev so the next
hit fetches fresh disambiguated data; prod cache will roll over on its own
TTL after promote.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 03:12:20 +03:00
2040686e4d fix(catalog): Hyundai/Kia/Nissan schema images (image-board ticket URL)
These three brands rendered parts with NO schema illustration (and no hotspots):
schema_pics=0 vs 56/29/19 parts on dev. Root cause: extractPsaImageTicketUrl only
read imageViewerParamsUrl from the id="jsinitparams" data-params attribute (where
Ford/PSA/Opel/Volvo put it). Hyundai/Kia/Nissan's jsinitparams carries only a
localization dict ({"commonTxt":{"ok":"TAMAM"}}); their imageViewerParamsUrl lives
in a separate <script> JSON blob → extractor returned null → image pipeline skipped.

Fix: when the jsinitparams-attribute parse yields no URL, fall back to a whole-HTML
scan for "imageViewerParamsUrl":"…" (decoding &). Brand-agnostic and additive;
Ford/PSA/Opel/Volvo unchanged (attribute path still wins). Proven live via the de
client: Hyundai 600x820/3 hotspots, Kia 600x820/11, Nissan 1024x560/13 — all download OK.

Also: drop a pre-existing noParameterAssign in fetchP4Page (derive `account` const
from `accountParam` instead of reassigning the param). +3 extractor unit tests.

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:52:14 +03:00
67513b356a chore(debug): revert p5fiat model-list probe
Finding: /p5fiat/extern/vehicle/models IS the endpoint (de-licensed, demo:false) but
returns 'Cannot parse null string' regardless of manufacturer/make/brand param — needs
an undiscoverable param from the P5 SPA session (launchCatalog.do flow). Requires a real
PL24 web-UI network capture to resolve. Turkish Tofaş NM4/Egea is not in this catalog
anyway, so catalog-browse value is European-Fiat-only. Kept the generic P5 endpoint
self-discovery fallback. Fiat VIN-decode (European) is unaffected and still works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 18:19:30 +03:00
ca535c6fcf chore(debug): TEMP probe p5fiat models manufacturer param 2026-06-05 18:15:30 +03:00
1995ab6035 chore(debug): TEMP probe p5fiat model-list endpoints + launchCatalog 2026-06-05 18:11:23 +03:00
c991c3846a feat(catalog): Ford de variant-drill — parse json-sub-group → image-board parts
Ford parts are licensed only on the de account. The de drill chain is:
group.action (engine/transmission restriction nodes, HTML) → json-main-group.action
(maingroups JSON) → json-sub-group.action (subgroups JSON) → image-board.action (parts).
The existing json-sub-group handler only knew the Nissan shape ({caption,code,url});
Ford de subgroups are {caption,id,subheader,jsonUrl:null,url:null} — leaves carry no
URL. Add parseFordSubGroupsJson: skip subheader rows, and for each leaf construct the
illustration URL (subGroupId = mainGroupId+subheaderId, bomId = subGroupId+leafId →
image-board.action?bomId=…), which drills to parts via the existing image-board path.
Route Ford (fordp/fordt) to de alongside Hyundai/Kia/Nissan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:36:54 +03:00
74d2c9d480 chore(debug): TEMP inspect Ford selected-subgroup response for inline parts 2026-06-05 17:30:19 +03:00
01d020ffdd chore(debug): TEMP probe Ford group-detail parts page 2026-06-05 17:25:24 +03:00
83170bb40c chore(debug): TEMP probe Ford leaf parts endpoints 2026-06-05 17:19:57 +03:00
9c3f3bf970 chore(debug): TEMP dump Ford subgroup object keys/leaf 2026-06-05 17:15:35 +03:00
e22b8820f9 chore(debug): TEMP follow Ford de json maingroup->subgroup chain 2026-06-05 17:09:38 +03:00
5ad68c2a1e chore(debug): TEMP dump Ford json-main-group raw JSON 2026-06-05 17:05:35 +03:00
44284d5f82 chore(debug): TEMP map de Ford variant-tree drill structure + route ford to de
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:57:13 +03:00
678a33a57a fix(catalog): keep only Hyundai/Kia/Nissan on de (Ford/Polestar not de-fixable)
Tested Ford & Polestar on de: Ford returns a real variant tree but drilling can't
reach parts (needs dedicated Ford variant-drill logic); Polestar returns 0
categories (data gap). Revert them to tr; keep the verified Hyundai/Kia/Nissan
de-routing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:49:20 +03:00
dda3931933 chore(test): TEMP route Ford/Polestar to de to test licensing (revert if no)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:44:20 +03:00
3163cf8a3e fix(catalog): route Hyundai/Kia/Nissan to de account (licensed; tr is demo)
Verified on dev: the tr account serves NOT_LOGGED_IN_DEMO for Hyundai/Kia/Nissan
parts (browse-only), while the de (account 2) subscription is licensed and returns
real data (de: 266 Hyundai / 169 Kia models, non-demo, ~88/64 drill rows). Route
every P4 fetch for these brands to de in fetchP4Page (+ resolveAccount), and ensure
the de service token is authorized. Existing tr-coded catalog_vehicles must be
re-seeded so they carry de catalog codes (ops step on deploy).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:36:54 +03:00
a101fe81c2 chore(debug): TEMP move de-probe into fetchPartsByPath (hyundai drill path)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:29:04 +03:00
5a5ab35e7c chore(debug): TEMP de full-flow probe for Hyundai/Kia/Nissan licensing (revert after)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:24:41 +03:00
9da0d4df75 revert(catalog): drop de-account demo fallback (de lacks EUR/TR catalogs)
Confirmed via dev probes: the de account is non-demo (licensed) for Hyundai
parts but does NOT have the EUR/TR-region catalogs our vehicles use, so the
fallback returned non-demo-but-empty and just doubled upstream load. Root cause
of empty Hyundai/Kia/Nissan parts is PL24 licensing/region (tr account not
licensed for these brands' parts) — a commercial issue, not code-fixable.
Keeping only the explanatory comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:10:29 +03:00
220988c02e fix(categories): don't cache an empty category tree for an hour
getCategoryTree cached the built tree for 3600s unconditionally, and the
read guard `if (cached)` treats an empty array as a hit. So a single
transient decode/proxy failure (emex via the flaky DataImpulse pool) seeded
0 categories and poisoned the catalog with an empty tree for a full hour,
even after the source recovered. Cache an empty tree for only 60s — it
self-heals on the next request post-recovery while still throttling
re-decode during a genuine outage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 16:09:45 +03:00
91881d5bd1 fix(catalog): fall back to de account when tr serves a PL24 demo page
Hyundai/Kia/Nissan parts-drilling returned a NOT_LOGGED_IN_DEMO page on the tr
account (license-gated), so live drills yielded 0 parts even though the tree
loads. After the tr re-auth retry still demos, try the de account once; if it
serves real (non-demo) content, use it. Tests whether de holds the brand license.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:02:42 +03:00
435250d6de feat(emex): Floxy sticky sessions (5min) to hold one IP across a chained flow
emex's ssd tokens appear IP-bound: a rotating proxy sends each hop of a
decode→tree→drill→parts flow from a different exit IP, so emex rejects the
ssd and returns empty (the rotating-Floxy verify still seeded 0). Pin the
Floxy exit IP with a sticky session (password suffix
`_session-<id>_lifetime-300`); a rolling session id is reused across the
flow and rotates near expiry or after a transport failure (dead IP → fresh
one). lifetime via EMEX_FLOXY_LIFETIME (0 = rotating).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 16:00:53 +03:00
ca9b11dff7 feat(emex): Floxy residential proxy fallback for the flaky DataImpulse pool
DataImpulse (74.81.81.81, random port 10001-10099) intermittently throws
connect-timeouts / resets, dropping real emex hits. Add a Floxy residential
fallback (residential.floxy.io:12321): fetchEmexHtml now runs a provider
schedule — 3 DataImpulse attempts (rotating port), then 2 Floxy attempts —
and only falls back on transport errors (a definitive HTTP answer like 404
still stops the schedule). On by default; endpoint/creds overridable via
EMEX_FLOXY_* env. Direct proxy-less last resort still gated by
EMEX_DIRECT_FALLBACK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 15:54:16 +03:00
fd5c617252 chore(debug): revert TEMP Ford flow-mapping logging
Ford P4 catalog navigation is session-stateful: catCode group.action returns
the full Ford model picker (30 vehicle.action rows), and vehicle.action?modelFamily=X
loops back to the same picker; a bare group.action after select still returns the
picker (parsedGroups=0). Stateless GETs over a rotating proxy can't hold PL24's
server-side model selection → groups are unreachable (explains 0/57 categories_fetched
on prod). Needs session-continuity or browser-based navigation — tracked, not a quick fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:35:43 +03:00
cd5c2e5c43 chore(debug): TEMP Ford session-state test (vehicle.action then bare group.action)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:30:00 +03:00
26efef22d4 feat(emex): seed catalogs from hierarchical Vehicle.aspx tree
emex catalogs were seeded from the flat QuickGroups taxonomy (~351
unsearchable leaves on one page). Switch the seeder to the Vehicle.aspx
OEM-microfiche tree (top groups -> sub groups -> Unit.aspx leaves), which
also reaches units that have no QuickGroups gid (e.g. the torque
converter, OEM 5205547 on the Fiesta).

- emex-tree.parser.ts: parseVehicleTree / parseUnitLeaves — pure,
  GET-based (per-href ssd, no browser/postback). Unit-tested vs fixtures.
- emex.service: fetchVehicleTree + drillVehicleNode; all 3 decode methods
  now populate categoryTree; Unit.aspx leaves fetch parts directly.
- categories.service: revive the dormant tree-insert branch with group
  markers (linkWid="emex-group" / hasSubgroups), add an emex getChildren
  lazy-drill branch + emex-group parent guard, tighten leaf detection,
  disambiguate colliding sub-group names.
- prefetch: emex Vehicle.aspx = parent to drill; Unit/QuickDetails = leaf.

No DDL and no frontend changes — the generic tree UI renders it like
pl24/pcat. Existing flat emex categories re-seed lazily once deleted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 15:28:26 +03:00
9772ce1ef4 chore(debug): TEMP recursive Ford selection-chain mapping (revert after)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:25:56 +03:00
ce297893ee chore(debug): TEMP map Ford catCode->vehicle-select->groups flow (revert after)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:21:35 +03:00
27c7a81573 chore(debug): revert TEMP Ford/Volvo diagnostic logging
Diagnosis captured: Ford catCode group.action returns a vehicle-selection
page (30 vehicle.action rows, no group rows) — needs an extra selection
level before groups. Polestar json-model-years returns an HTML landing page,
not JSON — needs a different navigation flow. Both tracked for follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:16:22 +03:00
df3949fa6b chore(debug): TEMP log Ford 0-groups HTML + Volvo model-years raw (revert after diag)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:11:44 +03:00
0e1c0a55d0 style(catalog): biome format fetch options in P5 endpoint fallback
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:02:27 +03:00
d918869742 fix(catalog): self-discover P5 model-list endpoint for unmapped backends
fetchVehicleList fell back to /extern/vehicle/modelfamilies for any backend
not in BACKEND_MODEL_PATH; p5fiat (Fiat) isn't mapped, so it returned 0
models and Fiat seeded nothing (0 catalog_vehicles on dev+prod). When the
primary path yields nothing, try the other known P5 listing endpoints and
use the first that returns models, logging which one worked so it can be
pinned. Only runs on the empty path → mapped backends unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:01:50 +03:00
1fc4f7780c fix(catalog): guard empty subgroup insert after nav-crumb filter (500)
When fetchSubGroupsByPath returns only nav-crumb stubs (e.g. a "Portal"-only
page for a Hyundai BODY node), the post-filter set is empty and drizzle
.values([]) threw → HTTP 500. Compute `unique` unconditionally and only
insert when non-empty; otherwise fall through to the leaf/parts path for a
clean empty node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:53:37 +03:00
1713616ba1 fix(catalog): self-heal nav-crumb junk children in drill path
getCategoryWithParts returned persisted child categories without the
nav-crumb filter, so a node whose subgroups were polluted with "Portal"
(e.g. Hyundai BODY → only child "Portal") drilled to a dead junk node.
Drop nav-crumb children, delete them from the DB so the node re-fetches its
real subgroups, and also skip nav-crumbs when persisting freshly-fetched
subgroups. Real subgroups (illustration codes) and parts are reached as
before once the junk is gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:48:28 +03:00
69251edc45 fix(catalog): catch basePath-prefixed external nav-crumb in read filter
Nissan's "Repair & Maintenance Information" persists as
`/nissan/nissan_parts/https://eu.nissan.biz/` — the parser prefixes the
external URL with basePath, so the anchored ^https check in isNavCrumbLink
missed it. Match `://` anywhere (non-.action) instead; real PL24 linkPaths
are relative and never contain `://`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:39:10 +03:00
4dabf97f89 fix(catalog): also drop nav-crumb junk on read (self-heal persisted rows)
The parser filter only stops NEW junk from being persisted; vehicles whose
trees were already fetched still had "Portal"/eu.nissan.biz rows in the DB,
and persistAndBuildLegacyTree/buildCategoryTreeFromDb build from those rows.
Filter dbCategories through isNavCrumbLink before buildTree in both, so the
response self-heals for previously-persisted junk without a DB migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:30:06 +03:00
8e804394a3 fix(catalog): build legacy category tree from DB rows (kill raw-path ids)
LEGACY_OPEL/HYUNDAI_KIA/NISSAN/FORD/VOLVO getCategoryTree built the
returned tree from the freshly-parsed PL24 list with
`externalIdToUuid.get(c.code) ?? c.code`, falling back to the RAW upstream
linkPath as the node id whenever a row wasn't in the map. That happened
because the two legacy blocks (unlike the PSA block) did not dedupe by
name, so onConflictDoNothing silently dropped name-colliding rows; those
nodes then leaked a raw `group.action?catId=…` id, which the frontend
turned into `/categories/group.action?...` → 404 "Bir hata oluştu".

Extract the duplicated build/persist into persistAndBuildLegacyTree which:
filters nav-crumb junk + dedupes by name, always re-selects after insert
(never trusts .returning()), and builds the tree FROM the DB rows so every
node id is a real UUID. Deterministic ORDER BY for stable tree order. PSA
block left untouched (it intentionally supports non-UUID PSA codes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:24:21 +03:00
f83973c628 fix(catalog): filter nav-crumb junk from PL24 legacy group parser
parseFordGroupsFromHtml leaked header/breadcrumb links into the category
list as pseudo-categories that drill to nothing: Hyundai/Kia "Portal"
(portal.action), logout, and Nissan "Repair & Maintenance Information"
(external https://eu.nissan.biz/). Skip these in both the primary tr-row
parser and the <a href> fallback. Real categories (group.action /
group-detail.action / json-(main|sub)-group.action) are unaffected, and
Volvo's vin-group.action?...group1=... real categories are kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:24:21 +03:00
0d64e22c8d feat(pl24): enrich P5 decode for non-VAG OEMs (SEV-2)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
parseVehicleResponse only read VAG/BMW vinfoBasic labels, so Jaguar/Land Rover/Toyota/MAN/Suzuki dropped engine+transmission and Mitsubishi's model showed an internal chassis code.

- engine/transmission: read brand labels (JLR Motor Tipi/Vites Kutusu, Toyota ENGINE 1/ATM,MTM + parens engine code, MAN Yedek motor/Şanzıman, Suzuki Motor No./Şanzıman numarası). - Mitsubishi model: prefer Araç/description (L200), not the internal Model code (KB4T 2500DIESEL...). - value normalize: unescape literal \- + collapse whitespace (was prNr-only) → fixes 'XJ 2010 \- 2019', '2005\-07', 'COROLLA   (TUP)'. VAG/BMW/Mercedes/Fiat unchanged. 40/40 pl24 specs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:48:39 +03:00
01ed1a4480 fix(pl24): P4 decode returns null on model-selection page (Hyundai/Kia/Nissan)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
When PL24 can't identify a VIN, vin-group.action redirects to vehicle.action (a brand-wide model picker). The generic P4 parser collapsed that page's <title> to the brand name with a bogus VIN-char year, and doResolveVin's fastest-source-wins treated that truthy result as a definitive decode — masking the pcat/emex candidate fallback.

parseP4VehicleResponse now returns null when the extracted model is just the brand (or contains the VIN), so the pipeline falls back to pcat/emex instead of storing a fake vehicle. Live discovery: 7/11 Hyundai/Kia/Nissan VINs hit the picker (newer 2024+ / Nissan EU model-pick catalog); the 4 PL24-indexed VINs (GETZ 02, RIO 15/17/20) still decode. Adds pl24-ford-legacy.service.spec.ts; biome-formats two pre-existing lines in the touched file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 02:25:00 +03:00
d575893447 fix(pl24): Mercedes year from 'Teslimat tarihi' (delivery date)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
p5daimler vinfoBasic has no model_yili/üretim tarihi — only "Teslimat tarihi"
(delivery). The parser ignored it, so year fell to the VIN 10th-char and was
wrong (WDD…1… → 2001 even for 2009/2015/2018 cars). Add "teslimat_tarihi" to the
date-year fallback. Live-verified: C 180→2009, E 250 BlueTEC→2015, E 350→2018.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 00:22:38 +03:00
Claude (notifications categorize)
d7e003f590 fix(notifications): drop per-category descriptions, capitalise "Mobil Bildirim"
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The two rows under Settings → Bildirimler were carrying a sentence each
that read like AI-generated filler ("Mobil uygulama push bildirimleri.
Mobil uygulama yayınlandığında bu tercih kullanılır.") — the row title is
enough; the auth/payment caveat lives in the static footer below.

  • Drop `description` from NOTIFICATION_CATEGORIES + the GET response shape.
  • Drop the `<p className="text-muted-foreground">{description}</p>`
    line from NotificationsCard so each row is now a single bold title
    line + the toggle button.
  • Tighten the row to `items-center` since the second-line description
    is gone and `items-start` looked off with only one line of content.
  • Rename `FALLBACK_CATEGORY_COPY` → `FALLBACK_CATEGORY_LABEL` to match
    the slimmer shape.
  • Capitalise the Mobile row to "Mobil Bildirim" (was "Mobil bildirim").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 00:06:28 +03:00
94faa6cdcd fix(pl24): cleaner BMW model — append chassis only, drop redundant line token
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
"Seri" is "{line} {chassis} [{variant}]" and the trim already implies the line
("520i"→5, "X3 sDrive20i"→X3), so append only the chassis(+variant): "520i G30",
"X3 sDrive20i G01" (was "520i 5 G30" / the redundant "X3 sDrive20i X3 G01").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:55:22 +03:00
d3ff19ad6a fix(pl24): enrich BMW decode — chassis/generation, body, drive from vinfoBasic
BMW (p5bmw) decode was weak: model was just the trim ("520i") with no
chassis/generation, body_type empty, because BMW has NO prNr segment and keeps
that data in distinct vinfoBasic labels the shared parser ignored. Live-verified
fields: "Seri"="5 G30" (chassis), "Karoseri"="Limousine" (body), "Tahrik"="RWD".

- model: fold the generation ("Seri"/"Model tanimi") into the model when present
  and not already included → "520i 5 G30" (disambiguates E60/F10/G30 for parts).
- bodyType: fall back to vinfoBasic "Karoseri" when there's no prNr K8*.
- series: read "Seri"; driveType: read "Tahrik".
Year already comes from "Üretim tarihi" (P5 year fallback). VW/Audi (prNr) and
Mercedes ("Piyasa adı", no seri/karoseri) verified unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:47:00 +03:00
Claude (notifications categorize)
aaa96d82e3 refactor(notifications): collapse 6 per-workflow toggles into 2 categories
Settings → Bildirimler had grown a 6-row list (welcome / trial-ending /
referral / referral-qualified / referral-reward / win-back) that read like
an internal cron schedule rather than a user choice. Users care about
e-mail vs mobile, not which Novu trigger fires the day-3 nudge.

Replaces the per-workflow UI with two switches:

  • E-posta bildirimleri  — bundles all six marketing/lifecycle workflows
                             above, off = mute all
  • Mobil bildirim         — placeholder for the not-yet-shipped mobile
                             app push channel; the preference is stored
                             so it Just Works when push ships

Auth + payment mail remain unaffected — the server-side OPTIONAL_WORKFLOWS
filter is still the canonical opt-out gate.

API
---
Same path (`/api/email/preferences`), category-shaped payload:

  GET  → `[{category, label, description, optedOut}, …]`  (two rows)
  POST → body `{category, optedOut}`                       (toggles every
                                                            workflow in
                                                            the bundle)

UnsubscribeController is untouched — one-click List-Unsubscribe URLs in
mail still address a single workflow (we don't want clicking the welcome-
mail unsub link to also kill the trial-ending nudge a week later).

Service
-------
New `NOTIFICATION_CATEGORIES` const + `getCategoryState()` /
`setCategoryState()` on EmailPreferencesService. `mobile_push` added to
OPTIONAL_WORKFLOWS so the same row-presence guard works for it.

UI
--
NotificationsCard renders two rows (or two skeletons) — keys are stable
so the skeletons match the final layout. Category copy comes from the
API; static FALLBACK_CATEGORY_COPY avoids a flash of untitled rows
before GET resolves.

PostHog events renamed from `email_workflow_opted_in/out` to
`notifications_category_opted_in/out` since the per-workflow event was
never going to be useful.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 23:46:07 +03:00
e979116738 Merge pull request 'fix/audit-9-4-operability' (#104) from fix/audit-9-4-operability into dev
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Reviewed-on: #104
2026-06-04 15:20:28 +00:00
e108683982 Merge pull request 'main' (#103) from main into dev
Reviewed-on: #103
2026-06-04 15:19:23 +00:00
9809012be5 fix(pl24): correct p5fiat vinfoBasic label/value extraction
The Fiat vinfoBasic record carries the row under `values` like p5vwag, but the
inner field names are swapped: p5fiat uses values.key=<label>,
values.description=<value> (vwag uses values.description=<label>,
values.value=<value>). The first pass read values.description as the label, so
model fell back to the numeric description code ("319") and year to 0. Detect
the shape via values.key. Live-verified: ZFA Panda→"Panda POP 1.2 8V 69CV 5M E6"
/2014, Grande Punto/2009, 500L/2015. Spec fixture corrected to the real shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:12:22 +03:00
Claude (audit §9.4)
bdbdd07566 feat(notifications): operability tier — send_limit + open-pixel + signed-URL exp + retention + sent-flag (audit §9.4)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Lands the §9.4 operability tier of postal/mailAudit.md as one PR on top of
the §9.3 stack (PR #101). Seven items, all independent of each other but
sharing the same notifications surface.

#15 Postal send_limit fuse — set per-hour cap (already applied LIVE in DB:
  servers.send_limit = 500). A runaway loop now hits Postal's own throttle
  long before flooding recipient ISPs into a rate-limit penalty.

#16 noreply@sase.tr decommission — change default fromAddress in both
  email.service.ts and config/configuration.ts to destek@sase.tr. `noreply@`
  had no inbound route so user replies bounced; `destek@` lands in the
  SnappyMail destek inbox. Overridable via POSTAL_FROM_ADDRESS env per workflow
  that genuinely shouldn't accept replies.

#21 Welcome CTA fallback — flip the {{else}} branch in novu-welcome.html
  from https://sase.tr to https://sase.tr/dashboard (the actual onboarding
  entry, not the marketing page). Already pushed live to Novu Mongo too.

#17 Open-pixel embed — new buildTrackPixelUrl() in novu.ts; injected
  trackPixel payload into welcome/trial-ending/win-back/referral×3/
  payment×2 NovuService methods + lifecycle-email.processor. Auth flows
  (email-verification, password-reset) deliberately skip the pixel.
  Templates updated with {{#if trackPixel}}<img ...>{{/if}} just before the
  footer; 8 templates touched, 2 (auth) skipped. Novu Mongo updated.

#18 Signed-URL exp / replay-resistance — track.sase.tr Worker /c endpoint
  now expects `e=<unix-ms>` + `s=HMAC(MID|TARGET|EXP)`. Expired signatures
  return 410. Legacy signatures (no `e=`) still accepted while in-flight
  mail with old links drains; remove that branch ~30 days post-deploy.
  buildTrackedUrl() now mints exp=now+30d.

#19 D1 retention cron — Cloudflare Cron Trigger added to mailtrack worker
  (`17 4 * * *` UTC, after Europe/Istanbul cron settles). `scheduled` handler
  DELETEs events older than RETENTION_DAYS (default 90). Both code and the
  cron schedule are LIVE on the production worker.

#20 Lifecycle sent-flag idempotency — new `lifecycle_email_sent` table
  (migration 0012) keyed (user_id, workflow). Replaces the 1-day endDate
  window's at-most-once trick that lost cohorts on skipped days. Cron now
  LEFT JOINs and writes the row immediately after each successful trigger.
  Historical seed in scripts/backfill-lifecycle-sent.sql (19 trial-ending +
  5 win-back users — generated from postal-server-1.messages) so the first
  post-deploy cron doesn't re-send to users we already mailed.

## Live infrastructure (deploy-independent)

- Postal MariaDB: `UPDATE servers SET send_limit = 500`.
- Cloudflare Worker mailtrack redeployed with new /c logic + scheduled handler.
- Cloudflare Worker: cron `17 4 * * *` registered on production env.
- Cloudflare Worker: RETENTION_DAYS=90 plain_text binding.
- Novu Mongo: 16 messagetemplates updated with pixel + Welcome /dashboard.

## Companion deploy steps post-merge

1. `pnpm db:generate` to refresh drizzle snapshots for 0011 + 0012.
2. Run `scripts/backfill-lifecycle-sent.sql` against prod + dev BEFORE the
   first cron tick post-deploy.
3. Apply host-side novu-patches/apply-headers-patch.sh again if Novu
   container rolled (idempotent).

## Verification

  curl /c?…e=<future>… ⇒ 302    new-style signature accepted
  curl /c?…(no e)…    ⇒ 302    legacy signature still accepted (drain)
  curl /c?…e=<past>…  ⇒ 410    expired signature rejected
  curl /c?…s=bad…     ⇒ 403    bad signature rejected
  CF API schedules    ⇒ `17 4 * * *` live on mailtrack worker.

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