mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(02-12): add scoped profile summary
- expose bounded read-only profile facts for shared records\n- keep profile navigation separate from Settings mutations
This commit is contained in:
1 parent
e2d308ec31
commit
9eab70f032
7 files changed
+233
-2
No files matched your search
@@ -879,6 +879,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
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<string, string> = {
|
||||
* 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` —
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ProfileSummaryPage } from "@/components/records/profile-summary";
|
||||
|
||||
export default function ProfilePage() {
|
||||
return <ProfileSummaryPage />;
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<div className="space-y-4" data-slot="profile-summary">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("records.allergies.cardTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{summary.allergies.length ? (
|
||||
<ul className="space-y-2" data-slot="profile-summary-allergies">
|
||||
{summary.allergies.map((allergy) => (
|
||||
<li key={`${allergy.substance}-${allergy.category}`}>
|
||||
<span className="font-medium">{allergy.substance}</span>{" "}
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t(`records.allergies.category.${allergy.category}`)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("records.allergies.emptyTitle")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("records.family.cardTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{summary.familyHistory.length ? (
|
||||
<ul
|
||||
className="space-y-2"
|
||||
data-slot="profile-summary-family-history"
|
||||
>
|
||||
{summary.familyHistory.map((entry) => (
|
||||
<li key={`${entry.condition}-${entry.relationship}`}>
|
||||
<span className="font-medium">{entry.condition}</span>{" "}
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t(`records.family.relationship.${entry.relationship}`)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("records.family.emptyTitle")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("records.profileFacts.cardTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{summary.facts.length ? (
|
||||
<dl className="space-y-2" data-slot="profile-summary-facts">
|
||||
{summary.facts.map((fact) => (
|
||||
<div key={fact.label} className="flex justify-between gap-4">
|
||||
<dt>{profileFactLabel(fact.label, t)}</dt>
|
||||
<dd className="text-muted-foreground text-right">
|
||||
{profileFactValue(fact.label, fact.value, t)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("records.profileFacts.notRecorded")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileSummaryPage() {
|
||||
const { t } = useTranslations();
|
||||
const summary = useQuery({
|
||||
queryKey: queryKeys.profileSummary(),
|
||||
queryFn: () => apiGet<ProfileSummaryPayload>("/api/profile/summary"),
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="container mx-auto max-w-3xl space-y-6 px-4 py-6">
|
||||
<PageHeader title={t("settings.sections.anamnesis.title")} />
|
||||
{summary.isPending ? (
|
||||
<p className="text-muted-foreground text-sm" aria-live="polite">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
) : null}
|
||||
{summary.isError ? (
|
||||
<QueryErrorCard onRetry={() => void summary.refetch()} />
|
||||
) : null}
|
||||
{summary.data ? <ProfileSummary summary={summary.data} /> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Query keys for the bounded, read-only profile summary surface. */
|
||||
export const profileKeys = {
|
||||
profileSummary: () => ["profile", "summary"] as const,
|
||||
};
|
||||
@@ -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<ProfileSummaryPayload> {
|
||||
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 }]
|
||||
: [],
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user