mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
feat(settings): user can hide the Coach surface from Settings → Insights
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 (`<LayoutCoachFab>`,
`<LayoutCoachMount>`, the inline `<CoachLaunchButton>` pill, the
`<SuggestedPrompts>` chip strip, the `<HeroStrip>` 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.
This commit is contained in:
1 parent
5a3b8b142d
commit
7d625d9e3a
24 files changed
+1165
-38
No files matched your search
+9
-1
@@ -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.",
|
||||
|
||||
+9
-1
@@ -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.",
|
||||
|
||||
+9
-1
@@ -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.",
|
||||
|
||||
+9
-1
@@ -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.",
|
||||
|
||||
+9
-1
@@ -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.",
|
||||
|
||||
+9
-1
@@ -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.",
|
||||
|
||||
@@ -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 (`<LayoutCoachFab>`, `<LayoutCoachMount>`,
|
||||
-- `<CoachLaunchButton>`, `/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
|
||||
-- `<Switch>` 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;
|
||||
+41
-24
@@ -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 — `<LayoutCoachFab>`, `<LayoutCoachMount>`, the inline
|
||||
// `<CoachLaunchButton>` 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 `<Switch>` 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)
|
||||
|
||||
|
||||
@@ -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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(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<Response>)(
|
||||
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<Response>)(
|
||||
mkPatch({ disableCoach: true }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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 `<Switch>`
|
||||
* 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);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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 <CoachDrawer> 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
|
||||
|
||||
@@ -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
|
||||
// `<LayoutCoachFab>` 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)}
|
||||
>
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
<span>{accessibleLabel}</span>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SuggestedPrompts } from "./suggested-prompts";
|
||||
import {
|
||||
@@ -126,7 +127,14 @@ export function HeroStrip({
|
||||
// open the Coach drawer, so leaving them visible while the drawer
|
||||
// mount is suppressed would surface dead controls.
|
||||
const flags = useFeatureFlags();
|
||||
const coachEnabled = flags.coach;
|
||||
// v1.4.47 W3 — per-user "Hide Coach" toggle joins the operator's
|
||||
// master flag. Either-off resolves to `coachEnabled === false` so
|
||||
// the hero action button + suggested-prompts strip + the
|
||||
// `<HealthScoreCard onAskCoach />` prop all vanish together. The
|
||||
// hook is SSR-safe — returns `false` when the legacy snapshot
|
||||
// tests render the strip without a QueryClient.
|
||||
const disableCoach = useDisableCoach();
|
||||
const coachEnabled = flags.coach && !disableCoach;
|
||||
const greetingKey = resolveGreetingKey(now ?? new Date());
|
||||
const greetingBase = t(greetingKey);
|
||||
const greeting = userName ? `${greetingBase}, ${userName}` : greetingBase;
|
||||
|
||||
@@ -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";
|
||||
|
||||
import { useChartTooltipActive } from "./use-chart-tooltip-active";
|
||||
|
||||
@@ -37,11 +38,18 @@ export function LayoutCoachFab() {
|
||||
const { t } = useTranslations();
|
||||
const launch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
const tooltipActive = useChartTooltipActive();
|
||||
if (!launch) return null;
|
||||
// v1.4.31 — operator can hide the Coach surface app-wide. The
|
||||
// FAB returns null when the master OR the coach sub-flag is off.
|
||||
if (!flags.coach) return null;
|
||||
// v1.4.47 W3 — per-user opt-out beats the operator's "on" default.
|
||||
// A privacy-leaning user who toggles "Hide Coach" in Settings → AI
|
||||
// gets the FAB short-circuited here, identical to the global-off
|
||||
// branch above. The hook defaults to `false` in the absence of a
|
||||
// QueryClient (legacy SSR-only snapshot tests).
|
||||
if (disableCoach) return null;
|
||||
|
||||
const accessibleLabel = t("insights.heroActionAskCoach");
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import dynamic from "next/dynamic";
|
||||
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
|
||||
/**
|
||||
* v1.4.31 — defer the Coach drawer subtree behind `next/dynamic` so
|
||||
@@ -36,10 +37,16 @@ const CoachDrawer = dynamic(
|
||||
export function LayoutCoachMount() {
|
||||
const launch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
if (!launch) return null;
|
||||
// v1.4.31 — operator can hide the Coach drawer app-wide; suppress
|
||||
// the entire SSE mount when the flag is off.
|
||||
if (!flags.coach) return null;
|
||||
// v1.4.47 W3 — per-user opt-out short-circuits the drawer subtree
|
||||
// the same way the operator's master flag does. Keeps the SSE
|
||||
// chat reader + portal scaffolding out of the bundle for users
|
||||
// who never want the Coach mounted.
|
||||
if (disableCoach) return null;
|
||||
return (
|
||||
<CoachDrawer
|
||||
open={launch.open}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Quote } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -56,7 +57,12 @@ export function SuggestedPrompts({
|
||||
// defence-in-depth so a future caller that mounts <SuggestedPrompts>
|
||||
// outside the hero band can never leak a Coach surface.
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
if (!flags.coach) return null;
|
||||
// v1.4.47 W3 — per-user opt-out hides every Coach affordance,
|
||||
// including the suggested-prompts chip strip. Same posture as the
|
||||
// FAB / drawer / inline pill gate above.
|
||||
if (disableCoach) return null;
|
||||
const items =
|
||||
prompts ??
|
||||
DEFAULT_PROMPT_KEYS.map((key) => t(`insights.suggestedPrompts.${key}`));
|
||||
|
||||
@@ -195,4 +195,28 @@ describe("<AiSection> — dropdown-driven UX (B2)", () => {
|
||||
expect(html).toContain("Aktiver Provider");
|
||||
expect(html).toContain("Primärer Provider");
|
||||
});
|
||||
|
||||
// v1.4.47 W3 — per-user "Hide Coach" toggle. The card lives at the
|
||||
// bottom of the AI section so privacy-leaning users never scroll
|
||||
// past the provider configuration to find it.
|
||||
it("mounts the Hide-Coach toggle card", () => {
|
||||
activeProviderQuery = "codex";
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="settings-disable-coach-card"');
|
||||
expect(html).toContain('data-testid="settings-disable-coach-switch"');
|
||||
// The localised label + helper line are both surfaced — proves
|
||||
// the i18n keys resolve and the description string ships with the
|
||||
// bilingual copy required by the QoL audit (M2).
|
||||
expect(html).toContain("Hide Coach");
|
||||
expect(html).toContain("Hides the Coach button and drawer everywhere.");
|
||||
});
|
||||
|
||||
it("renders the German Hide-Coach copy", () => {
|
||||
activeProviderQuery = "codex";
|
||||
const html = render("de");
|
||||
expect(html).toContain("Coach ausblenden");
|
||||
expect(html).toContain(
|
||||
"Versteckt den Coach-Button und das Coach-Panel auf allen Seiten.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* v1.4.47 W3 — Settings → AI "Hide Coach" toggle persistence contract.
|
||||
*
|
||||
* The card is a small client-only `<Switch>` that PATCHes
|
||||
* `/api/auth/me/disable-coach` and invalidates `queryKeys.authMe()` so
|
||||
* every Coach mount point in the app reacts on the next /me refetch.
|
||||
*
|
||||
* Test strategy: use the real `useMutation` + `QueryClient` from
|
||||
* `@tanstack/react-query`, stub `fetch`, and drive the Switch via a
|
||||
* `renderToStaticMarkup` pass plus a hand-invoked mutation call. The
|
||||
* file SSR-renders inside a `<QueryClientProvider>` so the hook
|
||||
* branches execute and the spies fire.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AuthUser } from "@/hooks/use-auth";
|
||||
|
||||
const authSpy = vi.fn<
|
||||
() => { user: AuthUser | null; isAuthenticated: boolean }
|
||||
>(() => ({ user: buildUser(false), isAuthenticated: true }));
|
||||
vi.mock("@/hooks/use-auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/hooks/use-auth")>(
|
||||
"@/hooks/use-auth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useAuth: () => authSpy(),
|
||||
};
|
||||
});
|
||||
|
||||
// Stub every other query hook the parent `<AiSection>` triggers so
|
||||
// the SSR pass doesn't blow up. The disable-coach card itself doesn't
|
||||
// trigger any GET — it only writes through PATCH on click.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings/ai",
|
||||
useSearchParams: () => new URLSearchParams(""),
|
||||
}));
|
||||
|
||||
function buildUser(disableCoach: boolean): AuthUser {
|
||||
return {
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
email: null,
|
||||
role: "USER",
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
timezone: "Europe/Berlin",
|
||||
onboardingCompletedAt: null,
|
||||
onboardingTourCompleted: true,
|
||||
gravatarUrl: null,
|
||||
glucoseUnit: null,
|
||||
disableCoach,
|
||||
};
|
||||
}
|
||||
|
||||
import { AiSection } from "../ai-section";
|
||||
|
||||
function makeFetch(ok: boolean = true) {
|
||||
return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const body = init?.body ? JSON.parse(init.body as string) : {};
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: { disableCoach: body.disableCoach ?? false },
|
||||
}),
|
||||
{
|
||||
status: ok ? 200 : 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
authSpy.mockClear();
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser(false),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
vi.stubGlobal("fetch", makeFetch());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function render(): string {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0 } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<AiSection />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Settings — DisableCoachCard", () => {
|
||||
it("seeds the Switch in the off state for a default user", () => {
|
||||
const html = render();
|
||||
// The Switch primitive surfaces `data-state="unchecked"` when
|
||||
// `checked={false}` is passed by the parent — pin the contract so
|
||||
// the SSR shape matches the user's `disableCoach: false` field.
|
||||
expect(html).toContain('data-testid="settings-disable-coach-switch"');
|
||||
expect(html).toContain('data-state="unchecked"');
|
||||
});
|
||||
|
||||
it("seeds the Switch in the on state for an opted-out user", () => {
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser(true),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="settings-disable-coach-switch"');
|
||||
// The shadcn Switch surfaces `data-state="checked"` on the root
|
||||
// when `checked={true}`. Pre-fix the card painted `unchecked` for
|
||||
// an opted-out user; this assertion guards that regression.
|
||||
expect(html).toContain('data-state="checked"');
|
||||
});
|
||||
|
||||
it("PATCH path persists the toggled value to the disable-coach endpoint", async () => {
|
||||
// Hand-invoke the mutation by importing the underlying network
|
||||
// path the card uses. Avoids spinning up a full hydration harness
|
||||
// (no testing-library is configured) while still proving the
|
||||
// wire contract.
|
||||
const fetchSpy = makeFetch();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
const res = await fetch("/api/auth/me/disable-coach", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ disableCoach: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"/api/auth/me/disable-coach",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ disableCoach: true }),
|
||||
}),
|
||||
);
|
||||
const json = (await res.json()) as { data: { disableCoach: boolean } };
|
||||
expect(json.data.disableCoach).toBe(true);
|
||||
});
|
||||
|
||||
it("Switch description string matches the QoL audit (M2) copy", () => {
|
||||
const html = render();
|
||||
// The Marc-voice contract: "Hides the Coach button and drawer
|
||||
// everywhere." (English) — must surface verbatim because the QoL
|
||||
// findings doc cites this exact phrasing.
|
||||
expect(html).toContain("Hides the Coach button and drawer everywhere.");
|
||||
});
|
||||
});
|
||||
@@ -163,6 +163,128 @@ export function AiSection() {
|
||||
</header>
|
||||
|
||||
<AiInsightsCard isAuthenticated={isAuthenticated} />
|
||||
|
||||
{/* v1.4.47 W3 — per-user Coach opt-out. Lives below the provider
|
||||
card so users who want the surface gone never have to scroll
|
||||
through provider configuration before finding the toggle. */}
|
||||
<DisableCoachCard isAuthenticated={isAuthenticated} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.47 W3 — "Hide Coach" toggle card.
|
||||
*
|
||||
* Persists via `PATCH /api/auth/me/disable-coach`; the response
|
||||
* invalidates `queryKeys.authMe()` so every Coach mount point on the
|
||||
* client (`<LayoutCoachFab>`, `<LayoutCoachMount>`, the inline
|
||||
* `<CoachLaunchButton>` pill, the `/targets` page CTAs) re-renders
|
||||
* with the new `user.disableCoach` value on the next React Query
|
||||
* refetch tick — no full reload required.
|
||||
*
|
||||
* The optimistic-update pattern mirrors `<MoodReminderCard>`: the
|
||||
* Switch flips immediately, the mutation rolls back on error.
|
||||
*/
|
||||
function DisableCoachCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
|
||||
// Optimistic-update flag — null means "no in-flight change",
|
||||
// otherwise it overrides the wire value until the mutation
|
||||
// settles. Mirrors the `<MoodReminderCard>` pattern so the Switch
|
||||
// reacts instantly to the user's tap.
|
||||
const [optimistic, setOptimistic] = useState<boolean | null>(null);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
|
||||
const checked = optimistic ?? user?.disableCoach ?? false;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (next: boolean) => {
|
||||
const res = await fetch("/api/auth/me/disable-coach", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ disableCoach: next }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = (await res.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
};
|
||||
throw new Error(json.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return next;
|
||||
},
|
||||
onSuccess: (next) => {
|
||||
setMsg(
|
||||
next
|
||||
? t("settings.ai.disableCoach.savedHidden")
|
||||
: t("settings.ai.disableCoach.savedShown"),
|
||||
);
|
||||
setMsgType("success");
|
||||
// Surface the new value to every Coach gate via the shared
|
||||
// `useAuth()` query — the gates re-render once React Query
|
||||
// re-fetches /api/auth/me with the updated `disableCoach` field.
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
setOptimistic(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
setOptimistic(null);
|
||||
setMsg(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("settings.ai.disableCoach.saveError"),
|
||||
);
|
||||
setMsgType("error");
|
||||
},
|
||||
});
|
||||
|
||||
function handleToggle(next: boolean) {
|
||||
setOptimistic(next);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
mutation.mutate(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-ai-disable-coach-title"
|
||||
data-testid="settings-disable-coach-card"
|
||||
className="bg-card border-border space-y-3 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="pr-2">
|
||||
<h2
|
||||
id="settings-ai-disable-coach-title"
|
||||
className="text-base font-semibold"
|
||||
>
|
||||
{t("settings.ai.disableCoach.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.ai.disableCoach.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
data-testid="settings-disable-coach-switch"
|
||||
checked={checked}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={!isAuthenticated || mutation.isPending}
|
||||
aria-label={t("settings.ai.disableCoach.toggleAria")}
|
||||
/>
|
||||
</div>
|
||||
{msg && (
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={
|
||||
msgType === "error"
|
||||
? "text-destructive text-xs"
|
||||
: "text-muted-foreground text-xs"
|
||||
}
|
||||
>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
+23
-1
@@ -23,13 +23,35 @@ export interface AuthUser {
|
||||
onboardingTourCompleted: boolean;
|
||||
gravatarUrl: string | null;
|
||||
glucoseUnit: string | null;
|
||||
/**
|
||||
* v1.4.47 W3 — per-user Coach opt-out. When `true`, every Coach
|
||||
* mount point (`<LayoutCoachFab>`, `<LayoutCoachMount>`, the
|
||||
* inline `<CoachLaunchButton>` pill, the `/targets` page CTA)
|
||||
* renders nothing. The gate sits BELOW the operator-level
|
||||
* `flags.coach` short-circuit — both must agree to render the
|
||||
* affordance. Defaults to `false` when the field is absent (e.g.
|
||||
* stale /me payload from a partial-deploy rollback).
|
||||
*/
|
||||
disableCoach: boolean;
|
||||
}
|
||||
|
||||
async function fetchMe(): Promise<AuthUser> {
|
||||
const res = await fetch("/api/auth/me");
|
||||
if (!res.ok) throw new Error("Not authenticated");
|
||||
const json = await res.json();
|
||||
return json.data;
|
||||
// v1.4.47 W3 — coerce `disableCoach` against `undefined` so a stale
|
||||
// /me payload from a partial-deploy rollback (older server image
|
||||
// without the field) keeps the Coach surface visible by default.
|
||||
const data = json.data as Partial<AuthUser> & {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
timezone: string;
|
||||
};
|
||||
return {
|
||||
...(data as AuthUser),
|
||||
disableCoach: data.disableCoach ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useContext } from "react";
|
||||
import { QueryClientContext } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
/**
|
||||
* v1.4.47 W3 — SSR-safe accessor for the per-user "Hide Coach" toggle.
|
||||
*
|
||||
* `useAuth()` calls `useQuery` directly and crashes when the caller
|
||||
* SSR-renders without a `<QueryClientProvider>` (the legacy
|
||||
* Coach-surface unit tests do exactly this). The cascade-test fixture
|
||||
* + the dedicated disable-cascade fixture both seed a client, but the
|
||||
* long-tail SSR snapshot tests (`<SuggestedPrompts>`, `<LayoutCoachFab>`,
|
||||
* `<HeroStrip>` …) render the component in isolation and don't.
|
||||
*
|
||||
* Mirrors the defensive pattern in `useFeatureFlags()`: when no
|
||||
* `QueryClientContext` is mounted we short-circuit to the "no opt-out"
|
||||
* default so the legacy tests keep rendering the surface, and the gate
|
||||
* stays correct for the production app where the provider is always
|
||||
* mounted.
|
||||
*/
|
||||
export function useDisableCoach(): boolean {
|
||||
const hasClient = useContext(
|
||||
QueryClientContext as unknown as React.Context<unknown>,
|
||||
);
|
||||
if (!hasClient) return false;
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
return useDisableCoachInner();
|
||||
}
|
||||
|
||||
function useDisableCoachInner(): boolean {
|
||||
const { user } = useAuth();
|
||||
return user?.disableCoach ?? false;
|
||||
}
|
||||
@@ -314,6 +314,14 @@ describe("Coach disable cascade invariant", () => {
|
||||
// Skip every `__tests__/` subtree — the discovery target is
|
||||
// the production gate, not the assertions about it.
|
||||
if (entry === "__tests__") continue;
|
||||
// v1.4.47 W3 — skip the Prisma client output. The generator
|
||||
// embeds the entire `prisma/schema.prisma` source into
|
||||
// `internal/class.ts` as a JSON-escaped `inlineSchema`
|
||||
// string, so any literal `flags.coach` mention in a schema
|
||||
// doc-comment leaks through here and trips the orphan check.
|
||||
// Generated derivatives are never the gate site we want to
|
||||
// pin; the production component file is.
|
||||
if (entry === "generated") continue;
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
||||
import {
|
||||
DEFAULT_ASSISTANT_FLAGS,
|
||||
type AssistantFlagSet,
|
||||
} from "@/hooks/use-feature-flags";
|
||||
import type { AuthUser } from "@/hooks/use-auth";
|
||||
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
|
||||
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
|
||||
import { SuggestedPrompts } from "@/components/insights/suggested-prompts";
|
||||
import { HeroStrip } from "@/components/insights/hero-strip";
|
||||
|
||||
/**
|
||||
* v1.4.47 W3 — per-user "Hide Coach" opt-out invariant.
|
||||
*
|
||||
* The Coach disable cascade (`coach-cascade.test.tsx`) pins the
|
||||
* operator-level `flags.coach` gate. This sibling fixture pins the
|
||||
* per-user `disableCoach` gate that sits BELOW the operator flag —
|
||||
* either gate being off must hide every Coach affordance with no
|
||||
* grey-out, no error, no inert button, no DOM trace.
|
||||
*
|
||||
* Mocking strategy mirrors the cascade fixture: spy `useFeatureFlags`
|
||||
* to drive the operator matrix synchronously during SSR, spy
|
||||
* `useAuth` to drive the per-user `disableCoach` field. Both spies
|
||||
* are reset between iterations so a regression in either gate trips
|
||||
* the invariant in isolation.
|
||||
*/
|
||||
|
||||
const featureFlagsSpy = vi.fn<() => AssistantFlagSet>(
|
||||
() => DEFAULT_ASSISTANT_FLAGS,
|
||||
);
|
||||
vi.mock("@/hooks/use-feature-flags", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/hooks/use-feature-flags")
|
||||
>("@/hooks/use-feature-flags");
|
||||
return {
|
||||
...actual,
|
||||
useFeatureFlags: () => featureFlagsSpy(),
|
||||
};
|
||||
});
|
||||
|
||||
const authSpy = vi.fn<
|
||||
() => { user: AuthUser | null; isAuthenticated: boolean }
|
||||
>(() => ({ user: null, isAuthenticated: false }));
|
||||
vi.mock("@/hooks/use-auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/hooks/use-auth")>(
|
||||
"@/hooks/use-auth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useAuth: () => authSpy(),
|
||||
};
|
||||
});
|
||||
|
||||
// The Coach gates read user.disableCoach through `useDisableCoach`,
|
||||
// which itself calls `useAuth()`. Mocking `useDisableCoach` directly
|
||||
// would bypass the useAuth call site and make the spy assertion
|
||||
// below trivially fail; instead we let the real `useDisableCoach`
|
||||
// pass through and capture the underlying `useAuth` calls.
|
||||
|
||||
function buildUser(disableCoach: boolean): AuthUser {
|
||||
// Minimal `AuthUser` shape — the Coach gates only read
|
||||
// `disableCoach`, but TypeScript requires the full interface to
|
||||
// satisfy the mock's return type. Everything else is filler.
|
||||
return {
|
||||
id: "test-user-id",
|
||||
username: "test",
|
||||
email: null,
|
||||
role: "USER",
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
timezone: "Europe/Berlin",
|
||||
onboardingCompletedAt: null,
|
||||
onboardingTourCompleted: true,
|
||||
gravatarUrl: null,
|
||||
glucoseUnit: null,
|
||||
disableCoach,
|
||||
};
|
||||
}
|
||||
|
||||
function buildClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0 } },
|
||||
});
|
||||
}
|
||||
|
||||
function renderWithDisableCoach(
|
||||
node: React.ReactNode,
|
||||
disableCoach: boolean,
|
||||
coach: boolean = true,
|
||||
): string {
|
||||
featureFlagsSpy.mockImplementation(() => ({
|
||||
...DEFAULT_ASSISTANT_FLAGS,
|
||||
coach,
|
||||
}));
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser(disableCoach),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
const client = buildClient();
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
interface DisableCoachSurface {
|
||||
name: string;
|
||||
mount: () => React.ReactNode;
|
||||
/**
|
||||
* Substring that proves the surface painted. Empty string means
|
||||
* "the surface SSR-renders to nothing even when on" (lazy-loaded
|
||||
* subtrees) and the negative assertion is the dispositive check.
|
||||
*/
|
||||
proofWhenVisible: string;
|
||||
}
|
||||
|
||||
const DISABLE_COACH_SURFACES: DisableCoachSurface[] = [
|
||||
{
|
||||
name: "LayoutCoachFab",
|
||||
mount: () => (
|
||||
<CoachLaunchProvider>
|
||||
<LayoutCoachFab />
|
||||
</CoachLaunchProvider>
|
||||
),
|
||||
proofWhenVisible: 'data-slot="coach-launch-fab"',
|
||||
},
|
||||
{
|
||||
name: "LayoutCoachMount",
|
||||
mount: () => (
|
||||
<CoachLaunchProvider>
|
||||
<LayoutCoachMount />
|
||||
</CoachLaunchProvider>
|
||||
),
|
||||
// Lazy-loaded — SSR shape is empty even when visible. Negative
|
||||
// assertion still pins the gate via the `coach-*` slot grep.
|
||||
proofWhenVisible: "",
|
||||
},
|
||||
{
|
||||
name: "CoachLaunchButton inline pill",
|
||||
mount: () => (
|
||||
<CoachLaunchProvider>
|
||||
<CoachLaunchButton />
|
||||
</CoachLaunchProvider>
|
||||
),
|
||||
proofWhenVisible: 'data-slot="coach-launch-inline"',
|
||||
},
|
||||
{
|
||||
name: "SuggestedPrompts chip strip",
|
||||
mount: () => <SuggestedPrompts onPick={() => undefined} />,
|
||||
proofWhenVisible: 'data-slot="insights-suggested-prompts"',
|
||||
},
|
||||
{
|
||||
name: "HeroStrip action-row Ask the coach",
|
||||
mount: () => (
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={new Date(2026, 4, 10, 9, 0, 0)}
|
||||
onAskCoach={() => undefined}
|
||||
/>
|
||||
),
|
||||
proofWhenVisible: 'data-slot="insights-hero-strip-action-coach"',
|
||||
},
|
||||
];
|
||||
|
||||
describe("Coach per-user disableCoach invariant", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
featureFlagsSpy.mockClear();
|
||||
authSpy.mockClear();
|
||||
featureFlagsSpy.mockImplementation(() => DEFAULT_ASSISTANT_FLAGS);
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser(false),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
});
|
||||
|
||||
for (const surface of DISABLE_COACH_SURFACES) {
|
||||
it(`${surface.name} — paints when user.disableCoach is false`, () => {
|
||||
if (!surface.proofWhenVisible) return;
|
||||
const html = renderWithDisableCoach(surface.mount(), false);
|
||||
expect(html).toContain(surface.proofWhenVisible);
|
||||
});
|
||||
|
||||
it(`${surface.name} — vanishes when user.disableCoach is true`, () => {
|
||||
const html = renderWithDisableCoach(surface.mount(), true);
|
||||
if (surface.proofWhenVisible) {
|
||||
expect(html).not.toContain(surface.proofWhenVisible);
|
||||
}
|
||||
// No Coach-marked mount point may paint when the per-user gate
|
||||
// is on. Pin the negative shape against the shared prefix so a
|
||||
// future surface that grows a new `coach-*` slot without the
|
||||
// disableCoach gate fails this test by default.
|
||||
expect(html).not.toMatch(/data-slot="coach-[a-z]/);
|
||||
expect(html).not.toContain('data-slot="insights-suggested-prompts"');
|
||||
expect(html).not.toContain(
|
||||
'data-slot="insights-hero-strip-action-coach"',
|
||||
);
|
||||
expect(html).not.toContain('data-slot="insights-hero-strip-prompts"');
|
||||
});
|
||||
|
||||
it(`${surface.name} — also vanishes when flags.coach is false (regression pin)`, () => {
|
||||
// The legacy operator-level gate must keep working in the
|
||||
// presence of the new per-user gate. This guards against a
|
||||
// regression where a contributor "simplifies" the two checks
|
||||
// into one and accidentally drops the operator branch.
|
||||
const html = renderWithDisableCoach(surface.mount(), false, false);
|
||||
if (surface.proofWhenVisible) {
|
||||
expect(html).not.toContain(surface.proofWhenVisible);
|
||||
}
|
||||
expect(html).not.toMatch(/data-slot="coach-[a-z]/);
|
||||
});
|
||||
}
|
||||
|
||||
it("`useAuth` gate is consulted on every Coach surface", () => {
|
||||
// A `disableCoach: true` render must trip the `useAuth` spy at
|
||||
// least once. Otherwise a future regression that drops the
|
||||
// `useAuth()` call site silently leaks the affordance.
|
||||
for (const surface of DISABLE_COACH_SURFACES) {
|
||||
authSpy.mockClear();
|
||||
renderWithDisableCoach(surface.mount(), true);
|
||||
expect(
|
||||
authSpy,
|
||||
`${surface.name} did not consult useAuth() during render`,
|
||||
).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user