mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
merge: one activity-aware reminder engine (Vorsorge + eventful satisfaction + lab-resolve + type completeness)
This commit is contained in:
commit
b6796eee96
30 files changed
+1168
-99
No files matched your search
+12
-3
@@ -7184,10 +7184,19 @@ components:
|
||||
- PULSE
|
||||
- BLOOD_GLUCOSE
|
||||
- OXYGEN_SATURATION
|
||||
- BODY_FAT
|
||||
- BODY_TEMPERATURE
|
||||
description: Auto-resolve target metric. BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured'
|
||||
sentinel). Omit for a free-text Vorsorge that resolves only on a manual satisfy.
|
||||
- BODY_FAT
|
||||
- FAT_MASS
|
||||
- FAT_FREE_MASS
|
||||
- MUSCLE_MASS
|
||||
- LEAN_BODY_MASS
|
||||
- BONE_MASS
|
||||
- TOTAL_BODY_WATER
|
||||
- VISCERAL_FAT
|
||||
- BODY_MASS_INDEX
|
||||
description: Auto-resolve target metric (vitals + body-composition family). BP resolves on BLOOD_PRESSURE_SYS (the SYS
|
||||
row is the 'a BP was measured' sentinel). Omit for a free-text Vorsorge that resolves only on a manual satisfy
|
||||
or a matching lab result.
|
||||
MeasurementReminderUpdate:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+13
-1
@@ -6478,7 +6478,19 @@
|
||||
"BLOOD_GLUCOSE": "Blutzucker",
|
||||
"OXYGEN_SATURATION": "Sauerstoffsättigung",
|
||||
"BODY_FAT": "Körperfett",
|
||||
"BODY_TEMPERATURE": "Körpertemperatur"
|
||||
"BODY_TEMPERATURE": "Körpertemperatur",
|
||||
"FAT_MASS": "Fettmasse",
|
||||
"FAT_FREE_MASS": "Fettfreie Masse",
|
||||
"MUSCLE_MASS": "Muskelmasse",
|
||||
"LEAN_BODY_MASS": "Magermasse",
|
||||
"BONE_MASS": "Knochenmasse",
|
||||
"TOTAL_BODY_WATER": "Körperwasser",
|
||||
"VISCERAL_FAT": "Viszeralfett",
|
||||
"BODY_MASS_INDEX": "Body-Mass-Index (BMI)"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Vitalwerte",
|
||||
"bodyComposition": "Körperzusammensetzung"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Noch keine Erinnerungen",
|
||||
|
||||
+13
-1
@@ -6478,7 +6478,19 @@
|
||||
"BLOOD_GLUCOSE": "Blood glucose",
|
||||
"OXYGEN_SATURATION": "Oxygen saturation",
|
||||
"BODY_FAT": "Body fat",
|
||||
"BODY_TEMPERATURE": "Body temperature"
|
||||
"BODY_TEMPERATURE": "Body temperature",
|
||||
"FAT_MASS": "Fat mass",
|
||||
"FAT_FREE_MASS": "Fat-free mass",
|
||||
"MUSCLE_MASS": "Muscle mass",
|
||||
"LEAN_BODY_MASS": "Lean body mass",
|
||||
"BONE_MASS": "Bone mass",
|
||||
"TOTAL_BODY_WATER": "Body water",
|
||||
"VISCERAL_FAT": "Visceral fat",
|
||||
"BODY_MASS_INDEX": "Body mass index (BMI)"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Vitals",
|
||||
"bodyComposition": "Body composition"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No reminders yet",
|
||||
|
||||
+13
-1
@@ -6478,7 +6478,19 @@
|
||||
"BLOOD_GLUCOSE": "Glucosa en sangre",
|
||||
"OXYGEN_SATURATION": "Saturación de oxígeno",
|
||||
"BODY_FAT": "Grasa corporal",
|
||||
"BODY_TEMPERATURE": "Temperatura corporal"
|
||||
"BODY_TEMPERATURE": "Temperatura corporal",
|
||||
"FAT_MASS": "Masa grasa",
|
||||
"FAT_FREE_MASS": "Masa libre de grasa",
|
||||
"MUSCLE_MASS": "Masa muscular",
|
||||
"LEAN_BODY_MASS": "Masa magra",
|
||||
"BONE_MASS": "Masa ósea",
|
||||
"TOTAL_BODY_WATER": "Agua corporal",
|
||||
"VISCERAL_FAT": "Grasa visceral",
|
||||
"BODY_MASS_INDEX": "Índice de masa corporal (IMC)"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Constantes vitales",
|
||||
"bodyComposition": "Composición corporal"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Aún no hay recordatorios",
|
||||
|
||||
+13
-1
@@ -6478,7 +6478,19 @@
|
||||
"BLOOD_GLUCOSE": "Glycémie",
|
||||
"OXYGEN_SATURATION": "Saturation en oxygène",
|
||||
"BODY_FAT": "Masse grasse",
|
||||
"BODY_TEMPERATURE": "Température corporelle"
|
||||
"BODY_TEMPERATURE": "Température corporelle",
|
||||
"FAT_MASS": "Masse grasse",
|
||||
"FAT_FREE_MASS": "Masse maigre totale",
|
||||
"MUSCLE_MASS": "Masse musculaire",
|
||||
"LEAN_BODY_MASS": "Masse maigre",
|
||||
"BONE_MASS": "Masse osseuse",
|
||||
"TOTAL_BODY_WATER": "Eau corporelle",
|
||||
"VISCERAL_FAT": "Graisse viscérale",
|
||||
"BODY_MASS_INDEX": "Indice de masse corporelle (IMC)"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Constantes vitales",
|
||||
"bodyComposition": "Composition corporelle"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Aucun rappel pour l'instant",
|
||||
|
||||
+13
-1
@@ -6478,7 +6478,19 @@
|
||||
"BLOOD_GLUCOSE": "Glicemia",
|
||||
"OXYGEN_SATURATION": "Saturazione di ossigeno",
|
||||
"BODY_FAT": "Grasso corporeo",
|
||||
"BODY_TEMPERATURE": "Temperatura corporea"
|
||||
"BODY_TEMPERATURE": "Temperatura corporea",
|
||||
"FAT_MASS": "Massa grassa",
|
||||
"FAT_FREE_MASS": "Massa magra totale",
|
||||
"MUSCLE_MASS": "Massa muscolare",
|
||||
"LEAN_BODY_MASS": "Massa magra",
|
||||
"BONE_MASS": "Massa ossea",
|
||||
"TOTAL_BODY_WATER": "Acqua corporea",
|
||||
"VISCERAL_FAT": "Grasso viscerale",
|
||||
"BODY_MASS_INDEX": "Indice di massa corporea (IMC)"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Parametri vitali",
|
||||
"bodyComposition": "Composizione corporea"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Ancora nessun promemoria",
|
||||
|
||||
+13
-1
@@ -6478,7 +6478,19 @@
|
||||
"BLOOD_GLUCOSE": "Glukoza we krwi",
|
||||
"OXYGEN_SATURATION": "Saturacja tlenem",
|
||||
"BODY_FAT": "Tkanka tłuszczowa",
|
||||
"BODY_TEMPERATURE": "Temperatura ciała"
|
||||
"BODY_TEMPERATURE": "Temperatura ciała",
|
||||
"FAT_MASS": "Masa tłuszczowa",
|
||||
"FAT_FREE_MASS": "Masa beztłuszczowa",
|
||||
"MUSCLE_MASS": "Masa mięśniowa",
|
||||
"LEAN_BODY_MASS": "Masa szczupła",
|
||||
"BONE_MASS": "Masa kostna",
|
||||
"TOTAL_BODY_WATER": "Woda w organizmie",
|
||||
"VISCERAL_FAT": "Tłuszcz trzewny",
|
||||
"BODY_MASS_INDEX": "Wskaźnik masy ciała (BMI)"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Parametry życiowe",
|
||||
"bodyComposition": "Skład ciała"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Brak przypomnień",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { encryptNoteToBytes } from "@/lib/labs/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
@@ -184,5 +185,10 @@ async function postLabResult(request: NextRequest) {
|
||||
meta: { labResultId: created.id },
|
||||
});
|
||||
|
||||
// v1.18.1 (D2) — eventful Lab↔Vorsorge satisfaction. A lab panel just
|
||||
// landed; resolve the user's free-text "annual blood panel" reminders
|
||||
// now rather than waiting on the 15-min cron. Fire-and-forget.
|
||||
void enqueueReminderSatisfy(user.id).catch(() => {});
|
||||
|
||||
return apiSuccess(serialiseLabResult(created), 201);
|
||||
}
|
||||
@@ -13,10 +13,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
computeReminderNextDueAt,
|
||||
type ReminderScheduleInput,
|
||||
} from "@/lib/measurement-reminders/scheduling";
|
||||
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -42,19 +39,14 @@ export const POST = apiHandler(
|
||||
const timezone = userRow?.timezone || DEFAULT_TIMEZONE;
|
||||
|
||||
const now = new Date();
|
||||
const scheduleInput: ReminderScheduleInput = {
|
||||
intervalDays: existing.intervalDays,
|
||||
rrule: existing.rrule,
|
||||
anchorDate: existing.anchorDate,
|
||||
notifyHour: existing.notifyHour,
|
||||
lastSatisfiedAt: now,
|
||||
createdAt: existing.createdAt,
|
||||
};
|
||||
const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
|
||||
// The ONE shared satisfaction primitive — same code the cron
|
||||
// auto-resolve and the eventful worker use. A manual "Erledigt" is
|
||||
// always strictly after any prior satisfy, so the forward-only guard
|
||||
// advances it.
|
||||
await satisfyReminder(prisma, existing, timezone, now);
|
||||
|
||||
const updated = await prisma.measurementReminder.update({
|
||||
const updated = await prisma.measurementReminder.findUniqueOrThrow({
|
||||
where: { id },
|
||||
data: { lastSatisfiedAt: now, nextDueAt },
|
||||
});
|
||||
|
||||
await auditLog("measurementReminder.satisfy", {
|
||||
|
||||
@@ -52,6 +52,7 @@ import { validateMeasurementRange } from "@/lib/validations/measurement";
|
||||
import { deviceTypeEnum } from "@/lib/validations/source-priority";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import {
|
||||
@@ -599,6 +600,10 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// personal record.
|
||||
if (insertedCount > 0 || updatedCount > 0 || duplicateCount > 0) {
|
||||
const silent = entries.length > PR_DETECTION_SILENT_THRESHOLD;
|
||||
// v1.18.1 — eventful Vorsorge satisfaction. A matching reading just
|
||||
// landed; resolve the user's reminders now rather than waiting on the
|
||||
// 15-min cron. Fire-and-forget — the cron reconciles on enqueue miss.
|
||||
void enqueueReminderSatisfy(user.id).catch(() => {});
|
||||
try {
|
||||
await enqueuePrDetection(user.id, { silent });
|
||||
await auditLog("personal_records.detection_enqueued", {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import {
|
||||
recomputeBucketsForMeasurement,
|
||||
collapseToTypeDayKeys,
|
||||
@@ -924,6 +925,10 @@ async function postMeasurement(request: NextRequest) {
|
||||
// SWR readers don't serve the pre-write body back to the user.
|
||||
invalidateUserMeasurements(user.id, { evict: true });
|
||||
|
||||
// v1.18.1 — eventful Vorsorge satisfaction. Resolve the user's
|
||||
// reminders against the just-landed readings now. Fire-and-forget.
|
||||
void enqueueReminderSatisfy(user.id).catch(() => {});
|
||||
|
||||
// v1.5.0 — refresh the persistent rollup table for every distinct
|
||||
// (type, day) the batch touched so the next analytics / coach read
|
||||
// hits the cache rather than falling through to live aggregation.
|
||||
@@ -1037,6 +1042,10 @@ async function postMeasurement(request: NextRequest) {
|
||||
// pre-write body back to the user.
|
||||
invalidateUserMeasurements(user.id, { evict: true });
|
||||
|
||||
// v1.18.1 — eventful Vorsorge satisfaction. Resolve the user's reminders
|
||||
// against the just-landed reading now. Fire-and-forget.
|
||||
void enqueueReminderSatisfy(user.id).catch(() => {});
|
||||
|
||||
// v1.5.0 — refresh the persistent rollup row for the affected
|
||||
// (type, day) tuple. Runs inline so the next read of the
|
||||
// comprehensive / analytics surface is correct without waiting on
|
||||
|
||||
@@ -38,14 +38,37 @@ import {
|
||||
type MeasurementReminder,
|
||||
} from "@/hooks/use-measurement-reminders";
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"PULSE",
|
||||
"BLOOD_GLUCOSE",
|
||||
"OXYGEN_SATURATION",
|
||||
"BODY_FAT",
|
||||
"BODY_TEMPERATURE",
|
||||
// v1.18.1 (V3) — the active-measurement set, grouped so the dropdown
|
||||
// doesn't read as a flat 15-item list. Mirrors the Zod allow-list in
|
||||
// `validations/measurement-reminders.ts`; the labels resolve through
|
||||
// `measurementReminders.types.*`, the group headers through
|
||||
// `measurementReminders.typeGroups.*`.
|
||||
const TYPE_GROUPS = [
|
||||
{
|
||||
group: "vitals",
|
||||
types: [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"PULSE",
|
||||
"BLOOD_GLUCOSE",
|
||||
"OXYGEN_SATURATION",
|
||||
"BODY_TEMPERATURE",
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "bodyComposition",
|
||||
types: [
|
||||
"BODY_FAT",
|
||||
"FAT_MASS",
|
||||
"FAT_FREE_MASS",
|
||||
"MUSCLE_MASS",
|
||||
"LEAN_BODY_MASS",
|
||||
"BONE_MASS",
|
||||
"TOTAL_BODY_WATER",
|
||||
"VISCERAL_FAT",
|
||||
"BODY_MASS_INDEX",
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const INTERVAL_PRESETS = [7, 14, 30, 90, 180, 365] as const;
|
||||
@@ -220,10 +243,17 @@ export function VorsorgeSection({
|
||||
value={measurementType}
|
||||
onChange={(e) => setMeasurementType(e.target.value)}
|
||||
>
|
||||
{TYPE_OPTIONS.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{t(`measurementReminders.types.${type}`)}
|
||||
</option>
|
||||
{TYPE_GROUPS.map(({ group, types }) => (
|
||||
<optgroup
|
||||
key={group}
|
||||
label={t(`measurementReminders.typeGroups.${group}`)}
|
||||
>
|
||||
{types.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{t(`measurementReminders.types.${type}`)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
@@ -672,6 +672,10 @@ export async function runFitbitPollCohort(
|
||||
concurrency?: number;
|
||||
sync?: (userId: string) => Promise<number>;
|
||||
onUserError?: (userId: string, err: unknown) => void;
|
||||
/** v1.18.1 — invoked after a user's sync lands `imported` measurements,
|
||||
* so the worker can fire the eventful Vorsorge satisfaction enqueue
|
||||
* without the fitbit lib reaching into the job layer. */
|
||||
onUserSynced?: (userId: string, imported: number) => void;
|
||||
} = {},
|
||||
): Promise<{ usersSynced: number; measurementsImported: number }> {
|
||||
const sync = opts.sync ?? ((userId: string) => syncUserFitbit(userId));
|
||||
@@ -689,6 +693,7 @@ export async function runFitbitPollCohort(
|
||||
const n = await sync(userId);
|
||||
measurementsImported = measurementsImported + n;
|
||||
usersSynced = usersSynced + 1;
|
||||
opts.onUserSynced?.(userId, n);
|
||||
} catch (err) {
|
||||
opts.onUserError?.(userId, err);
|
||||
}
|
||||
|
||||
@@ -58,3 +58,25 @@ describe("reminder-worker — measurement-reminder wiring", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reminder-worker — eventful reminder-satisfy wiring (v1.18.1)", () => {
|
||||
it("imports the satisfy queue constants", () => {
|
||||
expect(source).toMatch(/from\s*["']@\/lib\/jobs\/reminder-satisfy["']/);
|
||||
expect(source).toMatch(/\bREMINDER_SATISFY_QUEUE\b/);
|
||||
});
|
||||
|
||||
it("registers the satisfy queue in the allQueues loop", () => {
|
||||
const allQueuesMatch = source.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/);
|
||||
expect(allQueuesMatch).not.toBeNull();
|
||||
expect(allQueuesMatch![1]).toMatch(/\bREMINDER_SATISFY_QUEUE\b/);
|
||||
});
|
||||
|
||||
it("binds a boss.work handler for the satisfy queue", () => {
|
||||
expect(source).toMatch(
|
||||
/boss\.work[\s\S]{0,200}REMINDER_SATISFY_QUEUE[\s\S]{0,200}handleReminderSatisfy/,
|
||||
);
|
||||
expect(source).toMatch(
|
||||
/handleReminderSatisfy[\s\S]{0,600}runReminderSatisfyForUser/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -126,6 +126,9 @@ interface FakeReminder {
|
||||
function makePrisma(opts: {
|
||||
reminders: FakeReminder[];
|
||||
measurementMatch?: { measuredAt: Date } | null;
|
||||
/** v1.18.1 (D2) — a free-text reminder now auto-resolves from a matching
|
||||
* LabResult. Default `null` (no lab landed). */
|
||||
labMatch?: { takenAt: Date } | null;
|
||||
}) {
|
||||
const updates: Array<{ id: string; data: Record<string, unknown> }> = [];
|
||||
const prisma = {
|
||||
@@ -147,6 +150,9 @@ function makePrisma(opts: {
|
||||
measurement: {
|
||||
findFirst: vi.fn(async () => opts.measurementMatch ?? null),
|
||||
},
|
||||
labResult: {
|
||||
findFirst: vi.fn(async () => opts.labMatch ?? null),
|
||||
},
|
||||
};
|
||||
return { prisma, updates };
|
||||
}
|
||||
@@ -220,11 +226,13 @@ describe("runMeasurementReminderTick", () => {
|
||||
expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime());
|
||||
});
|
||||
|
||||
it("free-text reminder never auto-resolves (no measurement query match path)", async () => {
|
||||
it("free-text reminder never queries Measurement (matches on LabResult instead)", async () => {
|
||||
const { prisma } = makePrisma({
|
||||
reminders: [reminder({ measurementType: null })],
|
||||
// Even if a reading existed, a free-text reminder must not query it.
|
||||
// Even if a reading existed, a free-text reminder must not query it —
|
||||
// it resolves from a LabResult (D2), and none landed here.
|
||||
measurementMatch: { measuredAt: new Date("2026-06-14T18:00:00Z") },
|
||||
labMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
@@ -239,6 +247,29 @@ describe("runMeasurementReminderTick", () => {
|
||||
expect(prisma.measurement.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("v1.18.1 (D2) — free-text reminder auto-resolves from a matching LabResult", async () => {
|
||||
const takenAt = new Date("2026-06-14T18:00:00Z");
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [reminder({ measurementType: null })],
|
||||
labMatch: { takenAt },
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
const summary = await runMeasurementReminderTick(
|
||||
prisma as never,
|
||||
NINE_LOCAL,
|
||||
{ dispatch },
|
||||
);
|
||||
|
||||
expect(summary.autoResolved).toBe(1);
|
||||
expect(summary.dispatched).toBe(0);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0].data.lastSatisfiedAt).toEqual(takenAt);
|
||||
// A free-text reminder resolves from labs, never from measurements.
|
||||
expect(prisma.measurement.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses the push under clientManaged but still advances nextDueAt", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* v1.18.1 — eventful Vorsorge satisfaction.
|
||||
*
|
||||
* 1. `enqueueReminderSatisfy` — a thin wrapper around the global boss,
|
||||
* a silent no-op when no worker is attached (mirrors enqueuePrDetection).
|
||||
* 2. `runReminderSatisfyForUser` — resolves a user's live reminders
|
||||
* against their just-landed measurement / lab via the shared
|
||||
* primitives, honouring the module toggle and the forward-only guard.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const sendMock = vi.fn();
|
||||
let bossInstance: { send: typeof sendMock } | null = { send: sendMock };
|
||||
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: () => bossInstance,
|
||||
}));
|
||||
|
||||
import { enqueueReminderSatisfy, REMINDER_SATISFY_QUEUE } from "../reminder-satisfy";
|
||||
import { runReminderSatisfyForUser } from "../measurement-reminder";
|
||||
|
||||
describe("enqueueReminderSatisfy", () => {
|
||||
beforeEach(() => {
|
||||
sendMock.mockReset();
|
||||
bossInstance = { send: sendMock };
|
||||
});
|
||||
|
||||
it("sends a job carrying the userId to the satisfy queue", async () => {
|
||||
await enqueueReminderSatisfy("u1");
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock.mock.calls[0]![0]).toBe(REMINDER_SATISFY_QUEUE);
|
||||
expect(sendMock.mock.calls[0]![1]).toMatchObject({ userId: "u1" });
|
||||
});
|
||||
|
||||
it("is a silent no-op when no boss is attached (tests / web process)", async () => {
|
||||
bossInstance = null;
|
||||
await expect(enqueueReminderSatisfy("u1")).resolves.toBeUndefined();
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
function makePrisma(opts: {
|
||||
reminders: Array<Record<string, unknown>>;
|
||||
measurement?: { measuredAt: Date } | null;
|
||||
lab?: { takenAt: Date } | null;
|
||||
}) {
|
||||
const updates: Array<{ id: string; data: Record<string, unknown> }> = [];
|
||||
const prisma = {
|
||||
user: { findUnique: vi.fn(async () => ({ timezone: TZ })) },
|
||||
measurementReminder: {
|
||||
findMany: vi.fn(async () => opts.reminders),
|
||||
update: vi.fn(
|
||||
async ({ where, data }: { where: { id: string }; data: Record<string, unknown> }) => {
|
||||
updates.push({ id: where.id, data });
|
||||
return { id: where.id, ...data };
|
||||
},
|
||||
),
|
||||
},
|
||||
measurement: { findFirst: vi.fn(async () => opts.measurement ?? null) },
|
||||
labResult: { findFirst: vi.fn(async () => opts.lab ?? null) },
|
||||
};
|
||||
return { prisma, updates };
|
||||
}
|
||||
|
||||
function reminderRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "r1",
|
||||
measurementType: "WEIGHT",
|
||||
intervalDays: 7,
|
||||
rrule: null,
|
||||
anchorDate: null,
|
||||
notifyHour: 9,
|
||||
lastSatisfiedAt: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("runReminderSatisfyForUser", () => {
|
||||
it("satisfies a typed reminder eventfully when a matching reading landed", async () => {
|
||||
const measuredAt = new Date("2026-06-14T18:00:00Z");
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [reminderRow()],
|
||||
measurement: { measuredAt },
|
||||
});
|
||||
|
||||
const summary = await runReminderSatisfyForUser(
|
||||
prisma as never,
|
||||
"u1",
|
||||
new Date("2026-06-14T20:00:00Z"),
|
||||
{ isModuleEnabled: vi.fn(async () => true) },
|
||||
);
|
||||
|
||||
expect(summary.satisfied).toBe(1);
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0].data.lastSatisfiedAt).toEqual(measuredAt);
|
||||
});
|
||||
|
||||
it("satisfies a free-text reminder from a LabResult (D2)", async () => {
|
||||
const takenAt = new Date("2026-06-15T09:00:00Z");
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [reminderRow({ measurementType: null, intervalDays: 365 })],
|
||||
lab: { takenAt },
|
||||
});
|
||||
|
||||
const summary = await runReminderSatisfyForUser(
|
||||
prisma as never,
|
||||
"u1",
|
||||
new Date("2026-06-15T12:00:00Z"),
|
||||
);
|
||||
|
||||
expect(summary.satisfied).toBe(1);
|
||||
expect(updates[0].data.lastSatisfiedAt).toEqual(takenAt);
|
||||
});
|
||||
|
||||
it("produces no engine activity for a disabled module", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [reminderRow({ measurementType: "BLOOD_GLUCOSE" })],
|
||||
measurement: { measuredAt: new Date("2026-06-14T18:00:00Z") },
|
||||
});
|
||||
const isModuleEnabled = vi.fn(async () => false);
|
||||
|
||||
const summary = await runReminderSatisfyForUser(
|
||||
prisma as never,
|
||||
"u1",
|
||||
new Date(),
|
||||
{ isModuleEnabled },
|
||||
);
|
||||
|
||||
expect(summary.skippedModuleDisabled).toBe(1);
|
||||
expect(summary.satisfied).toBe(0);
|
||||
expect(isModuleEnabled).toHaveBeenCalledWith("u1", "glucose");
|
||||
expect(updates).toHaveLength(0);
|
||||
// The matcher never runs for a gated reminder.
|
||||
expect(prisma.measurement.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is forward-only: a stale event is a no-op (cron + hook converge)", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [
|
||||
reminderRow({ lastSatisfiedAt: new Date("2026-06-20T08:00:00Z") }),
|
||||
],
|
||||
measurement: { measuredAt: new Date("2026-06-10T08:00:00Z") },
|
||||
});
|
||||
|
||||
const summary = await runReminderSatisfyForUser(prisma as never, "u1", new Date());
|
||||
|
||||
expect(summary.satisfied).toBe(0);
|
||||
expect(updates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
computeReminderNextDueAt,
|
||||
type ReminderScheduleInput,
|
||||
} from "@/lib/measurement-reminders/scheduling";
|
||||
import { findSatisfyingEvent } from "@/lib/measurement-reminders/resolve";
|
||||
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
|
||||
|
||||
/**
|
||||
* v1.18.0 — map a reminder's `measurementType` to the toggleable module
|
||||
@@ -137,17 +139,6 @@ export function buildMeasurementReminderPayload(
|
||||
return { title: t("measurementReminders.pushTitle"), body };
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical sentinel measurement type the cron queries for
|
||||
* auto-resolve. BP is two rows (SYS + DIA); SYS is the "a BP was measured"
|
||||
* anchor (matching both would double-count).
|
||||
*/
|
||||
function autoResolveQueryType(
|
||||
reminderType: MeasurementType | null,
|
||||
): MeasurementType | null {
|
||||
return reminderType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one Vorsorge-reminder cron tick. Iterates every live, enabled
|
||||
* reminder, auto-resolves the typed ones against incoming readings,
|
||||
@@ -216,47 +207,27 @@ export async function runMeasurementReminderTick(
|
||||
try {
|
||||
const timezone = reminder.user.timezone || "Europe/Berlin";
|
||||
|
||||
// ── Auto-resolve from an incoming measurement ──────────────────
|
||||
// Cheap guard, in the cron (not on the ingest path). Only typed
|
||||
// reminders auto-resolve; free-text ones wait for a manual satisfy.
|
||||
const queryType = autoResolveQueryType(reminder.measurementType);
|
||||
if (queryType !== null) {
|
||||
// Match readings logged AFTER the last satisfy (or, never
|
||||
// satisfied, after the anchor). A reading inside the current due
|
||||
// cycle means the user already measured — re-anchor + skip the
|
||||
// nudge. `deletedAt: null` so a tombstoned reading never counts.
|
||||
const sinceFloor =
|
||||
reminder.lastSatisfiedAt ??
|
||||
reminder.anchorDate ??
|
||||
reminder.createdAt;
|
||||
const match = await prisma.measurement.findFirst({
|
||||
where: {
|
||||
userId: reminder.user.id,
|
||||
type: queryType,
|
||||
deletedAt: null,
|
||||
measuredAt: { gt: sinceFloor },
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { measuredAt: true },
|
||||
});
|
||||
if (match) {
|
||||
const scheduleInput: ReminderScheduleInput = {
|
||||
intervalDays: reminder.intervalDays,
|
||||
rrule: reminder.rrule,
|
||||
anchorDate: reminder.anchorDate,
|
||||
notifyHour: reminder.notifyHour,
|
||||
lastSatisfiedAt: match.measuredAt,
|
||||
createdAt: reminder.createdAt,
|
||||
};
|
||||
const nextDueAt = computeReminderNextDueAt(
|
||||
scheduleInput,
|
||||
timezone,
|
||||
match.measuredAt,
|
||||
);
|
||||
await prisma.measurementReminder.update({
|
||||
where: { id: reminder.id },
|
||||
data: { lastSatisfiedAt: match.measuredAt, nextDueAt },
|
||||
});
|
||||
// ── Auto-resolve from an incoming event ────────────────────────
|
||||
// Cheap safety-net poll, in the cron (the eventful `reminder-satisfy`
|
||||
// worker is the fast path). A typed reminder resolves from a matching
|
||||
// Measurement; a free-text reminder resolves from a LabResult (the
|
||||
// Lab↔Vorsorge link). Both go through the shared `findSatisfyingEvent`
|
||||
// + `satisfyReminder` primitives so the cron and the worker can never
|
||||
// diverge. A reading inside the current due cycle means the user
|
||||
// already measured — re-anchor + skip the nudge.
|
||||
const satisfiedAt = await findSatisfyingEvent(
|
||||
prisma,
|
||||
reminder.user.id,
|
||||
reminder,
|
||||
);
|
||||
if (satisfiedAt) {
|
||||
const result = await satisfyReminder(
|
||||
prisma,
|
||||
reminder,
|
||||
timezone,
|
||||
satisfiedAt,
|
||||
);
|
||||
if (result.satisfied) {
|
||||
summary.autoResolved += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -360,6 +331,105 @@ export async function runMeasurementReminderTick(
|
||||
return summary;
|
||||
}
|
||||
|
||||
export interface ReminderSatisfySummary {
|
||||
candidatesScanned: number;
|
||||
satisfied: number;
|
||||
skippedModuleDisabled: number;
|
||||
skippedNoEvent: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.1 — eventful satisfaction for one user. Called by the
|
||||
* `reminder-satisfy` worker right after a measurement / lab write lands
|
||||
* (from any ingest path: manual create, batch, or a device sync). The
|
||||
* 15-min cron remains the idempotent safety-net behind this.
|
||||
*
|
||||
* Loads the user's live, enabled reminders that could auto-resolve (typed
|
||||
* → a Measurement; free-text → a LabResult), and for each runs the SAME
|
||||
* `findSatisfyingEvent` + `satisfyReminder` primitives the cron uses, so
|
||||
* "I just weighed myself, stop reminding me" resolves immediately instead
|
||||
* of waiting up to 15 minutes.
|
||||
*
|
||||
* Respects the module toggle: a reminder whose measurement type belongs to
|
||||
* a disabled module produces no engine activity (the same gate the cron
|
||||
* dispatch applies). Forward-only `satisfyReminder` makes a duplicate
|
||||
* enqueue + the trailing cron poll converge without double-stamping.
|
||||
*/
|
||||
export async function runReminderSatisfyForUser(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
now: Date,
|
||||
options: { isModuleEnabled?: typeof isModuleEnabled } = {},
|
||||
): Promise<ReminderSatisfySummary> {
|
||||
void now;
|
||||
const moduleGate = options.isModuleEnabled ?? isModuleEnabled;
|
||||
|
||||
const summary: ReminderSatisfySummary = {
|
||||
candidatesScanned: 0,
|
||||
satisfied: 0,
|
||||
skippedModuleDisabled: 0,
|
||||
skippedNoEvent: 0,
|
||||
failed: 0,
|
||||
};
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const timezone = user?.timezone || "Europe/Berlin";
|
||||
|
||||
const reminders = await prisma.measurementReminder.findMany({
|
||||
where: { userId, deletedAt: null, enabled: true },
|
||||
});
|
||||
|
||||
for (const reminder of reminders) {
|
||||
summary.candidatesScanned += 1;
|
||||
try {
|
||||
// Module toggle — no engine activity for a reminder whose type
|
||||
// belongs to a disabled module. Core-vital + free-text reminders map
|
||||
// to no module and are never gated.
|
||||
const gatedModule = moduleForMeasurementType(reminder.measurementType);
|
||||
if (
|
||||
gatedModule !== null &&
|
||||
!(await moduleGate(userId, gatedModule))
|
||||
) {
|
||||
summary.skippedModuleDisabled += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const satisfiedAt = await findSatisfyingEvent(prisma, userId, reminder);
|
||||
if (!satisfiedAt) {
|
||||
summary.skippedNoEvent += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await satisfyReminder(
|
||||
prisma,
|
||||
reminder,
|
||||
timezone,
|
||||
satisfiedAt,
|
||||
);
|
||||
if (result.satisfied) {
|
||||
summary.satisfied += 1;
|
||||
getEvent()?.addMeta("measurement_reminder.satisfied_eventful", reminder.id);
|
||||
} else {
|
||||
// Forward-only no-op — the cron or a prior enqueue already
|
||||
// advanced this row past the event.
|
||||
summary.skippedNoEvent += 1;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
summary.failed += 1;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
getEvent()?.addWarning(
|
||||
`reminder-satisfy per-reminder resolve failed for ${reminder.id}: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance `nextDueAt` to the next occurrence strictly after `now`. Used
|
||||
* after a successful dispatch (and after a client-managed skip) as the
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* v1.18.1 — enqueue helper for the eventful Vorsorge-reminder satisfaction
|
||||
* queue. Mirrors `pr-detection.ts`: the Next.js ingest routes import
|
||||
* `enqueueReminderSatisfy` and never touch pg-boss directly, the worker
|
||||
* binds the handler in `reminder-worker.ts`, and the 15-min
|
||||
* measurement-reminder cron is the idempotent safety-net for any ingest
|
||||
* path that forgets to enqueue (or any job that fails to land).
|
||||
*
|
||||
* Why a queue and not an inline call on the ingest path: the batch route's
|
||||
* own comment warns against hooking the hot iOS batch-ingest path. The
|
||||
* matcher re-queries the user's measurements/labs, so keep it off the
|
||||
* request and out of the sync worker's tight loop — enqueue
|
||||
* fire-and-forget, resolve in the worker.
|
||||
*/
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
|
||||
export const REMINDER_SATISFY_QUEUE = "reminder-satisfy";
|
||||
|
||||
/** Concurrency budget for the worker process. The job is read-heavy on the
|
||||
* user's own reminders + a single indexed measurement/lab lookup per
|
||||
* typed reminder, so a small fleet drains an ingest spike without
|
||||
* crowding the reminder check or the daily insights workload on the same
|
||||
* node. */
|
||||
export const REMINDER_SATISFY_CONCURRENCY = 5;
|
||||
|
||||
export interface ReminderSatisfyPayload {
|
||||
userId: string;
|
||||
/** ISO timestamp — handy for debugging, never used for logic. */
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a reminder-satisfaction job for one user after a measurement /
|
||||
* lab write. Best-effort: when no boss instance is attached (the route
|
||||
* runs in a context without the worker — typically tests, scripts), the
|
||||
* call is a silent no-op and the 15-min cron reconciles the user within
|
||||
* one tick.
|
||||
*/
|
||||
export async function enqueueReminderSatisfy(userId: string): Promise<void> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) return;
|
||||
const payload: ReminderSatisfyPayload = {
|
||||
userId,
|
||||
triggeredAt: new Date().toISOString(),
|
||||
};
|
||||
await boss.send(REMINDER_SATISFY_QUEUE, payload, {
|
||||
// Gentle retry — the resolver is read-heavy on the user's own data; a
|
||||
// stampede of failed jobs against a briefly-flaky DB would compound the
|
||||
// problem. The cron is the ultimate backstop anyway.
|
||||
retryLimit: 3,
|
||||
retryDelay: 30,
|
||||
retryBackoff: true,
|
||||
});
|
||||
}
|
||||
@@ -42,6 +42,11 @@ import {
|
||||
PR_DETECTION_FALLBACK_CRON,
|
||||
type PrDetectionPayload,
|
||||
} from "@/lib/jobs/pr-detection";
|
||||
import {
|
||||
REMINDER_SATISFY_QUEUE,
|
||||
REMINDER_SATISFY_CONCURRENCY,
|
||||
type ReminderSatisfyPayload,
|
||||
} from "@/lib/jobs/reminder-satisfy";
|
||||
import {
|
||||
MEDICATION_INVENTORY_EXPIRE_QUEUE,
|
||||
MEDICATION_INVENTORY_EXPIRE_CRON,
|
||||
@@ -250,6 +255,7 @@ import {
|
||||
handleMoodReminderCheck,
|
||||
handleCycleReminderCheck,
|
||||
handleMeasurementReminderCheck,
|
||||
handleReminderSatisfy,
|
||||
} from "./reminder/mood-cycle-checks";
|
||||
import {
|
||||
HostMetricSamplePayload,
|
||||
@@ -677,6 +683,11 @@ export async function startReminderWorker() {
|
||||
// schedule below silently no-ops (the v1.4.37 dead-queue class).
|
||||
TLS_PIN_MONITOR_QUEUE,
|
||||
PR_DETECTION_QUEUE,
|
||||
// v1.18.1 — eventful Vorsorge satisfaction. No cron of its own (the
|
||||
// 15-min measurement-reminder check is the safety-net); enqueued by the
|
||||
// ingest paths. Must still be registered here or the worker binding
|
||||
// below never provisions the queue (v1.4.37 dead-queue lesson).
|
||||
REMINDER_SATISFY_QUEUE,
|
||||
MEDICATION_INVENTORY_EXPIRE_QUEUE,
|
||||
// v1.4.46 — hourly auto-skip pass for stale unmarked intakes.
|
||||
// Same pg-boss v12 createQueue contract as the other crons; without
|
||||
@@ -1281,6 +1292,14 @@ export async function startReminderWorker() {
|
||||
{ localConcurrency: PR_DETECTION_CONCURRENCY },
|
||||
handlePrDetection,
|
||||
);
|
||||
// v1.18.1 — eventful Vorsorge satisfaction. Resolves a user's reminders
|
||||
// against their just-landed measurement / lab. Read-heavy on the user's
|
||||
// own data; the same small concurrency budget as PR detection.
|
||||
await boss.work<ReminderSatisfyPayload>(
|
||||
REMINDER_SATISFY_QUEUE,
|
||||
{ localConcurrency: REMINDER_SATISFY_CONCURRENCY },
|
||||
handleReminderSatisfy,
|
||||
);
|
||||
await boss.work<MedicationInventoryExpirePayload>(
|
||||
MEDICATION_INVENTORY_EXPIRE_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { cleanupExpiredFitbitOAuthStates } from "@/lib/jobs/fitbit-oauth-state-cleanup";
|
||||
import { getWorkerPrisma } from "./shared";
|
||||
|
||||
@@ -45,6 +46,13 @@ export async function handleFitbitSync(jobs: Job<FitbitSyncPayload>[]) {
|
||||
{
|
||||
onUserError: (userId, err) =>
|
||||
evt.addWarning(`job.fitbit_sync failed for user ${userId}: ${err}`),
|
||||
// v1.18.1 — a fresh reading landed; resolve the user's Vorsorge
|
||||
// reminders eventfully. Fire-and-forget; the cron is the net.
|
||||
onUserSynced: (userId, imported) => {
|
||||
if (imported > 0) {
|
||||
void enqueueReminderSatisfy(userId).catch(() => {});
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -9,7 +9,11 @@ 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 { runMeasurementReminderTick } from "@/lib/jobs/measurement-reminder";
|
||||
import {
|
||||
runMeasurementReminderTick,
|
||||
runReminderSatisfyForUser,
|
||||
} from "@/lib/jobs/measurement-reminder";
|
||||
import type { ReminderSatisfyPayload } from "@/lib/jobs/reminder-satisfy";
|
||||
import { getWorkerPrisma } from "./shared";
|
||||
|
||||
export interface MoodReminderPayload {
|
||||
@@ -120,3 +124,45 @@ export async function handleMeasurementReminderCheck(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.1 — eventful Vorsorge satisfaction. Enqueued by every measurement
|
||||
* / lab ingest path (manual create, batch, device syncs) so a reminder
|
||||
* auto-resolves the moment a matching reading lands rather than waiting up
|
||||
* to 15 minutes for the cron. Batches one job per user; the resolver loads
|
||||
* that user's live reminders and runs the shared satisfy primitive.
|
||||
*/
|
||||
export async function handleReminderSatisfy(
|
||||
jobs: Job<ReminderSatisfyPayload>[],
|
||||
) {
|
||||
await withBackgroundEvent("job.reminder_satisfy", async (evt) => {
|
||||
const prisma = getWorkerPrisma();
|
||||
const now = new Date();
|
||||
// Distinct users in this batch — a multi-source ingest spike can enqueue
|
||||
// several jobs for one user; resolve each user once.
|
||||
const userIds = Array.from(
|
||||
new Set(jobs.map((j) => j.data?.userId).filter((id): id is string => !!id)),
|
||||
);
|
||||
let satisfied = 0;
|
||||
let candidates = 0;
|
||||
try {
|
||||
for (const userId of userIds) {
|
||||
const summary = await runReminderSatisfyForUser(prisma, userId, now);
|
||||
satisfied += summary.satisfied;
|
||||
candidates += summary.candidatesScanned;
|
||||
}
|
||||
evt.setBackground({
|
||||
task_name: "job.reminder_satisfy",
|
||||
result: {
|
||||
users: userIds.length,
|
||||
candidates_scanned: candidates,
|
||||
satisfied,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
evt.setError(err);
|
||||
recordError();
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { type Job } from "pg-boss";
|
||||
|
||||
import { recordError } from "@/lib/jobs/worker-status";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { getWorkerPrisma } from "./shared";
|
||||
|
||||
/** Every poll-cohort handler accepts an optional single-user payload. */
|
||||
@@ -69,8 +70,14 @@ export function makePollCohortHandler({
|
||||
let measurementsImported = 0;
|
||||
for (const { userId } of targets) {
|
||||
try {
|
||||
measurementsImported += await syncUser(userId);
|
||||
const imported = await syncUser(userId);
|
||||
measurementsImported += imported;
|
||||
usersSynced++;
|
||||
// v1.18.1 — a fresh reading landed; resolve this user's Vorsorge
|
||||
// reminders eventfully. Fire-and-forget; the cron is the net.
|
||||
if (imported > 0) {
|
||||
void enqueueReminderSatisfy(userId).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
evt.addWarning(`${taskName} failed for user ${userId}: ${err}`);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
syncUserWorkout,
|
||||
syncWhoopWorkoutById,
|
||||
} from "@/lib/whoop/sync-workout";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { getWorkerPrisma } from "./shared";
|
||||
|
||||
/**
|
||||
@@ -91,11 +92,17 @@ export async function runWhoopResourceSync(
|
||||
let measurementsImported = 0;
|
||||
for (const { userId, resourceId } of targets) {
|
||||
try {
|
||||
measurementsImported +=
|
||||
const imported =
|
||||
resourceId && byIdFn
|
||||
? await byIdFn(userId, resourceId)
|
||||
: await syncFn(userId);
|
||||
measurementsImported += imported;
|
||||
usersSynced++;
|
||||
// v1.18.1 — a fresh reading landed; resolve this user's Vorsorge
|
||||
// reminders eventfully. Fire-and-forget; the cron is the net.
|
||||
if (imported > 0) {
|
||||
void enqueueReminderSatisfy(userId).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
evt.addWarning(`${taskName} failed for user ${userId}: ${err}`);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { getWorkerPrisma } from "./shared";
|
||||
|
||||
export interface WithingsSyncPayload {
|
||||
@@ -64,6 +65,11 @@ export async function handleWithingsFallbackSync(
|
||||
const imported = await syncUserMeasurements(connection.userId);
|
||||
usersSynced++;
|
||||
measurementsImported += imported;
|
||||
// v1.18.1 — a fresh reading landed; resolve this user's Vorsorge
|
||||
// reminders eventfully. Fire-and-forget; the cron is the net.
|
||||
if (imported > 0) {
|
||||
void enqueueReminderSatisfy(connection.userId).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
evt.addWarning(
|
||||
`Fallback sync failed for user ${connection.userId}: ${err}`,
|
||||
@@ -131,6 +137,9 @@ export async function handleWithingsActivitySync(
|
||||
const imported = await syncUserActivity(userId);
|
||||
usersSynced++;
|
||||
measurementsImported += imported;
|
||||
if (imported > 0) {
|
||||
void enqueueReminderSatisfy(userId).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
evt.addWarning(
|
||||
`Withings activity sync failed for user ${userId}: ${err}`,
|
||||
@@ -186,6 +195,9 @@ export async function handleWithingsSleepSync(
|
||||
const imported = await syncUserSleep(userId);
|
||||
usersSynced++;
|
||||
measurementsImported += imported;
|
||||
if (imported > 0) {
|
||||
void enqueueReminderSatisfy(userId).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
evt.addWarning(
|
||||
`Withings sleep sync failed for user ${userId}: ${err}`,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* v1.18.1 — `findSatisfyingEvent`: the shared "did a satisfying event
|
||||
* land?" matcher. A typed reminder resolves from a Measurement; a
|
||||
* free-text reminder resolves from a LabResult (the Lab↔Vorsorge link, D2).
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { findSatisfyingEvent, satisfactionFloor } from "../resolve";
|
||||
|
||||
type FindFirstArgs = { where: Record<string, unknown> };
|
||||
|
||||
function makePrisma(opts: {
|
||||
measurement?: { measuredAt: Date } | null;
|
||||
lab?: { takenAt: Date } | null;
|
||||
}) {
|
||||
return {
|
||||
measurement: {
|
||||
findFirst: vi.fn((args: FindFirstArgs) => {
|
||||
void args;
|
||||
return Promise.resolve(opts.measurement ?? null);
|
||||
}),
|
||||
},
|
||||
labResult: {
|
||||
findFirst: vi.fn((args: FindFirstArgs) => {
|
||||
void args;
|
||||
return Promise.resolve(opts.lab ?? null);
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const base = {
|
||||
measurementType: null,
|
||||
anchorDate: null,
|
||||
lastSatisfiedAt: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
} as const;
|
||||
|
||||
describe("satisfactionFloor", () => {
|
||||
it("prefers lastSatisfiedAt, then anchorDate, then createdAt", () => {
|
||||
const last = new Date("2026-06-10T00:00:00Z");
|
||||
const anchor = new Date("2026-06-05T00:00:00Z");
|
||||
expect(
|
||||
satisfactionFloor({ ...base, lastSatisfiedAt: last, anchorDate: anchor }),
|
||||
).toEqual(last);
|
||||
expect(satisfactionFloor({ ...base, anchorDate: anchor })).toEqual(anchor);
|
||||
expect(satisfactionFloor(base)).toEqual(base.createdAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findSatisfyingEvent", () => {
|
||||
it("resolves a typed reminder from a matching Measurement, not a lab", async () => {
|
||||
const measuredAt = new Date("2026-06-14T18:00:00Z");
|
||||
const prisma = makePrisma({ measurement: { measuredAt }, lab: { takenAt: new Date() } });
|
||||
|
||||
const at = await findSatisfyingEvent(prisma as never, "u1", {
|
||||
...base,
|
||||
measurementType: "WEIGHT",
|
||||
});
|
||||
|
||||
expect(at).toEqual(measuredAt);
|
||||
expect(prisma.measurement.findFirst).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.labResult.findFirst).not.toHaveBeenCalled();
|
||||
// Queries the right type, non-deleted, after the floor.
|
||||
const where = prisma.measurement.findFirst.mock.calls[0]![0].where as {
|
||||
type: string;
|
||||
deletedAt: null;
|
||||
measuredAt: { gt: Date };
|
||||
};
|
||||
expect(where.type).toBe("WEIGHT");
|
||||
expect(where.deletedAt).toBeNull();
|
||||
expect(where.measuredAt.gt).toEqual(base.createdAt);
|
||||
});
|
||||
|
||||
it("returns null for a typed reminder with no matching reading", async () => {
|
||||
const prisma = makePrisma({ measurement: null });
|
||||
const at = await findSatisfyingEvent(prisma as never, "u1", {
|
||||
...base,
|
||||
measurementType: "BLOOD_PRESSURE_SYS",
|
||||
});
|
||||
expect(at).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves a free-text reminder from any LabResult (D2)", async () => {
|
||||
const takenAt = new Date("2026-06-15T09:00:00Z");
|
||||
const prisma = makePrisma({ lab: { takenAt } });
|
||||
|
||||
const at = await findSatisfyingEvent(prisma as never, "u1", {
|
||||
...base,
|
||||
measurementType: null,
|
||||
});
|
||||
|
||||
expect(at).toEqual(takenAt);
|
||||
expect(prisma.labResult.findFirst).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.measurement.findFirst).not.toHaveBeenCalled();
|
||||
const where = prisma.labResult.findFirst.mock.calls[0]![0].where as {
|
||||
deletedAt: null;
|
||||
takenAt: { gt: Date };
|
||||
};
|
||||
expect(where.deletedAt).toBeNull();
|
||||
expect(where.takenAt.gt).toEqual(base.createdAt);
|
||||
});
|
||||
|
||||
it("returns null for a free-text reminder with no lab landed", async () => {
|
||||
const prisma = makePrisma({ lab: null });
|
||||
const at = await findSatisfyingEvent(prisma as never, "u1", {
|
||||
...base,
|
||||
measurementType: null,
|
||||
});
|
||||
expect(at).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* v1.18.1 — the shared `satisfyReminder` primitive: forward-only guard +
|
||||
* re-anchored reschedule. The cron, the manual satisfy route, and the
|
||||
* eventful worker all route through it, so these tests pin the invariants
|
||||
* every caller relies on.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { satisfyReminder } from "../satisfy";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
function makePrisma() {
|
||||
const updates: Array<{ id: string; data: Record<string, unknown> }> = [];
|
||||
const prisma = {
|
||||
measurementReminder: {
|
||||
update: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
data,
|
||||
}: {
|
||||
where: { id: string };
|
||||
data: Record<string, unknown>;
|
||||
}) => {
|
||||
updates.push({ id: where.id, data });
|
||||
return { id: where.id, ...data };
|
||||
},
|
||||
),
|
||||
},
|
||||
};
|
||||
return { prisma, updates };
|
||||
}
|
||||
|
||||
function reminder(overrides: Partial<Parameters<typeof satisfyReminder>[1]> = {}) {
|
||||
return {
|
||||
id: "r1",
|
||||
intervalDays: 7,
|
||||
rrule: null,
|
||||
anchorDate: null,
|
||||
notifyHour: 9,
|
||||
lastSatisfiedAt: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("satisfyReminder", () => {
|
||||
it("stamps lastSatisfiedAt + recomputes nextDueAt when never satisfied", async () => {
|
||||
const { prisma, updates } = makePrisma();
|
||||
const at = new Date("2026-06-14T18:00:00Z");
|
||||
|
||||
const result = await satisfyReminder(
|
||||
prisma as never,
|
||||
reminder(),
|
||||
TZ,
|
||||
at,
|
||||
);
|
||||
|
||||
expect(result.satisfied).toBe(true);
|
||||
expect(result.nextDueAt).toBeInstanceOf(Date);
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0].data.lastSatisfiedAt).toEqual(at);
|
||||
// Rolling +7d from the satisfy instant — strictly in the future.
|
||||
expect((updates[0].data.nextDueAt as Date).getTime()).toBeGreaterThan(
|
||||
at.getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it("advances when the event is strictly after the existing lastSatisfiedAt", async () => {
|
||||
const { prisma, updates } = makePrisma();
|
||||
const prev = new Date("2026-06-10T08:00:00Z");
|
||||
const at = new Date("2026-06-17T08:00:00Z");
|
||||
|
||||
const result = await satisfyReminder(
|
||||
prisma as never,
|
||||
reminder({ lastSatisfiedAt: prev }),
|
||||
TZ,
|
||||
at,
|
||||
);
|
||||
|
||||
expect(result.satisfied).toBe(true);
|
||||
expect(updates[0].data.lastSatisfiedAt).toEqual(at);
|
||||
});
|
||||
|
||||
it("is a forward-only no-op when the event is older than lastSatisfiedAt", async () => {
|
||||
const { prisma, updates } = makePrisma();
|
||||
const prev = new Date("2026-06-17T08:00:00Z");
|
||||
const stale = new Date("2026-06-10T08:00:00Z");
|
||||
|
||||
const result = await satisfyReminder(
|
||||
prisma as never,
|
||||
reminder({ lastSatisfiedAt: prev }),
|
||||
TZ,
|
||||
stale,
|
||||
);
|
||||
|
||||
expect(result.satisfied).toBe(false);
|
||||
expect(result.nextDueAt).toBeNull();
|
||||
expect(prisma.measurementReminder.update).not.toHaveBeenCalled();
|
||||
expect(updates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is a no-op when the event equals lastSatisfiedAt (cron behind an applied hook)", async () => {
|
||||
const { prisma } = makePrisma();
|
||||
const same = new Date("2026-06-17T08:00:00Z");
|
||||
|
||||
const result = await satisfyReminder(
|
||||
prisma as never,
|
||||
reminder({ lastSatisfiedAt: same }),
|
||||
TZ,
|
||||
same,
|
||||
);
|
||||
|
||||
expect(result.satisfied).toBe(false);
|
||||
expect(prisma.measurementReminder.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* v1.18.1 — the shared "did a satisfying event land?" matcher for the
|
||||
* Vorsorge engine.
|
||||
*
|
||||
* One reminder, one question: since the last satisfy (or the anchor when
|
||||
* never satisfied), did a reading land that fulfils this reminder's
|
||||
* cadence? Both the 15-min cron and the eventful ingest-driven worker call
|
||||
* this, so the suppression logic lives in exactly one place.
|
||||
*
|
||||
* Two satisfaction sources:
|
||||
*
|
||||
* 1. **Typed reminder** (`measurementType` non-NULL): a non-deleted
|
||||
* `Measurement` of that type measured after the floor. BP matches on
|
||||
* `BLOOD_PRESSURE_SYS` (the SYS sentinel) so SYS + DIA don't
|
||||
* double-count — the validation allow-list only ever stores SYS.
|
||||
* 2. **Free-text reminder** (`measurementType` NULL): an "annual blood
|
||||
* panel" / lab Vorsorge has no Measurement to match. v1.18.1 closes
|
||||
* the clinical loop (gap D2): a non-deleted `LabResult` taken after
|
||||
* the floor satisfies it. Without this a lab reminder could only ever
|
||||
* resolve on a manual "Erledigt".
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
/** The reminder fields the matcher reads. */
|
||||
export interface ResolvableReminder {
|
||||
measurementType: import("@/generated/prisma/client").MeasurementType | null;
|
||||
anchorDate: Date | null;
|
||||
lastSatisfiedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* The floor a satisfying event must land strictly after: the last satisfy,
|
||||
* else the anchor, else the create instant. A reading inside the current
|
||||
* due cycle means the user already measured.
|
||||
*/
|
||||
export function satisfactionFloor(reminder: ResolvableReminder): Date {
|
||||
return reminder.lastSatisfiedAt ?? reminder.anchorDate ?? reminder.createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent satisfying event instant for a reminder, or `null`
|
||||
* when none has landed since the floor.
|
||||
*
|
||||
* A typed reminder resolves from a matching `Measurement`; a free-text
|
||||
* reminder resolves from any `LabResult` (the Lab↔Vorsorge link, D2).
|
||||
*/
|
||||
export async function findSatisfyingEvent(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
reminder: ResolvableReminder,
|
||||
): Promise<Date | null> {
|
||||
const floor = satisfactionFloor(reminder);
|
||||
|
||||
if (reminder.measurementType !== null) {
|
||||
const match = await prisma.measurement.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
type: reminder.measurementType,
|
||||
deletedAt: null,
|
||||
measuredAt: { gt: floor },
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { measuredAt: true },
|
||||
});
|
||||
return match?.measuredAt ?? null;
|
||||
}
|
||||
|
||||
// Free-text / checklist reminder → a lab panel fulfils it. Match on
|
||||
// `takenAt` (the sample instant), mirroring the measurement path's
|
||||
// `measuredAt`. Any analyte counts: a "Großes Blutbild" reminder is
|
||||
// satisfied the moment the user records any value from that panel.
|
||||
const lab = await prisma.labResult.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
takenAt: { gt: floor },
|
||||
},
|
||||
orderBy: { takenAt: "desc" },
|
||||
select: { takenAt: true },
|
||||
});
|
||||
return lab?.takenAt ?? null;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* v1.18.1 — the ONE shared satisfaction primitive for the Vorsorge /
|
||||
* measurement-reminder engine.
|
||||
*
|
||||
* Every path that marks a reminder's cadence fulfilled routes through
|
||||
* `satisfyReminder`: the manual "Erledigt" route, the cron auto-resolve,
|
||||
* and the eventful ingest-driven satisfaction worker. No duplicated
|
||||
* reschedule logic — one place stamps `lastSatisfiedAt` and recomputes the
|
||||
* server-authoritative `nextDueAt`.
|
||||
*
|
||||
* Two invariants the primitive owns:
|
||||
*
|
||||
* 1. **Forward-only.** `lastSatisfiedAt` only ever moves forward. A
|
||||
* cron poll and an ingest enqueue can both fire for the same reading;
|
||||
* the second is a no-op (returns `false`) rather than re-stamping an
|
||||
* older instant. This is what makes the cron a safe idempotent
|
||||
* safety-net behind the eventful hook.
|
||||
* 2. **Re-anchored reschedule.** `nextDueAt` is recomputed from the
|
||||
* satisfy instant via the canonical recurrence engine
|
||||
* (`computeReminderNextDueAt`), so a freshly-satisfied reminder rolls
|
||||
* strictly past the current due cycle and the self-tracker never gets
|
||||
* nagged.
|
||||
*
|
||||
* Pure-ish: it reads the row fields it is handed and issues one Prisma
|
||||
* update. The caller supplies the reminder row + the user's timezone.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import {
|
||||
computeReminderNextDueAt,
|
||||
type ReminderScheduleInput,
|
||||
} from "@/lib/measurement-reminders/scheduling";
|
||||
|
||||
/**
|
||||
* The reminder fields `satisfyReminder` needs. A subset of the Prisma row
|
||||
* so callers (and tests) can construct it without the full model.
|
||||
*/
|
||||
export interface SatisfiableReminder {
|
||||
id: string;
|
||||
intervalDays: number | null;
|
||||
rrule: string | null;
|
||||
anchorDate: Date | null;
|
||||
notifyHour: number;
|
||||
lastSatisfiedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface SatisfyResult {
|
||||
/** True when the reminder was advanced; false when the event was older
|
||||
* than (or equal to) the existing `lastSatisfiedAt` (forward-only
|
||||
* no-op). */
|
||||
satisfied: boolean;
|
||||
/** The recomputed next-due instant when satisfied, else `null`. */
|
||||
nextDueAt: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a reminder's cadence satisfied at `satisfiedAt` and reschedule.
|
||||
*
|
||||
* Forward-only: if `satisfiedAt` is not strictly after the existing
|
||||
* `lastSatisfiedAt`, nothing is written and `{ satisfied: false }` is
|
||||
* returned. Otherwise stamps `lastSatisfiedAt = satisfiedAt`, recomputes
|
||||
* `nextDueAt` from the satisfy instant, persists both, and returns
|
||||
* `{ satisfied: true, nextDueAt }`.
|
||||
*/
|
||||
export async function satisfyReminder(
|
||||
prisma: PrismaClient,
|
||||
reminder: SatisfiableReminder,
|
||||
timezone: string,
|
||||
satisfiedAt: Date,
|
||||
): Promise<SatisfyResult> {
|
||||
// Forward-only guard. A null `lastSatisfiedAt` always advances. Equal
|
||||
// instants are a no-op so a cron poll behind an already-applied ingest
|
||||
// hook doesn't churn the row.
|
||||
if (
|
||||
reminder.lastSatisfiedAt !== null &&
|
||||
satisfiedAt.getTime() <= reminder.lastSatisfiedAt.getTime()
|
||||
) {
|
||||
return { satisfied: false, nextDueAt: null };
|
||||
}
|
||||
|
||||
const scheduleInput: ReminderScheduleInput = {
|
||||
intervalDays: reminder.intervalDays,
|
||||
rrule: reminder.rrule,
|
||||
anchorDate: reminder.anchorDate,
|
||||
notifyHour: reminder.notifyHour,
|
||||
lastSatisfiedAt: satisfiedAt,
|
||||
createdAt: reminder.createdAt,
|
||||
};
|
||||
const nextDueAt = computeReminderNextDueAt(
|
||||
scheduleInput,
|
||||
timezone,
|
||||
satisfiedAt,
|
||||
);
|
||||
|
||||
await prisma.measurementReminder.update({
|
||||
where: { id: reminder.id },
|
||||
data: { lastSatisfiedAt: satisfiedAt, nextDueAt },
|
||||
});
|
||||
|
||||
return { satisfied: true, nextDueAt };
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* v1.18.1 (V3) — the Vorsorge measurement-type allow-list. Widened from
|
||||
* the original 7 to the ~15 active-measurement set (vitals + the
|
||||
* body-composition family). App-layer only — the DB column is the full
|
||||
* MeasurementType enum, so this is the gate, not a migration.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
measurementReminderTypeEnum,
|
||||
createMeasurementReminderSchema,
|
||||
} from "../measurement-reminders";
|
||||
|
||||
describe("measurementReminderTypeEnum (V3 — completeness)", () => {
|
||||
const allowed = measurementReminderTypeEnum.options;
|
||||
|
||||
it("exposes the full active set: vitals + body composition", () => {
|
||||
expect(new Set(allowed)).toEqual(
|
||||
new Set([
|
||||
// Vitals
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"PULSE",
|
||||
"BLOOD_GLUCOSE",
|
||||
"OXYGEN_SATURATION",
|
||||
"BODY_TEMPERATURE",
|
||||
// Body composition
|
||||
"BODY_FAT",
|
||||
"FAT_MASS",
|
||||
"FAT_FREE_MASS",
|
||||
"MUSCLE_MASS",
|
||||
"LEAN_BODY_MASS",
|
||||
"BONE_MASS",
|
||||
"TOTAL_BODY_WATER",
|
||||
"VISCERAL_FAT",
|
||||
"BODY_MASS_INDEX",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a newly-added body-composition type on create", () => {
|
||||
const parsed = createMeasurementReminderSchema.safeParse({
|
||||
label: "Körperzusammensetzung messen",
|
||||
measurementType: "MUSCLE_MASS",
|
||||
intervalDays: 7,
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("matches BP on the SYS sentinel only (DIA would double-count)", () => {
|
||||
expect(allowed).toContain("BLOOD_PRESSURE_SYS");
|
||||
expect(allowed).not.toContain("BLOOD_PRESSURE_DIA");
|
||||
});
|
||||
|
||||
it("deliberately excludes passive / cumulative / event types", () => {
|
||||
for (const excluded of [
|
||||
"RESTING_HEART_RATE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"VO2_MAX",
|
||||
"ACTIVITY_STEPS",
|
||||
"RECOVERY_SCORE",
|
||||
"SLEEP_DURATION",
|
||||
"IRREGULAR_RHYTHM_NOTIFICATION",
|
||||
]) {
|
||||
expect(allowed).not.toContain(excluded);
|
||||
}
|
||||
});
|
||||
|
||||
it("still accepts a free-text reminder (null type) resolving on satisfy / lab", () => {
|
||||
const parsed = createMeasurementReminderSchema.safeParse({
|
||||
label: "Großes Blutbild",
|
||||
measurementType: null,
|
||||
rrule: "FREQ=YEARLY",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -17,28 +17,50 @@ import { z } from "zod/v4";
|
||||
/**
|
||||
* The auto-resolve target types. Kept as an explicit allow-list rather
|
||||
* than the full `MeasurementType` enum: a Vorsorge reminder only makes
|
||||
* sense for the metrics a user actively measures on a cadence (BP,
|
||||
* weight, glucose, pulse, SpO2, body composition, body temperature).
|
||||
* Free-text reminders pass `null` and resolve only on a manual satisfy.
|
||||
* sense for the metrics a user actively goes and measures on a cadence —
|
||||
* vitals + the body-composition family a scale produces.
|
||||
*
|
||||
* v1.18.1 (V3) widens the original 7 to the full ~15 active-measurement
|
||||
* set. The DB column is the full `MeasurementType` enum, so this is a
|
||||
* pure application-layer change — no migration.
|
||||
*
|
||||
* DELIBERATELY excluded: passive wearable scores + cumulative activity +
|
||||
* device events (RECOVERY_SCORE, ACTIVITY_STEPS, IRREGULAR_RHYTHM_*, …),
|
||||
* and the passive nightly samples RESTING_HEART_RATE / HEART_RATE_VARIABILITY
|
||||
* / VO2_MAX — nudging a user to "go measure" a passive sample makes no
|
||||
* sense (they still auto-satisfy any free-text reminder via a LabResult if
|
||||
* ever wanted, but they are not "remind me to measure" targets).
|
||||
*
|
||||
* BP is matched on `BLOOD_PRESSURE_SYS` as the canonical sentinel — a BP
|
||||
* reading is two rows (SYS + DIA) and matching either would double-count;
|
||||
* SYS is the agreed "a BP was measured" anchor.
|
||||
* SYS is the agreed "a BP was measured" anchor. A single step-on-the-scale
|
||||
* event writes WEIGHT + several body-composition rows at once, so any one
|
||||
* of them satisfies its linked reminder.
|
||||
*/
|
||||
export const measurementReminderTypeEnum = z
|
||||
.enum([
|
||||
// Vitals
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"PULSE",
|
||||
"BLOOD_GLUCOSE",
|
||||
"OXYGEN_SATURATION",
|
||||
"BODY_FAT",
|
||||
"BODY_TEMPERATURE",
|
||||
// Body composition (one scale reading produces the whole family)
|
||||
"BODY_FAT",
|
||||
"FAT_MASS",
|
||||
"FAT_FREE_MASS",
|
||||
"MUSCLE_MASS",
|
||||
"LEAN_BODY_MASS",
|
||||
"BONE_MASS",
|
||||
"TOTAL_BODY_WATER",
|
||||
"VISCERAL_FAT",
|
||||
"BODY_MASS_INDEX",
|
||||
])
|
||||
.meta({
|
||||
id: "MeasurementReminderType",
|
||||
description:
|
||||
"Auto-resolve target metric. BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel). Omit for a free-text Vorsorge that resolves only on a manual satisfy.",
|
||||
"Auto-resolve target metric (vitals + body-composition family). BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel). Omit for a free-text Vorsorge that resolves only on a manual satisfy or a matching lab result.",
|
||||
});
|
||||
|
||||
export type MeasurementReminderType = z.infer<
|
||||
|
||||
Reference in new issue
Block a user