feat(coach): keep stored reminders readable and erasable with the Coach unavailable

Reminders are the person's own record, like the stored facts,
conversations and plans. The list and delete routes no longer ask the
Coach module; creating, confirming, resolving or rescheduling one stays
Coach use and is refused by POST and PATCH as before.

Settings -> Coach now always mounts the reminders card (shown with Hide
Coach on only when reminders exist), and Settings -> AI lists them through
the stored-memory block while Settings -> Coach is out of reach. With the
Coach unavailable the card is read-only: every reminder can be read and
deleted, while keep / done / dismiss are not offered.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 11:57:42 +02:00
1 parent 5e5d33ecf8
commit 6e45acfbc6
19 files changed
+303 -57

No files matched your search

@@ -330,6 +330,11 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
// The stored plans list. Its sibling `[id]` route keeps the Coach gate on
// PATCH (confirming a proposal is Coach use) and erases without it.
"src/app/api/coach/plans/route.ts",
// Not listed, by the same rule: `coach/reminders/route.ts` lists without the
// Coach gate but keeps it on POST (creating one is Coach use), and
// `coach/reminders/[id]/route.ts` erases without it but keeps it on PATCH.
// Both files still carry a gate, so the walk counts them as gated and an
// entry here would fail the "does not hide a gate" check below.
"src/app/api/insights/chat/[id]/route.ts",
"src/app/api/insights/chat/messages/[id]/feedback/route.ts",
// The Today digest and its dismiss: data, with the AI parts masked by the
+5 -2
View File
@@ -14,6 +14,11 @@
* 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 }`.
*
* PATCH is Coach use (it confirms, resolves or reschedules what the Coach
* captured) and is gated by `requireModuleEnabled(userId, "coach")`. DELETE is
* not: erasing one's own stored reminder never depends on the Coach being
* available, the same rule as the stored facts, conversations and plans.
*/
import type { NextRequest } from "next/server";
@@ -152,8 +157,6 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
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;
@@ -125,6 +125,14 @@ const callDelete = (id = "r1") =>
{ params: Promise.resolve({ id }) },
);
/** The Coach module gate refuses: operator switch, module off or Hide Coach. */
function coachOff() {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: new Response(null, { status: 403 }),
} as never);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
@@ -162,7 +170,14 @@ describe("GET /api/coach/reminders", () => {
?.where as { userId: string; deletedAt: null };
expect(where.userId).toBe("user-1");
expect(where.deletedAt).toBeNull();
expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach");
});
it("lists stored reminders with the Coach unavailable (they are the person's record)", async () => {
coachOff();
vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([] as never);
const res = await callGet();
expect(res.status).toBe(200);
expect(requireModuleEnabled).not.toHaveBeenCalled();
});
it("skips an undecryptable row rather than 500ing", async () => {
@@ -202,6 +217,13 @@ describe("GET /api/coach/reminders", () => {
});
describe("POST /api/coach/reminders", () => {
it("stays Coach use: refused with the Coach unavailable", async () => {
coachOff();
const res = await callPost({ note: "check my sleep" });
expect(res.status).toBe(403);
expect(prisma.coachReminder.create).not.toHaveBeenCalled();
});
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({
@@ -248,6 +270,14 @@ describe("POST /api/coach/reminders", () => {
});
describe("PATCH /api/coach/reminders/[id]", () => {
it("stays Coach use: refused with the Coach unavailable", async () => {
coachOff();
const res = await callPatch({ status: "active" });
expect(res.status).toBe(403);
expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach");
expect(prisma.coachReminder.updateMany).not.toHaveBeenCalled();
});
it("confirms proposed → active field-by-field, owner-scoped", async () => {
vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({
count: 1,
@@ -309,4 +339,16 @@ describe("DELETE /api/coach/reminders/[id]", () => {
const body = (await res.json()) as { data: { deleted: boolean } };
expect(body.data.deleted).toBe(false);
});
it("erases a stored reminder with the Coach unavailable", async () => {
coachOff();
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);
expect(requireModuleEnabled).not.toHaveBeenCalled();
});
});
+9 -7
View File
@@ -9,9 +9,13 @@
*
* 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.
* the session, never the body.
*
* GET is not gated on the Coach. A reminder is the person's own stored record:
* reading it never depends on the Coach being available, the same rule as the
* stored facts, conversations and plans (a Coach switched off by the operator
* or hidden by the person must not lock them out of what it kept). POST is
* Coach use and stays gated by `requireModuleEnabled(userId, "coach")`.
*
* `?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
@@ -77,11 +81,9 @@ export const GET = apiHandler(async (req: Request) => {
// the delegate's own ledger while the page shows another person's record,
// which is the failure mode this whole mode system exists to prevent.
//
// The module gate below resolves against the record, so the surface exists
// for a delegate only where the OWNER switched the Coach on.
// No Coach gate: the ledger is readable whatever the Coach's state, for the
// owner and for a delegate with a read grant alike, like the mood note.
const { user } = await requireRecordAuth("read", "record");
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const url = new URL(req.url);
const parsed = coachRemindersListQuerySchema.safeParse({