Commit Graph

251 Commits

Author SHA1 Message Date
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
e2a85ff94f test(pl24): cover p5fiat vinfoBasic parse shape + p5vwag regression
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:02:30 +03:00
4a115db885 fix(pl24): wire Fiat as P5-modern /p5fiat (was dead LEGACY_FIAT /fca)
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>
2026-06-04 16:58:03 +03:00
Claude (audit §9.3)
f5cd5be933 feat(notifications): settings UI for per-workflow opt-out (audit §9.3 #14 follow-on)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
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>
2026-06-04 14:51:10 +03:00
Claude (audit §9.3)
327d698945 feat(notifications): TR-only templates + name canonicalisation + MTA-STS + 2048-bit DKIM + unsubscribe (audit §9.3)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Lands the §9.3 "compliance + brand" tier of mailAudit.md as one PR. Six
changes share enough surface (notifications, shared utils, infrastructure)
that splitting them would require multiple stacked PRs.

#9 — Turkish-locale title-case for names at signup
   • New `normalizeName()` in @sase/shared, locale-aware (İ/ı pairs handled
     via toLocaleLowerCase('tr-TR') + matching toLocaleUpperCase). Hyphen-
     aware, collapses whitespace, idempotent.
   • Wired into better-auth's `user.create.before` hook so every new signup
     gets canonicalised before the row lands in Postgres.
   • 28 unit tests in packages/shared/src/index.spec.ts.
   • Backfill script at `scripts/backfill-user-names.ts` (already run
     against prod + dev — 210/402 prod users and 72/153 dev users
     canonicalised, plus 71 Novu subscribers).

#10 — Email typo correction at signup
   • New `suggestEmailFix()` in @sase/shared: exact-match typo dictionary
     for the addresses we've actually suppressed (icould.com, gmial.com,
     xn--gmail-bgd.com, …) plus Levenshtein ≤ 2 fallback against popular
     providers.
   • Inline UI hint on the register form — "Bunu mu demek istedin? <link>"
     that swaps the email on click; PostHog event tracks acceptance.

#11 — Strip EN branches (decision: TR-only)
   • 0/205 prod subscribers have locale='en' and there's no marketing in
     English — the {{#equals subscriber.locale "en"}}…{{else}}…{{/equals}}
     framework was dead code in all 10 templates.
   • Templates updated in-place (avg ~30 % smaller). Renamed
     `novu-welcome-tr.html` → `novu-welcome.html` for consistency with the
     other 9 files.
   • Novu workflow definitions in both Dev + Prod envs updated via Mongo:
     subjects collapsed to TR-only, content replaced with new HTML
     (mongodump/restore-safe).
   • App code: `NovuRecipient.locale` and `NovuUser.locale` removed; the
     `...(user.locale === "en" ? { locale: "en" } : {})` spread in NovuService
     is gone.

#12 — DKIM rotated to 2048-bit RSA
   • Postal default was 1024-bit (selector `postal-YeIm3w`). Generated new
     2048-bit key, added DNS TXT `postal-2k260604._domainkey.sase.tr`,
     atomically swapped `domains.dkim_identifier_string` +
     `dkim_private_key` in Postal MariaDB, restarted Postal SMTP.
   • Verified: outgoing welcome mail now signs with `s=postal-2k260604`
     and a 256-byte signature body (vs the previous 128-byte 1024-bit
     signature). Pubkey on DNS matches the private key.
   • OLD TXT record (`postal-YeIm3w._domainkey`) stays in DNS for ~7 days
     as a grace window for in-flight mail.

#13 — MTA-STS + TLS-RPT
   • Extended the existing mailtrack Cloudflare Worker to also serve
     `mta-sts.sase.tr/.well-known/mta-sts.txt` (`mode: enforce, mx:
     mx.postal.sase.tr, max_age: 604800`). Workers Domain bound to the
     mailtrack service via Cloudflare API.
   • DNS:
       `_mta-sts.sase.tr`        TXT  "v=STSv1; id=20260604111347"
       `_smtp._tls.sase.tr`      TXT  "v=TLSRPTv1; rua=mailto:dmarc@sase.tr"
   • Verified policy fetch returns 200 with the expected body; cert valid
     (sase.tr SAN issued by GTS).

#14 — Unsubscribe preferences + RFC 8058 one-click endpoint
   • New `email_preferences` table (migration 0011) keyed
     (user_id, workflow), captures source for audit
     (one_click / manual_link / settings_page).
   • New `UnsubscribeController` at `/api/email/unsubscribe`:
       - POST: Gmail/Yahoo one-click bot path (200 fast)
       - GET:  human-visit, renders a Turkish confirmation page
     Both validate an HMAC-SHA256(`userId|workflow`) token under
     `UNSUBSCRIBE_SECRET` — stateless, no DB lookup to validate, secret
     rotation invalidates all outstanding tokens.
   • `triggerNovu()` now mints the per-call `overrides.email.headers`:
       `List-Unsubscribe: <https://…?u=&w=&t=>, <mailto:unsubscribe@…>`
       `List-Unsubscribe-Post: List-Unsubscribe=One-Click`
     Auth + payment workflows opt out via NO_UNSUBSCRIBE_WORKFLOWS so the
     unsubscribe URL never appears on transactional mail.
   • `NovuService.trigger()` pre-flight-checks `isOptedOut()` and skips the
     trigger entirely if the user opted out. Fail-open on DB error so a
     transient blip can't swallow auth mail.
   • `lifecycle-email.processor.ts` (standalone BullMQ worker — no NestJS
     DI) does the same check inline via a LEFT JOIN on
     `email_preferences WHERE opted_out IS NULL`.
   • Coolify env wired in both Prod and Dev apps:
       `UNSUBSCRIBE_SECRET` (32-byte hex, distinct per env)
       `UNSUBSCRIBE_URL_BASE` = `https://(dev.)sase.tr/api/email/unsubscribe`

## Companion sibling changes (already applied, NOT in this PR)

- Cloudflare worker `mailtrack` redeployed with mta-sts.sase.tr custom domain.
- Postal MariaDB `domains.dkim_identifier_string` + `dkim_private_key`
  updated to the new 2k260604 selector (live since 2026-06-04 11:18).
- `postal-2k260604._domainkey.sase.tr` TXT record live at Cloudflare.
- `_mta-sts.sase.tr` + `_smtp._tls.sase.tr` TXT records live at Cloudflare.
- Novu Mongo notification + message templates updated to TR-only.
- 282 user names canonicalised across prod + dev + Novu subscribers.

## Verification snapshot

- Postal raw_headers (ID 157, post-rotation): `s=postal-2k260604` + 256-byte b=
- `dig +short TXT _mta-sts.sase.tr @1.1.1.1` ⇒ live id=20260604111347
- `curl https://mta-sts.sase.tr/.well-known/mta-sts.txt` ⇒ 200 with policy
- 28 unit tests (normalizeName + suggestEmailFix) all green via Node sanity.

## Deploy notes

- Re-run `pnpm db:generate` to regenerate the drizzle snapshot for 0011
  (added the journal entry manually because no drizzle-kit on this box).
- Run `pnpm tsx scripts/backfill-user-names.ts --apply` against any DB not
  yet canonicalised (already done for prod + dev today).
- The host-side Novu nodemailer-headers patch at
  `postal/novu-patches/apply-headers-patch.sh` must be re-run after every
  Novu container redeploy or the List-Unsubscribe header is silently dropped
  before reaching Postal (see audit §9.1 #3 for the upstream cause).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 14:28:39 +03:00
294e365bbd Merge pull request 'fix(pl24): Volvo + Ford + Opel/Vauxhall + Hyundai/Kia VIN decode (per-brand services)' (#100) from dev into main
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
2026-06-04 11:03:06 +00:00
45953f6dd5 fix(pl24): correct Opel/Vauxhall + Hyundai/Kia VIN decode via thin brand services
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Same shared-core + thin-brand pattern (P4BrandHooks). Both decoded with polluted models (Opel kept
the platform code "…: P10"; Hyundai kept the VIN breadcrumb), wrong VIN-char years, empty transmission.
Fix reads the vin-group info grid + title:
- Opel: model from <title> (segment after the platform code), year from Model yili, transmission from
  Şanzıman kodu, engine from Motor tipi. Verified 6/6 prod (ASTRA-J/2014, CORSA-D/2011, INSIGNIA-A, AGILA-A…).
- Hyundai/Kia: model from <title>, build-year from Üretim tarihi, transmission/body from the
  ENGLISH-labelled grid (plain lowercase — tr-locale would map I→ı and miss "TRANSMISSION").
  Verified (GETZ 02/2004/5 SPEED MT, RIO / STONIC 17/2019…).

Nissan deferred: both prod Nissan VINs are unresolvable upstream (PL24 returns "Model seçimi", no
model) — nothing to parse. Engine/categories/drill unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:49:34 +03:00
836b7c43a6 fix(pl24): correct Ford VIN decode (model/build-year/transmission from info grid) via thin brand service
Ford decoded with a polluted model ("Ford Nutzfahrzeuge {VIN}: Transit Connect - TC7…", from the
page title), a wrong VIN-char year, and empty transmission. The clean data is in the vin-group info
grid (Araç Hattı=model line, Üretim tarihi=build date, Vites Kutusu=transmission, Motor Tipi=engine,
Gövde Tarzı=body). New thin PL24FordService supplies parseFordVinInfo via the shared P4 brand hook;
orchestrator routes LEGACY_FORD to it. Engine/categories/drill unchanged.

Verified live vs 9 prod Ford VINs: 7 now decode clean model + correct build-year + transmission
(Transit Connect/2006, Mondeo/1997 CD4E Otomatik, Focus/2000, …) — VIN-char years were off by up to a
decade and transmission was empty for all. 2 VINs are upstream gaps (no Araç Hattı → unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:38:22 +03:00
50fd0b0e26 fix(pl24): correct Volvo/Polestar VIN decode (model from vinInfoTable) via thin brand service
Volvo decoded as "Volvo {VIN}" with no real model: the generic Ford-shaped
parseP4VehicleResponse reads window.vehicles/<title>/<h1>, but Volvo ships model/year/type in a
<table id="vinInfoTable"> caption/value grid (Model="V60 Cross Country (19-)", Model yili=2021,
Türü="V60 CC II"). 9 vehicles affected (3 dev + 6 prod).

- Add a per-brand vehicle-info hook (P4BrandHooks.parseVehicleInfo) to the shared P4 engine
  (PL24FordLegacyService); brand values win, generic fills gaps. Backward-compatible: no hook → identical.
- New thin PL24VolvoService supplies parseVolvoVinInfo (vinInfoTable parser); orchestrator routes
  LEGACY_VOLVO decode to it. Categories/drill unchanged (shared engine).

Verified live vs 6 prod Volvo VINs: all decode real models (S80/S60/S40/V40/EX40·XC40/V60 CC) +
correct years; drill intact (motor → 7 subgroups). First step of the per-brand split
(shared core + thin brand services).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:12:29 +03:00
c531829397 Merge pull request 'dev' (#98) from dev into main
Reviewed-on: #98
2026-06-04 09:45:44 +00:00
cd4f282537 Merge pull request 'fix(notifications): correct subscription CTA path' (#96) from fix/subscription-cta-path into main
Reviewed-on: #96
2026-06-04 09:44:16 +00:00
semih
7e5e0ee446 fix(jobs): gate lifecycle-email cron behind prod-host check
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
dev.sase.tr was running the daily lifecycle-email cron (trial-ending,
win-back) against sase_dev, which is a seeded copy of the prod DB —
real customers were getting duplicate mails (one from prod, one from
dev). Mirror the isCatalogBackfillEnabled() gate so the lifecycle cron
only registers on the canonical prod host (COOLIFY_FQDN=sase.tr or
BETTER_AUTH_URL=https://sase.tr). LIFECYCLE_EMAIL_ENABLED env can force-
enable for staging testing. The else branch removes any stale scheduler
from Redis so a previously-registered cron stops firing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 12:38:10 +03:00
semih
eecc173ecc fix(notifications): correct subscription CTA path to /dashboard/subscription
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The trial-ending / payment-success / payment-failed emails were pointing
to /dashboard/settings?tab=subscription which does not exist; the correct
path is /dashboard/subscription. Referral paths are unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 12:31:50 +03:00
223c4bc12f fix(pl24): correct PSA VIN decode via FI/VIN-indexed flow + cycle-correct model year
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
PSA (Peugeot/Citroën/DS) VIN decode was systemically broken: the catalog
vin-group page returns all families unfiltered, so decode fell back to the first
family/salesType (a manual base variant) — yielding "{Brand} {VIN}" model names,
empty transmission, wrong model year, and manual-only parts trees (automatic
gearbox parts missing). Reported for a 1999 Peugeot 106 automatic shown as a 2029
manual with no automatic parts.

- New self-contained PL24PsaService: consumes PL24's FI flow (vin.action →
  hintstoken → FI page → json-vin-main-groups → json-vin-illustrations →
  vin-image-board). Reads model/year/transmission from the FI identification
  table; builds the VIN-indexed parts tree (correct per actual VIN). Does not
  touch Ford/Volvo/Nissan/Opel/Hyundai-Kia/Fiat.
- Orchestrator + categories.service route PSA VIN decode/drill to the new service.
- Cycle-correct extractModelYear in @sase/shared (X→1999, not 2029): resolve the
  30-yr VIN year code to the most-recent plausible year (≤ now+1); dedupe 6 copies.

Validated live against 13 already-decoded PSA VINs: 12/13 full trees with real
model/year/transmission; automatics correctly detected (106 BVA, 206 AL4, 3008 BVA8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:27:04 +03:00
3df3e224e4 fix(vin): Q5 WMI brand map + Q6 PL24 nissan/van routing (RCA, verified)
Second batch from undecoded-vin-rca.md. Every entry verified (authoritative
WMI registry + prod sibling + VDS fingerprint + live PL24 probe) before adding
— a wrong brand label or a dead PL24 route both have real downside.

Q5 — WMI_BRAND_MAP (packages/shared, UX/telemetry only; does NOT change which
catalog decodes). Adds 9: W1V=Mercedes-Benz, VXF=Fiat, YAR=Toyota, NL1=Hyundai,
KPA=SsangYong, PL1=Proton, LSV=Volkswagen, LVV=Chery, ZCF=Iveco. Resolves the
RCA conflicts: VXF=Fiat (not Opel), YAR=Toyota ProAce (not Opel); NL1=Hyundai
(not TOGG — MY char predates TOGG). Deliberately NOT added: WF1/WAA/W0W (1-char
corruptions of WF0/WAU/WVW — mapping them would mislabel typo classes), VE1
(unresolved, deferred).

Q6 — PL24_WMI_SERVICE_MAP (real decode routing). Both target services are
proven live, so these only append WMIs to working routes (no dead-route →
isDecodeable()=true → 25s-budget/circuit-breaker hazard):
- SJN/VSK/MNT → nissan_parts (Sunderland/Spain/Thailand) — regression restore,
  dropped in the 757905f refactor; nissan_parts proven (JN1 decodes in prod).
- W1V → mercedesvans_parts (Sprinter/Vito) — retires the RCA "never tried" flag:
  mercedesvans_parts decodes WDF44770513186937 live today via the same P5 path.

Tests: shared getBrandFromWmi (adds + corruption-skips return null), pl24 route
map. Full api suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:58:46 +03:00
83b2693af2 fix(vin): Q5 WMI brand map + Q6 PL24 nissan/van routing (RCA, verified)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Second batch from undecoded-vin-rca.md. Every entry verified (authoritative
WMI registry + prod sibling + VDS fingerprint + live PL24 probe) before adding
— a wrong brand label or a dead PL24 route both have real downside.

Q5 — WMI_BRAND_MAP (packages/shared, UX/telemetry only; does NOT change which
catalog decodes). Adds 9: W1V=Mercedes-Benz, VXF=Fiat, YAR=Toyota, NL1=Hyundai,
KPA=SsangYong, PL1=Proton, LSV=Volkswagen, LVV=Chery, ZCF=Iveco. Resolves the
RCA conflicts: VXF=Fiat (not Opel), YAR=Toyota ProAce (not Opel); NL1=Hyundai
(not TOGG — MY char predates TOGG). Deliberately NOT added: WF1/WAA/W0W (1-char
corruptions of WF0/WAU/WVW — mapping them would mislabel typo classes), VE1
(unresolved, deferred).

Q6 — PL24_WMI_SERVICE_MAP (real decode routing). Both target services are
proven live, so these only append WMIs to working routes (no dead-route →
isDecodeable()=true → 25s-budget/circuit-breaker hazard):
- SJN/VSK/MNT → nissan_parts (Sunderland/Spain/Thailand) — regression restore,
  dropped in the 757905f refactor; nissan_parts proven (JN1 decodes in prod).
- W1V → mercedesvans_parts (Sprinter/Vito) — retires the RCA "never tried" flag:
  mercedesvans_parts decodes WDF44770513186937 live today via the same P5 path.

Tests: shared getBrandFromWmi (adds + corruption-skips return null), pl24 route
map. Full api suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:55:22 +03:00
26fae98246 fix(emex): coerce proxy port env to number (boot-crash on real range)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
ConfigService.get<number>("EMEX_PROXY_PORT_START") returns the raw env
STRING; the port-pick arithmetic then string-concatenated it
(45 + "10001" = "4510001"), producing an out-of-range port that made
undici's `new URL` throw "Invalid URL" at EmexService construction —
crashing the entire API on boot.

A single-port range (823) happened to concat to a still-parseable "0823",
which masked the bug for months. It surfaced the moment the prod
EMEX_PROXY_PORT range was widened (823 -> 10001-10099) to let Q1's
per-request port rotation work: prod crash-looped until the env was
reverted. Coerce to a validated integer port (1-65535) with default
fallback so a real range is safe.

Regression test: constructing EmexService with string port env over a
real range must not throw.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:04:49 +03:00
a3ae90ec29 fix(vin-decode): 4 RCA-confirmed decode-chain bugs
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Root-cause analysis live re-decoded all 124 historically-undecoded prod
VINs; 38 already decode now. These 4 fixes target confirmed code bugs
that drop or mask real decodes (see undecoded-vin-rca.md):

Q2 — PL24 circuit breaker now only counts transient transport faults. A
definitive upstream negative (NotFound/BadRequest) no longer trips the
global 30s breaker that was starving PL24 for every subsequent VIN
(the sibling-VIN inconsistency in the report). Live-proven on VR7.

Q3 — previewVin / multi-candidate path no longer returns an empty
success: the pcat/emex candidate branches fill brandName (from catalogId
/ WMI), fixing the 6 "HTTP 200 with null brand+model" cases.

Q1 — EMEX fetch retries transient proxy failures with a FRESH ProxyAgent
per attempt (rotates the DataImpulse port; ~42% blip rate observed),
plus an opt-in direct fallback (EMEX_DIRECT_FALLBACK). HTTP answers are
never retried.

Q4 — VIN resolve cache keys namespaced by DECODE_CHAIN_VERSION and the
negative TTL drops 6h -> 30m, so a decode-chain fix self-heals stale
negatives on deploy instead of masking phantom-undecoded VINs for hours.
The admin cache-buster uses the same key builder.

Tests: 179 passed (+ new Q2/Q3/Q4 specs). typecheck + biome clean.

Deploy note: prod EMEX_PROXY_PORT_START/END are both 823 (single port);
widen to a real range (e.g. 10001-10099) in Coolify so Q1's port
rotation takes full effect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:37:11 +03:00
bb60a95ec3 perf(backfill): Phase-1 residue exclusion — stop re-picking dead vehicles (Tier 3)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Phase-1 of the backfill scan selects all zero-parts decoded vehicles every wave.
Genuine residue (VINs with no catalog data anywhere — model-indexed HKN,
EMEX-uncovered, etc.) stays zero-parts forever, so it filled the batch every
wave, re-attempting dead vehicles and starving the Phase-2 rolling rescan (its
cursor was stuck for a week).

Track a per-vehicle no-result counter (prefetch:noresult:<id>) incremented when a
prefetch attempt finishes with the vehicle still at zero parts (0 categories in
init, or 0 parts after the whole chain). tryPick skips vehicles past
PREFETCH_NORESULT_MAX (default 2) attempts; the counter has a TTL
(PREFETCH_NORESULT_TTL_DAYS, default 7) so a later catalog fix re-fills them.
Frees capacity for fillable vehicles and lets Phase-2 run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:15:57 +03:00
48aea65b5c perf(backfill): raise prefetch worker throughput, env-tunable (Tier 2)
After Tier 1 removed the self-throttle, the 5 jobs/min limiter + concurrency 1
became the bottleneck. Make throughput env-tunable so prod can ramp while
watching the fail rate:
- concurrency 1 -> 3 (PREFETCH_CONCURRENCY): parallelises emex/pl24 so a slow
  parts-catalogs job no longer head-of-line-blocks the queue.
- rate ceiling 5 -> 20 jobs/min (PREFETCH_RATE_MAX).
- parts-catalogs: drop the pathological cumulative index*20s enqueue delay (the
  Nth leaf of a vehicle waited N*20s); keep one per-job pace (PCAT_PACE_MS,
  default 15s, 0 to disable).
- PL24 09-18 scrape window now env-tunable (PREFETCH_PL24_START / _END).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:43:10 +03:00
1e3953417e perf(backfill): stop the prefetch worker from throttling itself (Tier 1)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The worker's own upstream fetches called touchActivity(), setting the
prefetch:activity:<source> cooldown key (TTL 300s) that checkCooldown then
honoured — so after each fetch the worker paused itself for up to ~5 minutes
(re-checking every 60s, ~5 empty cycles per key). At ~1 fetch / 5 min the
3387-job backlog needed ~6 days to drain.

- Wrap each worker job in an AsyncLocalStorage backfill context; touchActivity
  skips the cooldown key when invoked from the worker, so the cooldown reflects
  only real user requests (worker yields to users, never to itself).
- Cooldown TTL 300s -> 90s (a request 5 min ago isn't "active").
- checkCooldown pauses for the key's actual remaining TTL (one wait) instead of
  a fixed 60s re-check loop.

No extra upstream load — only removes the worker's self-imposed idle time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:23:54 +03:00
3bfefe6196 feat(analytics): canonical subscription_activated revenue event ()
No PostHog event carried realized revenue, and EFT/havale activations fired
nothing at all — so total paid revenue / MRR was unmeasurable (a Stripe DWH
connector alone would also miss EFT). activateSubscription is the shared
chokepoint for both Stripe (stripe.service) and EFT/manual (billing.service)
activation, so emit one canonical subscription_activated there with PostHog
revenue props: $revenue (major TRY), currency, mrr (yearly amortised /12),
plan, plan_id, brand_count, billing_period, method (looked up from the latest
payment row), referral_credit_days. Funnel steps keep their kuruş 'amount' but
intentionally carry no $revenue, so revenue isn't double-counted.

Unblocks trial->paid, MRR/ARPU and revenue-by-plan/channel across ALL payment
methods. Injected PostHogService (PostHogModule is @Global).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:02:30 +03:00
c5f8288d71 fix(catalog): case-insensitive PL24 group-wid drill check
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
PL24 sub-group nav nodes were classified by linkWid.includes("Group")
(case-sensitive). That matched capitalised wids (subGroupsTable) but
missed lowercase ones — groupReferenceTable, groupTable, groupsTable
(~1157 leaf nodes in prod) — so those skipped the group-drill branch in
getCategoryWithPartsInner and the reference-resolution descent, falling
to the parts path (a wasted upstream fetch; the generic drill-on-empty
fallback then re-drilled them). Lowercasing the check routes these nav
nodes straight to children/loadError like their capitalised siblings.

Empty-catalog audit (2026-06-03) showed PL24 drives 61% of user-seen
'0 parça' views; pcat fake-leaves are effectively solved (1 case).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:40:39 +03:00