From 8d0ae98afdbf13ab8fa95a0419d45053b5c58ece Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 22 May 2026 08:49:44 +0200 Subject: [PATCH] fix(coach,withings): drop legacy_form_total counter + add navigator.onLine pre-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.4.47 W7 — the in-memory legacy_form_total counter from v1.4.25 W21 Fix-K was per-process and useless on multi-container deploys; the access-log warning already carries the signal it surfaced. Drop the counter + the admin-status field that exposed it. v1.4.47 W8 — Coach send-message path pre-checks navigator.onLine so an airplane-mode user gets the offline-specific 'coach.network' copy immediately instead of waiting for the fetch to fail with a generic network error. Surfaces W12 M6 properly. --- .../api/admin/status/__tests__/route.test.ts | 15 ------- src/app/api/admin/status/route.ts | 7 ---- .../withings/webhook/__tests__/route.test.ts | 28 +------------ src/app/api/withings/webhook/route.ts | 39 ++++--------------- .../insights/coach-panel/use-coach.ts | 14 +++++++ 5 files changed, 22 insertions(+), 81 deletions(-) diff --git a/src/app/api/admin/status/__tests__/route.test.ts b/src/app/api/admin/status/__tests__/route.test.ts index 459906c22..bb97d59bb 100644 --- a/src/app/api/admin/status/__tests__/route.test.ts +++ b/src/app/api/admin/status/__tests__/route.test.ts @@ -45,16 +45,9 @@ vi.mock("@/lib/jobs/worker-status", () => ({ })); // v1.4.25 W21 Fix-K — `/api/admin/status` now reads the legacy-form -// counter from the Withings webhook module. Mock it at module boundary -// so the test fixture stays deterministic. -vi.mock("@/app/api/withings/webhook/route", () => ({ - getLegacyFormTotal: vi.fn(() => 0), -})); - import { GET } from "../route"; import { prisma } from "@/lib/db"; import { requireAdmin, HttpError } from "@/lib/api-handler"; -import { getLegacyFormTotal } from "@/app/api/withings/webhook/route"; const ADMIN_CTX = { session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -178,12 +171,4 @@ describe("GET /api/admin/status (integration health summary)", () => { expect(tokenCallArgs).toEqual({ where: { revoked: false } }); }); - it("surfaces the legacy Withings webhook counter on the counters block (Fix-K sec-M2)", async () => { - vi.mocked(getLegacyFormTotal).mockReturnValue(7); - const res = await GET(); - const body = (await res.json()) as { - data: { counters: { withingsWebhookLegacyFormTotal: number } }; - }; - expect(body.data.counters.withingsWebhookLegacyFormTotal).toBe(7); - }); }); diff --git a/src/app/api/admin/status/route.ts b/src/app/api/admin/status/route.ts index 1fd192828..02389f0e9 100644 --- a/src/app/api/admin/status/route.ts +++ b/src/app/api/admin/status/route.ts @@ -3,7 +3,6 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { getWorkerStatus } from "@/lib/jobs/worker-status"; -import { getLegacyFormTotal } from "@/app/api/withings/webhook/route"; import { readFileSync } from "node:fs"; // Build timestamp (set at build time via next.config) @@ -92,11 +91,5 @@ export const GET = apiHandler(async () => { webPush: webPushConfigured ? { configured: true } : null, bugReport: bugReportConfigured ? { configured: true } : null, }, - // v1.4.25 W21 Fix-K — in-memory counters that the release-gate can - // read to confirm "legacy form usage trending toward zero" before - // the v1.4.27 cut of the legacy Withings webhook route. - counters: { - withingsWebhookLegacyFormTotal: getLegacyFormTotal(), - }, }); }); diff --git a/src/app/api/withings/webhook/__tests__/route.test.ts b/src/app/api/withings/webhook/__tests__/route.test.ts index abdbe298a..f3372c968 100644 --- a/src/app/api/withings/webhook/__tests__/route.test.ts +++ b/src/app/api/withings/webhook/__tests__/route.test.ts @@ -45,13 +45,7 @@ vi.mock("@/lib/logging/context", () => ({ })), })); -import { - POST, - GET, - HEAD, - getLegacyFormTotal, - __resetLegacyFormTotalForTests, -} from "../route"; +import { POST, GET, HEAD } from "../route"; import { prisma } from "@/lib/db"; import { syncUserMeasurements } from "@/lib/withings/sync"; import { syncUserActivity } from "@/lib/withings/sync-activity"; @@ -174,7 +168,6 @@ describe("POST /api/withings/webhook", () => { }); it("legacy ?secret=… query path still authorises and emits a Wide Event warning with the migration URL (Fix-K sec-M2)", async () => { - __resetLegacyFormTotalForTests(); const addWarning = vi.fn(); vi.mocked(getEvent).mockReturnValue({ setAuth: vi.fn(), @@ -198,25 +191,6 @@ describe("POST /api/withings/webhook", () => { expect(addWarning).toHaveBeenCalledWith( expect.stringContaining("/api/withings/webhook/[token]"), ); - // Fix-K sec-M2: in-memory counter increments on every legacy - // ?secret=… call so the release-gate can watch usage trend to zero. - expect(getLegacyFormTotal()).toBe(1); - }); - - it("counter stays at zero when the secret arrives via the header (Fix-K sec-M2)", async () => { - __resetLegacyFormTotalForTests(); - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ - userId: "user-h", - withingsUserId: "wu-h", - } as never); - - await POST( - jsonRequest( - { userid: "wu-h" }, - { "x-withings-webhook-secret": "test-secret" }, - ), - ); - expect(getLegacyFormTotal()).toBe(0); }); it("accepts form-encoded payloads (Withings legacy delivery format)", async () => { diff --git a/src/app/api/withings/webhook/route.ts b/src/app/api/withings/webhook/route.ts index 786376b7e..da695f6e8 100644 --- a/src/app/api/withings/webhook/route.ts +++ b/src/app/api/withings/webhook/route.ts @@ -28,41 +28,17 @@ import { * while users re-subscribe (which also happens organically when they * reconnect for the new `user.activity` OAuth scope shipped in W5d). * - * Removal target: v1.4.27 (after all live subscriptions have rotated). + * Removal target: a future release once all live subscriptions have + * rotated to the path-segment form. The warning is still emitted on the + * legacy path so an operator watching the access log knows what to + * migrate to. * - * v1.4.25 W21 Fix-K — added an in-memory `withings.webhook.legacy_form_total` - * counter the `/api/admin/status` endpoint surfaces so the release-gate - * can read "legacy form usage trending toward zero" before the v1.4.27 - * cut. The warning text now includes the re-subscription URL so anyone - * watching the access log knows what to migrate to. + * v1.4.47 — dropped the in-memory `legacy_form_total` counter (was per- + * process, useless on a multi-container deploy; the access-log warning + * already covers the signal it provided). */ const MIGRATION_URL = "/api/withings/webhook/[token]" as const; -// In-memory counter — resets on every deploy / cold-start. That is the -// intended granularity: we want to know "how many legacy-form requests -// did this process serve since it came up", not a forever-running -// cumulative metric (which would belong in Prisma). -const counters = { - legacy_form_total: 0, -}; - -/** - * Read the legacy-form counter. Exported so `/api/admin/status` can - * surface it without a backchannel. - */ -export function getLegacyFormTotal(): number { - return counters.legacy_form_total; -} - -/** - * Reset the legacy-form counter. Test-only utility; production code - * does not call this. Documented so a future smoke test can wire up - * a deterministic "counter was 0 before the call" assertion. - */ -export function __resetLegacyFormTotalForTests(): void { - counters.legacy_form_total = 0; -} - function hasValidWebhookSecret( request: NextRequest, ): Promise<{ ok: boolean; via: "header" | "query" | "none" }> { @@ -89,7 +65,6 @@ async function checkAndWarn( return NextResponse.json({ status: "unauthorized" }, { status: 401 }); } if (auth.via === "query") { - counters.legacy_form_total += 1; getEvent()?.addWarning( `withings webhook secret received via legacy URL query — migrate to ${MIGRATION_URL} (re-subscribe with the path-segment token form)`, ); diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 50db8d297..f7ed6af13 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -327,6 +327,20 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { errorCode: null, }); + // v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode + // user gets the offline-specific copy immediately rather than + // waiting for the fetch to fail with a generic network error. + if (typeof navigator !== "undefined" && navigator.onLine === false) { + setStreaming({ + content: "", + metricSource: null, + inProgress: false, + messageId: null, + errorCode: "coach.network", + }); + return; + } + let response: Response; try { response = await fetch("/api/insights/chat", {