From 7d625d9e3a698aa44e1b2e4a1ab66ba090e10a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 22 May 2026 09:03:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(settings):=20user=20can=20hide=20the=20Coa?= =?UTF-8?q?ch=20surface=20from=20Settings=20=E2=86=92=20Insights?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Privacy-leaning users want the Coach affordance gone even when the operator has enabled the global `assistant.coach` feature flag. The new per-user `user.disable_coach` boolean is a hard client-side gate that sits BELOW the existing operator flag — both must agree to render the affordance. The toggle ships as a Switch in Settings → Insights ("Hide Coach" / "Coach ausblenden") and persists via `PATCH /api/auth/me/disable-coach`. Every Coach mount point on the web (``, ``, the inline `` pill, the `` chip strip, the `` action row, the `/targets` page CTAs) reads the new value through a small SSR-safe `useDisableCoach` hook so the long-tail snapshot tests keep rendering without a QueryClientProvider. Default is `false` — the upgrade from v1.4.46 is invisible to anyone who hasn't toggled the new switch. The /api/auth/me payload tolerates an undefined field for partial-deploy rollback safety. A dedicated invariant fixture pins every Coach surface against both the operator-level and the per-user gates; the existing cascade test gains a `generated/` skip so Prisma's `inlineSchema` doc-comment copy doesn't trip the orphan check. Bilingual + es/fr/it/pl translations land in the same commit. --- messages/de.json | 10 +- messages/en.json | 10 +- messages/es.json | 10 +- messages/fr.json | 10 +- messages/it.json | 10 +- messages/pl.json | 10 +- .../migration.sql | 29 ++ prisma/schema.prisma | 65 +++-- .../me/disable-coach/__tests__/route.test.ts | 260 ++++++++++++++++++ src/app/api/auth/me/disable-coach/route.ts | 121 ++++++++ src/app/api/auth/me/route.ts | 6 + src/app/targets/page.tsx | 7 +- .../insights/coach-launch-button.tsx | 11 +- src/components/insights/hero-strip.tsx | 10 +- src/components/insights/layout-coach-fab.tsx | 8 + .../insights/layout-coach-mount.tsx | 7 + src/components/insights/suggested-prompts.tsx | 6 + .../settings/__tests__/ai-section.test.tsx | 24 ++ .../__tests__/disable-coach-card.test.tsx | 160 +++++++++++ src/components/settings/ai-section.tsx | 122 ++++++++ src/hooks/use-auth.ts | 24 +- src/hooks/use-disable-coach.ts | 36 +++ .../__tests__/coach-cascade.test.tsx | 8 + .../__tests__/coach-user-disable.test.tsx | 239 ++++++++++++++++ 24 files changed, 1165 insertions(+), 38 deletions(-) create mode 100644 prisma/migrations/0078_v1447_user_disable_coach/migration.sql create mode 100644 src/app/api/auth/me/disable-coach/__tests__/route.test.ts create mode 100644 src/app/api/auth/me/disable-coach/route.ts create mode 100644 src/components/settings/__tests__/disable-coach-card.test.tsx create mode 100644 src/hooks/use-disable-coach.ts create mode 100644 src/lib/feature-flags/__tests__/coach-user-disable.test.tsx diff --git a/messages/de.json b/messages/de.json index 5fac77931..054c8f549 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1869,7 +1869,15 @@ }, "testProvider": "Aktiven Provider testen", "testSuccess": "OK — {provider} ({model})", - "testFailedShort": "Test fehlgeschlagen: {message}" + "testFailedShort": "Test fehlgeschlagen: {message}", + "disableCoach": { + "title": "Coach ausblenden", + "description": "Versteckt den Coach-Button und das Coach-Panel auf allen Seiten.", + "toggleAria": "Coach-Button und Coach-Panel ausblenden", + "savedHidden": "Coach ist ausgeblendet.", + "savedShown": "Coach ist sichtbar.", + "saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen." + } }, "withings": "Withings", "withingsDescription": "Verbinde deine Withings-Waage und Blutdruckmessgeräte.", diff --git a/messages/en.json b/messages/en.json index f8ca42f8e..9581c5e22 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1869,7 +1869,15 @@ }, "testProvider": "Test active provider", "testSuccess": "OK — {provider} ({model})", - "testFailedShort": "Test failed: {message}" + "testFailedShort": "Test failed: {message}", + "disableCoach": { + "title": "Hide Coach", + "description": "Hides the Coach button and drawer everywhere.", + "toggleAria": "Hide the Coach button and drawer", + "savedHidden": "Coach hidden.", + "savedShown": "Coach visible.", + "saveError": "Could not save the change. Please try again." + } }, "withings": "Withings", "withingsDescription": "Connect your Withings scale and blood pressure monitors.", diff --git a/messages/es.json b/messages/es.json index 2ce6a1d5a..7c394bf40 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1869,7 +1869,15 @@ }, "testProvider": "Test active provider", "testSuccess": "OK — {provider} ({model})", - "testFailedShort": "Test failed: {message}" + "testFailedShort": "Test failed: {message}", + "disableCoach": { + "title": "Ocultar el Coach", + "description": "Oculta el botón del Coach y el panel en todas las páginas.", + "toggleAria": "Ocultar el botón del Coach y el panel", + "savedHidden": "Coach oculto.", + "savedShown": "Coach visible.", + "saveError": "No se pudo guardar el cambio. Inténtalo de nuevo." + } }, "withings": "Withings", "withingsDescription": "Connect your Withings scale and blood pressure monitors.", diff --git a/messages/fr.json b/messages/fr.json index b2e101020..50c5da96f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1869,7 +1869,15 @@ }, "testProvider": "Test active provider", "testSuccess": "OK — {provider} ({model})", - "testFailedShort": "Test failed: {message}" + "testFailedShort": "Test failed: {message}", + "disableCoach": { + "title": "Masquer le Coach", + "description": "Masque le bouton et le panneau du Coach sur toutes les pages.", + "toggleAria": "Masquer le bouton et le panneau du Coach", + "savedHidden": "Coach masqué.", + "savedShown": "Coach visible.", + "saveError": "Impossible d'enregistrer la modification. Veuillez réessayer." + } }, "withings": "Withings", "withingsDescription": "Connect your Withings scale and blood pressure monitors.", diff --git a/messages/it.json b/messages/it.json index 4757bf1f1..ef5bee847 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1869,7 +1869,15 @@ }, "testProvider": "Test active provider", "testSuccess": "OK — {provider} ({model})", - "testFailedShort": "Test failed: {message}" + "testFailedShort": "Test failed: {message}", + "disableCoach": { + "title": "Nascondi il Coach", + "description": "Nasconde il pulsante e il pannello del Coach su tutte le pagine.", + "toggleAria": "Nascondi il pulsante e il pannello del Coach", + "savedHidden": "Coach nascosto.", + "savedShown": "Coach visibile.", + "saveError": "Impossibile salvare la modifica. Riprova." + } }, "withings": "Withings", "withingsDescription": "Connect your Withings scale and blood pressure monitors.", diff --git a/messages/pl.json b/messages/pl.json index 4cd512dba..6f8113525 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1869,7 +1869,15 @@ }, "testProvider": "Test active provider", "testSuccess": "OK — {provider} ({model})", - "testFailedShort": "Test failed: {message}" + "testFailedShort": "Test failed: {message}", + "disableCoach": { + "title": "Ukryj Coacha", + "description": "Ukrywa przycisk Coacha i panel na wszystkich stronach.", + "toggleAria": "Ukryj przycisk Coacha i panel", + "savedHidden": "Coach ukryty.", + "savedShown": "Coach widoczny.", + "saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie." + } }, "withings": "Withings", "withingsDescription": "Connect your Withings scale and blood pressure monitors.", diff --git a/prisma/migrations/0078_v1447_user_disable_coach/migration.sql b/prisma/migrations/0078_v1447_user_disable_coach/migration.sql new file mode 100644 index 000000000..3133aebb5 --- /dev/null +++ b/prisma/migrations/0078_v1447_user_disable_coach/migration.sql @@ -0,0 +1,29 @@ +-- v1.4.47 W3 — per-user Coach opt-out. +-- +-- Privacy-leaning users want to hide the Coach surface even when the +-- operator has enabled the global `assistant.coach` feature flag. The +-- per-user `disable_coach` column is a hard client-side gate consulted +-- by every Coach mount point (``, ``, +-- ``, `/targets` page CTA, …) AFTER the +-- operator-level flag check — both must agree to render the affordance. +-- +-- Default `false` keeps the existing posture: every user who has not +-- explicitly opted out continues to see the Coach button + drawer +-- exactly as before. The toggle lives in Settings → Insights as a +-- `` labelled "Hide Coach" / "Coach ausblenden"; persisted via +-- `PATCH /api/auth/me/disable-coach`. +-- +-- Idempotent guard (`IF NOT EXISTS`) matches the 0067 / 0070 / 0071 / +-- 0075 pattern so reruns are safe on prod where the migration has +-- already been applied. +-- +-- Reversibility: down migration is `ALTER TABLE "users" DROP COLUMN +-- IF EXISTS "disable_coach";`. The column is `NOT NULL DEFAULT false` +-- so the down migration is lossy (every opted-out user reverts to +-- "Coach visible") but never crashes a roll-back. The application +-- layer treats a missing column the same as `false` (the auth/me +-- payload defaults the field) so a partial deploy where the schema +-- is rolled back ahead of the app code keeps working. + +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "disable_coach" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e1a03e68e..509ffb1a2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -223,6 +223,23 @@ model User { // payload when the toggle is off so the data never leaves the DB row. doctorReportPrefsJson Json? @map("doctor_report_prefs_json") + // v1.4.47 W3 — per-user Coach opt-out (privacy-leaning users). + // + // When `true`, every Coach mount point on the web client renders + // nothing — ``, ``, the inline + // `` pill, the `/targets` page CTA, etc. The + // gate sits BELOW the operator-level `flags.coach` check: both + // must agree to render the affordance. Default `false` so the + // upgrade from v1.4.46 is invisible to anyone who hasn't toggled + // the new switch. + // + // Persistence point is the Settings → Insights `` labelled + // "Hide Coach" / "Coach ausblenden"; the toggle writes through + // `PATCH /api/auth/me/disable-coach` and the next /api/auth/me + // round-trip surfaces the new value to every Coach gate via the + // `useAuth()` hook. + disableCoach Boolean @default(false) @map("disable_coach") + // Relations passkeys Passkey[] sessions Session[] @@ -258,25 +275,25 @@ model User { // session can build against a stable DTO. Routes hang off Workout // (1:1), not User — the User → WorkoutRoute path is two hops by // design. - workouts Workout[] + workouts Workout[] // v1.4.25 W8d — personal records (schema only; detection worker // lands in a later release). The GET /api/personal-records route // ships with v1.4.25 so the iOS app can build its query path // against a stable contract. - personalRecords PersonalRecord[] + personalRecords PersonalRecord[] // v1.4.25 W19b — per-pen / per-vial inventory entities, the // companion to the running-sum MedicationInventoryEvent ledger. // Carries the EMA §6.3 30-day in-use clock per opened pen. - inventoryItems MedicationInventoryItem[] + inventoryItems MedicationInventoryItem[] // v1.4.25 W19d — GLP-1 side-effect logbook. 21-entry × 5-category // EMA EPAR §4.8-derived taxonomy with 1-5 Likert severity. Daily // capture (not per-shot) so delayed-onset symptoms map to their // actual occurrence time. - medicationSideEffects MedicationSideEffect[] + medicationSideEffects MedicationSideEffect[] // v1.4.34 — Apple Health `export.zip` import jobs. Mirrors the // pg-boss job state so the polling endpoint can read live per- // phase progress without round-tripping through pg-boss internals. - importJobs ImportJob[] + importJobs ImportJob[] // v1.5.0 — persistent measurement rollup table. Carries the // count/min/max/mean/sd/slope/r2 stats per (type, granularity, // bucketStart) so the analytics + comprehensive read paths bypass @@ -284,19 +301,19 @@ model User { // multi-instance fan-out. Populated synchronously on the DAY // granularity from the measurement write hooks; WEEK/MONTH/YEAR // are enqueued through pg-boss for the worker to fold. - measurementRollups MeasurementRollup[] + measurementRollups MeasurementRollup[] // v0.5.4 ios-coord — per-day dedup ledger for MOOD_REMINDER pushes. // One row per (user, local date) the worker has nudged. Prevents // a second push when the cron rechecks the same hour later in the // 22:00 window (the cron fires every 15 min — without the ledger // a user could see three pushes back-to-back). - moodReminderDispatches MoodReminderDispatch[] + moodReminderDispatches MoodReminderDispatch[] // v1.4.39 W-MOOD — per-(user, granularity, bucket) mood rollup. New // tier behind the mood-analytics read path; per-write hooks fold // MoodEntry mutations into the DAY granularity inline so the next // read serves from this table instead of the unbounded // MoodEntry.findMany walk. - moodEntryRollups MoodEntryRollup[] + moodEntryRollups MoodEntryRollup[] // v1.4.39 W-MED — per-(user, medication, day) compliance rollup. // Per-write hook on MedicationIntakeEvent mutations + the scheduler // keep the row in sync so `/api/medications/intake?scope=compliance` @@ -308,7 +325,7 @@ model User { // be verifiable") plus the operator's GDPR Art. 7 documentation // burden. Append-only — a user revoking + re-granting consent mints // a fresh row so the timeline reads chronologically. - consentReceipts ConsentReceipt[] + consentReceipts ConsentReceipt[] @@map("users") } @@ -739,22 +756,22 @@ model Medication { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - schedules MedicationSchedule[] - intakeEvents MedicationIntakeEvent[] - phaseConfig ReminderPhaseConfig? - telegramMessages TelegramReminderMessage[] - doseChanges MedicationDoseChange[] - inventoryEvents MedicationInventoryEvent[] + schedules MedicationSchedule[] + intakeEvents MedicationIntakeEvent[] + phaseConfig ReminderPhaseConfig? + telegramMessages TelegramReminderMessage[] + doseChanges MedicationDoseChange[] + inventoryEvents MedicationInventoryEvent[] /// v1.4.25 W19b — per-pen / per-vial entities (state machine + /// 30-day in-use clock). Complements the running-sum /// MedicationInventoryEvent ledger. - inventoryItems MedicationInventoryItem[] + inventoryItems MedicationInventoryItem[] /// v1.4.25 W19d — structured GLP-1 side-effect logs. Surfaced on /// the medication detail page for GLP-1 treatmentClass only; the /// taxonomy follows EMA EPAR §4.8 with HealthLog's 5-category /// roll-up (GI / METABOLIC / INJECTION_SITE / COGNITIVE / /// GLP1_SPECIFIC) and 1-5 Likert severity per entry. - sideEffects MedicationSideEffect[] + sideEffects MedicationSideEffect[] /// v1.4.39 W-MED — per-(medication, day) compliance rollup. Mutations /// on the linked MedicationIntakeEvent rows refresh the row inline; /// the read paths consume this instead of the per-event findMany. @@ -1986,20 +2003,20 @@ model CoachUsage { // distinguish "ai_full" / "ai_insights_only" / future scopes without // requiring a schema migration per new tier. model ConsentReceipt { - id String @id @default(cuid()) - userId String @map("user_id") - kind String + id String @id @default(cuid()) + userId String @map("user_id") + kind String /// Raw consent artefact — either a base64-encoded PDF or a signed /// JWT. The server does not parse this payload; the audit trail is /// the storage itself plus the `signedAt` timestamp. - artefact String @db.Text + artefact String @db.Text /// Wall-clock the client recorded the user's tap. UTC ISO-8601. - signedAt DateTime @map("signed_at") + signedAt DateTime @map("signed_at") /// Optional revocation marker — set when a follow-up "revoke" row /// is minted so the latest-per-user lookup can shortcut to "no /// active consent" without diffing the kind discriminator. - revokedAt DateTime? @map("revoked_at") - createdAt DateTime @default(now()) @map("created_at") + revokedAt DateTime? @map("revoked_at") + createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/app/api/auth/me/disable-coach/__tests__/route.test.ts b/src/app/api/auth/me/disable-coach/__tests__/route.test.ts new file mode 100644 index 000000000..8b71b1547 --- /dev/null +++ b/src/app/api/auth/me/disable-coach/__tests__/route.test.ts @@ -0,0 +1,260 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + }), + rateLimitHeaders: () => ({}), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, PATCH } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const }, +}; + +function mkPatch(body: unknown): Request { + return new Request("http://localhost/api/auth/me/disable-coach", { + method: "PATCH", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /api/auth/me/disable-coach", () => { + it("rejects an unauthenticated request with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/disable-coach"), + ); + expect(res.status).toBe(401); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("returns false for a fresh user", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + disableCoach: false, + } as never); + + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/disable-coach"), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { data: { disableCoach: boolean } }; + expect(env.data.disableCoach).toBe(false); + }); + + it("returns true for an opted-out user", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + disableCoach: true, + } as never); + + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/disable-coach"), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { data: { disableCoach: boolean } }; + expect(env.data.disableCoach).toBe(true); + }); + + it("returns false when the column is missing (partial-deploy rollback)", async () => { + // Defensive default — the API surface tolerates a `findUnique` + // result that doesn't contain the column. Belt-and-braces with the + // schema-level NOT NULL default; both layers keep the Coach + // visible by default for users who never opted out. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({} as never); + + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/disable-coach"), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { data: { disableCoach: boolean } }; + expect(env.data.disableCoach).toBe(false); + }); +}); + +describe("PATCH /api/auth/me/disable-coach", () => { + it("rejects an unauthenticated request with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ disableCoach: true }), + ); + expect(res.status).toBe(401); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("flips the column on and writes the enable audit row", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + disableCoach: false, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ disableCoach: true }), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { data: { disableCoach: boolean } }; + expect(env.data.disableCoach).toBe(true); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { disableCoach: true }, + }); + + expect(auditLog).toHaveBeenCalledWith( + "user.disable-coach.enable", + expect.objectContaining({ + userId: "user-1", + details: expect.objectContaining({ + previous: false, + next: true, + }), + }), + ); + }); + + it("flips the column off and writes the disable audit row", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + disableCoach: true, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ disableCoach: false }), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { data: { disableCoach: boolean } }; + expect(env.data.disableCoach).toBe(false); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { disableCoach: false }, + }); + + expect(auditLog).toHaveBeenCalledWith( + "user.disable-coach.disable", + expect.objectContaining({ + userId: "user-1", + details: expect.objectContaining({ + previous: true, + next: false, + }), + }), + ); + }); + + it("rejects a missing disableCoach field with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ otherField: "ignored" }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects a non-boolean disableCoach field with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ disableCoach: "true" }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects malformed JSON with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + + const req = new Request("http://localhost/api/auth/me/disable-coach", { + method: "PATCH", + body: "{ not valid json", + headers: { "Content-Type": "application/json" }, + }); + + const res = await (PATCH as (r: Request) => Promise)(req); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("is idempotent when the column already holds the requested value", async () => { + // Toggling a column from true→true (or false→false) still writes + // through so the audit row exists. Matches the research-mode + // route's "always write" posture. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + disableCoach: true, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ disableCoach: true }), + ); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledTimes(1); + expect(auditLog).toHaveBeenCalledTimes(1); + }); + + it("returns 429 when the per-user rate-limit fires", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 30_000, + }); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ disableCoach: true }), + ); + expect(res.status).toBe(429); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/me/disable-coach/route.ts b/src/app/api/auth/me/disable-coach/route.ts new file mode 100644 index 000000000..87f2c352e --- /dev/null +++ b/src/app/api/auth/me/disable-coach/route.ts @@ -0,0 +1,121 @@ +/** + * v1.4.47 W3 — per-user Coach opt-out endpoint. + * + * GET /api/auth/me/disable-coach — current flag. + * PATCH /api/auth/me/disable-coach — body `{ disableCoach: boolean }`. + * Flips the column and emits an + * audit-log row mirroring the + * Research-Mode endpoint pattern. + * + * The toggle is reachable from Settings → Insights as a `` + * labelled "Hide Coach" / "Coach ausblenden". Default `false` (Coach + * visible) — see migration `0076_v1447_user_disable_coach` for the + * column-level documentation. + * + * Idempotent. The endpoint always returns the resolved next-state + * shape so the client can hard-set the optimistic update without an + * extra round-trip. Rate-limit is intentionally generous (60/min) — + * the Switch is a one-tap affordance, but iOS clients may eventually + * batch settings round-trips after a passkey re-pair and a tight + * 5/min would surface as a 429 on a normal onboarding flow. + */ +import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; + +export const dynamic = "force-dynamic"; + +const PATCH_RATE_LIMIT = 60; +const PATCH_WINDOW_MS = 60_000; + +type DisableCoachResponse = { + disableCoach: boolean; +}; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "auth.me.disable-coach.get" } }); + + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { disableCoach: true }, + }); + const payload: DisableCoachResponse = { + disableCoach: row?.disableCoach ?? false, + }; + return apiSuccess(payload); +}); + +export const PATCH = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `disable-coach:patch:${user.id}`, + PATCH_RATE_LIMIT, + PATCH_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + let body: unknown; + try { + body = await req.json(); + } catch { + throw new HttpError(422, "disable-coach.body.invalid_json"); + } + + // Hand-rolled validation — single boolean field. Keeps the + // prompt-injection surface visibly empty in code review (no string + // ever reaches an LLM). + if ( + !body || + typeof body !== "object" || + typeof (body as { disableCoach?: unknown }).disableCoach !== "boolean" + ) { + annotate({ action: { name: "auth.me.disable-coach.patch.invalid_shape" } }); + throw new HttpError(422, "disable-coach.body.invalid_shape"); + } + + const next = (body as { disableCoach: boolean }).disableCoach; + + const previous = await prisma.user.findUnique({ + where: { id: user.id }, + select: { disableCoach: true }, + }); + + // Idempotent — write even when the values match so the audit row + // mirrors the API call. The DB write is cheap and the audit log is + // the source of truth for "user toggled this". + await prisma.user.update({ + where: { id: user.id }, + data: { disableCoach: next }, + }); + + await auditLog( + next ? "user.disable-coach.enable" : "user.disable-coach.disable", + { + userId: user.id, + ipAddress: getClientIp(req), + details: { + previous: previous?.disableCoach ?? false, + next, + }, + }, + ); + + annotate({ + action: { name: "auth.me.disable-coach.patch" }, + meta: { disableCoach: next }, + }); + + const payload: DisableCoachResponse = { disableCoach: next }; + return apiSuccess(payload); +}); diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 4b71f2530..c83ac18f5 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -32,5 +32,11 @@ export const GET = apiHandler(async () => { gravatarUrl: user.email ? getGravatarUrl(user.email) : null, glucoseUnit: user.glucoseUnit ?? null, lastReportPracticeName: user.lastReportPracticeName ?? null, + // v1.4.47 W3 — per-user Coach opt-out. Default `false` if the + // column is absent (partial-deploy rollback safety, see migration + // 0076 commentary). Every Coach mount point on the client checks + // `user.disableCoach` BELOW the operator-level `flags.coach` + // short-circuit; both gates must agree to paint the affordance. + disableCoach: user.disableCoach ?? false, }); }); diff --git a/src/app/targets/page.tsx b/src/app/targets/page.tsx index 0a8b524d9..ec42546f2 100644 --- a/src/app/targets/page.tsx +++ b/src/app/targets/page.tsx @@ -129,7 +129,7 @@ function getTargetSourceLink(target: TargetData): string | null { export default function TargetsPage() { const { t } = useTranslations(); - const { isAuthenticated } = useAuth(); + const { isAuthenticated, user } = useAuth(); const { data, isLoading } = useQuery({ queryKey: queryKeys.insightsTargets(), @@ -162,7 +162,10 @@ export default function TargetsPage() { // below. The flag default is all-on for fresh installs and on any // fetch error, so the path stays open by default. const flags = useFeatureFlags(); - const coachEnabled = flags.coach; + // v1.4.47 W3 — per-user "Hide Coach" toggle is a peer gate to the + // operator's master flag. When either is off, the per-card CTAs + + // the page-level mount below both go dark. + const coachEnabled = flags.coach && !user?.disableCoach; const aiEnabled = coachEnabled && chainStatus?.activeProvider != null; // v1.4.25 W3e — Coach drawer state owned by the page. The per-card diff --git a/src/components/insights/coach-launch-button.tsx b/src/components/insights/coach-launch-button.tsx index 84c7dd6bc..d6e1dd7d4 100644 --- a/src/components/insights/coach-launch-button.tsx +++ b/src/components/insights/coach-launch-button.tsx @@ -7,6 +7,7 @@ import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; +import { useDisableCoach } from "@/hooks/use-disable-coach"; /** * Inline desktop Coach launch button. @@ -42,6 +43,7 @@ export function CoachLaunchButton({ const { t } = useTranslations(); const launch = useCoachLaunch(); const flags = useFeatureFlags(); + const disableCoach = useDisableCoach(); if (!launch) { // The button only makes sense beneath the provider. Render nothing @@ -50,6 +52,10 @@ export function CoachLaunchButton({ } // v1.4.31 — operator can hide the Coach surface app-wide. if (!flags.coach) return null; + // v1.4.47 W3 — per-user opt-out is a peer gate to the operator's + // flag; either being off hides the pill entirely. See + // `` for the matching FAB gate. + if (disableCoach) return null; const accessibleLabel = label ?? t("insights.heroActionAskCoach"); @@ -64,10 +70,7 @@ export function CoachLaunchButton({ size="sm" data-slot="coach-launch-inline" onClick={() => launch.askCoach(prefill ?? null)} - className={cn( - "hidden h-10 gap-2 self-end lg:inline-flex", - className, - )} + className={cn("hidden h-10 gap-2 self-end lg:inline-flex", className)} >