mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(withings): surface parked state + manual resume CTA
Pill UI gains a fifth state — 'parked' — for the v1.4.43 W14 24h persistent-failure park. Copy reads 'Pausiert — manuell wieder verbinden' (DE) / 'Paused — reconnect manually' (EN); chip uses the dracula-orange tokens (heavier than warning yellow, lighter than the red reconnect pill). The Withings card renders a 'Wieder verbinden' / 'Reconnect' banner under the pill when the state is parked. Clicking POSTs to the new /api/integrations/withings/resume endpoint (rate-limited 5/min per user, same envelope as the test-connection endpoint). The endpoint calls resumeIntegrationFromPark which flips state back to connected and zeroes every per-kind bucket; the per-card status query invalidates so the pill flips back without a page refresh. Locale parity across all six locales (en/de/es/fr/it/pl); end-to-end integration test against real Postgres pins both the resume flow and the per-kind bucket increment contract.
This commit is contained in:
1 parent
ee15aa05ea
commit
5a7484658e
13 files changed
+501
-18
No files matched your search
+5
-1
@@ -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).",
|
||||
|
||||
+5
-1
@@ -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).",
|
||||
|
||||
+5
-1
@@ -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).",
|
||||
|
||||
+5
-1
@@ -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).",
|
||||
|
||||
+5
-1
@@ -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).",
|
||||
|
||||
+5
-1
@@ -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).",
|
||||
|
||||
@@ -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: <T extends (...args: unknown[]) => 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<T> {
|
||||
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<unknown> & {
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.error).toBeTruthy();
|
||||
expect(body.meta?.errorCode).toBe("rate_limited_self");
|
||||
expect(resumeIntegrationFromPark).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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(
|
||||
<IntegrationStatusPill state="parked" lastSyncAt={null} />,
|
||||
);
|
||||
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(
|
||||
<IntegrationStatusPill state="parked" lastSyncAt={null} />,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Pausiert");
|
||||
expect(html).toContain("manuell wieder verbinden");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = <AlertTriangle aria-hidden="true" className="h-3 w-3" />;
|
||||
break;
|
||||
case "parked":
|
||||
label = t("settings.integrationPill.parkedReconnect");
|
||||
icon = <PauseCircle aria-hidden="true" className="h-3 w-3" />;
|
||||
break;
|
||||
case "disconnected":
|
||||
label = t("settings.integrationPill.notConnected");
|
||||
icon = <CircleSlash aria-hidden="true" className="h-3 w-3" />;
|
||||
@@ -124,7 +154,7 @@ export function IntegrationStatusPill({
|
||||
data-testid="integration-status-pill"
|
||||
data-state={state}
|
||||
variant={
|
||||
state === "connected" || state === "warning"
|
||||
state === "connected" || state === "warning" || state === "parked"
|
||||
? undefined
|
||||
: state === "error"
|
||||
? "destructive"
|
||||
@@ -135,6 +165,7 @@ export function IntegrationStatusPill({
|
||||
"max-w-full whitespace-nowrap",
|
||||
state === "connected" && connectedChipClass,
|
||||
state === "warning" && warningChipClass,
|
||||
state === "parked" && parkedChipClass,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -64,7 +64,8 @@ type IntegrationState =
|
||||
| "connected"
|
||||
| "error_transient"
|
||||
| "error_reauth"
|
||||
| "disconnected";
|
||||
| "disconnected"
|
||||
| "parked";
|
||||
|
||||
interface IntegrationStatusViewModel {
|
||||
integration: IntegrationKey;
|
||||
@@ -114,11 +115,14 @@ function pickStatus(
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse the API's four-state machine into the three states the
|
||||
* pill UI cares about: `error_transient` and `error_reauth` both
|
||||
* Collapse the API's five-state machine into the four states the
|
||||
* pill UI cares about. `error_transient` and `error_reauth` both
|
||||
* surface as the same "Error — reconnect" pill, the actionable
|
||||
* difference (whether the user must reconnect vs wait for the next
|
||||
* retry) is conveyed via the inline error text underneath.
|
||||
* retry) is conveyed via the inline error text underneath. `parked`
|
||||
* (v1.4.43 W14) is its own pill state — the integration has been
|
||||
* disabled after 24h of persistent failures and needs an explicit
|
||||
* "Wieder verbinden" click to resume.
|
||||
*/
|
||||
function pillStateFor(
|
||||
status: IntegrationStatusViewModel | undefined,
|
||||
@@ -130,6 +134,8 @@ function pillStateFor(
|
||||
case "error_transient":
|
||||
case "error_reauth":
|
||||
return "error";
|
||||
case "parked":
|
||||
return "parked";
|
||||
case "disconnected":
|
||||
return "disconnected";
|
||||
}
|
||||
@@ -256,6 +262,28 @@ function WithingsCard({
|
||||
},
|
||||
});
|
||||
|
||||
// v1.4.43 W14 — clear a parked integration via the resume endpoint.
|
||||
// The CTA is rendered inside the parked banner below; success
|
||||
// invalidates both the per-card status (so the pill flips back to
|
||||
// connected immediately) and the cross-integration envelope (so any
|
||||
// other view picks up the change on its next focus).
|
||||
const resume = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/integrations/withings/resume", {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as {
|
||||
resumed: boolean;
|
||||
wasParked: boolean;
|
||||
};
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() });
|
||||
},
|
||||
});
|
||||
|
||||
async function handleSync(fullSync = false) {
|
||||
setSyncing(true);
|
||||
setSyncMsg(null);
|
||||
@@ -339,8 +367,13 @@ function WithingsCard({
|
||||
: "disconnected";
|
||||
const pillLastSyncAt =
|
||||
status?.lastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
|
||||
// v1.4.43 W14 — `parked` and `error` both want the underlying error
|
||||
// message surfaced under the pill (the pill says "what" — the
|
||||
// message says "why"). Other states leave the inline line off.
|
||||
const errorMessage =
|
||||
pillState === "error" && viewModel?.lastError ? viewModel.lastError : null;
|
||||
(pillState === "error" || pillState === "parked") && viewModel?.lastError
|
||||
? viewModel.lastError
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
@@ -388,6 +421,59 @@ function WithingsCard({
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
{/* v1.4.43 W14 — parked-integration resume CTA. Surfaces only
|
||||
when the row state is `parked` (>24h of persistent
|
||||
failures). The button POSTs to /api/integrations/withings/
|
||||
resume which calls `resumeIntegrationFromPark`; on success
|
||||
the per-card status invalidates and the pill flips back to
|
||||
connected without a page refresh. The button is the
|
||||
primary action the user can take from this card — wider
|
||||
tap target than the inline action row so it's reachable
|
||||
on a Pixel 5 viewport. */}
|
||||
{pillState === "parked" && (
|
||||
<div
|
||||
data-testid="withings-parked-banner"
|
||||
className="border-dracula-orange/30 bg-dracula-orange/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="text-dracula-orange min-w-0 break-words text-xs">
|
||||
{t("settings.integrationPill.parkedReconnect")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => resume.mutate()}
|
||||
disabled={resume.isPending}
|
||||
data-testid="withings-resume-button"
|
||||
className="min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
{resume.isPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Link2 className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.integrationPill.resumeCta")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{resume.isError && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive text-xs"
|
||||
data-testid="withings-resume-error"
|
||||
>
|
||||
{t("settings.integrationPill.resumeError")}
|
||||
</p>
|
||||
)}
|
||||
{resume.isSuccess && resume.data?.wasParked && (
|
||||
<p
|
||||
role="status"
|
||||
className="text-dracula-green text-xs"
|
||||
data-testid="withings-resume-success"
|
||||
>
|
||||
{t("settings.integrationPill.resumeSuccess")}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("settings.withingsCredentials")}
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
markReconnected,
|
||||
isReauthRequired,
|
||||
getIntegrationStatus,
|
||||
resumeIntegrationFromPark,
|
||||
} from "@/lib/integrations/status";
|
||||
import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
|
||||
@@ -244,6 +245,112 @@ describe("IntegrationStatus end-to-end", () => {
|
||||
expect(row!.consecutiveFailures).toBe(0);
|
||||
});
|
||||
|
||||
// v1.4.43 W14 — end-to-end resume flow against real Postgres.
|
||||
//
|
||||
// The full ladder: persistent failure × N → bucket increments only
|
||||
// the persistent slot → wall-clock crosses 24h → next failure flips
|
||||
// state to `parked` → resumeIntegrationFromPark clears it back to
|
||||
// connected → every bucket zeroed → the next sync runs.
|
||||
it("resumeIntegrationFromPark clears a parked row and zeroes every bucket", async () => {
|
||||
// Stamp a row that's already parked. We do it through Prisma
|
||||
// directly so the test doesn't have to wait 24h of wall clock.
|
||||
await getPrismaClient().integrationStatus.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
integration: "withings",
|
||||
state: "parked",
|
||||
consecutiveFailures: 30,
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 2,
|
||||
reauth_required: 0,
|
||||
persistent: 28,
|
||||
},
|
||||
persistentFailureStartedAt: new Date(
|
||||
Date.now() - 26 * 60 * 60 * 1000,
|
||||
),
|
||||
lastError: "encrypted-blob-placeholder",
|
||||
lastAttemptAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resumeIntegrationFromPark(TEST_USER_ID, "withings");
|
||||
expect(result.wasParked).toBe(true);
|
||||
|
||||
const row = await getPrismaClient().integrationStatus.findUnique({
|
||||
where: {
|
||||
userId_integration: {
|
||||
userId: TEST_USER_ID,
|
||||
integration: "withings",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(row).not.toBeNull();
|
||||
expect(row!.state).toBe("connected");
|
||||
expect(row!.consecutiveFailures).toBe(0);
|
||||
expect(row!.consecutiveFailuresByKind).toEqual({
|
||||
transient: 0,
|
||||
reauth_required: 0,
|
||||
persistent: 0,
|
||||
});
|
||||
expect(row!.persistentFailureStartedAt).toBeNull();
|
||||
expect(row!.lastError).toBeNull();
|
||||
|
||||
// `isReauthRequired` no longer short-circuits the sync entry-point
|
||||
// — the next cron tick will attempt the upstream call.
|
||||
expect(await isReauthRequired(TEST_USER_ID, "withings")).toBe(false);
|
||||
});
|
||||
|
||||
it("recordSyncFailure increments only the matching per-kind bucket", async () => {
|
||||
// First write — transient bucket goes to 1.
|
||||
await recordSyncFailure({
|
||||
userId: TEST_USER_ID,
|
||||
integration: "withings",
|
||||
kind: "transient",
|
||||
message: "Withings refresh error: 503",
|
||||
errorCode: "503",
|
||||
});
|
||||
let row = await getPrismaClient().integrationStatus.findUnique({
|
||||
where: {
|
||||
userId_integration: {
|
||||
userId: TEST_USER_ID,
|
||||
integration: "withings",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(row!.consecutiveFailuresByKind).toMatchObject({
|
||||
transient: 1,
|
||||
reauth_required: 0,
|
||||
persistent: 0,
|
||||
});
|
||||
|
||||
// Second write — persistent failure ticks ONLY the persistent
|
||||
// bucket. The transient bucket is preserved at 1 (this is the
|
||||
// whole point of the per-kind counter — a transient hiccup must
|
||||
// not mask the persistent streak's true age).
|
||||
await recordSyncFailure({
|
||||
userId: TEST_USER_ID,
|
||||
integration: "withings",
|
||||
kind: "persistent",
|
||||
message: "Withings measure error: 293",
|
||||
errorCode: "293",
|
||||
});
|
||||
row = await getPrismaClient().integrationStatus.findUnique({
|
||||
where: {
|
||||
userId_integration: {
|
||||
userId: TEST_USER_ID,
|
||||
integration: "withings",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(row!.consecutiveFailuresByKind).toMatchObject({
|
||||
transient: 1,
|
||||
reauth_required: 0,
|
||||
persistent: 1,
|
||||
});
|
||||
// First persistent failure stamps the streak anchor.
|
||||
expect(row!.persistentFailureStartedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("CASCADE delete on User removes the IntegrationStatus row", async () => {
|
||||
await recordSyncFailure({
|
||||
userId: TEST_USER_ID,
|
||||
|
||||
Reference in new issue
Block a user