mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
35e8f076a2
commit
410c62c292
29 files changed
+3173
-2426
No files matched your search
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
+248
-1978
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user