Files
HealthLog/src/lib/validations/coach-reminder.ts
T
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00

135 lines
5.2 KiB
TypeScript

/**
* v1.22 — 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 {
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
>;