mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge branch 'feat/coach-reminders-readable' into release/v1.39.0
This commit is contained in:
commit
4ae231d757
19 files changed
+303
-57
No files matched your search
+7
-23
@@ -9593,11 +9593,11 @@ paths:
|
||||
tags:
|
||||
- Insights
|
||||
summary: List the caller's Coach reminders
|
||||
description: v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly,
|
||||
description: 'v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly,
|
||||
soonest-due first. Pass `?status=` (one status or a comma set like `due,surfaced` for the in-app tile); omitted
|
||||
returns the non-terminal set (proposed + active + due + surfaced). Coach-gated
|
||||
(`requireModuleEnabled("coach")`). Auth via cookie or Bearer; the owner is narrowed from the session.
|
||||
Undecryptable rows are omitted.
|
||||
returns the non-terminal set (proposed + active + due + surfaced). Not gated on the Coach: the reminders belong
|
||||
to the caller and stay readable while the Coach is unavailable (v1.39). Auth via cookie or Bearer; the owner is
|
||||
narrowed from the session. Undecryptable rows are omitted.'
|
||||
parameters:
|
||||
- name: status
|
||||
in: query
|
||||
@@ -9614,24 +9614,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachRemindersList"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: >-
|
||||
Coach surface disabled.
|
||||
|
||||
|
||||
Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`).
|
||||
Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections
|
||||
do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it
|
||||
is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's
|
||||
audit trail instead. On the cookie transport the same code answers one further case: a session that has
|
||||
entered a shared record and sends a request without its record-context assertion, which is a client older
|
||||
than the fence — leave the record and reload. A request that names no record but the caller's own never
|
||||
reaches this response, so a client that never switches and never sends the per-request account selector will
|
||||
not see it.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"403": *a9
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
post:
|
||||
@@ -9719,7 +9702,8 @@ paths:
|
||||
- Insights
|
||||
summary: Soft-delete one Coach reminder
|
||||
description: "v1.22 — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted id
|
||||
is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer."
|
||||
is an idempotent no-op returning `{ deleted: false }`. Not gated on the Coach: erasing a stored reminder works
|
||||
while the Coach is unavailable (v1.39). Auth via cookie or Bearer."
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
|
||||
+2
-1
@@ -5794,7 +5794,8 @@
|
||||
"deleteConfirmTitle": "Diese Erinnerung löschen?",
|
||||
"deleteConfirmBody": "Sie wird nicht mehr ausgelöst. An bereits erfassten Daten ändert sich nichts.",
|
||||
"deleteConfirmAction": "Löschen",
|
||||
"detail": "Fällige erscheinen hier und beim Coach."
|
||||
"detail": "Fällige erscheinen hier und beim Coach.",
|
||||
"readOnlyNote": "Der Coach ist gerade nicht verfügbar, deine Erinnerungen kannst du aber weiter lesen und löschen."
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "Über mich",
|
||||
|
||||
+2
-1
@@ -5794,7 +5794,8 @@
|
||||
"deleteConfirmTitle": "Delete this reminder?",
|
||||
"deleteConfirmBody": "It stops being raised. Anything you have already recorded is untouched.",
|
||||
"deleteConfirmAction": "Delete",
|
||||
"detail": "Due ones show up here and on the Coach."
|
||||
"detail": "Due ones show up here and on the Coach.",
|
||||
"readOnlyNote": "The Coach is not available right now, but you can still read and delete your reminders."
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "About me",
|
||||
|
||||
+2
-1
@@ -5794,7 +5794,8 @@
|
||||
"deleteConfirmTitle": "¿Eliminar este recordatorio?",
|
||||
"deleteConfirmBody": "Deja de aparecer. Lo que ya has registrado no cambia.",
|
||||
"deleteConfirmAction": "Eliminar",
|
||||
"detail": "Las que vencen aparecen aquí y en el Coach."
|
||||
"detail": "Las que vencen aparecen aquí y en el Coach.",
|
||||
"readOnlyNote": "El Coach no está disponible ahora mismo, pero aún puedes leer y eliminar tus recordatorios."
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "Sobre mí",
|
||||
|
||||
+2
-1
@@ -5794,7 +5794,8 @@
|
||||
"deleteConfirmTitle": "Supprimer ce rappel ?",
|
||||
"deleteConfirmBody": "Il ne se déclenchera plus. Ce que tu as déjà enregistré reste intact.",
|
||||
"deleteConfirmAction": "Supprimer",
|
||||
"detail": "Celles qui arrivent à échéance apparaissent ici et chez le Coach."
|
||||
"detail": "Celles qui arrivent à échéance apparaissent ici et chez le Coach.",
|
||||
"readOnlyNote": "Le Coach n'est pas disponible pour le moment, mais tu peux toujours lire et supprimer tes rappels."
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "À propos de moi",
|
||||
|
||||
+2
-1
@@ -5794,7 +5794,8 @@
|
||||
"deleteConfirmTitle": "Eliminare questo promemoria?",
|
||||
"deleteConfirmBody": "Non verrà più mostrato. Quanto hai già registrato resta invariato.",
|
||||
"deleteConfirmAction": "Elimina",
|
||||
"detail": "Quelle in scadenza compaiono qui e nel Coach."
|
||||
"detail": "Quelle in scadenza compaiono qui e nel Coach.",
|
||||
"readOnlyNote": "Il Coach non è disponibile al momento, ma puoi ancora leggere ed eliminare i tuoi promemoria."
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "Su di me",
|
||||
|
||||
+2
-1
@@ -5794,7 +5794,8 @@
|
||||
"deleteConfirmTitle": "이 알림을 삭제할까요?",
|
||||
"deleteConfirmBody": "더 이상 알려 주지 않아요. 이미 기록한 내용은 그대로예요.",
|
||||
"deleteConfirmAction": "삭제",
|
||||
"detail": "예정된 알림은 여기와 코치에 나타나요."
|
||||
"detail": "예정된 알림은 여기와 코치에 나타나요.",
|
||||
"readOnlyNote": "지금은 코치를 사용할 수 없지만 알림은 계속 읽고 삭제할 수 있어요."
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "나에 대해",
|
||||
|
||||
+2
-1
@@ -5794,7 +5794,8 @@
|
||||
"deleteConfirmTitle": "Usunąć to przypomnienie?",
|
||||
"deleteConfirmBody": "Przestanie się pojawiać. Zapisane wcześniej dane pozostają bez zmian.",
|
||||
"deleteConfirmAction": "Usuń",
|
||||
"detail": "Te, których termin nadszedł, pojawiają się tutaj i w Coachu."
|
||||
"detail": "Te, których termin nadszedł, pojawiają się tutaj i w Coachu.",
|
||||
"readOnlyNote": "Coach jest teraz niedostępny, ale nadal możesz czytać i usuwać swoje przypomnienia."
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "O mnie",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Stored Coach reminders stay readable and erasable with the Coach unavailable.
|
||||
*
|
||||
* A "remind me about X" note is the person's own record, like the stored facts
|
||||
* and conversations. Before v1.39 Settings → Coach mounted the reminders card
|
||||
* only while the Coach was shown, and Settings → AI never mounted it, so a
|
||||
* person whose Coach was switched off or hidden could neither read nor erase
|
||||
* what it had kept. Now:
|
||||
*
|
||||
* - the card lists every reminder and offers delete on each, whatever the
|
||||
* Coach's state;
|
||||
* - keeping, resolving or dismissing one is Coach use (the PATCH route
|
||||
* refuses it then), so those controls appear only with the Coach
|
||||
* available;
|
||||
* - Settings → Coach shows the card with Hide Coach on, and Settings → AI
|
||||
* shows it through `<StoredCoachMemory>` only when reminders exist.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const coach = vi.hoisted(() => ({ available: false }));
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({
|
||||
available: coach.available,
|
||||
reason: coach.available ? null : "user_disabled",
|
||||
onDeviceAllowed: false,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
disableCoach: !coach.available,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings/coach",
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { CoachRemindersSection } from "../coach-reminders-section";
|
||||
import { CoachSection } from "../coach-section";
|
||||
import { StoredCoachMemory } from "../coach-memory-section";
|
||||
|
||||
const reminder = (id: string, status: string) => ({
|
||||
id,
|
||||
note: `note ${id}`,
|
||||
metric: null,
|
||||
triggerKind: "date",
|
||||
dueAt: null,
|
||||
contextCue: null,
|
||||
status,
|
||||
source: "extractor",
|
||||
createdAt: "2026-09-01T08:00:00.000Z",
|
||||
updatedAt: "2026-09-01T08:00:00.000Z",
|
||||
});
|
||||
const STORED = [
|
||||
reminder("r-proposed", "proposed"),
|
||||
reminder("r-active", "active"),
|
||||
reminder("r-surfaced", "surfaced"),
|
||||
];
|
||||
|
||||
function render(node: React.ReactNode, reminders: unknown[]): string {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0, staleTime: Infinity } },
|
||||
});
|
||||
client.setQueryData(queryKeys.coachReminders(), reminders);
|
||||
client.setQueryData(queryKeys.coachFacts(), []);
|
||||
client.setQueryData(queryKeys.coachConversationHistory(""), {
|
||||
pages: [{ conversations: [], nextCursor: null }],
|
||||
pageParams: [null],
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const count = (html: string, needle: string) => html.split(needle).length - 1;
|
||||
|
||||
beforeEach(() => {
|
||||
// Everything is seeded; a background refetch must not reach the network.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("{}", { status: 500 })),
|
||||
);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("<CoachRemindersSection>", () => {
|
||||
it("stays readable and erasable with the Coach unavailable", () => {
|
||||
coach.available = false;
|
||||
const html = render(<CoachRemindersSection isAuthenticated />, STORED);
|
||||
expect(count(html, 'data-testid="settings-coach-reminder"')).toBe(3);
|
||||
expect(html).toContain("note r-proposed");
|
||||
// Every reminder can be removed.
|
||||
expect(count(html, 'data-slot="coach-reminder-delete"')).toBe(3);
|
||||
// Keeping, resolving or dismissing one is Coach use and is not offered.
|
||||
expect(html).not.toContain("settings-coach-reminder-confirm");
|
||||
expect(html).not.toContain("settings-coach-reminder-done");
|
||||
expect(html).not.toContain("settings-coach-reminder-dismiss");
|
||||
expect(html).toContain("you can still read and delete your reminders");
|
||||
});
|
||||
|
||||
it("offers the lifecycle controls with the Coach available", () => {
|
||||
coach.available = true;
|
||||
const html = render(<CoachRemindersSection isAuthenticated />, STORED);
|
||||
expect(html).toContain('data-testid="settings-coach-reminder-confirm"');
|
||||
expect(html).toContain('data-testid="settings-coach-reminder-done"');
|
||||
expect(html).toContain('data-testid="settings-coach-reminder-dismiss"');
|
||||
expect(count(html, 'data-slot="coach-reminder-delete"')).toBe(3);
|
||||
expect(html).not.toContain("you can still read and delete your reminders");
|
||||
});
|
||||
});
|
||||
|
||||
describe("where the reminders show while the Coach is unavailable", () => {
|
||||
it("Settings → Coach shows them with Hide Coach on", () => {
|
||||
coach.available = false;
|
||||
const html = render(<CoachSection />, STORED);
|
||||
expect(html).toContain('data-testid="settings-coach-reminders-card"');
|
||||
expect(html).toContain("note r-active");
|
||||
// The Coach's tuning stays hidden with the Coach.
|
||||
expect(html).not.toContain('id="coach-nudge"');
|
||||
});
|
||||
|
||||
it("Settings → Coach does not invite new reminders with Hide Coach on", () => {
|
||||
coach.available = false;
|
||||
const html = render(<CoachSection />, []);
|
||||
expect(html).not.toContain('data-testid="settings-coach-reminders-card"');
|
||||
});
|
||||
|
||||
it("Settings → AI shows them through <StoredCoachMemory> when they exist", () => {
|
||||
coach.available = false;
|
||||
const html = render(<StoredCoachMemory isAuthenticated />, STORED);
|
||||
expect(html).toContain('data-testid="settings-coach-reminders-card"');
|
||||
expect(count(html, 'data-slot="coach-reminder-delete"')).toBe(3);
|
||||
});
|
||||
|
||||
it("Settings → AI shows nothing for somebody with no stored reminders", () => {
|
||||
coach.available = false;
|
||||
expect(render(<StoredCoachMemory isAuthenticated />, [])).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,16 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
// The reminders card reads the Coach capability off `/api/auth/me`; the
|
||||
// react-query mock above carries no context for the real hook to read.
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
const authState: { disableCoach: boolean } = { disableCoach: false };
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
|
||||
@@ -25,6 +25,16 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn(), setQueryData: vi.fn() }),
|
||||
}));
|
||||
|
||||
// The reminders card reads the Coach capability off `/api/auth/me`; the
|
||||
// react-query mock above carries no context for the real hook to read.
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings",
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
* v1.39 — never gated on the Coach. What the Coach stored is the person's
|
||||
* own record: it stays readable and deletable while the Coach is off for any
|
||||
* reason (the operator's switch, Hide Coach, no provider, no consent). The
|
||||
* facts routes are data routes and ask no AI gate. Settings → Coach always
|
||||
* facts routes are data routes and ask no AI gate. The same holds for the
|
||||
* stored reminders (`<CoachRemindersSection>`). Settings → Coach always
|
||||
* shows this card; while that page is not reachable, Settings → AI shows it
|
||||
* through `<StoredCoachMemory>` below, whenever rows exist.
|
||||
*/
|
||||
@@ -51,6 +52,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { toast } from "sonner";
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
import { CoachConversationsMemoryCard } from "@/components/settings/coach-conversations-memory-card";
|
||||
import { CoachRemindersSection } from "@/components/settings/coach-reminders-section";
|
||||
|
||||
/** Closed enum mirrored from the server `CoachFact.category` column. */
|
||||
const FACT_CATEGORIES = [
|
||||
@@ -310,6 +312,7 @@ export function StoredCoachMemory({
|
||||
isAuthenticated={isAuthenticated}
|
||||
hideWhenEmpty
|
||||
/>
|
||||
<CoachRemindersSection isAuthenticated={isAuthenticated} hideWhenEmpty />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,15 @@
|
||||
* DELETE /api/coach/reminders/{id} → { data: { deleted } }
|
||||
*
|
||||
* Reads unwrap `(await res.json()).data`; every key routes through
|
||||
* `queryKeys.coachReminders()` so a mutation invalidates the list. Gated on the
|
||||
* Coach surface like the rest of the memory controls.
|
||||
* `queryKeys.coachReminders()` so a mutation invalidates the list.
|
||||
*
|
||||
* v1.39 — never gated on the Coach. A reminder is the person's own record, like
|
||||
* the stored facts and conversations: the list and delete routes ask no Coach
|
||||
* gate, so the card stays readable and every reminder stays deletable while the
|
||||
* Coach is unavailable for any reason. Keeping, resolving or dismissing one is
|
||||
* Coach use (the PATCH route refuses it then), so those controls are not
|
||||
* offered in that state. Settings → Coach always mounts this card; Settings → AI
|
||||
* mounts it through `<StoredCoachMemory>` with `hideWhenEmpty`.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { BellRing, Check, Trash2, X } from "lucide-react";
|
||||
@@ -28,6 +35,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import {
|
||||
useCoachReminders,
|
||||
useCoachReminderMutations,
|
||||
@@ -38,10 +46,16 @@ const DUE_STATUSES = new Set(["due", "surfaced"]);
|
||||
|
||||
export function CoachRemindersSection({
|
||||
isAuthenticated,
|
||||
hideWhenEmpty = false,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
/** Render nothing while there are no stored reminders (the Coach-off mount). */
|
||||
hideWhenEmpty?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
// Unavailable (including the loading frame) means read-only: the lifecycle
|
||||
// controls appear only once the account says the Coach can be used.
|
||||
const readOnly = !useAiCapability("coach").available;
|
||||
const query = useCoachReminders({ enabled: isAuthenticated });
|
||||
const { setStatus, remove } = useCoachReminderMutations();
|
||||
|
||||
@@ -87,7 +101,7 @@ export function CoachRemindersSection({
|
||||
<span>{t(`settings.ai.coachReminders.status.${r.status}`)}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isProposed && (
|
||||
{!readOnly && isProposed && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -101,7 +115,7 @@ export function CoachRemindersSection({
|
||||
{t("settings.ai.coachReminders.confirm")}
|
||||
</Button>
|
||||
)}
|
||||
{r.status !== "done" && (
|
||||
{!readOnly && r.status !== "done" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -115,7 +129,7 @@ export function CoachRemindersSection({
|
||||
{t("settings.ai.coachReminders.markDone")}
|
||||
</Button>
|
||||
)}
|
||||
{(r.status === "due" || r.status === "surfaced") && (
|
||||
{!readOnly && (r.status === "due" || r.status === "surfaced") && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -151,6 +165,9 @@ export function CoachRemindersSection({
|
||||
);
|
||||
};
|
||||
|
||||
if (hideWhenEmpty && (query.isPending || query.isError)) return null;
|
||||
if (hideWhenEmpty && reminders.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
as="section"
|
||||
@@ -163,7 +180,11 @@ export function CoachRemindersSection({
|
||||
title={t("settings.ai.coachReminders.title")}
|
||||
description={t("settings.ai.coachReminders.description")}
|
||||
/>
|
||||
<p className="text-sm">{t("settings.ai.coachReminders.detail")}</p>
|
||||
<p className="text-sm" data-slot="coach-reminders-detail">
|
||||
{readOnly
|
||||
? t("settings.ai.coachReminders.readOnlyNote")
|
||||
: t("settings.ai.coachReminders.detail")}
|
||||
</p>
|
||||
|
||||
{query.isError && (
|
||||
<QueryErrorRow
|
||||
|
||||
@@ -16,11 +16,12 @@
|
||||
* notification).
|
||||
* 4. Coach memory — durable-fact review + forget controls.
|
||||
*
|
||||
* The nav entry is module-gated on `coach`. The preference + nudge +
|
||||
* reminder cards keep their own content gate on `!user.disableCoach`: hiding
|
||||
* the Coach hides its tuning. The memory (facts and stored conversations) is
|
||||
* never gated: it is the person's own record, readable and deletable whatever
|
||||
* the Coach's state.
|
||||
* The nav entry is module-gated on `coach`. The preference + nudge cards
|
||||
* keep their own content gate on `!user.disableCoach`: hiding the Coach hides
|
||||
* its tuning. The memory (facts, stored conversations and reminders) is never
|
||||
* gated: it is the person's own record, readable and deletable whatever the
|
||||
* Coach's state. The reminders card turns read-only while the Coach is
|
||||
* unavailable and, with the Coach hidden, shows only when rows exist.
|
||||
*
|
||||
* The "about me" context lives in the AI section, not here — the daily
|
||||
* briefing reads it too, so it is not a Coach-only setting.
|
||||
@@ -69,7 +70,10 @@ export function CoachSection() {
|
||||
|
||||
<CoachConversationsMemoryCard isAuthenticated={authed} />
|
||||
|
||||
{coachEnabled && <CoachRemindersSection isAuthenticated={authed} />}
|
||||
<CoachRemindersSection
|
||||
isAuthenticated={authed}
|
||||
hideWhenEmpty={!coachEnabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1745,7 +1745,7 @@ export const coachReminderPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "List the caller's Coach reminders",
|
||||
description:
|
||||
'v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly, soonest-due first. Pass `?status=` (one status or a comma set like `due,surfaced` for the in-app tile); omitted returns the non-terminal set (proposed + active + due + surfaced). Coach-gated (`requireModuleEnabled("coach")`). Auth via cookie or Bearer; the owner is narrowed from the session. Undecryptable rows are omitted.',
|
||||
'v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly, soonest-due first. Pass `?status=` (one status or a comma set like `due,surfaced` for the in-app tile); omitted returns the non-terminal set (proposed + active + due + surfaced). Not gated on the Coach: the reminders belong to the caller and stay readable while the Coach is unavailable (v1.39). Auth via cookie or Bearer; the owner is narrowed from the session. Undecryptable rows are omitted.',
|
||||
parameters: [
|
||||
{
|
||||
name: "status",
|
||||
@@ -1769,8 +1769,8 @@ export const coachReminderPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
// The read is delegable and the create beside it is not, so only this
|
||||
// one shares its 403 with the sharing refusal.
|
||||
...recordRefusal("Coach surface disabled."),
|
||||
// one answers the sharing refusal. It asks no Coach gate.
|
||||
...recordRefusal(),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
@@ -1857,7 +1857,7 @@ export const coachReminderPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "Soft-delete one Coach reminder",
|
||||
description:
|
||||
"v1.22 — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer.",
|
||||
"v1.22 — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`. Not gated on the Coach: erasing a stored reminder works while the Coach is unavailable (v1.39). Auth via cookie or Bearer.",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
|
||||
Reference in new issue
Block a user