diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index aef327181..bb945ee6b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -39,6 +39,7 @@ tags: - name: Export - name: Sync - name: Cycle + - name: MeasurementReminders - name: Admin - name: Meta paths: @@ -5250,6 +5251,148 @@ paths: $ref: "#/components/schemas/CsvImportTooLargeEnvelope" "422": *a3 "429": *a4 + /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. + responses: + "200": + description: The owner's measurement reminders. + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementReminderListEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + 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. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementReminderCreate" + responses: + "201": + description: Reminder created. + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementReminderCreatedEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /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. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The reminder. + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementReminderEnvelope" + "401": *a1 + "404": &a6 + description: Measurement reminder not found / not owned. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + patch: + tags: + - MeasurementReminders + summary: Edit a Vorsorge reminder (v1.17.1) + description: Partial edit; omitted fields are left untouched. nextDueAt is recomputed server-side after the cadence + merge. Owner-scoped. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementReminderUpdate" + responses: + "200": + description: Reminder updated. + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" + "401": *a1 + "404": *a6 + "422": *a3 + "429": *a4 + delete: + tags: + - MeasurementReminders + summary: Soft-delete a Vorsorge reminder (v1.17.1) + description: Sets deletedAt (tombstone). Idempotent. Owner-scoped. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Soft-deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" + "401": *a1 + "404": *a6 + "422": *a3 + "429": *a4 + /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." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Reminder satisfied; next-due re-anchored. + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" + "401": *a1 + "404": *a6 + "422": *a3 + "429": *a4 components: schemas: PasskeyLoginVerifyRequest: @@ -6261,6 +6404,11 @@ components: enum: - weekly - biweekly + measurementReminder: + type: object + properties: + clientManaged: + type: boolean description: Partial per-user notification preferences. Every category and field is optional; the server deep-merges the supplied keys over the persisted row, so a PATCH touching only `medication` leaves the siblings intact. `medication.lowStockRunwayDays` (1–60, nullable) is the low-stock alert threshold in remaining runway days — @@ -6413,6 +6561,107 @@ components: properties: {} description: No body fields. The user is taken from the session / Bearer and the locale from the session; the warm covers every assessment for that user. + MeasurementReminderCreate: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 120 + measurementType: + anyOf: + - $ref: "#/components/schemas/MeasurementReminderType" + - type: "null" + intervalDays: + anyOf: + - type: integer + minimum: 1 + maximum: 3650 + - type: "null" + rrule: + anyOf: + - type: string + minLength: 1 + maxLength: 512 + - type: "null" + anchorDate: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + notifyHour: + default: 9 + type: integer + minimum: 0 + maximum: 23 + location: + anyOf: + - type: string + maxLength: 200 + - type: "null" + enabled: + default: true + type: boolean + required: + - label + 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. + MeasurementReminderType: + type: string + enum: + - WEIGHT + - BLOOD_PRESSURE_SYS + - 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. + MeasurementReminderUpdate: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 120 + measurementType: + anyOf: + - $ref: "#/components/schemas/MeasurementReminderType" + - type: "null" + intervalDays: + anyOf: + - type: integer + minimum: 1 + maximum: 3650 + - type: "null" + rrule: + anyOf: + - type: string + minLength: 1 + maxLength: 512 + - type: "null" + anchorDate: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + notifyHour: + type: integer + minimum: 0 + maximum: 23 + location: + anyOf: + - type: string + maxLength: 200 + - type: "null" + enabled: + type: boolean + description: Partial edit of a Vorsorge reminder. Omitted fields are left untouched; nextDueAt is recomputed server-side + after the patch applies. MedicationTreatmentClass: type: string enum: @@ -15722,6 +15971,190 @@ components: - data - error additionalProperties: false + MeasurementReminderListEnvelope: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/MeasurementReminderDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MeasurementReminderDTO: + type: object + properties: + id: + type: string + label: + type: string + measurementType: + anyOf: + - $ref: "#/components/schemas/MeasurementReminderType" + - type: "null" + intervalDays: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + rrule: + anyOf: + - type: string + - type: "null" + anchorDate: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + notifyHour: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + location: + anyOf: + - type: string + - type: "null" + nextDueAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + lastSatisfiedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + enabled: + type: boolean + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - label + - measurementType + - intervalDays + - rrule + - anchorDate + - notifyHour + - location + - nextDueAt + - lastSatisfiedAt + - enabled + - createdAt + - updatedAt + additionalProperties: false + description: A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries + measurementType=null and resolves only on a manual satisfy. + MeasurementReminderCreatedEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MeasurementReminderDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MeasurementReminderEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MeasurementReminderDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MeasurementReminderPatchEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MeasurementReminderDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MeasurementReminderDeleteEnvelope: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MeasurementReminderSatisfyEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MeasurementReminderDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false MedicationScheduleOutput: type: object properties: diff --git a/messages/de.json b/messages/de.json index fe4c16266..7600005a2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6255,5 +6255,56 @@ "deleteN": "Löschen ({count})", "clearSelection": "Auswahl aufheben", "selectionBarLabel": "Auswahlaktionen" + }, + "measurementReminders": { + "sectionTitle": "Vorsorge", + "sectionDescription": "Erinnerungen, was wann und wo zu messen oder zu prüfen ist.", + "addButton": "Erinnerung hinzufügen", + "markDone": "Erledigt", + "delete": "Löschen", + "disabledBadge": "Aus", + "nextDue": { + "none": "Nicht geplant", + "today": "Heute fällig", + "tomorrow": "Morgen fällig", + "inDays": "In {days} Tagen" + }, + "overdueByDays": "Überfällig seit {days} Tagen", + "cadence": { + "everyNDays": "Alle {days} Tage", + "custom": "Eigener Zeitplan" + }, + "location": { + "prefix": "Bei: {location}" + }, + "types": { + "WEIGHT": "Gewicht", + "BLOOD_PRESSURE_SYS": "Blutdruck", + "PULSE": "Puls", + "BLOOD_GLUCOSE": "Blutzucker", + "OXYGEN_SATURATION": "Sauerstoffsättigung", + "BODY_FAT": "Körperfett", + "BODY_TEMPERATURE": "Körpertemperatur" + }, + "empty": { + "title": "Noch keine Erinnerungen", + "description": "Füge eine Erinnerung hinzu, um z. B. regelmäßig den Blutdruck zu messen oder ein jährliches Blutbild zu planen." + }, + "form": { + "label": "Bezeichnung", + "labelPlaceholder": "z. B. Blutdruck messen", + "kind": "Art", + "kindType": "Mit einer Messung verknüpft", + "kindFreeText": "Freitext-Prüfung", + "measurementType": "Messwert", + "cadence": "Wiederholung", + "notifyHour": "Erinnern um", + "location": "Wo (optional)", + "locationPlaceholder": "z. B. Hausarzt, Labor", + "save": "Speichern" + }, + "pushTitle": "Vorsorge-Erinnerung", + "pushBody": "Fällig: {label}", + "pushLocation": "(bei {location})" } } diff --git a/messages/en.json b/messages/en.json index 518cc02b7..ad96d881d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6255,5 +6255,56 @@ "deleteN": "Delete ({count})", "clearSelection": "Clear selection", "selectionBarLabel": "Selection actions" + }, + "measurementReminders": { + "sectionTitle": "Preventive care", + "sectionDescription": "Reminders for what to measure or check, when, and where.", + "addButton": "Add reminder", + "markDone": "Done", + "delete": "Delete", + "disabledBadge": "Off", + "nextDue": { + "none": "Not scheduled", + "today": "Due today", + "tomorrow": "Due tomorrow", + "inDays": "In {days} days" + }, + "overdueByDays": "Overdue by {days} days", + "cadence": { + "everyNDays": "Every {days} days", + "custom": "Custom schedule" + }, + "location": { + "prefix": "At: {location}" + }, + "types": { + "WEIGHT": "Weight", + "BLOOD_PRESSURE_SYS": "Blood pressure", + "PULSE": "Pulse", + "BLOOD_GLUCOSE": "Blood glucose", + "OXYGEN_SATURATION": "Oxygen saturation", + "BODY_FAT": "Body fat", + "BODY_TEMPERATURE": "Body temperature" + }, + "empty": { + "title": "No reminders yet", + "description": "Add a reminder to measure your blood pressure on a cadence, or to schedule an annual blood panel." + }, + "form": { + "label": "Label", + "labelPlaceholder": "e.g. Measure blood pressure", + "kind": "Type", + "kindType": "Linked to a measurement", + "kindFreeText": "Free-text check", + "measurementType": "Measurement", + "cadence": "Repeat", + "notifyHour": "Remind at", + "location": "Where (optional)", + "locationPlaceholder": "e.g. GP, Lab", + "save": "Save" + }, + "pushTitle": "Preventive-care reminder", + "pushBody": "Time for: {label}", + "pushLocation": "(at {location})" } } diff --git a/messages/es.json b/messages/es.json index c3c8399d1..7d92cd4c7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6255,5 +6255,56 @@ "deleteN": "Eliminar ({count})", "clearSelection": "Borrar selección", "selectionBarLabel": "Acciones de selección" + }, + "measurementReminders": { + "sectionTitle": "Prevención", + "sectionDescription": "Recordatorios de qué medir o revisar, cuándo y dónde.", + "addButton": "Añadir recordatorio", + "markDone": "Hecho", + "delete": "Eliminar", + "disabledBadge": "Apagado", + "nextDue": { + "none": "Sin programar", + "today": "Vence hoy", + "tomorrow": "Vence mañana", + "inDays": "En {days} días" + }, + "overdueByDays": "Vencido hace {days} días", + "cadence": { + "everyNDays": "Cada {days} días", + "custom": "Programación personalizada" + }, + "location": { + "prefix": "En: {location}" + }, + "types": { + "WEIGHT": "Peso", + "BLOOD_PRESSURE_SYS": "Presión arterial", + "PULSE": "Pulso", + "BLOOD_GLUCOSE": "Glucosa en sangre", + "OXYGEN_SATURATION": "Saturación de oxígeno", + "BODY_FAT": "Grasa corporal", + "BODY_TEMPERATURE": "Temperatura corporal" + }, + "empty": { + "title": "Aún no hay recordatorios", + "description": "Añade un recordatorio para medir la presión arterial con regularidad o programar un análisis de sangre anual." + }, + "form": { + "label": "Etiqueta", + "labelPlaceholder": "p. ej. Medir la presión arterial", + "kind": "Tipo", + "kindType": "Vinculado a una medición", + "kindFreeText": "Comprobación de texto libre", + "measurementType": "Medición", + "cadence": "Repetir", + "notifyHour": "Recordar a las", + "location": "Dónde (opcional)", + "locationPlaceholder": "p. ej. Médico, Laboratorio", + "save": "Guardar" + }, + "pushTitle": "Recordatorio de prevención", + "pushBody": "Toca: {label}", + "pushLocation": "(en {location})" } } diff --git a/messages/fr.json b/messages/fr.json index a0d62950a..b52d3d2ac 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6255,5 +6255,56 @@ "deleteN": "Supprimer ({count})", "clearSelection": "Effacer la sélection", "selectionBarLabel": "Actions de sélection" + }, + "measurementReminders": { + "sectionTitle": "Prévention", + "sectionDescription": "Rappels de ce qu'il faut mesurer ou vérifier, quand et où.", + "addButton": "Ajouter un rappel", + "markDone": "Terminé", + "delete": "Supprimer", + "disabledBadge": "Désactivé", + "nextDue": { + "none": "Non planifié", + "today": "À faire aujourd'hui", + "tomorrow": "À faire demain", + "inDays": "Dans {days} jours" + }, + "overdueByDays": "En retard de {days} jours", + "cadence": { + "everyNDays": "Tous les {days} jours", + "custom": "Planning personnalisé" + }, + "location": { + "prefix": "Chez : {location}" + }, + "types": { + "WEIGHT": "Poids", + "BLOOD_PRESSURE_SYS": "Tension artérielle", + "PULSE": "Pouls", + "BLOOD_GLUCOSE": "Glycémie", + "OXYGEN_SATURATION": "Saturation en oxygène", + "BODY_FAT": "Masse grasse", + "BODY_TEMPERATURE": "Température corporelle" + }, + "empty": { + "title": "Aucun rappel pour l'instant", + "description": "Ajoutez un rappel pour mesurer régulièrement votre tension ou planifier un bilan sanguin annuel." + }, + "form": { + "label": "Libellé", + "labelPlaceholder": "ex. Mesurer la tension", + "kind": "Type", + "kindType": "Lié à une mesure", + "kindFreeText": "Vérification libre", + "measurementType": "Mesure", + "cadence": "Répétition", + "notifyHour": "Rappeler à", + "location": "Où (facultatif)", + "locationPlaceholder": "ex. Médecin, Laboratoire", + "save": "Enregistrer" + }, + "pushTitle": "Rappel de prévention", + "pushBody": "C'est l'heure : {label}", + "pushLocation": "(chez {location})" } } diff --git a/messages/it.json b/messages/it.json index d4c8c466a..80b09d762 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6255,5 +6255,56 @@ "deleteN": "Elimina ({count})", "clearSelection": "Cancella selezione", "selectionBarLabel": "Azioni di selezione" + }, + "measurementReminders": { + "sectionTitle": "Prevenzione", + "sectionDescription": "Promemoria su cosa misurare o controllare, quando e dove.", + "addButton": "Aggiungi promemoria", + "markDone": "Fatto", + "delete": "Elimina", + "disabledBadge": "Disattivato", + "nextDue": { + "none": "Non pianificato", + "today": "In scadenza oggi", + "tomorrow": "In scadenza domani", + "inDays": "Tra {days} giorni" + }, + "overdueByDays": "In ritardo di {days} giorni", + "cadence": { + "everyNDays": "Ogni {days} giorni", + "custom": "Pianificazione personalizzata" + }, + "location": { + "prefix": "Presso: {location}" + }, + "types": { + "WEIGHT": "Peso", + "BLOOD_PRESSURE_SYS": "Pressione sanguigna", + "PULSE": "Battito", + "BLOOD_GLUCOSE": "Glicemia", + "OXYGEN_SATURATION": "Saturazione di ossigeno", + "BODY_FAT": "Grasso corporeo", + "BODY_TEMPERATURE": "Temperatura corporea" + }, + "empty": { + "title": "Ancora nessun promemoria", + "description": "Aggiungi un promemoria per misurare regolarmente la pressione o pianificare un esame del sangue annuale." + }, + "form": { + "label": "Etichetta", + "labelPlaceholder": "es. Misurare la pressione", + "kind": "Tipo", + "kindType": "Collegato a una misurazione", + "kindFreeText": "Controllo a testo libero", + "measurementType": "Misurazione", + "cadence": "Ripeti", + "notifyHour": "Ricorda alle", + "location": "Dove (facoltativo)", + "locationPlaceholder": "es. Medico, Laboratorio", + "save": "Salva" + }, + "pushTitle": "Promemoria di prevenzione", + "pushBody": "È ora di: {label}", + "pushLocation": "(presso {location})" } } diff --git a/messages/pl.json b/messages/pl.json index 72ae0a0f3..5871f223f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6255,5 +6255,56 @@ "deleteN": "Usuń ({count})", "clearSelection": "Wyczyść zaznaczenie", "selectionBarLabel": "Akcje zaznaczenia" + }, + "measurementReminders": { + "sectionTitle": "Profilaktyka", + "sectionDescription": "Przypomnienia, co, kiedy i gdzie zmierzyć lub sprawdzić.", + "addButton": "Dodaj przypomnienie", + "markDone": "Gotowe", + "delete": "Usuń", + "disabledBadge": "Wył.", + "nextDue": { + "none": "Niezaplanowane", + "today": "Termin dzisiaj", + "tomorrow": "Termin jutro", + "inDays": "Za {days} dni" + }, + "overdueByDays": "Zaległe od {days} dni", + "cadence": { + "everyNDays": "Co {days} dni", + "custom": "Własny harmonogram" + }, + "location": { + "prefix": "U: {location}" + }, + "types": { + "WEIGHT": "Waga", + "BLOOD_PRESSURE_SYS": "Ciśnienie krwi", + "PULSE": "Tętno", + "BLOOD_GLUCOSE": "Glukoza we krwi", + "OXYGEN_SATURATION": "Saturacja tlenem", + "BODY_FAT": "Tkanka tłuszczowa", + "BODY_TEMPERATURE": "Temperatura ciała" + }, + "empty": { + "title": "Brak przypomnień", + "description": "Dodaj przypomnienie, aby regularnie mierzyć ciśnienie krwi lub zaplanować coroczne badanie krwi." + }, + "form": { + "label": "Etykieta", + "labelPlaceholder": "np. Zmierz ciśnienie krwi", + "kind": "Typ", + "kindType": "Powiązane z pomiarem", + "kindFreeText": "Sprawdzenie tekstowe", + "measurementType": "Pomiar", + "cadence": "Powtarzaj", + "notifyHour": "Przypomnij o", + "location": "Gdzie (opcjonalnie)", + "locationPlaceholder": "np. Lekarz, Laboratorium", + "save": "Zapisz" + }, + "pushTitle": "Przypomnienie profilaktyczne", + "pushBody": "Czas na: {label}", + "pushLocation": "(u {location})" } } diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index ae16929e3..46dbd045d 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -103,6 +103,9 @@ describe("GET /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); @@ -135,6 +138,9 @@ describe("GET /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); @@ -169,6 +175,9 @@ describe("GET /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); }); @@ -214,6 +223,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); expect(prisma.user.update).toHaveBeenCalledWith({ @@ -235,6 +247,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }, }, }); @@ -260,6 +275,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }, next: { medication: { @@ -277,6 +295,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }, changed: ["medication"], }), @@ -347,6 +368,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }, }, }); @@ -382,6 +406,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); expect(prisma.user.update).toHaveBeenCalledWith({ @@ -403,6 +430,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }, }, }); @@ -443,6 +473,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }, }, }); diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts new file mode 100644 index 000000000..5813cca22 --- /dev/null +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -0,0 +1,209 @@ +/** + * v1.17.1 — Vorsorge (measurement) reminder by id: get + patch + delete. + * + * PATCH re-derives the server-authoritative `nextDueAt` after applying + * the (mutually-exclusive cadence) edit. DELETE soft-deletes (tombstone) + * for parity with the rest of the tree. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { updateMeasurementReminderSchema } from "@/lib/validations/measurement-reminders"; +import { + computeReminderNextDueAt, + type ReminderScheduleInput, +} from "@/lib/measurement-reminders/scheduling"; +import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; + +type RouteParams = { params: Promise<{ id: string }> }; + +const DEFAULT_TIMEZONE = "Europe/Berlin"; + +async function resolveTimezone(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }); + return row?.timezone || DEFAULT_TIMEZONE; +} + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const reminder = await prisma.measurementReminder.findFirst({ + where: { id, deletedAt: null }, + }); + if (!reminder || reminder.userId !== user.id) { + return apiError("Measurement reminder not found", 404); + } + + annotate({ + action: { name: "measurement-reminders.get" }, + meta: { reminderId: id }, + }); + + return apiSuccess(toMeasurementReminderDto(reminder)); + }, +); + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const existing = await prisma.measurementReminder.findFirst({ + where: { id, deletedAt: null }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Measurement reminder not found", 404); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = updateMeasurementReminderSchema.safeParse(body); + if (!parsed.success) { + const issues = sanitiseZodIssues(parsed.error.issues); + annotate({ + action: { name: "measurement-reminders.update.validation-failed" }, + meta: { issue_count: issues.length, reminderId: id }, + }); + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "measurement-reminders.update.validation-failed", + details: JSON.stringify({ issues: auditIssues, reminderId: id }), + }, + }) + .catch(() => { + /* swallow — 422 response is the contract */ + }); + return returnAllZodIssues(parsed.error, 422); + } + + const data = parsed.data; + + // Field-by-field — no mass assignment. + const updateData: Record = {}; + if (data.label !== undefined) updateData.label = data.label; + if (data.measurementType !== undefined) { + updateData.measurementType = data.measurementType; + } + if (data.intervalDays !== undefined) { + updateData.intervalDays = data.intervalDays; + // Setting one cadence clears the other so the engine reads exactly + // one dispatch family. + if (data.intervalDays !== null && data.rrule === undefined) { + updateData.rrule = null; + } + } + if (data.rrule !== undefined) { + updateData.rrule = data.rrule; + if (data.rrule !== null && data.intervalDays === undefined) { + updateData.intervalDays = null; + } + } + if (data.anchorDate !== undefined) { + updateData.anchorDate = + data.anchorDate != null ? new Date(data.anchorDate) : null; + } + if (data.notifyHour !== undefined) updateData.notifyHour = data.notifyHour; + if (data.location !== undefined) updateData.location = data.location; + if (data.enabled !== undefined) updateData.enabled = data.enabled; + + // Recompute next-due against the merged cadence. Floor the search at + // the last-satisfied instant (or now) so a cadence edit re-anchors + // cleanly off the user's last fulfilment. + const timezone = await resolveTimezone(user.id); + const now = new Date(); + const merged: ReminderScheduleInput = { + intervalDays: + (updateData.intervalDays as number | null | undefined) ?? + existing.intervalDays, + rrule: (updateData.rrule as string | null | undefined) ?? existing.rrule, + anchorDate: + (updateData.anchorDate as Date | null | undefined) ?? + existing.anchorDate, + notifyHour: + (updateData.notifyHour as number | undefined) ?? existing.notifyHour, + lastSatisfiedAt: existing.lastSatisfiedAt, + createdAt: existing.createdAt, + }; + const after = + existing.lastSatisfiedAt && existing.lastSatisfiedAt > now + ? existing.lastSatisfiedAt + : now; + updateData.nextDueAt = computeReminderNextDueAt(merged, timezone, after); + + const updated = await prisma.measurementReminder.update({ + where: { id }, + data: updateData, + }); + + await auditLog("measurementReminder.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { reminderId: id }, + }); + + annotate({ + action: { name: "measurement-reminders.update" }, + meta: { reminderId: id }, + }); + + return apiSuccess(toMeasurementReminderDto(updated)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const existing = await prisma.measurementReminder.findUnique({ + where: { id }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Measurement reminder not found", 404); + } + + // Soft-delete (tombstone) parity with the rest of the tree. A + // re-delete of an already-tombstoned row is idempotent. + await prisma.measurementReminder.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + await auditLog("measurementReminder.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { reminderId: id }, + }); + + annotate({ + action: { name: "measurement-reminders.delete" }, + meta: { reminderId: id }, + }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/measurement-reminders/[id]/satisfy/route.ts b/src/app/api/measurement-reminders/[id]/satisfy/route.ts new file mode 100644 index 000000000..574a88edd --- /dev/null +++ b/src/app/api/measurement-reminders/[id]/satisfy/route.ts @@ -0,0 +1,73 @@ +/** + * v1.17.1 — manual "Erledigt" for a Vorsorge reminder. + * + * Stamps `lastSatisfiedAt = now` and recomputes the server-authoritative + * `nextDueAt` past now. Free-text (no measurementType) reminders resolve + * ONLY through this path; typed reminders auto-resolve in the cron when a + * matching reading lands, but a manual satisfy still works for them. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +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 { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; + +type RouteParams = { params: Promise<{ id: string }> }; + +const DEFAULT_TIMEZONE = "Europe/Berlin"; + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const existing = await prisma.measurementReminder.findFirst({ + where: { id, deletedAt: null }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Measurement reminder not found", 404); + } + + const userRow = await prisma.user.findUnique({ + where: { id: user.id }, + select: { timezone: true }, + }); + 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); + + const updated = await prisma.measurementReminder.update({ + where: { id }, + data: { lastSatisfiedAt: now, nextDueAt }, + }); + + await auditLog("measurementReminder.satisfy", { + userId: user.id, + ipAddress: getClientIp(request), + details: { reminderId: id }, + }); + + annotate({ + action: { name: "measurement-reminders.satisfy" }, + meta: { reminderId: id }, + }); + + return apiSuccess(toMeasurementReminderDto(updated)); + }, +); diff --git a/src/app/api/measurement-reminders/route.ts b/src/app/api/measurement-reminders/route.ts new file mode 100644 index 000000000..0a27715ba --- /dev/null +++ b/src/app/api/measurement-reminders/route.ts @@ -0,0 +1,137 @@ +/** + * v1.17.1 — Vorsorge (measurement) reminders CRUD: list + create. + * + * Server-authoritative `nextDueAt` is computed via the canonical + * medication recurrence engine so web ↔ iOS read identical numbers. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { withIdempotency } from "@/lib/idempotency"; +import { createMeasurementReminderSchema } from "@/lib/validations/measurement-reminders"; +import { + computeReminderNextDueAt, + type ReminderScheduleInput, +} from "@/lib/measurement-reminders/scheduling"; +import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; + +const DEFAULT_TIMEZONE = "Europe/Berlin"; + +async function resolveTimezone(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }); + return row?.timezone || DEFAULT_TIMEZONE; +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const reminders = await prisma.measurementReminder.findMany({ + where: { userId: user.id, deletedAt: null }, + // Most-urgent first; a null next-due (uncomputable / disabled) sinks + // to the end so the actionable items float to the top. + orderBy: [{ nextDueAt: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }], + }); + + annotate({ + action: { name: "measurement-reminders.list" }, + meta: { total: reminders.length }, + }); + + return apiSuccess(reminders.map(toMeasurementReminderDto)); +}); + +async function postReminder(request: NextRequest): Promise { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = createMeasurementReminderSchema.safeParse(body); + if (!parsed.success) { + const issues = sanitiseZodIssues(parsed.error.issues); + annotate({ + action: { name: "measurement-reminders.create.validation-failed" }, + meta: { issue_count: issues.length }, + }); + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "measurement-reminders.create.validation-failed", + details: JSON.stringify({ issues: auditIssues }), + }, + }) + .catch(() => { + /* swallow — 422 response is the contract */ + }); + return returnAllZodIssues(parsed.error, 422); + } + + const data = parsed.data; + + const timezone = await resolveTimezone(user.id); + const now = new Date(); + const anchorDate = + data.anchorDate != null ? new Date(data.anchorDate) : null; + + // Server-authoritative next-due. No satisfy yet → anchors on + // anchorDate ?? createdAt (createdAt ≈ now for a fresh row). + const scheduleInput: ReminderScheduleInput = { + intervalDays: data.intervalDays ?? null, + rrule: data.rrule ?? null, + anchorDate, + notifyHour: data.notifyHour, + lastSatisfiedAt: null, + createdAt: now, + }; + const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now); + + // Field-by-field — no mass assignment. + const created = await prisma.measurementReminder.create({ + data: { + userId: user.id, + label: data.label, + measurementType: data.measurementType ?? null, + intervalDays: data.intervalDays ?? null, + rrule: data.rrule ?? null, + anchorDate, + notifyHour: data.notifyHour, + location: data.location ?? null, + enabled: data.enabled, + nextDueAt, + }, + }); + + await auditLog("measurementReminder.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { reminderId: created.id }, + }); + + annotate({ + action: { name: "measurement-reminders.create" }, + meta: { reminderId: created.id, hasType: data.measurementType != null }, + }); + + return apiSuccess(toMeasurementReminderDto(created), 201); +} + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postReminder)); diff --git a/src/app/vorsorge/page.tsx b/src/app/vorsorge/page.tsx new file mode 100644 index 000000000..6873b7851 --- /dev/null +++ b/src/app/vorsorge/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Loader2 } from "lucide-react"; + +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section"; + +/** + * v1.17.1 — Vorsorge (preventive-care) reminders page. The dedicated + * feature surface for "wann muss ich was wo machen". Auth-gated; the + * section component owns the list + create flow. + */ +export default function VorsorgePage() { + const { isAuthenticated, isLoading } = useAuth(); + const mounted = useMounted(); + + if (!mounted || isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx new file mode 100644 index 000000000..b86900975 --- /dev/null +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -0,0 +1,369 @@ +"use client"; + +/** + * v1.17.1 — Vorsorge (preventive-care / measurement) reminder surface. + * + * Answers "wann muss ich was wo machen": a list of upcoming reminders + * sorted by server-computed next-due, each card showing the label, the + * cadence, the location, and a relative next-due badge. Per the project + * rule the card stays NEUTRAL regardless of due state — no alarming + * red/green tint; status reads through a discreet badge only. + * + * The server is authoritative for `nextDueAt`; this component renders it + * relative to "now" but never recomputes the cadence. + */ +import { useState } from "react"; +import { CheckCircle2, Plus, Trash2 } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { EmptyState } from "@/components/ui/empty-state"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { + useMeasurementReminders, + useMeasurementReminderMutations, + type MeasurementReminder, +} from "@/hooks/use-measurement-reminders"; + +const TYPE_OPTIONS = [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "PULSE", + "BLOOD_GLUCOSE", + "OXYGEN_SATURATION", + "BODY_FAT", + "BODY_TEMPERATURE", +] as const; + +const INTERVAL_PRESETS = [7, 14, 30, 90, 180, 365] as const; +const DAY_MS = 24 * 60 * 60 * 1000; + +function relativeDueKey( + nextDueAt: string | null, + now: number, +): { key: string; days: number } { + if (!nextDueAt) return { key: "nextDue.none", days: 0 }; + const due = new Date(nextDueAt).getTime(); + // Compare calendar-day deltas so "today" / "in 1 day" read cleanly. + const deltaDays = Math.round((due - now) / DAY_MS); + if (deltaDays < 0) return { key: "overdueByDays", days: Math.abs(deltaDays) }; + if (deltaDays === 0) return { key: "nextDue.today", days: 0 }; + if (deltaDays === 1) return { key: "nextDue.tomorrow", days: 1 }; + return { key: "nextDue.inDays", days: deltaDays }; +} + +export function VorsorgeSection({ enabled = true }: { enabled?: boolean }) { + const { t } = useTranslations(); + const { data: reminders, isLoading } = useMeasurementReminders(enabled); + const { create, remove, satisfy } = useMeasurementReminderMutations(); + const [showForm, setShowForm] = useState(false); + + // Form state. + const [label, setLabel] = useState(""); + const [kind, setKind] = useState<"type" | "freeText">("type"); + const [measurementType, setMeasurementType] = + useState("BLOOD_PRESSURE_SYS"); + const [intervalDays, setIntervalDays] = useState(7); + const [notifyHour, setNotifyHour] = useState(9); + const [location, setLocation] = useState(""); + + // Wall-clock anchor for the relative-due labels, captured once at mount + // via a lazy state initializer so the impure Date.now() stays out of + // render (the repo's purity + set-state-in-effect rules both reject the + // alternatives). The relative labels are coarse (day granularity), so a + // mount-time snapshot is plenty fresh. + const [now] = useState(() => Date.now()); + + function resetForm() { + setLabel(""); + setKind("type"); + setMeasurementType("BLOOD_PRESSURE_SYS"); + setIntervalDays(7); + setNotifyHour(9); + setLocation(""); + } + + function submit() { + const trimmed = label.trim(); + if (!trimmed) return; + create.mutate( + { + label: trimmed, + measurementType: kind === "type" ? measurementType : null, + intervalDays, + notifyHour, + location: location.trim() || null, + }, + { + onSuccess: () => { + resetForm(); + setShowForm(false); + }, + }, + ); + } + + return ( +
+
+
+

+ {t("measurementReminders.sectionTitle")} +

+

+ {t("measurementReminders.sectionDescription")} +

+
+ +
+ + {showForm && ( + + +
+ + setLabel(e.target.value)} + /> +
+ +
+ + + setKind(e.target.value as "type" | "freeText") + } + > + + + +
+ + {kind === "type" && ( +
+ + setMeasurementType(e.target.value)} + > + {TYPE_OPTIONS.map((type) => ( + + ))} + +
+ )} + +
+
+ + setIntervalDays(Number(e.target.value))} + > + {INTERVAL_PRESETS.map((days) => ( + + ))} + +
+
+ + setNotifyHour(Number(e.target.value))} + > + {Array.from({ length: 24 }, (_, h) => ( + + ))} + +
+
+ +
+ + setLocation(e.target.value)} + /> +
+ +
+ + +
+
+
+ )} + + {!isLoading && (reminders?.length ?? 0) === 0 && !showForm && ( + + )} + +
    + {reminders?.map((reminder) => ( + satisfy.mutate(reminder.id)} + onRemove={() => remove.mutate(reminder.id)} + busy={satisfy.isPending || remove.isPending} + /> + ))} +
+
+ ); +} + +function VorsorgeCard({ + reminder, + now, + onSatisfy, + onRemove, + busy, +}: { + reminder: MeasurementReminder; + now: number; + onSatisfy: () => void; + onRemove: () => void; + busy: boolean; +}) { + const { t } = useTranslations(); + const due = relativeDueKey(reminder.nextDueAt, now); + const cadence = + reminder.intervalDays != null + ? t("measurementReminders.cadence.everyNDays", { + days: reminder.intervalDays, + }) + : reminder.rrule + ? t("measurementReminders.cadence.custom") + : ""; + + return ( +
  • + {/* NEUTRAL card — no status-driven colour. The due state reads only + through the discreet badge below. */} + + +
    +
    +

    {reminder.label}

    + {!reminder.enabled && ( + + {t("measurementReminders.disabledBadge")} + + )} +
    +

    + + {t(`measurementReminders.${due.key}`, { days: due.days })} + + {cadence} +

    + {reminder.location && ( +

    + {t("measurementReminders.location.prefix", { + location: reminder.location, + })} +

    + )} +
    +
    + + +
    +
    +
    +
  • + ); +} diff --git a/src/hooks/use-measurement-reminders.ts b/src/hooks/use-measurement-reminders.ts new file mode 100644 index 000000000..243efe90f --- /dev/null +++ b/src/hooks/use-measurement-reminders.ts @@ -0,0 +1,97 @@ +"use client"; + +/** + * v1.17.1 — data hook for Vorsorge (measurement) reminders. + * + * Wraps the `/api/measurement-reminders` CRUD + satisfy surface behind + * TanStack Query. Every mutation invalidates the one + * `measurementReminders()` root so the section list and the dashboard + * tile repaint in lockstep. The DTO is the server-authoritative shape — + * the client renders `nextDueAt` as-is, never recomputing. + */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; + +export interface MeasurementReminder { + id: string; + label: string; + measurementType: string | null; + intervalDays: number | null; + rrule: string | null; + anchorDate: string | null; + notifyHour: number; + location: string | null; + nextDueAt: string | null; + lastSatisfiedAt: string | null; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CreateMeasurementReminderBody { + label: string; + measurementType?: string | null; + intervalDays?: number | null; + rrule?: string | null; + anchorDate?: string | null; + notifyHour?: number; + location?: string | null; + enabled?: boolean; +} + +export type UpdateMeasurementReminderBody = Partial< + CreateMeasurementReminderBody +>; + +const BASE = "/api/measurement-reminders"; + +export function useMeasurementReminders(enabled = true) { + return useQuery({ + queryKey: queryKeys.measurementReminders(), + queryFn: () => apiGet(BASE), + enabled, + }); +} + +export function useMeasurementReminderMutations() { + const qc = useQueryClient(); + const invalidate = () => + qc.invalidateQueries({ queryKey: queryKeys.measurementReminders() }); + + const create = useMutation({ + mutationKey: queryKeys.measurementReminderCreate(), + mutationFn: (body: CreateMeasurementReminderBody) => + apiPost(BASE, body), + onSuccess: invalidate, + }); + + const update = useMutation({ + mutationKey: queryKeys.measurementReminderUpdate(), + mutationFn: ({ + id, + body, + }: { + id: string; + body: UpdateMeasurementReminderBody; + }) => apiPatch(`${BASE}/${id}`, body), + onSuccess: invalidate, + }); + + const remove = useMutation({ + mutationKey: queryKeys.measurementReminderDelete(), + mutationFn: (id: string) => + apiDelete<{ deleted: boolean }>(`${BASE}/${id}`), + onSuccess: invalidate, + }); + + const satisfy = useMutation({ + mutationKey: queryKeys.measurementReminderSatisfy(), + mutationFn: (id: string) => + apiPost(`${BASE}/${id}/satisfy`), + onSuccess: invalidate, + }); + + return { create, update, remove, satisfy }; +} diff --git a/src/lib/jobs/__tests__/measurement-reminder-queue.test.ts b/src/lib/jobs/__tests__/measurement-reminder-queue.test.ts new file mode 100644 index 000000000..e159e626f --- /dev/null +++ b/src/lib/jobs/__tests__/measurement-reminder-queue.test.ts @@ -0,0 +1,60 @@ +/** + * v1.17.1 — measurement-reminder queue registration guard. + * + * Same source-text-grep approach as the cycle / mood / drain registration + * guards: assert the Vorsorge-reminder queue is declared, listed in + * `allQueues` (the v1.4.37 dead-queue lesson — an unregistered queue is + * never provisioned and the schedule silently no-ops), scheduled on its + * cron, and wired to a `boss.work` handler that delegates to + * `runMeasurementReminderTick`. No pg-boss / Prisma boot. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const source = + readFileSync(join(__dirname, "..", "reminder-worker.ts"), "utf8") + + readFileSync( + join(__dirname, "..", "reminder", "mood-cycle-checks.ts"), + "utf8", + ); + +describe("reminder-worker — measurement-reminder wiring", () => { + it("imports the measurement-reminder tick runner", () => { + expect(source).toMatch( + /from\s*["']@\/lib\/jobs\/measurement-reminder["']/, + ); + expect(source).toMatch(/\brunMeasurementReminderTick\b/); + }); + + it("declares the queue + cron constants", () => { + expect(source).toMatch( + /MEASUREMENT_REMINDER_QUEUE\s*=\s*["']measurement-reminder-check["']/, + ); + expect(source).toMatch( + /MEASUREMENT_REMINDER_CRON\s*=\s*["']\*\/15 \* \* \* \*["']/, + ); + }); + + it("registers the queue in the allQueues loop", () => { + const allQueuesMatch = source.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/); + expect(allQueuesMatch).not.toBeNull(); + expect(allQueuesMatch![1]).toMatch(/\bMEASUREMENT_REMINDER_QUEUE\b/); + }); + + it("schedules the measurement-reminder cron", () => { + expect(source).toMatch( + /\[MEASUREMENT_REMINDER_QUEUE,\s*MEASUREMENT_REMINDER_CRON\]/, + ); + }); + + it("registers a boss.work handler that runs the tick", () => { + expect(source).toMatch( + /boss\.work[\s\S]{0,200}MEASUREMENT_REMINDER_QUEUE[\s\S]{0,200}handleMeasurementReminderCheck/, + ); + expect(source).toMatch( + /handleMeasurementReminderCheck[\s\S]{0,400}runMeasurementReminderTick/, + ); + }); +}); diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts new file mode 100644 index 000000000..5c6ad36d0 --- /dev/null +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -0,0 +1,287 @@ +/** + * v1.17.1 — Vorsorge (measurement) reminder dispatcher unit tests. + * + * Pins the contract: + * - Due-predicate window: fires only when past-due AND inside the + * reminder's local notify-hour (08:59 → no, 09:00 → yes, 09:59 → + * yes, 10:00 → no). A disabled reminder / null nextDueAt never fires. + * - Auto-resolve: a matching reading of the reminder's measurementType + * logged since the last satisfy advances lastSatisfiedAt + recomputes + * nextDueAt and suppresses the nudge. Free-text reminders never + * auto-resolve. + * - Ledger-free dedup: a successful dispatch advances nextDueAt past + * now so the same due cycle never re-fires. + * - clientManaged suppression skips the APNs send but still advances. + * + * The Prisma surface is stubbed manually to avoid a testcontainer boot. + */ +import { describe, it, expect, vi } from "vitest"; + +import { + evaluateMeasurementReminderDue, + runMeasurementReminderTick, +} from "../measurement-reminder"; +import type { NotificationPayload } from "@/lib/notifications/types"; +import type { DispatchOutcome } from "@/lib/notifications/dispatcher"; + +type DispatchFn = (payload: NotificationPayload) => Promise; + +const OK: DispatchOutcome = { + dispatched: true, + channelsAttempted: 1, + channelsSucceeded: 1, +}; + +const TZ = "Europe/Berlin"; + +// 09:00 Berlin in June = 07:00Z. +const NINE_LOCAL = new Date("2026-06-15T07:00:00Z"); + +describe("evaluateMeasurementReminderDue — window boundary", () => { + const reminder = { enabled: true, notifyHour: 9, nextDueAt: new Date(0) }; + + it("08:59 local → not in hour window", () => { + const d = evaluateMeasurementReminderDue( + reminder, + TZ, + new Date("2026-06-15T06:59:00Z"), + ); + expect(d.isDue).toBe(true); + expect(d.inHourWindow).toBe(false); + expect(d.fire).toBe(false); + }); + + it("09:00 local → fires", () => { + const d = evaluateMeasurementReminderDue(reminder, TZ, NINE_LOCAL); + expect(d.fire).toBe(true); + }); + + it("09:59 local → still in window", () => { + const d = evaluateMeasurementReminderDue( + reminder, + TZ, + new Date("2026-06-15T07:59:00Z"), + ); + expect(d.fire).toBe(true); + }); + + it("10:00 local → outside window", () => { + const d = evaluateMeasurementReminderDue( + reminder, + TZ, + new Date("2026-06-15T08:00:00Z"), + ); + expect(d.inHourWindow).toBe(false); + expect(d.fire).toBe(false); + }); + + it("not yet due → never fires", () => { + const d = evaluateMeasurementReminderDue( + { enabled: true, notifyHour: 9, nextDueAt: new Date("2099-01-01") }, + TZ, + NINE_LOCAL, + ); + expect(d.isDue).toBe(false); + expect(d.fire).toBe(false); + }); + + it("disabled / null nextDueAt → never fires", () => { + expect( + evaluateMeasurementReminderDue( + { enabled: false, notifyHour: 9, nextDueAt: new Date(0) }, + TZ, + NINE_LOCAL, + ).fire, + ).toBe(false); + expect( + evaluateMeasurementReminderDue( + { enabled: true, notifyHour: 9, nextDueAt: null }, + TZ, + NINE_LOCAL, + ).fire, + ).toBe(false); + }); +}); + +interface FakeReminder { + id: string; + measurementType: string | null; + intervalDays: number | null; + rrule: string | null; + anchorDate: Date | null; + notifyHour: number; + location: string | null; + nextDueAt: Date | null; + lastSatisfiedAt: Date | null; + enabled: boolean; + createdAt: Date; + user: { + id: string; + timezone: string; + locale: string | null; + notificationPrefs: unknown; + }; +} + +function makePrisma(opts: { + reminders: FakeReminder[]; + measurementMatch?: { measuredAt: Date } | null; +}) { + const updates: Array<{ id: string; data: Record }> = []; + const prisma = { + measurementReminder: { + findMany: vi.fn(async () => opts.reminders), + update: vi.fn( + async ({ + where, + data, + }: { + where: { id: string }; + data: Record; + }) => { + updates.push({ id: where.id, data }); + return { id: where.id, ...data }; + }, + ), + }, + measurement: { + findFirst: vi.fn(async () => opts.measurementMatch ?? null), + }, + }; + return { prisma, updates }; +} + +function reminder(overrides: Partial): FakeReminder { + return { + id: "r1", + measurementType: "BLOOD_PRESSURE_SYS", + intervalDays: 7, + rrule: null, + anchorDate: null, + notifyHour: 9, + location: null, + nextDueAt: new Date("2026-06-14T07:00:00Z"), // past at NINE_LOCAL + lastSatisfiedAt: null, + enabled: true, + createdAt: new Date("2026-06-01T00:00:00Z"), + user: { + id: "u1", + timezone: TZ, + locale: "de", + notificationPrefs: null, + }, + ...overrides, + }; +} + +describe("runMeasurementReminderTick", () => { + it("auto-resolves a typed reminder when a matching reading landed", async () => { + const matchAt = new Date("2026-06-14T18:00:00Z"); + const { prisma, updates } = makePrisma({ + reminders: [reminder({})], + measurementMatch: { measuredAt: matchAt }, + }); + const dispatch = vi.fn(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(); + // Advanced lastSatisfiedAt to the reading instant + recomputed nextDueAt. + expect(updates).toHaveLength(1); + expect(updates[0].data.lastSatisfiedAt).toEqual(matchAt); + expect(updates[0].data.nextDueAt).toBeInstanceOf(Date); + }); + + it("dispatches a due reminder and advances nextDueAt past now (ledger-free dedup)", async () => { + const { prisma, updates } = makePrisma({ + reminders: [reminder({})], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + const summary = await runMeasurementReminderTick( + prisma as never, + NINE_LOCAL, + { dispatch }, + ); + + expect(summary.dispatched).toBe(1); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch.mock.calls[0][0].eventType).toBe("MEASUREMENT_REMINDER"); + // nextDueAt advanced strictly past now. + expect(updates).toHaveLength(1); + const advanced = updates[0].data.nextDueAt as Date; + expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime()); + }); + + it("free-text reminder never auto-resolves (no measurement query match path)", async () => { + const { prisma } = makePrisma({ + reminders: [reminder({ measurementType: null })], + // Even if a reading existed, a free-text reminder must not query it. + measurementMatch: { measuredAt: new Date("2026-06-14T18:00:00Z") }, + }); + const dispatch = vi.fn(async () => OK); + + const summary = await runMeasurementReminderTick( + prisma as never, + NINE_LOCAL, + { dispatch }, + ); + + expect(summary.autoResolved).toBe(0); + expect(summary.dispatched).toBe(1); + expect(prisma.measurement.findFirst).not.toHaveBeenCalled(); + }); + + it("suppresses the push under clientManaged but still advances nextDueAt", async () => { + const { prisma, updates } = makePrisma({ + reminders: [ + reminder({ + measurementType: null, + user: { + id: "u1", + timezone: TZ, + locale: "de", + notificationPrefs: { measurementReminder: { clientManaged: true } }, + }, + }), + ], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + const summary = await runMeasurementReminderTick( + prisma as never, + NINE_LOCAL, + { dispatch }, + ); + + expect(summary.skippedClientManaged).toBe(1); + expect(dispatch).not.toHaveBeenCalled(); + expect(updates).toHaveLength(1); + expect(updates[0].data.nextDueAt).toBeInstanceOf(Date); + }); + + it("skips a reminder outside its notify-hour window", async () => { + const { prisma } = makePrisma({ + reminders: [reminder({ measurementType: null })], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + const summary = await runMeasurementReminderTick( + prisma as never, + new Date("2026-06-15T08:00:00Z"), // 10:00 Berlin — outside the 09 window + { dispatch }, + ); + + expect(summary.skippedOutsideWindow).toBe(1); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts new file mode 100644 index 000000000..7223d76ec --- /dev/null +++ b/src/lib/jobs/measurement-reminder.ts @@ -0,0 +1,330 @@ +/** + * v1.17.1 — Vorsorge (measurement) reminder dispatcher. + * + * Mirrors the mood-reminder cron (`mood-reminder.ts`): a separate module, + * a pure due-predicate, a `runMeasurementReminderTick(prisma, now)` + * runner. The cron fires every 15 minutes so it picks up every IANA + * timezone crossing the reminder's `notifyHour` without one cron entry + * per zone. + * + * Differences from the mood reminder: + * + * 1. No dedicated dispatch-ledger table. The reminder's own + * `nextDueAt` IS the dedup guard: after a successful dispatch the + * runner advances `nextDueAt` to the next occurrence strictly past + * now, so the same due cycle never re-fires. This collapses one + * table + one cleanup queue versus the mood pattern. + * 2. Auto-resolve from an incoming measurement. Before deciding to + * fire, the runner checks whether a matching reading of the + * reminder's `measurementType` has landed since the last satisfy + * (BP matched on `BLOOD_PRESSURE_SYS`). If so it advances + * `lastSatisfiedAt` + recomputes `nextDueAt` and skips the nudge — + * the user who already measured today never gets nagged. This is a + * query in the cron, NOT a hook on the hot iOS batch-ingest path. + * Free-text reminders (no `measurementType`) never auto-resolve; + * they advance only on a manual satisfy. + * 3. Per-reminder `clientManaged` suppression of the server-side APNs + * (the medication `clientManaged` precedent) is applied for the + * whole user via `notificationPrefs.measurementReminder.clientManaged`. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import type { MeasurementType } from "@/generated/prisma/client"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import type { Locale } from "@/lib/i18n/config"; +import { defaultLocale, locales } from "@/lib/i18n/config"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { getEvent } from "@/lib/logging/context"; +import { isMeasurementReminderClientManaged } from "@/lib/validations/notification-prefs"; +import { + computeReminderNextDueAt, + type ReminderScheduleInput, +} from "@/lib/measurement-reminders/scheduling"; + +export interface MeasurementReminderSummary { + candidatesScanned: number; + inWindow: number; + dispatched: number; + autoResolved: number; + skippedNotDue: number; + skippedOutsideWindow: number; + skippedClientManaged: number; + skippedNoChannel: number; + failed: number; +} + +function resolveLocale(locale: string | null | undefined): Locale { + return locales.includes(locale as Locale) + ? (locale as Locale) + : defaultLocale; +} + +/** + * Pure due-predicate: at this instant, is the reminder due AND inside its + * local notify-hour window? + * + * "Due" = `nextDueAt != null` and `now >= nextDueAt`. The hour gate keeps + * a reminder that became overdue overnight from firing at 03:00 — it + * waits for the user's chosen `notifyHour` to come round in their local + * timezone. Pulled out so the unit tests can pin the window boundary + * (08:59 → no, 09:00 → yes, 09:59 → yes, 10:00 → no) without the DB. + */ +export function evaluateMeasurementReminderDue( + reminder: { + enabled: boolean; + nextDueAt: Date | null; + notifyHour: number; + }, + timezone: string, + now: Date, +): { fire: boolean; inHourWindow: boolean; isDue: boolean } { + if (!reminder.enabled || reminder.nextDueAt === null) { + return { fire: false, inHourWindow: false, isDue: false }; + } + const isDue = now.getTime() >= reminder.nextDueAt.getTime(); + const parts = wallClockInTz(now, timezone || "Europe/Berlin"); + const inHourWindow = parts.hour === reminder.notifyHour; + return { fire: isDue && inHourWindow, inHourWindow, isDue }; +} + +/** + * Build the localised title + body for the Vorsorge push. + */ +export function buildMeasurementReminderPayload( + locale: string | null | undefined, + label: string, + location: string | null, +): { title: string; body: string } { + const t = getServerTranslator(resolveLocale(locale)).t; + const base = t("measurementReminders.pushBody", { label }); + const body = location + ? `${base} ${t("measurementReminders.pushLocation", { location })}` + : base; + 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, + * and dispatches a `MEASUREMENT_REMINDER` push for any that are due + * inside the user's local notify-hour window. + */ +export async function runMeasurementReminderTick( + prisma: PrismaClient, + now: Date, + options: { + dispatch?: typeof dispatchNotification; + } = {}, +): Promise { + const dispatchImpl = options.dispatch ?? dispatchNotification; + + const summary: MeasurementReminderSummary = { + candidatesScanned: 0, + inWindow: 0, + dispatched: 0, + autoResolved: 0, + skippedNotDue: 0, + skippedOutsideWindow: 0, + skippedClientManaged: 0, + skippedNoChannel: 0, + failed: 0, + }; + + const reminders = await prisma.measurementReminder.findMany({ + where: { deletedAt: null, enabled: true }, + include: { + user: { + select: { + id: true, + timezone: true, + locale: true, + notificationPrefs: true, + }, + }, + }, + }); + + for (const reminder of reminders) { + summary.candidatesScanned += 1; + 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 }, + }); + summary.autoResolved += 1; + continue; + } + } + + const decision = evaluateMeasurementReminderDue( + { + enabled: reminder.enabled, + nextDueAt: reminder.nextDueAt, + notifyHour: reminder.notifyHour, + }, + timezone, + now, + ); + + if (!decision.isDue) { + summary.skippedNotDue += 1; + continue; + } + if (!decision.inHourWindow) { + summary.skippedOutsideWindow += 1; + continue; + } + + summary.inWindow += 1; + + // Per-user client-managed suppression (the medication precedent). + if ( + isMeasurementReminderClientManaged(reminder.user.notificationPrefs) + ) { + getEvent()?.addMeta( + "measurement_reminder.suppressed_client_managed", + reminder.id, + ); + summary.skippedClientManaged += 1; + // Advance past this cycle anyway so a client-managed reminder does + // not pin the server tick re-evaluating the same overdue slot + // every 15 minutes for the rest of the day. + await advanceNextDue(prisma, reminder, timezone, now); + continue; + } + + const { title, body } = buildMeasurementReminderPayload( + reminder.user.locale, + reminder.label, + reminder.location, + ); + + const outcome = await dispatchImpl({ + eventType: "MEASUREMENT_REMINDER", + userId: reminder.user.id, + title, + message: body, + metadata: { + scheduledAt: now.toISOString(), + reminderId: reminder.id, + }, + }); + + // No channel succeeded — leave `nextDueAt` where it is so the next + // tick (or the user's next channel) retries. The reminder simply + // stays overdue, which the surface already shows as "überfällig". + if (!outcome.dispatched) { + summary.skippedNoChannel += 1; + continue; + } + + // Dispatch succeeded — advance `nextDueAt` past now so this due + // cycle never re-fires (the ledger-free dedup guard). + await advanceNextDue(prisma, reminder, timezone, now); + summary.dispatched += 1; + } catch (err: unknown) { + summary.failed += 1; + const message = err instanceof Error ? err.message : String(err); + getEvent()?.addWarning( + `measurement-reminder per-reminder dispatch 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 + * ledger-free dedup guard. Does NOT touch `lastSatisfiedAt` — a fired + * reminder is not "satisfied", it just rolls to its next slot. + */ +async function advanceNextDue( + prisma: PrismaClient, + reminder: { + id: string; + intervalDays: number | null; + rrule: string | null; + anchorDate: Date | null; + notifyHour: number; + lastSatisfiedAt: Date | null; + createdAt: Date; + }, + timezone: string, + now: Date, +): Promise { + // The dedup guard must move the slot strictly forward. For an `rrule` + // the engine already walks to the next strictly-after-now occurrence, + // so passing the row as-is is correct. For a ROLLING reminder the + // engine anchors the first-due slot AT `anchorDate ?? createdAt` when + // never satisfied, which stays ≤ now and would re-fire every tick — so + // re-anchor the rolling cadence on `now` (a fire is the rhythm + // restarting from this dispatch) to roll it forward by exactly one + // interval. This does NOT advance `lastSatisfiedAt`: a fired reminder + // is not satisfied, just rescheduled past the slot it nagged on. + const rolling = reminder.intervalDays !== null; + const scheduleInput: ReminderScheduleInput = { + intervalDays: reminder.intervalDays, + rrule: reminder.rrule, + anchorDate: reminder.anchorDate, + notifyHour: reminder.notifyHour, + lastSatisfiedAt: rolling ? now : reminder.lastSatisfiedAt, + createdAt: reminder.createdAt, + }; + const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now); + await prisma.measurementReminder.update({ + where: { id: reminder.id }, + data: { nextDueAt }, + }); +} diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 056fbf574..db6b4d5b2 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -246,8 +246,10 @@ import { import { MoodReminderPayload, CycleReminderPayload, + MeasurementReminderPayload, handleMoodReminderCheck, handleCycleReminderCheck, + handleMeasurementReminderCheck, } from "./reminder/mood-cycle-checks"; import { HostMetricSamplePayload, @@ -507,6 +509,17 @@ const MEASUREMENT_TOMBSTONE_CLEANUP_CRON = "40 3 * * *"; const CYCLE_REMINDER_QUEUE = "cycle-reminder-check"; const CYCLE_REMINDER_CRON = "*/15 * * * *"; + +// v1.17.1 — every-15-min tick for Vorsorge (measurement) reminders. +// Same cadence + short-circuit shape as the mood / cycle reminders: the +// handler only fires a reminder whose `nextDueAt` is past AND whose local +// time is the reminder's `notifyHour`, so the 15-min cadence picks up +// every IANA timezone crossing that hour without one cron entry per zone. +// Dedup is the reminder's own `nextDueAt` advance — no ledger table. + +const MEASUREMENT_REMINDER_QUEUE = "measurement-reminder-check"; + +const MEASUREMENT_REMINDER_CRON = "*/15 * * * *"; // v1.4.38 — the per-sample cutoff hours constant now lives on the // helper module so the worker, the admin route, and the CLI all read // the same source of truth. Re-export pulled in alongside @@ -730,6 +743,11 @@ export async function startReminderWorker() { // without this entry the every-15-min schedule silently no-ops and the // cycle dispatcher never fires (the v1.4.37 dead-queue class). CYCLE_REMINDER_QUEUE, + // v1.17.1 — Vorsorge (measurement) reminder cron tick. Same pg-boss + // v12 createQueue contract; without this entry pg-boss never + // provisions the queue and the every-15-min schedule silently no-ops + // (the v1.4.37 dead-queue class). + MEASUREMENT_REMINDER_QUEUE, // v1.4.49 — push-attempt ledger cleanup. Same createQueue contract // as the other cleanup jobs; the daily schedule below would // silently no-op without this entry. @@ -922,6 +940,11 @@ export async function startReminderWorker() { // hour, so the cron costs ~one prediction-row scan per tick for the // opted-in cohort. [CYCLE_REMINDER_QUEUE, CYCLE_REMINDER_CRON], + // v1.17.1 — every-15-min tick for the Vorsorge (measurement) reminder. + // The handler short-circuits unless a reminder is past-due AND the + // user's local time matches the reminder's notifyHour, so the cron + // costs ~one reminder-row scan per tick for the active cohort. + [MEASUREMENT_REMINDER_QUEUE, MEASUREMENT_REMINDER_CRON], // v1.4.49 — daily 03:35 Europe/Berlin prune for push_attempts. [PUSH_ATTEMPT_CLEANUP_QUEUE, PUSH_ATTEMPT_CLEANUP_CRON], // v1.7.0 — nightly 04:30 Europe/Berlin comprehensive-insight @@ -1228,6 +1251,15 @@ export async function startReminderWorker() { { localConcurrency: 1 }, handleCycleReminderCheck, ); + // v1.17.1 — single-flight Vorsorge (measurement) reminder worker. + // localConcurrency=1 keeps two ticks from racing the `nextDueAt` + // advance that anchors the per-cycle idempotency, exactly like the + // mood / cycle reminder workers. + await boss.work( + MEASUREMENT_REMINDER_QUEUE, + { localConcurrency: 1 }, + handleMeasurementReminderCheck, + ); // v1.4.49 — daily prune of the push-attempt ledger. Single-flight // matches every other cleanup queue; two ticks racing on the same // DELETE statement is wasted work and the second tick's payload diff --git a/src/lib/jobs/reminder/mood-cycle-checks.ts b/src/lib/jobs/reminder/mood-cycle-checks.ts index cc94c338b..655a5b2b3 100644 --- a/src/lib/jobs/reminder/mood-cycle-checks.ts +++ b/src/lib/jobs/reminder/mood-cycle-checks.ts @@ -9,6 +9,7 @@ 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 { getWorkerPrisma } from "./shared"; export interface MoodReminderPayload { @@ -19,6 +20,10 @@ export interface CycleReminderPayload { triggeredAt: string; } +export interface MeasurementReminderPayload { + triggeredAt: string; +} + export async function handleMoodReminderCheck( jobs: Job[], ) { @@ -80,3 +85,38 @@ export async function handleCycleReminderCheck( } }); } + +/** + * v1.17.1 — every-15-min Vorsorge (measurement) reminder tick. Thin shim + * around `runMeasurementReminderTick` so the unit tests exercise the + * due-predicate + auto-resolve + advance logic without pg-boss. + */ +export async function handleMeasurementReminderCheck( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.measurement_reminder", async (evt) => { + const prisma = getWorkerPrisma(); + try { + const summary = await runMeasurementReminderTick(prisma, new Date()); + evt.setBackground({ + task_name: "job.measurement_reminder", + result: { + candidates_scanned: summary.candidatesScanned, + in_window: summary.inWindow, + dispatched: summary.dispatched, + auto_resolved: summary.autoResolved, + skipped_not_due: summary.skippedNotDue, + skipped_outside_window: summary.skippedOutsideWindow, + skipped_client_managed: summary.skippedClientManaged, + skipped_no_channel: summary.skippedNoChannel, + failed: summary.failed, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} diff --git a/src/lib/measurement-reminders/__tests__/scheduling.test.ts b/src/lib/measurement-reminders/__tests__/scheduling.test.ts new file mode 100644 index 000000000..e49caa45b --- /dev/null +++ b/src/lib/measurement-reminders/__tests__/scheduling.test.ts @@ -0,0 +1,100 @@ +/** + * v1.17.1 — server-authoritative next-due computation for Vorsorge + * reminders. Pins the rolling + rrule cadence contract that web ↔ iOS + * both read off `nextDueAt`. + */ +import { describe, it, expect } from "vitest"; + +import { + computeReminderNextDueAt, + type ReminderScheduleInput, +} from "../scheduling"; + +const TZ = "Europe/Berlin"; + +function base(overrides: Partial): ReminderScheduleInput { + return { + intervalDays: null, + rrule: null, + anchorDate: null, + notifyHour: 9, + lastSatisfiedAt: null, + createdAt: new Date("2026-06-01T08:00:00Z"), + ...overrides, + }; +} + +describe("computeReminderNextDueAt", () => { + it("returns null when neither intervalDays nor rrule is set", () => { + const now = new Date("2026-06-10T07:00:00Z"); + expect(computeReminderNextDueAt(base({}), TZ, now)).toBeNull(); + }); + + it("rolling, never satisfied: first due AT the anchor day notify-hour", () => { + // Anchor 2026-06-10; notifyHour 9 local (Berlin = UTC+2 in June → 07:00Z). + const reminder = base({ + intervalDays: 7, + anchorDate: new Date("2026-06-10T00:00:00Z"), + notifyHour: 9, + createdAt: new Date("2026-06-09T00:00:00Z"), + }); + // "after" before the anchor → the first due is the anchor's slot. + const due = computeReminderNextDueAt( + reminder, + TZ, + new Date("2026-06-09T12:00:00Z"), + ); + expect(due).not.toBeNull(); + // 09:00 Berlin on 2026-06-10 == 07:00Z (DST). + expect(due!.toISOString()).toBe("2026-06-10T07:00:00.000Z"); + }); + + it("rolling, satisfied: next due is lastSatisfiedAt + N days at notify-hour", () => { + const reminder = base({ + intervalDays: 7, + lastSatisfiedAt: new Date("2026-06-10T15:00:00Z"), + notifyHour: 9, + }); + const due = computeReminderNextDueAt( + reminder, + TZ, + new Date("2026-06-10T16:00:00Z"), + ); + expect(due).not.toBeNull(); + // 7 days after the satisfy → 2026-06-17, slot at 09:00 Berlin = 07:00Z. + expect(due!.toISOString()).toBe("2026-06-17T07:00:00.000Z"); + }); + + it("rolling honours the interval length (30 days)", () => { + const reminder = base({ + intervalDays: 30, + lastSatisfiedAt: new Date("2026-06-01T15:00:00Z"), + notifyHour: 9, + }); + const due = computeReminderNextDueAt( + reminder, + TZ, + new Date("2026-06-02T00:00:00Z"), + ); + expect(due!.toISOString()).toBe("2026-07-01T07:00:00.000Z"); + }); + + it("rrule annual: walks to the next yearly occurrence after now", () => { + // FREQ=YEARLY anchored on the createdAt day (2026-06-01). + const reminder = base({ + rrule: "FREQ=YEARLY", + anchorDate: new Date("2026-06-01T00:00:00Z"), + createdAt: new Date("2026-06-01T00:00:00Z"), + notifyHour: 9, + }); + // After the 2026 occurrence → expect the 2027 one. + const due = computeReminderNextDueAt( + reminder, + TZ, + new Date("2026-06-02T00:00:00Z"), + ); + expect(due).not.toBeNull(); + expect(due!.getUTCFullYear()).toBe(2027); + expect(due!.getUTCMonth()).toBe(5); // June (0-indexed) + }); +}); diff --git a/src/lib/measurement-reminders/dto.ts b/src/lib/measurement-reminders/dto.ts new file mode 100644 index 000000000..14ddd2239 --- /dev/null +++ b/src/lib/measurement-reminders/dto.ts @@ -0,0 +1,47 @@ +/** + * v1.17.1 — DTO serializer for Vorsorge (measurement) reminders. + * + * Maps the Prisma `MeasurementReminder` row onto the canonical + * `MeasurementReminderDTO` the web list, dashboard tile, and iOS client + * all mirror. Dates serialise to ISO-8601 with offset. + */ +import type { MeasurementReminder } from "@/generated/prisma/client"; +import type { MeasurementReminderType } from "@/lib/validations/measurement-reminders"; + +export interface MeasurementReminderDtoShape { + id: string; + label: string; + measurementType: MeasurementReminderType | null; + intervalDays: number | null; + rrule: string | null; + anchorDate: string | null; + notifyHour: number; + location: string | null; + nextDueAt: string | null; + lastSatisfiedAt: string | null; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + +export function toMeasurementReminderDto( + row: MeasurementReminder, +): MeasurementReminderDtoShape { + return { + id: row.id, + label: row.label, + measurementType: row.measurementType as MeasurementReminderType | null, + intervalDays: row.intervalDays, + rrule: row.rrule, + anchorDate: row.anchorDate ? row.anchorDate.toISOString() : null, + notifyHour: row.notifyHour, + location: row.location, + nextDueAt: row.nextDueAt ? row.nextDueAt.toISOString() : null, + lastSatisfiedAt: row.lastSatisfiedAt + ? row.lastSatisfiedAt.toISOString() + : null, + enabled: row.enabled, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} diff --git a/src/lib/measurement-reminders/scheduling.ts b/src/lib/measurement-reminders/scheduling.ts new file mode 100644 index 000000000..856448636 --- /dev/null +++ b/src/lib/measurement-reminders/scheduling.ts @@ -0,0 +1,120 @@ +/** + * v1.17.1 — server-authoritative next-due computation for Vorsorge + * (measurement) reminders. + * + * Reuses the canonical medication recurrence engine + * (`src/lib/medications/scheduling/recurrence.ts`) so a Vorsorge cadence + * is driven by exactly the same code that powers the medication + * "nextDueAt" line — web ↔ iOS read identical numbers (server-authoritative + * per project memory: iOS consumes the resolved DTO, never recomputes). + * + * A `MeasurementReminder` maps onto the engine's `CanonicalSchedule` + + * `RecurrenceContext` as follows: + * + * - rolling `intervalDays` → `rollingIntervalDays`, anchored on + * `lastSatisfiedAt ?? anchorDate ?? createdAt`. With no satisfy yet + * the first due is AT the anchor (not anchor + N); once satisfied the + * `+ N` cadence begins, exactly like a rolling medication's + * last-intake anchor. + * - `rrule` → passed straight through (RFC-5545). + * - the single `notifyHour` → the schedule's one `timesOfDay` entry, so + * the slot fires at the user's chosen local hour (DST-safe — the + * engine applies the time in the user's IANA timezone). + * + * Pure: no DB access. The caller fetches the reminder row + the user's + * timezone and threads them in. + */ +import { + type CanonicalSchedule, + type RecurrenceContext, + nextOccurrenceAfter, +} from "@/lib/medications/scheduling/recurrence"; + +/** + * The reminder fields this module reads. A subset of the Prisma row so + * tests can construct it without the full model. + */ +export interface ReminderScheduleInput { + intervalDays: number | null; + rrule: string | null; + anchorDate: Date | null; + notifyHour: number; + lastSatisfiedAt: Date | null; + createdAt: Date; +} + +function hourToHhmm(hour: number): string { + const safe = Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : 9; + return `${safe.toString().padStart(2, "0")}:00`; +} + +/** + * Build the `(CanonicalSchedule, RecurrenceContext)` pair the engine + * consumes for one Vorsorge reminder. + * + * The rolling anchor is threaded through the engine's `lastIntakeAt` + * (after a satisfy) and `medication.startsOn` (the first-due anchor when + * never satisfied) so the rolling path's "first dose AT the anchor, then + * + N after the first satisfy" semantics apply verbatim. + */ +export function buildReminderRecurrence( + reminder: ReminderScheduleInput, + timeZone: string, +): { schedule: CanonicalSchedule; ctx: RecurrenceContext } { + const hhmm = hourToHhmm(reminder.notifyHour); + + const schedule: CanonicalSchedule = { + id: "measurement-reminder", + rrule: reminder.rrule, + rollingIntervalDays: reminder.intervalDays, + timesOfDay: [hhmm], + daysOfWeek: null, + windowStart: hhmm, + windowEnd: hhmm, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }; + + // First-due anchor when never satisfied: anchorDate ?? createdAt. After + // a satisfy the rolling path re-anchors on `lastIntakeAt + N`, so we + // feed `lastSatisfiedAt` through `lastIntakeAt`. + const startsOn = reminder.anchorDate ?? reminder.createdAt; + + const ctx: RecurrenceContext = { + medication: { + id: "measurement-reminder", + startsOn, + endsOn: null, + oneShot: false, + createdAt: reminder.createdAt, + }, + timeZone: timeZone || "Europe/Berlin", + lastIntakeAt: reminder.lastSatisfiedAt, + }; + + return { schedule, ctx }; +} + +/** + * Compute the canonical next-due instant for a reminder, strictly after + * `after`. Returns `null` when the cadence is uncomputable (no interval + + * no rrule) or the engine finds no future occurrence. + * + * `after` defaults to `now` but the caller can floor it (e.g. to the last + * satisfy instant) so a freshly-satisfied reminder advances past the + * current due cycle. + */ +export function computeReminderNextDueAt( + reminder: ReminderScheduleInput, + timeZone: string, + after: Date, +): Date | null { + if (reminder.intervalDays === null && reminder.rrule === null) { + return null; + } + const { schedule, ctx } = buildReminderRecurrence(reminder, timeZone); + const occurrence = nextOccurrenceAfter(schedule, after, ctx); + return occurrence?.at ?? null; +} diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index 129bfe8a0..ca4eb6759 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -56,6 +56,16 @@ export const EVENT_TYPES = [ // refill or threshold change. ON at the channel layer — the real // gate is the per-user threshold in `notificationPrefs.medication`. "MEDICATION_LOW_STOCK", + // v1.17.1 — Vorsorge (measurement / preventive-care) reminder. Fired by + // the every-15-min measurement-reminder cron when a user-created + // reminder ("measure BP every 7 days", "annual blood panel") is due + // inside the user's local notify-hour window. ON by default at the + // channel layer (see EVENT_DEFAULT_ENABLED): these are reminders the + // user explicitly created, so silence-by-default would be surprising. + // The real gate is the per-reminder `enabled` flag plus the per-user + // `notificationPrefs.measurementReminder.clientManaged` suppression. + // NOT time-sensitive — a Vorsorge nudge is not a Focus-bypass case. + "MEASUREMENT_REMINDER", ] as const; export type EventType = (typeof EVENT_TYPES)[number]; @@ -102,6 +112,11 @@ export const EVENT_DEFAULT_ENABLED: Record = { // push per medication per crossing. An explicit per-channel // `NotificationPreference` row still wins. MEDICATION_LOW_STOCK: true, + // v1.17.1 — ON at the channel layer; the real gates are the per-reminder + // `enabled` flag and the per-user `measurementReminder.clientManaged` + // suppression the cron reads. An explicit per-channel + // `NotificationPreference` row still wins. + MEASUREMENT_REMINDER: true, }; export const CHANNEL_TYPE_LABELS: Record = { diff --git a/src/lib/openapi/registry.ts b/src/lib/openapi/registry.ts index 0b6a54a3b..e95717103 100644 --- a/src/lib/openapi/registry.ts +++ b/src/lib/openapi/registry.ts @@ -72,6 +72,7 @@ const openApiBase: Pick< { name: "Export" }, { name: "Sync" }, { name: "Cycle" }, + { name: "MeasurementReminders" }, { name: "Admin" }, { name: "Meta" }, ], diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index b9d1b13a2..121272080 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -31,6 +31,7 @@ import { healthRecordPaths } from "./health-record"; import { importPaths } from "./import"; import { insightsPaths } from "./insights"; import { measurementPaths } from "./measurements"; +import { measurementReminderPaths } from "./measurement-reminders"; import { medicationPaths, medicationResource } from "./medications"; import { metaPaths } from "./meta"; import { moodPaths } from "./mood"; @@ -59,6 +60,7 @@ export const openApiPaths: NonNullable = { ...settingsPaths, ...consentPaths, ...importPaths, + ...measurementReminderPaths, }; export const openApiComponents: NonNullable = { diff --git a/src/lib/openapi/routes/measurement-reminders.ts b/src/lib/openapi/routes/measurement-reminders.ts new file mode 100644 index 000000000..47952eec3 --- /dev/null +++ b/src/lib/openapi/routes/measurement-reminders.ts @@ -0,0 +1,172 @@ +/** + * 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, +} from "@/lib/validations/measurement-reminders"; +import { dataEnvelope, errorEnvelope, 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.", + responses: { + "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.", + requestBody: { + required: true, + content: { + "application/json": { schema: createMeasurementReminderSchema }, + }, + }, + responses: { + "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: { + "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 after the cadence merge. Owner-scoped.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { + "application/json": { schema: updateMeasurementReminderSchema }, + }, + }, + responses: { + "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: { + "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: { + "200": { + description: "Reminder satisfied; next-due re-anchored.", + content: { + "application/json": { + schema: dataEnvelope( + measurementReminderDto, + "MeasurementReminderSatisfyEnvelope", + ), + }, + }, + }, + ...reminderNotFound, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index d864a42bd..d619681a0 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -22,6 +22,7 @@ import { dashboardKeys } from "./dashboard"; import { insightsKeys } from "./insights"; import { integrationKeys } from "./integrations"; import { measurementKeys } from "./measurements"; +import { measurementReminderKeys } from "./measurement-reminders"; import { medicationKeys } from "./medications"; import { moodKeys } from "./mood"; import { settingsKeys } from "./settings"; @@ -40,6 +41,7 @@ export const queryKeys = { ...workoutKeys, ...settingsKeys, ...cycleKeys, + ...measurementReminderKeys, }; /** diff --git a/src/lib/query-keys/measurement-reminders.ts b/src/lib/query-keys/measurement-reminders.ts new file mode 100644 index 000000000..af4d7b6c2 --- /dev/null +++ b/src/lib/query-keys/measurement-reminders.ts @@ -0,0 +1,21 @@ +/** + * Query keys — Vorsorge (measurement) reminders (v1.17.1). + * Part of the centralized factory; aggregated in `./index.ts`. + */ +export const measurementReminderKeys = { + /** + * The owner's Vorsorge reminder list. A create / edit / delete / satisfy + * mutation invalidates this root so the section + the dashboard tile + * repaint in lockstep. + */ + measurementReminders: () => ["measurement-reminders"] as const, + /** Mutation keys — kept in the factory so no bare array reaches a call site. */ + measurementReminderCreate: () => + ["measurement-reminders", "create"] as const, + measurementReminderUpdate: () => + ["measurement-reminders", "update"] as const, + measurementReminderDelete: () => + ["measurement-reminders", "delete"] as const, + measurementReminderSatisfy: () => + ["measurement-reminders", "satisfy"] as const, +}; diff --git a/src/lib/validations/__tests__/notification-prefs.test.ts b/src/lib/validations/__tests__/notification-prefs.test.ts index eef97b83d..813f2c8ac 100644 --- a/src/lib/validations/__tests__/notification-prefs.test.ts +++ b/src/lib/validations/__tests__/notification-prefs.test.ts @@ -6,6 +6,7 @@ import { DEFAULT_NOTIFICATION_PREFS, DEFAULT_REORDER_LEAD_DAYS, isCycleReminderClientManaged, + isMeasurementReminderClientManaged, isMedicationReminderClientManaged, notificationPrefsSchema, parseNotificationPrefs, @@ -109,6 +110,9 @@ describe("parseNotificationPrefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); @@ -129,6 +133,9 @@ describe("parseNotificationPrefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); @@ -151,6 +158,9 @@ describe("parseNotificationPrefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); @@ -189,6 +199,9 @@ describe("resolveNotificationPrefs (deep-merge)", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); @@ -215,6 +228,9 @@ describe("resolveNotificationPrefs (deep-merge)", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); @@ -238,6 +254,9 @@ describe("resolveNotificationPrefs (deep-merge)", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); @@ -261,6 +280,9 @@ describe("resolveNotificationPrefs (deep-merge)", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); @@ -285,6 +307,9 @@ describe("resolveNotificationPrefs (deep-merge)", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); @@ -356,6 +381,38 @@ describe("isCycleReminderClientManaged — cron-skip gate", () => { }); }); +describe("isMeasurementReminderClientManaged — cron-skip gate", () => { + it("returns false for a null / undefined row (server-managed default)", () => { + expect(isMeasurementReminderClientManaged(null)).toBe(false); + expect(isMeasurementReminderClientManaged(undefined)).toBe(false); + }); + + it("returns false when the user has not opted in", () => { + expect( + isMeasurementReminderClientManaged({ + measurementReminder: { clientManaged: false }, + }), + ).toBe(false); + }); + + it("returns true when the iOS app owns local Vorsorge reminders", () => { + expect( + isMeasurementReminderClientManaged({ + measurementReminder: { clientManaged: true }, + }), + ).toBe(true); + }); + + it("is independent of the cycle / medication client-managed flags", () => { + expect( + isMeasurementReminderClientManaged({ + cycle: { clientManaged: true }, + medication: { deliveryDefault: "client" }, + }), + ).toBe(false); + }); +}); + describe("resolveMoodReminderHour — cron hour gate", () => { it("returns the default hour for a null row", () => { expect(resolveMoodReminderHour(null)).toBe(DEFAULT_MOOD_REMINDER_HOUR); diff --git a/src/lib/validations/measurement-reminders.ts b/src/lib/validations/measurement-reminders.ts new file mode 100644 index 000000000..fe295c189 --- /dev/null +++ b/src/lib/validations/measurement-reminders.ts @@ -0,0 +1,162 @@ +/** + * 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 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. + * + * 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. + */ +export const measurementReminderTypeEnum = z + .enum([ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "PULSE", + "BLOOD_GLUCOSE", + "OXYGEN_SATURATION", + "BODY_FAT", + "BODY_TEMPERATURE", + ]) + .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.", + }); + +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(), + 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.", + }); diff --git a/src/lib/validations/notification-prefs.ts b/src/lib/validations/notification-prefs.ts index 92e00d7f1..0f405c708 100644 --- a/src/lib/validations/notification-prefs.ts +++ b/src/lib/validations/notification-prefs.ts @@ -89,6 +89,19 @@ const cyclePrefsSchema = z }) .partial(); +/** + * v1.17.1 — measurement / Vorsorge reminder category schema. + * `clientManaged` mirrors the medication + cycle gate: when the iOS app + * owns local Vorsorge reminders it flips this to `true` and the + * server-side measurement-reminder cron suppresses its APNs send for that + * user. Sub-object form keeps the layout open for future Vorsorge knobs. + */ +const measurementReminderPrefsSchema = z + .object({ + clientManaged: z.boolean(), + }) + .partial(); + /** * v1.15.20 — coach category schema. `nudgesEnabled` gates the proactive * Coach nudge cron (05:15 Europe/Berlin): default ON (the nudge is an @@ -134,6 +147,7 @@ export const notificationPrefsSchema = z mood: moodPrefsSchema, cycle: cyclePrefsSchema, coach: coachPrefsSchema, + measurementReminder: measurementReminderPrefsSchema, }) .partial(); @@ -187,6 +201,14 @@ export interface NotificationPrefs { /** v1.16.5 — cap interval: "weekly" (7 d) or "biweekly" (14 d). */ nudgeFrequency: "weekly" | "biweekly"; }; + measurementReminder: { + /** + * v1.17.1 — when true the iOS app owns local Vorsorge reminders and + * the server-side measurement-reminder cron suppresses its APNs send + * (the medication / cycle `clientManaged` precedent). + */ + clientManaged: boolean; + }; } /** @@ -235,6 +257,9 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }; /** @@ -279,6 +304,10 @@ export function resolveNotificationPrefs( ...base.coach, ...(incoming.coach ?? {}), }, + measurementReminder: { + ...base.measurementReminder, + ...(incoming.measurementReminder ?? {}), + }, }); } @@ -307,6 +336,7 @@ function applyDeliveryDefaultMapping( mood: { ...prefs.mood }, cycle: { ...prefs.cycle }, coach: { ...prefs.coach }, + measurementReminder: { ...prefs.measurementReminder }, }; } @@ -335,6 +365,16 @@ export function isCycleReminderClientManaged(raw: unknown): boolean { return parseNotificationPrefs(raw).cycle.clientManaged === true; } +/** + * v1.17.1 — cron-side helper. Returns `true` when the user has opted in + * to client-managed Vorsorge (measurement) reminders so the server-side + * push should be suppressed. Tolerates a null / missing prefs row. + * Mirrors `isMedicationReminderClientManaged` / `isCycleReminderClientManaged`. + */ +export function isMeasurementReminderClientManaged(raw: unknown): boolean { + return parseNotificationPrefs(raw).measurementReminder.clientManaged === true; +} + /** * v1.7.0 — resolve the effective delivery channel for a device. The * per-device override wins; otherwise the user-level roaming default; @@ -426,6 +466,9 @@ function cloneDefaults(): NotificationPrefs { mood: { ...DEFAULT_NOTIFICATION_PREFS.mood }, cycle: { ...DEFAULT_NOTIFICATION_PREFS.cycle }, coach: { ...DEFAULT_NOTIFICATION_PREFS.coach }, + measurementReminder: { + ...DEFAULT_NOTIFICATION_PREFS.measurementReminder, + }, }; } @@ -447,5 +490,9 @@ function mergeOverDefaults(input: NotificationPrefsInput): NotificationPrefs { ...DEFAULT_NOTIFICATION_PREFS.coach, ...(input.coach ?? {}), }, + measurementReminder: { + ...DEFAULT_NOTIFICATION_PREFS.measurementReminder, + ...(input.measurementReminder ?? {}), + }, }); }