diff --git a/messages/de.json b/messages/de.json index 0953795e9..3e8661346 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1905,12 +1905,16 @@ "connected": "Verbunden", "errorReconnect": "Fehler — neu verbinden", "warningServerError": "Verbunden, aber Serverfehler", + "parkedReconnect": "Pausiert — manuell wieder verbinden", "notConnected": "Nicht verbunden", "justNow": "gerade eben", "minutesAgo": "vor {count} min", "hoursAgo": "vor {count} Std.", "daysAgo": "vor {count} T.", - "ariaLabel": "Integrationsstatus" + "ariaLabel": "Integrationsstatus", + "resumeCta": "Wieder verbinden", + "resumeSuccess": "Integration wieder aktiv", + "resumeError": "Wieder verbinden fehlgeschlagen" }, "apiTokens": "API-Tokens", "apiTokensDescription": "Erstelle API-Tokens für die externe Medikamenten-Einnahme (z.B. Shortcuts, Automationen).", diff --git a/messages/en.json b/messages/en.json index 7cbe112b4..e653433c5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1905,12 +1905,16 @@ "connected": "Connected", "errorReconnect": "Error — reconnect", "warningServerError": "Connected, server error", + "parkedReconnect": "Paused — reconnect manually", "notConnected": "Not connected", "justNow": "just now", "minutesAgo": "{count} min ago", "hoursAgo": "{count} h ago", "daysAgo": "{count} d ago", - "ariaLabel": "Integration status" + "ariaLabel": "Integration status", + "resumeCta": "Reconnect", + "resumeSuccess": "Integration resumed", + "resumeError": "Reconnect failed" }, "apiTokens": "API Tokens", "apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).", diff --git a/messages/es.json b/messages/es.json index a9ffba9d4..307fa90d3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1905,12 +1905,16 @@ "connected": "Conectado", "errorReconnect": "Error — reconectar", "warningServerError": "Conectado, error del servidor", + "parkedReconnect": "Pausado — reconectar manualmente", "notConnected": "No conectado", "justNow": "just now", "minutesAgo": "{count} min ago", "hoursAgo": "{count} h ago", "daysAgo": "{count} d ago", - "ariaLabel": "Integration status" + "ariaLabel": "Integration status", + "resumeCta": "Reconectar", + "resumeSuccess": "Integración reanudada", + "resumeError": "Reconexión fallida" }, "apiTokens": "API Tokens", "apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).", diff --git a/messages/fr.json b/messages/fr.json index 7881dd197..ddc7cf6da 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1905,12 +1905,16 @@ "connected": "Connecté", "errorReconnect": "Erreur — reconnecter", "warningServerError": "Connecté, erreur serveur", + "parkedReconnect": "En pause — reconnecter manuellement", "notConnected": "Non connecté", "justNow": "just now", "minutesAgo": "{count} min ago", "hoursAgo": "{count} h ago", "daysAgo": "{count} d ago", - "ariaLabel": "Integration status" + "ariaLabel": "Integration status", + "resumeCta": "Reconnecter", + "resumeSuccess": "Intégration reprise", + "resumeError": "Échec de la reconnexion" }, "apiTokens": "API Tokens", "apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).", diff --git a/messages/it.json b/messages/it.json index e6cb47e80..97b8f6a60 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1905,12 +1905,16 @@ "connected": "Connesso", "errorReconnect": "Errore — riconnetti", "warningServerError": "Connesso, errore del server", + "parkedReconnect": "In pausa — riconnettere manualmente", "notConnected": "Non connesso", "justNow": "just now", "minutesAgo": "{count} min ago", "hoursAgo": "{count} h ago", "daysAgo": "{count} d ago", - "ariaLabel": "Integration status" + "ariaLabel": "Integration status", + "resumeCta": "Riconnetti", + "resumeSuccess": "Integrazione ripresa", + "resumeError": "Riconnessione fallita" }, "apiTokens": "API Tokens", "apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).", diff --git a/messages/pl.json b/messages/pl.json index 59672d7b9..7f1aa2ef9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1905,12 +1905,16 @@ "connected": "Połączono", "errorReconnect": "Błąd — połącz ponownie", "warningServerError": "Połączono, błąd serwera", + "parkedReconnect": "Wstrzymane — połącz ręcznie", "notConnected": "Nie połączono", "justNow": "just now", "minutesAgo": "{count} min ago", "hoursAgo": "{count} h ago", "daysAgo": "{count} d ago", - "ariaLabel": "Integration status" + "ariaLabel": "Integration status", + "resumeCta": "Połącz ponownie", + "resumeSuccess": "Integracja wznowiona", + "resumeError": "Ponowne połączenie nieudane" }, "apiTokens": "API Tokens", "apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).", diff --git a/src/app/api/integrations/withings/resume/__tests__/route.test.ts b/src/app/api/integrations/withings/resume/__tests__/route.test.ts new file mode 100644 index 000000000..2de60c8ed --- /dev/null +++ b/src/app/api/integrations/withings/resume/__tests__/route.test.ts @@ -0,0 +1,109 @@ +/** + * v1.4.43 W14 — POST /api/integrations/withings/resume + * + * Three pins: + * 1. Happy path: a parked row resumes, response carries + * `wasParked: true`. + * 2. Idempotent: a connected row resumes silently, response carries + * `wasParked: false`. + * 3. Rate-limit: a 6th call within the window returns 429 with the + * `rate_limited_self` error code. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ + user: { id: "u-1" }, + session: { id: "s-1" }, + })), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ + allowed: true, + remaining: 5, + resetAt: Date.now() + 60_000, + })), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +vi.mock("@/lib/integrations/status", () => ({ + resumeIntegrationFromPark: vi.fn(), +})); + +import { POST } from "../route"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { resumeIntegrationFromPark } from "@/lib/integrations/status"; + +interface ApiEnvelope { + data: T | null; + error: string | null; +} + +function emptyRequest(): Request { + return new Request("http://localhost/api/integrations/withings/resume", { + method: "POST", + headers: { "content-length": "0" }, + }); +} + +beforeEach(() => { + vi.mocked(resumeIntegrationFromPark).mockReset(); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 5, + resetAt: Date.now() + 60_000, + } as never); +}); + +describe("POST /api/integrations/withings/resume", () => { + it("clears a parked integration and reports wasParked: true", async () => { + vi.mocked(resumeIntegrationFromPark).mockResolvedValueOnce({ + wasParked: true, + }); + + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(200); + const body = (await response.json()) as ApiEnvelope<{ + resumed: boolean; + wasParked: boolean; + }>; + expect(body.data).toEqual({ resumed: true, wasParked: true }); + expect(resumeIntegrationFromPark).toHaveBeenCalledWith("u-1", "withings"); + }); + + it("returns wasParked: false when the row was already connected", async () => { + vi.mocked(resumeIntegrationFromPark).mockResolvedValueOnce({ + wasParked: false, + }); + + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(200); + const body = (await response.json()) as ApiEnvelope<{ + resumed: boolean; + wasParked: boolean; + }>; + expect(body.data).toEqual({ resumed: true, wasParked: false }); + }); + + it("returns 429 with rate_limited_self when the rate limit is exhausted", async () => { + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + } as never); + + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(429); + const body = (await response.json()) as ApiEnvelope & { + meta?: { errorCode?: string }; + }; + expect(body.error).toBeTruthy(); + expect(body.meta?.errorCode).toBe("rate_limited_self"); + expect(resumeIntegrationFromPark).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/integrations/withings/resume/route.ts b/src/app/api/integrations/withings/resume/route.ts new file mode 100644 index 000000000..ca6ab21e0 --- /dev/null +++ b/src/app/api/integrations/withings/resume/route.ts @@ -0,0 +1,59 @@ +/** + * v1.4.43 W14 — manual resume endpoint for a parked Withings + * integration. + * + * Background: B4 introduced the `parked` state in + * `src/lib/integrations/status.ts`. Once a Withings persistent-failure + * streak (contract-mismatch 293/294) has been running for more than + * 24h, the status writer flips the row to `parked` and the sync + * entry-point short-circuits via `isReauthRequired`. The user (or an + * operator) calls this endpoint to clear the park and let the next + * cron tick attempt the sync again. + * + * Rate-limited (5/min per user) to match the existing + * `/api/integrations/withings/test` endpoint — there's no upstream + * call here, but a misbehaving client tab loop would still rack up + * audit-log rows if the limit was absent. + * + * Returns 200 with `{ resumed: true, wasParked: boolean }`. The + * `wasParked` flag lets the caller decide whether to surface a "you + * resumed it" toast or treat the action as a no-op (the row was + * already connected / disconnected). Either way the endpoint is + * idempotent — a second click in the same second sees the same + * post-state and writes no extra audit row. + */ +import type { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; +import { resumeIntegrationFromPark } from "@/lib/integrations/status"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async (request: NextRequest) => { + void request; + const { user } = await requireAuth(); + annotate({ action: { name: "integrations.withings.resume" } }); + + const rl = await checkRateLimit( + `withings-resume:${user.id}`, + 5, + 60_000, + ); + if (!rl.allowed) { + return apiError("Too many resume requests", 429, { + errorCode: "rate_limited_self", + }); + } + + const result = await resumeIntegrationFromPark(user.id, "withings"); + annotate({ + meta: { + withings_resume_was_parked: result.wasParked, + }, + }); + + return apiSuccess({ resumed: true, wasParked: result.wasParked }); +}); diff --git a/src/components/settings/__tests__/integration-status-pill.test.tsx b/src/components/settings/__tests__/integration-status-pill.test.tsx index cc85013c4..63349ec19 100644 --- a/src/components/settings/__tests__/integration-status-pill.test.tsx +++ b/src/components/settings/__tests__/integration-status-pill.test.tsx @@ -94,4 +94,27 @@ describe("IntegrationStatusPill", () => { // narrow on Pixel 5. expect(html).toMatch(/just\s+now/i); }); + + // v1.4.43 W14 — parked-state copy. + // Distinct from `error` (red reconnect pill) because the user can + // resume the integration without redoing the OAuth dance; distinct + // from `warning` because the persistent streak survived the alert + // ladder AND the 24h grace window — manual intervention required. + it("renders the parked state with manual-reconnect phrasing (EN)", () => { + const html = render( + , + ); + expect(html).toContain("Paused"); + expect(html).toContain("reconnect manually"); + expect(html).toContain('data-state="parked"'); + }); + + it("renders the parked state with manual-reconnect phrasing (DE)", () => { + const html = render( + , + "de", + ); + expect(html).toContain("Pausiert"); + expect(html).toContain("manuell wieder verbinden"); + }); }); diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx index ddd9fdc74..fc8193e9d 100644 --- a/src/components/settings/__tests__/integrations-section.test.tsx +++ b/src/components/settings/__tests__/integrations-section.test.tsx @@ -285,6 +285,50 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { expect(html).not.toContain("Last sync:"); }); + // v1.4.43 W14 — parked-state surface check. + // The pill flips to `data-state="parked"` and the resume banner + // renders with the reconnect CTA. The legacy `error` reconnect text + // (red) must NOT appear because the user-facing copy is the lighter + // "Paused — reconnect manually" string, not "Error — reconnect". + it("Withings parked state surfaces the parked pill + resume CTA", () => { + setIntegrationStatus({ + threshold: 3, + integrations: [ + { + integration: "withings", + state: "parked", + lastSuccessAt: "2026-05-08T12:00:00.000Z", + lastAttemptAt: "2026-05-09T18:00:00.000Z", + lastError: "Withings activity error: 293", + consecutiveFailures: 30, + }, + { + integration: "moodlog", + state: "connected", + lastSuccessAt: null, + lastAttemptAt: null, + lastError: null, + consecutiveFailures: 0, + }, + ], + }); + setWithingsStatus({ + connected: true, + configured: true, + lastSyncedAt: "2026-05-08T12:00:00.000Z", + }); + + const html = render(); + expect(html).toContain('data-state="parked"'); + expect(html).toContain("Paused"); + expect(html).toContain("reconnect manually"); + expect(html).toContain('data-testid="withings-parked-banner"'); + expect(html).toContain('data-testid="withings-resume-button"'); + // The lastError is surfaced under the pill so the operator sees + // the contract-mismatch reason without opening Audit Log. + expect(html).toContain("Withings activity error: 293"); + }); + it("Mood Log card carries the visual divider Withings has (consistency)", () => { setIntegrationStatus({ threshold: 3, diff --git a/src/components/settings/integration-status-pill.tsx b/src/components/settings/integration-status-pill.tsx index 0dbde5897..cc7d63abb 100644 --- a/src/components/settings/integration-status-pill.tsx +++ b/src/components/settings/integration-status-pill.tsx @@ -1,6 +1,11 @@ "use client"; -import { CheckCircle2, AlertTriangle, CircleSlash } from "lucide-react"; +import { + CheckCircle2, + AlertTriangle, + CircleSlash, + PauseCircle, +} from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { useTranslations } from "@/lib/i18n/context"; @@ -26,10 +31,24 @@ import { cn } from "@/lib/utils"; * abbreviated time form into a 12 char string at most. */ +/** + * Pill states. + * + * - connected → happy path; relative "last sync" suffix is shown. + * - warning → connected but the upstream just returned a + * contract-mismatch error (v1.4.43 W4). + * - error → reauth-required / persistent transient error; + * "Error — reconnect" copy. + * - parked → v1.4.43 W14: >24h of persistent failures put the + * integration to sleep; "Pausiert — manuell wieder + * verbinden" copy + reconnect CTA. + * - disconnected → user clicked Disconnect; "Not connected" copy. + */ export type IntegrationPillState = | "connected" | "warning" | "error" + | "parked" | "disconnected"; interface IntegrationStatusPillProps { @@ -78,15 +97,22 @@ export function IntegrationStatusPill({ }: IntegrationStatusPillProps) { const { t } = useTranslations(); - // chipClass only applies to the `connected` and `warning` branches — - // `error` falls back to `variant="destructive"` and `disconnected` to - // `variant="outline"`. The warning chip uses amber tokens to signal - // "connected, but the upstream just returned a contract-level error - // a reauth won't fix" — distinct from the red reconnect pill. + // chipClass only applies to the `connected`, `warning`, and `parked` + // branches — `error` falls back to `variant="destructive"` and + // `disconnected` to `variant="outline"`. The warning chip uses amber + // tokens to signal "connected, but the upstream just returned a + // contract-level error a reauth won't fix" — distinct from the red + // reconnect pill. The parked chip uses the orange tokens to signal + // "we tried to keep going for 24h but the contract mismatch persists + // — manual intervention required" — visually heavier than warning + // (sustained problem), lighter than error (still recoverable without + // re-auth). const connectedChipClass = "border-dracula-green/30 bg-dracula-green/15 text-dracula-green"; const warningChipClass = "border-dracula-yellow/30 bg-dracula-yellow/15 text-dracula-yellow"; + const parkedChipClass = + "border-dracula-orange/30 bg-dracula-orange/15 text-dracula-orange"; let label: string; let icon: React.ReactNode; @@ -104,6 +130,10 @@ export function IntegrationStatusPill({ label = t("settings.integrationPill.errorReconnect"); icon =