refactor(jobs,dashboard): a thin worker registry and a composed dashboard

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 00:10:01 +02:00
1 parent 35e8f076a2
commit 410c62c292
29 files changed
+3173 -2426

No files matched your search

+1 -1
View File
@@ -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"]';
+21 -396
View File
@@ -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<HTMLElement>(
'[data-slot="responsive-sheet-body"]',
);
if (!body) return false;
const fields = body.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(
"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 (
<>
<p>
<span className="text-success font-bold">{t("charts.colorGreen")}</span>{" "}
{format(range.greenMin)}-{format(range.greenMax)} {unit}
</p>
<p>
<span className="text-warning font-bold">
{t("charts.colorOrange")}
</span>{" "}
{format(range.orangeMin)}-{format(range.greenMin)} {t("common.or")}{" "}
{format(range.greenMax)}-{format(range.orangeMax)} {unit}
</p>
<p>
<span className="text-destructive font-bold">
{t("charts.colorRed")}
</span>{" "}
{"< "}
{format(range.orangeMin)} {t("common.or")} {"> "}
{format(range.orangeMax)} {unit}
</p>
</>
);
}
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<HTMLDivElement | null>(null);
const [moodFooterEl, setMoodFooterEl] = useState<HTMLDivElement | null>(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<HTMLDivElement | null>(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<QuickEntryDialog>(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 (
<div className="space-y-6">
{/* 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). */}
<div className="flex items-center justify-between gap-4 sm:items-start">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("dashboard.title")}
</h1>
<p className="text-muted-foreground mt-1 text-sm">{welcomeText}</p>
</div>
<div className="flex items-center gap-2">
{/* 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+. */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
data-testid="dashboard-customize-shortcut"
>
<Link
href="/settings/dashboard"
aria-label={t("dashboard.customizeDashboard")}
title={t("dashboard.customizeDashboard")}
>
<Settings2 className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* 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. */}
<Button
size="default"
className="min-h-11 sm:min-h-9"
data-tour-id="dashboard-quick-add"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-w-[calc(100vw-2rem)]"
>
{/* 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`. */}
<DropdownMenuItem
onClick={() => setQuickEntryDialog("measurement")}
>
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMeasurement")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setQuickEntryDialog("mood")}>
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMood")}
</DropdownMenuItem>
{/* 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). */}
<DropdownMenuItem
onClick={() => setQuickEntryDialog("medicationIntake")}
>
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMedicationIntake")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<DashboardHeader
welcomeText={welcomeText}
onQuickEntry={setQuickEntryDialog}
/>
{/* v1.4: Getting-started checklist for brand-new users.
* Self-gates visibility on (onboardingCompletedAt == null
@@ -942,82 +639,10 @@ export default function DashboardPage() {
<TourLauncher ready={data !== undefined} />
{/* Quick Entry Sheets — bottom-sheet on `<md`, centred Dialog on `md+`. */}
<ResponsiveSheet
open={quickEntryDialog === "measurement"}
onOpenChange={handleQuickEntryOpenChange}
title={t("measurements.addMeasurement")}
footer={<div ref={setMeasurementFooterEl} className="flex w-full" />}
>
<MeasurementForm
onSuccess={() => setQuickEntryDialog(null)}
onCancel={() => setQuickEntryDialog(null)}
footerSlot={measurementFooterEl}
/>
</ResponsiveSheet>
<ResponsiveSheet
open={quickEntryDialog === "mood"}
onOpenChange={handleQuickEntryOpenChange}
title={t("mood.addEntry")}
footer={<div ref={setMoodFooterEl} className="flex w-full" />}
>
<MoodForm
onSuccess={() => setQuickEntryDialog(null)}
onCancel={() => setQuickEntryDialog(null)}
footerSlot={moodFooterEl}
/>
</ResponsiveSheet>
{/* 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. */}
<ResponsiveSheet
open={quickEntryDialog === "medicationIntake"}
onOpenChange={handleQuickEntryOpenChange}
title={t("dashboard.medicationIntakeQuickAdd.sheetTitle")}
description={t("dashboard.medicationIntakeQuickAdd.sheetDescription")}
footer={
<div ref={setMedicationIntakeFooterEl} className="flex w-full" />
}
>
<MedicationIntakeQuickAdd
onSuccess={() => setQuickEntryDialog(null)}
onCancel={() => setQuickEntryDialog(null)}
footerSlot={medicationIntakeFooterEl}
/>
</ResponsiveSheet>
{/* 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. */}
<AlertDialog
open={confirmDiscardOpen}
onOpenChange={setConfirmDiscardOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("dashboard.quickEntryDiscard.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("dashboard.quickEntryDiscard.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("dashboard.quickEntryDiscard.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setConfirmDiscardOpen(false);
setQuickEntryDialog(null);
}}
>
{t("dashboard.quickEntryDiscard.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<QuickEntrySheets
open={quickEntryDialog}
onClose={() => setQuickEntryDialog(null)}
/>
{(() => {
type TrendEntry = { id: string; order: number; node: React.ReactNode };
@@ -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<QuickEntryDialog>) => 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). */
<div className="flex items-center justify-between gap-4 sm:items-start">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("dashboard.title")}
</h1>
<p className="text-muted-foreground mt-1 text-sm">{welcomeText}</p>
</div>
<div className="flex items-center gap-2">
{/* 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+. */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
data-testid="dashboard-customize-shortcut"
>
<Link
href="/settings/dashboard"
aria-label={t("dashboard.customizeDashboard")}
title={t("dashboard.customizeDashboard")}
>
<Settings2 className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* 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. */}
<Button
size="default"
className="min-h-11 sm:min-h-9"
data-tour-id="dashboard-quick-add"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-w-[calc(100vw-2rem)]">
{/* 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`. */}
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMeasurement")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMood")}
</DropdownMenuItem>
{/* 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). */}
<DropdownMenuItem onClick={() => onQuickEntry("medicationIntake")}>
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMedicationIntake")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
@@ -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<string | null>(
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<string | null>(null);
const [takenAt, setTakenAt] = useState<string>(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"
/>
<p className="text-muted-foreground text-xs">
{t("dashboard.medicationIntakeQuickAdd.doseHint")}
@@ -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 `<md`, centred Dialog on `md+`), plus the
* confirm-before-discard guard for dirty forms.
*
* Extracted from the dashboard page; the page keeps the open-state and
* the quick-add menu that triggers each sheet.
*/
import { useState } from "react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { MeasurementForm } from "@/components/measurements/measurement-form";
import { MoodForm } from "@/components/mood/mood-form";
import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add";
import { useTranslations } from "@/lib/i18n/context";
export type QuickEntryDialog =
| "measurement"
| "mood"
| "medicationIntake"
| null;
/**
* 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<HTMLElement>(
'[data-slot="responsive-sheet-body"]',
);
if (!body) return false;
const fields = body.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(
"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<HTMLDivElement | null>(null);
const [moodFooterEl, setMoodFooterEl] = useState<HTMLDivElement | null>(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<HTMLDivElement | null>(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 (
<>
<ResponsiveSheet
open={open === "measurement"}
onOpenChange={handleQuickEntryOpenChange}
title={t("measurements.addMeasurement")}
footer={<div ref={setMeasurementFooterEl} className="flex w-full" />}
>
<MeasurementForm
onSuccess={onClose}
onCancel={onClose}
footerSlot={measurementFooterEl}
/>
</ResponsiveSheet>
<ResponsiveSheet
open={open === "mood"}
onOpenChange={handleQuickEntryOpenChange}
title={t("mood.addEntry")}
footer={<div ref={setMoodFooterEl} className="flex w-full" />}
>
<MoodForm
onSuccess={onClose}
onCancel={onClose}
footerSlot={moodFooterEl}
/>
</ResponsiveSheet>
{/* 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. */}
<ResponsiveSheet
open={open === "medicationIntake"}
onOpenChange={handleQuickEntryOpenChange}
title={t("dashboard.medicationIntakeQuickAdd.sheetTitle")}
description={t("dashboard.medicationIntakeQuickAdd.sheetDescription")}
footer={
<div ref={setMedicationIntakeFooterEl} className="flex w-full" />
}
>
<MedicationIntakeQuickAdd
onSuccess={onClose}
onCancel={onClose}
footerSlot={medicationIntakeFooterEl}
/>
</ResponsiveSheet>
{/* 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. */}
<AlertDialog
open={confirmDiscardOpen}
onOpenChange={setConfirmDiscardOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("dashboard.quickEntryDiscard.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("dashboard.quickEntryDiscard.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("dashboard.quickEntryDiscard.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setConfirmDiscardOpen(false);
onClose();
}}
>
{t("dashboard.quickEntryDiscard.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
+117
View File
@@ -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 (
<>
<p>
<span className="text-success font-bold">{t("charts.colorGreen")}</span>{" "}
{format(range.greenMin)}-{format(range.greenMax)} {unit}
</p>
<p>
<span className="text-warning font-bold">
{t("charts.colorOrange")}
</span>{" "}
{format(range.orangeMin)}-{format(range.greenMin)} {t("common.or")}{" "}
{format(range.greenMax)}-{format(range.orangeMax)} {unit}
</p>
<p>
<span className="text-destructive font-bold">
{t("charts.colorRed")}
</span>{" "}
{"< "}
{format(range.orangeMin)} {t("common.or")} {"> "}
{format(range.orangeMax)} {unit}
</p>
</>
);
}
@@ -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/);
});
+3 -1
View File
@@ -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",
+7 -5
View File
@@ -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");
@@ -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<string, Buffer> = {}) {
const store = new Map<string, Buffer>(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<string, unknown> = {}): 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,
);
});
});
@@ -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/);
});
@@ -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", () => {
+4 -4
View File
@@ -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["']/);
});
@@ -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", () => {
+1 -1
View File
@@ -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<void>;
getObject(key: string): Promise<Buffer>;
headObject(key: string): Promise<boolean>;
File diff suppressed because it is too large. Load diff
+164
View File
@@ -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<OffhostBackupPayload>[]) {
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<DataBackupPayload>[]) {
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;
}
});
}
+240
View File
@@ -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<MoodReminderCleanupPayload>[],
) {
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<PushAttemptCleanupPayload>[],
) {
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<MeasurementTombstoneCleanupPayload>[],
) {
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<RateLimitCleanupPayload>[],
) {
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<IdempotencyCleanupPayload>[],
) {
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<AuditLogCleanupPayload>[],
) {
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<WithingsOAuthStateCleanupPayload>[],
) {
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<WhoopOAuthStateCleanupPayload>[],
) {
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}`);
}
});
}
+84
View File
@@ -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<FitbitSyncPayload>[]) {
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<FitbitOAuthStateCleanupPayload>[],
) {
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}`);
}
});
}
+263
View File
@@ -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<unknown>,
options: { recordRun?: boolean } = {},
): Promise<void> {
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<GeneralStatusPayload>[]) {
void jobs;
return runStatusCronGenerate(
"job.insights.general",
generateGeneralStatusForUser,
{ recordRun: true },
);
}
export function handleBloodPressureStatusGenerate(
jobs: Job<BloodPressureStatusPayload>[],
) {
void jobs;
return runStatusCronGenerate(
"job.insights.blood_pressure",
generateBloodPressureStatusForUser,
);
}
export function handleWeightStatusGenerate(jobs: Job<WeightStatusPayload>[]) {
void jobs;
return runStatusCronGenerate(
"job.insights.weight",
generateWeightStatusForUser,
);
}
export function handlePulseStatusGenerate(jobs: Job<PulseStatusPayload>[]) {
void jobs;
return runStatusCronGenerate(
"job.insights.pulse",
generatePulseStatusForUser,
);
}
export function handleBmiStatusGenerate(jobs: Job<BmiStatusPayload>[]) {
void jobs;
return runStatusCronGenerate("job.insights.bmi", generateBmiStatusForUser);
}
export function handleMoodStatusGenerate(jobs: Job<MoodStatusPayload>[]) {
void jobs;
return runStatusCronGenerate("job.insights.mood", generateMoodStatusForUser);
}
export function handleMedicationComplianceStatusGenerate(
jobs: Job<MedicationComplianceStatusPayload>[],
) {
void jobs;
return runStatusCronGenerate(
"job.insights.medication_compliance",
generateMedicationComplianceStatusForUser,
);
}
export async function handleInsightPregenerateJob(
jobs: Job<InsightPregeneratePayload>[],
) {
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<InsightStatusGeneratePayload>[],
) {
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;
}
}
});
}
@@ -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<MedicationInventoryExpirePayload>[],
) {
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<IntakeAutoSkipPayload>[]) {
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)
}`,
);
}
});
}
@@ -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<void> {
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<ReminderCheckPayload>[]) {
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<number>();
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;
}
});
}
@@ -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<MoodReminderPayload>[],
) {
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<CycleReminderPayload>[],
) {
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;
}
});
}
+70
View File
@@ -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<MoodLogSyncPayload>[]) {
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;
}
});
}
+183
View File
@@ -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<HostMetricSamplePayload>[],
) {
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<FeedbackAggregatorPayload>[],
) {
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<GeoBackfillPayload>[]) {
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<TlsPinMonitorPayload>[]) {
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<PrDetectionPayload | { userId?: undefined }>[],
) {
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");
});
}
}
+55
View File
@@ -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.
}
+101
View File
@@ -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<WhoopSyncPayload>[],
syncFn: (userId: string) => Promise<number>,
): Promise<void> {
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<WhoopSyncPayload>[]) {
return runWhoopResourceSync(
"job.whoop_recovery_sync",
jobs,
syncUserRecovery,
);
}
export function handleWhoopSleepSync(jobs: Job<WhoopSyncPayload>[]) {
return runWhoopResourceSync("job.whoop_sleep_sync", jobs, syncWhoopSleep);
}
export function handleWhoopWorkoutSync(jobs: Job<WhoopSyncPayload>[]) {
return runWhoopResourceSync("job.whoop_workout_sync", jobs, syncUserWorkout);
}
export function handleWhoopCycleSync(jobs: Job<WhoopSyncPayload>[]) {
return runWhoopResourceSync("job.whoop_cycle_sync", jobs, syncUserCycle);
}
+210
View File
@@ -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<WithingsSyncPayload>[],
) {
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<WithingsActivitySyncPayload>[],
) {
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<WithingsSleepSyncPayload>[],
) {
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;
}
});
}
+193
View File
@@ -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<RestoreDrillReport> {
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-<id>.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<string, unknown>;
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<object>[]): Promise<void> {
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);
}
});
}