Files
HealthLog/src/lib/validations/measurement-reminders.ts
T
Marc-André Bombeck 70f92558e6 feat(reminders): add server-side completion route for Vorsorge reminders
Add POST /api/measurement-reminders/{id}/complete so a client can mark a
measurement reminder done on the server, rather than only dismissing the
notification locally. The route is the explicit user-action equivalent of
the cron auto-resolve: it routes through the one shared satisfaction
primitive, stamps lastSatisfiedAt, re-anchors the server-authoritative
nextDueAt, and fires no notification of its own.

Idempotent via the primitive's forward-only guard — completing an
already-completed or auto-satisfied reminder returns 200 with
completed=false so a repeat tap need not be special-cased. Owner-scoped;
a cross-user or tombstoned id 404s and never satisfies. No schema change:
lastSatisfiedAt already records the completion instant.

Extend the OpenAPI contract with the new path and the completion result
schema, and cover the route with unit tests (marks done, idempotent
no-op, owner-scoped 404, no double-notify).
2026-06-18 21:39:24 +02:00

210 lines
8.2 KiB
TypeScript

/**
* v1.17.1 — Measurement / Vorsorge (preventive-care) reminders.
*
* A reminder class distinct from the medication loop: "measure BP every
* 7 days", "annual blood panel", "log weight weekly". Tied to a
* measurement TYPE (auto-resolving when a matching reading lands) or to a
* free-text checklist label (resolving only on a manual "Erledigt").
*
* The cadence vocabulary reuses the medication recurrence engine: a
* `intervalDays` rolling cadence OR an RFC-5545 `rrule`. The server
* computes the canonical `nextDueAt` so web ↔ iOS show identical numbers
* (server-authoritative; the client consumes the resolved DTO, never
* recomputes).
*/
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 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. 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_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 (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<
typeof measurementReminderTypeEnum
>;
/**
* RFC-5545 RRULE guard. The recurrence engine parses it; here we only
* bound the length and require the `FREQ=` token so a typo doesn't reach
* the engine. Full validation is the engine's job (it tolerates a
* malformed rule by emitting no slots rather than throwing).
*/
const rruleField = z
.string()
.min(1)
.max(512)
.refine((v) => /FREQ=/i.test(v), {
message: "rrule must contain a FREQ= component",
});
/**
* Shared cadence refinement: exactly one of `intervalDays` / `rrule` is
* required on create. Both NULL would leave `nextDueAt` uncomputable.
*/
const cadenceRefinement = (
data: { intervalDays?: number | null; rrule?: string | null },
ctx: z.RefinementCtx,
): void => {
const hasInterval =
data.intervalDays !== undefined && data.intervalDays !== null;
const hasRrule = data.rrule !== undefined && data.rrule !== null;
if (!hasInterval && !hasRrule) {
ctx.addIssue({
code: "custom",
message: "Provide either intervalDays or rrule",
path: ["intervalDays"],
});
}
if (hasInterval && hasRrule) {
ctx.addIssue({
code: "custom",
message: "Provide only one of intervalDays or rrule",
path: ["rrule"],
});
}
};
export const createMeasurementReminderSchema = z
.object({
label: z.string().trim().min(1).max(120),
measurementType: measurementReminderTypeEnum.nullish(),
intervalDays: z.number().int().min(1).max(3650).nullish(),
rrule: rruleField.nullish(),
anchorDate: z.iso.datetime({ offset: true }).nullish(),
notifyHour: z.number().int().min(0).max(23).default(9),
location: z.string().trim().max(200).nullish(),
enabled: z.boolean().default(true),
})
.superRefine(cadenceRefinement)
.meta({
id: "MeasurementReminderCreate",
description:
"Create a Vorsorge reminder. Exactly one of intervalDays (rolling, every N days) or rrule (RFC-5545, e.g. FREQ=YEARLY) is required. measurementType enables auto-resolve from an incoming reading; omit it for a free-text checklist reminder.",
});
export type CreateMeasurementReminderInput = z.infer<
typeof createMeasurementReminderSchema
>;
/**
* Update is fully partial. Cadence fields stay mutually-exclusive but
* are not required (an edit that only changes the label leaves the
* cadence alone). The route re-derives `nextDueAt` after applying the
* patch.
*/
export const updateMeasurementReminderSchema = z
.object({
label: z.string().trim().min(1).max(120).optional(),
measurementType: measurementReminderTypeEnum.nullish(),
intervalDays: z.number().int().min(1).max(3650).nullish(),
rrule: rruleField.nullish(),
anchorDate: z.iso.datetime({ offset: true }).nullish(),
notifyHour: z.number().int().min(0).max(23).optional(),
location: z.string().trim().max(200).nullish(),
enabled: z.boolean().optional(),
})
.meta({
id: "MeasurementReminderUpdate",
description:
"Partial edit of a Vorsorge reminder. Omitted fields are left untouched; nextDueAt is recomputed server-side after the patch applies.",
});
export type UpdateMeasurementReminderInput = z.infer<
typeof updateMeasurementReminderSchema
>;
/**
* The canonical DTO every surface (web list, dashboard tile, iOS) mirrors.
* `nextDueAt` is the server-computed next-due instant.
*/
export const measurementReminderDto = z
.object({
id: z.string(),
label: z.string(),
measurementType: measurementReminderTypeEnum.nullable(),
intervalDays: z.number().int().nullable(),
rrule: z.string().nullable(),
anchorDate: z.iso.datetime({ offset: true }).nullable(),
endsOn: z.iso.datetime({ offset: true }).nullable(),
origin: z.enum(["VORSORGE", "COACH"]),
notifyHour: z.number().int(),
location: z.string().nullable(),
nextDueAt: z.iso.datetime({ offset: true }).nullable(),
lastSatisfiedAt: z.iso.datetime({ offset: true }).nullable(),
enabled: z.boolean(),
createdAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
})
.meta({
id: "MeasurementReminderDTO",
description:
"A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries measurementType=null and resolves only on a manual satisfy. origin distinguishes a user-created (VORSORGE) reminder from one minted by a Coach cadence suggestion (COACH); endsOn bounds a finite course window (null = open-ended).",
});
/**
* v1.18.6 — explicit-completion result. The `complete` route is the
* user-action equivalent of the cron auto-satisfy: it stamps
* `lastSatisfiedAt` and re-anchors `nextDueAt` through the SAME shared
* primitive, so it fires no notification of its own and is idempotent.
*
* `completed` reports whether THIS call advanced the reminder (true) or was
* a forward-only no-op because a prior satisfy / matching reading already
* fulfilled the current cycle (false). Either way the response is a 200 so
* a client that completes an already-completed reminder need not special-case
* the second tap. `reminder` carries the canonical post-completion DTO.
*/
export const measurementReminderCompletionDto = z
.object({
completed: z.boolean(),
reminder: measurementReminderDto,
})
.meta({
id: "MeasurementReminderCompletion",
description:
"Result of an explicit reminder completion. completed=true when this call advanced lastSatisfiedAt; completed=false when an earlier satisfy or a matching reading had already fulfilled the current cycle (idempotent no-op). reminder is the canonical post-completion DTO.",
});