mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
merge: W7 coach memory, reminders, action cards into v1.22
This commit is contained in:
commit
e2c6e46f2a
33 files changed
+3913
-5
No files matched your search
@@ -4836,6 +4836,189 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/coach/reminders:
|
||||
get:
|
||||
tags:
|
||||
- Insights
|
||||
summary: List the caller's Coach reminders
|
||||
description: v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly,
|
||||
soonest-due first. Pass `?status=` (one status or a comma set like `due,surfaced` for the in-app tile); omitted
|
||||
returns the non-terminal set (proposed + active + due + surfaced). Coach-gated
|
||||
(`requireModuleEnabled("coach")`). Auth via cookie or Bearer; the owner is narrowed from the session.
|
||||
Undecryptable rows are omitted.
|
||||
parameters:
|
||||
- name: status
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Filter to one status or a comma-separated set (proposed | active | due | surfaced | done | dismissed). Omit
|
||||
for the non-terminal set.
|
||||
responses:
|
||||
"200":
|
||||
description: The caller's reminders.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachRemindersList"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: Coach surface disabled.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
post:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Create a Coach reminder manually
|
||||
description: v1.22 (B6) — create a reminder from the ledger. `note` is the only writable content; `when` is the closed
|
||||
grammar (ISO date | +Nd / +Nw | a context cue) resolved into the trigger server-side; `metric` optional. The
|
||||
per-user cap returns 409 when the non-terminal set is full. Coach-gated. Auth via cookie or Bearer; the owner is
|
||||
narrowed from the session.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachReminderCreate"
|
||||
responses:
|
||||
"201":
|
||||
description: The created reminder.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachReminderCreated"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: Coach surface disabled.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"409":
|
||||
description: The per-user reminder cap is full.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/coach/reminders/{id}:
|
||||
patch:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Confirm or update a Coach reminder's lifecycle
|
||||
description: v1.22 (B6) — confirm a proposed reminder (→ active), mark it done / dismissed, or re-schedule via the
|
||||
closed `when` grammar (null clears the due moment). The body never carries the note text. A foreign / unknown /
|
||||
already-deleted id maps to 404 (never 403) so the existence channel does not leak. Coach-gated. Auth via cookie
|
||||
or Bearer.
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Reminder id.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachReminderPatch"
|
||||
responses:
|
||||
"200":
|
||||
description: The reminder after the lifecycle update.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachReminderUpdated"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: Coach surface disabled.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"404":
|
||||
description: Reminder not found or not owned by the caller.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
delete:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Soft-delete one Coach reminder
|
||||
description: "v1.22 (B6) — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted
|
||||
id is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer."
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Reminder id.
|
||||
responses:
|
||||
"200":
|
||||
description: "The reminder was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted:
|
||||
false`)."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachReminderDeleted"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: Coach surface disabled.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/coach/suggested-actions:
|
||||
post:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Confirm a Coach action card
|
||||
description: v1.22 (F6) — the confirm half of the generalised propose→confirm moat. `actionType` is from the CLOSED
|
||||
allowlist (checkup.create | reminder.note) — NEVER a medication or clinical change. `checkup.create` builds a
|
||||
preventive-care Vorsorge `MeasurementReminder` (free-text label + a closed interval id resolved to an RRULE
|
||||
server-side); `reminder.note` builds a `CoachReminder`. Nothing is created without this explicit confirm.
|
||||
Coach-gated (`requireModuleEnabled("coach")`); per-user rate-limited (429). Auth via cookie or Bearer; the owner
|
||||
is narrowed from the session, built field-by-field — no mass assignment, no IDOR.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachSuggestedAction"
|
||||
responses:
|
||||
"201":
|
||||
description: The action was applied (entity created server-side).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachSuggestedActionResultCreated"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: Coach surface disabled.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"409":
|
||||
description: The per-user reminder cap is full (reminder.note).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/admin/invites:
|
||||
get:
|
||||
tags:
|
||||
@@ -8538,6 +8721,99 @@ components:
|
||||
compatibility and are normalised to their English equivalents before persisting; GET responses always carry the
|
||||
canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and will be removed in a
|
||||
future major version."
|
||||
CoachReminderCreate:
|
||||
type: object
|
||||
properties:
|
||||
note:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 280
|
||||
when:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
metric:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 60
|
||||
pattern: ^[A-Za-z][A-Za-z0-9_]*$
|
||||
required:
|
||||
- note
|
||||
additionalProperties: false
|
||||
description: "Create a Coach reminder manually. `note` is free text (the only writable content). `when` is an optional
|
||||
closed grammar token — an ISO date (YYYY-MM-DD), a relative offset (+Nd / +Nw), or a context cue
|
||||
(NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED, NEXT_SLEEP_LOGGED, NEXT_APP_OPEN) — resolved into the trigger server-side.
|
||||
Omit `when` for a recall-only note. Strict: unknown keys 422."
|
||||
CoachReminderPatch:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- proposed
|
||||
- active
|
||||
- due
|
||||
- surfaced
|
||||
- done
|
||||
- dismissed
|
||||
when:
|
||||
anyOf:
|
||||
- type: string
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
- type: "null"
|
||||
additionalProperties: false
|
||||
description: "Update a Coach reminder's lifecycle. `status` confirms (proposed → active), or marks done / dismissed.
|
||||
`when` re-schedules via the closed grammar (null clears the due moment). Never carries the note text. Strict:
|
||||
unknown keys 422."
|
||||
CoachSuggestedAction:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
actionType:
|
||||
type: string
|
||||
const: checkup.create
|
||||
label:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 120
|
||||
interval:
|
||||
type: string
|
||||
enum:
|
||||
- yearly
|
||||
- halfYearly
|
||||
- quarterly
|
||||
- monthly
|
||||
required:
|
||||
- actionType
|
||||
- label
|
||||
- interval
|
||||
- type: object
|
||||
properties:
|
||||
actionType:
|
||||
type: string
|
||||
const: reminder.note
|
||||
note:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 280
|
||||
when:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
metric:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 60
|
||||
pattern: ^[A-Za-z][A-Za-z0-9_]*$
|
||||
required:
|
||||
- actionType
|
||||
- note
|
||||
description: Confirm a Coach action card. `actionType` is from the closed allowlist (checkup.create, reminder.note);
|
||||
NEVER a medication or clinical change. `checkup.create` builds a preventive-care Vorsorge reminder (free-text
|
||||
label + a closed interval id resolved to an RRULE server-side); `reminder.note` builds a CoachReminder (note +
|
||||
optional closed `when` grammar + optional metric). Nothing is created without this explicit confirm.
|
||||
type: object
|
||||
CoachMessageFeedbackRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -17071,6 +17347,208 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachRemindersList:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
reminders:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/CoachReminder"
|
||||
description: The caller's reminders, soonest-due first. Undecryptable rows are omitted. No `status` filter returns the
|
||||
non-terminal set (proposed + active + due + surfaced).
|
||||
required:
|
||||
- reminders
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachReminder:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
note:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: Decrypted note (the user's own framing). Null when the row's key id is no longer in the map.
|
||||
metric:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
triggerKind:
|
||||
type: string
|
||||
enum:
|
||||
- date
|
||||
- context
|
||||
dueAt:
|
||||
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"
|
||||
contextCue:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- proposed
|
||||
- active
|
||||
- due
|
||||
- surfaced
|
||||
- done
|
||||
- dismissed
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
- sentinel
|
||||
- extractor
|
||||
- manual
|
||||
- action
|
||||
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
|
||||
- note
|
||||
- metric
|
||||
- triggerKind
|
||||
- dueAt
|
||||
- contextCue
|
||||
- status
|
||||
- source
|
||||
- createdAt
|
||||
- updatedAt
|
||||
additionalProperties: false
|
||||
description: One Coach episodic reminder, decrypted server-side. The note reads null only when the encryption key id has
|
||||
rotated out of the map.
|
||||
CoachReminderCreated:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
reminder:
|
||||
$ref: "#/components/schemas/CoachReminder"
|
||||
required:
|
||||
- reminder
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachReminderUpdated:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
reminder:
|
||||
$ref: "#/components/schemas/CoachReminder"
|
||||
required:
|
||||
- reminder
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachReminderDeleted:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
deleted:
|
||||
type: boolean
|
||||
description: True when a reminder owned by the caller was soft-deleted; false for an unknown / cross-user /
|
||||
already-deleted id (idempotent no-op).
|
||||
required:
|
||||
- deleted
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachSuggestedActionResultCreated:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/CoachSuggestedActionResult"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachSuggestedActionResult:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
const: true
|
||||
actionType:
|
||||
type: string
|
||||
enum:
|
||||
- checkup.create
|
||||
- reminder.note
|
||||
reminder: {}
|
||||
reminderId:
|
||||
type: string
|
||||
required:
|
||||
- ok
|
||||
- actionType
|
||||
additionalProperties: false
|
||||
description: Outcome of confirming a Coach action card. `checkup.create` returns the created MeasurementReminder DTO;
|
||||
`reminder.note` returns the created CoachReminder id.
|
||||
AdminInviteList:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -4419,6 +4419,28 @@
|
||||
"clearedToast": "{count} gespeicherte Angaben vergessen.",
|
||||
"clearError": "Konnte die gespeicherten Angaben nicht löschen. Bitte erneut versuchen."
|
||||
},
|
||||
"coachReminders": {
|
||||
"title": "Deine Erinnerungen",
|
||||
"description": "Dinge, um die du den Coach gebeten hast, später darauf zurückzukommen. Fällige erscheinen hier und beim Coach.",
|
||||
"empty": "Noch keine Erinnerungen. Bitte den Coach, dich an etwas zu erinnern — dann erscheint es hier.",
|
||||
"loadError": "Konnte deine Erinnerungen nicht laden.",
|
||||
"dueHeading": "Jetzt fällig",
|
||||
"allHeading": "Gespeichert",
|
||||
"duePrefix": "fällig {when}",
|
||||
"confirm": "Behalten",
|
||||
"markDone": "Erledigt",
|
||||
"dismiss": "Verwerfen",
|
||||
"deleteAria": "Diese Erinnerung löschen",
|
||||
"note": "Erinnerungen werden verschlüsselt gespeichert. Lösche eine jederzeit — sie wird aus dem entfernt, was der Coach sehen kann.",
|
||||
"status": {
|
||||
"proposed": "Vorgeschlagen",
|
||||
"active": "Gespeichert",
|
||||
"due": "Fällig",
|
||||
"surfaced": "Angezeigt",
|
||||
"done": "Erledigt",
|
||||
"dismissed": "Verworfen"
|
||||
}
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "Über mich",
|
||||
"description": "Persönlicher Kontext, den Coach und Tagesbriefing zusätzlich zu deinen Daten lesen.",
|
||||
@@ -7054,6 +7076,25 @@
|
||||
"glucoseStructured": "Miss deine Glukose nach einem strukturierten Tagesplan"
|
||||
}
|
||||
},
|
||||
"suggestedAction": {
|
||||
"apply": "Hinzufügen",
|
||||
"dismiss": "Jetzt nicht",
|
||||
"applied": "Hinzugefügt",
|
||||
"dismissed": "Verworfen",
|
||||
"failed": "Konnte das nicht hinzufügen — bitte erneut versuchen",
|
||||
"checkup": {
|
||||
"title": "Vorsorge-Erinnerung hinzufügen?"
|
||||
},
|
||||
"note": {
|
||||
"title": "Erinnerung speichern?"
|
||||
},
|
||||
"interval": {
|
||||
"yearly": "Jedes Jahr",
|
||||
"halfYearly": "Alle sechs Monate",
|
||||
"quarterly": "Alle drei Monate",
|
||||
"monthly": "Jeden Monat"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"reminderSuggestionsLabel": "Kadenz-Vorschläge",
|
||||
"reminderSuggestionsDescription": "Lass den Coach gelegentlich eine evidenzbasierte Erinnerung anbieten, eine Metrik regelmäßiger zu messen."
|
||||
|
||||
@@ -4419,6 +4419,28 @@
|
||||
"clearedToast": "Forgot {count} stored facts.",
|
||||
"clearError": "Could not clear the stored facts. Please try again."
|
||||
},
|
||||
"coachReminders": {
|
||||
"title": "Your reminders",
|
||||
"description": "Things you asked the Coach to bring back later. Due ones show up here and on the Coach.",
|
||||
"empty": "No reminders yet. Ask the Coach to remind you about something and it will appear here.",
|
||||
"loadError": "Could not load your reminders.",
|
||||
"dueHeading": "Due now",
|
||||
"allHeading": "Saved",
|
||||
"duePrefix": "due {when}",
|
||||
"confirm": "Keep",
|
||||
"markDone": "Done",
|
||||
"dismiss": "Dismiss",
|
||||
"deleteAria": "Delete this reminder",
|
||||
"note": "Reminders are stored encrypted. Delete one any time — it is removed from what the Coach can see.",
|
||||
"status": {
|
||||
"proposed": "Suggested",
|
||||
"active": "Saved",
|
||||
"due": "Due",
|
||||
"surfaced": "Surfaced",
|
||||
"done": "Done",
|
||||
"dismissed": "Dismissed"
|
||||
}
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "About me",
|
||||
"description": "Personal context the Coach and the daily briefing read alongside your data.",
|
||||
@@ -7054,6 +7076,25 @@
|
||||
"glucoseStructured": "Test your glucose on a structured daily schedule"
|
||||
}
|
||||
},
|
||||
"suggestedAction": {
|
||||
"apply": "Add",
|
||||
"dismiss": "Not now",
|
||||
"applied": "Added",
|
||||
"dismissed": "Dismissed",
|
||||
"failed": "Couldn't add that — please try again",
|
||||
"checkup": {
|
||||
"title": "Add a check-up reminder?"
|
||||
},
|
||||
"note": {
|
||||
"title": "Save a reminder?"
|
||||
},
|
||||
"interval": {
|
||||
"yearly": "Every year",
|
||||
"halfYearly": "Every six months",
|
||||
"quarterly": "Every three months",
|
||||
"monthly": "Every month"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"reminderSuggestionsLabel": "Cadence suggestions",
|
||||
"reminderSuggestionsDescription": "Let the Coach offer an occasional, evidence-based reminder to measure a metric more regularly."
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
-- v1.22 (B2/B6) — Coach episodic reminder memory.
|
||||
--
|
||||
-- A time-anchored "bring this back later" intent the Coach captured from a
|
||||
-- conversation. The missing memory type between coach_facts (durable traits)
|
||||
-- and coach_plans (standing if-then habits): an episode with a due moment and
|
||||
-- a done state. The free-text note is AES-256-GCM ciphertext stored as `bytea`
|
||||
-- via the shared Bytes codec — the same shape as coach_plans.if_cue_encrypted.
|
||||
-- metric / status / trigger fields / dates / source stay plain so the daily
|
||||
-- sweep and the injection picker can rank and filter without a per-row decrypt.
|
||||
-- status (proposed | active | due | surfaced | done | dismissed) and
|
||||
-- trigger_kind (date | context) are app-side closed enums, deliberately NOT DB
|
||||
-- enums so this migration stays purely additive. Soft-delete via deleted_at.
|
||||
CREATE TABLE "coach_reminders" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"note_encrypted" BYTEA NOT NULL,
|
||||
"metric" TEXT,
|
||||
"related_plan_id" TEXT,
|
||||
"trigger_kind" TEXT NOT NULL DEFAULT 'date',
|
||||
"due_at" TIMESTAMP(3),
|
||||
"context_cue" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"source" TEXT NOT NULL,
|
||||
"source_conversation_id" TEXT,
|
||||
"last_surfaced_at" TIMESTAMP(3),
|
||||
"surface_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"deleted_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "coach_reminders_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- The daily sweep scans a user's active reminders whose due_at has passed; the
|
||||
-- status + deleted_at columns are part of that predicate, so the composite
|
||||
-- index lets the planner serve the sweep + the injection read index-backed.
|
||||
CREATE INDEX "coach_reminders_user_id_status_due_at_deleted_at_idx" ON "coach_reminders"("user_id", "status", "due_at", "deleted_at");
|
||||
|
||||
-- The context-cue evaluation pass filters by trigger_kind + status per user.
|
||||
CREATE INDEX "coach_reminders_user_id_trigger_kind_status_idx" ON "coach_reminders"("user_id", "trigger_kind", "status");
|
||||
|
||||
ALTER TABLE "coach_reminders" ADD CONSTRAINT "coach_reminders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -571,6 +571,7 @@ model User {
|
||||
coachConversations CoachConversation[]
|
||||
coachFacts CoachFact[]
|
||||
coachPlans CoachPlan[]
|
||||
coachReminders CoachReminder[]
|
||||
coachUsage CoachUsage[]
|
||||
// v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings
|
||||
// getworkouts / future Strava). Schema lands now so the v1.5 iOS
|
||||
@@ -3938,6 +3939,56 @@ model CoachPlan {
|
||||
@@map("coach_plans")
|
||||
}
|
||||
|
||||
// v1.22 (B2/B6) — durable, user-scoped EPISODIC reminder memory: a
|
||||
// time-anchored "bring this back later" intent the Coach captured from a
|
||||
// conversation ("remind me about my sleep next week"). This is the middle
|
||||
// memory type that neither CoachFact (durable traits) nor CoachPlan (standing
|
||||
// if-then habits) covers — an episode with a due moment and a done state.
|
||||
//
|
||||
// The free-text note is the user's own framing, encrypted at rest (AES-256-GCM
|
||||
// via the shared Bytes codec, exactly like CoachPlan's cue/action). `metric`,
|
||||
// `status`, the trigger fields and the dates stay plain so the daily sweep and
|
||||
// the injection picker can rank / filter without paying a per-row decrypt.
|
||||
//
|
||||
// `status` is an APP-SIDE closed enum (NOT a DB enum, to keep the migration
|
||||
// purely additive): proposed | active | due | surfaced | done | dismissed.
|
||||
// `triggerKind` is likewise app-side closed: date | context. Soft-delete via
|
||||
// `deletedAt` so a "forget this" action is reversible / auditable.
|
||||
model CoachReminder {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
// What to bring back — the user's own framing, encrypted at rest.
|
||||
noteEncrypted Bytes @map("note_encrypted")
|
||||
// Optional link to the metric / plan it concerns; plain for filtering.
|
||||
metric String?
|
||||
relatedPlanId String? @map("related_plan_id")
|
||||
// Trigger model — app-side closed enum (date | context). For `date`, `dueAt`
|
||||
// carries the fire moment; for `context`, `contextCue` names a closed cue.
|
||||
triggerKind String @default("date") @map("trigger_kind")
|
||||
dueAt DateTime? @map("due_at")
|
||||
contextCue String? @map("context_cue")
|
||||
// Lifecycle: proposed | active | due | surfaced | done | dismissed.
|
||||
status String @default("active")
|
||||
// Provenance — how it was captured (sentinel | extractor | manual | action).
|
||||
source String
|
||||
sourceConversationId String? @map("source_conversation_id")
|
||||
lastSurfacedAt DateTime? @map("last_surfaced_at")
|
||||
// How many times the sweep has surfaced this without the user acting; capped
|
||||
// before auto-dismiss so an ignored reminder cannot nag indefinitely.
|
||||
surfaceCount Int @default(0) @map("surface_count")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// The daily sweep scans active reminders whose dueAt has passed; the status +
|
||||
// deleted_at columns are part of the predicate so the read stays index-backed.
|
||||
@@index([userId, status, dueAt, deletedAt])
|
||||
@@index([userId, triggerKind, status])
|
||||
@@map("coach_reminders")
|
||||
}
|
||||
|
||||
// v1.15.20 — user-authored "about me" self-description the AI surfaces
|
||||
// (Coach system prompt + daily-briefing user prompt) inject as a clearly
|
||||
// delimited, user-provided context block. 1:1 with User and lazily
|
||||
|
||||
@@ -328,6 +328,16 @@ async function main() {
|
||||
results.push(await rotateBytesColumn("CoachPlan", field, prisma.coachPlan));
|
||||
}
|
||||
|
||||
// ───── CoachReminder (Bytes column) ─────
|
||||
// "noteEncrypted"
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"CoachReminder",
|
||||
"noteEncrypted",
|
||||
prisma.coachReminder,
|
||||
),
|
||||
);
|
||||
|
||||
// ───── UserHealthProfile (Bytes columns) ─────
|
||||
// "aboutMeEncrypted" "conditionsEncrypted" "allergiesEncrypted"
|
||||
// "coachFocusEncrypted" "pendingQuestionsEncrypted"
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* PATCH /api/coach/reminders/[id] — confirm / update a reminder's lifecycle.
|
||||
* DELETE /api/coach/reminders/[id] — soft-delete one reminder.
|
||||
*
|
||||
* v1.22 (B2/B6) — the user-confirm + management surface for the Coach episodic
|
||||
* reminders. `status` confirms a `proposed` reminder (→ active), marks it
|
||||
* `done` / `dismissed`, or re-activates it; `when` re-schedules (snooze) via the
|
||||
* closed grammar. The body never carries the note text, so a client can never
|
||||
* inject or overwrite a reminder's prose — only its lifecycle / timing.
|
||||
*
|
||||
* Ownership + existence privacy: every mutation is scoped
|
||||
* `where: { id, userId, deletedAt: null }` via `updateMany`, so a cross-user id,
|
||||
* an unknown id, or an already-deleted reminder all resolve to a `count: 0`
|
||||
* no-op rather than a P2025 throw — the existence channel never leaks across
|
||||
* accounts. PATCH on a 0-count match returns 404; DELETE returns the idempotent
|
||||
* `{ deleted: false }`.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { resolveWhenGrammar } from "@/lib/ai/coach/reminders";
|
||||
import { coachReminderPatchSchema } from "@/lib/validations/coach-reminder";
|
||||
|
||||
interface RouteCtx {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
const MUTATE_RATE_LIMIT = 40;
|
||||
const MUTATE_WINDOW_MS = 60_000;
|
||||
|
||||
async function enforceMutateLimit(
|
||||
op: string,
|
||||
userId: string,
|
||||
): Promise<Response | null> {
|
||||
const rl = await checkRateLimit(
|
||||
`coach-reminders:${op}:${userId}`,
|
||||
MUTATE_RATE_LIMIT,
|
||||
MUTATE_WINDOW_MS,
|
||||
);
|
||||
if (rl.allowed) return null;
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const limited = await enforceMutateLimit("patch", user.id);
|
||||
if (limited) return limited;
|
||||
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = coachReminderPatchSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
// Field-by-field (no mass assignment): only the lifecycle / timing fields the
|
||||
// body carried are written; the note + encrypted text are never touched here.
|
||||
const data: {
|
||||
status?: string;
|
||||
dueAt?: Date | null;
|
||||
triggerKind?: string;
|
||||
contextCue?: string | null;
|
||||
} = {};
|
||||
if (parsed.data.status !== undefined) data.status = parsed.data.status;
|
||||
if (parsed.data.when !== undefined) {
|
||||
if (parsed.data.when === null) {
|
||||
data.dueAt = null;
|
||||
data.triggerKind = "date";
|
||||
data.contextCue = null;
|
||||
} else {
|
||||
const trigger = resolveWhenGrammar(parsed.data.when, new Date());
|
||||
if (!trigger) return apiError("coach.reminder.invalidWhen", 422);
|
||||
data.dueAt = trigger.dueAt;
|
||||
data.triggerKind = trigger.triggerKind;
|
||||
data.contextCue = trigger.contextCue;
|
||||
}
|
||||
}
|
||||
|
||||
const { count } = await prisma.coachReminder.updateMany({
|
||||
where: { id, userId: user.id, deletedAt: null },
|
||||
data,
|
||||
});
|
||||
|
||||
if (count === 0) return apiError("Reminder not found", 404);
|
||||
|
||||
const row = await prisma.coachReminder.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
metric: true,
|
||||
noteEncrypted: true,
|
||||
triggerKind: true,
|
||||
dueAt: true,
|
||||
contextCue: true,
|
||||
status: true,
|
||||
source: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
if (!row) return apiError("Reminder not found", 404);
|
||||
|
||||
let note: string | null = null;
|
||||
try {
|
||||
note = decryptFromBytes(row.noteEncrypted);
|
||||
} catch {
|
||||
note = null;
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.reminders.updated" },
|
||||
meta: { status: row.status },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
reminder: {
|
||||
id: row.id,
|
||||
note,
|
||||
metric: row.metric,
|
||||
triggerKind: row.triggerKind,
|
||||
dueAt: row.dueAt?.toISOString() ?? null,
|
||||
contextCue: row.contextCue,
|
||||
status: row.status,
|
||||
source: row.source,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: NextRequest, ctx: RouteCtx) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const limited = await enforceMutateLimit("delete", user.id);
|
||||
if (limited) return limited;
|
||||
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const { count } = await prisma.coachReminder.updateMany({
|
||||
where: { id, userId: user.id, deletedAt: null },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
const deleted = count > 0;
|
||||
annotate({
|
||||
action: { name: "coach.reminders.deleted" },
|
||||
meta: { deleted },
|
||||
});
|
||||
return apiSuccess({ deleted });
|
||||
},
|
||||
);
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* v1.22 (B2/B6) — `/api/coach/reminders` (+ `/[id]`) routes.
|
||||
*
|
||||
* Covers:
|
||||
* - GET lists the caller's reminders decrypted, scoped to the user; an
|
||||
* undecryptable row is skipped rather than 500ing the list.
|
||||
* - POST creates a reminder field-by-field (source manual, active), resolving
|
||||
* the closed `when` grammar server-side; an invalid `when` 422s.
|
||||
* - PATCH confirms proposed → active field-by-field; a 0-count match 404s
|
||||
* (no-IDOR: the cross-user / unknown id never leaks existence).
|
||||
* - DELETE soft-deletes the caller's reminder; a cross-user id is a 0-count
|
||||
* no-op returning `{ deleted: false }`.
|
||||
* - the coach module gate runs on every verb.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
coachReminder: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
remaining: 39,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
decryptFromBytes: vi.fn((buf: Uint8Array) => {
|
||||
const tag = Buffer.from(buf).toString("utf8");
|
||||
if (tag === "__bad__") throw new Error("unknown key id");
|
||||
return `dec:${tag}`;
|
||||
}),
|
||||
encryptToBytes: vi.fn((s: string) => new Uint8Array(Buffer.from(`enc:${s}`))),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return { ...actual, annotate: vi.fn() };
|
||||
});
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, POST } from "../route";
|
||||
import { PATCH, DELETE as DELETE_ONE } from "../[id]/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "t",
|
||||
role: "USER" as const,
|
||||
displayName: null,
|
||||
},
|
||||
};
|
||||
|
||||
function bytes(tag: string): Uint8Array {
|
||||
return new Uint8Array(Buffer.from(tag, "utf8"));
|
||||
}
|
||||
|
||||
const callGet = (url = "http://localhost/api/coach/reminders") =>
|
||||
(GET as unknown as (req: Request) => Promise<Response>)(new Request(url));
|
||||
|
||||
function jsonReq(body: unknown, method = "POST"): NextRequest {
|
||||
return new NextRequest("http://localhost/api/coach/reminders", {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
const callPost = (body: unknown) =>
|
||||
(POST as unknown as (req: Request) => Promise<Response>)(jsonReq(body));
|
||||
|
||||
const callPatch = (body: unknown, id = "r1") =>
|
||||
(
|
||||
PATCH as unknown as (
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
) => Promise<Response>
|
||||
)(
|
||||
new NextRequest("http://localhost/api/coach/reminders/x", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
|
||||
const callDelete = (id = "r1") =>
|
||||
(
|
||||
DELETE_ONE as unknown as (
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
) => Promise<Response>
|
||||
)(
|
||||
new NextRequest("http://localhost/api/coach/reminders/x", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
});
|
||||
|
||||
describe("GET /api/coach/reminders", () => {
|
||||
it("lists the caller's reminders decrypted, scoped to the user", async () => {
|
||||
vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "r1",
|
||||
metric: "SLEEP",
|
||||
noteEncrypted: bytes("revisit sleep"),
|
||||
triggerKind: "date",
|
||||
dueAt: new Date("2026-07-11T09:00:00Z"),
|
||||
contextCue: null,
|
||||
status: "active",
|
||||
source: "sentinel",
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-02T00:00:00Z"),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await callGet();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { reminders: Array<Record<string, unknown>> };
|
||||
};
|
||||
expect(body.data.reminders[0]).toMatchObject({
|
||||
id: "r1",
|
||||
note: "dec:revisit sleep",
|
||||
status: "active",
|
||||
});
|
||||
const where = vi.mocked(prisma.coachReminder.findMany).mock.calls[0]?.[0]
|
||||
?.where as { userId: string; deletedAt: null };
|
||||
expect(where.userId).toBe("user-1");
|
||||
expect(where.deletedAt).toBeNull();
|
||||
expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach");
|
||||
});
|
||||
|
||||
it("skips an undecryptable row rather than 500ing", async () => {
|
||||
vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "r1",
|
||||
metric: null,
|
||||
noteEncrypted: bytes("__bad__"),
|
||||
triggerKind: "date",
|
||||
dueAt: null,
|
||||
contextCue: null,
|
||||
status: "active",
|
||||
source: "manual",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
metric: null,
|
||||
noteEncrypted: bytes("ok"),
|
||||
triggerKind: "date",
|
||||
dueAt: null,
|
||||
contextCue: null,
|
||||
status: "active",
|
||||
source: "manual",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
] as never);
|
||||
const res = await callGet();
|
||||
const body = (await res.json()) as {
|
||||
data: { reminders: Array<{ id: string }> };
|
||||
};
|
||||
expect(body.data.reminders).toHaveLength(1);
|
||||
expect(body.data.reminders[0]?.id).toBe("r2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/coach/reminders", () => {
|
||||
it("creates a reminder field-by-field, resolving the when grammar", async () => {
|
||||
vi.mocked(prisma.coachReminder.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.coachReminder.create).mockResolvedValue({
|
||||
id: "r9",
|
||||
metric: "SLEEP",
|
||||
noteEncrypted: bytes("x"),
|
||||
triggerKind: "date",
|
||||
dueAt: new Date(),
|
||||
contextCue: null,
|
||||
status: "active",
|
||||
source: "manual",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const res = await callPost({
|
||||
note: "revisit sleep",
|
||||
when: "+7d",
|
||||
metric: "SLEEP",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const data = vi.mocked(prisma.coachReminder.create).mock.calls[0]?.[0]
|
||||
?.data as {
|
||||
userId: string;
|
||||
status: string;
|
||||
source: string;
|
||||
};
|
||||
expect(data.userId).toBe("user-1");
|
||||
expect(data.status).toBe("active");
|
||||
expect(data.source).toBe("manual");
|
||||
});
|
||||
|
||||
it("422s on an invalid when token", async () => {
|
||||
vi.mocked(prisma.coachReminder.count).mockResolvedValue(0 as never);
|
||||
const res = await callPost({ note: "x", when: "someday" });
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.coachReminder.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s on an unknown key (strict)", async () => {
|
||||
const res = await callPost({ note: "x", userId: "evil" });
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/coach/reminders/[id]", () => {
|
||||
it("confirms proposed → active field-by-field, owner-scoped", async () => {
|
||||
vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.coachReminder.findFirst).mockResolvedValue({
|
||||
id: "r1",
|
||||
metric: null,
|
||||
noteEncrypted: bytes("x"),
|
||||
triggerKind: "date",
|
||||
dueAt: null,
|
||||
contextCue: null,
|
||||
status: "active",
|
||||
source: "sentinel",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as never);
|
||||
const res = await callPatch({ status: "active" });
|
||||
expect(res.status).toBe(200);
|
||||
const arg = vi.mocked(prisma.coachReminder.updateMany).mock
|
||||
.calls[0]?.[0] as {
|
||||
where: { id: string; userId: string; deletedAt: null };
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(arg.where.userId).toBe("user-1");
|
||||
expect(Object.keys(arg.data)).toEqual(["status"]);
|
||||
});
|
||||
|
||||
it("404s on a 0-count (cross-user / unknown) match — no IDOR leak", async () => {
|
||||
vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
const res = await callPatch({ status: "done" }, "someone-elses");
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.coachReminder.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s on an empty body", async () => {
|
||||
const res = await callPatch({});
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/coach/reminders/[id]", () => {
|
||||
it("soft-deletes the caller's reminder", async () => {
|
||||
vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
const res = await callDelete();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { deleted: boolean } };
|
||||
expect(body.data.deleted).toBe(true);
|
||||
});
|
||||
|
||||
it("returns deleted:false for a cross-user id (0-count no-op)", async () => {
|
||||
vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
const res = await callDelete("someone-elses");
|
||||
const body = (await res.json()) as { data: { deleted: boolean } };
|
||||
expect(body.data.deleted).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* GET /api/coach/reminders — list the caller's Coach episodic reminders.
|
||||
* POST /api/coach/reminders — create one manually (the ledger "+ add" path).
|
||||
*
|
||||
* v1.22 (B2/B6) — the management + in-app-surface read for the durable
|
||||
* "remind me about X" memory the Coach captures inline via the `---REMEMBER---`
|
||||
* sentinel. The note free-text is decrypted on the fly; an undecryptable row (a
|
||||
* key rotated out of the map) is skipped rather than 500ing the whole list.
|
||||
*
|
||||
* Ownership: every query is scoped `where: { userId, ... }`, so a caller can
|
||||
* only ever see / create their own reminders. The owner is always narrowed from
|
||||
* the session, never the body. Coach-gated by the same
|
||||
* `requireModuleEnabled(userId, "coach")` kill-switch as the rest of the Coach
|
||||
* management stack.
|
||||
*
|
||||
* `?status=` optionally filters (one status, or a comma set like `due,surfaced`
|
||||
* for the in-app tile). Omitted returns the non-terminal set (proposed / active
|
||||
* / due / surfaced), soonest-due first.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
resolveWhenGrammar,
|
||||
MAX_REMINDERS_PER_USER,
|
||||
} from "@/lib/ai/coach/reminders";
|
||||
import {
|
||||
coachRemindersListQuerySchema,
|
||||
coachReminderCreateSchema,
|
||||
} from "@/lib/validations/coach-reminder";
|
||||
|
||||
const NON_TERMINAL = ["proposed", "active", "due", "surfaced"] as const;
|
||||
|
||||
interface ReminderRow {
|
||||
id: string;
|
||||
metric: string | null;
|
||||
noteEncrypted: Uint8Array;
|
||||
triggerKind: string;
|
||||
dueAt: Date | null;
|
||||
contextCue: string | null;
|
||||
status: string;
|
||||
source: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
function toDto(row: ReminderRow, note: string) {
|
||||
return {
|
||||
id: row.id,
|
||||
note,
|
||||
metric: row.metric,
|
||||
triggerKind: row.triggerKind,
|
||||
dueAt: row.dueAt?.toISOString() ?? null,
|
||||
contextCue: row.contextCue,
|
||||
status: row.status,
|
||||
source: row.source,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const url = new URL(req.url);
|
||||
const parsed = coachRemindersListQuerySchema.safeParse({
|
||||
status: url.searchParams.get("status") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const statusWhere = parsed.data.status
|
||||
? { status: { in: parsed.data.status } }
|
||||
: { status: { in: [...NON_TERMINAL] } };
|
||||
|
||||
const rows = (await prisma.coachReminder.findMany({
|
||||
where: { userId: user.id, deletedAt: null, ...statusWhere },
|
||||
orderBy: [{ dueAt: "asc" }, { updatedAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
metric: true,
|
||||
noteEncrypted: true,
|
||||
triggerKind: true,
|
||||
dueAt: true,
|
||||
contextCue: true,
|
||||
status: true,
|
||||
source: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})) as ReminderRow[];
|
||||
|
||||
const reminders: ReturnType<typeof toDto>[] = [];
|
||||
for (const row of rows) {
|
||||
let note: string;
|
||||
try {
|
||||
note = decryptFromBytes(row.noteEncrypted);
|
||||
} catch {
|
||||
// Fail closed per row — never surface ciphertext, never 500 the list.
|
||||
continue;
|
||||
}
|
||||
reminders.push(toDto(row, note));
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.reminders.listed" },
|
||||
meta: { count: reminders.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ reminders });
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rate = await checkRateLimit(
|
||||
`coach-reminders:create:${user.id}`,
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
if (!rate.allowed) return apiError("Too many requests", 429);
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = coachReminderCreateSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
// Resolve the optional `when` against the closed grammar; an invalid token
|
||||
// 422s rather than silently dropping the timing the user asked for.
|
||||
const now = new Date();
|
||||
let trigger: ReturnType<typeof resolveWhenGrammar> = null;
|
||||
if (parsed.data.when) {
|
||||
trigger = resolveWhenGrammar(parsed.data.when, now);
|
||||
if (!trigger) return apiError("coach.reminder.invalidWhen", 422);
|
||||
}
|
||||
|
||||
// Per-user cap on the non-terminal set.
|
||||
const nonTerminal = await prisma.coachReminder.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
status: { in: [...NON_TERMINAL] },
|
||||
},
|
||||
});
|
||||
if (nonTerminal >= MAX_REMINDERS_PER_USER) {
|
||||
return apiError("coach.reminder.capReached", 409);
|
||||
}
|
||||
|
||||
// Field-by-field — no mass assignment. Manual reminders are active on create.
|
||||
const created = await prisma.coachReminder.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
noteEncrypted: encryptToBytes(parsed.data.note),
|
||||
metric: parsed.data.metric ?? null,
|
||||
triggerKind: trigger?.triggerKind ?? "date",
|
||||
dueAt: trigger?.dueAt ?? null,
|
||||
contextCue: trigger?.contextCue ?? null,
|
||||
status: "active",
|
||||
source: "manual",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
metric: true,
|
||||
noteEncrypted: true,
|
||||
triggerKind: true,
|
||||
dueAt: true,
|
||||
contextCue: true,
|
||||
status: true,
|
||||
source: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.reminders.created" },
|
||||
meta: { triggerKind: created.triggerKind, hasDue: created.dueAt != null },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{ reminder: toDto(created as ReminderRow, parsed.data.note) },
|
||||
201,
|
||||
);
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* v1.22 (F6) — `POST /api/coach/suggested-actions`.
|
||||
*
|
||||
* Covers the closed allowlist confirm endpoint:
|
||||
* - checkup.create builds a Vorsorge MeasurementReminder field-by-field
|
||||
* (origin COACH, server-resolved RRULE from the closed interval id).
|
||||
* - reminder.note builds a CoachReminder field-by-field (source action).
|
||||
* - an off-allowlist actionType (e.g. medication.create) 422s — the moat
|
||||
* never auto-applies or mints an arbitrary entity.
|
||||
* - the owner is always the session user (no IDOR, no body userId).
|
||||
* - the coach module gate runs.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
measurementReminder: { create: vi.fn() },
|
||||
coachReminder: { count: vi.fn(), create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
resetAt: Date.now() + 1000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)),
|
||||
}));
|
||||
vi.mock("@/lib/measurement-reminders/scheduling", () => ({
|
||||
computeReminderNextDueAt: vi.fn(() => new Date("2027-06-27T09:00:00Z")),
|
||||
}));
|
||||
vi.mock("@/lib/measurement-reminders/dto", () => ({
|
||||
toMeasurementReminderDto: vi.fn((r: { id: string }) => ({ id: r.id })),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return { ...actual, annotate: vi.fn() };
|
||||
});
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "t",
|
||||
role: "USER" as const,
|
||||
displayName: null,
|
||||
},
|
||||
};
|
||||
|
||||
const call = (body: unknown) =>
|
||||
(POST as unknown as (req: Request) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/coach/suggested-actions", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
});
|
||||
|
||||
describe("POST /api/coach/suggested-actions", () => {
|
||||
it("checkup.create builds a Vorsorge reminder field-by-field (origin COACH, server RRULE)", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
} as never);
|
||||
vi.mocked(prisma.measurementReminder.create).mockResolvedValue({
|
||||
id: "mr1",
|
||||
} as never);
|
||||
|
||||
const res = await call({
|
||||
actionType: "checkup.create",
|
||||
label: "Annual blood panel",
|
||||
interval: "yearly",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const data = vi.mocked(prisma.measurementReminder.create).mock.calls[0]?.[0]
|
||||
?.data as {
|
||||
userId: string;
|
||||
origin: string;
|
||||
rrule: string;
|
||||
measurementType: null;
|
||||
label: string;
|
||||
};
|
||||
expect(data.userId).toBe("user-1"); // owner from session, never the body
|
||||
expect(data.origin).toBe("COACH");
|
||||
expect(data.rrule).toBe("FREQ=YEARLY;INTERVAL=1");
|
||||
expect(data.measurementType).toBeNull();
|
||||
expect(data.label).toBe("Annual blood panel");
|
||||
expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach");
|
||||
});
|
||||
|
||||
it("reminder.note builds a CoachReminder field-by-field (source action)", async () => {
|
||||
vi.mocked(prisma.coachReminder.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.coachReminder.create).mockResolvedValue({
|
||||
id: "cr1",
|
||||
} as never);
|
||||
|
||||
const res = await call({
|
||||
actionType: "reminder.note",
|
||||
note: "revisit evening walk",
|
||||
when: "+14d",
|
||||
metric: "SLEEP",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const data = vi.mocked(prisma.coachReminder.create).mock.calls[0]?.[0]
|
||||
?.data as {
|
||||
userId: string;
|
||||
source: string;
|
||||
status: string;
|
||||
};
|
||||
expect(data.userId).toBe("user-1");
|
||||
expect(data.source).toBe("action");
|
||||
expect(data.status).toBe("active");
|
||||
});
|
||||
|
||||
it("422s an off-allowlist actionType — never auto-applies a clinical/med change", async () => {
|
||||
const res = await call({
|
||||
actionType: "medication.create",
|
||||
label: "start a drug",
|
||||
interval: "monthly",
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.measurementReminder.create).not.toHaveBeenCalled();
|
||||
expect(prisma.coachReminder.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("403s when the coach module is disabled", async () => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: false,
|
||||
response: new Response(null, { status: 403 }),
|
||||
} as never);
|
||||
const res = await call({
|
||||
actionType: "reminder.note",
|
||||
note: "x",
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* POST /api/coach/suggested-actions — confirm a generalised Coach action card.
|
||||
*
|
||||
* v1.22 (F6) — the confirm half of the generalised propose→confirm moat. The
|
||||
* Coach proposes an action via the closed `---SUGGEST-ACTION---` sentinel →
|
||||
* `suggestedAction` SSE frame → `<SuggestedActionCard>`. This endpoint is what
|
||||
* the card's confirm button calls. It switches on the CLOSED `actionType`
|
||||
* allowlist and, for each, builds the entity FIELD-BY-FIELD from a
|
||||
* server-resolved spec:
|
||||
*
|
||||
* - `checkup.create` → a preventive-care Vorsorge `MeasurementReminder`
|
||||
* (free-text label + a closed interval id the SERVER resolves to an RRULE),
|
||||
* minted through the SAME engine the Vorsorge surface uses.
|
||||
* - `reminder.note` → a `CoachReminder` (note + optional closed `when`
|
||||
* grammar + optional metric).
|
||||
*
|
||||
* HARD SAFETY: never auto-apply (this endpoint runs only on the explicit
|
||||
* confirm tap); closed allowlist only (the discriminated-union Zod gate rejects
|
||||
* any other `actionType`); NEVER a medication / dose / clinical-target change.
|
||||
* Cookie-auth, coach module-gated, per-user rate-limited, owner-scoped — no
|
||||
* IDOR, no mass assignment.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
resolveWhenGrammar,
|
||||
MAX_REMINDERS_PER_USER,
|
||||
} from "@/lib/ai/coach/reminders";
|
||||
import { CHECKUP_INTERVAL_CATALOG } from "@/lib/ai/coach/suggest-action";
|
||||
import { coachSuggestedActionSchema } from "@/lib/validations/coach-reminder";
|
||||
import {
|
||||
computeReminderNextDueAt,
|
||||
type ReminderScheduleInput,
|
||||
} from "@/lib/measurement-reminders/scheduling";
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
|
||||
const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
const CHECKUP_NOTIFY_HOUR = 9;
|
||||
|
||||
async function postAction(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rate = await checkRateLimit(
|
||||
`coach:suggested-action:${user.id}`,
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
if (!rate.allowed) return apiError("coach.action.rateLimited", 429);
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = coachSuggestedActionSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
const action = parsed.data;
|
||||
|
||||
// ── checkup.create → a preventive-care Vorsorge reminder ─────
|
||||
if (action.actionType === "checkup.create") {
|
||||
// The model named an interval id; the SERVER owns the RRULE so the model
|
||||
// can never mint an arbitrary schedule.
|
||||
const preset = CHECKUP_INTERVAL_CATALOG[action.interval];
|
||||
if (!preset) return apiError("coach.action.unknownInterval", 422);
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const timezone = row?.timezone || DEFAULT_TIMEZONE;
|
||||
const now = new Date();
|
||||
const scheduleInput: ReminderScheduleInput = {
|
||||
intervalDays: null,
|
||||
rrule: preset.rrule,
|
||||
anchorDate: null,
|
||||
notifyHour: CHECKUP_NOTIFY_HOUR,
|
||||
lastSatisfiedAt: null,
|
||||
createdAt: now,
|
||||
endsOn: null,
|
||||
};
|
||||
const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
|
||||
|
||||
// Field-by-field — no mass assignment. A free-text checklist Vorsorge
|
||||
// (measurementType null) so it resolves only on a manual "done". origin
|
||||
// COACH marks the provenance.
|
||||
const created = await prisma.measurementReminder.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
label: action.label,
|
||||
measurementType: null,
|
||||
intervalDays: null,
|
||||
rrule: preset.rrule,
|
||||
anchorDate: null,
|
||||
endsOn: null,
|
||||
origin: "COACH",
|
||||
notifyHour: CHECKUP_NOTIFY_HOUR,
|
||||
location: null,
|
||||
enabled: true,
|
||||
nextDueAt,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("measurementReminder.create.coachAction", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reminderId: created.id, interval: action.interval },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "coach.action.applied" },
|
||||
meta: { actionType: action.actionType, interval: action.interval },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
ok: true,
|
||||
actionType: action.actionType,
|
||||
reminder: toMeasurementReminderDto(created),
|
||||
},
|
||||
201,
|
||||
);
|
||||
}
|
||||
|
||||
// ── reminder.note → a CoachReminder ──────────────────────────
|
||||
const now = new Date();
|
||||
let trigger: ReturnType<typeof resolveWhenGrammar> = null;
|
||||
if (action.when) {
|
||||
trigger = resolveWhenGrammar(action.when, now);
|
||||
if (!trigger) return apiError("coach.reminder.invalidWhen", 422);
|
||||
}
|
||||
|
||||
const nonTerminal = await prisma.coachReminder.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
status: { in: ["proposed", "active", "due", "surfaced"] },
|
||||
},
|
||||
});
|
||||
if (nonTerminal >= MAX_REMINDERS_PER_USER) {
|
||||
return apiError("coach.reminder.capReached", 409);
|
||||
}
|
||||
|
||||
const created = await prisma.coachReminder.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
noteEncrypted: encryptToBytes(action.note),
|
||||
metric: action.metric ?? null,
|
||||
triggerKind: trigger?.triggerKind ?? "date",
|
||||
dueAt: trigger?.dueAt ?? null,
|
||||
contextCue: trigger?.contextCue ?? null,
|
||||
status: "active",
|
||||
source: "action",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.action.applied" },
|
||||
meta: {
|
||||
actionType: action.actionType,
|
||||
hasDue: trigger?.dueAt != null,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{ ok: true, actionType: action.actionType, reminderId: created.id },
|
||||
201,
|
||||
);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(postAction);
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@@ -93,6 +93,16 @@ import {
|
||||
import { scrubUnknownLearnLinks } from "@/lib/ai/coach/learn-link-guard";
|
||||
import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder";
|
||||
import { gateSuggestion } from "@/lib/ai/coach/suggest-gate";
|
||||
import {
|
||||
parseRememberSentinel,
|
||||
captureReminderFromSentinel,
|
||||
buildRememberAddendum,
|
||||
} from "@/lib/ai/coach/reminders";
|
||||
import {
|
||||
parseSuggestAction,
|
||||
buildSuggestActionAddendum,
|
||||
type CoachSuggestedAction,
|
||||
} from "@/lib/ai/coach/suggest-action";
|
||||
import {
|
||||
parseCoachPrefs,
|
||||
DEFAULT_REMINDER_SUGGESTION_PREFS,
|
||||
@@ -371,7 +381,12 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
// v1.16.0 — composed self-context: structured questionnaire fields
|
||||
// plus age/gender merged in from the User profile.
|
||||
const aboutMe = await getSelfContextTextForUser(userId, locale);
|
||||
const systemPrompt = getCoachSystemPrompt(locale, coachPrefs, aboutMe);
|
||||
// v1.22 (B2/F6) — the canonical system-prompt module is owned elsewhere, so
|
||||
// the two memory/action clauses are appended at assembly time here: one
|
||||
// teaches the model to emit `---REMEMBER---` (durable "remind me" capture),
|
||||
// one teaches the closed `---SUGGEST-ACTION---` confirm-card allowlist. Both
|
||||
// are provider-neutral sentinels stripped from the prose before it streams.
|
||||
const systemPrompt = `${getCoachSystemPrompt(locale, coachPrefs, aboutMe)}\n\n${buildRememberAddendum(locale)}\n\n${buildSuggestActionAddendum(locale)}`;
|
||||
const allTurns: CoachTurn[] = [
|
||||
...priorTurns,
|
||||
{ role: "user", content: message },
|
||||
@@ -755,6 +770,39 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch
|
||||
const suggestParse = parseSuggestReminder(proseAfterStrip);
|
||||
let replyText = suggestParse.prose.trim() || proseAfterStrip;
|
||||
|
||||
// v1.22 (B2) — strip the optional `---REMEMBER---` block and capture the
|
||||
// reminder INLINE on this turn (not in the >20-turn memory worker), so a
|
||||
// casual "remind me about X" in a SHORT chat is no longer lost. The note is
|
||||
// the user's OWN explicit request → captured silently as `active` (no confirm
|
||||
// gate); the model acknowledges it in prose. A missing note / invalid `when`
|
||||
// drops the block (the user never sees the raw marker). Fire-and-forget: the
|
||||
// capture write must never break the chat turn.
|
||||
const rememberParse = parseRememberSentinel(replyText, new Date());
|
||||
replyText = rememberParse.prose.trim() || replyText;
|
||||
if (rememberParse.reminder) {
|
||||
const capture = rememberParse.reminder;
|
||||
void captureReminderFromSentinel({
|
||||
userId,
|
||||
conversationId: workingConversationId,
|
||||
parsed: capture,
|
||||
}).catch(() => {
|
||||
// Reminder capture is best-effort; never sink the turn.
|
||||
});
|
||||
} else if (rememberParse.malformed) {
|
||||
annotate({
|
||||
action: { name: "coach.reminder.capture_malformed" },
|
||||
meta: { conversationId: workingConversationId },
|
||||
});
|
||||
}
|
||||
|
||||
// v1.22 (F6) — strip the optional `---SUGGEST-ACTION---` block (the
|
||||
// generalised confirm→apply moat). The model names ONE action from the closed
|
||||
// allowlist (`checkup.create` / `reminder.note`); the card surfaces additively
|
||||
// and NOTHING is created until the user taps confirm (the entity is built
|
||||
// server-side, field-by-field, by `POST /api/coach/suggested-actions`).
|
||||
const actionParse = parseSuggestAction(replyText);
|
||||
replyText = actionParse.prose.trim() || replyText;
|
||||
|
||||
// v1.18.10 (HIGH-2) — OUTBOUND safety screen on the assembled assistant
|
||||
// reply, before persistence and streaming. The inbound `detectRefusal`
|
||||
// guards the user's message; this guards the model's reply for a
|
||||
@@ -888,8 +936,21 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch
|
||||
});
|
||||
}
|
||||
}
|
||||
// v1.22 (F6) — surface the confirm-card action when the turn was not blocked.
|
||||
// Additive: the prose already stands alone; the card only offers the one-tap
|
||||
// confirm. Nothing is created server-side until the user taps it.
|
||||
let surfacedAction: CoachSuggestedAction | null = null;
|
||||
if (!outbound.block && actionParse.action) {
|
||||
surfacedAction = actionParse.action;
|
||||
annotate({
|
||||
action: { name: "coach.action.suggested" },
|
||||
meta: { actionType: actionParse.action.actionType },
|
||||
});
|
||||
}
|
||||
|
||||
const enrichedProvenance: typeof snapshot.provenance = {
|
||||
...snapshot.provenance,
|
||||
...(surfacedAction ? { suggestedAction: surfacedAction } : {}),
|
||||
// v1.18.10 (HIGH-2) — a blocked turn carries the fallback prose, so the
|
||||
// key-values from the discarded reply must not ride along as provenance.
|
||||
...(!outbound.block && sentinel.keyValues.length > 0
|
||||
@@ -1018,6 +1079,16 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch
|
||||
encodeFrame({ type: "suggestion", suggestion: surfacedSuggestion }),
|
||||
);
|
||||
}
|
||||
// v1.22 (F6) — additive `suggestedAction` frame. Older clients ignore it;
|
||||
// newer ones render the generic one-tap confirm card.
|
||||
if (surfacedAction) {
|
||||
controller.enqueue(
|
||||
encodeFrame({
|
||||
type: "suggestedAction",
|
||||
suggestedAction: surfacedAction,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// v1.18.9 — additive `usage` envelope on the `done` frame so the client
|
||||
// can paint the quiet per-message token footer the instant the stream
|
||||
// closes. Server-authoritative: the client renders these numbers, never
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Check, Loader2, Sparkles } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action";
|
||||
|
||||
/**
|
||||
* v1.22 (F6) — the generalised one-tap "confirm → apply" Coach action card.
|
||||
*
|
||||
* Rendered under an assistant bubble when the turn carried a `suggestedAction`
|
||||
* (live from the SSE frame, or persisted on the message provenance). The card
|
||||
* shows a localized heading per action type (`titleKey`) + the model's plain-text
|
||||
* summary, and two actions:
|
||||
*
|
||||
* - "Add" → POSTs the validated params to `POST /api/coach/suggested-actions`,
|
||||
* which builds the entity SERVER-side, field-by-field. NEVER auto-applied.
|
||||
* - "Not now" → dismisses the card locally (no server write).
|
||||
*
|
||||
* RENDER dedup mirrors the cadence card: once settled, the card collapses to a
|
||||
* confirmation so a stale stream + persisted twin can't double-prompt. The
|
||||
* STATIC copy is i18n; the dynamic summary/label is the user's own content
|
||||
* (plain text — no markdown, matching the XSS posture).
|
||||
*/
|
||||
export function SuggestedActionCard({
|
||||
action,
|
||||
}: {
|
||||
action: CoachSuggestedAction;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [settled, setSettled] = useState<"applied" | "dismissed" | null>(null);
|
||||
|
||||
const apply = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiPost("/api/coach/suggested-actions", action.params);
|
||||
},
|
||||
onSuccess: () => setSettled("applied"),
|
||||
onError: () => toast.error(t("coach.suggestedAction.failed")),
|
||||
});
|
||||
|
||||
if (settled) {
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
data-slot="coach-suggested-action-done"
|
||||
className="text-muted-foreground flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<Check className="text-dracula-green size-3.5" aria-hidden="true" />
|
||||
{t(
|
||||
settled === "applied"
|
||||
? "coach.suggestedAction.applied"
|
||||
: "coach.suggestedAction.dismissed",
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const busy = apply.isPending;
|
||||
const intervalLabel =
|
||||
action.params.actionType === "checkup.create"
|
||||
? t(`coach.suggestedAction.interval.${action.params.interval}`)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-suggested-action-card"
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 flex flex-col gap-2.5 rounded-xl border",
|
||||
"px-3.5 py-3 text-sm",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="from-dracula-purple to-dracula-cyan mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
|
||||
>
|
||||
<Sparkles className="text-background size-3.5" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
{t(action.titleKey)}
|
||||
</span>
|
||||
<span className="text-foreground leading-relaxed break-words">
|
||||
{action.summary}
|
||||
</span>
|
||||
{intervalLabel && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{intervalLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-suggested-action-apply"
|
||||
onClick={() => apply.mutate()}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
"bg-dracula-purple/90 text-background hover:bg-dracula-purple",
|
||||
"focus-visible:ring-ring/50 inline-flex min-h-9 items-center gap-1.5",
|
||||
"rounded-md px-3 py-1.5 text-xs font-medium outline-none",
|
||||
"focus-visible:ring-2 disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Check className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
{t("coach.suggestedAction.apply")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-suggested-action-dismiss"
|
||||
onClick={() => setSettled("dismissed")}
|
||||
disabled={busy}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-9 items-center rounded-md px-2.5 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
|
||||
>
|
||||
{t("coach.suggestedAction.dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -122,8 +122,27 @@ export function LayoutCoachFab() {
|
||||
},
|
||||
});
|
||||
|
||||
// v1.22 (M4) — surface a due/surfaced Coach reminder on the FAB. Additive:
|
||||
// the dot also lights when the daily sweep has flipped a "remind me" reminder
|
||||
// to due, independent of the nudge seen-stamp (resolving the reminder in the
|
||||
// ledger clears it). Same cheap, gated, stale-while-revalidate read shape.
|
||||
const { data: dueReminders } = useQuery({
|
||||
queryKey: queryKeys.coachReminders("due,surfaced"),
|
||||
queryFn: async () => {
|
||||
const data = await apiGet<{ reminders?: unknown[] } | undefined>(
|
||||
"/api/coach/reminders?status=due,surfaced",
|
||||
);
|
||||
return data?.reminders ?? [];
|
||||
},
|
||||
enabled: coachAvailable && !onCoachPage,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const hasDueReminders = (dueReminders?.length ?? 0) > 0;
|
||||
|
||||
const nudgedAt = status?.nudgedAt ?? null;
|
||||
const unread = isNudgeUnread(status, seenStamp);
|
||||
// The visual dot lights for either an unread nudge OR a due reminder.
|
||||
const showDot = unread || hasDueReminders;
|
||||
|
||||
// The unread dot is visual-only (`aria-hidden`) and the swapped
|
||||
// `aria-label` is not announced on mutation — a screen-reader user
|
||||
@@ -212,7 +231,7 @@ export function LayoutCoachFab() {
|
||||
type="button"
|
||||
size="icon"
|
||||
data-slot="coach-fab"
|
||||
data-unread={unread ? "true" : undefined}
|
||||
data-unread={showDot ? "true" : undefined}
|
||||
onClick={handleOpen}
|
||||
aria-label={accessibleLabel}
|
||||
title={accessibleLabel}
|
||||
@@ -257,7 +276,7 @@ export function LayoutCoachFab() {
|
||||
)}
|
||||
>
|
||||
<Sparkles className="text-background size-6" aria-hidden="true" />
|
||||
{unread ? (
|
||||
{showDot ? (
|
||||
// v1.18.6 (CCH-03) — discreet "the Coach said something" dot.
|
||||
// Deliberately NOT an alarming red (the medication-card rule:
|
||||
// status via calm signal, never an alarm tint): a small cyan
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.22 (B6) — Settings → AI "Your context: reminders" panel.
|
||||
*
|
||||
* The user-facing ledger for the durable "remind me about X" memory the Coach
|
||||
* captures inline (the `---REMEMBER---` sentinel) and the sweep resurfaces. The
|
||||
* due / surfaced reminders are highlighted at the top (the in-app surface of the
|
||||
* sweep output); the rest are listed with view + lifecycle controls so the user
|
||||
* stays in control of their stored memory:
|
||||
*
|
||||
* GET /api/coach/reminders → { data: { reminders: [...] } }
|
||||
* PATCH /api/coach/reminders/{id} {status} → confirm / done / dismiss
|
||||
* DELETE /api/coach/reminders/{id} → { data: { deleted } }
|
||||
*
|
||||
* Reads unwrap `(await res.json()).data`; every key routes through
|
||||
* `queryKeys.coachReminders()` so a mutation invalidates the list. Gated on the
|
||||
* Coach surface like the rest of the memory controls.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { BellRing, Check, Loader2, Trash2, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDateOrRelative } from "@/lib/format";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
useCoachReminders,
|
||||
useCoachReminderMutations,
|
||||
type CoachReminderDTO,
|
||||
} from "@/hooks/use-coach-reminders";
|
||||
|
||||
const DUE_STATUSES = new Set(["due", "surfaced"]);
|
||||
|
||||
export function CoachRemindersSection({
|
||||
isAuthenticated,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const query = useCoachReminders({ enabled: isAuthenticated });
|
||||
const { setStatus, remove } = useCoachReminderMutations();
|
||||
|
||||
const reminders = useMemo(() => query.data ?? [], [query.data]);
|
||||
const { due, rest } = useMemo(() => {
|
||||
const due: CoachReminderDTO[] = [];
|
||||
const rest: CoachReminderDTO[] = [];
|
||||
for (const r of reminders) {
|
||||
(DUE_STATUSES.has(r.status) ? due : rest).push(r);
|
||||
}
|
||||
return { due, rest };
|
||||
}, [reminders]);
|
||||
|
||||
const pendingId =
|
||||
(setStatus.isPending && setStatus.variables?.id) ||
|
||||
(remove.isPending && remove.variables) ||
|
||||
null;
|
||||
|
||||
const row = (r: CoachReminderDTO, highlighted: boolean) => {
|
||||
const busy = pendingId === r.id;
|
||||
const isProposed = r.status === "proposed";
|
||||
return (
|
||||
<li
|
||||
key={r.id}
|
||||
data-testid="settings-coach-reminder"
|
||||
data-status={r.status}
|
||||
className={
|
||||
highlighted
|
||||
? "border-dracula-purple/40 bg-dracula-purple/5 flex flex-col gap-2 rounded-lg border p-3"
|
||||
: "border-border bg-background flex flex-col gap-2 rounded-lg border p-3"
|
||||
}
|
||||
>
|
||||
<p className="text-sm break-words">{r.note}</p>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
{r.metric && <span className="uppercase">{r.metric}</span>}
|
||||
{r.dueAt && (
|
||||
<span>
|
||||
{t("settings.ai.coachReminders.duePrefix", {
|
||||
when: formatDateOrRelative(r.dueAt, t),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<span>{t(`settings.ai.coachReminders.status.${r.status}`)}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isProposed && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
disabled={!isAuthenticated || busy}
|
||||
data-testid="settings-coach-reminder-confirm"
|
||||
onClick={() => setStatus.mutate({ id: r.id, status: "active" })}
|
||||
>
|
||||
<Check className="size-3.5" aria-hidden />
|
||||
{t("settings.ai.coachReminders.confirm")}
|
||||
</Button>
|
||||
)}
|
||||
{r.status !== "done" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
disabled={!isAuthenticated || busy}
|
||||
data-testid="settings-coach-reminder-done"
|
||||
onClick={() => setStatus.mutate({ id: r.id, status: "done" })}
|
||||
>
|
||||
<Check className="size-3.5" aria-hidden />
|
||||
{t("settings.ai.coachReminders.markDone")}
|
||||
</Button>
|
||||
)}
|
||||
{(r.status === "due" || r.status === "surfaced") && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
disabled={!isAuthenticated || busy}
|
||||
data-testid="settings-coach-reminder-dismiss"
|
||||
onClick={() =>
|
||||
setStatus.mutate({ id: r.id, status: "dismissed" })
|
||||
}
|
||||
>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
{t("settings.ai.coachReminders.dismiss")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-destructive min-h-9"
|
||||
disabled={!isAuthenticated || busy}
|
||||
data-testid="settings-coach-reminder-delete"
|
||||
aria-label={t("settings.ai.coachReminders.deleteAria")}
|
||||
onClick={() => remove.mutate(r.id)}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<Trash2 className="size-3.5" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
as="section"
|
||||
aria-labelledby="settings-ai-coach-reminders-title"
|
||||
data-testid="settings-coach-reminders-card"
|
||||
className="space-y-4"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={BellRing}
|
||||
titleId="settings-ai-coach-reminders-title"
|
||||
title={t("settings.ai.coachReminders.title")}
|
||||
description={t("settings.ai.coachReminders.description")}
|
||||
/>
|
||||
|
||||
{query.isError && (
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{t("settings.ai.coachReminders.loadError")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!query.isError && reminders.length === 0 ? (
|
||||
<p
|
||||
data-testid="settings-coach-reminders-empty"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{t("settings.ai.coachReminders.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{due.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{t("settings.ai.coachReminders.dueHeading")}
|
||||
</h3>
|
||||
<ul className="space-y-2">{due.map((r) => row(r, true))}</ul>
|
||||
</div>
|
||||
)}
|
||||
{rest.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{t("settings.ai.coachReminders.allHeading")}
|
||||
</h3>
|
||||
<ul className="space-y-2">{rest.map((r) => row(r, false))}</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
|
||||
{t("settings.ai.coachReminders.note")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card";
|
||||
import { CoachMemorySection } from "@/components/settings/coach-memory-section";
|
||||
import { CoachRemindersSection } from "@/components/settings/coach-reminders-section";
|
||||
import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
|
||||
import { CoachPrefsSection } from "@/components/settings/coach-prefs-section";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -62,6 +63,8 @@ export function CoachSection() {
|
||||
)}
|
||||
|
||||
{coachEnabled && <CoachMemorySection isAuthenticated={authed} />}
|
||||
|
||||
{coachEnabled && <CoachRemindersSection isAuthenticated={authed} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.22 (B2/B6) — read + mutate the user's Coach episodic reminders.
|
||||
*
|
||||
* One shared hook for the in-app reminder tile/indicator and the "Your context"
|
||||
* ledger. Reads unwrap `(await res.json()).data` per the envelope convention;
|
||||
* every key routes through `queryKeys.coachReminders(status)` so a lifecycle
|
||||
* mutation invalidates the matching list. The `status` arg keys the tile read
|
||||
* (`due,surfaced`) separately from the full ledger so they never share a slot.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiDelete, apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
|
||||
export interface CoachReminderDTO {
|
||||
id: string;
|
||||
note: string;
|
||||
metric: string | null;
|
||||
triggerKind: string;
|
||||
dueAt: string | null;
|
||||
contextCue: string | null;
|
||||
status: string;
|
||||
source: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
async function fetchReminders(status?: string): Promise<CoachReminderDTO[]> {
|
||||
const qs = status ? `?status=${encodeURIComponent(status)}` : "";
|
||||
const data = await apiGet<{ reminders?: CoachReminderDTO[] } | undefined>(
|
||||
`/api/coach/reminders${qs}`,
|
||||
);
|
||||
return data?.reminders ?? [];
|
||||
}
|
||||
|
||||
/** List the caller's reminders, optionally filtered to a status set. */
|
||||
export function useCoachReminders(opts?: {
|
||||
status?: string;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const status = opts?.status;
|
||||
return useQuery({
|
||||
queryKey: queryKeys.coachReminders(status),
|
||||
queryFn: () => fetchReminders(status),
|
||||
enabled: opts?.enabled ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Lifecycle + delete mutations, invalidating every reminders list slot. */
|
||||
export function useCoachReminderMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.coachRemindersAll(),
|
||||
});
|
||||
|
||||
const setStatus = useMutation({
|
||||
mutationFn: async (args: { id: string; status: string }) => {
|
||||
await apiPatch(`/api/coach/reminders/${encodeURIComponent(args.id)}`, {
|
||||
status: args.status,
|
||||
});
|
||||
return args.id;
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiDelete(`/api/coach/reminders/${encodeURIComponent(id)}`);
|
||||
return id;
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { setStatus, remove };
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* v1.22 (B2) — Coach episodic reminder capture: grammar + sentinel + write.
|
||||
*
|
||||
* Covers the closed `when` grammar (ISO date / relative / context cue / reject),
|
||||
* the `---REMEMBER---` strip (capture, recall-only note, malformed drop, prose
|
||||
* preserved), and the cap-aware capture write.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("../bytes-codec", () => ({
|
||||
encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)),
|
||||
}));
|
||||
|
||||
import {
|
||||
resolveWhenGrammar,
|
||||
parseRememberSentinel,
|
||||
captureReminderFromSentinel,
|
||||
MAX_REMINDERS_PER_USER,
|
||||
} from "../reminders";
|
||||
|
||||
const NOW = new Date("2026-06-27T12:00:00.000Z");
|
||||
|
||||
describe("resolveWhenGrammar", () => {
|
||||
it("parses an ISO date into a date trigger at 09:00 UTC", () => {
|
||||
const r = resolveWhenGrammar("2026-07-11", NOW);
|
||||
expect(r).toEqual({
|
||||
triggerKind: "date",
|
||||
dueAt: new Date("2026-07-11T09:00:00.000Z"),
|
||||
contextCue: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a relative +Nd / +Nw offset from now", () => {
|
||||
expect(resolveWhenGrammar("+14d", NOW)?.dueAt).toEqual(
|
||||
new Date("2026-07-11T12:00:00.000Z"),
|
||||
);
|
||||
expect(resolveWhenGrammar("+2w", NOW)?.dueAt).toEqual(
|
||||
new Date("2026-07-11T12:00:00.000Z"),
|
||||
);
|
||||
});
|
||||
|
||||
it("parses a context cue into a context trigger (no dueAt)", () => {
|
||||
expect(resolveWhenGrammar("NEXT_BP_LOGGED", NOW)).toEqual({
|
||||
triggerKind: "context",
|
||||
dueAt: null,
|
||||
contextCue: "NEXT_BP_LOGGED",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an unknown / out-of-bounds token", () => {
|
||||
expect(resolveWhenGrammar("someday", NOW)).toBeNull();
|
||||
expect(resolveWhenGrammar("+0d", NOW)).toBeNull();
|
||||
expect(resolveWhenGrammar("+9999d", NOW)).toBeNull();
|
||||
expect(resolveWhenGrammar("NEXT_MOON", NOW)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRememberSentinel", () => {
|
||||
it("captures note + when + metric and strips the block from the prose", () => {
|
||||
const raw = `Got it — I'll bring that up around the 11th.
|
||||
---REMEMBER---
|
||||
note: revisit the evening-walk idea for your sleep
|
||||
when: 2026-07-11
|
||||
metric: SLEEP
|
||||
---END---`;
|
||||
const r = parseRememberSentinel(raw, NOW);
|
||||
expect(r.malformed).toBe(false);
|
||||
expect(r.prose).toBe("Got it — I'll bring that up around the 11th.");
|
||||
expect(r.reminder?.note).toBe(
|
||||
"revisit the evening-walk idea for your sleep",
|
||||
);
|
||||
expect(r.reminder?.metric).toBe("SLEEP");
|
||||
expect(r.reminder?.trigger?.dueAt).toEqual(
|
||||
new Date("2026-07-11T09:00:00.000Z"),
|
||||
);
|
||||
});
|
||||
|
||||
it("captures a recall-only note when `when` is omitted", () => {
|
||||
const raw = `Noted.
|
||||
---REMEMBER---
|
||||
note: you wanted to discuss your labs
|
||||
---END---`;
|
||||
const r = parseRememberSentinel(raw, NOW);
|
||||
expect(r.malformed).toBe(false);
|
||||
expect(r.reminder?.note).toBe("you wanted to discuss your labs");
|
||||
expect(r.reminder?.trigger).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the block (malformed) on a missing note or an invalid when, keeping prose", () => {
|
||||
const noNote = parseRememberSentinel(
|
||||
"Sure.\n---REMEMBER---\nwhen: 2026-07-11\n---END---",
|
||||
NOW,
|
||||
);
|
||||
expect(noNote.reminder).toBeNull();
|
||||
expect(noNote.malformed).toBe(true);
|
||||
expect(noNote.prose).toBe("Sure.");
|
||||
|
||||
const badWhen = parseRememberSentinel(
|
||||
"Sure.\n---REMEMBER---\nnote: x\nwhen: someday\n---END---",
|
||||
NOW,
|
||||
);
|
||||
expect(badWhen.reminder).toBeNull();
|
||||
expect(badWhen.malformed).toBe(true);
|
||||
// The raw marker is never left in the prose.
|
||||
expect(badWhen.prose).not.toContain("REMEMBER");
|
||||
});
|
||||
|
||||
it("returns the prose untouched when no block is present", () => {
|
||||
const r = parseRememberSentinel("just a normal reply", NOW);
|
||||
expect(r).toEqual({
|
||||
prose: "just a normal reply",
|
||||
reminder: null,
|
||||
malformed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureReminderFromSentinel", () => {
|
||||
function db(count: number) {
|
||||
return {
|
||||
coachReminder: {
|
||||
count: vi.fn(async () => count),
|
||||
create: vi.fn(async () => ({ id: "r1" })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("writes an active reminder field-by-field with source sentinel", async () => {
|
||||
const d = db(0);
|
||||
const id = await captureReminderFromSentinel({
|
||||
userId: "u1",
|
||||
conversationId: "c1",
|
||||
parsed: {
|
||||
note: "revisit sleep",
|
||||
trigger: {
|
||||
triggerKind: "date",
|
||||
dueAt: new Date("2026-07-11T09:00:00.000Z"),
|
||||
contextCue: null,
|
||||
},
|
||||
metric: "SLEEP",
|
||||
},
|
||||
db: d as never,
|
||||
});
|
||||
expect(id).toBe("r1");
|
||||
const createArgs = d.coachReminder.create.mock.calls[0] as unknown as [
|
||||
{
|
||||
data: {
|
||||
userId: string;
|
||||
status: string;
|
||||
source: string;
|
||||
sourceConversationId: string;
|
||||
metric: string;
|
||||
};
|
||||
},
|
||||
];
|
||||
const data = createArgs[0].data;
|
||||
expect(data.userId).toBe("u1");
|
||||
expect(data.status).toBe("active");
|
||||
expect(data.source).toBe("sentinel");
|
||||
expect(data.sourceConversationId).toBe("c1");
|
||||
expect(data.metric).toBe("SLEEP");
|
||||
});
|
||||
|
||||
it("refuses the write once the per-user cap is reached", async () => {
|
||||
const d = db(MAX_REMINDERS_PER_USER);
|
||||
const id = await captureReminderFromSentinel({
|
||||
userId: "u1",
|
||||
conversationId: "c1",
|
||||
parsed: { note: "x", trigger: null, metric: null },
|
||||
db: d as never,
|
||||
});
|
||||
expect(id).toBeNull();
|
||||
expect(d.coachReminder.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* v1.22 (F6) — generalised confirm-card sentinel parse.
|
||||
*
|
||||
* Covers the CLOSED allowlist (checkup.create / reminder.note), the
|
||||
* field-by-field resolution, and the safety property that an unknown action
|
||||
* type or a malformed block yields NO action (never auto-applies, never mints
|
||||
* an arbitrary entity) while the prose is preserved and the raw marker stripped.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
parseSuggestAction,
|
||||
isSuggestedActionType,
|
||||
isCheckupIntervalId,
|
||||
} from "../suggest-action";
|
||||
|
||||
describe("parseSuggestAction", () => {
|
||||
it("resolves a checkup.create action with a closed interval id", () => {
|
||||
const raw = `You might add an annual panel.
|
||||
---SUGGEST-ACTION---
|
||||
action: checkup.create
|
||||
label: Annual blood panel
|
||||
interval: yearly
|
||||
---END---`;
|
||||
const r = parseSuggestAction(raw);
|
||||
expect(r.malformed).toBe(false);
|
||||
expect(r.prose).toBe("You might add an annual panel.");
|
||||
expect(r.action?.actionType).toBe("checkup.create");
|
||||
expect(r.action?.summary).toBe("Annual blood panel");
|
||||
expect(r.action?.params).toEqual({
|
||||
actionType: "checkup.create",
|
||||
label: "Annual blood panel",
|
||||
interval: "yearly",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a reminder.note action with the closed when grammar token", () => {
|
||||
const raw = `---SUGGEST-ACTION---
|
||||
action: reminder.note
|
||||
note: revisit the evening-walk effect on sleep
|
||||
when: +14d
|
||||
metric: SLEEP
|
||||
---END---`;
|
||||
const r = parseSuggestAction(raw);
|
||||
expect(r.action?.params).toEqual({
|
||||
actionType: "reminder.note",
|
||||
note: "revisit the evening-walk effect on sleep",
|
||||
when: "+14d",
|
||||
metric: "SLEEP",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops an unknown / off-allowlist action type (no entity, prose kept)", () => {
|
||||
const raw = `ok.
|
||||
---SUGGEST-ACTION---
|
||||
action: medication.create
|
||||
label: start a new drug
|
||||
interval: monthly
|
||||
---END---`;
|
||||
const r = parseSuggestAction(raw);
|
||||
expect(r.action).toBeNull();
|
||||
expect(r.malformed).toBe(true);
|
||||
expect(r.prose).toBe("ok.");
|
||||
expect(r.prose).not.toContain("SUGGEST-ACTION");
|
||||
});
|
||||
|
||||
it("drops a checkup with an unknown interval id", () => {
|
||||
const raw = `---SUGGEST-ACTION---
|
||||
action: checkup.create
|
||||
label: x
|
||||
interval: hourly
|
||||
---END---`;
|
||||
const r = parseSuggestAction(raw);
|
||||
expect(r.action).toBeNull();
|
||||
expect(r.malformed).toBe(true);
|
||||
});
|
||||
|
||||
it("passes the prose through untouched when no block is present", () => {
|
||||
const r = parseSuggestAction("a normal reply");
|
||||
expect(r).toEqual({
|
||||
prose: "a normal reply",
|
||||
action: null,
|
||||
malformed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("closed-allowlist guards reject anything off-list", () => {
|
||||
expect(isSuggestedActionType("checkup.create")).toBe(true);
|
||||
expect(isSuggestedActionType("reminder.note")).toBe(true);
|
||||
expect(isSuggestedActionType("medication.create")).toBe(false);
|
||||
expect(isCheckupIntervalId("yearly")).toBe(true);
|
||||
expect(isCheckupIntervalId("hourly")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,12 @@ import {
|
||||
} from "@/lib/insights/narrative/period-narrative";
|
||||
import type { BaselineProfile } from "@/lib/insights/derived";
|
||||
import { buildCoachFactsBlock } from "./facts";
|
||||
import { buildCoachPlansBlock, type CoachPlanInjectEntry } from "./plans";
|
||||
import {
|
||||
buildCoachPlansBlock,
|
||||
buildCoachRemindersBlock,
|
||||
type CoachPlanInjectEntry,
|
||||
type CoachReminderInjectEntry,
|
||||
} from "./plans";
|
||||
|
||||
/** The period the rolling profile recalls — month is the high-signal beat. */
|
||||
const MEMORY_PERIOD: NarrativePeriod = "month";
|
||||
@@ -85,6 +90,13 @@ export interface CoachMemoryBlock {
|
||||
* still awaiting the user's confirmation. Absent when none.
|
||||
*/
|
||||
plans?: CoachPlanInjectEntry[];
|
||||
/**
|
||||
* v1.22 (B2/B3) — the user's near/overdue or already-surfaced episodic
|
||||
* reminders ("remind me about X"), top-N soonest first. Lets the Coach
|
||||
* reference them unprompted next turn — the progress-reflection reflex.
|
||||
* Absent when none.
|
||||
*/
|
||||
reminders?: CoachReminderInjectEntry[];
|
||||
}
|
||||
|
||||
/** Pull the headline + driver recall off the latest period narrative. */
|
||||
@@ -185,11 +197,25 @@ export async function buildCoachMemoryBlock(
|
||||
plans = undefined;
|
||||
}
|
||||
|
||||
// Sub-source 5 (v1.22 B2/B3): the user's near/overdue or surfaced episodic
|
||||
// reminders. Fault-isolated like the others; the read half lives in plans.ts
|
||||
// alongside the plan-injection block.
|
||||
let reminders: CoachReminderInjectEntry[] | undefined;
|
||||
try {
|
||||
const remindersBlock = await buildCoachRemindersBlock(userId, now);
|
||||
if (remindersBlock && remindersBlock.reminders.length > 0) {
|
||||
reminders = remindersBlock.reminders;
|
||||
}
|
||||
} catch {
|
||||
reminders = undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
!priorNarrative &&
|
||||
Object.keys(trendMemory).length === 0 &&
|
||||
!facts &&
|
||||
!plans
|
||||
!plans &&
|
||||
!reminders
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -198,5 +224,6 @@ export async function buildCoachMemoryBlock(
|
||||
if (priorNarrative) block.priorNarrative = priorNarrative;
|
||||
if (facts) block.facts = facts;
|
||||
if (plans) block.plans = plans;
|
||||
if (reminders) block.reminders = reminders;
|
||||
return block;
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
import { decryptFromBytes, encryptToBytes } from "./bytes-codec";
|
||||
import { REMINDERS_INJECT_TOP_N } from "./reminders";
|
||||
|
||||
/** App-side closed status enum (NOT a DB enum — matches the schema column). */
|
||||
export const COACH_PLAN_STATUSES = [
|
||||
@@ -60,6 +61,7 @@ const RECENT_TURNS_CAP = 10;
|
||||
|
||||
type RunCompletionFn = typeof runStatusCompletion;
|
||||
type PrismaLike = Pick<typeof prisma, "coachPlan" | "coachConversation">;
|
||||
type ReminderPrismaLike = Pick<typeof prisma, "coachReminder">;
|
||||
|
||||
interface ExtractOpts {
|
||||
runCompletion?: RunCompletionFn;
|
||||
@@ -462,3 +464,94 @@ export async function buildCoachPlansBlock(
|
||||
if (plans.length === 0) return null;
|
||||
return { plans };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v1.22 (B2/B3) — episodic reminder recall sub-block
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* How far ahead of `now` an active reminder enters the recall block. A
|
||||
* reminder due within this horizon (or already overdue) is "near" enough that
|
||||
* the Coach should reference it unprompted on the next message; a reminder due
|
||||
* far in the future stays dormant until its window approaches.
|
||||
*/
|
||||
const REMINDER_RECALL_HORIZON_DAYS = 3;
|
||||
const REMINDER_RECALL_HORIZON_MS = REMINDER_RECALL_HORIZON_DAYS * 86_400_000;
|
||||
|
||||
/** One reminder as the snapshot recall block carries it. */
|
||||
export interface CoachReminderInjectEntry {
|
||||
note: string;
|
||||
metric?: string;
|
||||
/** ISO due moment, when the reminder is date-triggered. */
|
||||
dueAt?: string;
|
||||
/** Lifecycle status (active | due | surfaced) for the model's framing. */
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface InjectReminderRow {
|
||||
noteEncrypted: Uint8Array;
|
||||
metric: string | null;
|
||||
dueAt: Date | null;
|
||||
status: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the recall sub-block: the reminders the Coach should reference next
|
||||
* time — anything already `due`/`surfaced` (the sweep flipped it and the user
|
||||
* has not resolved it) plus `active` reminders whose `dueAt` is near or overdue.
|
||||
* A recall-only `active` note (no `dueAt`) is also surfaced so the Coach honours
|
||||
* the user's explicit "remember this" even without a fire moment. Decrypt is
|
||||
* fault-isolated — an undecryptable row is skipped, never thrown. Returns `null`
|
||||
* when the user has nothing to recall.
|
||||
*
|
||||
* This is the read half of the episodic memory type, mirroring
|
||||
* `buildCoachPlansBlock`; the write half lives in `reminders.ts`.
|
||||
*/
|
||||
export async function buildCoachRemindersBlock(
|
||||
userId: string,
|
||||
now: Date,
|
||||
opts?: { prisma?: ReminderPrismaLike },
|
||||
): Promise<{ reminders: CoachReminderInjectEntry[] } | null> {
|
||||
const db = opts?.prisma ?? prisma;
|
||||
const horizon = new Date(now.getTime() + REMINDER_RECALL_HORIZON_MS);
|
||||
|
||||
const rows = (await db.coachReminder.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
OR: [
|
||||
// Already surfaced by the sweep and not yet resolved.
|
||||
{ status: { in: ["due", "surfaced"] } },
|
||||
// Active + near/overdue date trigger.
|
||||
{ status: "active", dueAt: { lte: horizon } },
|
||||
// Active recall-only note (no fire moment) — honour the explicit ask.
|
||||
{ status: "active", dueAt: null },
|
||||
],
|
||||
},
|
||||
// Soonest-due first, then most-recently touched.
|
||||
orderBy: [{ dueAt: "asc" }, { updatedAt: "desc" }],
|
||||
take: REMINDERS_INJECT_TOP_N * 3,
|
||||
select: {
|
||||
noteEncrypted: true,
|
||||
metric: true,
|
||||
dueAt: true,
|
||||
status: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})) as InjectReminderRow[];
|
||||
|
||||
const reminders: CoachReminderInjectEntry[] = [];
|
||||
for (const r of rows) {
|
||||
if (reminders.length >= REMINDERS_INJECT_TOP_N) break;
|
||||
const note = decryptOrNull(r.noteEncrypted);
|
||||
if (note === null) continue;
|
||||
const entry: CoachReminderInjectEntry = { note, status: r.status };
|
||||
if (r.metric) entry.metric = r.metric;
|
||||
if (r.dueAt) entry.dueAt = r.dueAt.toISOString();
|
||||
reminders.push(entry);
|
||||
}
|
||||
|
||||
if (reminders.length === 0) return null;
|
||||
return { reminders };
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* v1.22 (B2/B6) — Coach episodic reminder memory: capture + grammar.
|
||||
*
|
||||
* The missing memory type between `CoachFact` (durable traits) and `CoachPlan`
|
||||
* (standing if-then habits): a time-anchored "bring this back later" intent the
|
||||
* user explicitly asked the Coach to remember ("remind me about my sleep next
|
||||
* week"). Captured inline on EVERY turn via the provider-neutral
|
||||
* `---REMEMBER---` sentinel — so a casual "remember this" in a SHORT chat is no
|
||||
* longer lost waiting for the >20-turn memory worker.
|
||||
*
|
||||
* Friction tier (matches the design): a plain note the user EXPLICITLY asked
|
||||
* for is captured silently as `status: "active"` — confirming "should I remember
|
||||
* what you just told me to remember?" is the annoying anti-pattern. The
|
||||
* propose→confirm rule stays where it belongs: durable traits/plans the model
|
||||
* INFERRED (those still flow through the extractor + a confirm card).
|
||||
*
|
||||
* Defence-in-depth mirrors `suggest-reminder.ts` / `keyvalues.ts`: a byte cap on
|
||||
* the payload, a single block kept, the `when` validated against a CLOSED
|
||||
* grammar (ISO date / relative `+Nd`-`+Nw` / a context-cue enum), the metric
|
||||
* validated against a loose key pattern, and a malformed block dropped (the user
|
||||
* never sees the raw marker). The note TEXT is encrypted via `bytes-codec.ts`
|
||||
* (the same AES-256-GCM codec as `CoachPlan`); `metric`, `status`, the trigger
|
||||
* fields and the dates stay plain so the sweep + picker filter without a decrypt.
|
||||
*
|
||||
* Server-only — the capture path reads `@/lib/db`.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
import { encryptToBytes } from "./bytes-codec";
|
||||
|
||||
/** App-side closed status enum (NOT a DB enum — matches the schema column). */
|
||||
export const COACH_REMINDER_STATUSES = [
|
||||
"proposed",
|
||||
"active",
|
||||
"due",
|
||||
"surfaced",
|
||||
"done",
|
||||
"dismissed",
|
||||
] as const;
|
||||
export type CoachReminderStatus = (typeof COACH_REMINDER_STATUSES)[number];
|
||||
|
||||
/** App-side closed trigger-kind enum (matches the schema column). */
|
||||
export const COACH_REMINDER_TRIGGER_KINDS = ["date", "context"] as const;
|
||||
export type CoachReminderTriggerKind =
|
||||
(typeof COACH_REMINDER_TRIGGER_KINDS)[number];
|
||||
|
||||
/**
|
||||
* Closed context-cue enum. A `context` reminder fires when the cue is satisfied
|
||||
* (evaluated by the daily sweep against persisted rows). The set is deliberately
|
||||
* small and deterministic — "next time you log X" / "next time you open the app".
|
||||
*/
|
||||
export const COACH_REMINDER_CONTEXT_CUES = [
|
||||
"NEXT_BP_LOGGED",
|
||||
"NEXT_WEIGHT_LOGGED",
|
||||
"NEXT_SLEEP_LOGGED",
|
||||
"NEXT_APP_OPEN",
|
||||
] as const;
|
||||
export type CoachReminderContextCue =
|
||||
(typeof COACH_REMINDER_CONTEXT_CUES)[number];
|
||||
|
||||
/** Provenance of a reminder row (matches the schema `source` column). */
|
||||
export const COACH_REMINDER_SOURCES = [
|
||||
"sentinel",
|
||||
"extractor",
|
||||
"manual",
|
||||
"action",
|
||||
] as const;
|
||||
export type CoachReminderSource = (typeof COACH_REMINDER_SOURCES)[number];
|
||||
|
||||
/** Per-field caps (mirror the Zod gate + the prompt instruction). */
|
||||
export const REMINDER_NOTE_MAX_CHARS = 280;
|
||||
export const REMINDER_METRIC_MAX_CHARS = 60;
|
||||
/** Hard cap on non-terminal reminders per user (active + proposed + due/surfaced). */
|
||||
export const MAX_REMINDERS_PER_USER = 50;
|
||||
/** How many reminders the recall sub-block carries into the snapshot. */
|
||||
export const REMINDERS_INJECT_TOP_N = 6;
|
||||
/** Bounds on the relative-`+Nd` grammar (1 day .. 1 year out). */
|
||||
const MIN_RELATIVE_DAYS = 1;
|
||||
const MAX_RELATIVE_DAYS = 366;
|
||||
|
||||
const OPEN_SENTINEL = "---REMEMBER---";
|
||||
const CLOSE_SENTINEL = "---END---";
|
||||
/** Hard cap on the sentinel payload before parsing (prompt-injection guard). */
|
||||
const SENTINEL_BYTE_CAP = 1024;
|
||||
|
||||
/** A loose metric key the model may name ("SLEEP", "BLOOD_PRESSURE"). */
|
||||
const METRIC_KEY_RE = /^[A-Za-z][A-Za-z0-9_]*$/;
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const RELATIVE_RE = /^\+(\d{1,3})([dw])$/i;
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** The resolved trigger a valid `when` grammar maps onto. */
|
||||
export interface ResolvedTrigger {
|
||||
triggerKind: CoachReminderTriggerKind;
|
||||
dueAt: Date | null;
|
||||
contextCue: CoachReminderContextCue | null;
|
||||
}
|
||||
|
||||
function isContextCue(value: string): value is CoachReminderContextCue {
|
||||
return (COACH_REMINDER_CONTEXT_CUES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the closed `when` grammar into a trigger, or `null` when the token is
|
||||
* unparseable / out of bounds (the caller drops the reminder). Accepts:
|
||||
* - an ISO calendar date `YYYY-MM-DD` → date trigger, dueAt 09:00 UTC that day
|
||||
* - a relative `+Nd` / `+Nw` → date trigger, dueAt now + N days/weeks
|
||||
* - a context-cue token from the enum → context trigger, dueAt null
|
||||
*/
|
||||
export function resolveWhenGrammar(
|
||||
whenRaw: string,
|
||||
now: Date,
|
||||
): ResolvedTrigger | null {
|
||||
const when = whenRaw.trim();
|
||||
if (!when) return null;
|
||||
|
||||
if (ISO_DATE_RE.test(when)) {
|
||||
// Anchor at 09:00 UTC on the named day — a deterministic, timezone-stable
|
||||
// fire moment for the daily sweep (push delivery + per-tz receptivity land
|
||||
// in a later wave; this is in-app surfacing only).
|
||||
const dueAt = new Date(`${when}T09:00:00.000Z`);
|
||||
if (Number.isNaN(dueAt.getTime())) return null;
|
||||
return { triggerKind: "date", dueAt, contextCue: null };
|
||||
}
|
||||
|
||||
const rel = RELATIVE_RE.exec(when);
|
||||
if (rel) {
|
||||
const n = Number.parseInt(rel[1], 10);
|
||||
const unit = rel[2].toLowerCase();
|
||||
const days = unit === "w" ? n * 7 : n;
|
||||
if (days < MIN_RELATIVE_DAYS || days > MAX_RELATIVE_DAYS) return null;
|
||||
return {
|
||||
triggerKind: "date",
|
||||
dueAt: new Date(now.getTime() + days * MS_PER_DAY),
|
||||
contextCue: null,
|
||||
};
|
||||
}
|
||||
|
||||
const cue = when.toUpperCase();
|
||||
if (isContextCue(cue)) {
|
||||
return { triggerKind: "context", dueAt: null, contextCue: cue };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The fields a valid `---REMEMBER---` block yields. */
|
||||
export interface ParsedReminderSentinel {
|
||||
note: string;
|
||||
/** Resolved trigger when a valid `when` was given; null = a recall-only note. */
|
||||
trigger: ResolvedTrigger | null;
|
||||
metric: string | null;
|
||||
}
|
||||
|
||||
export interface RememberParseResult {
|
||||
/** The reply with the block stripped (run AFTER the other sentinels). */
|
||||
prose: string;
|
||||
reminder: ParsedReminderSentinel | null;
|
||||
/** True when an opening sentinel was seen but yielded no valid reminder. */
|
||||
malformed: boolean;
|
||||
}
|
||||
|
||||
/** Pull one `key: value` line out of the block body (first match wins). */
|
||||
function fieldLine(body: string, key: string): string | null {
|
||||
for (const rawLine of body.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const colon = line.indexOf(":");
|
||||
if (colon < 1) continue;
|
||||
if (line.slice(0, colon).trim().toLowerCase() !== key) continue;
|
||||
const value = line
|
||||
.slice(colon + 1)
|
||||
.trim()
|
||||
// Strip a trailing inline comment + surrounding quotes/backticks.
|
||||
.replace(/\s+#.*$/, "")
|
||||
.replace(/^["'`]|["'`]$/g, "")
|
||||
.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the `---REMEMBER---` block out of a reply and resolve its fields. Run
|
||||
* AFTER the KEYVALUES + SUGGEST-REMINDER strips so the prose it receives is
|
||||
* already free of those blocks. A missing `note`, or a present-but-unparseable
|
||||
* `when`, drops the reminder (`malformed: true`) while still removing the raw
|
||||
* marker from the prose.
|
||||
*/
|
||||
export function parseRememberSentinel(
|
||||
raw: string,
|
||||
now: Date,
|
||||
): RememberParseResult {
|
||||
if (!raw) return { prose: "", reminder: null, malformed: false };
|
||||
|
||||
const openIdx = raw.indexOf(OPEN_SENTINEL);
|
||||
if (openIdx === -1) return { prose: raw, reminder: null, malformed: false };
|
||||
|
||||
const before = raw.slice(0, openIdx);
|
||||
const afterOpen = raw.slice(openIdx + OPEN_SENTINEL.length);
|
||||
const closeRel = afterOpen.indexOf(CLOSE_SENTINEL);
|
||||
const bodyRaw = closeRel === -1 ? afterOpen : afterOpen.slice(0, closeRel);
|
||||
const body =
|
||||
bodyRaw.length > SENTINEL_BYTE_CAP
|
||||
? bodyRaw.slice(0, SENTINEL_BYTE_CAP)
|
||||
: bodyRaw;
|
||||
const after =
|
||||
closeRel === -1 ? "" : afterOpen.slice(closeRel + CLOSE_SENTINEL.length);
|
||||
const prose = `${before}${after}`.replace(/^\s+|\s+$/gu, "");
|
||||
|
||||
const noteRaw = fieldLine(body, "note");
|
||||
if (!noteRaw) return { prose, reminder: null, malformed: true };
|
||||
const note = noteRaw.slice(0, REMINDER_NOTE_MAX_CHARS).trim();
|
||||
if (!note) return { prose, reminder: null, malformed: true };
|
||||
|
||||
// `when` is optional. Present-but-invalid is a malformed drop; absent is a
|
||||
// recall-only note (active, no due moment).
|
||||
const whenRaw = fieldLine(body, "when");
|
||||
let trigger: ResolvedTrigger | null = null;
|
||||
if (whenRaw) {
|
||||
trigger = resolveWhenGrammar(whenRaw, now);
|
||||
if (!trigger) return { prose, reminder: null, malformed: true };
|
||||
}
|
||||
|
||||
let metric: string | null = null;
|
||||
const metricRaw = fieldLine(body, "metric");
|
||||
if (metricRaw && METRIC_KEY_RE.test(metricRaw)) {
|
||||
metric = metricRaw.slice(0, REMINDER_METRIC_MAX_CHARS).toUpperCase();
|
||||
}
|
||||
|
||||
return { prose, reminder: { note, trigger, metric }, malformed: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* The one system-prompt clause that teaches the model to emit `---REMEMBER---`.
|
||||
* Appended to the assembled Coach system prompt in the chat route (the canonical
|
||||
* system-prompt module is owned elsewhere). Provider-neutral; EN + DE only (the
|
||||
* Coach reasons in de/en).
|
||||
*/
|
||||
export function buildRememberAddendum(locale: string | undefined): string {
|
||||
if (locale?.toLowerCase().startsWith("de")) {
|
||||
return `WENN die Person dich bittet, dir etwas zu merken oder sie später daran zu erinnern, hänge GENAU EINEN Block ans Ende deiner Antwort:
|
||||
---REMEMBER---
|
||||
note: <kurz, in der Formulierung der Person, worum es geht>
|
||||
when: <optional: ISO-Datum JJJJ-MM-TT | relativ +Nd / +Nw | ein Kontext-Cue: NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED, NEXT_SLEEP_LOGGED, NEXT_APP_OPEN>
|
||||
metric: <optional, z. B. SLEEP, BLOOD_PRESSURE>
|
||||
---END---
|
||||
Erfinde NIEMALS eine Erinnerung, um die die Person nicht ausdrücklich gebeten hat. Bestätige kurz im Fließtext ("Notiert — ich komme darauf zurück."). Lass "when" weg, wenn keine Zeit genannt wurde.`;
|
||||
}
|
||||
return `WHEN the user asks you to remember something or remind them later, append EXACTLY ONE block at the end of your reply:
|
||||
---REMEMBER---
|
||||
note: <short, in the user's own framing, what to bring back>
|
||||
when: <optional: ISO date YYYY-MM-DD | relative +Nd / +Nw | a context cue: NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED, NEXT_SLEEP_LOGGED, NEXT_APP_OPEN>
|
||||
metric: <optional, e.g. SLEEP, BLOOD_PRESSURE>
|
||||
---END---
|
||||
NEVER invent a reminder the user did not explicitly ask for. Acknowledge briefly in prose ("Got it — I'll bring that up again."). Omit "when" if no time was stated.`;
|
||||
}
|
||||
|
||||
type PrismaLike = Pick<typeof prisma, "coachReminder">;
|
||||
|
||||
/**
|
||||
* Persist a sentinel-captured reminder field-by-field (no mass assignment),
|
||||
* always `status: "active"` (the user explicitly asked — no confirm gate). The
|
||||
* per-user cap refuses a write once the non-terminal set is full. Best-effort:
|
||||
* a failure never breaks the chat turn (the caller fires it fire-and-forget).
|
||||
*
|
||||
* Returns the created id, or `null` when the cap displaced it / a write failed.
|
||||
*/
|
||||
export async function captureReminderFromSentinel(args: {
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
parsed: ParsedReminderSentinel;
|
||||
now?: Date;
|
||||
db?: PrismaLike;
|
||||
}): Promise<string | null> {
|
||||
const db = args.db ?? prisma;
|
||||
const { userId, conversationId, parsed } = args;
|
||||
|
||||
const nonTerminal = await db.coachReminder.count({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
status: { in: ["proposed", "active", "due", "surfaced"] },
|
||||
},
|
||||
});
|
||||
if (nonTerminal >= MAX_REMINDERS_PER_USER) {
|
||||
annotate({
|
||||
action: { name: "coach.reminder.capture_capped" },
|
||||
meta: { conversationId },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const created = await db.coachReminder.create({
|
||||
data: {
|
||||
userId,
|
||||
noteEncrypted: encryptToBytes(parsed.note),
|
||||
metric: parsed.metric,
|
||||
triggerKind: parsed.trigger?.triggerKind ?? "date",
|
||||
dueAt: parsed.trigger?.dueAt ?? null,
|
||||
contextCue: parsed.trigger?.contextCue ?? null,
|
||||
status: "active",
|
||||
source: "sentinel",
|
||||
sourceConversationId: conversationId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.reminder.captured" },
|
||||
meta: {
|
||||
conversationId,
|
||||
triggerKind: parsed.trigger?.triggerKind ?? "none",
|
||||
hasDue: parsed.trigger?.dueAt != null,
|
||||
},
|
||||
});
|
||||
return created.id;
|
||||
}
|
||||
@@ -2558,11 +2558,21 @@ function degradeToBudget(
|
||||
block && Array.isArray(block.plans) && block.plans.length > 0
|
||||
? block.plans
|
||||
: null;
|
||||
// v1.22 (B2/B3) — the episodic reminders survive the drop alongside
|
||||
// facts/plans: they are the user's own "remember this" asks, tiny by
|
||||
// construction (top-6, ≤280 chars each), and shedding them would make the
|
||||
// Coach forget a reminder on exactly the data-heavy accounts that hit the
|
||||
// char cap.
|
||||
const reminders =
|
||||
block && Array.isArray(block.reminders) && block.reminders.length > 0
|
||||
? block.reminders
|
||||
: null;
|
||||
const survivors: Record<string, unknown> = {
|
||||
omitted: "trimmed for prompt budget",
|
||||
};
|
||||
if (facts) survivors.facts = facts;
|
||||
if (plans) survivors.plans = plans;
|
||||
if (reminders) survivors.reminders = reminders;
|
||||
snapshot[key] = survivors;
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* v1.22 (F6) — generalised inline "confirm → apply" Coach action cards.
|
||||
*
|
||||
* The propose→confirm moat already ships for ONE action type — the cadence
|
||||
* reminder (`suggest-reminder.ts` CADENCE_CATALOG → `suggestion` SSE frame →
|
||||
* `reminder-suggestion-card.tsx` → `POST /api/coach/reminder-suggestions`).
|
||||
* This module generalises it: the Coach may append a `---SUGGEST-ACTION---`
|
||||
* block naming ONE action type from a CLOSED allowlist plus bounded,
|
||||
* server-resolvable params. The route strips it, validates it, and emits an
|
||||
* additive `suggestedAction` SSE frame the client renders as a one-tap confirm
|
||||
* card. Confirm POSTs to `POST /api/coach/suggested-actions`, which builds the
|
||||
* entity FIELD-BY-FIELD from a server-resolved spec.
|
||||
*
|
||||
* HARD SAFETY (matches the design §3.6):
|
||||
* - NEVER auto-apply. The frame is additive; the prose stands alone if
|
||||
* ignored. Only the user's confirm tap creates anything.
|
||||
* - CLOSED allowlist only. The model names an action type + bounded params;
|
||||
* it can never mint an arbitrary entity or widen a schedule (the interval
|
||||
* is a closed preset id resolved on the server, never a free rrule).
|
||||
* - NO clinical / medication changes via this path. The allowlist excludes
|
||||
* anything that creates/edits a medication, a dose, or a clinical target.
|
||||
*
|
||||
* Defence-in-depth mirrors the other sentinels: a byte cap, a single block, the
|
||||
* fields validated, a malformed block dropped (the user never sees the marker).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The system-prompt clause teaching the model to emit `---SUGGEST-ACTION---`.
|
||||
* Appended to the assembled Coach system prompt in the chat route. Provider-
|
||||
* neutral; EN + DE only. Names ONLY the closed allowlist — never medication or
|
||||
* clinical changes.
|
||||
*/
|
||||
export function buildSuggestActionAddendum(locale: string | undefined): string {
|
||||
if (locale?.toLowerCase().startsWith("de")) {
|
||||
return `WENN eine deiner Empfehlungen mit einem Tipp umsetzbar ist, biete sie als bestätigbare Aktion an — hänge GENAU EINEN Block ans Ende deiner Antwort. NUR diese geschlossene Liste, NIEMALS Medikamente oder klinische Ziele:
|
||||
---SUGGEST-ACTION---
|
||||
action: checkup.create # eine Vorsorge-/Check-up-Erinnerung
|
||||
label: <kurzer Titel, z. B. Jährliches Blutbild>
|
||||
interval: <yearly | halfYearly | quarterly | monthly>
|
||||
---END---
|
||||
oder
|
||||
---SUGGEST-ACTION---
|
||||
action: reminder.note # eine Erinnerung, auf etwas zurückzukommen
|
||||
note: <kurz, worum es geht>
|
||||
when: <optional: ISO-Datum | +Nd / +Nw | NEXT_BP_LOGGED …>
|
||||
metric: <optional>
|
||||
---END---
|
||||
Wende NIEMALS etwas selbst an — die Person bestätigt mit einem Tipp. Schlage höchstens eine Aktion pro Antwort vor und nur, wenn sie klar hilfreich ist.`;
|
||||
}
|
||||
return `WHEN one of your recommendations is actionable with a reminder, offer it as a confirmable action — append EXACTLY ONE block at the end of your reply. ONLY this closed allowlist, NEVER a medication or clinical target:
|
||||
---SUGGEST-ACTION---
|
||||
action: checkup.create # a preventive-care / check-up reminder
|
||||
label: <short title, e.g. Annual blood panel>
|
||||
interval: <yearly | halfYearly | quarterly | monthly>
|
||||
---END---
|
||||
or
|
||||
---SUGGEST-ACTION---
|
||||
action: reminder.note # a reminder to revisit something
|
||||
note: <short, what to bring back>
|
||||
when: <optional: ISO date | +Nd / +Nw | NEXT_BP_LOGGED …>
|
||||
metric: <optional>
|
||||
---END---
|
||||
NEVER apply anything yourself — the user confirms with a tap. Suggest at most one action per reply, and only when it is clearly helpful.`;
|
||||
}
|
||||
|
||||
const OPEN_SENTINEL = "---SUGGEST-ACTION---";
|
||||
const CLOSE_SENTINEL = "---END---";
|
||||
const SENTINEL_BYTE_CAP = 1024;
|
||||
|
||||
/** The CLOSED action-type allowlist. NEVER add a clinical / medication type. */
|
||||
export const SUGGESTED_ACTION_TYPES = [
|
||||
"checkup.create",
|
||||
"reminder.note",
|
||||
] as const;
|
||||
export type SuggestedActionType = (typeof SUGGESTED_ACTION_TYPES)[number];
|
||||
|
||||
export function isSuggestedActionType(v: string): v is SuggestedActionType {
|
||||
return (SUGGESTED_ACTION_TYPES as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
/** Field caps shared with the route Zod gate. */
|
||||
export const CHECKUP_LABEL_MAX_CHARS = 120;
|
||||
export const ACTION_NOTE_MAX_CHARS = 280;
|
||||
export const ACTION_METRIC_MAX_CHARS = 60;
|
||||
|
||||
/**
|
||||
* Closed cadence catalog for `checkup.create`. The model names ONLY a preset
|
||||
* id; the server owns the RRULE so the model cannot mint an arbitrary schedule.
|
||||
* A preventive-care item is, by nature, infrequent — the set is deliberately
|
||||
* coarse (monthly at the tightest).
|
||||
*/
|
||||
export const CHECKUP_INTERVAL_CATALOG: Readonly<
|
||||
Record<string, { id: string; rrule: string; labelKey: string }>
|
||||
> = Object.freeze({
|
||||
yearly: {
|
||||
id: "yearly",
|
||||
rrule: "FREQ=YEARLY;INTERVAL=1",
|
||||
labelKey: "coach.suggestedAction.interval.yearly",
|
||||
},
|
||||
halfYearly: {
|
||||
id: "halfYearly",
|
||||
rrule: "FREQ=MONTHLY;INTERVAL=6",
|
||||
labelKey: "coach.suggestedAction.interval.halfYearly",
|
||||
},
|
||||
quarterly: {
|
||||
id: "quarterly",
|
||||
rrule: "FREQ=MONTHLY;INTERVAL=3",
|
||||
labelKey: "coach.suggestedAction.interval.quarterly",
|
||||
},
|
||||
monthly: {
|
||||
id: "monthly",
|
||||
rrule: "FREQ=MONTHLY;INTERVAL=1",
|
||||
labelKey: "coach.suggestedAction.interval.monthly",
|
||||
},
|
||||
});
|
||||
export type CheckupIntervalId = keyof typeof CHECKUP_INTERVAL_CATALOG;
|
||||
|
||||
export function isCheckupIntervalId(v: string): v is CheckupIntervalId {
|
||||
return Object.prototype.hasOwnProperty.call(CHECKUP_INTERVAL_CATALOG, v);
|
||||
}
|
||||
|
||||
/**
|
||||
* The validated params the SSE frame carries + the confirm route accepts. A
|
||||
* discriminated union keyed on `actionType`, so the route switch is exhaustive.
|
||||
*/
|
||||
export type SuggestedActionParams =
|
||||
| { actionType: "checkup.create"; label: string; interval: CheckupIntervalId }
|
||||
| {
|
||||
actionType: "reminder.note";
|
||||
note: string;
|
||||
when?: string;
|
||||
metric?: string;
|
||||
};
|
||||
|
||||
/** The DTO the `suggestedAction` SSE frame carries (additive; older clients drop it). */
|
||||
export interface CoachSuggestedAction {
|
||||
actionType: SuggestedActionType;
|
||||
/** Plain-text preview of what will be created (the model's own content). */
|
||||
summary: string;
|
||||
/** i18n key for the action-type heading on the card. */
|
||||
titleKey: string;
|
||||
params: SuggestedActionParams;
|
||||
}
|
||||
|
||||
const METRIC_KEY_RE = /^[A-Za-z][A-Za-z0-9_]*$/;
|
||||
|
||||
export interface SuggestActionParseResult {
|
||||
prose: string;
|
||||
action: CoachSuggestedAction | null;
|
||||
malformed: boolean;
|
||||
}
|
||||
|
||||
/** Pull one `key: value` line out of the block body (first match wins). */
|
||||
function fieldLine(body: string, key: string): string | null {
|
||||
for (const rawLine of body.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const colon = line.indexOf(":");
|
||||
if (colon < 1) continue;
|
||||
if (line.slice(0, colon).trim().toLowerCase() !== key) continue;
|
||||
const value = line
|
||||
.slice(colon + 1)
|
||||
.trim()
|
||||
.replace(/\s+#.*$/, "")
|
||||
.replace(/^["'`]|["'`]$/g, "")
|
||||
.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolve the per-action params from the block body; null = drop (malformed). */
|
||||
function resolveActionParams(
|
||||
actionType: SuggestedActionType,
|
||||
body: string,
|
||||
): { params: SuggestedActionParams; summary: string; titleKey: string } | null {
|
||||
if (actionType === "checkup.create") {
|
||||
const labelRaw = fieldLine(body, "label");
|
||||
const intervalRaw = fieldLine(body, "interval");
|
||||
if (!labelRaw || !intervalRaw) return null;
|
||||
const label = labelRaw.slice(0, CHECKUP_LABEL_MAX_CHARS).trim();
|
||||
if (!label) return null;
|
||||
if (!isCheckupIntervalId(intervalRaw)) return null;
|
||||
return {
|
||||
params: { actionType, label, interval: intervalRaw },
|
||||
summary: label,
|
||||
titleKey: "coach.suggestedAction.checkup.title",
|
||||
};
|
||||
}
|
||||
|
||||
// reminder.note
|
||||
const noteRaw = fieldLine(body, "note");
|
||||
if (!noteRaw) return null;
|
||||
const note = noteRaw.slice(0, ACTION_NOTE_MAX_CHARS).trim();
|
||||
if (!note) return null;
|
||||
const params: SuggestedActionParams = { actionType: "reminder.note", note };
|
||||
const whenRaw = fieldLine(body, "when");
|
||||
if (whenRaw) params.when = whenRaw.slice(0, 32);
|
||||
const metricRaw = fieldLine(body, "metric");
|
||||
if (metricRaw && METRIC_KEY_RE.test(metricRaw)) {
|
||||
params.metric = metricRaw.slice(0, ACTION_METRIC_MAX_CHARS).toUpperCase();
|
||||
}
|
||||
return {
|
||||
params,
|
||||
summary: note,
|
||||
titleKey: "coach.suggestedAction.note.title",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the `---SUGGEST-ACTION---` block and resolve it into a closed action.
|
||||
* Run AFTER the other sentinel strips. An unknown action type, or a missing /
|
||||
* invalid param, drops the action (`malformed: true`) while still removing the
|
||||
* raw marker from the prose.
|
||||
*/
|
||||
export function parseSuggestAction(raw: string): SuggestActionParseResult {
|
||||
if (!raw) return { prose: "", action: null, malformed: false };
|
||||
|
||||
const openIdx = raw.indexOf(OPEN_SENTINEL);
|
||||
if (openIdx === -1) return { prose: raw, action: null, malformed: false };
|
||||
|
||||
const before = raw.slice(0, openIdx);
|
||||
const afterOpen = raw.slice(openIdx + OPEN_SENTINEL.length);
|
||||
const closeRel = afterOpen.indexOf(CLOSE_SENTINEL);
|
||||
const bodyRaw = closeRel === -1 ? afterOpen : afterOpen.slice(0, closeRel);
|
||||
const body =
|
||||
bodyRaw.length > SENTINEL_BYTE_CAP
|
||||
? bodyRaw.slice(0, SENTINEL_BYTE_CAP)
|
||||
: bodyRaw;
|
||||
const after =
|
||||
closeRel === -1 ? "" : afterOpen.slice(closeRel + CLOSE_SENTINEL.length);
|
||||
const prose = `${before}${after}`.replace(/^\s+|\s+$/gu, "");
|
||||
|
||||
const actionRaw = fieldLine(body, "action");
|
||||
if (!actionRaw || !isSuggestedActionType(actionRaw)) {
|
||||
return { prose, action: null, malformed: true };
|
||||
}
|
||||
|
||||
const resolved = resolveActionParams(actionRaw, body);
|
||||
if (!resolved) return { prose, action: null, malformed: true };
|
||||
|
||||
return {
|
||||
prose,
|
||||
action: {
|
||||
actionType: actionRaw,
|
||||
summary: resolved.summary,
|
||||
titleKey: resolved.titleKey,
|
||||
params: resolved.params,
|
||||
},
|
||||
malformed: false,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { CoachSuggestedAction } from "./suggest-action";
|
||||
|
||||
/**
|
||||
* Chat-message role. Stored as a free-form string column server-side
|
||||
* (`coach_messages.role`) but constrained at the application layer so a
|
||||
@@ -205,6 +207,7 @@ export type CoachStreamEvent =
|
||||
metricSource: CoachProvenance;
|
||||
}
|
||||
| { type: "suggestion"; suggestion: CoachSuggestion }
|
||||
| { type: "suggestedAction"; suggestedAction: CoachSuggestedAction }
|
||||
| { type: "reasoning"; text: string }
|
||||
| {
|
||||
type: "done";
|
||||
@@ -338,6 +341,13 @@ export interface CoachProvenance {
|
||||
* on a conversation reload. Absent on turns that carry no suggestion.
|
||||
*/
|
||||
suggestion?: CoachSuggestion;
|
||||
/**
|
||||
* v1.22 (F6) — a generalised confirm→apply action card attached to this turn
|
||||
* (`checkup.create` / `reminder.note`, closed allowlist). Persisted alongside
|
||||
* the message so the card re-renders on a conversation reload. Absent on turns
|
||||
* that carry no action.
|
||||
*/
|
||||
suggestedAction?: CoachSuggestedAction;
|
||||
/**
|
||||
* v1.20.0 (F1) — the retrieval-tool trace for this turn: which tools the
|
||||
* Coach called and whether each found data (`present`). Metadata only — no
|
||||
|
||||
@@ -112,6 +112,8 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
|
||||
{ model: "CoachPlan", field: "ifCueEncrypted", kind: "bytes" },
|
||||
{ model: "CoachPlan", field: "thenActionEncrypted", kind: "bytes" },
|
||||
{ model: "CoachPlan", field: "targetEncrypted", kind: "bytes" },
|
||||
// v1.22 (B2/B6) — Coach episodic reminder free-text note.
|
||||
{ model: "CoachReminder", field: "noteEncrypted", kind: "bytes" },
|
||||
|
||||
// ───── User health profile (Bytes columns) ─────
|
||||
{ model: "UserHealthProfile", field: "aboutMeEncrypted", kind: "bytes" },
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* v1.22 (M4) — daily Coach-reminder sweep.
|
||||
*
|
||||
* Covers: overdue active date-reminders flip to `due`; a passed CoachPlan
|
||||
* reviewDate mints a one-off reminder from the plan's own cue→action text and
|
||||
* clears the reviewDate (activating the dangling B1 column); an undecryptable
|
||||
* plan is skipped (counted errored) without sinking the tick.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
decryptFromBytes: (buf: Uint8Array) => {
|
||||
const tag = Buffer.from(buf).toString("utf8");
|
||||
if (tag === "__bad__") throw new Error("unknown key id");
|
||||
return `dec:${tag}`;
|
||||
},
|
||||
encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)),
|
||||
}));
|
||||
|
||||
import { runCoachReminderSweep } from "../coach-reminder-sweep";
|
||||
|
||||
const NOW = new Date("2026-06-27T05:20:00.000Z");
|
||||
|
||||
function bytes(tag: string): Uint8Array {
|
||||
return new Uint8Array(Buffer.from(tag, "utf8"));
|
||||
}
|
||||
|
||||
describe("runCoachReminderSweep", () => {
|
||||
it("flips overdue active date-reminders to due and mints plan-review reminders", async () => {
|
||||
const prisma = {
|
||||
coachReminder: {
|
||||
updateMany: vi.fn(async () => ({ count: 3 })),
|
||||
create: vi.fn(async () => ({ id: "r-new" })),
|
||||
},
|
||||
coachPlan: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
id: "p1",
|
||||
userId: "u1",
|
||||
metric: "WEIGHT",
|
||||
ifCueEncrypted: bytes("every morning"),
|
||||
thenActionEncrypted: bytes("weigh in"),
|
||||
},
|
||||
]),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
};
|
||||
|
||||
const summary = await runCoachReminderSweep(prisma as never, NOW);
|
||||
expect(summary.remindersDue).toBe(3);
|
||||
expect(summary.planReviewsMinted).toBe(1);
|
||||
expect(summary.errored).toBe(0);
|
||||
|
||||
// The flip query is scoped to active, date-triggered, overdue rows.
|
||||
const flipArgs = prisma.coachReminder.updateMany.mock
|
||||
.calls[0] as unknown as [
|
||||
{ where: { status: string; triggerKind: string; dueAt: unknown } },
|
||||
];
|
||||
const where = flipArgs[0].where;
|
||||
expect(where.status).toBe("active");
|
||||
expect(where.triggerKind).toBe("date");
|
||||
expect(where.dueAt).toEqual({ not: null, lte: NOW });
|
||||
|
||||
// The minted reminder carries the plan's own prose + relatedPlanId.
|
||||
const createArgs = prisma.coachReminder.create.mock.calls[0] as unknown as [
|
||||
{ data: { relatedPlanId: string; source: string; status: string } },
|
||||
];
|
||||
const data = createArgs[0].data;
|
||||
expect(data.relatedPlanId).toBe("p1");
|
||||
expect(data.source).toBe("extractor");
|
||||
expect(data.status).toBe("due");
|
||||
|
||||
// The plan's reviewDate is cleared so the review fires exactly once.
|
||||
expect(prisma.coachPlan.update).toHaveBeenCalledWith({
|
||||
where: { id: "p1" },
|
||||
data: { reviewDate: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("skips an undecryptable plan without sinking the tick", async () => {
|
||||
const prisma = {
|
||||
coachReminder: {
|
||||
updateMany: vi.fn(async () => ({ count: 0 })),
|
||||
create: vi.fn(async () => ({ id: "r-new" })),
|
||||
},
|
||||
coachPlan: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
id: "p1",
|
||||
userId: "u1",
|
||||
metric: "SLEEP",
|
||||
ifCueEncrypted: bytes("__bad__"),
|
||||
thenActionEncrypted: bytes("lights out"),
|
||||
},
|
||||
]),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
};
|
||||
|
||||
const summary = await runCoachReminderSweep(prisma as never, NOW);
|
||||
expect(summary.planReviewsMinted).toBe(0);
|
||||
expect(summary.errored).toBe(1);
|
||||
expect(prisma.coachReminder.create).not.toHaveBeenCalled();
|
||||
expect(prisma.coachPlan.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* v1.22 (B2/M4) — daily Coach-reminder sweep.
|
||||
*
|
||||
* The active scheduler the "remind me" complaint needs: a single indexed query
|
||||
* per tick that brings a reminder back at its chosen moment. Two sources:
|
||||
*
|
||||
* 1. `CoachReminder` rows that are date-triggered, `active`, and whose `dueAt`
|
||||
* has passed → flipped to `due` (the in-app tile + indicator read `due` /
|
||||
* `surfaced`). `lastSurfacedAt` is stamped + `surfaceCount` bumped.
|
||||
* 2. `CoachPlan` rows whose dangling `reviewDate` (written nowhere, read
|
||||
* nowhere until now — see the design §1c) has passed and that are still
|
||||
* `active` → a one-off `CoachReminder` is minted from the plan's own
|
||||
* cue→action text (relatedPlanId set, source `extractor`) and the plan's
|
||||
* `reviewDate` is cleared so the review fires once. This finally activates
|
||||
* the column the B1 schema anticipated.
|
||||
*
|
||||
* In-app surfacing ONLY in this wave: push delivery + the receptivity gate (B4)
|
||||
* land later. The sweep never calls a provider and never pushes — it only flips
|
||||
* status + mints the plan-review reminder, both idempotent across re-fires.
|
||||
*
|
||||
* Context-cue reminders (`triggerKind: "context"`) are NOT evaluated here — that
|
||||
* is a follow-on (F4); the column exists and capture supports it, but the date
|
||||
* sweep is the high-value slice.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
|
||||
export const COACH_REMINDER_SWEEP_QUEUE = "coach-reminder-sweep";
|
||||
// Daily at 05:20 Europe/Berlin — just after the 05:15 nudge tick so both
|
||||
// proactive passes run on settled overnight rows.
|
||||
export const COACH_REMINDER_SWEEP_CRON = "20 5 * * *";
|
||||
|
||||
/** Bound the plan-review fan-out per tick so one sweep can't run unbounded. */
|
||||
const PLAN_REVIEW_BATCH = 200;
|
||||
|
||||
export interface CoachReminderSweepSummary {
|
||||
remindersDue: number;
|
||||
planReviewsMinted: number;
|
||||
errored: number;
|
||||
}
|
||||
|
||||
type SweepPrisma = Pick<PrismaClient, "coachReminder" | "coachPlan">;
|
||||
|
||||
/**
|
||||
* Run one sweep tick. Idempotent: a reminder already `due` is not re-flipped,
|
||||
* and a plan whose `reviewDate` was cleared on a prior tick is not re-minted.
|
||||
*/
|
||||
export async function runCoachReminderSweep(
|
||||
prisma: SweepPrisma,
|
||||
now: Date = new Date(),
|
||||
): Promise<CoachReminderSweepSummary> {
|
||||
const summary: CoachReminderSweepSummary = {
|
||||
remindersDue: 0,
|
||||
planReviewsMinted: 0,
|
||||
errored: 0,
|
||||
};
|
||||
|
||||
// ── 1. flip overdue active date-reminders → due ──────────────
|
||||
const due = await prisma.coachReminder.updateMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
status: "active",
|
||||
triggerKind: "date",
|
||||
dueAt: { not: null, lte: now },
|
||||
},
|
||||
data: {
|
||||
status: "due",
|
||||
lastSurfacedAt: now,
|
||||
surfaceCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
summary.remindersDue = due.count;
|
||||
|
||||
// ── 2. mint plan-review reminders for passed reviewDates ──────
|
||||
const plans = await prisma.coachPlan.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
status: "active",
|
||||
reviewDate: { not: null, lte: now },
|
||||
},
|
||||
take: PLAN_REVIEW_BATCH,
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
metric: true,
|
||||
ifCueEncrypted: true,
|
||||
thenActionEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const plan of plans) {
|
||||
try {
|
||||
// The note is the plan's OWN cue→action prose (the user's words), never
|
||||
// fabricated. Decrypt fault-isolated — an undecryptable plan is skipped
|
||||
// (its reviewDate stays so a future key fix can still surface it).
|
||||
let note: string;
|
||||
try {
|
||||
const ifCue = decryptFromBytes(plan.ifCueEncrypted);
|
||||
const thenAction = decryptFromBytes(plan.thenActionEncrypted);
|
||||
note = `${ifCue} → ${thenAction}`.slice(0, 280);
|
||||
} catch {
|
||||
summary.errored += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.coachReminder.create({
|
||||
data: {
|
||||
userId: plan.userId,
|
||||
noteEncrypted: encryptToBytes(note),
|
||||
metric: plan.metric,
|
||||
relatedPlanId: plan.id,
|
||||
triggerKind: "date",
|
||||
dueAt: now,
|
||||
status: "due",
|
||||
source: "extractor",
|
||||
lastSurfacedAt: now,
|
||||
surfaceCount: 1,
|
||||
},
|
||||
});
|
||||
// Clear the plan's reviewDate so the review fires exactly once.
|
||||
await prisma.coachPlan.update({
|
||||
where: { id: plan.id },
|
||||
data: { reviewDate: null },
|
||||
});
|
||||
summary.planReviewsMinted += 1;
|
||||
} catch {
|
||||
summary.errored += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
@@ -34,6 +34,11 @@ import {
|
||||
COACH_NUDGE_CRON,
|
||||
runCoachNudgeTick,
|
||||
} from "@/lib/jobs/coach-nudge";
|
||||
import {
|
||||
COACH_REMINDER_SWEEP_QUEUE,
|
||||
COACH_REMINDER_SWEEP_CRON,
|
||||
runCoachReminderSweep,
|
||||
} from "@/lib/jobs/coach-reminder-sweep";
|
||||
import {
|
||||
MEDICATION_LOW_STOCK_QUEUE,
|
||||
MEDICATION_LOW_STOCK_CRON,
|
||||
@@ -159,6 +164,9 @@ const allQueues = [
|
||||
// v1.15.20 — proactive Coach nudge. Without this entry the daily 05:15
|
||||
// schedule silently no-ops and no nudge ever fires.
|
||||
COACH_NUDGE_QUEUE,
|
||||
// v1.22 (M4) — daily Coach-reminder sweep. Without this entry the 05:20
|
||||
// schedule silently no-ops and no "remind me" reminder ever surfaces.
|
||||
COACH_REMINDER_SWEEP_QUEUE,
|
||||
// v1.16.11 — daily medication low-stock pass. Without this entry the 09:00
|
||||
// schedule silently no-ops and no low-stock alert ever fires.
|
||||
MEDICATION_LOW_STOCK_QUEUE,
|
||||
@@ -204,6 +212,10 @@ const schedules: ScheduleEntry[] = [
|
||||
// the 04:45–04:55 score crons so the recovery-score trigger reads
|
||||
// settled rows. Deterministic triggers only — no AI call on this path.
|
||||
[COACH_NUDGE_QUEUE, COACH_NUDGE_CRON],
|
||||
// v1.22 (M4) — daily 05:20 Europe/Berlin Coach-reminder sweep, just after
|
||||
// the nudge tick. Flips overdue reminders to `due` + mints plan-review
|
||||
// reminders from passed CoachPlan.reviewDate. In-app surface only; no push.
|
||||
[COACH_REMINDER_SWEEP_QUEUE, COACH_REMINDER_SWEEP_CRON],
|
||||
// v1.16.11 — medication low-stock pass at 09:00 Europe/Berlin: a
|
||||
// supply alert is an errand prompt, so it fires at a time the user
|
||||
// can act on it. Once daily; the per-medication stamp keeps it at
|
||||
@@ -447,5 +459,36 @@ export async function registerStatusQueues(
|
||||
},
|
||||
);
|
||||
|
||||
// v1.22 (M4) — daily Coach-reminder sweep. Single-flight; flipping an overdue
|
||||
// reminder to `due` and minting plan-review reminders are both idempotent
|
||||
// across re-fires (an already-due reminder is not re-flipped; a plan whose
|
||||
// reviewDate was cleared is not re-minted). No provider call, no push.
|
||||
await boss.work(
|
||||
COACH_REMINDER_SWEEP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
async () => {
|
||||
await withBackgroundEvent("job.coach_reminder_sweep", async (evt) => {
|
||||
try {
|
||||
const summary = await runCoachReminderSweep(
|
||||
getWorkerPrisma(),
|
||||
new Date(),
|
||||
);
|
||||
evt.setBackground({
|
||||
task_name: "job.coach_reminder_sweep",
|
||||
result: {
|
||||
reminders_due: summary.remindersDue,
|
||||
plan_reviews_minted: summary.planReviewsMinted,
|
||||
errored: summary.errored,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
evt.setError(err);
|
||||
recordError();
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return allQueues;
|
||||
}
|
||||
@@ -22,6 +22,12 @@ import { coachChatRequestSchema } from "@/lib/ai/coach/types";
|
||||
import { exportSelectionSchema } from "@/lib/validations/health-record-export";
|
||||
import { createShareLinkSchema } from "@/lib/validations/clinician-share-link";
|
||||
import { coachReminderSuggestionActionSchema } from "@/lib/validations/coach-reminder-suggestion";
|
||||
import { COACH_REMINDER_STATUSES } from "@/lib/ai/coach/reminders";
|
||||
import {
|
||||
coachReminderCreateSchema,
|
||||
coachReminderPatchSchema,
|
||||
coachSuggestedActionSchema,
|
||||
} from "@/lib/validations/coach-reminder";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
// ── Coach cadence suggestions (v1.18.1) ──────────────────────────────
|
||||
@@ -316,6 +322,70 @@ coachPlansListQuerySchema.meta({
|
||||
"Optional `?status=` filter for the plans list (proposed | active | met | abandoned). Omitted returns the non-terminal set (proposed + active).",
|
||||
});
|
||||
|
||||
// ── Coach episodic reminders (v1.22 B2/B6) ──────────────────────────────
|
||||
const coachReminderItem = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
note: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Decrypted note (the user's own framing). Null when the row's key id is no longer in the map.",
|
||||
),
|
||||
metric: z.string().nullable(),
|
||||
triggerKind: z.enum(["date", "context"]),
|
||||
dueAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
contextCue: z.string().nullable(),
|
||||
status: z.enum(COACH_REMINDER_STATUSES),
|
||||
source: z.enum(["sentinel", "extractor", "manual", "action"]),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
updatedAt: z.iso.datetime({ offset: true }),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachReminder",
|
||||
description:
|
||||
"One Coach episodic reminder, decrypted server-side. The note reads null only when the encryption key id has rotated out of the map.",
|
||||
});
|
||||
|
||||
const coachRemindersListResponse = z.object({
|
||||
reminders: z
|
||||
.array(coachReminderItem)
|
||||
.describe(
|
||||
"The caller's reminders, soonest-due first. Undecryptable rows are omitted. No `status` filter returns the non-terminal set (proposed + active + due + surfaced).",
|
||||
),
|
||||
});
|
||||
|
||||
const coachReminderCreatedResponse = z.object({
|
||||
reminder: coachReminderItem,
|
||||
});
|
||||
|
||||
const coachReminderUpdatedResponse = z.object({
|
||||
reminder: coachReminderItem,
|
||||
});
|
||||
|
||||
const coachReminderDeletedResponse = z.object({
|
||||
deleted: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when a reminder owned by the caller was soft-deleted; false for an unknown / cross-user / already-deleted id (idempotent no-op).",
|
||||
),
|
||||
});
|
||||
|
||||
const coachSuggestedActionResultResponse = z
|
||||
.object({
|
||||
ok: z.literal(true),
|
||||
actionType: z.enum(["checkup.create", "reminder.note"]),
|
||||
reminder: z.unknown().optional(),
|
||||
reminderId: z.string().optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachSuggestedActionResult",
|
||||
description:
|
||||
"Outcome of confirming a Coach action card. `checkup.create` returns the created MeasurementReminder DTO; `reminder.note` returns the created CoachReminder id.",
|
||||
});
|
||||
// The request bodies carry their `.meta({ id })` from
|
||||
// `@/lib/validations/coach-reminder` (single-source with the runtime parse).
|
||||
|
||||
// ── Coach conversation history (v1.18.0) ─────────────────────────────
|
||||
// List + detail + delete surface for the Coach's persisted chat
|
||||
// conversations. Bodies are stored encrypted at rest; the detail
|
||||
@@ -1058,6 +1128,198 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
};
|
||||
|
||||
export const coachReminderPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/coach/reminders": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "List the caller's Coach reminders",
|
||||
description:
|
||||
'v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly, soonest-due first. Pass `?status=` (one status or a comma set like `due,surfaced` for the in-app tile); omitted returns the non-terminal set (proposed + active + due + surfaced). Coach-gated (`requireModuleEnabled("coach")`). Auth via cookie or Bearer; the owner is narrowed from the session. Undecryptable rows are omitted.',
|
||||
parameters: [
|
||||
{
|
||||
name: "status",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string" },
|
||||
description:
|
||||
"Filter to one status or a comma-separated set (proposed | active | due | surfaced | done | dismissed). Omit for the non-terminal set.",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The caller's reminders.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
coachRemindersListResponse,
|
||||
"CoachRemindersList",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description: "Coach surface disabled.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Insights"],
|
||||
summary: "Create a Coach reminder manually",
|
||||
description:
|
||||
"v1.22 (B6) — create a reminder from the ledger. `note` is the only writable content; `when` is the closed grammar (ISO date | +Nd / +Nw | a context cue) resolved into the trigger server-side; `metric` optional. The per-user cap returns 409 when the non-terminal set is full. Coach-gated. Auth via cookie or Bearer; the owner is narrowed from the session.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: coachReminderCreateSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "The created reminder.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
coachReminderCreatedResponse,
|
||||
"CoachReminderCreated",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description: "Coach surface disabled.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description: "The per-user reminder cap is full.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/coach/reminders/{id}": {
|
||||
patch: {
|
||||
tags: ["Insights"],
|
||||
summary: "Confirm or update a Coach reminder's lifecycle",
|
||||
description:
|
||||
"v1.22 (B6) — confirm a proposed reminder (→ active), mark it done / dismissed, or re-schedule via the closed `when` grammar (null clears the due moment). The body never carries the note text. A foreign / unknown / already-deleted id maps to 404 (never 403) so the existence channel does not leak. Coach-gated. Auth via cookie or Bearer.",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "Reminder id.",
|
||||
},
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: coachReminderPatchSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The reminder after the lifecycle update.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
coachReminderUpdatedResponse,
|
||||
"CoachReminderUpdated",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description: "Coach surface disabled.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"404": {
|
||||
description: "Reminder not found or not owned by the caller.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Insights"],
|
||||
summary: "Soft-delete one Coach reminder",
|
||||
description:
|
||||
"v1.22 (B6) — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer.",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "Reminder id.",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The reminder was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: false`).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
coachReminderDeletedResponse,
|
||||
"CoachReminderDeleted",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description: "Coach surface disabled.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const coachSuggestedActionPaths: NonNullable<ZodOpenApiObject["paths"]> =
|
||||
{
|
||||
"/api/coach/suggested-actions": {
|
||||
post: {
|
||||
tags: ["Insights"],
|
||||
summary: "Confirm a Coach action card",
|
||||
description:
|
||||
'v1.22 (F6) — the confirm half of the generalised propose→confirm moat. `actionType` is from the CLOSED allowlist (checkup.create | reminder.note) — NEVER a medication or clinical change. `checkup.create` builds a preventive-care Vorsorge `MeasurementReminder` (free-text label + a closed interval id resolved to an RRULE server-side); `reminder.note` builds a `CoachReminder`. Nothing is created without this explicit confirm. Coach-gated (`requireModuleEnabled("coach")`); per-user rate-limited (429). Auth via cookie or Bearer; the owner is narrowed from the session, built field-by-field — no mass assignment, no IDOR.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: coachSuggestedActionSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "The action was applied (entity created server-side).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
coachSuggestedActionResultResponse,
|
||||
"CoachSuggestedActionResultCreated",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description: "Coach surface disabled.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description: "The per-user reminder cap is full (reminder.note).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const coachFeedbackPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/insights/chat/messages/{id}/feedback": {
|
||||
post: {
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
coachFeedbackPaths,
|
||||
coachPaths,
|
||||
coachReminderSuggestionPaths,
|
||||
coachReminderPaths,
|
||||
coachSuggestedActionPaths,
|
||||
} from "./coach";
|
||||
import { consentPaths } from "./consent";
|
||||
import { cyclePaths } from "./cycle";
|
||||
@@ -62,6 +64,8 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...profilePaths,
|
||||
...coachPaths,
|
||||
...coachReminderSuggestionPaths,
|
||||
...coachReminderPaths,
|
||||
...coachSuggestedActionPaths,
|
||||
...adminInvitePaths,
|
||||
...coachFeedbackPaths,
|
||||
...adminDiagnosticPaths,
|
||||
|
||||
@@ -32,4 +32,14 @@ export const coachKeys = {
|
||||
* (`GET /api/insights/coach/seeded-question`).
|
||||
*/
|
||||
coachSeededQuestion: () => ["coach-seeded-question"] as const,
|
||||
/**
|
||||
* v1.22 (B2/B6) — the user's Coach episodic reminders (`GET /api/coach/
|
||||
* reminders`). The optional `status` arg keys the in-app tile read
|
||||
* (`due,surfaced`) separately from the full ledger list so the two never
|
||||
* share a cache slot.
|
||||
*/
|
||||
coachReminders: (status?: string) =>
|
||||
["coach-reminders", status ?? "all"] as const,
|
||||
/** Prefix key — invalidates every `coachReminders(status)` slot at once. */
|
||||
coachRemindersAll: () => ["coach-reminders"] as const,
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* v1.22 (B2/B6/F6) — request schemas for the Coach episodic-reminder surface
|
||||
* (`/api/coach/reminders` + `/[id]`) and the generalised confirm-card endpoint
|
||||
* (`/api/coach/suggested-actions`).
|
||||
*
|
||||
* Lives outside the route files so the OpenAPI registry can import it without
|
||||
* touching the route modules (route files may only export handlers + config).
|
||||
*
|
||||
* No `userId` field anywhere — the owner is always narrowed from the session.
|
||||
* The note free-text is the only user-writable content; everything timing is
|
||||
* resolved SERVER-side from a closed grammar / catalog, so a client can never
|
||||
* mint an arbitrary schedule.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
COACH_REMINDER_STATUSES,
|
||||
REMINDER_NOTE_MAX_CHARS,
|
||||
REMINDER_METRIC_MAX_CHARS,
|
||||
} from "@/lib/ai/coach/reminders";
|
||||
import {
|
||||
SUGGESTED_ACTION_TYPES,
|
||||
CHECKUP_LABEL_MAX_CHARS,
|
||||
ACTION_NOTE_MAX_CHARS,
|
||||
ACTION_METRIC_MAX_CHARS,
|
||||
} from "@/lib/ai/coach/suggest-action";
|
||||
|
||||
/** A `when` grammar token: ISO date | relative +Nd/+Nw | a context cue. */
|
||||
const whenToken = z.string().trim().min(1).max(32);
|
||||
const metricToken = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(REMINDER_METRIC_MAX_CHARS)
|
||||
.regex(/^[A-Za-z][A-Za-z0-9_]*$/, "metric must be a metric key");
|
||||
|
||||
/**
|
||||
* `?status=` filter on the list endpoint. Accepts one status or a
|
||||
* comma-separated set (e.g. `due,surfaced` for the in-app tile). Each token is
|
||||
* validated against the closed status enum; an unknown token 422s.
|
||||
*/
|
||||
export const coachRemindersListQuerySchema = z.object({
|
||||
status: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) =>
|
||||
v
|
||||
? v
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
)
|
||||
.pipe(z.array(z.enum(COACH_REMINDER_STATUSES)).min(1).optional()),
|
||||
});
|
||||
|
||||
/**
|
||||
* Manual create (the ledger "+ add a reminder" path). The note is the only
|
||||
* free text; `when` is the closed grammar (resolved server-side into the
|
||||
* trigger), `metric` optional. Strict: unknown keys 422.
|
||||
*/
|
||||
export const coachReminderCreateSchema = z
|
||||
.object({
|
||||
note: z.string().trim().min(1).max(REMINDER_NOTE_MAX_CHARS),
|
||||
when: whenToken.optional(),
|
||||
metric: metricToken.optional(),
|
||||
})
|
||||
.strict()
|
||||
.meta({
|
||||
id: "CoachReminderCreate",
|
||||
description:
|
||||
"Create a Coach reminder manually. `note` is free text (the only writable content). `when` is an optional closed grammar token — an ISO date (YYYY-MM-DD), a relative offset (+Nd / +Nw), or a context cue (NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED, NEXT_SLEEP_LOGGED, NEXT_APP_OPEN) — resolved into the trigger server-side. Omit `when` for a recall-only note. Strict: unknown keys 422.",
|
||||
});
|
||||
|
||||
/**
|
||||
* The lifecycle PATCH. `status` confirms a proposed reminder (→ active), marks
|
||||
* it done / dismissed, or re-activates it. `when` optionally re-schedules
|
||||
* (snooze) via the same closed grammar. The body never carries the note text,
|
||||
* so a client can never overwrite a reminder's prose — only its lifecycle.
|
||||
*/
|
||||
export const coachReminderPatchSchema = z
|
||||
.object({
|
||||
status: z.enum(COACH_REMINDER_STATUSES).optional(),
|
||||
when: whenToken.nullable().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(v) => v.status !== undefined || v.when !== undefined,
|
||||
"At least one of status or when must be provided",
|
||||
)
|
||||
.meta({
|
||||
id: "CoachReminderPatch",
|
||||
description:
|
||||
"Update a Coach reminder's lifecycle. `status` confirms (proposed → active), or marks done / dismissed. `when` re-schedules via the closed grammar (null clears the due moment). Never carries the note text. Strict: unknown keys 422.",
|
||||
});
|
||||
|
||||
// ── Suggested-action confirm endpoint (F6) ──────────────────────────────
|
||||
|
||||
const checkupParams = z.object({
|
||||
actionType: z.literal("checkup.create"),
|
||||
label: z.string().trim().min(1).max(CHECKUP_LABEL_MAX_CHARS),
|
||||
interval: z.enum(["yearly", "halfYearly", "quarterly", "monthly"]),
|
||||
});
|
||||
|
||||
const reminderNoteParams = z.object({
|
||||
actionType: z.literal("reminder.note"),
|
||||
note: z.string().trim().min(1).max(ACTION_NOTE_MAX_CHARS),
|
||||
when: whenToken.optional(),
|
||||
metric: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(ACTION_METRIC_MAX_CHARS)
|
||||
.regex(/^[A-Za-z][A-Za-z0-9_]*$/)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* The confirm-card POST body. `actionType` names a closed-allowlist action; the
|
||||
* params are a discriminated union, validated field-by-field. The server resolves
|
||||
* every schedule from a closed catalog and builds the entity field-by-field —
|
||||
* the client can never mint an arbitrary entity, widen a cadence, or touch a
|
||||
* medication / clinical target.
|
||||
*/
|
||||
export const coachSuggestedActionSchema = z
|
||||
.discriminatedUnion("actionType", [checkupParams, reminderNoteParams])
|
||||
.meta({
|
||||
id: "CoachSuggestedAction",
|
||||
description:
|
||||
"Confirm a Coach action card. `actionType` is from the closed allowlist (checkup.create, reminder.note); NEVER a medication or clinical change. `checkup.create` builds a preventive-care Vorsorge reminder (free-text label + a closed interval id resolved to an RRULE server-side); `reminder.note` builds a CoachReminder (note + optional closed `when` grammar + optional metric). Nothing is created without this explicit confirm.",
|
||||
});
|
||||
|
||||
export type CoachSuggestedActionInput = z.infer<
|
||||
typeof coachSuggestedActionSchema
|
||||
>;
|
||||
|
||||
/** Stable list of confirmable action types (mirrors the lib allowlist). */
|
||||
export const COACH_SUGGESTED_ACTION_TYPES = SUGGESTED_ACTION_TYPES;
|
||||
Reference in new issue
Block a user