From 29e10b432f9eb2405614efaee2fd4c20b66770b6 Mon Sep 17 00:00:00 2001 From: Semih Yesilyurt Date: Tue, 14 Jul 2026 16:35:47 +0300 Subject: [PATCH 1/3] feat(notifications): visible unsubscribe footer link in lifecycle mails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inject signed unsubscribeUrl into every optional-workflow Novu payload (templates render it via {{#if unsubscribeUrl}} footer) - add 'conversion' campaign workflow to OPTIONAL_WORKFLOWS + email_marketing category so its one-click tokens validate and opt-outs suppress it - declare UNSUBSCRIBE_SECRET/URL_BASE/EMAIL in env schema (""→undefined preprocess against the url().optional() boot-crash trap), .env.example and both compose env blocks - fix confirmation-page settings link (?tab=notifications) Co-Authored-By: Claude Fable 5 --- apps/api/.env.example | 5 +++ .../email-preferences.service.ts | 4 ++ apps/api/src/notifications/novu.ts | 39 +++++++++++++------ .../notifications/unsubscribe.controller.ts | 3 +- docker-compose.coolify.yml | 8 ++++ packages/config/src/index.ts | 14 +++++++ 6 files changed, 61 insertions(+), 12 deletions(-) diff --git a/apps/api/.env.example b/apps/api/.env.example index 54e3a80..c093918 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -23,3 +23,8 @@ NOVU_API_KEY= APP_PUBLIC_URL=https://sase.tr # HMAC secret for signed track.sase.tr click links (Bitwarden "mailtrack tracking (track.sase.tr)"). Empty → no click tracking. MAILTRACK_SECRET= +# Unsubscribe links (List-Unsubscribe header + mail footer). Empty secret → mailto-only header, no footer link (dev default). +UNSUBSCRIBE_SECRET= +# Defaults to /api/email/unsubscribe when empty. +UNSUBSCRIBE_URL_BASE= +UNSUBSCRIBE_EMAIL=unsubscribe@sase.tr diff --git a/apps/api/src/notifications/email-preferences.service.ts b/apps/api/src/notifications/email-preferences.service.ts index 60bec2b..964f04c 100644 --- a/apps/api/src/notifications/email-preferences.service.ts +++ b/apps/api/src/notifications/email-preferences.service.ts @@ -21,6 +21,9 @@ export const OPTIONAL_WORKFLOWS = new Set([ "referral", "referral-qualified", "referral-reward", + // Manual campaign sends (host-side sase-conversion-campaign.sh) — marketing, + // so it must honour opt-out and its unsubscribe tokens must validate here. + "conversion", "mobile_push", ]); @@ -50,6 +53,7 @@ export const NOTIFICATION_CATEGORIES = [ "referral", "referral-qualified", "referral-reward", + "conversion", ] as const, }, { diff --git a/apps/api/src/notifications/novu.ts b/apps/api/src/notifications/novu.ts index fa6e33e..2f5909c 100644 --- a/apps/api/src/notifications/novu.ts +++ b/apps/api/src/notifications/novu.ts @@ -62,24 +62,34 @@ const NO_UNSUBSCRIBE_WORKFLOWS = new Set([ "payment-failed", ]); +/** + * Signed HTTPS unsubscribe link for a (workflow, user) pair — the same URL the + * List-Unsubscribe header carries. GET renders a confirmation page, POST is + * the RFC 8058 one-click. Null for transactional flows or when the secret is + * unset (dev), so callers can skip the payload/header entirely. + */ +export function buildUnsubscribeUrl(workflow: string, subscriberId: string): string | null { + if (NO_UNSUBSCRIBE_WORKFLOWS.has(workflow)) return null; + if (!UNSUBSCRIBE_URL_BASE || !UNSUBSCRIBE_SECRET) return null; + const token = createHmac("sha256", UNSUBSCRIBE_SECRET) + .update(`${subscriberId}|${workflow}`) + .digest("hex"); + const q = new URLSearchParams({ u: subscriberId, w: workflow, t: token }); + return `${UNSUBSCRIBE_URL_BASE}?${q.toString()}`; +} + function buildUnsubscribeHeaders(workflow: string, subscriberId: string): Record { if (NO_UNSUBSCRIBE_WORKFLOWS.has(workflow)) return {}; const targets: string[] = []; - if (UNSUBSCRIBE_URL_BASE && UNSUBSCRIBE_SECRET) { - const token = createHmac("sha256", UNSUBSCRIBE_SECRET) - .update(`${subscriberId}|${workflow}`) - .digest("hex"); - const q = new URLSearchParams({ u: subscriberId, w: workflow, t: token }); - targets.push(`<${UNSUBSCRIBE_URL_BASE}?${q.toString()}>`); - } + const httpsUrl = buildUnsubscribeUrl(workflow, subscriberId); + if (httpsUrl) targets.push(`<${httpsUrl}>`); targets.push( ``, ); const headers: Record = { "List-Unsubscribe": targets.join(", ") }; // RFC 8058 one-click — only assert when an HTTPS endpoint is wired; Gmail - // will probe the HTTPS target with POST when this header is present, so - // gate it behind both env vars being set. - if (UNSUBSCRIBE_URL_BASE && UNSUBSCRIBE_SECRET) { + // will probe the HTTPS target with POST when this header is present. + if (httpsUrl) { headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"; } return headers; @@ -176,7 +186,14 @@ export async function triggerNovu( // Postal only AFTER the host-side Novu NodemailerProvider patch is applied — // see postal/novu-patches/apply-headers-patch.sh. const unsubHeaders = buildUnsubscribeHeaders(name, to.subscriberId); - const body: Record = { name, to, payload }; + // Visible body-footer variant of the same link — templates render it via + // `{{#if unsubscribeUrl}}` so mails stay valid when the secret is unset. + const unsubscribeUrl = buildUnsubscribeUrl(name, to.subscriberId); + const fullPayload = + unsubscribeUrl && payload.unsubscribeUrl === undefined + ? { ...payload, unsubscribeUrl } + : payload; + const body: Record = { name, to, payload: fullPayload }; if (Object.keys(unsubHeaders).length > 0) { body.overrides = { email: { headers: unsubHeaders } }; } diff --git a/apps/api/src/notifications/unsubscribe.controller.ts b/apps/api/src/notifications/unsubscribe.controller.ts index 761d7ca..98baa7b 100644 --- a/apps/api/src/notifications/unsubscribe.controller.ts +++ b/apps/api/src/notifications/unsubscribe.controller.ts @@ -113,6 +113,7 @@ const WORKFLOW_LABELS: Record = { referral: "Davet hatırlatması", "referral-qualified": "Davet bildirimleri", "referral-reward": "Ödül bildirimleri", + conversion: "Kampanya mailleri", }; /** @@ -141,7 +142,7 @@ function renderPage(ok: boolean, workflow: string): string {

Abonelikten çıkıldı

Artık ${escapeHtml(label)} almayacaksın. Hesabınla ilgili önemli bilgilendirme mailleri (e-posta doğrulama, ödeme bildirimleri) gelmeye devam eder.

Fikrini değiştirirsen ayarlar > bildirimler sayfasından geri açabilirsin.

-

Ayarları aç

+

Ayarları aç

`; } diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index aaf6171..b93667b 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -73,6 +73,10 @@ services: - NOVU_API_KEY=${NOVU_API_KEY:-} - APP_PUBLIC_URL=${APP_PUBLIC_URL:-https://sase.tr} - MAILTRACK_SECRET=${MAILTRACK_SECRET:-} + # Unsubscribe links (List-Unsubscribe header + mail footer); empty secret = mailto-only + - UNSUBSCRIBE_SECRET=${UNSUBSCRIBE_SECRET:-} + - UNSUBSCRIBE_URL_BASE=${UNSUBSCRIBE_URL_BASE:-} + - UNSUBSCRIBE_EMAIL=${UNSUBSCRIBE_EMAIL:-} # PostHog server-side analytics (payments, subscription lifecycle, $revenue). # Empty key → PostHogService no-ops (server-side analytics disabled). - POSTHOG_API_KEY=${POSTHOG_API_KEY:-} @@ -211,6 +215,10 @@ services: - NOVU_API_KEY=${NOVU_API_KEY:-} - APP_PUBLIC_URL=${APP_PUBLIC_URL:-https://sase.tr} - MAILTRACK_SECRET=${MAILTRACK_SECRET:-} + # Unsubscribe links (List-Unsubscribe header + mail footer); empty secret = mailto-only + - UNSUBSCRIBE_SECRET=${UNSUBSCRIBE_SECRET:-} + - UNSUBSCRIBE_URL_BASE=${UNSUBSCRIBE_URL_BASE:-} + - UNSUBSCRIBE_EMAIL=${UNSUBSCRIBE_EMAIL:-} # PostHog server-side analytics (payments, subscription lifecycle, $revenue). # Empty key → PostHogService no-ops (server-side analytics disabled). - POSTHOG_API_KEY=${POSTHOG_API_KEY:-} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index cc55990..c10b1f9 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -120,6 +120,20 @@ export const envSchema = z.object({ // HMAC secret for signed track.sase.tr click links. When unset, CTAs are passed // un-wrapped (no click tracking) — links still work. MAILTRACK_SECRET: z.string().optional(), + // Unsubscribe (List-Unsubscribe header + body footer links). When the secret + // is unset, mails carry the mailto: variant only and one-click POSTs are + // rejected — fine for dev, must be set in prod. + UNSUBSCRIBE_SECRET: z.string().optional(), + // "" → undefined preprocess: compose ships `${VAR:-}` so unset values arrive + // as empty strings, and a bare .url()/.email() would crash boot on "". + UNSUBSCRIBE_URL_BASE: z.preprocess( + (v) => (typeof v === "string" && v.trim() === "" ? undefined : v), + z.string().url().optional(), + ), + UNSUBSCRIBE_EMAIL: z.preprocess( + (v) => (typeof v === "string" && v.trim() === "" ? undefined : v), + z.string().email().default("unsubscribe@sase.tr"), + ), // OpenTelemetry OTEL_ENABLED: z From 8e10ebc883ad5793c453f80adc961f1c0092bb3a Mon Sep 17 00:00:00 2001 From: Semih Yesilyurt Date: Tue, 14 Jul 2026 17:47:24 +0300 Subject: [PATCH 2/3] feat(web): redirect path-style /dashboard/settings/ to ?tab= search param Unsubscribe confirmations (and any stale links) used the path form which had no route and fell to the SPA not-found screen. Co-Authored-By: Claude Fable 5 --- apps/web/src/routeTree.gen.ts | 21 +++++++++++++++++++ .../src/routes/dashboard/settings_/$tab.tsx | 14 +++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 apps/web/src/routes/dashboard/settings_/$tab.tsx diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 532d091..0e2d845 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -39,6 +39,7 @@ import { Route as DashboardSubscriptionIndexRouteImport } from './routes/dashboa import { Route as DashboardCatalogIndexRouteImport } from './routes/dashboard/catalog/index' import { Route as DashboardAdminIndexRouteImport } from './routes/dashboard/admin/index' import { Route as DemoCategoriesCategoryIdRouteImport } from './routes/demo_/categories_/$categoryId' +import { Route as DashboardSettingsTabRouteImport } from './routes/dashboard/settings_/$tab' import { Route as DashboardOemCodeRouteImport } from './routes/dashboard/oem.$code' import { Route as DashboardBlogSlugRouteImport } from './routes/dashboard/blog_/$slug' import { Route as DashboardAdminUsersRouteImport } from './routes/dashboard/admin/users' @@ -210,6 +211,11 @@ const DemoCategoriesCategoryIdRoute = path: '/demo/categories/$categoryId', getParentRoute: () => rootRouteImport, } as any) +const DashboardSettingsTabRoute = DashboardSettingsTabRouteImport.update({ + id: '/settings_/$tab', + path: '/settings/$tab', + getParentRoute: () => DashboardRoute, +} as any) const DashboardOemCodeRoute = DashboardOemCodeRouteImport.update({ id: '/oem/$code', path: '/oem/$code', @@ -352,6 +358,7 @@ export interface FileRoutesByFullPath { '/dashboard/admin/users': typeof DashboardAdminUsersRoute '/dashboard/blog/$slug': typeof DashboardBlogSlugRoute '/dashboard/oem/$code': typeof DashboardOemCodeRoute + '/dashboard/settings/$tab': typeof DashboardSettingsTabRoute '/demo/categories/$categoryId': typeof DemoCategoriesCategoryIdRoute '/dashboard/admin/': typeof DashboardAdminIndexRoute '/dashboard/catalog/': typeof DashboardCatalogIndexRoute @@ -401,6 +408,7 @@ export interface FileRoutesByTo { '/dashboard/admin/users': typeof DashboardAdminUsersRoute '/dashboard/blog/$slug': typeof DashboardBlogSlugRoute '/dashboard/oem/$code': typeof DashboardOemCodeRoute + '/dashboard/settings/$tab': typeof DashboardSettingsTabRoute '/demo/categories/$categoryId': typeof DemoCategoriesCategoryIdRoute '/dashboard/admin': typeof DashboardAdminIndexRoute '/dashboard/catalog': typeof DashboardCatalogIndexRoute @@ -453,6 +461,7 @@ export interface FileRoutesById { '/dashboard/admin/users': typeof DashboardAdminUsersRoute '/dashboard/blog_/$slug': typeof DashboardBlogSlugRoute '/dashboard/oem/$code': typeof DashboardOemCodeRoute + '/dashboard/settings_/$tab': typeof DashboardSettingsTabRoute '/demo_/categories_/$categoryId': typeof DemoCategoriesCategoryIdRoute '/dashboard/admin/': typeof DashboardAdminIndexRoute '/dashboard/catalog/': typeof DashboardCatalogIndexRoute @@ -505,6 +514,7 @@ export interface FileRouteTypes { | '/dashboard/admin/users' | '/dashboard/blog/$slug' | '/dashboard/oem/$code' + | '/dashboard/settings/$tab' | '/demo/categories/$categoryId' | '/dashboard/admin/' | '/dashboard/catalog/' @@ -554,6 +564,7 @@ export interface FileRouteTypes { | '/dashboard/admin/users' | '/dashboard/blog/$slug' | '/dashboard/oem/$code' + | '/dashboard/settings/$tab' | '/demo/categories/$categoryId' | '/dashboard/admin' | '/dashboard/catalog' @@ -605,6 +616,7 @@ export interface FileRouteTypes { | '/dashboard/admin/users' | '/dashboard/blog_/$slug' | '/dashboard/oem/$code' + | '/dashboard/settings_/$tab' | '/demo_/categories_/$categoryId' | '/dashboard/admin/' | '/dashboard/catalog/' @@ -851,6 +863,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DemoCategoriesCategoryIdRouteImport parentRoute: typeof rootRouteImport } + '/dashboard/settings_/$tab': { + id: '/dashboard/settings_/$tab' + path: '/settings/$tab' + fullPath: '/dashboard/settings/$tab' + preLoaderRoute: typeof DashboardSettingsTabRouteImport + parentRoute: typeof DashboardRoute + } '/dashboard/oem/$code': { id: '/dashboard/oem/$code' path: '/oem/$code' @@ -1022,6 +1041,7 @@ interface DashboardRouteChildren { DashboardAdminUsersRoute: typeof DashboardAdminUsersRoute DashboardBlogSlugRoute: typeof DashboardBlogSlugRoute DashboardOemCodeRoute: typeof DashboardOemCodeRoute + DashboardSettingsTabRoute: typeof DashboardSettingsTabRoute DashboardAdminIndexRoute: typeof DashboardAdminIndexRoute DashboardCatalogIndexRoute: typeof DashboardCatalogIndexRoute DashboardSubscriptionIndexRoute: typeof DashboardSubscriptionIndexRoute @@ -1056,6 +1076,7 @@ const DashboardRouteChildren: DashboardRouteChildren = { DashboardAdminUsersRoute: DashboardAdminUsersRoute, DashboardBlogSlugRoute: DashboardBlogSlugRoute, DashboardOemCodeRoute: DashboardOemCodeRoute, + DashboardSettingsTabRoute: DashboardSettingsTabRoute, DashboardAdminIndexRoute: DashboardAdminIndexRoute, DashboardCatalogIndexRoute: DashboardCatalogIndexRoute, DashboardSubscriptionIndexRoute: DashboardSubscriptionIndexRoute, diff --git a/apps/web/src/routes/dashboard/settings_/$tab.tsx b/apps/web/src/routes/dashboard/settings_/$tab.tsx new file mode 100644 index 0000000..e32fd8a --- /dev/null +++ b/apps/web/src/routes/dashboard/settings_/$tab.tsx @@ -0,0 +1,14 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { SETTINGS_TABS, type SettingsTab } from "../settings"; + +// Path-style deep links (e.g. /dashboard/settings/notifications from e-mail +// unsubscribe confirmations) — the settings page keys tabs off `?tab=`, so +// redirect there. Unknown segments fall back to the default tab. +export const Route = createFileRoute("/dashboard/settings_/$tab")({ + beforeLoad: ({ params }) => { + const tab = SETTINGS_TABS.includes(params.tab as SettingsTab) + ? (params.tab as SettingsTab) + : undefined; + throw redirect({ to: "/dashboard/settings", search: tab ? { tab } : {} }); + }, +}); From 61b5769e4826a1cc2f02d1515d2dd045971a6ce2 Mon Sep 17 00:00:00 2001 From: Semih Yesilyurt Date: Tue, 14 Jul 2026 19:11:21 +0300 Subject: [PATCH 3/3] fix(vinpin): reliable warm-daemon establish (clean teardown + tab-close + backoff) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of warm-establish failures was resume-into-dirty-session + broken window-close + no backoff, not OCR/detection. Four composing fixes: A. Clean teardown (cleanTeardown): before the browser close(), close each open catalog window via the corrected tab-✕ then click "Çıkış yap" logout to END the RDS session, so the next warm-up starts from a fresh login/grid instead of resuming into the last-open 3-window desktop. Wired into teardownWarm() and both failed-warmUp exits. Bounded + never-throw. B. Fix close coords + tab-✕ primary. catalogTabClose {135,45}→{93,45} (validated live). In ensureBrandGrid/returnToBrandGrid the tab-✕ is now the PRIMARY close; the windowClose {1298,14} click (which opens the HTML-Access language dropdown) is no longer used there. Escape pressed after each close to dismiss an accidental dropdown before re-OCR. After N failed closes, ensureBrandGrid escalates to logout+relogin (one-shot, no recursion) instead of limping into the ePER-open loop. C. Realistic grid wait. afterLogin 20_000→45_000 (real grid render ~32-46s). D. Backoff between re-warm attempts. A failed warmUp sets a cooldown (60s, exponential to 5min) that BOTH reconcile() and decode()'s warm-on-demand honour; a successful warm resets it — so a failing seat is no longer hammered every ~60s leaving fresh dirty windows. Safety nets preserved: never-throw contract, VINPIN_DECODE_BUDGET_MS, sessionPoisoned, cold/Dialogys fallbacks; fcc0298 Rpartstore spinner-guard, Fiat ePER path, and Russian-dialog dismissal (746,454) untouched. Cannot be exercised in dev (single seat on prod) — needs prod validation on a rested seat. Tests: +8 unit tests (clean-teardown ordering, tab-✕ primary + relogin escalation, warm-up backoff respected by reconcile + warm-on-demand + reset). 81 vinpin tests green; tsc + biome clean. Co-Authored-By: Claude Opus 4.8 --- .../vinpin/vinpin-daemon.service.spec.ts | 66 ++++++++ .../vinpin/vinpin-daemon.service.ts | 65 +++++++- .../vinpin/vinpin-driver.service.ts | 132 ++++++++++++--- .../vinpin/vinpin-driver.teardown.spec.ts | 153 ++++++++++++++++++ .../integrations/vinpin/vinpin.constants.ts | 31 +++- 5 files changed, 415 insertions(+), 32 deletions(-) create mode 100644 apps/api/src/integrations/vinpin/vinpin-driver.teardown.spec.ts diff --git a/apps/api/src/integrations/vinpin/vinpin-daemon.service.spec.ts b/apps/api/src/integrations/vinpin/vinpin-daemon.service.spec.ts index eea4929..6c3a5c1 100644 --- a/apps/api/src/integrations/vinpin/vinpin-daemon.service.spec.ts +++ b/apps/api/src/integrations/vinpin/vinpin-daemon.service.spec.ts @@ -125,6 +125,72 @@ describe("VinpinDaemonService — decode routing", () => { }); }); +describe("VinpinDaemonService — warm-up backoff", () => { + afterEach(() => vi.restoreAllMocks()); + + function backoffDaemon(driver: FakeDriver, clock: { t: number }) { + return new VinpinDaemonService({ + driver: driver as unknown as VinpinDriverService, + isBusinessHours: () => true, + isEnabled: () => true, + now: () => clock.t, + }); + } + + it("a failed warmUp sets a cooldown that reconcile() respects (no immediate re-warm)", async () => { + const driver = makeDriver(false); + driver.warmUp.mockResolvedValue(false); // warm-up keeps failing + const clock = { t: 0 }; + const d = backoffDaemon(driver, clock); + + await d.reconcile(); + expect(driver.warmUp).toHaveBeenCalledTimes(1); // first attempt ran + + // Still cold + in hours, but inside the cooldown → no second attempt. + clock.t = 30_000; + await d.reconcile(); + expect(driver.warmUp).toHaveBeenCalledTimes(1); + + // After the base cooldown (60s) elapses → it tries again. + clock.t = 61_000; + await d.reconcile(); + expect(driver.warmUp).toHaveBeenCalledTimes(2); + }); + + it("warm-on-demand decode ALSO respects the cooldown, but still delegates the cold decode", async () => { + const driver = makeDriver(false); + driver.warmUp.mockResolvedValue(false); + const clock = { t: 0 }; + const d = backoffDaemon(driver, clock); + + await d.reconcile(); // fails → cooldown until 60_000 + expect(driver.warmUp).toHaveBeenCalledTimes(1); + + clock.t = 20_000; + await d.decode("NM435600006H43436"); // in cooldown → no warm-on-demand + expect(driver.warmUp).toHaveBeenCalledTimes(1); + expect(driver.decode).toHaveBeenCalledTimes(1); // still decodes via the cold path + }); + + it("a successful warm resets the backoff (next attempt is not blocked)", async () => { + const driver = makeDriver(false); + driver.warmUp.mockResolvedValueOnce(false).mockResolvedValueOnce(true).mockResolvedValue(false); + const clock = { t: 0 }; + const d = backoffDaemon(driver, clock); + + await d.reconcile(); // fail → cooldown until 60_000 + clock.t = 61_000; + await d.reconcile(); // success → cooldown reset + expect(driver.warmUp).toHaveBeenCalledTimes(2); + + // isWarm() is still false in the fake, so reconcile would warm again — and with + // the cooldown reset it may attempt immediately (no leftover backoff window). + clock.t = 61_500; + await d.reconcile(); + expect(driver.warmUp).toHaveBeenCalledTimes(3); + }); +}); + describe("VinpinDaemonService — start/stop lifecycle", () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => { diff --git a/apps/api/src/integrations/vinpin/vinpin-daemon.service.ts b/apps/api/src/integrations/vinpin/vinpin-daemon.service.ts index 291323c..a218102 100644 --- a/apps/api/src/integrations/vinpin/vinpin-daemon.service.ts +++ b/apps/api/src/integrations/vinpin/vinpin-daemon.service.ts @@ -35,6 +35,8 @@ export interface VinpinDaemonDeps { isBusinessHours?: () => boolean; /** Warm-daemon enabled predicate (injectable for tests). */ isEnabled?: () => boolean; + /** Monotonic-ish clock (injectable so tests can drive the warm-up backoff). */ + now?: () => number; } export class VinpinDaemonService { @@ -42,16 +44,55 @@ export class VinpinDaemonService { private readonly driver: VinpinDriverService; private readonly isBusinessHours: () => boolean; private readonly isEnabled: () => boolean; + private readonly now: () => number; private schedulerTimer: ReturnType | null = null; private keepaliveTimer: ReturnType | null = null; private reconciling = false; private started = false; + // ─── Warm-up backoff ───────────────────────────────────── + /** Wall-clock time until which a re-warm is suppressed after a failed warmUp. + * Both reconcile() and decode()'s warm-on-demand honour this so a failing seat + * isn't hammered every ~60s (each failed attempt would leave a fresh dirty + * window). A successful warm resets it. */ + private warmCooldownUntil = 0; + /** Current backoff span (ms): 0 when healthy, else base…max, doubling per + * consecutive failure. */ + private warmBackoffMs = 0; + constructor(deps: VinpinDaemonDeps = {}) { this.driver = deps.driver ?? getVinpinDriver(); this.isBusinessHours = deps.isBusinessHours ?? (() => isVinpinBusinessHours()); this.isEnabled = deps.isEnabled ?? isVinpinWarmDaemonEnabled; + this.now = deps.now ?? (() => Date.now()); + } + + /** True while a failed warmUp's cooldown is still in effect. */ + private inWarmCooldown(): boolean { + return this.now() < this.warmCooldownUntil; + } + + /** + * Record a warm-up outcome and update the backoff. Success clears the cooldown; + * failure sets/extends it (base, then exponential up to max). Returns `ok` so + * callers can chain. + */ + private noteWarmResult(ok: boolean): boolean { + if (ok) { + this.warmBackoffMs = 0; + this.warmCooldownUntil = 0; + } else { + this.warmBackoffMs = + this.warmBackoffMs === 0 + ? VINPIN_WARM.warmBackoffBaseMs + : Math.min(this.warmBackoffMs * 2, VINPIN_WARM.warmBackoffMaxMs); + this.warmCooldownUntil = this.now() + this.warmBackoffMs; + this.logger.warn( + `warmUp failed — backing off ${Math.round(this.warmBackoffMs / 1000)}s before the next attempt`, + ); + } + return ok; } /** Start the scheduler + keepalive loops (idempotent). */ @@ -96,11 +137,19 @@ export class VinpinDaemonService { const enabled = this.isEnabled(); const inHours = this.isBusinessHours(); if (enabled && inHours && !this.driver.isWarm()) { - this.logger.log("scheduler: inside business hours — warming the seat"); - await this.driver.warmUp(); + if (this.inWarmCooldown()) { + this.logger.debug("scheduler: in warm-up backoff — skipping this cycle"); + } else { + this.logger.log("scheduler: inside business hours — warming the seat"); + this.noteWarmResult(await this.driver.warmUp()); + } } else if (this.driver.isWarm() && (!enabled || !inHours)) { this.logger.log("scheduler: outside business hours / disabled — tearing the seat down"); await this.driver.teardownWarm(); + // Intentional teardown → clear any stale warm-up backoff so the next window + // (e.g. tomorrow 08:00) isn't blocked by a leftover cooldown. + this.warmBackoffMs = 0; + this.warmCooldownUntil = 0; } } catch (err) { this.logger.warn(`reconcile failed: ${(err as Error).message}`); @@ -117,11 +166,17 @@ export class VinpinDaemonService { */ async decode(vin: string): Promise { try { - if (this.isEnabled() && this.isBusinessHours() && !this.driver.isWarm()) { + if ( + this.isEnabled() && + this.isBusinessHours() && + !this.driver.isWarm() && + !this.inWarmCooldown() + ) { // Warm-on-demand: a decode arrived inside hours before the scheduler warmed // (e.g. right after 08:00, or after a drop). Best-effort — if it fails the - // driver silently runs the cold path for this decode. - await this.driver.warmUp().catch(() => false); + // driver silently runs the cold path for this decode, and the backoff spaces + // out the next warm attempt (respected by both this check and reconcile()). + this.noteWarmResult(await this.driver.warmUp().catch(() => false)); } return await this.driver.decode(vin); } catch (err) { diff --git a/apps/api/src/integrations/vinpin/vinpin-driver.service.ts b/apps/api/src/integrations/vinpin/vinpin-driver.service.ts index b42034a..1166654 100644 --- a/apps/api/src/integrations/vinpin/vinpin-driver.service.ts +++ b/apps/api/src/integrations/vinpin/vinpin-driver.service.ts @@ -354,10 +354,53 @@ export class VinpinDriverService implements OnModuleDestroy { await this.runExclusive(async () => { this.warm = false; this.taskbarCoords = {}; + // Clean the RDS SEAT (close catalog windows + log out) BEFORE dropping the + // browser, so the next warm-up starts from a fresh login/grid rather than + // resuming into the last-open dirty catalog desktop. + await this.cleanTeardown(); await this.close(); }).catch(() => undefined); } + /** + * Clean-teardown routine (never-throw, bounded). Leaves the RDS SEAT itself clean + * before the browser is closed, which is the root fix for warm-establish + * reliability: closing the Playwright browser alone does NOT end the remote + * session, so Horizon otherwise resumes into whatever catalog windows were left + * open. This (1) closes every open catalog window via the corrected tab-✕, then + * (2) clicks the "Çıkış yap" logout button to end the RDS session so the next + * warm-up gets a fresh login/grid. If the logout click misses, step (1) at least + * guarantees resume lands on the brand grid, not inside a catalog. Best-effort: + * a page that's already gone is a no-op; every step swallows its own errors. + */ + private async cleanTeardown(): Promise { + try { + const page = this.page; + if (!this.browser?.isConnected() || !this.context || !page || page.isClosed()) return; + // (1) Close any open catalog windows (bounded) so resume can't land inside a + // catalog. The warm daemon keeps up to 3 windows open, so allow a few passes. + for (let i = 0; i < 6; i++) { + const t = await ocrRegion(page).catch(() => ""); + if (!VINPIN_OCR.catalogWindowOpen.test(t)) break; + await page.mouse + .click(VINPIN_COORDS.catalogTabClose.x, VINPIN_COORDS.catalogTabClose.y) + .catch(() => undefined); + await page.waitForTimeout(VINPIN_WAITS.afterWindowClose); + // A close may pop an accidental dropdown/confirm — dismiss it before re-OCR. + await page.keyboard.press("Escape").catch(() => undefined); + } + // (2) End the RDS session via the "Çıkış yap" logout button so the next + // warm-up gets a fresh login/grid, not a resumed desktop. + await page.mouse + .click(VINPIN_COORDS.logoutButton.x, VINPIN_COORDS.logoutButton.y) + .catch(() => undefined); + await page.waitForTimeout(VINPIN_WAITS.afterWindowClose); + await page.keyboard.press("Enter").catch(() => undefined); // confirm-logout dialog + } catch (err) { + this.logger.warn(`cleanTeardown best-effort failure: ${(err as Error).message}`); + } + } + /** * Idle keepalive nudge (daemon calls this every ~75s). A `mouse.move` to a * harmless in-window point holds the RDS session (proven: 12.6 min, zero @@ -413,7 +456,10 @@ export class VinpinDriverService implements OnModuleDestroy { await this.bindTaskbarCoords(page); if (!fiatUp && !okR && !okD) { - // Nothing opened → don't claim warm; leave the cold path in effect. + // Nothing opened → don't claim warm; leave the cold path in effect. Clean + // the seat (log out) first so the next warm-up isn't sabotaged by a dirty + // resumed desktop from this failed attempt. + await this.cleanTeardown(); await this.close(); return false; } @@ -429,6 +475,9 @@ export class VinpinDriverService implements OnModuleDestroy { this.logger.warn( `warmUp failed: ${(err as Error).message} — falling back to cold per-decode path`, ); + // Clean the RDS seat (close windows + log out) before dropping the browser so + // this failed attempt doesn't leave a dirty desktop for the next warm-up. + await this.cleanTeardown().catch(() => undefined); await this.close().catch(() => undefined); this.warm = false; return false; @@ -914,7 +963,19 @@ export class VinpinDriverService implements OnModuleDestroy { if (!page) throw new Error("Vinpin page not initialized"); if (this.authed) return; - // Login (reliable DOM path). + await this.performWebLogin(page, cfg); + // Bring VinPower up (permanent seat needs the app clicked; trial auto-launches). + await this.ensureBrandGrid(page, cfg); + this.authed = true; + } + + /** + * Submit the web login form (reliable DOM path) and wait for the post-login + * screen — the Horizon app-launcher (permanent seat) or the VinPower brand grid + * (trial auto-launch). Factored out so both the normal auth path and the + * logout→relogin escalation share one implementation. + */ + private async performWebLogin(page: Page, cfg: VinpinConfig): Promise { await page.goto(cfg.url, { waitUntil: "domcontentloaded", timeout: 60_000 }); await page.waitForTimeout(VINPIN_WAITS.afterGoto); const userInput = page.locator('input[type="text"]:visible, input:not([type]):visible').first(); @@ -925,17 +986,37 @@ export class VinpinDriverService implements OnModuleDestroy { .fill(cfg.pass ?? ""); const submit = page.locator('button:has-text("Oturum"), [type="submit"]').first(); await submit.click(); - // Poll for the post-login screen: the Horizon app-launcher (permanent seat) or - // the VinPower brand grid (trial auto-launch). Cap = afterLogin fallback. + // Poll for the post-login screen. Cap = afterLogin fallback (the real grid + // render measured ~32–46s, so the cap is now 45s — see VINPIN_WAITS.afterLogin). await this.pollForState( page, (t) => VINPIN_OCR.launcher.test(t) || VINPIN_OCR.brandGrid.test(t), VINPIN_WAITS.afterLogin, ); + } - // Bring VinPower up (permanent seat needs the app clicked; trial auto-launches). - await this.ensureBrandGrid(page, cfg); - this.authed = true; + /** + * Last-ditch recovery when the brand grid can't be reached by closing windows: end + * the dirty RDS session (clean-teardown → logout), cold-relaunch the browser, and + * log back in from scratch to a fresh grid. One-shot — the inner ensureBrandGrid is + * called with allowRelogin=false so this can never recurse into a livelock. Returns + * true when the brand grid is confirmed after relogin. Never throws. + */ + private async logoutAndRelogin(cfg: VinpinConfig): Promise { + try { + this.logger.warn("escalating to logout + relogin to clear a stuck resumed desktop"); + await this.cleanTeardown(); + await this.close(); + await this.launch(cfg); + const page = this.page; + if (!page) return false; + await this.performWebLogin(page, cfg); + await this.ensureBrandGrid(page, cfg, false); // one-shot: no recursive relogin + return VINPIN_OCR.brandGrid.test(await ocrRegion(page)); + } catch (err) { + this.logger.warn(`logoutAndRelogin failed: ${(err as Error).message}`); + return false; + } } /** @@ -947,12 +1028,14 @@ export class VinpinDriverService implements OnModuleDestroy { for (let i = 0; i < 4; i++) { const t = await ocrRegion(page); if (VINPIN_OCR.brandGrid.test(t)) return; - // A catalog/ePER window is open → close it to fall back to the grid. + // A catalog/ePER window is open → close it to fall back to the grid. PRIMARY: + // the browser-tab ✕ (93,45). The old windowClose (1298,14) actually hits the + // HTML-Access language selector (opens a dropdown), so it's no longer used here. this.logger.warn(`not on brand grid (try ${i + 1}/4) — closing open window`); - await page.mouse.click(VINPIN_COORDS.windowClose.x, VINPIN_COORDS.windowClose.y); + await page.mouse.click(VINPIN_COORDS.catalogTabClose.x, VINPIN_COORDS.catalogTabClose.y); await page.waitForTimeout(VINPIN_WAITS.afterWindowClose); - // A confirm/language dialog may pop on close. - await page.keyboard.press("Enter").catch(() => {}); + // Dismiss an accidentally-opened dropdown/confirm before re-OCR. + await page.keyboard.press("Escape").catch(() => {}); await this.ensureBrandGrid(page, cfg); } } @@ -1084,7 +1167,7 @@ export class VinpinDriverService implements OnModuleDestroy { * OCR-gated with a small retry budget. Never throws — if the grid can't be * confirmed, the caller's own ePER-open retry loop still runs. */ - private async ensureBrandGrid(page: Page, cfg: VinpinConfig): Promise { + private async ensureBrandGrid(page: Page, cfg: VinpinConfig, allowRelogin = true): Promise { for (let i = 0; i < 6; i++) { const t = await ocrRegion(page); if (VINPIN_OCR.brandGrid.test(t)) { @@ -1117,13 +1200,12 @@ export class VinpinDriverService implements OnModuleDestroy { // the grid. Window-close first, then the browser-tab ✕ as a fallback. if (VINPIN_OCR.catalogWindowOpen.test(t)) { this.logger.log(`catalog window resumed open — closing to reach grid (try ${i + 1}/6)`); - await page.mouse.click(VINPIN_COORDS.windowClose.x, VINPIN_COORDS.windowClose.y); + // PRIMARY: the browser-tab ✕ (93,45). The window-close (1298,14) actually + // opens the HTML-Access language dropdown, so it's no longer clicked here. + await page.mouse.click(VINPIN_COORDS.catalogTabClose.x, VINPIN_COORDS.catalogTabClose.y); await page.waitForTimeout(VINPIN_WAITS.afterWindowClose); - await page.keyboard.press("Enter").catch(() => {}); - if (!VINPIN_OCR.brandGrid.test(await ocrRegion(page))) { - await page.mouse.click(VINPIN_COORDS.catalogTabClose.x, VINPIN_COORDS.catalogTabClose.y); - await page.waitForTimeout(VINPIN_WAITS.afterWindowClose); - } + // Dismiss an accidentally-opened dropdown/confirm before re-OCR. + await page.keyboard.press("Escape").catch(() => {}); continue; } // Neither grid, login, nor launcher — a "Disconnected" dialog or a blank @@ -1132,10 +1214,18 @@ export class VinpinDriverService implements OnModuleDestroy { await page.waitForTimeout(VINPIN_WAITS.afterVinpinLaunch); } // Bounded tries exhausted without confirming the grid — most often a resumed - // catalog window we couldn't close (the seat-livelock trigger). Poison the seat - // so the next decode cold re-establishes rather than reconnecting to this state. + // catalog window we couldn't close (the seat-livelock trigger). Rather than limp + // into the ePER-open loop on a dirty desktop, escalate ONCE to logout + relogin + // (end the RDS session and log back in to a fresh grid). One-shot: the inner + // relogin runs ensureBrandGrid with allowRelogin=false, so this can't recurse. + if (allowRelogin && (await this.logoutAndRelogin(cfg))) { + this.sessionPoisoned = false; // relogin reached a fresh, confirmed-clean grid + return; + } + // Relogin disabled (already the one-shot attempt) or it too failed → poison the + // seat so the next decode cold re-establishes rather than reusing this state. this.sessionPoisoned = true; - this.logger.warn("VinPower brand grid not confirmed — letting the ePER-open loop try anyway"); + this.logger.warn("VinPower brand grid not confirmed (relogin exhausted) — poisoning seat"); } /** diff --git a/apps/api/src/integrations/vinpin/vinpin-driver.teardown.spec.ts b/apps/api/src/integrations/vinpin/vinpin-driver.teardown.spec.ts new file mode 100644 index 0000000..ffdad0d --- /dev/null +++ b/apps/api/src/integrations/vinpin/vinpin-driver.teardown.spec.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Clean-teardown + grid-return reliability tests for the Vinpin driver. These + * exercise the warm-establish fix: the clean-teardown routine (close resumed + * catalog windows via the corrected tab-✕, then log out of the RDS session before + * dropping the browser) and ensureBrandGrid's escalation to logout+relogin. OCR is + * mocked so the screen-state sequence is fully controllable — the real seat lives + * on prod and can't be driven from a test. + */ +vi.mock("./vinpin.ocr", () => ({ + ocrRegion: vi.fn(async () => ""), + pollForText: vi.fn(async () => ({ matched: false, text: "" })), + terminateOcr: vi.fn(async () => undefined), +})); + +import { VinpinDriverService } from "./vinpin-driver.service"; +import { ocrRegion } from "./vinpin.ocr"; + +const mockOcr = vi.mocked(ocrRegion); + +type AnyDriver = Record; + +function fakePage(sink: (x: number, y: number) => void) { + return { + isClosed: () => false, + frames: () => [] as unknown[], + mouse: { click: vi.fn(async (x: number, y: number) => sink(x, y)) }, + keyboard: { press: vi.fn(async () => undefined) }, + waitForTimeout: vi.fn(async () => undefined), + }; +} + +describe("VinpinDriverService — clean teardown", () => { + const savedEnv = { ...process.env }; + beforeEach(() => { + process.env.VINPIN_ENABLED = "true"; + process.env.VINPIN_USER = "user"; + process.env.VINPIN_PASS = "pass"; + process.env.VINPIN_WARM_DAEMON = "true"; + mockOcr.mockReset(); + }); + afterEach(() => { + vi.restoreAllMocks(); + process.env = { ...savedEnv }; + }); + + it("teardownWarm closes each resumed catalog window (tab-✕) then logs out BEFORE closing the browser", async () => { + const driver = new VinpinDriverService(); + const any = driver as unknown as AnyDriver; + const events: string[] = []; + vi.spyOn(any as never, "close").mockImplementation((async () => { + events.push("close"); + }) as never); + any.browser = { isConnected: () => true }; + any.context = {}; + any.warm = true; + any.page = fakePage((x, y) => { + if (x === 93 && y === 45) events.push("tabClose"); + if (x === 1543 && y === 877) events.push("logout"); + }); + // Two catalog windows open, then a clear desktop. + mockOcr + .mockResolvedValueOnce("RPartStore") + .mockResolvedValueOnce("ePER Dealer") + .mockResolvedValue(""); + + await driver.teardownWarm(); + + // Both windows closed via the corrected tab-✕, then logout, then browser close. + expect(events).toEqual(["tabClose", "tabClose", "logout", "close"]); + expect(any.warm).toBe(false); + }); + + it("clean-teardown still logs out when no catalog window is open (bounded, no-op close loop)", async () => { + const driver = new VinpinDriverService(); + const any = driver as unknown as AnyDriver; + const clicks: Array<[number, number]> = []; + any.browser = { isConnected: () => true }; + any.context = {}; + any.page = fakePage((x, y) => clicks.push([x, y])); + mockOcr.mockResolvedValue(""); // desktop already clear + + await (any.cleanTeardown as () => Promise).call(driver); + + expect(clicks.filter(([x, y]) => x === 93 && y === 45)).toHaveLength(0); // nothing to close + expect(clicks).toContainEqual([1543, 877]); // still logs out to end the RDS session + }); + + it("clean-teardown is a no-op when the browser/page is already gone", async () => { + const driver = new VinpinDriverService(); + const any = driver as unknown as AnyDriver; + any.browser = null; + any.context = null; + any.page = null; + // Must not throw and must not touch OCR. + await expect((any.cleanTeardown as () => Promise).call(driver)).resolves.toBeUndefined(); + expect(mockOcr).not.toHaveBeenCalled(); + }); +}); + +describe("VinpinDriverService — ensureBrandGrid tab-✕ + relogin escalation", () => { + const savedEnv = { ...process.env }; + const cfg = { url: "https://vinpin.test", user: "user", pass: "pass" }; + beforeEach(() => { + process.env.VINPIN_ENABLED = "true"; + mockOcr.mockReset(); + }); + afterEach(() => { + vi.restoreAllMocks(); + process.env = { ...savedEnv }; + }); + + it("closes a resumed catalog via the tab-✕ (93,45) — never the language-selector coord (1298,14) — and escalates to relogin after N failures", async () => { + const driver = new VinpinDriverService(); + const any = driver as unknown as AnyDriver; + const clicks: Array<[number, number]> = []; + const page = fakePage((x, y) => clicks.push([x, y])); + // Always a catalog window open → grid never reached → forces the escalation. + mockOcr.mockResolvedValue("RPartStore catalog open"); + const relogin = vi.spyOn(any as never, "logoutAndRelogin").mockResolvedValue(true as never); + + await (any.ensureBrandGrid as (p: unknown, c: unknown, allow?: boolean) => Promise).call( + driver, + page, + cfg, + true, + ); + + expect(clicks).toContainEqual([93, 45]); // primary tab-✕ used + expect(clicks).not.toContainEqual([1298, 14]); // language-selector coord NOT clicked + expect(relogin).toHaveBeenCalledTimes(1); // escalated once + expect(any.sessionPoisoned).toBe(false); // relogin reached a fresh grid + }); + + it("with allowRelogin=false it poisons the seat instead of recursing (one-shot, no livelock)", async () => { + const driver = new VinpinDriverService(); + const any = driver as unknown as AnyDriver; + const page = fakePage(() => undefined); + mockOcr.mockResolvedValue("RPartStore catalog open"); // never reaches grid + const relogin = vi.spyOn(any as never, "logoutAndRelogin"); + + await (any.ensureBrandGrid as (p: unknown, c: unknown, allow?: boolean) => Promise).call( + driver, + page, + cfg, + false, + ); + + expect(relogin).not.toHaveBeenCalled(); // no recursion + expect(any.sessionPoisoned).toBe(true); // poisoned → next decode cold-restarts + }); +}); diff --git a/apps/api/src/integrations/vinpin/vinpin.constants.ts b/apps/api/src/integrations/vinpin/vinpin.constants.ts index 2f98b05..6e14073 100644 --- a/apps/api/src/integrations/vinpin/vinpin.constants.ts +++ b/apps/api/src/integrations/vinpin/vinpin.constants.ts @@ -50,12 +50,22 @@ export const VINPIN_COORDS = { // permanent seat (trvinpin41080). They are OCR-verified+retried at runtime like // the Fiat flow, so minor drift self-heals — but re-verify if layout changes. // TUNE. - /** Window-close (X) button of an open catalog window — returns to the brand - * grid when the seat resumed a previously-open catalog. TUNE. */ + /** Window-close (X) button of an open catalog window. ⚠️ On the HTML-Access + * desktop this coord actually hits the language selector (it opens a dropdown), + * so it is NO LONGER used by the grid-return paths — the browser-tab ✕ below is + * the primary close. Kept only as the last-ditch fallback inside the Rpartstore + * spinner-guard (closeRpartstoreTab), where it's tried after the tab ✕. TUNE. */ windowClose: { x: 1298, y: 14 }, - /** Browser-tab ✕ of an open catalog app (fallback for windowClose) — the tab - * sits just under the window title bar, e.g. "Renault Rpartstore ✕". TUNE. */ - catalogTabClose: { x: 135, y: 45 }, + /** Browser-tab ✕ of an open catalog app — the PRIMARY window-close action. The + * tab sits just under the window title bar, e.g. "Renault Rpartstore ✕". + * Validated live: clicking (93,45) closed the catalog and returned to the clean + * brand grid (the old {135,45} missed the ✕). TUNE. */ + catalogTabClose: { x: 93, y: 45 }, + /** "Çıkış yap" logout button (bottom-right of the RDS/Horizon desktop). Ends the + * remote session so the NEXT warm-up starts from a fresh login/grid instead of + * resuming into the last-open dirty catalog desktop. Used by the clean-teardown + * routine. TUNE. */ + logoutButton: { x: 1543, y: 877 }, /** Renault brand tile on the VinPower grid (opens the Rpartstore/Dialogys * submenu). mousedown/up like the Fiat tile. TUNE. */ renaultBrand: { x: 450, y: 707 }, @@ -234,6 +244,12 @@ export const VINPIN_WARM = { raiseVerifyRetries: 3, /** After a taskbar-raise click, wait for the window to come forward. */ afterRaiseMs: 1_200, + /** Backoff after a FAILED warmUp: reconcile() and decode()'s warm-on-demand both + * skip re-warming until the cooldown elapses, so a failing seat is not hammered + * every ~60s (which leaves a fresh dirty window each attempt). Starts at the base + * and doubles per consecutive failure up to the max; a successful warm resets it. */ + warmBackoffBaseMs: 60_000, + warmBackoffMaxMs: 300_000, } as const; /** @@ -284,7 +300,10 @@ export function isVinpinBusinessHours(now: Date = new Date()): boolean { /** Wait budgets (ms) for each step. Verified live @ 1600x900. */ export const VINPIN_WAITS = { afterGoto: 7_000, - afterLogin: 20_000, + /** After submitting the web login: wait for the post-login screen (Horizon + * launcher or VinPower brand grid) to render. The real grid render measured + * ~32–46s on the permanent seat, so the old 20s always timed out (noise). */ + afterLogin: 45_000, /** After clicking the launcher's VINPIN app: wait for VinPower to connect and * raise its login dialog (permanent seat only). */ afterVinpinLaunch: 14_000,