merge(reliability): SMTP timeouts, cron index use, doctor-report sleep parity, VAPID dialog, redaction

This commit is contained in:
Marc-André Bombeck committed 2026-06-15 01:29:06 +02:00
commit 7574ea00ff
17 files changed
+211 -14

No files matched your search

+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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ó",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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ę",
+2 -2
View File
@@ -50,12 +50,12 @@ export default function LabsPage() {
return (
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<div className="flex items-center justify-between">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">
{t("labs.title")}
</h1>
<p className="text-muted-foreground truncate text-xs sm:text-sm">
<p className="text-muted-foreground text-xs sm:text-sm">
{t("labs.subtitle")}
</p>
</div>
@@ -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")}
</Button>
</div>
<AlertDialog
open={overwriteConfirmOpen}
onOpenChange={setOverwriteConfirmOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.webPushVapidGenerateConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.webPushVapidGenerateConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => void generateVapidKeys(true)}
>
{t("admin.webPushVapidRegenerate")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
Binary file not shown.
+38 -1
View File
@@ -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<string, DoctorReportStats> = {};
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,
@@ -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 =
@@ -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<DispatchFn>(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 })],
+23 -1
View File
@@ -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: {
@@ -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);
+8
View File
@@ -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 };
@@ -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",
});
});
});
+7
View File
@@ -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]";