diff --git a/messages/de.json b/messages/de.json
index d0a56200a..d98ec822d 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4936,6 +4936,8 @@
"webPushVapidGenerated": "VAPID-Schlüssel erzeugt. Betreff speichern, um abzuschließen.",
"webPushVapidGenerateFailed": "VAPID-Schlüssel konnten nicht erzeugt werden.",
"webPushVapidGenerateConfirm": "VAPID-Schlüssel sind bereits konfiguriert. Ein neues Schlüsselpaar macht alle Browser-Push-Abos ungültig — jedes Gerät muss sich neu anmelden. Fortfahren?",
+ "webPushVapidGenerateConfirmTitle": "VAPID-Schlüssel neu erzeugen?",
+ "webPushVapidRegenerate": "Neu erzeugen",
"configured": "Konfiguriert",
"monitoringTestSuccess": "Testevent wurde gesendet",
"monitoringTestFailed": "Monitoring-Test fehlgeschlagen",
diff --git a/messages/en.json b/messages/en.json
index 4b4ec8acc..e8113d837 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4936,6 +4936,8 @@
"webPushVapidGenerated": "VAPID keys generated. Save the subject to finish.",
"webPushVapidGenerateFailed": "Could not generate VAPID keys.",
"webPushVapidGenerateConfirm": "VAPID keys are already configured. Generating a new pair invalidates every browser push subscription — each device must re-subscribe. Continue?",
+ "webPushVapidGenerateConfirmTitle": "Regenerate VAPID keys?",
+ "webPushVapidRegenerate": "Regenerate",
"configured": "Configured",
"monitoringTestSuccess": "Test event sent",
"monitoringTestFailed": "Monitoring test failed",
diff --git a/messages/es.json b/messages/es.json
index 6538cc760..329cd2efe 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4936,6 +4936,8 @@
"webPushVapidGenerated": "Claves VAPID generadas. Guarda el asunto para terminar.",
"webPushVapidGenerateFailed": "No se pudieron generar las claves VAPID.",
"webPushVapidGenerateConfirm": "Las claves VAPID ya están configuradas. Generar un par nuevo invalida todas las suscripciones push del navegador: cada dispositivo debe volver a suscribirse. ¿Continuar?",
+ "webPushVapidGenerateConfirmTitle": "¿Regenerar las claves VAPID?",
+ "webPushVapidRegenerate": "Regenerar",
"configured": "Configurado",
"monitoringTestSuccess": "Evento de prueba enviado",
"monitoringTestFailed": "La prueba de monitorización falló",
diff --git a/messages/fr.json b/messages/fr.json
index 95e9530f7..6e8777654 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4936,6 +4936,8 @@
"webPushVapidGenerated": "Clés VAPID générées. Enregistrez le sujet pour terminer.",
"webPushVapidGenerateFailed": "Impossible de générer les clés VAPID.",
"webPushVapidGenerateConfirm": "Les clés VAPID sont déjà configurées. Générer une nouvelle paire invalide tous les abonnements push du navigateur — chaque appareil doit se réabonner. Continuer ?",
+ "webPushVapidGenerateConfirmTitle": "Régénérer les clés VAPID ?",
+ "webPushVapidRegenerate": "Régénérer",
"configured": "Configuré",
"monitoringTestSuccess": "Événement de test envoyé",
"monitoringTestFailed": "Échec du test de surveillance",
diff --git a/messages/it.json b/messages/it.json
index 37215d94d..bb9d81337 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4936,6 +4936,8 @@
"webPushVapidGenerated": "Chiavi VAPID generate. Salva l'oggetto per completare.",
"webPushVapidGenerateFailed": "Impossibile generare le chiavi VAPID.",
"webPushVapidGenerateConfirm": "Le chiavi VAPID sono già configurate. Generare una nuova coppia invalida tutte le sottoscrizioni push del browser: ogni dispositivo deve ri-sottoscriversi. Continuare?",
+ "webPushVapidGenerateConfirmTitle": "Rigenerare le chiavi VAPID?",
+ "webPushVapidRegenerate": "Rigenera",
"configured": "Configurato",
"monitoringTestSuccess": "Evento di prova inviato",
"monitoringTestFailed": "Test di monitoraggio non riuscito",
diff --git a/messages/pl.json b/messages/pl.json
index b081fa30e..09b493d64 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4936,6 +4936,8 @@
"webPushVapidGenerated": "Klucze VAPID wygenerowane. Zapisz temat, aby zakończyć.",
"webPushVapidGenerateFailed": "Nie udało się wygenerować kluczy VAPID.",
"webPushVapidGenerateConfirm": "Klucze VAPID są już skonfigurowane. Wygenerowanie nowej pary unieważnia wszystkie subskrypcje push w przeglądarce — każde urządzenie musi zasubskrybować ponownie. Kontynuować?",
+ "webPushVapidGenerateConfirmTitle": "Wygenerować nowe klucze VAPID?",
+ "webPushVapidRegenerate": "Wygeneruj ponownie",
"configured": "Skonfigurowano",
"monitoringTestSuccess": "Wysłano zdarzenie testowe",
"monitoringTestFailed": "Test monitorowania nie powiódł się",
diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx
index 524e903e2..2030d0510 100644
--- a/src/app/labs/page.tsx
+++ b/src/app/labs/page.tsx
@@ -50,12 +50,12 @@ export default function LabsPage() {
return (
-
+
{t("labs.title")}
-
+
{t("labs.subtitle")}
diff --git a/src/components/admin/web-push-vapid-section.tsx b/src/components/admin/web-push-vapid-section.tsx
index e409d96f8..d67d49bc6 100644
--- a/src/components/admin/web-push-vapid-section.tsx
+++ b/src/components/admin/web-push-vapid-section.tsx
@@ -6,6 +6,16 @@ import { toast } from "sonner";
import { BellRing, KeyRound, Loader2 } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
@@ -32,6 +42,11 @@ export function WebPushVapidSection() {
string | null
>(null);
const [generating, setGenerating] = useState(false);
+ // Overwrite guard: the first generate returns 409 when keys already exist;
+ // we surface the project's AlertDialog (not a native window.confirm) before
+ // retrying with force, since regenerating invalidates every live Web-Push
+ // subscription.
+ const [overwriteConfirmOpen, setOverwriteConfirmOpen] = useState(false);
const webPushVapidPublicKeyValue =
webPushVapidPublicKeyDraft ?? settings?.webPushVapidPublicKey ?? "";
@@ -71,12 +86,10 @@ export function WebPushVapidSection() {
);
if (res.status === 409) {
- // Overwrite guard — existing keys would be replaced. Confirm with
- // the operator (regenerating invalidates current subscriptions),
- // then retry with force.
- if (window.confirm(t("admin.webPushVapidGenerateConfirm"))) {
- await generateVapidKeys(true);
- }
+ // Overwrite guard — existing keys would be replaced. Surface the
+ // in-app confirm dialog (regenerating invalidates current
+ // subscriptions); the dialog action retries with force.
+ setOverwriteConfirmOpen(true);
return;
}
@@ -201,6 +214,31 @@ export function WebPushVapidSection() {
{t("common.save")}
+
+
+
+
+
+ {t("admin.webPushVapidGenerateConfirmTitle")}
+
+
+ {t("admin.webPushVapidGenerateConfirm")}
+
+
+
+ {t("common.cancel")}
+ void generateVapidKeys(true)}
+ >
+ {t("admin.webPushVapidRegenerate")}
+
+
+
+
);
}
diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts
index a1fae1714..9c3bfc1f1 100644
Binary files a/src/lib/__tests__/doctor-report-data.test.ts and b/src/lib/__tests__/doctor-report-data.test.ts differ
diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts
index 62ddc5ee0..f08da5c08 100644
--- a/src/lib/doctor-report-data.ts
+++ b/src/lib/doctor-report-data.ts
@@ -27,6 +27,10 @@ import type {
MeasurementSource,
} from "@/generated/prisma/client";
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
+import {
+ reconstructSleepNights,
+ type SleepStageRow,
+} from "@/lib/analytics/sleep-night";
import {
DEFAULT_DOCTOR_REPORT_PREFS,
type DoctorReportPrefs,
@@ -685,6 +689,10 @@ export async function collectDoctorReportData(
// v1.17 W1a — the user timezone anchors the ledger compliance
// band minter (matches the detail page's dose-day attribution).
timezone: true,
+ // v1.17.1 — source-priority feeds the per-night sleep reconstruction
+ // so the report's SLEEP_DURATION resolves the same canonical night
+ // (multi-source de-dup) the dashboard + iOS feed show.
+ sourcePriorityJson: true,
// v1.7.0 — patient-identity fields for the export cover + FHIR
// Patient. KVNR is encrypted (and not selected here) — the route
// decrypts it and hands it to the builders.
@@ -708,6 +716,36 @@ export async function collectDoctorReportData(
});
}
+ // v1.17.1 parity — SLEEP_DURATION enters `byType` as RAW per-stage rows (a
+ // 40-min DEEP block, a 12-min AWAKE block, …). Every other sleep surface
+ // (dashboard slim slice, /insights/sleep, /api/sleep/night, the iOS feed)
+ // shows the per-night reconstructed asleep total via `summarizeSleepNights`,
+ // and the v1.17.1 stamping fixes rewrite exactly those raw stage rows. Route
+ // the report's sleep value through the same engine — one number, one surface
+ // — exactly as RECOVERY_SCORE routes through `summariseCanonicalRecovery`.
+ const reportTz = userProfile?.timezone ?? "Europe/Berlin";
+ const sleepRows = measurements.filter(
+ (m) => m.type === "SLEEP_DURATION",
+ ) as unknown as SleepStageRow[];
+ if (sleepRows.length > 0) {
+ const nights = reconstructSleepNights(
+ sleepRows,
+ reportTz,
+ userProfile?.sourcePriorityJson ?? null,
+ ).filter((n) => n.asleepMinutes > 0);
+ // Per-night asleep totals, ascending by night, replace the raw per-stage
+ // rows so the clinical vitals table reads time-asleep hours, not stage-row
+ // minutes. Empty (no scorable night) drops SLEEP_DURATION entirely.
+ if (nights.length > 0) {
+ byType.SLEEP_DURATION = nights.map((n) => ({
+ value: n.asleepMinutes,
+ measuredAt: n.measuredAt.toISOString(),
+ }));
+ } else {
+ delete byType.SLEEP_DURATION;
+ }
+ }
+
// Per-type stats.
const stats: Record
= {};
for (const [type, entries] of Object.entries(byType)) {
@@ -728,7 +766,6 @@ export async function collectDoctorReportData(
// detail page (slot dedup, band timing, cadence honoured). As-needed / PRN
// medications are excluded — no schedule, no expected dose, no fabricated
// 100 % on a clinical report. The medication itself stays on the list.
- const reportTz = userProfile?.timezone ?? "Europe/Berlin";
const compliance = buildLedgerCompliance(
medications.map((m) => ({
id: m.id,
diff --git a/src/lib/insights/derived/recovery-resolve.ts b/src/lib/insights/derived/recovery-resolve.ts
index b87298301..ce8cb09e0 100644
--- a/src/lib/insights/derived/recovery-resolve.ts
+++ b/src/lib/insights/derived/recovery-resolve.ts
@@ -76,6 +76,20 @@ function rankOf(source: MeasurementSource): number {
* shifted forward by one before the local-day read — aligning it onto the same
* wake day the WHOOP row for that night carries. Reading the key in the user's
* timezone keeps a near-midnight or late re-score on the same night.
+ *
+ * KNOWN LIMITATION (05-M2, deferred to v1.17.2). OURA and POLAR are bucketed by
+ * the local day of their raw `measuredAt` on the assumption that both stamp the
+ * wake morning. Polar always anchors `measuredAt = ${date}T00:00:00.000Z`
+ * (UTC-midnight of the wake date); Oura prefers a real `bedtimeEnd` wake instant
+ * but FALLS BACK to the same UTC-midnight anchor. For a user in a far-negative
+ * UTC zone, a UTC-midnight anchor reads as the PREVIOUS local evening, so a
+ * Polar (or Oura-fallback) night can land one day earlier than the WHOOP/Oura
+ * night it should align with — splitting one physiological night across two
+ * recovery days near the date line. A correct fix needs an "anchor kind"
+ * (date-midnight vs real-instant) threaded through the recovery read so the
+ * date-anchored sources read their wake-day in UTC while WHOOP / Oura-bedtimeEnd
+ * stay local; that DTO change is out of scope for v1.17.1. Europe / positive-UTC
+ * users (the common case) are unaffected.
*/
function wakeDayKeyOf(d: Date, source: MeasurementSource, tz: string): string {
const anchor =
diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts
index 5c6ad36d0..d64cd34aa 100644
--- a/src/lib/jobs/__tests__/measurement-reminder.test.ts
+++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts
@@ -268,6 +268,33 @@ describe("runMeasurementReminderTick", () => {
expect(updates[0].data.nextDueAt).toBeInstanceOf(Date);
});
+ it("bounds the scan with a nextDueAt filter so the index is used", async () => {
+ const { prisma } = makePrisma({
+ reminders: [reminder({})],
+ measurementMatch: null,
+ });
+ const dispatch = vi.fn(async () => OK);
+
+ await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
+
+ const calls = prisma.measurementReminder.findMany.mock
+ .calls as unknown as unknown[][];
+ const args = calls[0][0] as {
+ where: {
+ deletedAt: null;
+ enabled: boolean;
+ nextDueAt: { not: null; lte: Date };
+ };
+ };
+ expect(args.where.enabled).toBe(true);
+ expect(args.where.deletedAt).toBeNull();
+ expect(args.where.nextDueAt.not).toBeNull();
+ // Floor is now + one tick of slack; anything due (<= floor) is included.
+ expect(args.where.nextDueAt.lte.getTime()).toBe(
+ NINE_LOCAL.getTime() + 15 * 60_000,
+ );
+ });
+
it("skips a reminder outside its notify-hour window", async () => {
const { prisma } = makePrisma({
reminders: [reminder({ measurementType: null })],
diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts
index 7223d76ec..2afa31687 100644
--- a/src/lib/jobs/measurement-reminder.ts
+++ b/src/lib/jobs/measurement-reminder.ts
@@ -41,6 +41,14 @@ import {
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
+/**
+ * Slack added to `now` when scanning for due reminders, so a `nextDueAt`
+ * stamped just ahead of a tick still falls inside the same hour window.
+ * One tick interval (15 min) — small enough that the in-Node hour gate stays
+ * the authoritative fire decision.
+ */
+const DUE_QUERY_SLACK_MS = 15 * 60_000;
+
export interface MeasurementReminderSummary {
candidatesScanned: number;
inWindow: number;
@@ -141,8 +149,22 @@ export async function runMeasurementReminderTick(
failed: 0,
};
+ // Bound the scan to reminders that could plausibly fire this tick so Postgres
+ // uses `measurement_reminders_user_id_next_due_at_idx` instead of loading the
+ // whole cross-tenant enabled set 4×/hour. `nextDueAt` is stamped at the
+ // notify-hour boundary, so anything due is already <= now; the small slack
+ // (one tick interval) covers a stamp landing just ahead of a :00 tick. A
+ // null `nextDueAt` is a non-recurring reminder that can never fire — the
+ // in-Node `evaluateMeasurementReminderDue` short-circuits it anyway, so
+ // excluding it here is parity. Reminders satisfied early re-anchor once they
+ // cross due, before any nudge fires, so dropping future rows is safe.
+ const dueFloor = new Date(now.getTime() + DUE_QUERY_SLACK_MS);
const reminders = await prisma.measurementReminder.findMany({
- where: { deletedAt: null, enabled: true },
+ where: {
+ deletedAt: null,
+ enabled: true,
+ nextDueAt: { not: null, lte: dueFloor },
+ },
include: {
user: {
select: {
diff --git a/src/lib/notifications/senders/__tests__/email.test.ts b/src/lib/notifications/senders/__tests__/email.test.ts
index 53066c67a..e78403306 100644
--- a/src/lib/notifications/senders/__tests__/email.test.ts
+++ b/src/lib/notifications/senders/__tests__/email.test.ts
@@ -8,15 +8,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const sendMailMock = vi.fn();
-const createTransportMock = vi.fn((..._args: unknown[]) => ({
- sendMail: sendMailMock,
-}));
+const createTransportMock = vi.fn((options: unknown) => {
+ void options;
+ return { sendMail: sendMailMock };
+});
const loadEmailConfigMock = vi.fn();
const recordPushAttemptMock = vi.fn();
vi.mock("nodemailer", () => ({
default: {
- createTransport: (...args: unknown[]) => createTransportMock(...args),
+ createTransport: (options: unknown) => createTransportMock(options),
},
}));
@@ -86,6 +87,23 @@ describe("sendViaEmail", () => {
);
});
+ it("builds the transport with explicit SMTP timeouts (no unbounded hang)", async () => {
+ loadEmailConfigMock.mockReturnValue(transport);
+ sendMailMock.mockResolvedValue({ messageId: "1" });
+
+ await sendViaEmail({ recipient: "you@example.com" }, payload());
+
+ expect(createTransportMock).toHaveBeenCalledTimes(1);
+ const opts = createTransportMock.mock.calls[0][0] as {
+ connectionTimeout?: number;
+ greetingTimeout?: number;
+ socketTimeout?: number;
+ };
+ expect(opts.connectionTimeout).toBe(10_000);
+ expect(opts.greetingTimeout).toBe(10_000);
+ expect(opts.socketTimeout).toBe(20_000);
+ });
+
it("soft-skips when SMTP is unconfigured (no channel burn)", async () => {
loadEmailConfigMock.mockReturnValue(null);
diff --git a/src/lib/notifications/senders/email.ts b/src/lib/notifications/senders/email.ts
index 289c7490b..b072cb90b 100644
--- a/src/lib/notifications/senders/email.ts
+++ b/src/lib/notifications/senders/email.ts
@@ -55,6 +55,14 @@ function getTransporter(): { transporter: Transporter; from: string } | null {
port: config.port,
secure: config.secure,
...(config.auth ? { auth: config.auth } : {}),
+ // Bound every phase of the SMTP exchange. nodemailer's defaults (2 min
+ // connect, 30 s greeting, 10 min socket) would let a hung/blackholed relay
+ // pin a dispatch worker for minutes per send. These caps map a stall to the
+ // transient `SendOutcome` `classifySmtpError` already retries, matching the
+ // webhook sender's 5 s ethos.
+ connectionTimeout: 10_000,
+ greetingTimeout: 10_000,
+ socketTimeout: 20_000,
});
cachedTransportKey = key;
return { transporter: cachedTransporter, from: config.from };
diff --git a/src/lib/observability/__tests__/redact-payload.test.ts b/src/lib/observability/__tests__/redact-payload.test.ts
index 713f47897..0f9491efb 100644
--- a/src/lib/observability/__tests__/redact-payload.test.ts
+++ b/src/lib/observability/__tests__/redact-payload.test.ts
@@ -163,4 +163,18 @@ describe("redactSensitiveFields", () => {
benignField: "kept",
});
});
+
+ it("redacts the webhook shared secret (headerValue) and the Live Activity token", () => {
+ const body = {
+ headerValue: "super-secret-webhook-key",
+ liveActivityPushToken: "abc123",
+ webhookUrl: "https://example.com/hook",
+ };
+ expect(redactSensitiveFields(body)).toEqual({
+ headerValue: "[redacted]",
+ // Covered by the existing /token/i pattern.
+ liveActivityPushToken: "[redacted]",
+ webhookUrl: "https://example.com/hook",
+ });
+ });
});
diff --git a/src/lib/observability/redact-payload.ts b/src/lib/observability/redact-payload.ts
index 1ee5992e8..6b7a1f4a7 100644
--- a/src/lib/observability/redact-payload.ts
+++ b/src/lib/observability/redact-payload.ts
@@ -65,6 +65,13 @@ export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [
/progesteronetest/i,
/contraceptive/i,
/cervicalmucus/i,
+ // v1.17.1 — the webhook shared secret. `headerValue` lives only inside the
+ // AES-GCM-encrypted `NotificationChannel.config` blob and the GET masks it
+ // to `hasHeaderValue`, so no current path excerpts it. Defence-in-depth: if
+ // a future change routes a webhook body through `buildPayloadDiagnostic`,
+ // the secret redacts instead of landing verbatim. The Live Activity push
+ // token is already covered by `/token/i`.
+ /headervalue/i,
];
const REDACTED = "[redacted]";