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]";