feat(coach): evidence-based cadence suggestions with one-tap in-chat reminder

The Coach can append a ---SUGGEST-REMINDER--- sentinel to propose a
measurement cadence (weight daily, the ESH/AHA 7-2-2 home-BP protocol,
structured glucose). The server resolves the cadence from a closed catalog
and emits an additive 'suggestion' SSE frame the chat renders as a one-tap
action card; accepting it creates a MeasurementReminder with origin COACH
through the same engine the Vorsorge surface uses (server-authoritative
schedule, finite course window self-expires via endsOn).

Non-naggy by construction: a frequency-cap cooldown, dismissal memory, a
master opt-out under Coach settings, and an explicit 'I measure enough'
stop path, all in the coach-prefs blob. Triple dedup at suggest, render,
and create so a cadence is never double-suggested or double-created, and
suggestions for a disabled module never surface. RHR/HRV stay passive --
the Coach surfaces the baseline, it never nudges to 'go measure' them.

The reminder DTO now echoes origin and endsOn for the iOS client.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 21:17:51 +02:00
1 parent b6796eee96
commit 12f4bc24e8
29 files changed
+1538 -9

No files matched your search

@@ -0,0 +1,193 @@
/**
* POST /api/coach/reminder-suggestions — act on a Coach cadence suggestion.
*
* v1.18.1 (Workstream C). The Coach proposes an evidence-based measurement
* cadence (the `---SUGGEST-REMINDER---` sentinel → `suggestion` SSE frame →
* action card). This endpoint is what the card's buttons call:
*
* - `accept` → create a `MeasurementReminder` with `origin: COACH`,
* routed through the SAME engine + endpoint semantics as the
* Vorsorge surface. The server resolves the cadence from the
* closed catalog (metric + interval/rrule + course window) —
* the client sends only the cadence id, never a schedule.
* - `dismiss` → dismissal memory (never re-suggest this cadence).
* - `stop` → the explicit "you measure enough — stop" path.
*
* Triple-dedup completion: the CREATE path here refuses a second live
* COACH reminder for the same metric, so a re-tap or a stale card cannot
* double-create even though the SUGGEST gate already deduped at emit time.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
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 {
parseCoachPrefs,
DEFAULT_REMINDER_SUGGESTION_PREFS,
} from "@/lib/validations/coach-prefs";
import { coachReminderSuggestionActionSchema } from "@/lib/validations/coach-reminder-suggestion";
import {
CADENCE_CATALOG,
isCadenceId,
} from "@/lib/ai/coach/suggest-reminder";
import {
computeReminderNextDueAt,
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
const DEFAULT_TIMEZONE = "Europe/Berlin";
async function postAction(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// The Coach surface gates the suggestion card; mirror the gate here so a
// disabled-coach account cannot drive the endpoint directly.
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = coachReminderSuggestionActionSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const { cadenceId, action } = parsed.data;
if (!isCadenceId(cadenceId)) {
return apiError("coach.suggestion.unknownCadence", 422);
}
const cadence = CADENCE_CATALOG[cadenceId];
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { coachPrefsJson: true, timezone: true },
});
const coachPrefs = parseCoachPrefs(row?.coachPrefsJson);
const suggestionPrefs =
coachPrefs.reminderSuggestions ?? DEFAULT_REMINDER_SUGGESTION_PREFS;
// ── dismiss / stop: prefs-only writes ────────────────────────
if (action === "dismiss" || action === "stop") {
const next = {
...suggestionPrefs,
...(action === "stop" ? { stopped: true } : {}),
dismissedCadences:
action === "dismiss" &&
!suggestionPrefs.dismissedCadences.includes(cadenceId)
? [...suggestionPrefs.dismissedCadences, cadenceId].slice(0, 32)
: suggestionPrefs.dismissedCadences,
};
await prisma.user.update({
where: { id: user.id },
data: {
coachPrefsJson: { ...coachPrefs, reminderSuggestions: next },
},
});
annotate({
action: { name: `coach.reminder.${action}` },
meta: { cadenceId },
});
return apiSuccess({ ok: true, action });
}
// ── accept: create the reminder (origin: COACH) ──────────────
// Module-toggle re-check at create time (defence in depth — the gate
// already checked at emit, but a stale card could outlive a module flip).
if (cadence.module) {
const moduleGate = await requireModuleEnabled(user.id, cadence.module);
if (!moduleGate.enabled) return moduleGate.response;
}
// Third dedup: refuse a second live COACH reminder for the same metric.
const existing = await prisma.measurementReminder.findFirst({
where: {
userId: user.id,
origin: "COACH",
measurementType: cadence.measurementType,
deletedAt: null,
},
select: { id: true },
});
if (existing) {
annotate({
action: { name: "coach.reminder.accept.duplicate" },
meta: { cadenceId, reminderId: existing.id },
});
return apiSuccess(
{ ok: true, action: "accept", reminder: null, duplicate: true },
200,
);
}
const timezone = row?.timezone || DEFAULT_TIMEZONE;
const now = new Date();
const endsOn =
cadence.courseDays != null
? new Date(now.getTime() + cadence.courseDays * 24 * 60 * 60 * 1000)
: null;
const scheduleInput: ReminderScheduleInput = {
intervalDays: cadence.intervalDays,
rrule: cadence.rrule,
anchorDate: null,
notifyHour: cadence.notifyHour,
lastSatisfiedAt: null,
createdAt: now,
endsOn,
};
const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
// Field-by-field — no mass assignment. `origin: COACH` is the
// provenance the UI labels and the SUGGEST gate dedupes against.
const created = await prisma.measurementReminder.create({
data: {
userId: user.id,
label: cadence.labelKey,
measurementType: cadence.measurementType,
intervalDays: cadence.intervalDays,
rrule: cadence.rrule,
anchorDate: null,
endsOn,
origin: "COACH",
notifyHour: cadence.notifyHour,
location: null,
enabled: true,
nextDueAt,
},
});
await auditLog("measurementReminder.create.coach", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reminderId: created.id, cadenceId },
});
annotate({
action: { name: "coach.reminder.accept" },
meta: { cadenceId, reminderId: created.id },
});
return apiSuccess(
{
ok: true,
action: "accept",
reminder: toMeasurementReminderDto(created),
duplicate: false,
},
201,
);
}
export const POST = apiHandler(postAction);
export const dynamic = "force-dynamic";
export const runtime = "nodejs";