mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user