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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
"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>
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>
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>
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>
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.
#16noreply@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>
Fiat (fiatp_parts/fiatt_parts) was misconfigured as LEGACY_FIAT basePath /fca,
which 404s on every request — PL24 Fiat decode was dead. Live discovery
(de-708171) shows Fiat is a standard P5 Modern catalog at /p5fiat: directAccess
+ maingroups/subgroups/parts/images all match the existing P5 flow. Only the
vinfoBasic record shape differs ({key,description} vs {values:{description,value}}).
- types: fiatp_parts/fiatt_parts -> P5_MODERN, apiPath/basePath /p5fiat
- parseVehicleResponse: parse the p5fiat vinfoBasic shape; friendly model from
"Model bilgisi"; year from MY / production date
Covers European (ZFA) Fiats + some commercial Tofas (fiatt). Turkish Tofas
passenger VINs (NM4, incl. Egea) are not in this catalog. de account separation
(resolveAccount Rule 1) unchanged; de auth handshake proxied, catalog data not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lands the user-facing half of the unsubscribe preferences work. The
one-click endpoint already shipped in this PR's main commit; this adds
the proactive self-service path at /dashboard/settings?tab=notifications
so users don't have to wait for a mail to land before tuning their
preferences.
Backend
-------
New EmailPreferencesController at /api/email/preferences:
GET → returns one row per OPTIONAL_WORKFLOWS entry, each with current
optedOut boolean (false when no DB row exists).
POST → body {workflow, optedOut} flips the row; source='settings_page'
captured for the audit trail.
Auth+payment workflows are deliberately not exposed — the server's
OPTIONAL_WORKFLOWS set stays the single source of truth.
Frontend
--------
Adds a 'notifications' tab to /dashboard/settings (between 'preferences'
and 'security'). One toggle row per optional workflow with TR copy that
explains what each mail is for. Optimistic update — switch flips
instantly and reverts on failure; PostHog event captures accept/reject.
Static footer note clarifies that auth + payment mail keeps coming
regardless of the switches above (so users don't think they've
unsubscribed from password-reset).
i18n
----
Added settings.tabs.notifications + settings.notifications.{title,
description} to both tr.json and en.json. Body copy is hard-coded TR
(matches audit §9.3 #11 TR-only decision).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>