Merge commit '38214e472' into work/v137-record-session-fence

This commit is contained in:
Marc-André Bombeck committed 2026-08-06 17:10:56 +02:00
commit a08e82d0be
81 files changed
+3505 -386

No files matched your search

+2 -2
View File
@@ -16,7 +16,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
@@ -43,7 +43,7 @@ const DEFAULT_FORWARD_DAYS = 180;
const MAX_SPAN_DAYS = 400;
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read", "cycle");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
+2 -2
View File
@@ -15,7 +15,7 @@
* is causal.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
@@ -50,7 +50,7 @@ const WINDOW_DAYS = 365;
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read", "cycle");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
+26
View File
@@ -0,0 +1,26 @@
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { readProfileSummary } from "@/lib/records/profile-summary";
export const dynamic = "force-dynamic";
/**
* Read-only shared-record profile summary. The underlying fields are bounded
* and structured so this endpoint never becomes a Settings or AI surface.
*/
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read", "profile");
const summary = await readProfileSummary(user.id);
annotate({
action: { name: "profile.summary.get" },
meta: {
allergy_count: summary.allergies.length,
family_history_count: summary.familyHistory.length,
fact_count: summary.facts.length,
},
});
return apiSuccess(summary);
});
@@ -44,6 +44,12 @@ vi.mock("@/hooks/use-feature-flags", () => ({
vi.mock("@/hooks/use-disable-coach", () => ({
useDisableCoach: () => false,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: {}, isLoading: false }),
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ inSharedRecord: false }),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: undefined }),
}));
+9 -3
View File
@@ -10,6 +10,8 @@ import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { coachScopeSourceSchema } from "@/lib/ai/coach/types";
@@ -163,6 +165,8 @@ export default function CoachPageClient() {
const launch = useCoachLaunch();
const flags = useFeatureFlags();
const disableCoach = useDisableCoach();
const { user, isLoading } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const coachUnavailable = !flags.coach || disableCoach;
@@ -170,11 +174,13 @@ export default function CoachPageClient() {
// operator master flag OR per-user opt-out hides the Coach entirely.
// Send a direct navigator back to the Insights mother page so the
// route is never a dead-end.
const canRenderOwnCoach = user !== null && !isLoading && !inSharedRecord;
useEffect(() => {
if (coachUnavailable) {
if (canRenderOwnCoach && coachUnavailable) {
router.replace("/insights");
}
}, [coachUnavailable, router]);
}, [canRenderOwnCoach, coachUnavailable, router]);
// Reopening the bottom-right FAB drawer is no longer wired to a page
// control, but keep the launch context referenced so the lint rule does
@@ -182,7 +188,7 @@ export default function CoachPageClient() {
// the shared `<AuthShell>`.
void launch;
if (coachUnavailable) return null;
if (!canRenderOwnCoach || coachUnavailable) return null;
return (
<div
+3 -1
View File
@@ -4,6 +4,7 @@ import { Suspense, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { CycleView } from "@/components/cycle/cycle-view";
@@ -22,9 +23,10 @@ import { CycleView } from "@/components/cycle/cycle-view";
*/
export default function CyclePage() {
const { user, isLoading, isAuthenticated } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const router = useRouter();
const enabled = user?.modules?.cycle !== false;
const enabled = inSharedRecord || user?.modules?.cycle !== false;
useEffect(() => {
if (isLoading) return;
+3 -1
View File
@@ -14,6 +14,7 @@ import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { IllnessEpisodeDetail } from "@/components/illness/illness-episode-detail";
import { BackLink } from "@/components/ui/back-link";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
@@ -24,11 +25,12 @@ export default function IllnessEpisodePage({
}) {
const { id } = use(params);
const { user, isAuthenticated, isLoading } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const mounted = useMounted();
const router = useRouter();
const { t } = useTranslations();
const enabled = user?.modules?.illness === true;
const enabled = inSharedRecord || user?.modules?.illness === true;
useEffect(() => {
if (isLoading) return;
+3 -1
View File
@@ -4,6 +4,7 @@ import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { IllnessView } from "@/components/illness/illness-view";
@@ -20,10 +21,11 @@ import { IllnessView } from "@/components/illness/illness-view";
*/
export default function IllnessPage() {
const { user, isLoading, isAuthenticated } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const router = useRouter();
// Born-gated: enabled ONLY when the resolved map says `true`.
const enabled = user?.modules?.illness === true;
const enabled = inSharedRecord || user?.modules?.illness === true;
useEffect(() => {
if (isLoading) return;
+15 -4
View File
@@ -17,7 +17,10 @@ import {
import { LabForm } from "@/components/labs/lab-form";
import { LabList } from "@/components/labs/lab-list";
import { OcrReviewDialog } from "@/components/labs/ocr-review-dialog";
import { useOcrCapability } from "@/components/labs/use-ocr-extract";
import {
shouldProbeOcrCapability,
useOcrCapability,
} from "@/components/labs/use-ocr-extract";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -41,7 +44,7 @@ export default function LabsPage() {
// Scanning a report is not: it files a document and commits through the OCR
// route, and neither is delegated. So a WRITE delegate keeps the manual add
// and loses the choice menu around it.
const { canAdd, canManage } = useRecordCapabilities();
const { canAdd, canManage, inSharedRecord } = useRecordCapabilities();
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -53,14 +56,22 @@ export default function LabsPage() {
// v1.18.9 — Lab-OCR "Scan a report" dialog. The scan affordance only shows
// when the user's configured AI provider can read images (capability probe).
const [scanOpen, setScanOpen] = useState(false);
const ocrCapability = useOcrCapability(isAuthenticated);
// Gated on the resolved `modules.labs` flag from `GET /api/auth/me` (the
// per-user toggle AND the operator server-wide kill-switch). Default-on: an
// absent key reads as enabled, so a direct URL hit only bounces on an
// explicit `false`. Every `/api/labs/*` route also enforces the gate
// server-side, so this is a UX redirect, not the security boundary.
const enabled = user?.modules?.labs !== false;
const enabled = inSharedRecord || user?.modules?.labs !== false;
const ocrCapability = useOcrCapability(
shouldProbeOcrCapability({
isAuthenticated,
isLoading,
labsEnabled: enabled,
mounted,
canManage,
}),
);
const refreshVisible = useCallback(
() => queryClient.invalidateQueries({ type: "active" }),
+3 -2
View File
@@ -168,13 +168,14 @@ export default function MedicationsPageClient() {
// to tapping each card in turn — so withholding it made a caregiver with
// five morning tablets tap five times for nothing. The customize page stays
// withheld; sharing does not cover it at all.
const { canAdd, canManage } = useRecordCapabilities();
const { canAdd, canManage, inSharedRecord } = useRecordCapabilities();
// v1.18.1 (D3) — medications is an opt-out module. When the account has it
// turned off the whole page disappears (the nav entry is hidden by the same
// gate) and the list query never fires (the API would 403 anyway).
// Default-on: an absent key reads as enabled, so the page only hides on an
// explicit `false`.
const medicationsEnabled = user?.modules?.medications !== false;
const medicationsEnabled =
inSharedRecord || user?.modules?.medications !== false;
const router = useRouter();
const queryClient = useQueryClient();
const searchParams = useSearchParams();
+3 -1
View File
@@ -4,6 +4,7 @@ import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
@@ -26,9 +27,10 @@ import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
*/
export default function MentalWellbeingPage() {
const { user, isLoading, isAuthenticated } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const router = useRouter();
const enabled = user?.modules?.mentalHealth === true;
const enabled = inSharedRecord || user?.modules?.mentalHealth === true;
useEffect(() => {
if (isLoading) return;
+2 -2
View File
@@ -21,7 +21,7 @@ import { useTranslations } from "@/lib/i18n/context";
export default function MoodPage() {
const { user, isAuthenticated, isLoading } = useAuth();
const { canManage } = useRecordCapabilities();
const { canManage, inSharedRecord } = useRecordCapabilities();
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -37,7 +37,7 @@ export default function MoodPage() {
// absent key reads as enabled, so a direct URL hit only bounces on an
// explicit `false`. Every `/api/mood-entries/*` route also enforces the gate
// server-side, so this is a UX redirect, not the security boundary.
const enabled = user?.modules?.mood !== false;
const enabled = inSharedRecord || user?.modules?.mood !== false;
// v1.16.4 — PWA pull-to-refresh: a top-anchored touch pull refetches
// whatever this page currently has mounted (`type: "active"` scopes the
+5
View File
@@ -0,0 +1,5 @@
import { ProfileSummaryPage } from "@/components/records/profile-summary";
export default function ProfilePage() {
return <ProfileSummaryPage />;
}