mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
Reference in new issue
Block a user