diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 235964540..8d773b878 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -879,6 +879,10 @@ const DELEGABLE_ROUTES: Record = { domain: "profile", why: "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.", }, + "app/api/profile/summary/route.ts": { + domain: "profile", + why: "The bounded shared profile read combines only the record's allergies, family history, and current lifestyle facts. It carries no settings, AI configuration, mutations, or encrypted free text.", + }, "app/api/mood-entries/route.ts": { domain: "mind", why: "The record's mood entries. The nested include is the tag vocabulary — a key and a kind, no identifiers. Note that `GET /api/mood/tags` is refused and stays refused: it pulls the owner's tag LAYOUT, which is a presentation preference and belongs to the person, not the record.", @@ -1793,9 +1797,10 @@ const ACTOR_ROUTES: Record = { * find out from the merge rather than from production. Import-status polling * adds two record entries and two MANAGE entries: 190 → 194. Managed-record * settings add two guardian-only entries: 194 → 196. Field-specific - * managed-record configuration adds one more: 196 → 197. + * managed-record configuration adds one more: 196 → 197. The bounded + * read-only profile summary makes the next explicit admission: 197 → 198. */ -const FROZEN_ENTRY_COUNT = 197; +const FROZEN_ENTRY_COUNT = 198; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/profile/summary/route.ts b/src/app/api/profile/summary/route.ts new file mode 100644 index 000000000..485d79ff6 --- /dev/null +++ b/src/app/api/profile/summary/route.ts @@ -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); +}); diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx new file mode 100644 index 000000000..c0132cbe7 --- /dev/null +++ b/src/app/profile/page.tsx @@ -0,0 +1,5 @@ +import { ProfileSummaryPage } from "@/components/records/profile-summary"; + +export default function ProfilePage() { + return ; +} diff --git a/src/components/records/profile-summary.tsx b/src/components/records/profile-summary.tsx new file mode 100644 index 000000000..ac2e5f407 --- /dev/null +++ b/src/components/records/profile-summary.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { PageHeader } from "@/components/ui/page-header"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { ProfileSummaryPayload } from "@/lib/records/profile-summary"; + +function profileFactLabel(label: string, t: (key: string) => string): string { + const keys: Record = { + SMOKING_STATUS: "records.profileFacts.smokingLabel", + ALCOHOL_PATTERN: "records.profileFacts.alcoholLabel", + SHIFT_SCHEDULE: "records.profileFacts.shiftLabel", + }; + return keys[label] ? t(keys[label]) : label; +} + +function profileFactValue( + label: string, + value: string, + t: (key: string) => string, +): string { + const supported = new Set([ + "SMOKING_STATUS", + "ALCOHOL_PATTERN", + "SHIFT_SCHEDULE", + ]); + return supported.has(label) + ? t(`records.profileFacts.values.${label}.${value}`) + : value; +} + +/** Bounded profile facts, intentionally free of mutation controls. */ +export function ProfileSummary({ + summary, +}: { + summary: ProfileSummaryPayload; +}) { + const { t } = useTranslations(); + + return ( +
+ + + {t("records.allergies.cardTitle")} + + + {summary.allergies.length ? ( +
    + {summary.allergies.map((allergy) => ( +
  • + {allergy.substance}{" "} + + {t(`records.allergies.category.${allergy.category}`)} + +
  • + ))} +
+ ) : ( +

+ {t("records.allergies.emptyTitle")} +

+ )} +
+
+ + + + {t("records.family.cardTitle")} + + + {summary.familyHistory.length ? ( +
    + {summary.familyHistory.map((entry) => ( +
  • + {entry.condition}{" "} + + {t(`records.family.relationship.${entry.relationship}`)} + +
  • + ))} +
+ ) : ( +

+ {t("records.family.emptyTitle")} +

+ )} +
+
+ + + + {t("records.profileFacts.cardTitle")} + + + {summary.facts.length ? ( +
+ {summary.facts.map((fact) => ( +
+
{profileFactLabel(fact.label, t)}
+
+ {profileFactValue(fact.label, fact.value, t)} +
+
+ ))} +
+ ) : ( +

+ {t("records.profileFacts.notRecorded")} +

+ )} +
+
+
+ ); +} + +export function ProfileSummaryPage() { + const { t } = useTranslations(); + const summary = useQuery({ + queryKey: queryKeys.profileSummary(), + queryFn: () => apiGet("/api/profile/summary"), + }); + + return ( +
+ + {summary.isPending ? ( +

+ {t("common.loading")} +

+ ) : null} + {summary.isError ? ( + void summary.refetch()} /> + ) : null} + {summary.data ? : null} +
+ ); +} diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 7dd9aec45..d73047d8b 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -36,6 +36,7 @@ import { mentalHealthKeys } from "./mental-health"; import { moodKeys } from "./mood"; import { nutrientKeys } from "./nutrients"; import { recordSettingsKeys } from "./record-settings"; +import { profileKeys } from "./profile"; import { settingsKeys } from "./settings"; import { sharingKeys } from "./sharing"; import { workoutKeys } from "./workouts"; @@ -65,6 +66,7 @@ export const queryKeys = { ...customMetrics, ...nutrientKeys, ...recordSettingsKeys, + ...profileKeys, ...sharingKeys, }; diff --git a/src/lib/query-keys/profile.ts b/src/lib/query-keys/profile.ts new file mode 100644 index 000000000..53d0260aa --- /dev/null +++ b/src/lib/query-keys/profile.ts @@ -0,0 +1,4 @@ +/** Query keys for the bounded, read-only profile summary surface. */ +export const profileKeys = { + profileSummary: () => ["profile", "summary"] as const, +}; diff --git a/src/lib/records/profile-summary.ts b/src/lib/records/profile-summary.ts new file mode 100644 index 000000000..e78a55c6a --- /dev/null +++ b/src/lib/records/profile-summary.ts @@ -0,0 +1,43 @@ +import { prisma } from "@/lib/db"; +import { readHealthProfileFacts } from "@/lib/profile/health-facts"; + +export interface ProfileSummaryPayload { + allergies: Array<{ substance: string; category: string }>; + familyHistory: Array<{ condition: string; relationship: string }>; + facts: Array<{ label: string; value: string }>; +} + +/** + * The shared profile surface deliberately carries only the bounded, structured + * facts a record viewer needs. It does not load settings, AI configuration, or + * any encrypted free-text field. + */ +export async function readProfileSummary( + userId: string, +): Promise { + const [allergies, familyHistory, healthFacts] = await Promise.all([ + prisma.allergy.findMany({ + where: { userId, deletedAt: null }, + orderBy: { createdAt: "desc" }, + take: 25, + select: { substance: true, category: true }, + }), + prisma.familyHistoryEntry.findMany({ + where: { userId, deletedAt: null }, + orderBy: { createdAt: "desc" }, + take: 25, + select: { condition: true, relationship: true }, + }), + readHealthProfileFacts(userId), + ]); + + return { + allergies, + familyHistory, + facts: Object.values(healthFacts.current).flatMap((fact) => + fact?.value && !fact.unreadable + ? [{ label: fact.kind, value: fact.value }] + : [], + ), + }; +}