mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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:
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";
|
||||
Reference in new issue
Block a user