mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
merge(reliability): SMTP timeouts, cron index use, doctor-report sleep parity, VAPID dialog, redaction
This commit is contained in:
commit
7574ea00ff
17 files changed
+211
-14
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ó",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ę",
|
||||
|
||||
@@ -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.
@@ -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 })],
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]";
|
||||
|
||||
Reference in new issue
Block a user