mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
The first cut kept only free-text check-ups due after their reminder and let every reminder with a measurement type roll on. A fortnightly PHQ-9 or GAD-7 reminder is a measurement reminder, and each one still jumped two weeks forward the morning it was sent without the questionnaire being filled. The rule is now the cycle length. A reminder whose next slot is more than seven days away, or that has no cadence, stays due after its reminder until it is satisfied (a matching reading, a lab result or "done"), skipped or snoozed, and is sent again at most once a week. A reminder on a weekly or shorter cycle rolls on as before. Appointments stay one-shot. A reading that lands on an open measurement reminder still sets lastSatisfiedAt and schedules the next cycle from itself. The 0355 repair drops its "no measurement type" condition for the same cycle rule, so the fortnightly questionnaires in the reported shape are put back on the day they were reminded.
303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
/**
|
|
* OpenAPI route table — Vorsorge (measurement) reminders (v1.17.1).
|
|
*
|
|
* Part of the OpenAPI route table; aggregated in `./index.ts`. Request
|
|
* bodies come from the Zod validation module so the wire contract stays
|
|
* single-source; the response DTO mirrors `src/lib/measurement-reminders/dto.ts`.
|
|
*/
|
|
import { z } from "zod/v4";
|
|
import type { ZodOpenApiObject } from "zod-openapi";
|
|
|
|
import {
|
|
createMeasurementReminderSchema,
|
|
updateMeasurementReminderSchema,
|
|
measurementReminderDto,
|
|
measurementReminderCompletionDto,
|
|
measurementReminderSkipDto,
|
|
measurementReminderHistoryDto,
|
|
snoozeMeasurementReminderSchema,
|
|
listReminderHistorySchema,
|
|
} from "@/lib/validations/measurement-reminders";
|
|
import {
|
|
dataEnvelope,
|
|
errorEnvelope,
|
|
idempotencyKeyParameter,
|
|
idempotentWrite,
|
|
recordRefusal,
|
|
stdResponses,
|
|
} from "./shared";
|
|
|
|
const reminderNotFound = {
|
|
"404": {
|
|
description: "Measurement reminder not found / not owned.",
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
} as const;
|
|
|
|
export const measurementReminderPaths: NonNullable<ZodOpenApiObject["paths"]> =
|
|
{
|
|
"/api/measurement-reminders": {
|
|
get: {
|
|
tags: ["MeasurementReminders"],
|
|
summary: "List Vorsorge reminders (v1.17.1)",
|
|
description:
|
|
"Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing. Since v1.39.2 a reminder whose cycle is longer than seven days (intervalDays > 7, an rrule whose next occurrence is more than seven days out) or that has no cadence keeps its nextDueAt after its reminder is sent, with or without a measurementType: it stays due, then overdue, until it is satisfied (a matching reading or lab result, or completed), skipped or snoozed, and the server repeats the reminder at most once every seven local days while it stays open. A reminder on a weekly or shorter cycle still rolls on to its next slot after a delivered reminder.",
|
|
responses: {
|
|
...recordRefusal(),
|
|
"200": {
|
|
description: "The owner's measurement reminders.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
z.array(measurementReminderDto),
|
|
"MeasurementReminderListEnvelope",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
post: {
|
|
tags: ["MeasurementReminders"],
|
|
summary: "Create a Vorsorge reminder (v1.17.1)",
|
|
description:
|
|
"Creates a reminder and computes its server-authoritative nextDueAt. Exactly one of intervalDays (rolling) or rrule (RFC-5545) is required. Wrapped in withIdempotency. 201 on insert.",
|
|
parameters: [idempotencyKeyParameter],
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
"application/json": { schema: createMeasurementReminderSchema },
|
|
},
|
|
},
|
|
responses: {
|
|
...idempotentWrite(),
|
|
...recordRefusal(),
|
|
"201": {
|
|
description: "Reminder created.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
measurementReminderDto,
|
|
"MeasurementReminderCreatedEnvelope",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/measurement-reminders/{id}": {
|
|
get: {
|
|
tags: ["MeasurementReminders"],
|
|
summary: "Read a single Vorsorge reminder (v1.17.1)",
|
|
description: "Owner-scoped; a cross-user or tombstoned id 404s.",
|
|
requestParams: { path: z.object({ id: z.string() }) },
|
|
responses: {
|
|
...recordRefusal(),
|
|
"200": {
|
|
description: "The reminder.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
measurementReminderDto,
|
|
"MeasurementReminderEnvelope",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...reminderNotFound,
|
|
...stdResponses,
|
|
},
|
|
},
|
|
patch: {
|
|
tags: ["MeasurementReminders"],
|
|
summary: "Edit a Vorsorge reminder (v1.17.1)",
|
|
description:
|
|
"Partial edit; omitted fields are left untouched. nextDueAt is recomputed server-side only when the edit changes when the reminder recurs (intervalDays, rrule, anchorDate compared by value, or enabled switched back on); that recompute also clears a snooze. A notifyHour-only edit keeps the due day and moves the hour on it. Any other edit (label, location, measurementType) leaves nextDueAt as it is, so an open, overdue check-up stays due. Owner-scoped.",
|
|
requestParams: { path: z.object({ id: z.string() }) },
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
"application/json": { schema: updateMeasurementReminderSchema },
|
|
},
|
|
},
|
|
responses: {
|
|
...recordRefusal(),
|
|
"200": {
|
|
description: "Reminder updated.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
measurementReminderDto,
|
|
"MeasurementReminderPatchEnvelope",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...reminderNotFound,
|
|
...stdResponses,
|
|
},
|
|
},
|
|
delete: {
|
|
tags: ["MeasurementReminders"],
|
|
summary: "Soft-delete a Vorsorge reminder (v1.17.1)",
|
|
description: "Sets deletedAt (tombstone). Idempotent. Owner-scoped.",
|
|
requestParams: { path: z.object({ id: z.string() }) },
|
|
responses: {
|
|
...recordRefusal(),
|
|
"200": {
|
|
description: "Soft-deleted.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
z.object({ deleted: z.boolean() }),
|
|
"MeasurementReminderDeleteEnvelope",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...reminderNotFound,
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/measurement-reminders/{id}/satisfy": {
|
|
post: {
|
|
tags: ["MeasurementReminders"],
|
|
summary: "Mark a Vorsorge reminder done (v1.17.1)",
|
|
description:
|
|
"Manual 'Erledigt': stamps lastSatisfiedAt = now and recomputes nextDueAt past now. Free-text reminders resolve only through this path; typed reminders also auto-resolve in the cron when a matching reading lands.",
|
|
requestParams: { path: z.object({ id: z.string() }) },
|
|
responses: {
|
|
...recordRefusal(),
|
|
"200": {
|
|
description: "Reminder satisfied; next-due re-anchored.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
measurementReminderDto,
|
|
"MeasurementReminderSatisfyEnvelope",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...reminderNotFound,
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/measurement-reminders/{id}/complete": {
|
|
post: {
|
|
tags: ["MeasurementReminders"],
|
|
summary: "Complete a Vorsorge reminder (v1.18.6)",
|
|
description:
|
|
"Explicit user-action completion (iOS #23): the app marks a reminder done server-side instead of only dismissing it locally. Routes through the same satisfaction primitive as the cron auto-resolve — stamps lastSatisfiedAt = now, re-anchors nextDueAt, fires no notification. Idempotent: completing an already-completed / auto-satisfied reminder returns 200 with completed=false. Owner-scoped.",
|
|
requestParams: { path: z.object({ id: z.string() }) },
|
|
responses: {
|
|
...recordRefusal(),
|
|
"200": {
|
|
description:
|
|
"Completion applied (completed=true) or already-satisfied no-op (completed=false); reminder carries the canonical post-completion DTO.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
measurementReminderCompletionDto,
|
|
"MeasurementReminderCompleteEnvelope",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...reminderNotFound,
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/measurement-reminders/{id}/skip": {
|
|
post: {
|
|
tags: ["MeasurementReminders"],
|
|
summary: "Skip the current due cycle (v1.37.20)",
|
|
description:
|
|
"Honestly skips the current due cycle — no body; the skip instant is the server clock. The interval restarts from the skip (rolling: skip + N days at the notify hour; rrule: next occurrence strictly after the skip), lastSkippedAt is stamped, skipCount incremented, any snooze cleared, and a SKIPPED row lands in the completion ledger. lastSatisfiedAt is never touched: a skip is not a completion. Screening reminders are skippable (the satisfy-side 409 guards claimed fulfilment; a skip claims the opposite). Owner-scoped; an appointment (encounter) reminder 404s.",
|
|
requestParams: { path: z.object({ id: z.string() }) },
|
|
responses: {
|
|
...recordRefusal(),
|
|
"200": {
|
|
description:
|
|
"Skip applied (skipped=true) or forward-only no-op (skipped=false); reminder carries the canonical post-skip DTO.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
measurementReminderSkipDto,
|
|
"MeasurementReminderSkipEnvelope",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...reminderNotFound,
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/measurement-reminders/{id}/snooze": {
|
|
post: {
|
|
tags: ["MeasurementReminders"],
|
|
summary: "Snooze to a calendar day (v1.37.20)",
|
|
description:
|
|
"Pushes the current due date back to a named calendar day (YYYY-MM-DD), resolved server-side to the reminder's notifyHour in the profile timezone. snoozedUntil and nextDueAt are set to the same instant, so every due-state consumer moves together and the cursor self-expires. The regular cadence is untouched — lastSatisfiedAt, lastSkippedAt and the anchor stay put. Must be at least tomorrow and at most five years out (422 otherwise). Repeated snoozes: last one wins. Owner-scoped; an appointment reminder 404s.",
|
|
requestParams: { path: z.object({ id: z.string() }) },
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
"application/json": { schema: snoozeMeasurementReminderSchema },
|
|
},
|
|
},
|
|
responses: {
|
|
...recordRefusal(),
|
|
"200": {
|
|
description: "Snooze applied; reminder carries the resolved DTO.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
z.object({ reminder: measurementReminderDto }),
|
|
"MeasurementReminderSnoozeEnvelope",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...reminderNotFound,
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/measurement-reminders/{id}/history": {
|
|
get: {
|
|
tags: ["MeasurementReminders"],
|
|
summary: "Completion ledger, newest first (v1.37.20)",
|
|
description:
|
|
"Paginated completion ledger for one reminder (iOS #68): one row per satisfy (from any path) and per skip, newest first. onTime is derived server-side at write time against the due instant that was current when the event landed. History begins at the release that introduced the ledger — the single-cursor engine holds nothing to backfill from. Owner-scoped; an appointment reminder 404s.",
|
|
requestParams: {
|
|
path: z.object({ id: z.string() }),
|
|
query: listReminderHistorySchema,
|
|
},
|
|
responses: {
|
|
...recordRefusal(),
|
|
"200": {
|
|
description: "The reminder's completion ledger page.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
measurementReminderHistoryDto,
|
|
"MeasurementReminderHistoryEnvelope",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...reminderNotFound,
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
};
|