From 410c62c292a7d6810d77e109a5773c84ace45338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 00:10:01 +0200 Subject: [PATCH] refactor(jobs,dashboard): a thin worker registry and a composed dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reminder worker becomes a registry over twelve queue modules cut along the existing test structure, and the dashboard page sheds its quick-entry sheets, header and range logic into components — same behaviour, pinned by the existing suites. --- src/app/__tests__/sheet-dirty-check.test.ts | 2 +- src/app/page.tsx | 417 +-- src/components/dashboard/dashboard-header.tsx | 120 + .../dashboard/medication-intake-quick-add.tsx | 42 +- .../dashboard/quick-entry-sheets.tsx | 214 ++ src/components/dashboard/range-display.tsx | 117 + .../__tests__/cycle-reminder-queue.test.ts | 15 +- src/lib/jobs/__tests__/fitbit-queues.test.ts | 4 +- src/lib/jobs/__tests__/geo-backfill.test.ts | 12 +- src/lib/jobs/__tests__/restore-drill.test.ts | 164 ++ .../__tests__/status-cron-candidates.test.ts | 16 +- .../__tests__/tls-pin-monitor-queue.test.ts | 4 +- src/lib/jobs/__tests__/whoop-queues.test.ts | 8 +- .../jobs/__tests__/withings-queues.test.ts | 14 +- src/lib/jobs/offhost-backup.ts | 2 +- src/lib/jobs/reminder-worker.ts | 2226 ++--------------- src/lib/jobs/reminder/backup-handlers.ts | 164 ++ src/lib/jobs/reminder/cleanup-handlers.ts | 240 ++ src/lib/jobs/reminder/fitbit-sync.ts | 84 + src/lib/jobs/reminder/insights-handlers.ts | 263 ++ .../jobs/reminder/medication-maintenance.ts | 67 + .../reminder/medication-reminder-check.ts | 510 ++++ src/lib/jobs/reminder/mood-cycle-checks.ts | 82 + src/lib/jobs/reminder/moodlog-sync.ts | 70 + src/lib/jobs/reminder/ops-handlers.ts | 183 ++ src/lib/jobs/reminder/shared.ts | 55 + src/lib/jobs/reminder/whoop-sync.ts | 101 + src/lib/jobs/reminder/withings-sync.ts | 210 ++ src/lib/jobs/restore-drill.ts | 193 ++ 29 files changed, 3173 insertions(+), 2426 deletions(-) create mode 100644 src/components/dashboard/dashboard-header.tsx create mode 100644 src/components/dashboard/quick-entry-sheets.tsx create mode 100644 src/components/dashboard/range-display.tsx create mode 100644 src/lib/jobs/__tests__/restore-drill.test.ts create mode 100644 src/lib/jobs/reminder/backup-handlers.ts create mode 100644 src/lib/jobs/reminder/cleanup-handlers.ts create mode 100644 src/lib/jobs/reminder/fitbit-sync.ts create mode 100644 src/lib/jobs/reminder/insights-handlers.ts create mode 100644 src/lib/jobs/reminder/medication-maintenance.ts create mode 100644 src/lib/jobs/reminder/medication-reminder-check.ts create mode 100644 src/lib/jobs/reminder/mood-cycle-checks.ts create mode 100644 src/lib/jobs/reminder/moodlog-sync.ts create mode 100644 src/lib/jobs/reminder/ops-handlers.ts create mode 100644 src/lib/jobs/reminder/shared.ts create mode 100644 src/lib/jobs/reminder/whoop-sync.ts create mode 100644 src/lib/jobs/reminder/withings-sync.ts create mode 100644 src/lib/jobs/restore-drill.ts diff --git a/src/app/__tests__/sheet-dirty-check.test.ts b/src/app/__tests__/sheet-dirty-check.test.ts index 188dc4fbb..9a27f1056 100644 --- a/src/app/__tests__/sheet-dirty-check.test.ts +++ b/src/app/__tests__/sheet-dirty-check.test.ts @@ -17,7 +17,7 @@ * convention (cf. the SSR-string tests elsewhere). */ import { afterEach, describe, expect, it } from "vitest"; -import { sheetBodyHasUnsavedInput } from "../page"; +import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets"; const BODY_SELECTOR = '[data-slot="responsive-sheet-body"]'; const MOOD_RADIO_SELECTOR = '[role="radio"][aria-checked="true"]'; diff --git a/src/app/page.tsx b/src/app/page.tsx index e9e2aedba..670620e50 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -11,13 +11,10 @@ import { Heart, Moon, Percent, - Pill, Plus, - Settings2, Smile, Target, TrendingUp, - Waves, } from "lucide-react"; import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; import { cn } from "@/lib/utils"; @@ -28,35 +25,25 @@ import { } from "@/lib/dashboard-layout"; import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics"; import dynamic from "next/dynamic"; -import Link from "next/link"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; -import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { MeasurementForm } from "@/components/measurements/measurement-form"; -import { MoodForm } from "@/components/mood/mood-form"; -import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add"; import { DashboardChartCell, useDashboardChartReveal, } from "@/components/dashboard/chart-reveal"; +import { DashboardHeader } from "@/components/dashboard/dashboard-header"; +import { + QuickEntrySheets, + type QuickEntryDialog, +} from "@/components/dashboard/quick-entry-sheets"; +import { + getHourForTimeZone, + getRangeColorClass, + getRangeHint, + toHoursSummary, +} from "@/components/dashboard/range-display"; import { TrendCard } from "@/components/charts/trend-card"; import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton"; import { TrendHint } from "@/components/charts/trend-hint"; @@ -76,69 +63,6 @@ const DASHBOARD_QUERY_OPTS = { refetchOnWindowFocus: false, } as const; -/** - * v1.11.3 F3 — guard the quick-entry sheets against an accidental - * dismiss (overlay tap, Esc, mobile swipe-down) discarding a partly - * filled form. The forms live behind a stable `ResponsiveSheet` body - * slot and don't expose their dirty state, so we read it from the DOM - * at dismiss time: any visible text/number input or textarea that - * carries a value, or any checked checkbox/radio that isn't a default, - * counts as unsaved input. Date/time pickers are excluded — the forms - * prefill them with the current timestamp on mount, so a pristine sheet - * would otherwise always read as dirty. Cheap, synchronous, and runs - * only on the one mounted sheet body. - */ -const PREFILLED_PICKER_TYPES = new Set([ - "date", - "datetime-local", - "time", - "month", - "week", -]); -export function sheetBodyHasUnsavedInput(): boolean { - if (typeof document === "undefined") return false; - const body = document.querySelector( - '[data-slot="responsive-sheet-body"]', - ); - if (!body) return false; - const fields = body.querySelectorAll( - "input, textarea", - ); - for (const field of fields) { - if (field.disabled || field.type === "hidden") continue; - // Skip pickers the form prefills with "now" — an untouched default - // is not user input (v1.11.3 QA H1). - if (PREFILLED_PICKER_TYPES.has(field.type)) continue; - if (field.type === "checkbox" || field.type === "radio") { - const box = field as HTMLInputElement; - if (box.checked !== box.defaultChecked) return true; - continue; - } - if (field.value.trim() !== "") return true; - } - // v1.11.5 — the input/textarea walk misses two non-text controls a user can - // change before dismissing: the mood `role="radio"` selector and a Radix - // Select (measurement type / medication). Losing either silently on a - // backdrop-dismiss is the same data-loss class the H1 confirm-on-dirty guard - // was meant to close. - // - // Mood radios start with nothing selected (`mood = ""`), so ANY - // `aria-checked="true"` is a deliberate user choice — flag it. - if (body.querySelector('[role="radio"][aria-checked="true"]') !== null) { - return true; - } - // Radix Select trigger renders `role="combobox"`. A trigger with - // `data-state="open"` is mid-interaction (the dropdown is up) — the user is - // actively choosing, so a dismiss should confirm rather than drop the pick. - // We deliberately do NOT treat a merely non-placeholder value as dirty: the - // type / medication selects mount with a non-placeholder DEFAULT, and - // flagging that would over-trigger the confirm on a pristine sheet. - if (body.querySelector('[role="combobox"][data-state="open"]') !== null) { - return true; - } - return false; -} - const MoodChart = dynamic( () => import("@/components/charts/mood-chart").then((mod) => ({ @@ -167,68 +91,12 @@ import { buildWeightBandsFromHeight, buildWeightRangeFromHeight, getBodyFatTargetRange, - type TrafficRange, } from "@/lib/analytics/value-bands"; import { getAgeFromDateOfBirth, getPersonalizedPulseTarget, } from "@/lib/analytics/pulse-targets"; -interface RangeDisplayConfig { - range: TrafficRange | null; -} - -/** - * v1.11.4 — minutes→hours projection of a `DataSummary` for the sleep - * tile. The server emits `summaries.SLEEP_DURATION` as per-night minutes; - * the tile renders hours. Every value-bearing field (latest / min / max / - * mean / median / avg7 / avg30 / avg30LastMonth / avg30LastYear) divides - * by 60; the slope tuples scale their `slope` (per-day rate) by the same - * factor so the trend arrow stays consistent; count / direction / - * confidence / anomalyCount are unit-free and pass through. - */ -function toHoursSummary(s: DataSummary): DataSummary { - const h = (v: number | null | undefined): number | null => - v == null ? null : Math.round((v / 60) * 100) / 100; - const scaleSlope = (slope: DataSummary["slope7"]): DataSummary["slope7"] => - slope == null - ? null - : { ...slope, slope: Math.round((slope.slope / 60) * 1000) / 1000 }; - return { - ...s, - latest: h(s.latest), - min: h(s.min), - max: h(s.max), - mean: h(s.mean), - median: h(s.median), - avg7: h(s.avg7), - avg30: h(s.avg30), - avg30LastMonth: h(s.avg30LastMonth), - avg30LastYear: h(s.avg30LastYear), - slope7: scaleSlope(s.slope7), - slope30: scaleSlope(s.slope30), - slope90: scaleSlope(s.slope90), - }; -} - -function getHourForTimeZone(timeZone?: string): number { - const now = new Date(); - if (!timeZone) return now.getHours(); - - try { - const parts = new Intl.DateTimeFormat("en-US", { - hour: "2-digit", - hour12: false, - timeZone, - }).formatToParts(now); - const hourPart = parts.find((part) => part.type === "hour")?.value; - const parsed = hourPart ? Number(hourPart) : Number.NaN; - return Number.isNaN(parsed) ? now.getHours() : parsed; - } catch { - return now.getHours(); - } -} - /** * v1.7.0 — first-paint gate for the dashboard tile strip. * @@ -257,92 +125,12 @@ export function resolveDashboardFirstPaintGate(input: { return { showTileStripSkeleton, showEmptyState }; } -function getRangeColorClass( - value: number | null | undefined, - config: RangeDisplayConfig, -): string | undefined { - const range = config.range; - if (value == null || !range) return undefined; - const inGreen = value >= range.greenMin && value <= range.greenMax; - const inOrange = - !inGreen && value >= range.orangeMin && value <= range.orangeMax; - - if (inGreen) return "text-success"; - if (inOrange) return "text-warning"; - return "text-destructive"; -} - -function getRangeHint( - unit: string, - config: RangeDisplayConfig, - t: (key: string) => string, - formatNumber: (value: number, fractionDigits?: number) => string, -): React.ReactNode | undefined { - const range = config.range; - if (!range) return undefined; - - const format = (value: number) => formatNumber(value, 1); - - return ( - <> -

- {t("charts.colorGreen")}{" "} - {format(range.greenMin)}-{format(range.greenMax)} {unit} -

-

- - {t("charts.colorOrange")} - {" "} - {format(range.orangeMin)}-{format(range.greenMin)} {t("common.or")}{" "} - {format(range.greenMax)}-{format(range.orangeMax)} {unit} -

-

- - {t("charts.colorRed")} - {" "} - {"< "} - {format(range.orangeMin)} {t("common.or")} {"> "} - {format(range.orangeMax)} {unit} -

- - ); -} - export default function DashboardPage() { const { isAuthenticated, user } = useAuth(); const { t } = useTranslations(); const fmt = useFormatters(); - const [quickEntryDialog, setQuickEntryDialog] = useState< - "measurement" | "mood" | "medicationIntake" | null - >(null); - // v1.4.27 R4 RC2 — DOM handles for the form action-row portal target - // on each quick-entry sheet. The Sheet branch sticky-pins this slot. - const [measurementFooterEl, setMeasurementFooterEl] = - useState(null); - const [moodFooterEl, setMoodFooterEl] = useState(null); - // v1.4.37 W7b — medication-intake quick-add lives on the same Sheet - // primitive as the other two quick-entries; the form's action row is - // portalled into this slot so Save / Cancel stay reachable above the - // mobile soft keyboard. - const [medicationIntakeFooterEl, setMedicationIntakeFooterEl] = - useState(null); - // v1.11.3 F3 — when an open quick-entry sheet is dismissed with - // unsaved input, hold the close in this flag and surface a confirm - // instead of nulling the dialog outright. Cleared once the user - // confirms (close) or keeps editing (dismiss the confirm). - const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false); - - // Intercept a sheet dismiss: close immediately when the form is - // clean, otherwise keep the sheet open and ask before discarding. - function handleQuickEntryOpenChange(open: boolean) { - if (open) return; - if (sheetBodyHasUnsavedInput()) { - setConfirmDiscardOpen(true); - return; - } - setQuickEntryDialog(null); - } - + const [quickEntryDialog, setQuickEntryDialog] = + useState(null); // v1.4.29 M5 — the three inline dashboard queries default to a // 0-ms `staleTime`, so a tab-focus-and-return triggered a refetch // storm. None of these are real-time data; minute-scale staleness @@ -829,101 +617,10 @@ export default function DashboardPage() { return (
- {/* v1.4.37 W4a item 7 — centre-align the Hinzufügen button - against the 2-line title block on mobile (< sm). The - `welcomeText` line wraps under the title at < 380 px so the - button used to float at the top of the row without a - baseline anchor; `items-center sm:items-start` keeps the - mobile vertical centre while preserving the original - top-aligned posture on sm+ (where the title is one line). */} -
-
-

- {t("dashboard.title")} -

-

{welcomeText}

-
-
- {/* Customize shortcut to the dashboard-customization settings - (the tile/layout editor at /settings/dashboard). Sits to - the left of the add button as a monochrome ghost icon, with - a `min-h-11 min-w-11` mobile floor so it meets the 44 px - touch-target contract the add button also honours, shrinking - back to the 40 px icon footprint on sm+. */} - - - - {/* v1.4.33 maintainer-item-7 — restore proportional sizing - across viewports. The v1.4.27 fix pinned a `size="sm" - min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target - contract on mobile, but `size="sm"` is h-8 (32 px) and - the `min-h-11` override stretched the cap vertically - while keeping the small horizontal padding — the - button read as klobig on Pixel 5. Switch to - `size="default"` (h-10 = 40 px) on mobile with a - responsive `min-h-11 sm:min-h-9` so the button is - 44 px tall under finger pressure and shrinks back to - the desktop-friendly 36 px on `sm:` upwards. The icon - + label keep the same visual contract. */} - - - - {/* Menu items must each carry a self-contained verb-phrase - ("Log measurement", "Log mood") — the trigger above already - says "Add", and the icon is `aria-hidden`, so the visible - text is the only thing distinguishing the rows. v1.4.15 - phase-A3 fix #1 hardened this with a unit guard at - `src/app/__tests__/quick-add-labels.test.ts` — both labels - must differ from each other AND from `common.add`. */} - setQuickEntryDialog("measurement")} - > - - setQuickEntryDialog("mood")}> - - {/* v1.4.37 W7b — third quick-add row: medication intake. - Same Sheet-on-mobile / Dialog-on-desktop primitive as - the other two; the menu label is a self-contained - verb-phrase so it doesn't collide with the trigger or - the sibling rows (cf. quick-add-labels.test.ts). */} - setQuickEntryDialog("medicationIntake")} - > - - - -
-
+ {/* v1.4: Getting-started checklist for brand-new users. * Self-gates visibility on (onboardingCompletedAt == null @@ -942,82 +639,10 @@ export default function DashboardPage() { {/* Quick Entry Sheets — bottom-sheet on `} - > - setQuickEntryDialog(null)} - onCancel={() => setQuickEntryDialog(null)} - footerSlot={measurementFooterEl} - /> - - } - > - setQuickEntryDialog(null)} - onCancel={() => setQuickEntryDialog(null)} - footerSlot={moodFooterEl} - /> - - {/* v1.4.37 W7b — third Sheet: medication intake. Same - ResponsiveSheet contract as the two above; the form's footer - (Cancel + Save) portals into the sticky-pinned slot so it - stays reachable above the soft keyboard on mobile. */} - - } - > - setQuickEntryDialog(null)} - onCancel={() => setQuickEntryDialog(null)} - footerSlot={medicationIntakeFooterEl} - /> - - - {/* v1.11.3 F3 — confirm before discarding a partly-filled - quick-entry form when the sheet is dismissed by an overlay - tap, Escape, or a mobile swipe-down. Keeps the sheet open - until the user decides. */} - - - - - {t("dashboard.quickEntryDiscard.title")} - - - {t("dashboard.quickEntryDiscard.description")} - - - - - {t("dashboard.quickEntryDiscard.cancel")} - - { - setConfirmDiscardOpen(false); - setQuickEntryDialog(null); - }} - > - {t("dashboard.quickEntryDiscard.confirm")} - - - - + setQuickEntryDialog(null)} + /> {(() => { type TrendEntry = { id: string; order: number; node: React.ReactNode }; diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx new file mode 100644 index 000000000..994e54578 --- /dev/null +++ b/src/components/dashboard/dashboard-header.tsx @@ -0,0 +1,120 @@ +"use client"; + +/** + * Dashboard page header: title + greeting line, the customize shortcut, + * and the quick-add dropdown that opens the quick-entry sheets. + * + * Extracted from the dashboard page; the page owns the open-state the + * dropdown items set via `onQuickEntry`. + */ +import Link from "next/link"; +import { Activity, Pill, Plus, Settings2, Waves } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useTranslations } from "@/lib/i18n/context"; +import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets"; + +export function DashboardHeader({ + welcomeText, + onQuickEntry, +}: { + welcomeText: string; + onQuickEntry: (dialog: NonNullable) => void; +}) { + const { t } = useTranslations(); + return ( + /* v1.4.37 W4a item 7 — centre-align the Hinzufügen button + against the 2-line title block on mobile (< sm). The + `welcomeText` line wraps under the title at < 380 px so the + button used to float at the top of the row without a + baseline anchor; `items-center sm:items-start` keeps the + mobile vertical centre while preserving the original + top-aligned posture on sm+ (where the title is one line). */ +
+
+

+ {t("dashboard.title")} +

+

{welcomeText}

+
+
+ {/* Customize shortcut to the dashboard-customization settings + (the tile/layout editor at /settings/dashboard). Sits to + the left of the add button as a monochrome ghost icon, with + a `min-h-11 min-w-11` mobile floor so it meets the 44 px + touch-target contract the add button also honours, shrinking + back to the 40 px icon footprint on sm+. */} + + + + {/* v1.4.33 maintainer-item-7 — restore proportional sizing + across viewports. The v1.4.27 fix pinned a `size="sm" + min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target + contract on mobile, but `size="sm"` is h-8 (32 px) and + the `min-h-11` override stretched the cap vertically + while keeping the small horizontal padding — the + button read as klobig on Pixel 5. Switch to + `size="default"` (h-10 = 40 px) on mobile with a + responsive `min-h-11 sm:min-h-9` so the button is + 44 px tall under finger pressure and shrinks back to + the desktop-friendly 36 px on `sm:` upwards. The icon + + label keep the same visual contract. */} + + + + {/* Menu items must each carry a self-contained verb-phrase + ("Log measurement", "Log mood") — the trigger above already + says "Add", and the icon is `aria-hidden`, so the visible + text is the only thing distinguishing the rows. v1.4.15 + phase-A3 fix #1 hardened this with a unit guard at + `src/app/__tests__/quick-add-labels.test.ts` — both labels + must differ from each other AND from `common.add`. */} + onQuickEntry("measurement")}> + + onQuickEntry("mood")}> + + {/* v1.4.37 W7b — third quick-add row: medication intake. + Same Sheet-on-mobile / Dialog-on-desktop primitive as + the other two; the menu label is a self-contained + verb-phrase so it doesn't collide with the trigger or + the sibling rows (cf. quick-add-labels.test.ts). */} + onQuickEntry("medicationIntake")}> + + + +
+
+ ); +} diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index bdc775fc8..b9dab9081 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -47,13 +47,10 @@ import { * unusable form. * 2. Dose. Pre-filled from the medication's catalogue dose for * visual confirmation ("this is the strength I'm logging"). - * v1.4.37 W10 — rendered read-only because the POST body has no - * `dose` field; any user edit would be silently dropped. When - * the user wants to record a half/double dose they still edit - * the medication itself on `/medications` until the v1.4.38 - * schema grows a `doseOverride` slot. Hint copy below the - * field warns about the constraint so the user's mental model - * tracks what the server persists. + * v1.16.4 — editable: the intake POST body grew a `doseTaken` + * override slot, so a half/double dose can be recorded for THIS + * take. An untouched (or configured-dose) value sends nothing and + * the take records under the medication's dose as before. * 3. Time taken. Defaults to `now` (local datetime-local format * shaped the same as `MoodForm`). * @@ -200,13 +197,11 @@ export function MedicationIntakeQuickAdd({ const [medicationOverride, setMedicationOverride] = useState( null, ); - // v1.4.37 W10 — the dose field is informational only. The intake - // POST body has no `dose` slot, so any user edit would be silently - // dropped on submit. Until the v1.4.38 schema grows a `doseOverride` - // field, render the input as read-only so the user's mental model - // tracks what the server actually persists. The state holder is - // kept (as `null`) so the medication-switch handler still resets - // the slot cleanly if the schema ever grows the field. + // v1.16.4 — the dose field is editable: the intake POST body grew a + // `doseTaken` slot, so a half/double dose can be recorded per intake. + // `null` = untouched → the medication's configured dose flows through + // and nothing extra is sent; an edit that differs from the configured + // dose is persisted as the per-intake override. const [doseOverride, setDoseOverride] = useState(null); const [takenAt, setTakenAt] = useState(getDefaultIntakeAtValue); const [loading, setLoading] = useState(false); @@ -227,10 +222,7 @@ export function MedicationIntakeQuickAdd({ function handleMedicationChange(value: string) { setMedicationOverride(value); // Reset the dose override when the medication changes so the new - // default flows through on the next render. The dose field is - // currently read-only (see v1.4.37 W10 note above), but the reset - // stays in place so the slot is ready to switch back to editable - // when the v1.4.38 doseOverride schema lands. + // medication's configured dose flows through on the next render. setDoseOverride(null); } @@ -242,12 +234,20 @@ export function MedicationIntakeQuickAdd({ try { const timestamp = new Date(takenAt).toISOString(); + // v1.16.4 — only a deliberate deviation travels: an untouched or + // configured-dose value sends nothing, so the row stays NULL and + // read paths fall back to the medication's dose. + const trimmedDose = dose.trim(); + const doseDeviates = + trimmedDose.length > 0 && + trimmedDose !== (selectedMedication?.dose ?? "").trim(); const res = await fetch(`/api/medications/${medicationId}/intake`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ takenAt: timestamp, skipped: false, + ...(doseDeviates && { doseTaken: trimmedDose }), }), }); @@ -418,15 +418,13 @@ export function MedicationIntakeQuickAdd({ id="medication-intake-dose" data-testid="medication-intake-quick-add-dose" value={dose} - readOnly - aria-readonly="true" - tabIndex={-1} + onChange={(e) => setDoseOverride(e.target.value)} + maxLength={50} placeholder={ selectedMedication?.dose ?? t("dashboard.medicationIntakeQuickAdd.dosePlaceholder") } autoComplete="off" - className="bg-muted/40" />

{t("dashboard.medicationIntakeQuickAdd.doseHint")} diff --git a/src/components/dashboard/quick-entry-sheets.tsx b/src/components/dashboard/quick-entry-sheets.tsx new file mode 100644 index 000000000..0eeb74073 --- /dev/null +++ b/src/components/dashboard/quick-entry-sheets.tsx @@ -0,0 +1,214 @@ +"use client"; + +/** + * Quick-entry sheets for the dashboard: measurement, mood, and + * medication-intake forms behind the shared ResponsiveSheet primitive + * (bottom-sheet on `( + '[data-slot="responsive-sheet-body"]', + ); + if (!body) return false; + const fields = body.querySelectorAll( + "input, textarea", + ); + for (const field of fields) { + if (field.disabled || field.type === "hidden") continue; + // Skip pickers the form prefills with "now" — an untouched default + // is not user input (v1.11.3 QA H1). + if (PREFILLED_PICKER_TYPES.has(field.type)) continue; + if (field.type === "checkbox" || field.type === "radio") { + const box = field as HTMLInputElement; + if (box.checked !== box.defaultChecked) return true; + continue; + } + if (field.value.trim() !== "") return true; + } + // v1.11.5 — the input/textarea walk misses two non-text controls a user can + // change before dismissing: the mood `role="radio"` selector and a Radix + // Select (measurement type / medication). Losing either silently on a + // backdrop-dismiss is the same data-loss class the H1 confirm-on-dirty guard + // was meant to close. + // + // Mood radios start with nothing selected (`mood = ""`), so ANY + // `aria-checked="true"` is a deliberate user choice — flag it. + if (body.querySelector('[role="radio"][aria-checked="true"]') !== null) { + return true; + } + // Radix Select trigger renders `role="combobox"`. A trigger with + // `data-state="open"` is mid-interaction (the dropdown is up) — the user is + // actively choosing, so a dismiss should confirm rather than drop the pick. + // We deliberately do NOT treat a merely non-placeholder value as dirty: the + // type / medication selects mount with a non-placeholder DEFAULT, and + // flagging that would over-trigger the confirm on a pristine sheet. + if (body.querySelector('[role="combobox"][data-state="open"]') !== null) { + return true; + } + return false; +} + +export function QuickEntrySheets({ + open, + onClose, +}: { + open: QuickEntryDialog; + onClose: () => void; +}) { + const { t } = useTranslations(); + // v1.4.27 R4 RC2 — DOM handles for the form action-row portal target + // on each quick-entry sheet. The Sheet branch sticky-pins this slot. + const [measurementFooterEl, setMeasurementFooterEl] = + useState(null); + const [moodFooterEl, setMoodFooterEl] = useState(null); + // v1.4.37 W7b — medication-intake quick-add lives on the same Sheet + // primitive as the other two quick-entries; the form's action row is + // portalled into this slot so Save / Cancel stay reachable above the + // mobile soft keyboard. + const [medicationIntakeFooterEl, setMedicationIntakeFooterEl] = + useState(null); + // v1.11.3 F3 — when an open quick-entry sheet is dismissed with + // unsaved input, hold the close in this flag and surface a confirm + // instead of nulling the dialog outright. Cleared once the user + // confirms (close) or keeps editing (dismiss the confirm). + const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false); + + // Intercept a sheet dismiss: close immediately when the form is + // clean, otherwise keep the sheet open and ask before discarding. + function handleQuickEntryOpenChange(nextOpen: boolean) { + if (nextOpen) return; + if (sheetBodyHasUnsavedInput()) { + setConfirmDiscardOpen(true); + return; + } + onClose(); + } + + return ( + <> + } + > + + + } + > + + + {/* v1.4.37 W7b — third Sheet: medication intake. Same + ResponsiveSheet contract as the two above; the form's footer + (Cancel + Save) portals into the sticky-pinned slot so it + stays reachable above the soft keyboard on mobile. */} + + } + > + + + + {/* v1.11.3 F3 — confirm before discarding a partly-filled + quick-entry form when the sheet is dismissed by an overlay + tap, Escape, or a mobile swipe-down. Keeps the sheet open + until the user decides. */} + + + + + {t("dashboard.quickEntryDiscard.title")} + + + {t("dashboard.quickEntryDiscard.description")} + + + + + {t("dashboard.quickEntryDiscard.cancel")} + + { + setConfirmDiscardOpen(false); + onClose(); + }} + > + {t("dashboard.quickEntryDiscard.confirm")} + + + + + + ); +} diff --git a/src/components/dashboard/range-display.tsx b/src/components/dashboard/range-display.tsx new file mode 100644 index 000000000..4c16758f5 --- /dev/null +++ b/src/components/dashboard/range-display.tsx @@ -0,0 +1,117 @@ +/** + * Range-display helpers for the dashboard tile strip and chart grid: + * traffic-light colour classes, the colour-legend hint block, the + * minutes→hours sleep projection, and the timezone-aware greeting hour. + * + * Extracted from the dashboard page; the composition (which tile uses + * which range) stays there. + */ +import type * as React from "react"; +import type { TrafficRange } from "@/lib/analytics/value-bands"; +import type { DataSummary } from "@/lib/analytics/trends"; + +export interface RangeDisplayConfig { + range: TrafficRange | null; +} + +/** + * v1.11.4 — minutes→hours projection of a `DataSummary` for the sleep + * tile. The server emits `summaries.SLEEP_DURATION` as per-night minutes; + * the tile renders hours. Every value-bearing field (latest / min / max / + * mean / median / avg7 / avg30 / avg30LastMonth / avg30LastYear) divides + * by 60; the slope tuples scale their `slope` (per-day rate) by the same + * factor so the trend arrow stays consistent; count / direction / + * confidence / anomalyCount are unit-free and pass through. + */ +export function toHoursSummary(s: DataSummary): DataSummary { + const h = (v: number | null | undefined): number | null => + v == null ? null : Math.round((v / 60) * 100) / 100; + const scaleSlope = (slope: DataSummary["slope7"]): DataSummary["slope7"] => + slope == null + ? null + : { ...slope, slope: Math.round((slope.slope / 60) * 1000) / 1000 }; + return { + ...s, + latest: h(s.latest), + min: h(s.min), + max: h(s.max), + mean: h(s.mean), + median: h(s.median), + avg7: h(s.avg7), + avg30: h(s.avg30), + avg30LastMonth: h(s.avg30LastMonth), + avg30LastYear: h(s.avg30LastYear), + slope7: scaleSlope(s.slope7), + slope30: scaleSlope(s.slope30), + slope90: scaleSlope(s.slope90), + }; +} + +export function getHourForTimeZone(timeZone?: string): number { + const now = new Date(); + if (!timeZone) return now.getHours(); + + try { + const parts = new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + hour12: false, + timeZone, + }).formatToParts(now); + const hourPart = parts.find((part) => part.type === "hour")?.value; + const parsed = hourPart ? Number(hourPart) : Number.NaN; + return Number.isNaN(parsed) ? now.getHours() : parsed; + } catch { + return now.getHours(); + } +} + +export function getRangeColorClass( + value: number | null | undefined, + config: RangeDisplayConfig, +): string | undefined { + const range = config.range; + if (value == null || !range) return undefined; + const inGreen = value >= range.greenMin && value <= range.greenMax; + const inOrange = + !inGreen && value >= range.orangeMin && value <= range.orangeMax; + + if (inGreen) return "text-success"; + if (inOrange) return "text-warning"; + return "text-destructive"; +} + +export function getRangeHint( + unit: string, + config: RangeDisplayConfig, + t: (key: string) => string, + formatNumber: (value: number, fractionDigits?: number) => string, +): React.ReactNode | undefined { + const range = config.range; + if (!range) return undefined; + + const format = (value: number) => formatNumber(value, 1); + + return ( + <> +

+ {t("charts.colorGreen")}{" "} + {format(range.greenMin)}-{format(range.greenMax)} {unit} +

+

+ + {t("charts.colorOrange")} + {" "} + {format(range.orangeMin)}-{format(range.greenMin)} {t("common.or")}{" "} + {format(range.greenMax)}-{format(range.orangeMax)} {unit} +

+

+ + {t("charts.colorRed")} + {" "} + {"< "} + {format(range.orangeMin)} {t("common.or")} {"> "} + {format(range.orangeMax)} {unit} +

+ + ); +} diff --git a/src/lib/jobs/__tests__/cycle-reminder-queue.test.ts b/src/lib/jobs/__tests__/cycle-reminder-queue.test.ts index 6e06f9ed7..6d0779793 100644 --- a/src/lib/jobs/__tests__/cycle-reminder-queue.test.ts +++ b/src/lib/jobs/__tests__/cycle-reminder-queue.test.ts @@ -14,7 +14,12 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts"); -const source = readFileSync(REMINDER_WORKER_PATH, "utf8"); +const source = + readFileSync(REMINDER_WORKER_PATH, "utf8") + + readFileSync( + join(__dirname, "..", "reminder", "mood-cycle-checks.ts"), + "utf8", + ); describe("reminder-worker — cycle-reminder wiring", () => { it("imports the cycle-reminder tick runner", () => { @@ -26,11 +31,15 @@ describe("reminder-worker — cycle-reminder wiring", () => { expect(source).toMatch( /CYCLE_REMINDER_QUEUE\s*=\s*["']cycle-reminder-check["']/, ); - expect(source).toMatch(/CYCLE_REMINDER_CRON\s*=\s*["']\*\/15 \* \* \* \*["']/); + expect(source).toMatch( + /CYCLE_REMINDER_CRON\s*=\s*["']\*\/15 \* \* \* \*["']/, + ); }); it("registers the cycle-reminder queue in the allQueues loop", () => { - const allQueuesMatch = source.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/); + const allQueuesMatch = source.match( + /const allQueues\s*=\s*\[([\s\S]*?)\];/, + ); expect(allQueuesMatch).not.toBeNull(); expect(allQueuesMatch![1]).toMatch(/\bCYCLE_REMINDER_QUEUE\b/); }); diff --git a/src/lib/jobs/__tests__/fitbit-queues.test.ts b/src/lib/jobs/__tests__/fitbit-queues.test.ts index 5049be89b..17ed457b4 100644 --- a/src/lib/jobs/__tests__/fitbit-queues.test.ts +++ b/src/lib/jobs/__tests__/fitbit-queues.test.ts @@ -18,7 +18,9 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts"); -const source = readFileSync(REMINDER_WORKER_PATH, "utf8"); +const source = + readFileSync(REMINDER_WORKER_PATH, "utf8") + + readFileSync(join(__dirname, "..", "reminder", "fitbit-sync.ts"), "utf8"); const FITBIT_QUEUE_CONSTS = [ "FITBIT_SYNC_QUEUE", diff --git a/src/lib/jobs/__tests__/geo-backfill.test.ts b/src/lib/jobs/__tests__/geo-backfill.test.ts index fa21d5be2..977c3d84b 100644 --- a/src/lib/jobs/__tests__/geo-backfill.test.ts +++ b/src/lib/jobs/__tests__/geo-backfill.test.ts @@ -63,9 +63,7 @@ describe("runGeoBackfill", () => { location: null, ipAddress: { not: null }, createdAt: { - gt: new Date( - now.getTime() - GEO_BACKFILL_WINDOW_DAYS * 86_400_000, - ), + gt: new Date(now.getTime() - GEO_BACKFILL_WINDOW_DAYS * 86_400_000), }, }); expect(args?.take).toBe(GEO_BACKFILL_BATCH_CAP); @@ -248,8 +246,12 @@ describe("geo-backfill scheduling contract (v1.4.37)", () => { path.resolve(__dirname, "..", "reminder-worker.ts"), "utf-8", ); - expect(workerSrc).toContain( - "import {\n runGeoBackfill,\n GEO_BACKFILL_QUEUE,\n GEO_BACKFILL_CRON,\n} from \"@/lib/jobs/geo-backfill\";", + const handlerSrc = await fs.readFile( + path.resolve(__dirname, "..", "reminder", "ops-handlers.ts"), + "utf-8", + ); + expect(handlerSrc).toMatch( + /import \{[^}]*runGeoBackfill[^}]*\} from "@\/lib\/jobs\/geo-backfill";/, ); expect(workerSrc).toContain("GEO_BACKFILL_QUEUE, GEO_BACKFILL_CRON"); expect(workerSrc).toContain("handleGeoBackfill"); diff --git a/src/lib/jobs/__tests__/restore-drill.test.ts b/src/lib/jobs/__tests__/restore-drill.test.ts new file mode 100644 index 000000000..46cb5a13b --- /dev/null +++ b/src/lib/jobs/__tests__/restore-drill.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { encryptBackup } from "../offhost-backup"; +import { + handleRestoreDrill, + runRestoreDrill, + RESTORE_DRILL_CRON, + RESTORE_DRILL_QUEUE, +} from "../restore-drill"; + +vi.mock("../report-worker-error", () => ({ + reportWorkerError: vi.fn().mockResolvedValue(undefined), +})); + +import { reportWorkerError } from "../report-worker-error"; + +const ENC_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const KEY = Buffer.from(ENC_KEY, "hex"); + +function stubConfigEnv() { + vi.stubEnv("BACKUP_S3_ENDPOINT", "https://r2.example"); + vi.stubEnv("BACKUP_S3_BUCKET", "hl-backups"); + vi.stubEnv("BACKUP_S3_ACCESS_KEY", "AKIA"); + vi.stubEnv("BACKUP_S3_SECRET_KEY", "secret"); + vi.stubEnv("BACKUP_ENCRYPTION_KEY", ENC_KEY); +} + +function makeS3Mock(initial: Record = {}) { + const store = new Map(Object.entries(initial)); + return { + store, + putObject: vi.fn(async (k: string, b: Buffer | Uint8Array) => { + store.set(k, Buffer.from(b)); + }), + getObject: vi.fn(async (k: string) => { + const v = store.get(k); + if (!v) throw new Error("not found"); + return v; + }), + headObject: vi.fn(async (k: string) => store.has(k)), + listObjects: vi.fn(async (prefix: string) => + Array.from(store.keys()) + .filter((k) => k.startsWith(prefix)) + .map((key) => ({ key })), + ), + deleteObject: vi.fn(async (k: string) => { + store.delete(k); + }), + }; +} + +function backupObject(overrides: Record = {}): Buffer { + return encryptBackup( + JSON.stringify({ + exportedAt: "2026-06-01T02:30:00.000Z", + userId: "user-abc", + measurements: [{ id: "m1" }, { id: "m2" }], + medications: [{ id: "med1" }], + intakeEvents: [], + moodEntries: [{ id: "mood1" }], + ...overrides, + }), + KEY, + ); +} + +describe("runRestoreDrill", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + stubConfigEnv(); + }); + + it("exports the agreed queue name and monthly cron slot", () => { + expect(RESTORE_DRILL_QUEUE).toBe("data-restore-drill"); + expect(RESTORE_DRILL_CRON).toBe("11 4 1 * *"); + }); + + it("fetches, decrypts, and parses the newest backup object", async () => { + const s3 = makeS3Mock({ + "2026-05-30/user-old.json.enc": backupObject(), + "2026-06-01/user-abc.json.enc": backupObject(), + "_healthcheck/123.bin": Buffer.from([0x42]), + }); + const report = await runRestoreDrill(s3, new Date("2026-06-02T04:11:00Z")); + expect(report.objectKey).toBe("2026-06-01/user-abc.json.enc"); + expect(report.dateKey).toBe("2026-06-01"); + expect(report.ageDays).toBe(1); + expect(report.stale).toBe(false); + expect(report.recordCounts).toEqual({ + measurements: 2, + medications: 1, + intakeEvents: 0, + moodEntries: 1, + }); + expect(s3.getObject).toHaveBeenCalledWith("2026-06-01/user-abc.json.enc"); + // Read-only drill: nothing is ever written or deleted. + expect(s3.putObject).not.toHaveBeenCalled(); + expect(s3.deleteObject).not.toHaveBeenCalled(); + }); + + it("flags the report stale when the newest backup is older than the threshold", async () => { + const s3 = makeS3Mock({ "2026-05-20/user-abc.json.enc": backupObject() }); + const report = await runRestoreDrill(s3, new Date("2026-06-01T04:11:00Z")); + expect(report.ageDays).toBe(12); + expect(report.stale).toBe(true); + }); + + it("throws when the bucket holds no backup-shaped objects", async () => { + const s3 = makeS3Mock({ "_healthcheck/1.bin": Buffer.from([0x42]) }); + await expect(runRestoreDrill(s3)).rejects.toThrow(/no backup objects/); + }); + + it("throws when the object cannot be decrypted (wrong key / tampering)", async () => { + const tampered = Buffer.from(backupObject()); + tampered[tampered.length - 1] ^= 0xff; + const s3 = makeS3Mock({ "2026-06-01/user-abc.json.enc": tampered }); + await expect(runRestoreDrill(s3)).rejects.toThrow(); + }); + + it("throws when the payload is missing core fields", async () => { + const s3 = makeS3Mock({ + "2026-06-01/user-abc.json.enc": encryptBackup( + JSON.stringify({ exportedAt: "2026-06-01T02:30:00.000Z" }), + KEY, + ), + }); + await expect(runRestoreDrill(s3)).rejects.toThrow(/missing core fields/); + }); + + it("throws OffhostBackupNotConfiguredError when the S3 vars are unset", async () => { + vi.unstubAllEnvs(); + vi.stubEnv("BACKUP_S3_ENDPOINT", ""); + await expect(runRestoreDrill(makeS3Mock())).rejects.toThrow( + /not configured/, + ); + }); +}); + +describe("handleRestoreDrill", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + }); + + it("skips without paging when off-host backup is not configured", async () => { + vi.stubEnv("BACKUP_S3_ENDPOINT", ""); + await handleRestoreDrill([]); + expect(reportWorkerError).not.toHaveBeenCalled(); + }); + + it("pages via reportWorkerError when the drill fails", async () => { + stubConfigEnv(); + // Bucket is configured but the AWS SDK client would be constructed + // against the fake endpoint; force the failure earlier by pointing + // the loader at an invalid encryption key instead. + vi.stubEnv("BACKUP_ENCRYPTION_KEY", "not-a-key"); + await handleRestoreDrill([]); + expect(reportWorkerError).toHaveBeenCalledTimes(1); + expect(vi.mocked(reportWorkerError).mock.calls[0][0]).toBe( + RESTORE_DRILL_QUEUE, + ); + }); +}); diff --git a/src/lib/jobs/__tests__/status-cron-candidates.test.ts b/src/lib/jobs/__tests__/status-cron-candidates.test.ts index b694cfc94..449e9ec29 100644 --- a/src/lib/jobs/__tests__/status-cron-candidates.test.ts +++ b/src/lib/jobs/__tests__/status-cron-candidates.test.ts @@ -190,9 +190,7 @@ describe("mood-status queue registration (dead-queue guard)", () => { expect(workerSrc).toMatch( /\[\s*MOOD_STATUS_QUEUE\s*,\s*MOOD_STATUS_CRON\s*,\s*insightRetryOptions\s*\]/, ); - expect(workerSrc).toMatch( - /MOOD_STATUS_CRON\s*=\s*"30 2 \* \* \*"/, - ); + expect(workerSrc).toMatch(/MOOD_STATUS_CRON\s*=\s*"30 2 \* \* \*"/); }); it("registers a boss.work handler for the queue", () => { @@ -200,12 +198,16 @@ describe("mood-status queue registration (dead-queue guard)", () => { }); it("drives all seven status crons through the shared discovery", () => { - expect(workerSrc).toMatch(/findStatusCronCandidates\(prisma\)/); + const statusSrc = fs.readFileSync( + path.resolve(__dirname, "../reminder/insights-handlers.ts"), + "utf8", + ); + expect(statusSrc).toMatch(/findStatusCronCandidates\(prisma\)/); // The old iterate-every-user discovery must be gone from the status // handlers (the WHOOP/data-backup cohort reads keep their own scans). - const statusBlock = workerSrc.slice( - workerSrc.indexOf("async function runStatusCronGenerate"), - workerSrc.indexOf("async function handleMoodLogSync"), + const statusBlock = statusSrc.slice( + statusSrc.indexOf("async function runStatusCronGenerate"), + statusSrc.indexOf("async function handleInsightPregenerateJob"), ); expect(statusBlock).not.toMatch(/prisma\.user\.findMany/); }); diff --git a/src/lib/jobs/__tests__/tls-pin-monitor-queue.test.ts b/src/lib/jobs/__tests__/tls-pin-monitor-queue.test.ts index 4c5a44a0e..3e1e385a7 100644 --- a/src/lib/jobs/__tests__/tls-pin-monitor-queue.test.ts +++ b/src/lib/jobs/__tests__/tls-pin-monitor-queue.test.ts @@ -13,7 +13,9 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts"); -const workerSource = readFileSync(REMINDER_WORKER_PATH, "utf8"); +const workerSource = + readFileSync(REMINDER_WORKER_PATH, "utf8") + + readFileSync(join(__dirname, "..", "reminder", "ops-handlers.ts"), "utf8"); describe("reminder-worker — tls-pin-monitor wiring", () => { it("imports the queue symbols from the tls-pin-monitor module", () => { diff --git a/src/lib/jobs/__tests__/whoop-queues.test.ts b/src/lib/jobs/__tests__/whoop-queues.test.ts index ded3c83eb..b91e7cf73 100644 --- a/src/lib/jobs/__tests__/whoop-queues.test.ts +++ b/src/lib/jobs/__tests__/whoop-queues.test.ts @@ -14,7 +14,9 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts"); -const source = readFileSync(REMINDER_WORKER_PATH, "utf8"); +const source = + readFileSync(REMINDER_WORKER_PATH, "utf8") + + readFileSync(join(__dirname, "..", "reminder", "whoop-sync.ts"), "utf8"); const WHOOP_QUEUE_CONSTS = [ "WHOOP_RECOVERY_SYNC_QUEUE", @@ -93,9 +95,7 @@ describe("reminder-worker — WHOOP sync queues", () => { }); it("imports the WHOOP per-resource sync routines", () => { - expect(source).toMatch( - /from\s*["']@\/lib\/whoop\/sync-recovery["']/, - ); + expect(source).toMatch(/from\s*["']@\/lib\/whoop\/sync-recovery["']/); expect(source).toMatch(/from\s*["']@\/lib\/whoop\/sync-cycle["']/); expect(source).toMatch(/from\s*["']@\/lib\/whoop\/sync-workout["']/); }); diff --git a/src/lib/jobs/__tests__/withings-queues.test.ts b/src/lib/jobs/__tests__/withings-queues.test.ts index 8e4d27505..23f380f2a 100644 --- a/src/lib/jobs/__tests__/withings-queues.test.ts +++ b/src/lib/jobs/__tests__/withings-queues.test.ts @@ -14,20 +14,20 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -const REMINDER_WORKER_PATH = join( - __dirname, - "..", - "reminder-worker.ts", -); +const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts"); -const source = readFileSync(REMINDER_WORKER_PATH, "utf8"); +const source = + readFileSync(REMINDER_WORKER_PATH, "utf8") + + readFileSync(join(__dirname, "..", "reminder", "withings-sync.ts"), "utf8"); describe("reminder-worker — Withings activity + sleep v2 queues", () => { it("declares the activity-sync queue at the documented cadence", () => { expect(source).toMatch( /WITHINGS_ACTIVITY_QUEUE\s*=\s*["']withings-activity-sync["']/, ); - expect(source).toMatch(/WITHINGS_ACTIVITY_CRON\s*=\s*["']0 \* \* \* \*["']/); + expect(source).toMatch( + /WITHINGS_ACTIVITY_CRON\s*=\s*["']0 \* \* \* \*["']/, + ); }); it("declares the sleep-sync queue at the offset cadence", () => { diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index 32c4f2b80..ecfb5fb91 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -107,7 +107,7 @@ export function decryptBackup(buf: Buffer, key: Buffer): string { return Buffer.concat([dec.update(ct), dec.final()]).toString("utf8"); } -interface S3Like { +export interface S3Like { putObject(key: string, body: Buffer | Uint8Array): Promise; getObject(key: string): Promise; headObject(key: string): Promise; diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index c671e70b9..362b8debe 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -7,24 +7,6 @@ * custom server setup. In dev, use: npx tsx src/lib/jobs/reminder-worker.ts */ import { PgBoss } from "pg-boss"; -import type { Job } from "pg-boss"; -import { PrismaClient } from "@/generated/prisma/client"; -import { PrismaPg } from "@prisma/adapter-pg"; -import { dispatchNotification } from "@/lib/notifications/dispatcher"; -import { buildCycleBackupSection } from "@/lib/cycle/backup"; -import { - buildCanonicalSchedule, - buildRecurrenceContext, - scheduleEmitsInWindow, - shouldMintMissedDoseRow, -} from "@/lib/medications/scheduling/worker-helpers"; -import { syncUserMeasurements } from "@/lib/withings/sync"; -import { syncUserActivity } from "@/lib/withings/sync-activity"; -import { syncUserSleep } from "@/lib/withings/sync-sleep"; -import { syncUserRecovery } from "@/lib/whoop/sync-recovery"; -import { syncUserSleep as syncWhoopSleep } from "@/lib/whoop/sync-sleep"; -import { syncUserCycle } from "@/lib/whoop/sync-cycle"; -import { syncUserWorkout } from "@/lib/whoop/sync-workout"; import { WHOOP_BACKFILL_QUEUE, WHOOP_BACKFILL_CONCURRENCY, @@ -32,11 +14,6 @@ import { enqueueBootTimeWhoopBackfill, type WhoopBackfillPayload, } from "@/lib/jobs/whoop-backfill"; -import { - cleanupExpiredWhoopConnectTickets, - cleanupExpiredWhoopOAuthStates, -} from "@/lib/jobs/whoop-oauth-state-cleanup"; -import { runFitbitPollCohort } from "@/lib/fitbit/sync"; import { FITBIT_BACKFILL_QUEUE, FITBIT_BACKFILL_CONCURRENCY, @@ -44,41 +21,11 @@ import { enqueueBootTimeFitbitBackfill, type FitbitBackfillPayload, } from "@/lib/jobs/fitbit-backfill"; -import { cleanupExpiredFitbitOAuthStates } from "@/lib/jobs/fitbit-oauth-state-cleanup"; -import { generateGeneralStatusForUser } from "@/lib/insights/general-status"; -import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status"; -import { generateWeightStatusForUser } from "@/lib/insights/weight-status"; -import { generatePulseStatusForUser } from "@/lib/insights/pulse-status"; -import { generateBmiStatusForUser } from "@/lib/insights/bmi-status"; -import { generateMoodStatusForUser } from "@/lib/insights/mood-status"; -import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status"; -import { findStatusCronCandidates } from "@/lib/jobs/status-cron-candidates"; import { reportWorkerError } from "@/lib/jobs/report-worker-error"; -import { - markWorkerStarted, - recordReminderCheck, - recordWithingsSync, - recordInsightsRun, - recordError, -} from "@/lib/jobs/worker-status"; +import { markWorkerStarted, recordError } from "@/lib/jobs/worker-status"; import { setGlobalBoss } from "@/lib/jobs/boss-instance"; -import { cleanupExpiredIdempotencyKeys } from "@/lib/jobs/idempotency-cleanup"; -import { cleanupOldAuditLogs } from "@/lib/jobs/audit-log-cleanup"; -import { cleanupExpiredWithingsOAuthStates } from "@/lib/jobs/withings-oauth-state-cleanup"; +import { GEO_BACKFILL_QUEUE, GEO_BACKFILL_CRON } from "@/lib/jobs/geo-backfill"; import { - cleanupExpiredMeasurementTombstones, - cleanupExpiredMoodTombstones, - cleanupExpiredIntakeTombstones, -} from "@/lib/jobs/measurement-tombstone-cleanup"; -import { runHostMetricTick } from "@/lib/jobs/host-metric-sampler"; -import { aggregateRecommendationFeedback } from "@/lib/jobs/feedback-aggregator"; -import { - runGeoBackfill, - GEO_BACKFILL_QUEUE, - GEO_BACKFILL_CRON, -} from "@/lib/jobs/geo-backfill"; -import { - runTlsPinMonitor, TLS_PIN_MONITOR_QUEUE, TLS_PIN_MONITOR_CRON, } from "@/lib/jobs/tls-pin-monitor"; @@ -88,7 +35,6 @@ import { PR_DETECTION_FALLBACK_CRON, type PrDetectionPayload, } from "@/lib/jobs/pr-detection"; -import { detectPersonalRecordsForUser } from "@/lib/personal-records/pr-detection-worker"; import { MEDICATION_INVENTORY_EXPIRE_QUEUE, MEDICATION_INVENTORY_EXPIRE_CRON, @@ -97,8 +43,6 @@ import { import { INSIGHT_PREGENERATE_QUEUE, INSIGHT_PREGENERATE_CRON, - runInsightPregenerate, - forceWarmUser, type InsightPregeneratePayload, } from "@/lib/jobs/insight-pregenerate"; import { @@ -145,13 +89,11 @@ import { runDenseIntradayRetention } from "@/lib/measurements/dense-intraday-ret import { INSIGHT_STATUS_GENERATE_QUEUE, INSIGHT_STATUS_GENERATE_CONCURRENCY, - runInsightStatusGenerate, type InsightStatusGeneratePayload, } from "@/lib/jobs/insight-status-generate"; import { INTAKE_AUTO_SKIP_QUEUE, INTAKE_AUTO_SKIP_CRON, - runIntakeAutoSkipPass, type IntakeAutoSkipPayload, } from "@/lib/jobs/intake-auto-skip"; import { @@ -184,7 +126,6 @@ import { import { MEDICATION_COMPLIANCE_BACKFILL_QUEUE, MEDICATION_COMPLIANCE_BACKFILL_CONCURRENCY, - recomputeMedicationComplianceForEvent, recomputeUserMedicationCompliance, enqueueBootTimeMedicationComplianceBackfill, type MedicationComplianceBackfillPayload, @@ -218,56 +159,112 @@ import { enqueueBootTimeIntakeSlotDedup, type IntakeSlotDedupPayload, } from "@/lib/medications/intake-slot-dedup"; -import { expireStaleInUseItems } from "@/lib/medications/inventory/service"; import { rotateLegacyMoodLogSecrets } from "@/lib/moodlog-secret"; import { probeIntegrationStatusNullBuckets } from "@/lib/jobs/integration-status-null-probe"; -import { deleteMessage } from "@/lib/telegram"; -import { decrypt, encrypt } from "@/lib/crypto"; -import { syncMoodLogEntries } from "@/lib/moodlog/sync"; -import { - DEFAULT_PHASE_CONFIG, - resolvePhaseThresholds, - determinePhase, - getPhaseMessage, - getPhaseKeyboard, -} from "@/lib/jobs/reminder-phases"; -import { runMoodReminderTick } from "@/lib/jobs/mood-reminder"; -import { runCycleReminderTick } from "@/lib/jobs/cycle-reminder"; -import { isMedicationReminderClientManaged } from "@/lib/validations/notification-prefs"; import { withBackgroundEvent } from "@/lib/logging/background"; import { assertSubsystemEnabled } from "@/lib/process-type"; -import { runOffhostBackup } from "@/lib/jobs/offhost-backup"; import { - getUserTodayBounds as getUserTodayBoundsUtil, - localHmAsUtc, -} from "@/lib/timezone"; - -function parseTimeToMinutes(value: string): number { - const [h, m] = value.split(":").map(Number); - if (!Number.isFinite(h) || !Number.isFinite(m)) return 0; - // Some Node ICU builds render midnight as "24:00" via toLocaleTimeString. - // Normalize so comparisons against schedule windows that wrap midnight - // don't produce a 1440-minute value. - const hours = h === 24 ? 0 : h; - return hours * 60 + m; -} - -const DATABASE_URL = process.env.DATABASE_URL!; - -// Reuse a single PrismaClient across all job handlers to avoid connection pool exhaustion -let workerPrisma: PrismaClient | null = null; - -function getWorkerPrisma(): PrismaClient { - if (!workerPrisma) { - const adapter = new PrismaPg({ connectionString: DATABASE_URL }); - workerPrisma = new PrismaClient({ adapter }); - } - return workerPrisma; -} + handleRestoreDrill, + RESTORE_DRILL_CRON, + RESTORE_DRILL_QUEUE, +} from "@/lib/jobs/restore-drill"; +import { DATABASE_URL, getWorkerPrisma, workerLog } from "./reminder/shared"; +import { + ReminderCheckPayload, + handleReminderCheck, +} from "./reminder/medication-reminder-check"; +import { + WithingsSyncPayload, + WithingsActivitySyncPayload, + WithingsSleepSyncPayload, + handleWithingsFallbackSync, + handleWithingsActivitySync, + handleWithingsSleepSync, +} from "./reminder/withings-sync"; +import { + WhoopSyncPayload, + handleWhoopRecoverySync, + handleWhoopSleepSync, + handleWhoopWorkoutSync, + handleWhoopCycleSync, +} from "./reminder/whoop-sync"; +import { + GeneralStatusPayload, + BloodPressureStatusPayload, + WeightStatusPayload, + PulseStatusPayload, + BmiStatusPayload, + MoodStatusPayload, + MedicationComplianceStatusPayload, + handleGeneralStatusGenerate, + handleBloodPressureStatusGenerate, + handleWeightStatusGenerate, + handlePulseStatusGenerate, + handleBmiStatusGenerate, + handleMoodStatusGenerate, + handleMedicationComplianceStatusGenerate, + handleInsightPregenerateJob, + handleInsightStatusGenerate, +} from "./reminder/insights-handlers"; +import { MoodLogSyncPayload, handleMoodLogSync } from "./reminder/moodlog-sync"; +import { + DataBackupPayload, + OffhostBackupPayload, + handleOffhostBackup, + handleDataBackup, +} from "./reminder/backup-handlers"; +import { + RateLimitCleanupPayload, + IdempotencyCleanupPayload, + AuditLogCleanupPayload, + WithingsOAuthStateCleanupPayload, + MoodReminderCleanupPayload, + handleMoodReminderCleanup, + PushAttemptCleanupPayload, + handlePushAttemptCleanup, + MeasurementTombstoneCleanupPayload, + handleMeasurementTombstoneCleanup, + handleRateLimitCleanup, + handleIdempotencyCleanup, + handleAuditLogCleanup, + handleWithingsOAuthStateCleanup, + WhoopOAuthStateCleanupPayload, + handleWhoopOAuthStateCleanup, +} from "./reminder/cleanup-handlers"; +import { + MoodReminderPayload, + CycleReminderPayload, + handleMoodReminderCheck, + handleCycleReminderCheck, +} from "./reminder/mood-cycle-checks"; +import { + HostMetricSamplePayload, + FeedbackAggregatorPayload, + GeoBackfillPayload, + TlsPinMonitorPayload, + handleHostMetricSample, + handleFeedbackAggregator, + handleGeoBackfill, + handleTlsPinMonitor, + handlePrDetection, +} from "./reminder/ops-handlers"; +import { + FitbitSyncPayload, + handleFitbitSync, + FitbitOAuthStateCleanupPayload, + handleFitbitOAuthStateCleanup, +} from "./reminder/fitbit-sync"; +import { + handleMedicationInventoryExpire, + handleIntakeAutoSkip, +} from "./reminder/medication-maintenance"; const QUEUE_NAME = "medication-reminder-check"; + const CHECK_INTERVAL_CRON = "*/15 * * * *"; // every 15 minutes + const WITHINGS_SYNC_QUEUE = "withings-fallback-sync"; + const WITHINGS_SYNC_CRON = "0 * * * *"; // every 60 minutes // v1.4.25 W17b/c — webhook-primary + cron-safety-net for activity and // sleep v2. The webhook handler enqueues per-user jobs on appli=16 / 44 @@ -275,37 +272,65 @@ const WITHINGS_SYNC_CRON = "0 * * * *"; // every 60 minutes // notifications Withings drops. Offset 15-minute cadence per the // research recommendation so the two queues don't lockstep against the // existing measure cron at :00. + const WITHINGS_ACTIVITY_QUEUE = "withings-activity-sync"; + const WITHINGS_ACTIVITY_CRON = "0 * * * *"; // every hour at :00 + const WITHINGS_SLEEP_QUEUE = "withings-sleep-sync"; + const WITHINGS_SLEEP_CRON = "15 * * * *"; // every hour at :15 + const GENERAL_STATUS_QUEUE = "insights-general-status"; + const GENERAL_STATUS_CRON = "0 2 * * *"; // daily at 02:00 + const BLOOD_PRESSURE_STATUS_QUEUE = "insights-blood-pressure-status"; + const BLOOD_PRESSURE_STATUS_CRON = "5 2 * * *"; // daily at 02:05 + const WEIGHT_STATUS_QUEUE = "insights-weight-status"; + const WEIGHT_STATUS_CRON = "10 2 * * *"; // daily at 02:10 + const PULSE_STATUS_QUEUE = "insights-pulse-status"; + const PULSE_STATUS_CRON = "15 2 * * *"; // daily at 02:15 + const BMI_STATUS_QUEUE = "insights-bmi-status"; + const BMI_STATUS_CRON = "20 2 * * *"; // daily at 02:20 // v1.15.20 — mood joins the nightly per-metric status ladder. Same gate + // discovery as the six older crons (see status-cron-candidates.ts); 02:30 // continues the 5-minute stagger after BMI (02:20) and compliance (02:25). + const MOOD_STATUS_QUEUE = "insights-mood-status"; + const MOOD_STATUS_CRON = "30 2 * * *"; // daily at 02:30 + const MEDICATION_COMPLIANCE_STATUS_QUEUE = "insights-medication-compliance-status"; + const MEDICATION_COMPLIANCE_STATUS_CRON = "25 2 * * *"; // daily at 02:25 + const MOODLOG_SYNC_QUEUE = "moodlog-sync"; + const MOODLOG_SYNC_CRON = "30 * * * *"; // every hour at :30 + const DATA_BACKUP_QUEUE = "data-backup"; + const DATA_BACKUP_CRON = "0 3 * * 0"; // weekly Sunday at 03:00 + const RATE_LIMIT_CLEANUP_QUEUE = "rate-limit-cleanup"; + const RATE_LIMIT_CLEANUP_CRON = "*/5 * * * *"; // every 5 minutes + const IDEMPOTENCY_CLEANUP_QUEUE = "idempotency-cleanup"; + const IDEMPOTENCY_CLEANUP_CRON = "0 3 * * *"; // daily at 03:00 (Europe/Berlin) + const AUDIT_LOG_CLEANUP_QUEUE = "audit-log-cleanup"; + const AUDIT_LOG_CLEANUP_CRON = "15 3 * * *"; // daily at 03:15 (Europe/Berlin) // v1.4.47 W6 — daily sweep for the Withings OAuth state ledger. Slots // at :20 between the audit-log cleanup (:15) and the mood-reminder @@ -313,7 +338,9 @@ const AUDIT_LOG_CLEANUP_CRON = "15 3 * * *"; // daily at 03:15 (Europe/Berlin) // Rows are normally consumed by the callback handler in single-use // fashion; this sweep picks up the long tail where a user closed the // Withings approval tab without bouncing back to the callback URL. + const WITHINGS_OAUTH_STATE_CLEANUP_QUEUE = "withings-oauth-state-cleanup"; + const WITHINGS_OAUTH_STATE_CLEANUP_CRON = "20 3 * * *"; // v1.11.0 — WHOOP sync queues. Webhook-primary + cron-safety-net, mirroring // the Withings activity/sleep crons. Recovery / sleep / workout each have a @@ -321,28 +348,42 @@ const WITHINGS_OAUTH_STATE_CLEANUP_CRON = "20 3 * * *"; // crons below are the catch-net for dropped deliveries. Cycle has NO webhook, // so its cron is the only driver. Minutes are staggered off the Withings crons // (:00/:15) to spread DB load. + const WHOOP_RECOVERY_SYNC_QUEUE = "whoop-recovery-sync"; + const WHOOP_RECOVERY_SYNC_CRON = "5 * * * *"; // every hour at :05 + const WHOOP_SLEEP_SYNC_QUEUE = "whoop-sleep-sync"; + const WHOOP_SLEEP_SYNC_CRON = "20 * * * *"; // every hour at :20 + const WHOOP_WORKOUT_SYNC_QUEUE = "whoop-workout-sync"; + const WHOOP_WORKOUT_SYNC_CRON = "35 * * * *"; // every hour at :35 + const WHOOP_CYCLE_SYNC_QUEUE = "whoop-cycle-sync"; + const WHOOP_CYCLE_SYNC_CRON = "50 * * * *"; // every hour at :50 (poll-only) // v1.11.0 — daily sweep for the WHOOP OAuth state ledger. Slots at 03:22, // next to the Withings sweep (03:20), inside the maintenance window. + const WHOOP_OAUTH_STATE_CLEANUP_QUEUE = "whoop-oauth-state-cleanup"; + const WHOOP_OAUTH_STATE_CLEANUP_CRON = "22 3 * * *"; // v1.12.0 — Fitbit / Google Health poll-only sync. There is no Fitbit webhook // at launch (Pub/Sub deferred), so a single hourly cron drives the per-user // `syncUserFitbit` driver across every connection. Minute staggered off the // WHOOP slots (:05/:20/:35/:50) and the Withings slots (:00/:15) so the hourly // ticks don't pile up on one boss poll. + const FITBIT_SYNC_QUEUE = "fitbit-sync"; + const FITBIT_SYNC_CRON = "8 * * * *"; // every hour at :08 // v1.12.0 — daily sweep for the Fitbit OAuth state ledger. Slots at 03:24, next // to the WHOOP sweep (03:22), inside the maintenance window. + const FITBIT_OAUTH_STATE_CLEANUP_QUEUE = "fitbit-oauth-state-cleanup"; + const FITBIT_OAUTH_STATE_CLEANUP_CRON = "24 3 * * *"; // v1.15.19 — daily duplicate dose-slot dedup discovery tick. The boot-time // pass only ran on worker restart, so a cross-source duplicate slot created @@ -352,20 +393,27 @@ const FITBIT_OAUTH_STATE_CLEANUP_CRON = "24 3 * * *"; // per-user job (singletonKey-coalesced) exactly like the boot pass. Slots at // 03:28 between the mood-reminder cleanup (03:25) and the inventory expire // (03:30), inside the maintenance window. + const INTAKE_SLOT_DEDUP_CRON = "28 3 * * *"; + const OFFHOST_BACKUP_QUEUE = "data-backup-offhost"; // 02:30 Europe/Berlin — runs after audit-log/idempotency cleanups so old // rows are gone before they're snapshotted, but before the existing // in-DB DATA_BACKUP at 03:00 (Sundays only) so the off-host copy is // always at-or-ahead of the local one. + const OFFHOST_BACKUP_CRON = "30 2 * * *"; + const HOST_METRIC_QUEUE = "host-metric-sample"; // Per-minute cadence — matches the chart's 60s polling refetchInterval. + const HOST_METRIC_CRON = "* * * * *"; // v1.4.16 phase B5e — daily rec-feedback aggregator. 04:00 Europe/Berlin // runs the slot AFTER all the cleanup jobs (rate-limit, idempotency, // audit-log) so the previous-day's noise is gone before we aggregate. + const FEEDBACK_AGGREGATOR_QUEUE = "feedback-aggregator"; + const FEEDBACK_AGGREGATOR_CRON = "0 4 * * *"; // v1.4.37 — hourly geo backfill. Queue name + cron expression live // in `@/lib/jobs/geo-backfill` so a unit test can pin the scheduling @@ -378,7 +426,9 @@ const FEEDBACK_AGGREGATOR_CRON = "0 4 * * *"; // 36-hour grace window keeps today + the trailing watch-sync window // intact for real-time visibility; only completed-and-stable days // fall to the drain. + const DRAIN_CUMULATIVE_QUEUE = "drain-per-sample-cumulative"; + const DRAIN_CUMULATIVE_CRON = "45 3 * * *"; // v0.5.4 ios-coord — daily mood-reminder cron. // @@ -389,33 +439,26 @@ const DRAIN_CUMULATIVE_CRON = "45 3 * * *"; // ~4 ticks-per-hour × 1 actual-dispatch-window-per-user = at most one // push per user per day. Idempotency is enforced by the // `MoodReminderDispatch` ledger inside the handler. + const MOOD_REMINDER_QUEUE = "mood-reminder-check"; + const MOOD_REMINDER_CRON = "*/15 * * * *"; // v1.4.38.2 — daily retention sweep for the mood-reminder dispatch // ledger. Rows older than 90 days are behavioural footprints of // mood-log gaps; we keep them long enough to debug a duplicate-push // report (~one billing cycle) but no longer. Slots between the // audit-log cleanup (03:15) and the drain (03:45). + const MOOD_REMINDER_CLEANUP_QUEUE = "mood-reminder-cleanup"; + const MOOD_REMINDER_CLEANUP_CRON = "25 3 * * *"; -const MOOD_REMINDER_RETENTION_DAYS = 90; -// v1.4.49 — daily prune for the push-attempt ledger. Same 90-day -// retention as the mood-reminder dispatch ledger; both surfaces are -// behavioural footprints we keep long enough to debug a duplicate-push -// report (~one billing cycle) but no longer. Slots at 03:35 between -// mood-reminder cleanup (03:25) and drain-cumulative (03:45) so the -// 03:xx maintenance window stays ordered. + const PUSH_ATTEMPT_CLEANUP_QUEUE = "push-attempt-cleanup"; + const PUSH_ATTEMPT_CLEANUP_CRON = "35 3 * * *"; -const PUSH_ATTEMPT_RETENTION_DAYS = 90; -// v1.7.0 — daily prune for soft-deleted measurement tombstones. Rows -// whose `deletedAt` predates the refresh-token lifetime + margin are -// hard-deleted (a device offline that long re-pairs with a full backfill, -// not an incremental delta, so it never relies on the tombstone). -// Retention lives on the helper module keyed to the refresh lifetime so -// the two never drift. Slots at 03:40 between push-attempt cleanup (03:35) -// and the drain (03:45) inside the existing 03:xx maintenance window. + const MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE = "measurement-tombstone-cleanup"; + const MEASUREMENT_TOMBSTONE_CLEANUP_CRON = "40 3 * * *"; // v1.15 — daily cycle reminder cron (period-soon + period-start-confirm). // @@ -425,1777 +468,19 @@ const MEASUREMENT_TOMBSTONE_CLEANUP_CRON = "40 3 * * *"; // timezone crossing that hour without one cron entry per zone. At most one // push per event per user per local day — the `push_attempts` ledger is the // idempotency anchor inside the handler. + const CYCLE_REMINDER_QUEUE = "cycle-reminder-check"; + const CYCLE_REMINDER_CRON = "*/15 * * * *"; // v1.4.38 — the per-sample cutoff hours constant now lives on the // helper module so the worker, the admin route, and the CLI all read // the same source of truth. Re-export pulled in alongside // `drainPerSampleCumulative` above. + interface DrainCumulativePayload { triggeredAt: string; } -interface ReminderCheckPayload { - triggeredAt: string; -} - -interface WithingsSyncPayload { - triggeredAt: string; -} - -/** - * v1.4.25 W17b — payload for the activity-sync queue. When enqueued - * by the webhook handler, `userId` is set so the worker syncs only - * that user; when enqueued by the cron schedule, `userId` is absent - * and the worker iterates every connection (safety-net behaviour). - */ -interface WithingsActivitySyncPayload { - triggeredAt: string; - userId?: string; -} - -/** - * v1.4.25 W17c — payload for the sleep-sync queue. Same shape and - * webhook-vs-cron semantics as the activity payload. - */ -interface WithingsSleepSyncPayload { - triggeredAt: string; - userId?: string; -} - -interface GeneralStatusPayload { - triggeredAt: string; -} - -interface BloodPressureStatusPayload { - triggeredAt: string; -} - -interface WeightStatusPayload { - triggeredAt: string; -} - -interface PulseStatusPayload { - triggeredAt: string; -} - -interface BmiStatusPayload { - triggeredAt: string; -} - -interface MoodStatusPayload { - triggeredAt?: string; -} - -interface MedicationComplianceStatusPayload { - triggeredAt: string; -} - -interface MoodLogSyncPayload { - triggeredAt: string; -} - -interface DataBackupPayload { - triggeredAt: string; -} - -interface RateLimitCleanupPayload { - triggeredAt: string; -} - -interface IdempotencyCleanupPayload { - triggeredAt: string; -} - -interface AuditLogCleanupPayload { - triggeredAt: string; -} - -interface WithingsOAuthStateCleanupPayload { - triggeredAt: string; -} - -interface OffhostBackupPayload { - triggeredAt: string; -} - -interface HostMetricSamplePayload { - triggeredAt: string; -} - -interface FeedbackAggregatorPayload { - triggeredAt: string; -} - -interface GeoBackfillPayload { - triggeredAt: string; -} - -interface MoodReminderPayload { - triggeredAt: string; -} - -interface CycleReminderPayload { - triggeredAt: string; -} - -interface TlsPinMonitorPayload { - triggeredAt: string; -} - -// Re-export timezone utilities under local names for backward compatibility -const getUserTodayBounds = getUserTodayBoundsUtil; - -/** - * Process expired TelegramScheduledDeletion records. - * Deletes messages from Telegram and removes the DB records. - * Called at the start of every reminder check (every 15 minutes). - */ -async function cleanupScheduledTelegramDeletions(): Promise { - const prisma = getWorkerPrisma(); - try { - let totalDeleted = 0; - - // Process in batches until all expired records are handled - while (true) { - const expired = await prisma.telegramScheduledDeletion.findMany({ - where: { deleteAfter: { lte: new Date() } }, - take: 100, - }); - - if (expired.length === 0) break; - - // Group by userId to fetch bot token once per user - const byUser = new Map< - string, - { chatId: string; messageId: number; id: string }[] - >(); - for (const record of expired) { - const list = byUser.get(record.userId) ?? []; - list.push({ - chatId: record.chatId, - messageId: record.messageId, - id: record.id, - }); - byUser.set(record.userId, list); - } - - const deletedIds: string[] = []; - for (const [userId, messages] of byUser) { - const user = await prisma.user.findFirst({ - where: { id: userId, telegramBotToken: { not: null } }, - select: { telegramBotToken: true }, - }); - if (!user?.telegramBotToken) { - // No bot token — just clean up the records - deletedIds.push(...messages.map((m) => m.id)); - continue; - } - const botToken = decrypt(user.telegramBotToken); - for (const msg of messages) { - try { - await deleteMessage(botToken, msg.chatId, msg.messageId); - } catch { - // Best-effort: message may already be deleted - } - deletedIds.push(msg.id); - } - } - - if (deletedIds.length > 0) { - await prisma.telegramScheduledDeletion.deleteMany({ - where: { id: { in: deletedIds } }, - }); - totalDeleted += deletedIds.length; - } - } - - if (totalDeleted > 0) { - const { getEvent } = await import("@/lib/logging/context"); - getEvent()?.addMeta("telegram_scheduled_cleanup", totalDeleted); - } - } catch (err) { - const { getEvent } = await import("@/lib/logging/context"); - getEvent()?.addWarning(`telegram-scheduled-cleanup failed: ${err}`); - } -} - -/** - * Check all active medications for each user and determine reminder phases. - * Uses phase-based logic (GREEN/YELLOW/ORANGE/RED) to send one notification - * per phase transition rather than every 15 minutes. - */ -async function handleReminderCheck(jobs: Job[]) { - void jobs; - await withBackgroundEvent("job.medication_reminder", async (evt) => { - const prisma = getWorkerPrisma(); - try { - recordReminderCheck(); - const now = new Date(); - - // Clean up expired scheduled Telegram message deletions - await cleanupScheduledTelegramDeletions(); - - // Clean up expired snoozes - await prisma.medication.updateMany({ - where: { snoozedUntil: { lt: now } }, - data: { snoozedUntil: null }, - }); - - // Get all active medications with schedules and phase config - const medications = await prisma.medication.findMany({ - where: { active: true }, - include: { - schedules: true, - phaseConfig: true, - user: { - select: { - id: true, - timezone: true, - // Used to localise the reminder title / message / keyboard - // labels per user. Null falls back to the app default. - locale: true, - // v1.4.49 M-DOUBLE-REMINDER — read the per-user prefs - // blob so the dispatch step can skip APNs sends for users - // whose iOS client has opted in to local SpeziScheduler - // reminders. Null = legacy default (clientManaged: false), - // i.e. the server reminder fires as before. - notificationPrefs: true, - }, - }, - }, - }); - - for (const med of medications) { - const userTz = med.user.timezone || "Europe/Berlin"; - const { start: todayStart, end: todayEnd } = getUserTodayBounds( - now, - userTz, - ); - - const currentTime = now.toLocaleTimeString("en-GB", { - timeZone: userTz, - hour: "2-digit", - minute: "2-digit", - hour12: false, - }); - - // Get today's date string in user's timezone for message tracking - const localDateStr = now.toLocaleDateString("sv-SE", { - timeZone: userTz, - }); // YYYY-MM-DD format - - // v1.7.0 code-correctness M4 — fetch today's intake events so a - // logged dose suppresses the reminder for the SLOT it belongs to, - // not a positional running counter. The pre-v1.7 code suppressed - // by `eventCount > schedulesProcessed`, which attributed a logged - // morning dose to whichever slot iterated first; with an unsorted - // `timesOfDay = ["20:00","08:00"]` that suppressed the evening - // reminder while the morning still fired. We match by time-of-day - // proximity instead. Worker-minted RED placeholders (takenAt null, - // not skipped, source REMINDER) are NOT a user action, so they are - // excluded from the suppression set. - const todayEvents = await prisma.medicationIntakeEvent.findMany({ - where: { - medicationId: med.id, - userId: med.user.id, - // v1.7.0 sync — a tombstoned dose is no longer a logged - // action, so it must not suppress today's reminder. - deletedAt: null, - scheduledFor: { gte: todayStart, lte: todayEnd }, - }, - select: { scheduledFor: true, takenAt: true, skipped: true }, - }); - const loggedDoseInstants = todayEvents - .filter((e) => e.takenAt !== null || e.skipped) - .map((e) => (e.takenAt ?? e.scheduledFor).getTime()); - - // Resolve phase configuration - const phaseConfig = med.phaseConfig ?? DEFAULT_PHASE_CONFIG; - - // Slots a logged dose has already claimed (by index into the - // chronologically-sorted slotTimes) so one dose can't suppress two. - const claimedSlotInstants = new Set(); - - const sortedSchedules = [...med.schedules].sort((a, b) => - a.windowStart.localeCompare(b.windowStart), - ); - - // v1.5.0 — fetch the latest `takenAt` for this medication - // once per tick when any schedule on it is rolling. The - // canonical recurrence engine needs `lastIntakeAt` to compute - // the next-due instant for rolling cadences; calendar - // cadences ignore the field, so the fetch is conditional to - // avoid an extra round-trip on the common path. Failures bias - // toward "treat as never logged" so a flaky DB doesn't - // suppress reminders. - const hasRollingSchedule = med.schedules.some( - (s) => s.rollingIntervalDays !== null, - ); - let lastIntakeAt: Date | null = null; - if (hasRollingSchedule) { - const lastIntake = await prisma.medicationIntakeEvent.findFirst({ - where: { - userId: med.user.id, - medicationId: med.id, - // v1.7.0 sync — a tombstoned intake no longer anchors the - // rolling-interval next-due computation. - deletedAt: null, - takenAt: { not: null }, - }, - orderBy: { takenAt: "desc" }, - select: { takenAt: true }, - }); - lastIntakeAt = lastIntake?.takenAt ?? null; - } - - for (const schedule of sortedSchedules) { - // v1.5.0 — route every "does today emit a slot?" decision - // through the canonical recurrence engine. Replaces the - // legacy weekday-only filter that silently ignored - // `intervalWeeks` (the pre-v1.5 bi-weekly bug — a Wed - // bi-weekly schedule fired every Wed instead of every other - // Wed). The engine honours RRULE / rolling / one-shot / - // legacy-with-interval-weeks / `endsOn` cap; no special- - // casing here. - const canonicalSchedule = buildCanonicalSchedule(schedule); - const recurrenceCtx = buildRecurrenceContext({ - medication: med, - userTz, - lastIntakeAt, - }); - if ( - !scheduleEmitsInWindow( - canonicalSchedule, - recurrenceCtx, - todayStart, - todayEnd, - ) - ) { - continue; - } - - // v1.7.0 SB-SCHED-4 — multi-time-of-day dispatch. A schedule - // with `timesOfDay = ["08:00","20:00"]` is two distinct dose - // slots per day; the pre-v1.7 worker keyed phase + dedup on the - // single `windowStart`, so the evening dose never reminded. - // Iterate every first-class time-of-day, each with its own - // window (anchored at the time, spanning the legacy - // `windowEnd - windowStart` duration), phase, dedup key - // (now including the time-of-day), and RED-mint instant. - // - // The legacy single-window contract is preserved: a schedule - // with no first-class `timesOfDay` emits exactly one slot at - // `windowStart` with `timeOfDay = ""`, which dedupes against - // pre-v1.7 rows (backfilled to "") byte-for-byte. - const baseStartMins = parseTimeToMinutes(schedule.windowStart); - const baseEndMins = parseTimeToMinutes(schedule.windowEnd); - const windowDuration = baseEndMins - baseStartMins; - const currentMins = parseTimeToMinutes(currentTime); - - const hasFirstClassTimes = - schedule.timesOfDay && schedule.timesOfDay.length > 0; - // v1.7.0 code-correctness M4 — iterate slots in chronological - // order so phase/dedup/suppression decisions are deterministic - // and a logged dose maps to the nearest slot, not whichever the - // stored array order happened to surface first. - const slotTimes = ( - hasFirstClassTimes ? [...schedule.timesOfDay] : [schedule.windowStart] - ).sort((a, b) => parseTimeToMinutes(a) - parseTimeToMinutes(b)); - - for (const slotTime of slotTimes) { - // Dedup key time-of-day: "" for a legacy single-window - // schedule (byte-stable against pre-v1.7 rows), else the - // explicit HH:mm. - const dedupTimeOfDay = hasFirstClassTimes ? slotTime : ""; - const slotStartMins = parseTimeToMinutes(slotTime); - const slotEndMins = slotStartMins + windowDuration; - const minutesToEnd = slotEndMins - currentMins; - const minutesFromStart = currentMins - slotStartMins; - - // The UTC instant this slot is due (DST-safe). - const [slotH, slotM] = slotTime.split(":").map(Number); - const slotInstant = localHmAsUtc( - now, - med.user.timezone, - slotH, - slotM, - ).getTime(); - - // Suppress this slot's reminder if a user-logged dose (taken - // or skipped) sits within half the window duration of the - // slot's due time. Matching by proximity — not a positional - // counter — means a partially-dosed day still reminds for the - // correct missing slot. Each logged dose claims at most one - // slot so two slots can't both be suppressed by one dose. - const matchRadiusMs = - Math.max(windowDuration, 60) * 60_000 * 0.5; - let matchedIdx = -1; - let matchedDist = Infinity; - for (let li = 0; li < loggedDoseInstants.length; li++) { - if (claimedSlotInstants.has(li)) continue; - const dist = Math.abs(loggedDoseInstants[li] - slotInstant); - if (dist <= matchRadiusMs && dist < matchedDist) { - matchedDist = dist; - matchedIdx = li; - } - } - if (matchedIdx >= 0) { - claimedSlotInstants.add(matchedIdx); - continue; - } - - // Skip if medication is snoozed - if (med.snoozedUntil && now < med.snoozedUntil) { - continue; - } - - // Resolve phase thresholds - const thresholds = resolvePhaseThresholds( - phaseConfig, - windowDuration, - ); - - // Determine current phase for this slot's window. - const currentPhase = determinePhase( - minutesToEnd, - minutesFromStart, - thresholds, - ); - - if (!currentPhase) { - continue; - } - - // Check if this phase was already notified today for this - // time-of-day. - const existingMessage = - await prisma.telegramReminderMessage.findUnique({ - where: { - medicationId_scheduleId_date_phase_timeOfDay: { - medicationId: med.id, - scheduleId: schedule.id, - date: localDateStr, - phase: currentPhase, - timeOfDay: dedupTimeOfDay, - }, - }, - }); - - if (existingMessage) { - // Already sent for this phase + time-of-day — skip - continue; - } - - const doseInfo = schedule.dose ?? med.dose; - const timeWindow = `${slotTime}`; - - // DST-safe slot instant, computed once above. - const slotScheduledFor = new Date(slotInstant); - - // RED phase: create missed intake event for this slot. - if (currentPhase === "RED") { - // v1.8.2 — gate the missed-dose mint through the shared - // guard. It refuses to mint when the slot already carries an - // existing pending REMINDER row (P2002-collision avoidance, - // tombstones included) OR an ACTIONED row (taken / skipped) - // from ANY source. The intake write paths snap a "Genommen" / - // "Übersprungen" write onto the canonical slot instant - // (source-agnostic update), so a user who acted before the - // RED phase opens has a live taken/skipped row at this exact - // slot — minting here would re-create the duplicate the - // write paths just collapsed. - const shouldMint = await shouldMintMissedDoseRow(prisma, { - userId: med.user.id, - medicationId: med.id, - scheduledFor: slotScheduledFor, - }); - - if (shouldMint) { - await prisma.medicationIntakeEvent.create({ - data: { - userId: med.user.id, - medicationId: med.id, - scheduledFor: slotScheduledFor, - takenAt: null, - skipped: false, - source: "REMINDER", - }, - }); - - evt.addMeta("missed_dose", `${med.name}:${slotTime}`); - - // v1.4.39 W-MED — refresh the compliance rollup so the - // per-day `scheduled` count increments before any read. - await recomputeMedicationComplianceForEvent({ - userId: med.user.id, - medicationId: med.id, - scheduledFor: slotScheduledFor, - tz: med.user.timezone, - }); - } - } - - // Send notification if enabled - if (med.notificationsEnabled) { - // v1.4.49 M-DOUBLE-REMINDER — opt-in client-managed - // suppression. ONLY suppresses MEDICATION_REMINDER. - if ( - isMedicationReminderClientManaged(med.user.notificationPrefs) - ) { - const doseAtIso = slotScheduledFor.toISOString(); - evt.addMeta( - "medication_reminder_suppressed_client_managed", - `${med.name}:${slotTime}`, - ); - evt.addMeta("medication_reminder_suppressed_meta", { - user_id: med.user.id, - medication_id: med.id, - schedule_id: schedule.id, - phase: currentPhase, - dose_at: doseAtIso, - }); - continue; - } - - const { title, message } = getPhaseMessage( - currentPhase, - med.name, - doseInfo, - timeWindow, - minutesToEnd, - med.user.locale, - ); - - const keyboard = getPhaseKeyboard( - currentPhase, - med.id, - med.user.locale, - ); - - // v0.5.4 — surface the slot time as an ISO 8601 string so - // the iOS snooze action pins against the actual slot. - const scheduledAtIso = slotScheduledFor.toISOString(); - - evt.addMeta( - "notification_phase", - `${currentPhase}:${med.name}:${slotTime}`, - ); - - try { - await dispatchNotification({ - eventType: "MEDICATION_REMINDER", - userId: med.user.id, - title, - message, - metadata: { - medicationId: med.id, - scheduleId: schedule.id, - phase: currentPhase, - date: localDateStr, - // v1.7.0 SB-SCHED-4 — carry the dedup time-of-day so - // the Telegram-message ledger keys per slot. - timeOfDay: dedupTimeOfDay, - scheduledAt: scheduledAtIso, - replyMarkup: keyboard, - }, - }); - } catch (notifErr) { - evt.addWarning( - `Notification dispatch failed for ${currentPhase} phase ${med.name}: ${notifErr}`, - ); - } - } - } - } - } - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); -} - -/** - * Fallback polling for Withings data. - * Runs periodically in case webhook delivery is delayed or unavailable. - */ -async function handleWithingsFallbackSync(jobs: Job[]) { - void jobs; - await withBackgroundEvent("job.withings_sync", async (evt) => { - const prisma = getWorkerPrisma(); - try { - recordWithingsSync(); - const connections = await prisma.withingsConnection.findMany({ - select: { userId: true }, - }); - - if (connections.length === 0) { - return; - } - - let usersSynced = 0; - let measurementsImported = 0; - - for (const connection of connections) { - try { - const imported = await syncUserMeasurements(connection.userId); - usersSynced++; - measurementsImported += imported; - } catch (err) { - evt.addWarning( - `Fallback sync failed for user ${connection.userId}: ${err}`, - ); - } - } - - evt.setBackground({ - task_name: "job.withings_sync", - result: { - users_synced: usersSynced, - total: connections.length, - measurements_imported: measurementsImported, - }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); -} - -/** - * v1.4.25 W17b — activity-sync handler. - * - * Two enqueue paths feed this queue: - * - * 1. Webhook (appli=16) — payload carries `userId`, the handler - * runs `syncUserActivity` for that one user. - * 2. Cron (`withings-activity-sync` at :00 every hour) — payload - * has no `userId`, the handler iterates every Withings - * connection and re-syncs each. Catches the 1 % of webhook - * deliveries Withings drops. - * - * Sync failures per-user are logged as warnings; the queue carries on - * so one user's parked-at-reauth state doesn't starve every other - * connection on the cron tick. - */ -async function handleWithingsActivitySync( - jobs: Job[], -) { - await withBackgroundEvent("job.withings_activity_sync", async (evt) => { - const prisma = getWorkerPrisma(); - try { - const targets: Array<{ userId: string }> = []; - for (const job of jobs) { - if (job.data?.userId) { - targets.push({ userId: job.data.userId }); - } - } - // No user-specific enqueue → cron fallback iterating everyone. - if (targets.length === 0) { - const connections = await prisma.withingsConnection.findMany({ - select: { userId: true }, - }); - targets.push(...connections); - } - if (targets.length === 0) return; - - let usersSynced = 0; - let measurementsImported = 0; - for (const { userId } of targets) { - try { - const imported = await syncUserActivity(userId); - usersSynced++; - measurementsImported += imported; - } catch (err) { - evt.addWarning( - `Withings activity sync failed for user ${userId}: ${err}`, - ); - } - } - - evt.setBackground({ - task_name: "job.withings_activity_sync", - result: { - users_synced: usersSynced, - total: targets.length, - measurements_imported: measurementsImported, - }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); -} - -/** - * v1.4.25 W17c — sleep-sync handler. Same enqueue semantics as the - * activity handler: per-user when the webhook fires, full-iteration - * when the cron ticks. - */ -async function handleWithingsSleepSync(jobs: Job[]) { - await withBackgroundEvent("job.withings_sleep_sync", async (evt) => { - const prisma = getWorkerPrisma(); - try { - const targets: Array<{ userId: string }> = []; - for (const job of jobs) { - if (job.data?.userId) { - targets.push({ userId: job.data.userId }); - } - } - if (targets.length === 0) { - const connections = await prisma.withingsConnection.findMany({ - select: { userId: true }, - }); - targets.push(...connections); - } - if (targets.length === 0) return; - - let usersSynced = 0; - let measurementsImported = 0; - for (const { userId } of targets) { - try { - const imported = await syncUserSleep(userId); - usersSynced++; - measurementsImported += imported; - } catch (err) { - evt.addWarning( - `Withings sleep sync failed for user ${userId}: ${err}`, - ); - } - } - - evt.setBackground({ - task_name: "job.withings_sleep_sync", - result: { - users_synced: usersSynced, - total: targets.length, - measurements_imported: measurementsImported, - }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); -} - -/** - * v1.11.0 — WHOOP per-resource sync payload. Two enqueue paths feed each - * WHOOP sync queue: - * - * 1. Webhook (`recovery.updated` / `sleep.updated` / `workout.updated`) — - * payload carries `userId`, the handler syncs that one user. - * 2. Cron — payload has no `userId`; the handler iterates every WHOOP - * connection and re-syncs each, catching dropped webhook deliveries. - * Cycle has no webhook, so its cron is the sole driver. - */ -interface WhoopSyncPayload { - userId?: string; -} - -/** - * Shared driver for the per-resource WHOOP sync handlers. Resolves the target - * set (per-user from the webhook payload, or every connection on the cron - * tick) and runs `syncFn` per user. One user's parked-at-reauth state never - * starves the rest of the cohort on the cron path. - */ -async function runWhoopResourceSync( - taskName: string, - jobs: Job[], - syncFn: (userId: string) => Promise, -): Promise { - await withBackgroundEvent(taskName, async (evt) => { - const prisma = getWorkerPrisma(); - try { - const targets: Array<{ userId: string }> = []; - for (const job of jobs) { - if (job.data?.userId) targets.push({ userId: job.data.userId }); - } - if (targets.length === 0) { - const connections = await prisma.whoopConnection.findMany({ - select: { userId: true }, - }); - targets.push(...connections); - } - if (targets.length === 0) return; - - let usersSynced = 0; - let measurementsImported = 0; - for (const { userId } of targets) { - try { - measurementsImported += await syncFn(userId); - usersSynced++; - } catch (err) { - evt.addWarning(`${taskName} failed for user ${userId}: ${err}`); - } - } - - evt.setBackground({ - task_name: taskName, - result: { - users_synced: usersSynced, - total: targets.length, - measurements_imported: measurementsImported, - }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); -} - -function handleWhoopRecoverySync(jobs: Job[]) { - return runWhoopResourceSync("job.whoop_recovery_sync", jobs, syncUserRecovery); -} - -function handleWhoopSleepSync(jobs: Job[]) { - return runWhoopResourceSync("job.whoop_sleep_sync", jobs, syncWhoopSleep); -} - -function handleWhoopWorkoutSync(jobs: Job[]) { - return runWhoopResourceSync("job.whoop_workout_sync", jobs, syncUserWorkout); -} - -function handleWhoopCycleSync(jobs: Job[]) { - return runWhoopResourceSync("job.whoop_cycle_sync", jobs, syncUserCycle); -} - -/** - * Shared driver for the nightly 02:xx per-metric status crons. User - * discovery is centralised in `findStatusCronCandidates`, which applies - * the operator assistant kill-switch, the per-user `disableCoach` gate, - * and the pregenerate-candidate skip (users with a configured provider - * and a stale comprehensive cache belong to the 04:30 pre-generate pass, - * which re-warms every per-status cache anyway — see - * `status-cron-candidates.ts` for the full division of nightly labour). - * The generators normalise `locale` themselves (de stays de, everything - * else gets English prose). - */ -async function runStatusCronGenerate( - taskName: string, - generate: ( - userId: string, - options: { locale: string | null; force: boolean }, - ) => Promise, - options: { recordRun?: boolean } = {}, -): Promise { - await withBackgroundEvent(taskName, async (evt) => { - const prisma = getWorkerPrisma(); - try { - if (options.recordRun) recordInsightsRun(); - const users = await findStatusCronCandidates(prisma); - - if (users.length === 0) return; - - let generated = 0; - let failed = 0; - - for (const user of users) { - try { - await generate(user.id, { locale: user.locale, force: false }); - generated++; - } catch (error) { - failed++; - evt.addWarning( - `${taskName} generation failed for user ${user.id}: ${error}`, - ); - } - } - - evt.setBackground({ - task_name: taskName, - result: { generated, failed, total: users.length }, - }); - } catch (err) { - evt.setError(err); - recordError(); - await reportWorkerError(taskName, err); - throw err; - } - }); -} - -function handleGeneralStatusGenerate(jobs: Job[]) { - void jobs; - return runStatusCronGenerate( - "job.insights.general", - generateGeneralStatusForUser, - { recordRun: true }, - ); -} - -function handleBloodPressureStatusGenerate( - jobs: Job[], -) { - void jobs; - return runStatusCronGenerate( - "job.insights.blood_pressure", - generateBloodPressureStatusForUser, - ); -} - -function handleWeightStatusGenerate(jobs: Job[]) { - void jobs; - return runStatusCronGenerate( - "job.insights.weight", - generateWeightStatusForUser, - ); -} - -function handlePulseStatusGenerate(jobs: Job[]) { - void jobs; - return runStatusCronGenerate( - "job.insights.pulse", - generatePulseStatusForUser, - ); -} - -function handleBmiStatusGenerate(jobs: Job[]) { - void jobs; - return runStatusCronGenerate("job.insights.bmi", generateBmiStatusForUser); -} - -function handleMoodStatusGenerate(jobs: Job[]) { - void jobs; - return runStatusCronGenerate("job.insights.mood", generateMoodStatusForUser); -} - -function handleMedicationComplianceStatusGenerate( - jobs: Job[], -) { - void jobs; - return runStatusCronGenerate( - "job.insights.medication_compliance", - generateMedicationComplianceStatusForUser, - ); -} - -/** - * Fallback polling for moodLog data. - * Syncs mood entries for all users with moodLog enabled. - */ -async function handleMoodLogSync(jobs: Job[]) { - void jobs; - await withBackgroundEvent("job.moodlog_sync", async (evt) => { - const prisma = getWorkerPrisma(); - try { - // Check global toggle - const appSettings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - select: { moodLogGlobal: true }, - }); - if (appSettings && !appSettings.moodLogGlobal) { - evt.addMeta("skipped", "global_toggle_disabled"); - return; - } - - const users = await prisma.user.findMany({ - where: { moodLogEnabled: true }, - select: { id: true }, - }); - - if (users.length === 0) return; - - let synced = 0; - let totalImported = 0; - - for (const user of users) { - try { - const imported = await syncMoodLogEntries(user.id); - synced++; - totalImported += imported; - } catch (err) { - evt.addWarning(`Fallback sync failed for user ${user.id}: ${err}`); - } - } - - evt.setBackground({ - task_name: "job.moodlog_sync", - result: { - synced, - total: users.length, - entries_imported: totalImported, - }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); -} - -/** - * v0.5.4 ios-coord — daily mood-reminder dispatcher. - * - * Delegates the dispatch decision to `runMoodReminderTick` in - * `mood-reminder.ts` so the unit tests can exercise the logic without - * spinning up pg-boss. The handler is a thin shim that wires the worker - * Prisma singleton + the wide-event sink to the pure function. - */ -interface MoodReminderCleanupPayload { - triggeredAt: string; -} - -async function handleMoodReminderCleanup( - jobs: Job[], -) { - void jobs; - await withBackgroundEvent("job.mood_reminder_cleanup", async (evt) => { - const p = getWorkerPrisma(); - try { - const cutoff = new Date(); - cutoff.setUTCDate(cutoff.getUTCDate() - MOOD_REMINDER_RETENTION_DAYS); - const cutoffIso = cutoff.toISOString().slice(0, 10); - const deleted = await p.moodReminderDispatch.deleteMany({ - where: { date: { lt: cutoffIso } }, - }); - evt.addMeta("mood_reminder_cleanup_deleted", deleted.count); - } catch (err) { - evt.addWarning(`mood-reminder-cleanup failed: ${err}`); - } - }); -} - -/** - * v1.4.49 — daily prune for the per-attempt push-delivery ledger. - * - * Every sender (APNS, WEB_PUSH, TELEGRAM, NTFY) writes one - * fire-and-forget row to `push_attempts` per dispatch. The admin - * diagnostic endpoint only ever reads the trailing 20 rows per user, - * so anything older than the 90-day retention window is dead weight - * inflating the table and the `(user_id, created_at DESC)` index. - * - * The DELETE is unbounded by user — the index covers `created_at` - * directly, so a `WHERE created_at < cutoff` scan is bounded by the - * size of the trailing-edge of the table rather than the live working - * set. On a one-million-row table with the documented retention - * window, the daily prune touches ~11k rows (1M / 90d × 1d) and - * completes in milliseconds. - */ -interface PushAttemptCleanupPayload { - triggeredAt: string; -} - -async function handlePushAttemptCleanup( - jobs: Job[], -) { - void jobs; - await withBackgroundEvent("job.push_attempt_cleanup", async (evt) => { - const p = getWorkerPrisma(); - try { - const cutoff = new Date(); - cutoff.setUTCDate(cutoff.getUTCDate() - PUSH_ATTEMPT_RETENTION_DAYS); - const deleted = await p.pushAttempt.deleteMany({ - where: { createdAt: { lt: cutoff } }, - }); - evt.addMeta("push_attempt_cleanup_deleted", deleted.count); - } catch (err) { - evt.addWarning(`push-attempt-cleanup failed: ${err}`); - } - }); -} - -interface MeasurementTombstoneCleanupPayload { - triggeredAt: string; -} - -async function handleMeasurementTombstoneCleanup( - jobs: Job[], -) { - void jobs; - await withBackgroundEvent("job.measurement_tombstone_cleanup", async (evt) => { - const p = getWorkerPrisma(); - try { - // v1.7.0 sync — prune tombstones across all three sync domains on - // the same retention horizon. - const [measurements, mood, intakes] = await Promise.all([ - cleanupExpiredMeasurementTombstones(p), - cleanupExpiredMoodTombstones(p), - cleanupExpiredIntakeTombstones(p), - ]); - evt.addMeta("measurement_tombstone_cleanup_pruned", measurements); - evt.addMeta("mood_tombstone_cleanup_pruned", mood); - evt.addMeta("intake_tombstone_cleanup_pruned", intakes); - } catch (err) { - evt.addWarning(`tombstone-cleanup failed: ${err}`); - } - }); -} - -async function handleMoodReminderCheck(jobs: Job[]) { - void jobs; - await withBackgroundEvent("job.mood_reminder", async (evt) => { - const prisma = getWorkerPrisma(); - try { - const summary = await runMoodReminderTick(prisma, new Date()); - evt.setBackground({ - task_name: "job.mood_reminder", - result: { - candidates_scanned: summary.candidatesScanned, - in_window: summary.inWindow, - dispatched: summary.dispatched, - skipped_already_logged: summary.skippedAlreadyLogged, - skipped_already_dispatched: summary.skippedAlreadyDispatched, - skipped_outside_window: summary.skippedOutsideWindow, - }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); -} - -/** - * v1.15 — daily cycle-reminder dispatcher (period-soon + period-start-confirm). - * - * Thin shim around `runCycleReminderTick` in `cycle-reminder.ts` so the - * unit tests exercise the windowing + suppression logic without pg-boss. - */ -async function handleCycleReminderCheck(jobs: Job[]) { - void jobs; - await withBackgroundEvent("job.cycle_reminder", async (evt) => { - const prisma = getWorkerPrisma(); - try { - const summary = await runCycleReminderTick(prisma, new Date()); - evt.setBackground({ - task_name: "job.cycle_reminder", - result: { - candidates_scanned: summary.candidatesScanned, - in_window: summary.inWindow, - dispatched_period_soon: summary.dispatchedPeriodSoon, - dispatched_period_confirm: summary.dispatchedPeriodConfirm, - suppressed_client_managed: summary.suppressedClientManaged, - suppressed_discreet: summary.suppressedDiscreet, - skipped_already_notified: summary.skippedAlreadyNotified, - skipped_outside_window: summary.skippedOutsideWindow, - }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); -} - -async function handleRateLimitCleanup(jobs: Job[]) { - void jobs; - await withBackgroundEvent("job.rate_limit_cleanup", async (evt) => { - const p = getWorkerPrisma(); - try { - const result = await p.$executeRaw` - DELETE FROM rate_limits WHERE reset_at < NOW() - `; - evt.addMeta("rate_limit_cleanup_deleted", result); - } catch (err) { - evt.addWarning(`rate-limit-cleanup failed: ${err}`); - } - }); -} - -async function handleIdempotencyCleanup( - jobs: Job[], -) { - void jobs; - await withBackgroundEvent("job.idempotency_cleanup", async (evt) => { - const p = getWorkerPrisma(); - try { - const deleted = await cleanupExpiredIdempotencyKeys(p); - evt.addMeta("idempotency_cleanup_deleted", deleted); - } catch (err) { - evt.addWarning(`idempotency-cleanup failed: ${err}`); - } - }); -} - -async function handleAuditLogCleanup(jobs: Job[]) { - void jobs; - await withBackgroundEvent("job.audit_log_cleanup", async (evt) => { - const p = getWorkerPrisma(); - try { - const deleted = await cleanupOldAuditLogs(p); - evt.addMeta("audit_log_cleanup_deleted", deleted); - } catch (err) { - evt.addWarning(`audit-log-cleanup failed: ${err}`); - } - }); -} - -async function handleWithingsOAuthStateCleanup( - jobs: Job[], -) { - void jobs; - await withBackgroundEvent("job.withings_oauth_state_cleanup", async (evt) => { - const p = getWorkerPrisma(); - try { - const deleted = await cleanupExpiredWithingsOAuthStates(p); - evt.addMeta("withings_oauth_state_cleanup_deleted", deleted); - } catch (err) { - // The OAuth flow tolerates a stale row sticking around for an - // extra day — log + carry on so the boss queue doesn't retry-loop. - evt.addWarning(`withings-oauth-state-cleanup failed: ${err}`); - } - }); -} - -interface WhoopOAuthStateCleanupPayload { - triggeredAt?: string; -} - -async function handleWhoopOAuthStateCleanup( - jobs: Job[], -) { - void jobs; - await withBackgroundEvent("job.whoop_oauth_state_cleanup", async (evt) => { - const p = getWorkerPrisma(); - try { - const deleted = await cleanupExpiredWhoopOAuthStates(p); - evt.addMeta("whoop_oauth_state_cleanup_deleted", deleted); - const ticketsDeleted = await cleanupExpiredWhoopConnectTickets(p); - evt.addMeta("whoop_connect_ticket_cleanup_deleted", ticketsDeleted); - } catch (err) { - evt.addWarning(`whoop-oauth-state-cleanup failed: ${err}`); - } - }); -} - -/** - * v1.12.0 — Fitbit poll-sync payload. Poll-only (no webhook at launch): the - * single hourly cron tick carries no `userId`, so the handler iterates every - * Fitbit connection and re-syncs each via `syncUserFitbit`. One user's - * parked-at-reauth state never starves the rest of the cohort. - */ -interface FitbitSyncPayload { - userId?: string; -} - -async function handleFitbitSync(jobs: Job[]) { - await withBackgroundEvent("job.fitbit_sync", async (evt) => { - const prisma = getWorkerPrisma(); - try { - const targets: Array<{ userId: string }> = []; - for (const job of jobs) { - if (job.data?.userId) targets.push({ userId: job.data.userId }); - } - if (targets.length === 0) { - const connections = await prisma.fitbitConnection.findMany({ - select: { userId: true }, - }); - targets.push(...connections); - } - if (targets.length === 0) return; - - // Fan the cohort out with bounded concurrency + per-user error isolation: - // one slow Google response can't stall the whole cohort, and a single - // user's failure is warned without aborting the pass. - const { usersSynced, measurementsImported } = await runFitbitPollCohort( - targets.map((t) => t.userId), - { - onUserError: (userId, err) => - evt.addWarning(`job.fitbit_sync failed for user ${userId}: ${err}`), - }, - ); - - evt.setBackground({ - task_name: "job.fitbit_sync", - result: { - users_synced: usersSynced, - total: targets.length, - measurements_imported: measurementsImported, - }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); -} - -interface FitbitOAuthStateCleanupPayload { - triggeredAt?: string; -} - -async function handleFitbitOAuthStateCleanup( - jobs: Job[], -) { - void jobs; - await withBackgroundEvent("job.fitbit_oauth_state_cleanup", async (evt) => { - const p = getWorkerPrisma(); - try { - const deleted = await cleanupExpiredFitbitOAuthStates(p); - evt.addMeta("fitbit_oauth_state_cleanup_deleted", deleted); - } catch (err) { - evt.addWarning(`fitbit-oauth-state-cleanup failed: ${err}`); - } - }); -} - -async function handleHostMetricSample(jobs: Job[]) { - void jobs; - await withBackgroundEvent("job.host_metric_sample", async (evt) => { - const p = getWorkerPrisma(); - try { - const { pruned } = await runHostMetricTick(p); - evt.addMeta("host_metric_pruned", pruned); - } catch (err) { - // The chart degrades gracefully when samples are missing — log - // and move on rather than poisoning the boss queue with retries. - evt.addWarning(`host-metric-sample failed: ${err}`); - } - }); -} - -async function handleFeedbackAggregator( - jobs: Job[], -) { - void jobs; - await withBackgroundEvent("job.feedback_aggregator", async (evt) => { - const p = getWorkerPrisma(); - try { - const summary = await aggregateRecommendationFeedback(p); - evt.addMeta("feedback_buckets", summary.buckets.length); - evt.addMeta( - "feedback_total_rows", - summary.buckets.reduce((acc, b) => acc + b.total, 0), - ); - evt.addMeta("feedback_window_days", summary.windowDays); - } catch (err) { - // The admin dashboard tolerates a stale summary — log and move - // on rather than poisoning the boss queue with retries that - // would block the next cleanup window. - evt.addWarning(`feedback-aggregator failed: ${err}`); - } - }); -} - -/** - * v1.4.37 — geo-backfill worker. Walks `audit_logs` rows that landed - * with a null `location` (offline MMDB missing at write time, online - * provider unreachable) and re-resolves them through the now-bundled - * resolver chain. The helper is idempotent and capped per pass so - * the hourly cadence cannot starve a live login spike. - * - * v1.4.38 — in-process singleton guard. pg-boss already coalesces - * concurrent cron ticks across multiple worker containers via the - * shared queue lease, but a single container that takes longer than - * one cron interval can pick up two jobs back-to-back when the - * second tick fires while the first pass is still running. The - * in-process `geoBackfillRunning` flag fans the second invocation - * out as a no-op log line instead of stacking two concurrent passes - * inside the same Node process — the next cron tick after the first - * completes will catch up the work the skipped pass would have done. - */ -let geoBackfillRunning = false; -async function handleGeoBackfill(jobs: Job[]) { - void jobs; - await withBackgroundEvent("job.geo_backfill", async (evt) => { - if (geoBackfillRunning) { - // Earlier pass still in flight; skip this tick. Idempotent + the - // next tick after the in-flight pass completes will pick up - // anything we miss here. - evt.addWarning( - "geo-backfill skipped — earlier pass still in flight inside the same worker process", - ); - evt.addMeta("geo_backfill_skipped", true); - return; - } - geoBackfillRunning = true; - const p = getWorkerPrisma(); - try { - const summary = await runGeoBackfill(p); - evt.addMeta("geo_backfill_scanned", summary.scanned); - evt.addMeta("geo_backfill_located", summary.located); - evt.addMeta("geo_backfill_carrier_resolved", summary.carrierResolved); - evt.addMeta("geo_backfill_still_unresolved", summary.stillUnresolved); - } catch (err) { - // The admin sign-in overview tolerates a stale Standort cell — - // log and move on so a one-off resolver hiccup does not poison - // the queue and block the next pass. - evt.addWarning(`geo-backfill failed: ${err}`); - } finally { - geoBackfillRunning = false; - } - }); -} - -async function handleTlsPinMonitor(jobs: Job[]) { - void jobs; - await withBackgroundEvent("job.tls_pin_monitor", async (evt) => { - const p = getWorkerPrisma(); - try { - const summary = await runTlsPinMonitor(p); - evt.addMeta("tls_pin_monitor_outcome", summary.outcome); - evt.addMeta("tls_pin_monitor_host", summary.host); - evt.addMeta("tls_pin_monitor_known_count", summary.knownPinCount); - } catch (err) { - // runTlsPinMonitor swallows probe failures internally; anything that - // escapes is unexpected. Log and move on so a one-off failure does not - // poison the queue and block the next tick. - evt.addWarning(`tls-pin-monitor failed: ${err}`); - } - }); -} - -async function handlePrDetection( - jobs: Job[], -) { - for (const job of jobs) { - await withBackgroundEvent("job.pr_detection", async (evt) => { - const p = getWorkerPrisma(); - // The cron-fired job carries an empty payload — iterate all - // users in that case. The push-suppression flag is irrelevant - // for the cron path (the dispatcher's per-user opt-in handles - // the loud/quiet decision once the row is written). - const payloadUserId = (job.data as PrDetectionPayload | undefined) - ?.userId; - const silent = - (job.data as PrDetectionPayload | undefined)?.silent ?? false; - const userIds: string[] = payloadUserId - ? [payloadUserId] - : (await p.user.findMany({ select: { id: true } })).map((u) => u.id); - - let insertedTotal = 0; - let tiesTotal = 0; - for (const userId of userIds) { - try { - const result = await detectPersonalRecordsForUser(userId, { - silent, - prisma: p, - }); - insertedTotal += result.inserted; - tiesTotal += result.ties; - } catch (err) { - evt.addWarning(`pr-detection failed for user ${userId}: ${err}`); - } - } - evt.addMeta("pr_detection_users", userIds.length); - evt.addMeta("pr_detection_inserted", insertedTotal); - evt.addMeta("pr_detection_ties", tiesTotal); - evt.addMeta("pr_detection_silent", silent); - evt.addMeta("pr_detection_mode", payloadUserId ? "ingest" : "cron"); - }); - } -} - -/** - * v1.4.25 W19b — daily expire-stale pass for `MedicationInventoryItem` - * rows. Flips IN_USE pens whose 30-day window has lapsed to EXPIRED - * via the pure state-machine evaluator. - */ -async function handleMedicationInventoryExpire( - jobs: Job[], -) { - void jobs; - await withBackgroundEvent("job.medication_inventory_expire", async (evt) => { - try { - const count = await expireStaleInUseItems({ nowMs: Date.now() }); - evt.addMeta("inventory_expired_count", count); - } catch (err) { - evt.addWarning( - `medication-inventory-expire failed: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - } - }); -} - -async function handleInsightPregenerateJob( - jobs: Job[], -) { - await withBackgroundEvent("job.insight_pregenerate", async (evt) => { - // v1.8.7.1 — a forced single-user warm carries `{ userId, force }`; - // the scheduled tick carries neither. Route each job individually so a - // batch that mixes a cron tick with on-demand warms (it never does in - // practice, but the contract is per-job) stays correct. - const forced = jobs.filter((j) => j.data?.force && j.data?.userId); - const scheduled = jobs.filter((j) => !(j.data?.force && j.data?.userId)); - - for (const job of forced) { - const userId = job.data.userId as string; - const locale = job.data.locale === "en" ? "en" : "de"; - try { - const summary = await forceWarmUser(getWorkerPrisma(), userId, locale); - evt.addMeta( - "force_warm", - `${summary.comprehensive}:${summary.assessmentsWarmed}+${summary.metricAssessmentsWarmed}`, - ); - } catch (err) { - evt.addWarning( - `insight-pregenerate force-warm failed: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - // Surface the failure centrally and rethrow so the queue's retry - // policy (retryLimit 3 + backoff) re-runs the warm — swallowing it - // here left the user's caches cold with zero operator signal. - await reportWorkerError(INSIGHT_PREGENERATE_QUEUE, err, { - mode: "force-warm", - }); - throw err; - } - } - - if (scheduled.length === 0) return; - try { - const summary = await runInsightPregenerate(getWorkerPrisma()); - evt.setBackground({ - task_name: "job.insight_pregenerate", - result: { ...summary }, - }); - } catch (err) { - evt.addWarning( - `insight-pregenerate failed: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - // Same contract as the force path: report + rethrow so the nightly - // tick retries instead of silently waiting for the next night. - await reportWorkerError(INSIGHT_PREGENERATE_QUEUE, err, { - mode: "scheduled", - }); - throw err; - } - }); -} - -/** - * v1.8.3 — on-demand per-metric status generation. The read-only status - * route enqueues one job per cold card; this handler runs the matching - * generator with `force: true` so the assessment cache row lands and the - * polling client picks it up. Each job carries `{ userId, metric, locale }`. - */ -async function handleInsightStatusGenerate( - jobs: Job[], -) { - await withBackgroundEvent("job.insight_status_generate", async (evt) => { - for (const job of jobs) { - if (!job.data?.userId || !job.data?.metric) continue; - try { - await runInsightStatusGenerate(job.data); - evt.addMeta("status_generated", `${job.data.metric}:${job.data.locale}`); - } catch (err) { - evt.addWarning( - `insight-status-generate failed for ${job.data.metric}: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - // Report centrally + rethrow so the enqueue's retry policy - // (retryLimit 3 + backoff) re-runs the generation; the polling - // client otherwise sits on "preparing" with zero operator signal. - await reportWorkerError(INSIGHT_STATUS_GENERATE_QUEUE, err, { - metric: job.data.metric, - }); - throw err; - } - } - }); -} - -/** - * v1.4.46 — hourly auto-skip for stale unmarked medication intakes. - * - * Flips `MedicationIntakeEvent.skipped` to `true` for every event the - * user neither took nor explicitly skipped within the 24 h grace - * window. The pure helper lives in `@/lib/jobs/intake-auto-skip` so a - * unit test can drive it with an in-memory fake Prisma; this wrapper - * threads the worker's pg-boss + background-event plumbing. - */ -async function handleIntakeAutoSkip(jobs: Job[]) { - void jobs; - await withBackgroundEvent("job.intake_auto_skip", async (evt) => { - const prisma = getWorkerPrisma(); - try { - const result = await runIntakeAutoSkipPass(prisma, { - nowMs: Date.now(), - }); - evt.addMeta("intake_auto_skip_count", result.skippedCount); - evt.addMeta("intake_auto_skip_cutoff", result.cutoff.toISOString()); - } catch (err) { - evt.addWarning( - `intake-auto-skip failed: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - } - }); -} - -async function handleOffhostBackup(jobs: Job[]) { - void jobs; - await withBackgroundEvent("job.offhost_backup", async (evt) => { - const p = getWorkerPrisma(); - try { - const report = await runOffhostBackup(p); - evt.addMeta("offhost_backup_uploaded", report.uploaded); - evt.addMeta("offhost_backup_failed", report.failed); - evt.addMeta("offhost_backup_total_users", report.totalUsers); - evt.addMeta("offhost_backup_endpoint", report.config.endpoint); - evt.addMeta("offhost_backup_bucket", report.config.bucket); - // Per-user failure detail is also emitted as warnings inside - // runOffhostBackup; echo a structured digest for at-a-glance triage. - if (report.failures.length > 0) { - evt.addMeta( - "offhost_backup_failures", - JSON.stringify(report.failures.slice(0, 10)), - ); - } - } catch (err) { - // Not configured ⇒ skip silently with a warning, not an error. - evt.addWarning(`offhost-backup skipped/failed: ${err}`); - } - }); -} - -async function handleDataBackup(jobs: Job[]) { - void jobs; - await withBackgroundEvent("job.data_backup", async (evt) => { - const prisma = getWorkerPrisma(); - try { - const users = await prisma.user.findMany({ - select: { id: true, username: true }, - }); - - let backed = 0; - for (const user of users) { - try { - const [measurements, medications, intakeEvents, moodEntries, cycle] = - await Promise.all([ - prisma.measurement.findMany({ - where: { userId: user.id }, - orderBy: { measuredAt: "desc" }, - }), - prisma.medication.findMany({ - where: { userId: user.id }, - include: { schedules: true }, - }), - prisma.medicationIntakeEvent.findMany({ - where: { userId: user.id }, - include: { medication: { select: { name: true } } }, - orderBy: { scheduledFor: "desc" }, - }), - prisma.moodEntry.findMany({ - where: { userId: user.id }, - orderBy: { moodLoggedAt: "desc" }, - }), - // v1.15.0 — cycle slice (shared helper, notesEncrypted verbatim). - buildCycleBackupSection(prisma, user.id), - ]); - - const backupJson = JSON.stringify({ - // Bumped only when the on-disk shape changes incompatibly. - // Mirrors `BACKUP_SCHEMA_VERSION` in - // `src/lib/validations/backup.ts` — keep them in sync. - schemaVersion: "1", - exportedAt: new Date().toISOString(), - userId: user.id, - measurements: measurements.map((m) => ({ - type: m.type, - value: m.value, - unit: m.unit, - measuredAt: m.measuredAt.toISOString(), - source: m.source, - notes: m.notes, - })), - medications: medications.map((m) => ({ - name: m.name, - dose: m.dose, - active: m.active, - schedules: m.schedules.map((s) => ({ - windowStart: s.windowStart, - windowEnd: s.windowEnd, - label: s.label, - dose: s.dose, - })), - })), - intakeEvents: intakeEvents.map((e) => ({ - medication: e.medication.name, - scheduledFor: e.scheduledFor.toISOString(), - takenAt: e.takenAt?.toISOString() ?? null, - skipped: e.skipped, - source: e.source, - })), - moodEntries: moodEntries.map((e) => ({ - date: e.date, - mood: e.mood, - score: e.score, - tags: e.tags, - source: e.source, - loggedAt: e.moodLoggedAt.toISOString(), - })), - // v1.15.0 — cycle slice (profile + observed spans + day-logs). - cycleProfile: cycle.cycleProfile, - cycles: cycle.cycles, - cycleDayLogs: cycle.cycleDayLogs, - }); - - // Encrypt the backup data (contains sensitive health information) - const encryptedBackup = encrypt(backupJson); - - await prisma.dataBackup.upsert({ - where: { - userId_type: { userId: user.id, type: "WEEKLY_AUTO" }, - }, - update: { - data: encryptedBackup, - createdAt: new Date(), - }, - create: { - userId: user.id, - type: "WEEKLY_AUTO", - data: encryptedBackup, - }, - }); - backed++; - } catch (err) { - evt.addWarning(`Failed for user ${user.id}: ${err}`); - } - } - - evt.setBackground({ - task_name: "job.data_backup", - result: { backed, total: users.length }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); -} - -/** - * Internal logger that prefers structured Wide-Event annotations when a - * worker context is active, and falls back to stderr only for true - * lifecycle events that fire outside any handler (init, fatal startup - * errors, shutdown). Avoids the historical pattern of `console.log` - * everywhere in this file, which polluted production stdout and was - * never queryable in Loki. - */ -function workerLog(level: "info" | "error", msg: string, err?: unknown): void { - if (level === "error") { - // Errors during worker init or shutdown happen outside any request - // context, so stderr is the only audience the operator has. - if (err !== undefined) console.error(`[pg-boss] ${msg}`, err); - else console.error(`[pg-boss] ${msg}`); - } - // info-level lifecycle messages are intentionally silent — pg-boss own - // events surface state, and Wide Events from handlers carry the work. -} - export async function startReminderWorker() { // v1.4 G3: refuse to boot if the operator marked this container as // web-only via HEALTHLOG_PROCESS_TYPE. @@ -2317,6 +602,7 @@ export async function startReminderWorker() { FITBIT_BACKFILL_QUEUE, FITBIT_OAUTH_STATE_CLEANUP_QUEUE, OFFHOST_BACKUP_QUEUE, + RESTORE_DRILL_QUEUE, HOST_METRIC_QUEUE, FEEDBACK_AGGREGATOR_QUEUE, GEO_BACKFILL_QUEUE, @@ -2469,9 +755,12 @@ export async function startReminderWorker() { // count query + Wide-Event warning if any survive; fire-and-forget // so a probe failure never blocks worker boot. try { - await withBackgroundEvent("worker.boot.integration_status_null_probe", async () => { - await probeIntegrationStatusNullBuckets(getWorkerPrisma()); - }); + await withBackgroundEvent( + "worker.boot.integration_status_null_probe", + async () => { + await probeIntegrationStatusNullBuckets(getWorkerPrisma()); + }, + ); } catch (err) { workerLog("error", "integration-status-null-probe failed", err); } @@ -2529,6 +818,7 @@ export async function startReminderWorker() { [FITBIT_SYNC_QUEUE, FITBIT_SYNC_CRON], [FITBIT_OAUTH_STATE_CLEANUP_QUEUE, FITBIT_OAUTH_STATE_CLEANUP_CRON], [OFFHOST_BACKUP_QUEUE, OFFHOST_BACKUP_CRON], + [RESTORE_DRILL_QUEUE, RESTORE_DRILL_CRON], [HOST_METRIC_QUEUE, HOST_METRIC_CRON], [FEEDBACK_AGGREGATOR_QUEUE, FEEDBACK_AGGREGATOR_CRON], // v1.4.37 — hourly geo backfill. The helper is idempotent + capped @@ -2608,10 +898,15 @@ export async function startReminderWorker() { ]; for (const [name, cron, sendOptions] of schedules) { - await boss.schedule(name, cron, {}, { - tz: "Europe/Berlin", - ...(sendOptions ?? {}), - }); + await boss.schedule( + name, + cron, + {}, + { + tz: "Europe/Berlin", + ...(sendOptions ?? {}), + }, + ); } // Register the handler @@ -2779,6 +1074,8 @@ export async function startReminderWorker() { { localConcurrency: 1 }, handleOffhostBackup, ); + // prettier-ignore + await boss.work(RESTORE_DRILL_QUEUE, { localConcurrency: 1 }, handleRestoreDrill); await boss.work( HOST_METRIC_QUEUE, { localConcurrency: 1 }, @@ -2863,99 +1160,80 @@ export async function startReminderWorker() { // eligible user and upserts one `COMPUTED RECOVERY_SCORE` row per scored // day (idempotent — a re-fire overwrites in place). Single-flight so two // ticks never double-walk the cohort. - await boss.work( - RECOVERY_SCORE_QUEUE, - { localConcurrency: 1 }, - async () => { - try { - const summary = await runRecoveryScore(getWorkerPrisma()); - workerLog( - "info", - `[recovery-score] considered=${summary.considered} stored=${summary.stored} insufficient=${summary.insufficient} errored=${summary.errored}`, - ); - } catch (err) { - recordError(); - workerLog("error", "[recovery-score] pass failed", err); - throw err; - } - }, - ); + await boss.work(RECOVERY_SCORE_QUEUE, { localConcurrency: 1 }, async () => { + try { + const summary = await runRecoveryScore(getWorkerPrisma()); + workerLog( + "info", + `[recovery-score] considered=${summary.considered} stored=${summary.stored} insufficient=${summary.insufficient} errored=${summary.errored}`, + ); + } catch (err) { + recordError(); + workerLog("error", "[recovery-score] pass failed", err); + throw err; + } + }); // v1.15.20 — proactive Coach nudge. Single-flight; the push-attempts // ledger caps a user at one nudge per rolling week, so an overlapping // tick would only waste reads. Deterministic triggers, no AI call. - await boss.work( - COACH_NUDGE_QUEUE, - { localConcurrency: 1 }, - async () => { - await withBackgroundEvent("job.coach_nudge", async (evt) => { - try { - const summary = await runCoachNudgeTick( - getWorkerPrisma(), - new Date(), - ); - evt.setBackground({ - task_name: "job.coach_nudge", - result: { - candidates_scanned: summary.candidatesScanned, - dispatched: summary.dispatched, - skipped_opted_out: summary.skippedOptedOut, - skipped_no_provider: summary.skippedNoProvider, - skipped_recent_nudge: summary.skippedRecentNudge, - skipped_no_trigger: summary.skippedNoTrigger, - skipped_no_channel: summary.skippedNoChannel, - failed: summary.failed, - }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); - }, - ); + await boss.work(COACH_NUDGE_QUEUE, { localConcurrency: 1 }, async () => { + await withBackgroundEvent("job.coach_nudge", async (evt) => { + try { + const summary = await runCoachNudgeTick(getWorkerPrisma(), new Date()); + evt.setBackground({ + task_name: "job.coach_nudge", + result: { + candidates_scanned: summary.candidatesScanned, + dispatched: summary.dispatched, + skipped_opted_out: summary.skippedOptedOut, + skipped_no_provider: summary.skippedNoProvider, + skipped_recent_nudge: summary.skippedRecentNudge, + skipped_no_trigger: summary.skippedNoTrigger, + skipped_no_channel: summary.skippedNoChannel, + failed: summary.failed, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); + }); // v1.10.0 — computed scores (WX-E). Nightly Stress-score (HRV-derived // proxy) compute + store. Single-flight so two ticks never double-walk // the cohort. The runner iterates every eligible user and upserts one // `COMPUTED STRESS_SCORE` row per scored day (idempotent — a re-fire // overwrites in place). - await boss.work( - STRESS_SCORE_QUEUE, - { localConcurrency: 1 }, - async () => { - try { - const summary = await runStressScore(getWorkerPrisma()); - workerLog( - "info", - `[stress-score] considered=${summary.considered} stored=${summary.stored} insufficient=${summary.insufficient} errored=${summary.errored}`, - ); - } catch (err) { - recordError(); - workerLog("error", "[stress-score] pass failed", err); - throw err; - } - }, - ); + await boss.work(STRESS_SCORE_QUEUE, { localConcurrency: 1 }, async () => { + try { + const summary = await runStressScore(getWorkerPrisma()); + workerLog( + "info", + `[stress-score] considered=${summary.considered} stored=${summary.stored} insufficient=${summary.insufficient} errored=${summary.errored}`, + ); + } catch (err) { + recordError(); + workerLog("error", "[stress-score] pass failed", err); + throw err; + } + }); // v1.10.0 — computed scores (WX-E). Nightly Strain-score (Banister TRIMP // cardio-load) compute + store. Single-flight; upserts one `COMPUTED // STRAIN_SCORE` row per scored day (idempotent). - await boss.work( - STRAIN_SCORE_QUEUE, - { localConcurrency: 1 }, - async () => { - try { - const summary = await runStrainScore(getWorkerPrisma()); - workerLog( - "info", - `[strain-score] considered=${summary.considered} stored=${summary.stored} insufficient=${summary.insufficient} errored=${summary.errored}`, - ); - } catch (err) { - recordError(); - workerLog("error", "[strain-score] pass failed", err); - throw err; - } - }, - ); + await boss.work(STRAIN_SCORE_QUEUE, { localConcurrency: 1 }, async () => { + try { + const summary = await runStrainScore(getWorkerPrisma()); + workerLog( + "info", + `[strain-score] considered=${summary.considered} stored=${summary.stored} insufficient=${summary.insufficient} errored=${summary.errored}`, + ); + } catch (err) { + recordError(); + workerLog("error", "[strain-score] pass failed", err); + throw err; + } + }); // v1.11.0 — period-narrative warm. A scheduled tick (no `userId`) runs the // boundary-gated nightly fan-out; a `userId` payload runs a single-user warm // enqueued by the read-only GET on a cold/stale read. Single-flight so two @@ -3191,11 +1469,7 @@ export async function startReminderWorker() { ); } catch (err) { recordError(); - workerLog( - "error", - `[intake-slot-dedup] user=${userId} failed`, - err, - ); + workerLog("error", `[intake-slot-dedup] user=${userId} failed`, err); throw err; } } @@ -3556,13 +1830,9 @@ export async function startReminderWorker() { // returned through the helper's result value — the worker boot never // fails because of a dedup miss. try { - const { enqueued, skipped, error } = - await enqueueBootTimeIntakeSlotDedup(); + const { enqueued, skipped, error } = await enqueueBootTimeIntakeSlotDedup(); if (error) { - workerLog( - "error", - `[intake-slot-dedup] boot discovery failed: ${error}`, - ); + workerLog("error", `[intake-slot-dedup] boot discovery failed: ${error}`); } else { workerLog( "info", diff --git a/src/lib/jobs/reminder/backup-handlers.ts b/src/lib/jobs/reminder/backup-handlers.ts new file mode 100644 index 000000000..ceeeccecd --- /dev/null +++ b/src/lib/jobs/reminder/backup-handlers.ts @@ -0,0 +1,164 @@ +/** + * Weekly on-host data backup and the nightly off-host backup handler. + * + * Extracted from reminder-worker.ts, which owns the queue names, cron + * schedules, and boss.work registrations. + */ +import { type Job } from "pg-boss"; +import { recordError } from "@/lib/jobs/worker-status"; +import { encrypt } from "@/lib/crypto"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { buildCycleBackupSection } from "@/lib/cycle/backup"; +import { runOffhostBackup } from "@/lib/jobs/offhost-backup"; +import { getWorkerPrisma } from "./shared"; + +export interface DataBackupPayload { + triggeredAt: string; +} + +export interface OffhostBackupPayload { + triggeredAt: string; +} + +export async function handleOffhostBackup(jobs: Job[]) { + void jobs; + await withBackgroundEvent("job.offhost_backup", async (evt) => { + const p = getWorkerPrisma(); + try { + const report = await runOffhostBackup(p); + evt.addMeta("offhost_backup_uploaded", report.uploaded); + evt.addMeta("offhost_backup_failed", report.failed); + evt.addMeta("offhost_backup_total_users", report.totalUsers); + evt.addMeta("offhost_backup_endpoint", report.config.endpoint); + evt.addMeta("offhost_backup_bucket", report.config.bucket); + // Per-user failure detail is also emitted as warnings inside + // runOffhostBackup; echo a structured digest for at-a-glance triage. + if (report.failures.length > 0) { + evt.addMeta( + "offhost_backup_failures", + JSON.stringify(report.failures.slice(0, 10)), + ); + } + } catch (err) { + // Not configured ⇒ skip silently with a warning, not an error. + evt.addWarning(`offhost-backup skipped/failed: ${err}`); + } + }); +} + +export async function handleDataBackup(jobs: Job[]) { + void jobs; + await withBackgroundEvent("job.data_backup", async (evt) => { + const prisma = getWorkerPrisma(); + try { + const users = await prisma.user.findMany({ + select: { id: true, username: true }, + }); + + let backed = 0; + for (const user of users) { + try { + const [measurements, medications, intakeEvents, moodEntries, cycle] = + await Promise.all([ + prisma.measurement.findMany({ + where: { userId: user.id }, + orderBy: { measuredAt: "desc" }, + }), + prisma.medication.findMany({ + where: { userId: user.id }, + include: { schedules: true }, + }), + prisma.medicationIntakeEvent.findMany({ + where: { userId: user.id }, + include: { medication: { select: { name: true } } }, + orderBy: { scheduledFor: "desc" }, + }), + prisma.moodEntry.findMany({ + where: { userId: user.id }, + orderBy: { moodLoggedAt: "desc" }, + }), + // v1.15.0 — cycle slice (shared helper, notesEncrypted verbatim). + buildCycleBackupSection(prisma, user.id), + ]); + + const backupJson = JSON.stringify({ + // Bumped only when the on-disk shape changes incompatibly. + // Mirrors `BACKUP_SCHEMA_VERSION` in + // `src/lib/validations/backup.ts` — keep them in sync. + schemaVersion: "1", + exportedAt: new Date().toISOString(), + userId: user.id, + measurements: measurements.map((m) => ({ + type: m.type, + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt.toISOString(), + source: m.source, + notes: m.notes, + })), + medications: medications.map((m) => ({ + name: m.name, + dose: m.dose, + active: m.active, + schedules: m.schedules.map((s) => ({ + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: s.label, + dose: s.dose, + })), + })), + intakeEvents: intakeEvents.map((e) => ({ + medication: e.medication.name, + scheduledFor: e.scheduledFor.toISOString(), + takenAt: e.takenAt?.toISOString() ?? null, + skipped: e.skipped, + source: e.source, + })), + moodEntries: moodEntries.map((e) => ({ + date: e.date, + mood: e.mood, + score: e.score, + tags: e.tags, + source: e.source, + loggedAt: e.moodLoggedAt.toISOString(), + })), + // v1.15.0 — cycle slice (profile + observed spans + day-logs). + cycleProfile: cycle.cycleProfile, + cycles: cycle.cycles, + cycleDayLogs: cycle.cycleDayLogs, + }); + + // Encrypt the backup data (contains sensitive health information) + const encryptedBackup = encrypt(backupJson); + + await prisma.dataBackup.upsert({ + where: { + userId_type: { userId: user.id, type: "WEEKLY_AUTO" }, + }, + update: { + data: encryptedBackup, + createdAt: new Date(), + }, + create: { + userId: user.id, + type: "WEEKLY_AUTO", + data: encryptedBackup, + }, + }); + backed++; + } catch (err) { + evt.addWarning(`Failed for user ${user.id}: ${err}`); + } + } + + evt.setBackground({ + task_name: "job.data_backup", + result: { backed, total: users.length }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} diff --git a/src/lib/jobs/reminder/cleanup-handlers.ts b/src/lib/jobs/reminder/cleanup-handlers.ts new file mode 100644 index 000000000..8ae6c8415 --- /dev/null +++ b/src/lib/jobs/reminder/cleanup-handlers.ts @@ -0,0 +1,240 @@ +/** + * Retention and maintenance cleanups: rate-limit rows, idempotency keys, audit logs, OAuth states (Withings / WHOOP), mood-reminder events, push attempts, and measurement tombstones. + * + * Extracted from reminder-worker.ts, which owns the queue names, cron + * schedules, and boss.work registrations. + */ +import { type Job } from "pg-boss"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { + cleanupExpiredWhoopConnectTickets, + cleanupExpiredWhoopOAuthStates, +} from "@/lib/jobs/whoop-oauth-state-cleanup"; +import { cleanupExpiredIdempotencyKeys } from "@/lib/jobs/idempotency-cleanup"; +import { cleanupOldAuditLogs } from "@/lib/jobs/audit-log-cleanup"; +import { cleanupExpiredWithingsOAuthStates } from "@/lib/jobs/withings-oauth-state-cleanup"; +import { + cleanupExpiredMeasurementTombstones, + cleanupExpiredMoodTombstones, + cleanupExpiredIntakeTombstones, +} from "@/lib/jobs/measurement-tombstone-cleanup"; +import { getWorkerPrisma } from "./shared"; + +const MOOD_REMINDER_RETENTION_DAYS = 90; +// v1.4.49 — daily prune for the push-attempt ledger. Same 90-day +// retention as the mood-reminder dispatch ledger; both surfaces are +// behavioural footprints we keep long enough to debug a duplicate-push +// report (~one billing cycle) but no longer. Slots at 03:35 between +// mood-reminder cleanup (03:25) and drain-cumulative (03:45) so the +// 03:xx maintenance window stays ordered. + +const PUSH_ATTEMPT_RETENTION_DAYS = 90; +// v1.7.0 — daily prune for soft-deleted measurement tombstones. Rows +// whose `deletedAt` predates the refresh-token lifetime + margin are +// hard-deleted (a device offline that long re-pairs with a full backfill, +// not an incremental delta, so it never relies on the tombstone). +// Retention lives on the helper module keyed to the refresh lifetime so +// the two never drift. Slots at 03:40 between push-attempt cleanup (03:35) +// and the drain (03:45) inside the existing 03:xx maintenance window. + +export interface RateLimitCleanupPayload { + triggeredAt: string; +} + +export interface IdempotencyCleanupPayload { + triggeredAt: string; +} + +export interface AuditLogCleanupPayload { + triggeredAt: string; +} + +export interface WithingsOAuthStateCleanupPayload { + triggeredAt: string; +} + +/** + * v0.5.4 ios-coord — daily mood-reminder dispatcher. + * + * Delegates the dispatch decision to `runMoodReminderTick` in + * `mood-reminder.ts` so the unit tests can exercise the logic without + * spinning up pg-boss. The handler is a thin shim that wires the worker + * Prisma singleton + the wide-event sink to the pure function. + */ +export interface MoodReminderCleanupPayload { + triggeredAt: string; +} + +export async function handleMoodReminderCleanup( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.mood_reminder_cleanup", async (evt) => { + const p = getWorkerPrisma(); + try { + const cutoff = new Date(); + cutoff.setUTCDate(cutoff.getUTCDate() - MOOD_REMINDER_RETENTION_DAYS); + const cutoffIso = cutoff.toISOString().slice(0, 10); + const deleted = await p.moodReminderDispatch.deleteMany({ + where: { date: { lt: cutoffIso } }, + }); + evt.addMeta("mood_reminder_cleanup_deleted", deleted.count); + } catch (err) { + evt.addWarning(`mood-reminder-cleanup failed: ${err}`); + } + }); +} + +/** + * v1.4.49 — daily prune for the per-attempt push-delivery ledger. + * + * Every sender (APNS, WEB_PUSH, TELEGRAM, NTFY) writes one + * fire-and-forget row to `push_attempts` per dispatch. The admin + * diagnostic endpoint only ever reads the trailing 20 rows per user, + * so anything older than the 90-day retention window is dead weight + * inflating the table and the `(user_id, created_at DESC)` index. + * + * The DELETE is unbounded by user — the index covers `created_at` + * directly, so a `WHERE created_at < cutoff` scan is bounded by the + * size of the trailing-edge of the table rather than the live working + * set. On a one-million-row table with the documented retention + * window, the daily prune touches ~11k rows (1M / 90d × 1d) and + * completes in milliseconds. + */ +export interface PushAttemptCleanupPayload { + triggeredAt: string; +} + +export async function handlePushAttemptCleanup( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.push_attempt_cleanup", async (evt) => { + const p = getWorkerPrisma(); + try { + const cutoff = new Date(); + cutoff.setUTCDate(cutoff.getUTCDate() - PUSH_ATTEMPT_RETENTION_DAYS); + const deleted = await p.pushAttempt.deleteMany({ + where: { createdAt: { lt: cutoff } }, + }); + evt.addMeta("push_attempt_cleanup_deleted", deleted.count); + } catch (err) { + evt.addWarning(`push-attempt-cleanup failed: ${err}`); + } + }); +} + +export interface MeasurementTombstoneCleanupPayload { + triggeredAt: string; +} + +export async function handleMeasurementTombstoneCleanup( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent( + "job.measurement_tombstone_cleanup", + async (evt) => { + const p = getWorkerPrisma(); + try { + // v1.7.0 sync — prune tombstones across all three sync domains on + // the same retention horizon. + const [measurements, mood, intakes] = await Promise.all([ + cleanupExpiredMeasurementTombstones(p), + cleanupExpiredMoodTombstones(p), + cleanupExpiredIntakeTombstones(p), + ]); + evt.addMeta("measurement_tombstone_cleanup_pruned", measurements); + evt.addMeta("mood_tombstone_cleanup_pruned", mood); + evt.addMeta("intake_tombstone_cleanup_pruned", intakes); + } catch (err) { + evt.addWarning(`tombstone-cleanup failed: ${err}`); + } + }, + ); +} + +export async function handleRateLimitCleanup( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.rate_limit_cleanup", async (evt) => { + const p = getWorkerPrisma(); + try { + const result = await p.$executeRaw` + DELETE FROM rate_limits WHERE reset_at < NOW() + `; + evt.addMeta("rate_limit_cleanup_deleted", result); + } catch (err) { + evt.addWarning(`rate-limit-cleanup failed: ${err}`); + } + }); +} + +export async function handleIdempotencyCleanup( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.idempotency_cleanup", async (evt) => { + const p = getWorkerPrisma(); + try { + const deleted = await cleanupExpiredIdempotencyKeys(p); + evt.addMeta("idempotency_cleanup_deleted", deleted); + } catch (err) { + evt.addWarning(`idempotency-cleanup failed: ${err}`); + } + }); +} + +export async function handleAuditLogCleanup( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.audit_log_cleanup", async (evt) => { + const p = getWorkerPrisma(); + try { + const deleted = await cleanupOldAuditLogs(p); + evt.addMeta("audit_log_cleanup_deleted", deleted); + } catch (err) { + evt.addWarning(`audit-log-cleanup failed: ${err}`); + } + }); +} + +export async function handleWithingsOAuthStateCleanup( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.withings_oauth_state_cleanup", async (evt) => { + const p = getWorkerPrisma(); + try { + const deleted = await cleanupExpiredWithingsOAuthStates(p); + evt.addMeta("withings_oauth_state_cleanup_deleted", deleted); + } catch (err) { + // The OAuth flow tolerates a stale row sticking around for an + // extra day — log + carry on so the boss queue doesn't retry-loop. + evt.addWarning(`withings-oauth-state-cleanup failed: ${err}`); + } + }); +} + +export interface WhoopOAuthStateCleanupPayload { + triggeredAt?: string; +} + +export async function handleWhoopOAuthStateCleanup( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.whoop_oauth_state_cleanup", async (evt) => { + const p = getWorkerPrisma(); + try { + const deleted = await cleanupExpiredWhoopOAuthStates(p); + evt.addMeta("whoop_oauth_state_cleanup_deleted", deleted); + const ticketsDeleted = await cleanupExpiredWhoopConnectTickets(p); + evt.addMeta("whoop_connect_ticket_cleanup_deleted", ticketsDeleted); + } catch (err) { + evt.addWarning(`whoop-oauth-state-cleanup failed: ${err}`); + } + }); +} diff --git a/src/lib/jobs/reminder/fitbit-sync.ts b/src/lib/jobs/reminder/fitbit-sync.ts new file mode 100644 index 000000000..53e6ae00d --- /dev/null +++ b/src/lib/jobs/reminder/fitbit-sync.ts @@ -0,0 +1,84 @@ +/** + * Fitbit hourly poll-cohort sync and OAuth-state cleanup handlers. + * + * Extracted from reminder-worker.ts, which owns the queue names, cron + * schedules, and boss.work registrations. + */ +import { type Job } from "pg-boss"; +import { recordError } from "@/lib/jobs/worker-status"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { runFitbitPollCohort } from "@/lib/fitbit/sync"; +import { cleanupExpiredFitbitOAuthStates } from "@/lib/jobs/fitbit-oauth-state-cleanup"; +import { getWorkerPrisma } from "./shared"; + +/** + * v1.12.0 — Fitbit poll-sync payload. Poll-only (no webhook at launch): the + * single hourly cron tick carries no `userId`, so the handler iterates every + * Fitbit connection and re-syncs each via `syncUserFitbit`. One user's + * parked-at-reauth state never starves the rest of the cohort. + */ +export interface FitbitSyncPayload { + userId?: string; +} + +export async function handleFitbitSync(jobs: Job[]) { + await withBackgroundEvent("job.fitbit_sync", async (evt) => { + const prisma = getWorkerPrisma(); + try { + const targets: Array<{ userId: string }> = []; + for (const job of jobs) { + if (job.data?.userId) targets.push({ userId: job.data.userId }); + } + if (targets.length === 0) { + const connections = await prisma.fitbitConnection.findMany({ + select: { userId: true }, + }); + targets.push(...connections); + } + if (targets.length === 0) return; + + // Fan the cohort out with bounded concurrency + per-user error isolation: + // one slow Google response can't stall the whole cohort, and a single + // user's failure is warned without aborting the pass. + const { usersSynced, measurementsImported } = await runFitbitPollCohort( + targets.map((t) => t.userId), + { + onUserError: (userId, err) => + evt.addWarning(`job.fitbit_sync failed for user ${userId}: ${err}`), + }, + ); + + evt.setBackground({ + task_name: "job.fitbit_sync", + result: { + users_synced: usersSynced, + total: targets.length, + measurements_imported: measurementsImported, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} + +export interface FitbitOAuthStateCleanupPayload { + triggeredAt?: string; +} + +export async function handleFitbitOAuthStateCleanup( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.fitbit_oauth_state_cleanup", async (evt) => { + const p = getWorkerPrisma(); + try { + const deleted = await cleanupExpiredFitbitOAuthStates(p); + evt.addMeta("fitbit_oauth_state_cleanup_deleted", deleted); + } catch (err) { + evt.addWarning(`fitbit-oauth-state-cleanup failed: ${err}`); + } + }); +} diff --git a/src/lib/jobs/reminder/insights-handlers.ts b/src/lib/jobs/reminder/insights-handlers.ts new file mode 100644 index 000000000..7703794cb --- /dev/null +++ b/src/lib/jobs/reminder/insights-handlers.ts @@ -0,0 +1,263 @@ +/** + * Nightly insight status crons (general / blood pressure / weight / pulse / BMI / mood / medication compliance) plus the insight pregenerate and per-user status-generate handlers. + * + * Extracted from reminder-worker.ts, which owns the queue names, cron + * schedules, and boss.work registrations. + */ +import { type Job } from "pg-boss"; +import { reportWorkerError } from "@/lib/jobs/report-worker-error"; +import { recordError, recordInsightsRun } from "@/lib/jobs/worker-status"; +import { + INSIGHT_PREGENERATE_QUEUE, + type InsightPregeneratePayload, + runInsightPregenerate, + forceWarmUser, +} from "@/lib/jobs/insight-pregenerate"; +import { + INSIGHT_STATUS_GENERATE_QUEUE, + type InsightStatusGeneratePayload, + runInsightStatusGenerate, +} from "@/lib/jobs/insight-status-generate"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { generateGeneralStatusForUser } from "@/lib/insights/general-status"; +import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status"; +import { generateWeightStatusForUser } from "@/lib/insights/weight-status"; +import { generatePulseStatusForUser } from "@/lib/insights/pulse-status"; +import { generateBmiStatusForUser } from "@/lib/insights/bmi-status"; +import { generateMoodStatusForUser } from "@/lib/insights/mood-status"; +import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status"; +import { findStatusCronCandidates } from "@/lib/jobs/status-cron-candidates"; +import { getWorkerPrisma } from "./shared"; + +export interface GeneralStatusPayload { + triggeredAt: string; +} + +export interface BloodPressureStatusPayload { + triggeredAt: string; +} + +export interface WeightStatusPayload { + triggeredAt: string; +} + +export interface PulseStatusPayload { + triggeredAt: string; +} + +export interface BmiStatusPayload { + triggeredAt: string; +} + +export interface MoodStatusPayload { + triggeredAt?: string; +} + +export interface MedicationComplianceStatusPayload { + triggeredAt: string; +} + +/** + * Shared driver for the nightly 02:xx per-metric status crons. User + * discovery is centralised in `findStatusCronCandidates`, which applies + * the operator assistant kill-switch, the per-user `disableCoach` gate, + * and the pregenerate-candidate skip (users with a configured provider + * and a stale comprehensive cache belong to the 04:30 pre-generate pass, + * which re-warms every per-status cache anyway — see + * `status-cron-candidates.ts` for the full division of nightly labour). + * The generators normalise `locale` themselves (de stays de, everything + * else gets English prose). + */ +export async function runStatusCronGenerate( + taskName: string, + generate: ( + userId: string, + options: { locale: string | null; force: boolean }, + ) => Promise, + options: { recordRun?: boolean } = {}, +): Promise { + await withBackgroundEvent(taskName, async (evt) => { + const prisma = getWorkerPrisma(); + try { + if (options.recordRun) recordInsightsRun(); + const users = await findStatusCronCandidates(prisma); + + if (users.length === 0) return; + + let generated = 0; + let failed = 0; + + for (const user of users) { + try { + await generate(user.id, { locale: user.locale, force: false }); + generated++; + } catch (error) { + failed++; + evt.addWarning( + `${taskName} generation failed for user ${user.id}: ${error}`, + ); + } + } + + evt.setBackground({ + task_name: taskName, + result: { generated, failed, total: users.length }, + }); + } catch (err) { + evt.setError(err); + recordError(); + await reportWorkerError(taskName, err); + throw err; + } + }); +} + +export function handleGeneralStatusGenerate(jobs: Job[]) { + void jobs; + return runStatusCronGenerate( + "job.insights.general", + generateGeneralStatusForUser, + { recordRun: true }, + ); +} + +export function handleBloodPressureStatusGenerate( + jobs: Job[], +) { + void jobs; + return runStatusCronGenerate( + "job.insights.blood_pressure", + generateBloodPressureStatusForUser, + ); +} + +export function handleWeightStatusGenerate(jobs: Job[]) { + void jobs; + return runStatusCronGenerate( + "job.insights.weight", + generateWeightStatusForUser, + ); +} + +export function handlePulseStatusGenerate(jobs: Job[]) { + void jobs; + return runStatusCronGenerate( + "job.insights.pulse", + generatePulseStatusForUser, + ); +} + +export function handleBmiStatusGenerate(jobs: Job[]) { + void jobs; + return runStatusCronGenerate("job.insights.bmi", generateBmiStatusForUser); +} + +export function handleMoodStatusGenerate(jobs: Job[]) { + void jobs; + return runStatusCronGenerate("job.insights.mood", generateMoodStatusForUser); +} + +export function handleMedicationComplianceStatusGenerate( + jobs: Job[], +) { + void jobs; + return runStatusCronGenerate( + "job.insights.medication_compliance", + generateMedicationComplianceStatusForUser, + ); +} + +export async function handleInsightPregenerateJob( + jobs: Job[], +) { + await withBackgroundEvent("job.insight_pregenerate", async (evt) => { + // v1.8.7.1 — a forced single-user warm carries `{ userId, force }`; + // the scheduled tick carries neither. Route each job individually so a + // batch that mixes a cron tick with on-demand warms (it never does in + // practice, but the contract is per-job) stays correct. + const forced = jobs.filter((j) => j.data?.force && j.data?.userId); + const scheduled = jobs.filter((j) => !(j.data?.force && j.data?.userId)); + + for (const job of forced) { + const userId = job.data.userId as string; + const locale = job.data.locale === "en" ? "en" : "de"; + try { + const summary = await forceWarmUser(getWorkerPrisma(), userId, locale); + evt.addMeta( + "force_warm", + `${summary.comprehensive}:${summary.assessmentsWarmed}+${summary.metricAssessmentsWarmed}`, + ); + } catch (err) { + evt.addWarning( + `insight-pregenerate force-warm failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + // Surface the failure centrally and rethrow so the queue's retry + // policy (retryLimit 3 + backoff) re-runs the warm — swallowing it + // here left the user's caches cold with zero operator signal. + await reportWorkerError(INSIGHT_PREGENERATE_QUEUE, err, { + mode: "force-warm", + }); + throw err; + } + } + + if (scheduled.length === 0) return; + try { + const summary = await runInsightPregenerate(getWorkerPrisma()); + evt.setBackground({ + task_name: "job.insight_pregenerate", + result: { ...summary }, + }); + } catch (err) { + evt.addWarning( + `insight-pregenerate failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + // Same contract as the force path: report + rethrow so the nightly + // tick retries instead of silently waiting for the next night. + await reportWorkerError(INSIGHT_PREGENERATE_QUEUE, err, { + mode: "scheduled", + }); + throw err; + } + }); +} + +/** + * v1.8.3 — on-demand per-metric status generation. The read-only status + * route enqueues one job per cold card; this handler runs the matching + * generator with `force: true` so the assessment cache row lands and the + * polling client picks it up. Each job carries `{ userId, metric, locale }`. + */ +export async function handleInsightStatusGenerate( + jobs: Job[], +) { + await withBackgroundEvent("job.insight_status_generate", async (evt) => { + for (const job of jobs) { + if (!job.data?.userId || !job.data?.metric) continue; + try { + await runInsightStatusGenerate(job.data); + evt.addMeta( + "status_generated", + `${job.data.metric}:${job.data.locale}`, + ); + } catch (err) { + evt.addWarning( + `insight-status-generate failed for ${job.data.metric}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + // Report centrally + rethrow so the enqueue's retry policy + // (retryLimit 3 + backoff) re-runs the generation; the polling + // client otherwise sits on "preparing" with zero operator signal. + await reportWorkerError(INSIGHT_STATUS_GENERATE_QUEUE, err, { + metric: job.data.metric, + }); + throw err; + } + } + }); +} diff --git a/src/lib/jobs/reminder/medication-maintenance.ts b/src/lib/jobs/reminder/medication-maintenance.ts new file mode 100644 index 000000000..326cb8cd3 --- /dev/null +++ b/src/lib/jobs/reminder/medication-maintenance.ts @@ -0,0 +1,67 @@ +/** + * Medication maintenance: inventory expiry and intake auto-skip handlers. + * + * Extracted from reminder-worker.ts, which owns the queue names, cron + * schedules, and boss.work registrations. + */ +import { type Job } from "pg-boss"; +import { type MedicationInventoryExpirePayload } from "@/lib/jobs/medication-inventory-expire"; +import { + type IntakeAutoSkipPayload, + runIntakeAutoSkipPass, +} from "@/lib/jobs/intake-auto-skip"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { expireStaleInUseItems } from "@/lib/medications/inventory/service"; +import { getWorkerPrisma } from "./shared"; + +/** + * v1.4.25 W19b — daily expire-stale pass for `MedicationInventoryItem` + * rows. Flips IN_USE pens whose 30-day window has lapsed to EXPIRED + * via the pure state-machine evaluator. + */ +export async function handleMedicationInventoryExpire( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.medication_inventory_expire", async (evt) => { + try { + const count = await expireStaleInUseItems({ nowMs: Date.now() }); + evt.addMeta("inventory_expired_count", count); + } catch (err) { + evt.addWarning( + `medication-inventory-expire failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + }); +} + +/** + * v1.4.46 — hourly auto-skip for stale unmarked medication intakes. + * + * Flips `MedicationIntakeEvent.skipped` to `true` for every event the + * user neither took nor explicitly skipped within the 24 h grace + * window. The pure helper lives in `@/lib/jobs/intake-auto-skip` so a + * unit test can drive it with an in-memory fake Prisma; this wrapper + * threads the worker's pg-boss + background-event plumbing. + */ +export async function handleIntakeAutoSkip(jobs: Job[]) { + void jobs; + await withBackgroundEvent("job.intake_auto_skip", async (evt) => { + const prisma = getWorkerPrisma(); + try { + const result = await runIntakeAutoSkipPass(prisma, { + nowMs: Date.now(), + }); + evt.addMeta("intake_auto_skip_count", result.skippedCount); + evt.addMeta("intake_auto_skip_cutoff", result.cutoff.toISOString()); + } catch (err) { + evt.addWarning( + `intake-auto-skip failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + }); +} diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts new file mode 100644 index 000000000..3225195ca --- /dev/null +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -0,0 +1,510 @@ +/** + * Medication-reminder check: the 15-minute tick that finds due and overdue intakes, mints missed-dose rows, escalates phases, and dispatches notifications. Also prunes scheduled Telegram deletions. + * + * Extracted from reminder-worker.ts, which owns the queue names, cron + * schedules, and boss.work registrations. + */ +import { type Job } from "pg-boss"; +import { recordError, recordReminderCheck } from "@/lib/jobs/worker-status"; +import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { decrypt } from "@/lib/crypto"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { + buildCanonicalSchedule, + buildRecurrenceContext, + scheduleEmitsInWindow, + shouldMintMissedDoseRow, +} from "@/lib/medications/scheduling/worker-helpers"; +import { deleteMessage } from "@/lib/telegram"; +import { + DEFAULT_PHASE_CONFIG, + resolvePhaseThresholds, + determinePhase, + getPhaseMessage, + getPhaseKeyboard, +} from "@/lib/jobs/reminder-phases"; +import { isMedicationReminderClientManaged } from "@/lib/validations/notification-prefs"; +import { + getUserTodayBounds as getUserTodayBoundsUtil, + localHmAsUtc, +} from "@/lib/timezone"; +import { getWorkerPrisma, parseTimeToMinutes } from "./shared"; + +export interface ReminderCheckPayload { + triggeredAt: string; +} + +const getUserTodayBounds = getUserTodayBoundsUtil; + +/** + * Process expired TelegramScheduledDeletion records. + * Deletes messages from Telegram and removes the DB records. + * Called at the start of every reminder check (every 15 minutes). + */ +export async function cleanupScheduledTelegramDeletions(): Promise { + const prisma = getWorkerPrisma(); + try { + let totalDeleted = 0; + + // Process in batches until all expired records are handled + while (true) { + const expired = await prisma.telegramScheduledDeletion.findMany({ + where: { deleteAfter: { lte: new Date() } }, + take: 100, + }); + + if (expired.length === 0) break; + + // Group by userId to fetch bot token once per user + const byUser = new Map< + string, + { chatId: string; messageId: number; id: string }[] + >(); + for (const record of expired) { + const list = byUser.get(record.userId) ?? []; + list.push({ + chatId: record.chatId, + messageId: record.messageId, + id: record.id, + }); + byUser.set(record.userId, list); + } + + const deletedIds: string[] = []; + for (const [userId, messages] of byUser) { + const user = await prisma.user.findFirst({ + where: { id: userId, telegramBotToken: { not: null } }, + select: { telegramBotToken: true }, + }); + if (!user?.telegramBotToken) { + // No bot token — just clean up the records + deletedIds.push(...messages.map((m) => m.id)); + continue; + } + const botToken = decrypt(user.telegramBotToken); + for (const msg of messages) { + try { + await deleteMessage(botToken, msg.chatId, msg.messageId); + } catch { + // Best-effort: message may already be deleted + } + deletedIds.push(msg.id); + } + } + + if (deletedIds.length > 0) { + await prisma.telegramScheduledDeletion.deleteMany({ + where: { id: { in: deletedIds } }, + }); + totalDeleted += deletedIds.length; + } + } + + if (totalDeleted > 0) { + const { getEvent } = await import("@/lib/logging/context"); + getEvent()?.addMeta("telegram_scheduled_cleanup", totalDeleted); + } + } catch (err) { + const { getEvent } = await import("@/lib/logging/context"); + getEvent()?.addWarning(`telegram-scheduled-cleanup failed: ${err}`); + } +} + +/** + * Check all active medications for each user and determine reminder phases. + * Uses phase-based logic (GREEN/YELLOW/ORANGE/RED) to send one notification + * per phase transition rather than every 15 minutes. + */ +export async function handleReminderCheck(jobs: Job[]) { + void jobs; + await withBackgroundEvent("job.medication_reminder", async (evt) => { + const prisma = getWorkerPrisma(); + try { + recordReminderCheck(); + const now = new Date(); + + // Clean up expired scheduled Telegram message deletions + await cleanupScheduledTelegramDeletions(); + + // Clean up expired snoozes + await prisma.medication.updateMany({ + where: { snoozedUntil: { lt: now } }, + data: { snoozedUntil: null }, + }); + + // Get all active medications with schedules and phase config + const medications = await prisma.medication.findMany({ + where: { active: true }, + include: { + schedules: true, + phaseConfig: true, + user: { + select: { + id: true, + timezone: true, + // Used to localise the reminder title / message / keyboard + // labels per user. Null falls back to the app default. + locale: true, + // v1.4.49 M-DOUBLE-REMINDER — read the per-user prefs + // blob so the dispatch step can skip APNs sends for users + // whose iOS client has opted in to local SpeziScheduler + // reminders. Null = legacy default (clientManaged: false), + // i.e. the server reminder fires as before. + notificationPrefs: true, + }, + }, + }, + }); + + for (const med of medications) { + const userTz = med.user.timezone || "Europe/Berlin"; + const { start: todayStart, end: todayEnd } = getUserTodayBounds( + now, + userTz, + ); + + const currentTime = now.toLocaleTimeString("en-GB", { + timeZone: userTz, + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + + // Get today's date string in user's timezone for message tracking + const localDateStr = now.toLocaleDateString("sv-SE", { + timeZone: userTz, + }); // YYYY-MM-DD format + + // v1.7.0 code-correctness M4 — fetch today's intake events so a + // logged dose suppresses the reminder for the SLOT it belongs to, + // not a positional running counter. The pre-v1.7 code suppressed + // by `eventCount > schedulesProcessed`, which attributed a logged + // morning dose to whichever slot iterated first; with an unsorted + // `timesOfDay = ["20:00","08:00"]` that suppressed the evening + // reminder while the morning still fired. We match by time-of-day + // proximity instead. Worker-minted RED placeholders (takenAt null, + // not skipped, source REMINDER) are NOT a user action, so they are + // excluded from the suppression set. + const todayEvents = await prisma.medicationIntakeEvent.findMany({ + where: { + medicationId: med.id, + userId: med.user.id, + // v1.7.0 sync — a tombstoned dose is no longer a logged + // action, so it must not suppress today's reminder. + deletedAt: null, + scheduledFor: { gte: todayStart, lte: todayEnd }, + }, + select: { scheduledFor: true, takenAt: true, skipped: true }, + }); + const loggedDoseInstants = todayEvents + .filter((e) => e.takenAt !== null || e.skipped) + .map((e) => (e.takenAt ?? e.scheduledFor).getTime()); + + // Resolve phase configuration + const phaseConfig = med.phaseConfig ?? DEFAULT_PHASE_CONFIG; + + // Slots a logged dose has already claimed (by index into the + // chronologically-sorted slotTimes) so one dose can't suppress two. + const claimedSlotInstants = new Set(); + + const sortedSchedules = [...med.schedules].sort((a, b) => + a.windowStart.localeCompare(b.windowStart), + ); + + // v1.5.0 — fetch the latest `takenAt` for this medication + // once per tick when any schedule on it is rolling. The + // canonical recurrence engine needs `lastIntakeAt` to compute + // the next-due instant for rolling cadences; calendar + // cadences ignore the field, so the fetch is conditional to + // avoid an extra round-trip on the common path. Failures bias + // toward "treat as never logged" so a flaky DB doesn't + // suppress reminders. + const hasRollingSchedule = med.schedules.some( + (s) => s.rollingIntervalDays !== null, + ); + let lastIntakeAt: Date | null = null; + if (hasRollingSchedule) { + const lastIntake = await prisma.medicationIntakeEvent.findFirst({ + where: { + userId: med.user.id, + medicationId: med.id, + // v1.7.0 sync — a tombstoned intake no longer anchors the + // rolling-interval next-due computation. + deletedAt: null, + takenAt: { not: null }, + }, + orderBy: { takenAt: "desc" }, + select: { takenAt: true }, + }); + lastIntakeAt = lastIntake?.takenAt ?? null; + } + + for (const schedule of sortedSchedules) { + // v1.5.0 — route every "does today emit a slot?" decision + // through the canonical recurrence engine. Replaces the + // legacy weekday-only filter that silently ignored + // `intervalWeeks` (the pre-v1.5 bi-weekly bug — a Wed + // bi-weekly schedule fired every Wed instead of every other + // Wed). The engine honours RRULE / rolling / one-shot / + // legacy-with-interval-weeks / `endsOn` cap; no special- + // casing here. + const canonicalSchedule = buildCanonicalSchedule(schedule); + const recurrenceCtx = buildRecurrenceContext({ + medication: med, + userTz, + lastIntakeAt, + }); + if ( + !scheduleEmitsInWindow( + canonicalSchedule, + recurrenceCtx, + todayStart, + todayEnd, + ) + ) { + continue; + } + + // v1.7.0 SB-SCHED-4 — multi-time-of-day dispatch. A schedule + // with `timesOfDay = ["08:00","20:00"]` is two distinct dose + // slots per day; the pre-v1.7 worker keyed phase + dedup on the + // single `windowStart`, so the evening dose never reminded. + // Iterate every first-class time-of-day, each with its own + // window (anchored at the time, spanning the legacy + // `windowEnd - windowStart` duration), phase, dedup key + // (now including the time-of-day), and RED-mint instant. + // + // The legacy single-window contract is preserved: a schedule + // with no first-class `timesOfDay` emits exactly one slot at + // `windowStart` with `timeOfDay = ""`, which dedupes against + // pre-v1.7 rows (backfilled to "") byte-for-byte. + const baseStartMins = parseTimeToMinutes(schedule.windowStart); + const baseEndMins = parseTimeToMinutes(schedule.windowEnd); + const windowDuration = baseEndMins - baseStartMins; + const currentMins = parseTimeToMinutes(currentTime); + + const hasFirstClassTimes = + schedule.timesOfDay && schedule.timesOfDay.length > 0; + // v1.7.0 code-correctness M4 — iterate slots in chronological + // order so phase/dedup/suppression decisions are deterministic + // and a logged dose maps to the nearest slot, not whichever the + // stored array order happened to surface first. + const slotTimes = ( + hasFirstClassTimes + ? [...schedule.timesOfDay] + : [schedule.windowStart] + ).sort((a, b) => parseTimeToMinutes(a) - parseTimeToMinutes(b)); + + for (const slotTime of slotTimes) { + // Dedup key time-of-day: "" for a legacy single-window + // schedule (byte-stable against pre-v1.7 rows), else the + // explicit HH:mm. + const dedupTimeOfDay = hasFirstClassTimes ? slotTime : ""; + const slotStartMins = parseTimeToMinutes(slotTime); + const slotEndMins = slotStartMins + windowDuration; + const minutesToEnd = slotEndMins - currentMins; + const minutesFromStart = currentMins - slotStartMins; + + // The UTC instant this slot is due (DST-safe). + const [slotH, slotM] = slotTime.split(":").map(Number); + const slotInstant = localHmAsUtc( + now, + med.user.timezone, + slotH, + slotM, + ).getTime(); + + // Suppress this slot's reminder if a user-logged dose (taken + // or skipped) sits within half the window duration of the + // slot's due time. Matching by proximity — not a positional + // counter — means a partially-dosed day still reminds for the + // correct missing slot. Each logged dose claims at most one + // slot so two slots can't both be suppressed by one dose. + const matchRadiusMs = Math.max(windowDuration, 60) * 60_000 * 0.5; + let matchedIdx = -1; + let matchedDist = Infinity; + for (let li = 0; li < loggedDoseInstants.length; li++) { + if (claimedSlotInstants.has(li)) continue; + const dist = Math.abs(loggedDoseInstants[li] - slotInstant); + if (dist <= matchRadiusMs && dist < matchedDist) { + matchedDist = dist; + matchedIdx = li; + } + } + if (matchedIdx >= 0) { + claimedSlotInstants.add(matchedIdx); + continue; + } + + // Skip if medication is snoozed + if (med.snoozedUntil && now < med.snoozedUntil) { + continue; + } + + // Resolve phase thresholds + const thresholds = resolvePhaseThresholds( + phaseConfig, + windowDuration, + ); + + // Determine current phase for this slot's window. + const currentPhase = determinePhase( + minutesToEnd, + minutesFromStart, + thresholds, + ); + + if (!currentPhase) { + continue; + } + + // Check if this phase was already notified today for this + // time-of-day. + const existingMessage = + await prisma.telegramReminderMessage.findUnique({ + where: { + medicationId_scheduleId_date_phase_timeOfDay: { + medicationId: med.id, + scheduleId: schedule.id, + date: localDateStr, + phase: currentPhase, + timeOfDay: dedupTimeOfDay, + }, + }, + }); + + if (existingMessage) { + // Already sent for this phase + time-of-day — skip + continue; + } + + const doseInfo = schedule.dose ?? med.dose; + const timeWindow = `${slotTime}`; + + // DST-safe slot instant, computed once above. + const slotScheduledFor = new Date(slotInstant); + + // RED phase: create missed intake event for this slot. + if (currentPhase === "RED") { + // v1.8.2 — gate the missed-dose mint through the shared + // guard. It refuses to mint when the slot already carries an + // existing pending REMINDER row (P2002-collision avoidance, + // tombstones included) OR an ACTIONED row (taken / skipped) + // from ANY source. The intake write paths snap a "Genommen" / + // "Übersprungen" write onto the canonical slot instant + // (source-agnostic update), so a user who acted before the + // RED phase opens has a live taken/skipped row at this exact + // slot — minting here would re-create the duplicate the + // write paths just collapsed. + const shouldMint = await shouldMintMissedDoseRow(prisma, { + userId: med.user.id, + medicationId: med.id, + scheduledFor: slotScheduledFor, + }); + + if (shouldMint) { + await prisma.medicationIntakeEvent.create({ + data: { + userId: med.user.id, + medicationId: med.id, + scheduledFor: slotScheduledFor, + takenAt: null, + skipped: false, + source: "REMINDER", + }, + }); + + evt.addMeta("missed_dose", `${med.name}:${slotTime}`); + + // v1.4.39 W-MED — refresh the compliance rollup so the + // per-day `scheduled` count increments before any read. + await recomputeMedicationComplianceForEvent({ + userId: med.user.id, + medicationId: med.id, + scheduledFor: slotScheduledFor, + tz: med.user.timezone, + }); + } + } + + // Send notification if enabled + if (med.notificationsEnabled) { + // v1.4.49 M-DOUBLE-REMINDER — opt-in client-managed + // suppression. ONLY suppresses MEDICATION_REMINDER. + if ( + isMedicationReminderClientManaged(med.user.notificationPrefs) + ) { + const doseAtIso = slotScheduledFor.toISOString(); + evt.addMeta( + "medication_reminder_suppressed_client_managed", + `${med.name}:${slotTime}`, + ); + evt.addMeta("medication_reminder_suppressed_meta", { + user_id: med.user.id, + medication_id: med.id, + schedule_id: schedule.id, + phase: currentPhase, + dose_at: doseAtIso, + }); + continue; + } + + const { title, message } = getPhaseMessage( + currentPhase, + med.name, + doseInfo, + timeWindow, + minutesToEnd, + med.user.locale, + ); + + const keyboard = getPhaseKeyboard( + currentPhase, + med.id, + med.user.locale, + ); + + // v0.5.4 — surface the slot time as an ISO 8601 string so + // the iOS snooze action pins against the actual slot. + const scheduledAtIso = slotScheduledFor.toISOString(); + + evt.addMeta( + "notification_phase", + `${currentPhase}:${med.name}:${slotTime}`, + ); + + try { + await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: med.user.id, + title, + message, + metadata: { + medicationId: med.id, + scheduleId: schedule.id, + phase: currentPhase, + date: localDateStr, + // v1.7.0 SB-SCHED-4 — carry the dedup time-of-day so + // the Telegram-message ledger keys per slot. + timeOfDay: dedupTimeOfDay, + scheduledAt: scheduledAtIso, + replyMarkup: keyboard, + }, + }); + } catch (notifErr) { + evt.addWarning( + `Notification dispatch failed for ${currentPhase} phase ${med.name}: ${notifErr}`, + ); + } + } + } + } + } + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} diff --git a/src/lib/jobs/reminder/mood-cycle-checks.ts b/src/lib/jobs/reminder/mood-cycle-checks.ts new file mode 100644 index 000000000..cc94c338b --- /dev/null +++ b/src/lib/jobs/reminder/mood-cycle-checks.ts @@ -0,0 +1,82 @@ +/** + * Mood-reminder and cycle-reminder 15-minute tick handlers. + * + * Extracted from reminder-worker.ts, which owns the queue names, cron + * schedules, and boss.work registrations. + */ +import { type Job } from "pg-boss"; +import { recordError } from "@/lib/jobs/worker-status"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { runMoodReminderTick } from "@/lib/jobs/mood-reminder"; +import { runCycleReminderTick } from "@/lib/jobs/cycle-reminder"; +import { getWorkerPrisma } from "./shared"; + +export interface MoodReminderPayload { + triggeredAt: string; +} + +export interface CycleReminderPayload { + triggeredAt: string; +} + +export async function handleMoodReminderCheck( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.mood_reminder", async (evt) => { + const prisma = getWorkerPrisma(); + try { + const summary = await runMoodReminderTick(prisma, new Date()); + evt.setBackground({ + task_name: "job.mood_reminder", + result: { + candidates_scanned: summary.candidatesScanned, + in_window: summary.inWindow, + dispatched: summary.dispatched, + skipped_already_logged: summary.skippedAlreadyLogged, + skipped_already_dispatched: summary.skippedAlreadyDispatched, + skipped_outside_window: summary.skippedOutsideWindow, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} + +/** + * v1.15 — daily cycle-reminder dispatcher (period-soon + period-start-confirm). + * + * Thin shim around `runCycleReminderTick` in `cycle-reminder.ts` so the + * unit tests exercise the windowing + suppression logic without pg-boss. + */ +export async function handleCycleReminderCheck( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.cycle_reminder", async (evt) => { + const prisma = getWorkerPrisma(); + try { + const summary = await runCycleReminderTick(prisma, new Date()); + evt.setBackground({ + task_name: "job.cycle_reminder", + result: { + candidates_scanned: summary.candidatesScanned, + in_window: summary.inWindow, + dispatched_period_soon: summary.dispatchedPeriodSoon, + dispatched_period_confirm: summary.dispatchedPeriodConfirm, + suppressed_client_managed: summary.suppressedClientManaged, + suppressed_discreet: summary.suppressedDiscreet, + skipped_already_notified: summary.skippedAlreadyNotified, + skipped_outside_window: summary.skippedOutsideWindow, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} diff --git a/src/lib/jobs/reminder/moodlog-sync.ts b/src/lib/jobs/reminder/moodlog-sync.ts new file mode 100644 index 000000000..deca022fe --- /dev/null +++ b/src/lib/jobs/reminder/moodlog-sync.ts @@ -0,0 +1,70 @@ +/** + * moodLog hourly sync handler. + * + * Extracted from reminder-worker.ts, which owns the queue names, cron + * schedules, and boss.work registrations. + */ +import { type Job } from "pg-boss"; +import { recordError } from "@/lib/jobs/worker-status"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { syncMoodLogEntries } from "@/lib/moodlog/sync"; +import { getWorkerPrisma } from "./shared"; + +export interface MoodLogSyncPayload { + triggeredAt: string; +} + +/** + * Fallback polling for moodLog data. + * Syncs mood entries for all users with moodLog enabled. + */ +export async function handleMoodLogSync(jobs: Job[]) { + void jobs; + await withBackgroundEvent("job.moodlog_sync", async (evt) => { + const prisma = getWorkerPrisma(); + try { + // Check global toggle + const appSettings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { moodLogGlobal: true }, + }); + if (appSettings && !appSettings.moodLogGlobal) { + evt.addMeta("skipped", "global_toggle_disabled"); + return; + } + + const users = await prisma.user.findMany({ + where: { moodLogEnabled: true }, + select: { id: true }, + }); + + if (users.length === 0) return; + + let synced = 0; + let totalImported = 0; + + for (const user of users) { + try { + const imported = await syncMoodLogEntries(user.id); + synced++; + totalImported += imported; + } catch (err) { + evt.addWarning(`Fallback sync failed for user ${user.id}: ${err}`); + } + } + + evt.setBackground({ + task_name: "job.moodlog_sync", + result: { + synced, + total: users.length, + entries_imported: totalImported, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} diff --git a/src/lib/jobs/reminder/ops-handlers.ts b/src/lib/jobs/reminder/ops-handlers.ts new file mode 100644 index 000000000..a03154056 --- /dev/null +++ b/src/lib/jobs/reminder/ops-handlers.ts @@ -0,0 +1,183 @@ +/** + * Operational handlers: host metric sampling, recommendation-feedback aggregation, geo backfill, TLS pin monitoring, and personal-record detection. + * + * Extracted from reminder-worker.ts, which owns the queue names, cron + * schedules, and boss.work registrations. + */ +import { type Job } from "pg-boss"; +import { runGeoBackfill } from "@/lib/jobs/geo-backfill"; +import { runTlsPinMonitor } from "@/lib/jobs/tls-pin-monitor"; +import { type PrDetectionPayload } from "@/lib/jobs/pr-detection"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { runHostMetricTick } from "@/lib/jobs/host-metric-sampler"; +import { aggregateRecommendationFeedback } from "@/lib/jobs/feedback-aggregator"; +import { detectPersonalRecordsForUser } from "@/lib/personal-records/pr-detection-worker"; +import { getWorkerPrisma } from "./shared"; + +export interface HostMetricSamplePayload { + triggeredAt: string; +} + +export interface FeedbackAggregatorPayload { + triggeredAt: string; +} + +export interface GeoBackfillPayload { + triggeredAt: string; +} + +export interface TlsPinMonitorPayload { + triggeredAt: string; +} + +// Re-export timezone utilities under local names for backward compatibility + +export async function handleHostMetricSample( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.host_metric_sample", async (evt) => { + const p = getWorkerPrisma(); + try { + const { pruned } = await runHostMetricTick(p); + evt.addMeta("host_metric_pruned", pruned); + } catch (err) { + // The chart degrades gracefully when samples are missing — log + // and move on rather than poisoning the boss queue with retries. + evt.addWarning(`host-metric-sample failed: ${err}`); + } + }); +} + +export async function handleFeedbackAggregator( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.feedback_aggregator", async (evt) => { + const p = getWorkerPrisma(); + try { + const summary = await aggregateRecommendationFeedback(p); + evt.addMeta("feedback_buckets", summary.buckets.length); + evt.addMeta( + "feedback_total_rows", + summary.buckets.reduce((acc, b) => acc + b.total, 0), + ); + evt.addMeta("feedback_window_days", summary.windowDays); + } catch (err) { + // The admin dashboard tolerates a stale summary — log and move + // on rather than poisoning the boss queue with retries that + // would block the next cleanup window. + evt.addWarning(`feedback-aggregator failed: ${err}`); + } + }); +} + +/** + * v1.4.37 — geo-backfill worker. Walks `audit_logs` rows that landed + * with a null `location` (offline MMDB missing at write time, online + * provider unreachable) and re-resolves them through the now-bundled + * resolver chain. The helper is idempotent and capped per pass so + * the hourly cadence cannot starve a live login spike. + * + * v1.4.38 — in-process singleton guard. pg-boss already coalesces + * concurrent cron ticks across multiple worker containers via the + * shared queue lease, but a single container that takes longer than + * one cron interval can pick up two jobs back-to-back when the + * second tick fires while the first pass is still running. The + * in-process `geoBackfillRunning` flag fans the second invocation + * out as a no-op log line instead of stacking two concurrent passes + * inside the same Node process — the next cron tick after the first + * completes will catch up the work the skipped pass would have done. + */ +let geoBackfillRunning = false; + +export async function handleGeoBackfill(jobs: Job[]) { + void jobs; + await withBackgroundEvent("job.geo_backfill", async (evt) => { + if (geoBackfillRunning) { + // Earlier pass still in flight; skip this tick. Idempotent + the + // next tick after the in-flight pass completes will pick up + // anything we miss here. + evt.addWarning( + "geo-backfill skipped — earlier pass still in flight inside the same worker process", + ); + evt.addMeta("geo_backfill_skipped", true); + return; + } + geoBackfillRunning = true; + const p = getWorkerPrisma(); + try { + const summary = await runGeoBackfill(p); + evt.addMeta("geo_backfill_scanned", summary.scanned); + evt.addMeta("geo_backfill_located", summary.located); + evt.addMeta("geo_backfill_carrier_resolved", summary.carrierResolved); + evt.addMeta("geo_backfill_still_unresolved", summary.stillUnresolved); + } catch (err) { + // The admin sign-in overview tolerates a stale Standort cell — + // log and move on so a one-off resolver hiccup does not poison + // the queue and block the next pass. + evt.addWarning(`geo-backfill failed: ${err}`); + } finally { + geoBackfillRunning = false; + } + }); +} + +export async function handleTlsPinMonitor(jobs: Job[]) { + void jobs; + await withBackgroundEvent("job.tls_pin_monitor", async (evt) => { + const p = getWorkerPrisma(); + try { + const summary = await runTlsPinMonitor(p); + evt.addMeta("tls_pin_monitor_outcome", summary.outcome); + evt.addMeta("tls_pin_monitor_host", summary.host); + evt.addMeta("tls_pin_monitor_known_count", summary.knownPinCount); + } catch (err) { + // runTlsPinMonitor swallows probe failures internally; anything that + // escapes is unexpected. Log and move on so a one-off failure does not + // poison the queue and block the next tick. + evt.addWarning(`tls-pin-monitor failed: ${err}`); + } + }); +} + +export async function handlePrDetection( + jobs: Job[], +) { + for (const job of jobs) { + await withBackgroundEvent("job.pr_detection", async (evt) => { + const p = getWorkerPrisma(); + // The cron-fired job carries an empty payload — iterate all + // users in that case. The push-suppression flag is irrelevant + // for the cron path (the dispatcher's per-user opt-in handles + // the loud/quiet decision once the row is written). + const payloadUserId = (job.data as PrDetectionPayload | undefined) + ?.userId; + const silent = + (job.data as PrDetectionPayload | undefined)?.silent ?? false; + const userIds: string[] = payloadUserId + ? [payloadUserId] + : (await p.user.findMany({ select: { id: true } })).map((u) => u.id); + + let insertedTotal = 0; + let tiesTotal = 0; + for (const userId of userIds) { + try { + const result = await detectPersonalRecordsForUser(userId, { + silent, + prisma: p, + }); + insertedTotal += result.inserted; + tiesTotal += result.ties; + } catch (err) { + evt.addWarning(`pr-detection failed for user ${userId}: ${err}`); + } + } + evt.addMeta("pr_detection_users", userIds.length); + evt.addMeta("pr_detection_inserted", insertedTotal); + evt.addMeta("pr_detection_ties", tiesTotal); + evt.addMeta("pr_detection_silent", silent); + evt.addMeta("pr_detection_mode", payloadUserId ? "ingest" : "cron"); + }); + } +} diff --git a/src/lib/jobs/reminder/shared.ts b/src/lib/jobs/reminder/shared.ts new file mode 100644 index 000000000..7c98e62eb --- /dev/null +++ b/src/lib/jobs/reminder/shared.ts @@ -0,0 +1,55 @@ +/** + * Shared plumbing for the reminder-worker handler modules: the worker-local Prisma client, the stdout logger, and the HH:mm parser. + * + * Extracted from reminder-worker.ts, which owns the queue names, cron + * schedules, and boss.work registrations. + */ +import { PrismaClient } from "@/generated/prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; + +export function parseTimeToMinutes(value: string): number { + const [h, m] = value.split(":").map(Number); + if (!Number.isFinite(h) || !Number.isFinite(m)) return 0; + // Some Node ICU builds render midnight as "24:00" via toLocaleTimeString. + // Normalize so comparisons against schedule windows that wrap midnight + // don't produce a 1440-minute value. + const hours = h === 24 ? 0 : h; + return hours * 60 + m; +} + +export const DATABASE_URL = process.env.DATABASE_URL!; + +// Reuse a single PrismaClient across all job handlers to avoid connection pool exhaustion + +let workerPrisma: PrismaClient | null = null; + +export function getWorkerPrisma(): PrismaClient { + if (!workerPrisma) { + const adapter = new PrismaPg({ connectionString: DATABASE_URL }); + workerPrisma = new PrismaClient({ adapter }); + } + return workerPrisma; +} + +/** + * Internal logger that prefers structured Wide-Event annotations when a + * worker context is active, and falls back to stderr only for true + * lifecycle events that fire outside any handler (init, fatal startup + * errors, shutdown). Avoids the historical pattern of `console.log` + * everywhere in this file, which polluted production stdout and was + * never queryable in Loki. + */ +export function workerLog( + level: "info" | "error", + msg: string, + err?: unknown, +): void { + if (level === "error") { + // Errors during worker init or shutdown happen outside any request + // context, so stderr is the only audience the operator has. + if (err !== undefined) console.error(`[pg-boss] ${msg}`, err); + else console.error(`[pg-boss] ${msg}`); + } + // info-level lifecycle messages are intentionally silent — pg-boss own + // events surface state, and Wide Events from handlers carry the work. +} diff --git a/src/lib/jobs/reminder/whoop-sync.ts b/src/lib/jobs/reminder/whoop-sync.ts new file mode 100644 index 000000000..7f5513124 --- /dev/null +++ b/src/lib/jobs/reminder/whoop-sync.ts @@ -0,0 +1,101 @@ +/** + * WHOOP hourly resource syncs (recovery / sleep / workout / cycle) sharing one cohort runner. + * + * Extracted from reminder-worker.ts, which owns the queue names, cron + * schedules, and boss.work registrations. + */ +import { type Job } from "pg-boss"; +import { recordError } from "@/lib/jobs/worker-status"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { syncUserRecovery } from "@/lib/whoop/sync-recovery"; +import { syncUserSleep as syncWhoopSleep } from "@/lib/whoop/sync-sleep"; +import { syncUserCycle } from "@/lib/whoop/sync-cycle"; +import { syncUserWorkout } from "@/lib/whoop/sync-workout"; +import { getWorkerPrisma } from "./shared"; + +/** + * v1.11.0 — WHOOP per-resource sync payload. Two enqueue paths feed each + * WHOOP sync queue: + * + * 1. Webhook (`recovery.updated` / `sleep.updated` / `workout.updated`) — + * payload carries `userId`, the handler syncs that one user. + * 2. Cron — payload has no `userId`; the handler iterates every WHOOP + * connection and re-syncs each, catching dropped webhook deliveries. + * Cycle has no webhook, so its cron is the sole driver. + */ +export interface WhoopSyncPayload { + userId?: string; +} + +/** + * Shared driver for the per-resource WHOOP sync handlers. Resolves the target + * set (per-user from the webhook payload, or every connection on the cron + * tick) and runs `syncFn` per user. One user's parked-at-reauth state never + * starves the rest of the cohort on the cron path. + */ +export async function runWhoopResourceSync( + taskName: string, + jobs: Job[], + syncFn: (userId: string) => Promise, +): Promise { + await withBackgroundEvent(taskName, async (evt) => { + const prisma = getWorkerPrisma(); + try { + const targets: Array<{ userId: string }> = []; + for (const job of jobs) { + if (job.data?.userId) targets.push({ userId: job.data.userId }); + } + if (targets.length === 0) { + const connections = await prisma.whoopConnection.findMany({ + select: { userId: true }, + }); + targets.push(...connections); + } + if (targets.length === 0) return; + + let usersSynced = 0; + let measurementsImported = 0; + for (const { userId } of targets) { + try { + measurementsImported += await syncFn(userId); + usersSynced++; + } catch (err) { + evt.addWarning(`${taskName} failed for user ${userId}: ${err}`); + } + } + + evt.setBackground({ + task_name: taskName, + result: { + users_synced: usersSynced, + total: targets.length, + measurements_imported: measurementsImported, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} + +export function handleWhoopRecoverySync(jobs: Job[]) { + return runWhoopResourceSync( + "job.whoop_recovery_sync", + jobs, + syncUserRecovery, + ); +} + +export function handleWhoopSleepSync(jobs: Job[]) { + return runWhoopResourceSync("job.whoop_sleep_sync", jobs, syncWhoopSleep); +} + +export function handleWhoopWorkoutSync(jobs: Job[]) { + return runWhoopResourceSync("job.whoop_workout_sync", jobs, syncUserWorkout); +} + +export function handleWhoopCycleSync(jobs: Job[]) { + return runWhoopResourceSync("job.whoop_cycle_sync", jobs, syncUserCycle); +} diff --git a/src/lib/jobs/reminder/withings-sync.ts b/src/lib/jobs/reminder/withings-sync.ts new file mode 100644 index 000000000..3360813e9 --- /dev/null +++ b/src/lib/jobs/reminder/withings-sync.ts @@ -0,0 +1,210 @@ +/** + * Withings fallback measure sync plus the hourly activity / sleep v2 sync handlers. + * + * Extracted from reminder-worker.ts, which owns the queue names, cron + * schedules, and boss.work registrations. + */ +import { type Job } from "pg-boss"; +import { recordError, recordWithingsSync } from "@/lib/jobs/worker-status"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { syncUserMeasurements } from "@/lib/withings/sync"; +import { syncUserActivity } from "@/lib/withings/sync-activity"; +import { syncUserSleep } from "@/lib/withings/sync-sleep"; +import { getWorkerPrisma } from "./shared"; + +export interface WithingsSyncPayload { + triggeredAt: string; +} + +/** + * v1.4.25 W17b — payload for the activity-sync queue. When enqueued + * by the webhook handler, `userId` is set so the worker syncs only + * that user; when enqueued by the cron schedule, `userId` is absent + * and the worker iterates every connection (safety-net behaviour). + */ +export interface WithingsActivitySyncPayload { + triggeredAt: string; + userId?: string; +} + +/** + * v1.4.25 W17c — payload for the sleep-sync queue. Same shape and + * webhook-vs-cron semantics as the activity payload. + */ +export interface WithingsSleepSyncPayload { + triggeredAt: string; + userId?: string; +} + +/** + * Fallback polling for Withings data. + * Runs periodically in case webhook delivery is delayed or unavailable. + */ +export async function handleWithingsFallbackSync( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.withings_sync", async (evt) => { + const prisma = getWorkerPrisma(); + try { + recordWithingsSync(); + const connections = await prisma.withingsConnection.findMany({ + select: { userId: true }, + }); + + if (connections.length === 0) { + return; + } + + let usersSynced = 0; + let measurementsImported = 0; + + for (const connection of connections) { + try { + const imported = await syncUserMeasurements(connection.userId); + usersSynced++; + measurementsImported += imported; + } catch (err) { + evt.addWarning( + `Fallback sync failed for user ${connection.userId}: ${err}`, + ); + } + } + + evt.setBackground({ + task_name: "job.withings_sync", + result: { + users_synced: usersSynced, + total: connections.length, + measurements_imported: measurementsImported, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} + +/** + * v1.4.25 W17b — activity-sync handler. + * + * Two enqueue paths feed this queue: + * + * 1. Webhook (appli=16) — payload carries `userId`, the handler + * runs `syncUserActivity` for that one user. + * 2. Cron (`withings-activity-sync` at :00 every hour) — payload + * has no `userId`, the handler iterates every Withings + * connection and re-syncs each. Catches the 1 % of webhook + * deliveries Withings drops. + * + * Sync failures per-user are logged as warnings; the queue carries on + * so one user's parked-at-reauth state doesn't starve every other + * connection on the cron tick. + */ +export async function handleWithingsActivitySync( + jobs: Job[], +) { + await withBackgroundEvent("job.withings_activity_sync", async (evt) => { + const prisma = getWorkerPrisma(); + try { + const targets: Array<{ userId: string }> = []; + for (const job of jobs) { + if (job.data?.userId) { + targets.push({ userId: job.data.userId }); + } + } + // No user-specific enqueue → cron fallback iterating everyone. + if (targets.length === 0) { + const connections = await prisma.withingsConnection.findMany({ + select: { userId: true }, + }); + targets.push(...connections); + } + if (targets.length === 0) return; + + let usersSynced = 0; + let measurementsImported = 0; + for (const { userId } of targets) { + try { + const imported = await syncUserActivity(userId); + usersSynced++; + measurementsImported += imported; + } catch (err) { + evt.addWarning( + `Withings activity sync failed for user ${userId}: ${err}`, + ); + } + } + + evt.setBackground({ + task_name: "job.withings_activity_sync", + result: { + users_synced: usersSynced, + total: targets.length, + measurements_imported: measurementsImported, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} + +/** + * v1.4.25 W17c — sleep-sync handler. Same enqueue semantics as the + * activity handler: per-user when the webhook fires, full-iteration + * when the cron ticks. + */ +export async function handleWithingsSleepSync( + jobs: Job[], +) { + await withBackgroundEvent("job.withings_sleep_sync", async (evt) => { + const prisma = getWorkerPrisma(); + try { + const targets: Array<{ userId: string }> = []; + for (const job of jobs) { + if (job.data?.userId) { + targets.push({ userId: job.data.userId }); + } + } + if (targets.length === 0) { + const connections = await prisma.withingsConnection.findMany({ + select: { userId: true }, + }); + targets.push(...connections); + } + if (targets.length === 0) return; + + let usersSynced = 0; + let measurementsImported = 0; + for (const { userId } of targets) { + try { + const imported = await syncUserSleep(userId); + usersSynced++; + measurementsImported += imported; + } catch (err) { + evt.addWarning( + `Withings sleep sync failed for user ${userId}: ${err}`, + ); + } + } + + evt.setBackground({ + task_name: "job.withings_sleep_sync", + result: { + users_synced: usersSynced, + total: targets.length, + measurements_imported: measurementsImported, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} diff --git a/src/lib/jobs/restore-drill.ts b/src/lib/jobs/restore-drill.ts new file mode 100644 index 000000000..c37b6fc73 --- /dev/null +++ b/src/lib/jobs/restore-drill.ts @@ -0,0 +1,193 @@ +/** + * Monthly off-host backup restore drill (v1.16.4). + * + * A backup that has never been read back is a hope, not a backup. The + * off-host uploader (`offhost-backup.ts`) writes an encrypted snapshot + * per user every night, but nothing on the write path proves the + * objects are still fetchable, decryptable under the CURRENT + * `BACKUP_ENCRYPTION_KEY`, and structurally parseable. A silent key + * rotation mishap or a bucket-side corruption would only surface on + * the day a real restore is needed. + * + * This drill closes that gap once a month: fetch the most recent + * backup object, decrypt it, JSON-parse it, and sanity-check the + * payload shape. It deliberately performs NO database restore — the + * drill validates the artefact, not the import path. The result is + * surfaced through the wide-event meta on success and through + * `reportWorkerError` (stderr + GlitchTip) on any failure, so a broken + * backup chain pages the operator eleven months before it matters. + * + * Schedule: 04:11 on the 1st of each month (`11 4 1 * *`), after the + * 02:30 nightly upload and the 03:xx cleanup window, on a minute slot + * no other cron uses. + */ +import type { Job } from "pg-boss"; +import { + decryptBackup, + getS3Client, + loadOffhostConfig, + OffhostBackupNotConfiguredError, + type S3Like, +} from "@/lib/jobs/offhost-backup"; +import { reportWorkerError } from "@/lib/jobs/report-worker-error"; +import { withBackgroundEvent } from "@/lib/logging/background"; + +export const RESTORE_DRILL_QUEUE = "data-restore-drill"; +export const RESTORE_DRILL_CRON = "11 4 1 * *"; + +/** + * A drill run that finds the newest backup older than this is reported + * as a failure even when decrypt + parse succeed: the nightly uploader + * has evidently stopped producing fresh objects (or the lifecycle rule + * is eating them faster than they are written). + */ +const MAX_BACKUP_AGE_DAYS = 3; + +const BACKUP_KEY_PATTERN = /^(\d{4}-\d{2}-\d{2})\/user-.+\.json\.enc$/; + +export interface RestoreDrillReport { + objectKey: string; + dateKey: string; + ageDays: number; + stale: boolean; + ciphertextBytes: number; + plaintextBytes: number; + recordCounts: { + measurements: number; + medications: number; + intakeEvents: number; + moodEntries: number; + }; +} + +function countArray(value: unknown): number { + return Array.isArray(value) ? value.length : 0; +} + +/** + * Fetch → decrypt → parse the most recent off-host backup object. + * + * Read-only against the bucket (GetObject + ListObjects — both inside + * the uploader's existing IAM grant). Throws on every failure mode: + * not configured, empty bucket, fetch error, bad envelope / wrong key, + * malformed JSON, payload missing its core fields. + */ +export async function runRestoreDrill( + s3Override?: S3Like, + now: Date = new Date(), +): Promise { + const cfg = loadOffhostConfig(); + if (!cfg) { + throw new OffhostBackupNotConfiguredError( + "Off-host backup not configured — restore drill has nothing to verify.", + ); + } + const s3 = s3Override ?? (await getS3Client(cfg)); + + // Date-prefixed keys (`YYYY-MM-DD/user-.json.enc`) sort + // lexicographically in chronological order, so the newest object is + // simply the maximum matching key. `_healthcheck/` probes and any + // foreign objects in the bucket are filtered out by the pattern. + const objects = await s3.listObjects(""); + const backupKeys = objects + .map((o) => o.key) + .filter((k) => BACKUP_KEY_PATTERN.test(k)) + .sort(); + if (backupKeys.length === 0) { + throw new Error( + `Restore drill found no backup objects in bucket "${cfg.bucket}" — the nightly off-host upload is not producing artefacts.`, + ); + } + const objectKey = backupKeys[backupKeys.length - 1]; + const dateKey = BACKUP_KEY_PATTERN.exec(objectKey)![1]; + + const ciphertext = await s3.getObject(objectKey); + const plaintext = decryptBackup(ciphertext, cfg.encryptionKey); + const parsed: unknown = JSON.parse(plaintext); + + if (typeof parsed !== "object" || parsed === null) { + throw new Error( + `Restore drill: backup object "${objectKey}" decrypted and parsed but is not a JSON object.`, + ); + } + const payload = parsed as Record; + if ( + typeof payload.exportedAt !== "string" || + typeof payload.userId !== "string" || + !Array.isArray(payload.measurements) + ) { + throw new Error( + `Restore drill: backup object "${objectKey}" parses but is missing core fields (exportedAt / userId / measurements).`, + ); + } + + const ageDays = Math.floor( + (now.getTime() - Date.parse(`${dateKey}T00:00:00Z`)) / + (24 * 60 * 60 * 1000), + ); + + return { + objectKey, + dateKey, + ageDays, + stale: ageDays > MAX_BACKUP_AGE_DAYS, + ciphertextBytes: ciphertext.length, + plaintextBytes: Buffer.byteLength(plaintext, "utf8"), + recordCounts: { + measurements: payload.measurements.length, + medications: countArray(payload.medications), + intakeEvents: countArray(payload.intakeEvents), + moodEntries: countArray(payload.moodEntries), + }, + }; +} + +/** + * pg-boss handler. Mirrors the off-host uploader's posture: a + * not-configured deployment skips with a wide-event warning (most + * self-hosters never set the S3 vars and must not see a monthly error + * page); every other failure goes through `reportWorkerError` so the + * operator hears about a rotting backup chain. + */ +export async function handleRestoreDrill(jobs: Job[]): Promise { + void jobs; + await withBackgroundEvent("job.restore_drill", async (evt) => { + try { + const report = await runRestoreDrill(); + evt.addMeta("restore_drill_object_key", report.objectKey); + evt.addMeta("restore_drill_age_days", report.ageDays); + evt.addMeta("restore_drill_ciphertext_bytes", report.ciphertextBytes); + evt.addMeta("restore_drill_plaintext_bytes", report.plaintextBytes); + evt.addMeta( + "restore_drill_measurements", + report.recordCounts.measurements, + ); + evt.addMeta("restore_drill_medications", report.recordCounts.medications); + evt.addMeta( + "restore_drill_intake_events", + report.recordCounts.intakeEvents, + ); + evt.addMeta( + "restore_drill_mood_entries", + report.recordCounts.moodEntries, + ); + evt.addMeta("restore_drill_stale", report.stale); + if (report.stale) { + await reportWorkerError( + RESTORE_DRILL_QUEUE, + new Error( + `Newest off-host backup is ${report.ageDays} days old (threshold ${MAX_BACKUP_AGE_DAYS}) — the nightly upload chain has stalled.`, + ), + { objectKey: report.objectKey, ageDays: report.ageDays }, + ); + } + } catch (err) { + if (err instanceof OffhostBackupNotConfiguredError) { + evt.addWarning(`restore-drill skipped: ${err.message}`); + return; + } + evt.addWarning(`restore-drill failed: ${err}`); + await reportWorkerError(RESTORE_DRILL_QUEUE, err); + } + }); +}