feat(share-links): public clinician view at /c/[token]

A standalone read-only RSC that resolves the path token through the share-token
resolver and renders an owner-scoped clinical summary: provenance header,
clinical vitals and labs, medications with adherence, and a fenced muted card
for the descriptive wellness scores carrying the not-a-clinical-assessment,
not-a-diagnosis disclaimer. KVNR is default off. No app chrome, no coach, no AI,
no session, no markdown — every value renders as escaped text. force-dynamic and
noindex; the page returns a flat 404 for any null resolve.

The route joins the proxy public-path allowlist and the AuthShell standalone
list so it reaches the page bare without a sign-in redirect. New clinicianView
strings land in all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 01:21:42 +02:00
1 parent 9830ae8027
commit 6aa3c75d27
13 files changed
+753 -1

No files matched your search

+28
View File
@@ -4523,5 +4523,33 @@
"moodReminders": {
"dailyTitle": "Stimmung erfassen",
"dailyBody": "Wie geht es dir heute?"
},
"clinicianView": {
"title": "Geteilte Gesundheitsakte",
"period": "Berichtszeitraum: {start} – {end}",
"expires": "Dieser Link läuft am {date} ab.",
"provenance": "Dies ist eine schreibgeschützte Zusammenfassung, die von der betreffenden Person geteilt wurde, exportiert aus ihren selbst erfassten HealthLog-Daten. Die Werte sind selbst erhoben und können geräteübertragene Messwerte enthalten; behandeln Sie sie als von der Patientin/dem Patienten berichtet, nicht als klinische Untersuchung.",
"vitals": "Vitalwerte",
"labs": "Laborwerte",
"medications": "Medikamente & Einnahmetreue",
"bmi": "Body-Mass-Index (BMI)",
"statSummary": "zuletzt {latest} (Ø {avg}, Bereich {min}–{max})",
"adherence": "{rate} Einnahmetreue",
"noAdherence": "—",
"glucose": {
"FASTING": "Glukose (nüchtern)",
"POSTPRANDIAL": "Glukose (postprandial)",
"RANDOM": "Glukose (zufällig)",
"BEDTIME": "Glukose (vor dem Schlafen)"
},
"wellness": {
"title": "Wellness-Werte",
"disclaimer": "Beschreibende Werte (0–100), berechnet aus erfassten Signalen. Dies ist keine klinische Beurteilung und keine Diagnose.",
"recovery": "Erholungswert",
"stress": "Stresswert",
"strain": "Belastungswert",
"score": "Wellness-Wert"
},
"footer": "Geteilt aus HealthLog — einem selbst gehosteten Gesundheits-Tracker."
}
}
+28
View File
@@ -4523,5 +4523,33 @@
"moodReminders": {
"dailyTitle": "Log your mood",
"dailyBody": "How are you feeling today?"
},
"clinicianView": {
"title": "Shared health record",
"period": "Reporting period: {start} – {end}",
"expires": "This link expires on {date}.",
"provenance": "This is a read-only summary shared by the person it belongs to, exported from their self-tracked HealthLog data. Values are self-recorded and may include device-synced readings; treat them as patient-reported, not as a clinical examination.",
"vitals": "Vital signs",
"labs": "Lab values",
"medications": "Medications & adherence",
"bmi": "Body mass index (BMI)",
"statSummary": "latest {latest} (avg {avg}, range {min}–{max})",
"adherence": "{rate} adherence",
"noAdherence": "—",
"glucose": {
"FASTING": "Glucose (fasting)",
"POSTPRANDIAL": "Glucose (postprandial)",
"RANDOM": "Glucose (random)",
"BEDTIME": "Glucose (bedtime)"
},
"wellness": {
"title": "Wellness scores",
"disclaimer": "Descriptive scores (0–100) computed from tracked signals. These are not a clinical assessment and not a diagnosis.",
"recovery": "Recovery score",
"stress": "Stress score",
"strain": "Strain score",
"score": "Wellness score"
},
"footer": "Shared from HealthLog — a self-hosted personal health tracker."
}
}
+28
View File
@@ -4523,5 +4523,33 @@
"moodReminders": {
"dailyTitle": "Registrar el ánimo",
"dailyBody": "¿Cómo te sientes hoy?"
},
"clinicianView": {
"title": "Registro de salud compartido",
"period": "Período del informe: {start} – {end}",
"expires": "Este enlace caduca el {date}.",
"provenance": "Este es un resumen de solo lectura compartido por la persona a la que pertenece, exportado de sus datos de HealthLog autorregistrados. Los valores son autoinformados y pueden incluir lecturas sincronizadas desde dispositivos; trátelos como informados por el paciente, no como un examen clínico.",
"vitals": "Signos vitales",
"labs": "Valores de laboratorio",
"medications": "Medicamentos y adherencia",
"bmi": "Índice de masa corporal (IMC)",
"statSummary": "último {latest} (prom. {avg}, rango {min}–{max})",
"adherence": "{rate} de adherencia",
"noAdherence": "—",
"glucose": {
"FASTING": "Glucosa (en ayunas)",
"POSTPRANDIAL": "Glucosa (posprandial)",
"RANDOM": "Glucosa (aleatoria)",
"BEDTIME": "Glucosa (al acostarse)"
},
"wellness": {
"title": "Puntuaciones de bienestar",
"disclaimer": "Puntuaciones descriptivas (0–100) calculadas a partir de las señales registradas. No son una evaluación clínica ni un diagnóstico.",
"recovery": "Puntuación de recuperación",
"stress": "Puntuación de estrés",
"strain": "Puntuación de esfuerzo",
"score": "Puntuación de bienestar"
},
"footer": "Compartido desde HealthLog — un registro de salud autoalojado."
}
}
+28
View File
@@ -4523,5 +4523,33 @@
"moodReminders": {
"dailyTitle": "Enregistrer votre humeur",
"dailyBody": "Comment vous sentez-vous aujourd’hui ?"
},
"clinicianView": {
"title": "Dossier de santé partagé",
"period": "Période couverte : {start} – {end}",
"expires": "Ce lien expire le {date}.",
"provenance": "Il s'agit d'un résumé en lecture seule partagé par la personne concernée, exporté de ses données HealthLog auto-enregistrées. Les valeurs sont auto-déclarées et peuvent inclure des mesures synchronisées depuis des appareils ; à considérer comme rapportées par le patient, non comme un examen clinique.",
"vitals": "Signes vitaux",
"labs": "Valeurs de laboratoire",
"medications": "Médicaments et observance",
"bmi": "Indice de masse corporelle (IMC)",
"statSummary": "dernier {latest} (moy. {avg}, plage {min}–{max})",
"adherence": "{rate} d'observance",
"noAdherence": "—",
"glucose": {
"FASTING": "Glycémie (à jeun)",
"POSTPRANDIAL": "Glycémie (postprandiale)",
"RANDOM": "Glycémie (aléatoire)",
"BEDTIME": "Glycémie (au coucher)"
},
"wellness": {
"title": "Scores de bien-être",
"disclaimer": "Scores descriptifs (0–100) calculés à partir des signaux suivis. Ce n'est ni une évaluation clinique ni un diagnostic.",
"recovery": "Score de récupération",
"stress": "Score de stress",
"strain": "Score de charge",
"score": "Score de bien-être"
},
"footer": "Partagé depuis HealthLog — un suivi de santé auto-hébergé."
}
}
+28
View File
@@ -4523,5 +4523,33 @@
"moodReminders": {
"dailyTitle": "Registra il tuo umore",
"dailyBody": "Come ti senti oggi?"
},
"clinicianView": {
"title": "Cartella sanitaria condivisa",
"period": "Periodo del report: {start} – {end}",
"expires": "Questo link scade il {date}.",
"provenance": "Questo è un riepilogo di sola lettura condiviso dalla persona a cui appartiene, esportato dai suoi dati HealthLog autoregistrati. I valori sono autodichiarati e possono includere letture sincronizzate da dispositivi; vanno considerati come riferiti dal paziente, non come un esame clinico.",
"vitals": "Parametri vitali",
"labs": "Valori di laboratorio",
"medications": "Farmaci e aderenza",
"bmi": "Indice di massa corporea (IMC)",
"statSummary": "ultimo {latest} (media {avg}, intervallo {min}–{max})",
"adherence": "{rate} di aderenza",
"noAdherence": "—",
"glucose": {
"FASTING": "Glicemia (a digiuno)",
"POSTPRANDIAL": "Glicemia (postprandiale)",
"RANDOM": "Glicemia (casuale)",
"BEDTIME": "Glicemia (prima di dormire)"
},
"wellness": {
"title": "Punteggi di benessere",
"disclaimer": "Punteggi descrittivi (0–100) calcolati dai segnali monitorati. Non sono una valutazione clinica né una diagnosi.",
"recovery": "Punteggio di recupero",
"stress": "Punteggio di stress",
"strain": "Punteggio di sforzo",
"score": "Punteggio di benessere"
},
"footer": "Condiviso da HealthLog — un tracker della salute self-hosted."
}
}
+28
View File
@@ -4523,5 +4523,33 @@
"moodReminders": {
"dailyTitle": "Zapisz nastrój",
"dailyBody": "Jak się dzisiaj czujesz?"
},
"clinicianView": {
"title": "Udostępniona dokumentacja zdrowotna",
"period": "Okres raportu: {start} – {end}",
"expires": "Ten link wygasa dnia {date}.",
"provenance": "To jest tylko do odczytu podsumowanie udostępnione przez osobę, do której należy, wyeksportowane z jej samodzielnie rejestrowanych danych HealthLog. Wartości są zgłaszane samodzielnie i mogą obejmować odczyty zsynchronizowane z urządzeń; należy je traktować jako zgłoszone przez pacjenta, a nie jako badanie kliniczne.",
"vitals": "Parametry życiowe",
"labs": "Wyniki laboratoryjne",
"medications": "Leki i przestrzeganie zaleceń",
"bmi": "Wskaźnik masy ciała (BMI)",
"statSummary": "ostatnio {latest} (śr. {avg}, zakres {min}–{max})",
"adherence": "{rate} przestrzegania",
"noAdherence": "—",
"glucose": {
"FASTING": "Glukoza (na czczo)",
"POSTPRANDIAL": "Glukoza (poposiłkowa)",
"RANDOM": "Glukoza (losowa)",
"BEDTIME": "Glukoza (przed snem)"
},
"wellness": {
"title": "Wyniki dobrostanu",
"disclaimer": "Opisowe wyniki (0–100) obliczone na podstawie śledzonych sygnałów. Nie są one oceną kliniczną ani diagnozą.",
"recovery": "Wynik regeneracji",
"stress": "Wynik stresu",
"strain": "Wynik obciążenia",
"score": "Wynik dobrostanu"
},
"footer": "Udostępnione z HealthLog — samodzielnie hostowanego trackera zdrowia."
}
}
+75
View File
@@ -0,0 +1,75 @@
/**
* v1.11.0 (Epic C, C5) — public clinician-view page gating.
*
* Asserts the page answers a flat `notFound()` (404) whenever the resolver
* yields null — the single blunt response for unknown / revoked / expired /
* malformed tokens — and renders the scoped view on a successful resolve.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
class NotFoundError extends Error {
digest = "NEXT_HTTP_ERROR_FALLBACK;404";
}
vi.mock("next/navigation", () => ({
notFound: vi.fn(() => {
throw new NotFoundError("NEXT_NOT_FOUND");
}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({ get: () => undefined })),
}));
vi.mock("@/lib/clinician-share/resolve-share-token", () => ({
resolveShareToken: vi.fn(),
}));
vi.mock("@/lib/clinician-share/share-view-data", () => ({
loadShareViewData: vi.fn(),
}));
vi.mock("@/components/clinician/clinician-view", () => ({
ClinicianView: () => null,
}));
import ClinicianSharePage from "../page";
import { resolveShareToken } from "@/lib/clinician-share/resolve-share-token";
import { loadShareViewData } from "@/lib/clinician-share/share-view-data";
import { notFound } from "next/navigation";
const resolve = resolveShareToken as ReturnType<typeof vi.fn>;
const loadData = loadShareViewData as ReturnType<typeof vi.fn>;
function pageProps(token: string) {
return { params: Promise.resolve({ token }) };
}
describe("clinician share page", () => {
beforeEach(() => vi.clearAllMocks());
it("404s when the token resolves to null (revoked / expired / unknown)", async () => {
resolve.mockResolvedValue(null);
await expect(
ClinicianSharePage(pageProps(`hls_${"a".repeat(48)}`)),
).rejects.toBeInstanceOf(NotFoundError);
expect(notFound).toHaveBeenCalledTimes(1);
// No data load is attempted on a failed resolve.
expect(loadData).not.toHaveBeenCalled();
});
it("loads the scoped view on a successful resolve", async () => {
resolve.mockResolvedValue({
shareLinkId: "link-1",
ownerUserId: "owner-1",
label: "Clinic",
rangeStart: new Date(),
rangeEnd: null,
sectionsJson: {},
resourceTypes: [],
allowFhirApi: false,
expiresAt: new Date(Date.now() + 86_400_000),
});
loadData.mockResolvedValue({ report: {}, sections: {} });
await ClinicianSharePage(pageProps(`hls_${"b".repeat(48)}`));
expect(notFound).not.toHaveBeenCalled();
expect(loadData).toHaveBeenCalledTimes(1);
});
});
+85
View File
@@ -0,0 +1,85 @@
/**
* v1.11.0 — public clinician view (Epic C, C5).
*
* A standalone, read-only RSC at `/c/<token>`. It carries NO app chrome, NO
* coach / AI / insight generation, and NO session — it is authenticated solely
* by the unguessable `hls_` token in the path, resolved through the C3
* {@link resolveShareToken} security core (the only trust boundary here).
*
* An unknown / revoked / expired / malformed token resolves to `null` and the
* page answers a flat `notFound()` (404) — the same blunt response for every
* failure class so a probe learns nothing.
*
* The descriptive wellness scores are fenced inside a muted card carrying the
* load-bearing "not a clinical assessment / not a diagnosis" disclaimer. KVNR
* is default OFF (never decrypted or shown here). No markdown anywhere — every
* string renders as escaped React text children (XSS posture).
*/
import type { Metadata } from "next";
import { cookies, headers } from "next/headers";
import { notFound } from "next/navigation";
import { resolveShareToken } from "@/lib/clinician-share/resolve-share-token";
import { loadShareViewData } from "@/lib/clinician-share/share-view-data";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
defaultLocale,
locales,
type Locale,
} from "@/lib/i18n/config";
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
import { ClinicianView } from "@/components/clinician/clinician-view";
// Never cache a scoped health view — `no-store` end to end.
export const dynamic = "force-dynamic";
export const revalidate = 0;
// A shared clinical record must never be indexed.
export const metadata: Metadata = {
title: "Shared health record",
robots: { index: false, follow: false },
};
async function resolveLocale(): Promise<Locale> {
try {
const cookieStore = await cookies();
const cookieLocale = cookieStore.get("healthlog-locale")?.value;
if (cookieLocale && (locales as readonly string[]).includes(cookieLocale)) {
return cookieLocale as Locale;
}
const headerList = await headers();
return parseLocaleFromAcceptLanguage(headerList.get("accept-language"));
} catch {
return defaultLocale;
}
}
export default async function ClinicianSharePage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
// The ONE trust boundary. No session is read; this proves only that the raw
// path token hashes to a live, in-window share link and yields the owner
// scope. Any failure → null → flat 404.
const context = await resolveShareToken(token);
if (!context) notFound();
const [{ report, sections }, locale] = await Promise.all([
loadShareViewData(context),
resolveLocale(),
]);
const { t } = getServerTranslator(locale);
return (
<ClinicianView
t={(key, vars) => t(key, vars)}
label={context.label}
expiresAt={context.expiresAt.toISOString()}
report={report}
sections={sections}
/>
);
}
@@ -0,0 +1,105 @@
/**
* v1.11.0 (Epic C, C5) — clinician-view presentation.
*
* Asserts the load-bearing rendering properties: the fenced wellness card
* carries the descriptive "not a clinical assessment / not a diagnosis"
* disclaimer, the provenance header renders, and the view holds no app chrome.
*/
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { ClinicianView } from "../clinician-view";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
import type { DoctorReportData } from "@/lib/doctor-report-data";
function makeReport(
overrides: Partial<DoctorReportData> = {},
): DoctorReportData {
return {
period: {
days: 30,
since: "2026-01-01",
start: "2026-01-01T00:00:00.000Z",
end: "2026-01-31T00:00:00.000Z",
},
patient: {
username: "tester",
dateOfBirth: null,
gender: null,
heightCm: null,
},
practiceName: null,
measurements: {},
stats: {
WEIGHT: { avg: 80, min: 78, max: 82, count: 12, latest: 79 },
},
glucoseStats: {},
glucoseRanges: {},
glucoseUnit: "mg/dL",
bmi: 24.5,
compliance: {},
medications: [],
wellnessScores: [
{
type: "RECOVERY_SCORE",
latest: 72,
avg: 68,
min: 50,
max: 90,
count: 20,
latestAt: "2026-01-30T00:00:00.000Z",
},
],
...overrides,
} as DoctorReportData;
}
function render(report: DoctorReportData) {
const { t } = getServerTranslator("en");
return renderToStaticMarkup(
<ClinicianView
t={(k, v) => t(k, v)}
label="Cardiology clinic"
expiresAt="2026-03-01T00:00:00.000Z"
report={report}
sections={{ ...DEFAULT_DOCTOR_REPORT_PREFS }}
/>,
);
}
describe("<ClinicianView>", () => {
it("renders the fenced wellness card with the descriptive disclaimer", () => {
const html = render(makeReport());
expect(html).toContain("Wellness scores");
expect(html).toContain("not a clinical assessment");
expect(html).toContain("not a diagnosis");
expect(html).toContain("Recovery score");
});
it("renders the provenance header treating values as patient-reported", () => {
const html = render(makeReport());
expect(html).toContain("Shared health record");
expect(html).toContain("patient-reported");
expect(html).toContain("Cardiology clinic");
});
it("renders clinical vitals from the scoped report", () => {
const html = render(makeReport());
expect(html).toContain("Vital signs");
// The measurement-type enum is humanised, not raw.
expect(html).toContain("Weight");
expect(html).not.toContain("WEIGHT");
});
it("omits the wellness card when there are no scores", () => {
const html = render(makeReport({ wellnessScores: [] }));
expect(html).not.toContain("Wellness scores");
});
it("carries no app chrome (no nav / coach landmarks)", () => {
const html = render(makeReport());
expect(html).not.toContain('data-slot="sidebar"');
expect(html).not.toContain("coach");
});
});
+244
View File
@@ -0,0 +1,244 @@
/**
* v1.11.0 — clinician-view presentation (Epic C, C5).
*
* A pure server component: it receives an already-resolved, owner-scoped
* {@link DoctorReportData} plus a server-side translator and renders a
* read-only clinical summary. NO client hooks, NO session, NO AI/coach, NO
* markdown — every value renders as escaped React text.
*
* Layout: provenance header → clinical vitals/labs → medications + adherence →
* a FENCED, muted wellness card carrying the load-bearing "descriptive, not a
* clinical assessment / not a diagnosis" disclaimer.
*/
import type { DoctorReportData } from "@/lib/doctor-report-data";
import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
type Translate = (
key: string,
vars?: Record<string, string | number>,
) => string;
interface ClinicianViewProps {
t: Translate;
/** Owner-set label for the share (e.g. a clinic note). */
label: string;
/** ISO expiry instant — surfaced so the clinician knows the link lifetime. */
expiresAt: string;
report: DoctorReportData;
sections: DoctorReportPrefs;
}
/** Human-readable display per persisted wellness-score type (i18n key suffix). */
const WELLNESS_KEY: Record<string, string> = {
RECOVERY_SCORE: "recovery",
STRESS_SCORE: "stress",
STRAIN_SCORE: "strain",
};
/**
* Humanise a measurement-type enum (`BLOOD_PRESSURE_SYS` → "Blood pressure
* sys") for a clinical label. The clinician view is locale-neutral on the raw
* metric name by design — the enum is the stable, language-independent anchor a
* clinician reads alongside the numbers; no per-type i18n bundle is maintained.
*/
function humaniseType(type: string): string {
const lower = type.replace(/_/g, " ").toLowerCase();
return lower.charAt(0).toUpperCase() + lower.slice(1);
}
/** Render a single labelled stat row. */
function StatRow({
label,
value,
}: {
label: string;
value: string;
}) {
return (
<div className="flex items-baseline justify-between gap-4 border-b border-border/40 py-2 last:border-0">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-sm font-medium tabular-nums">{value}</span>
</div>
);
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<section className="rounded-lg border border-border bg-card p-5">
<h2 className="mb-3 text-base font-semibold">{title}</h2>
{children}
</section>
);
}
export function ClinicianView({
t,
label,
expiresAt,
report,
sections,
}: ClinicianViewProps) {
const fmtDate = (iso: string) => new Date(iso).toLocaleDateString();
const fmtNum = (n: number) => Math.round(n * 100) / 100;
// Measurement series with at least one stat, mapped to their stats row.
const measurementEntries = Object.entries(report.stats).filter(
([, s]) => s.count > 0,
);
const glucoseEntries = Object.entries(report.glucoseStats).filter(
([, s]) => s.count > 0,
);
const complianceEntries = sections.compliance
? Object.entries(report.compliance).filter(([, c]) => c.total > 0)
: [];
const wellness =
report.wellnessScores?.filter((s) => s.count > 0) ?? [];
return (
<main
id="main-content"
className="mx-auto min-h-dvh w-full max-w-3xl px-4 py-8"
>
{/* ── Provenance header ───────────────────────────────────────── */}
<header className="mb-6">
<h1 className="text-2xl font-bold tracking-tight">
{t("clinicianView.title")}
</h1>
{label ? (
<p className="mt-1 text-sm text-muted-foreground">{label}</p>
) : null}
<p className="mt-3 text-sm text-muted-foreground">
{t("clinicianView.period", {
start: fmtDate(report.period.start),
end: fmtDate(report.period.end),
})}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("clinicianView.expires", { date: fmtDate(expiresAt) })}
</p>
<p className="mt-3 rounded-md border border-border bg-muted/40 p-3 text-xs text-muted-foreground">
{t("clinicianView.provenance")}
</p>
</header>
<div className="space-y-5">
{/* ── Clinical vitals ─────────────────────────────────────── */}
{measurementEntries.length > 0 ? (
<Section title={t("clinicianView.vitals")}>
{measurementEntries.map(([type, s]) => (
<StatRow
key={type}
label={humaniseType(type)}
value={t("clinicianView.statSummary", {
latest: fmtNum(s.latest),
avg: fmtNum(s.avg),
min: fmtNum(s.min),
max: fmtNum(s.max),
})}
/>
))}
{report.bmi !== null && report.bmi !== undefined && sections.bmi ? (
<StatRow
label={t("clinicianView.bmi")}
value={String(fmtNum(report.bmi))}
/>
) : null}
</Section>
) : null}
{/* ── Labs (glucose) ──────────────────────────────────────── */}
{glucoseEntries.length > 0 ? (
<Section title={t("clinicianView.labs")}>
{glucoseEntries.map(([ctx, s]) => (
<StatRow
key={ctx}
label={t(`clinicianView.glucose.${ctx}`)}
value={t("clinicianView.statSummary", {
latest: fmtNum(s.latest),
avg: fmtNum(s.avg),
min: fmtNum(s.min),
max: fmtNum(s.max),
})}
/>
))}
</Section>
) : null}
{/* ── Medications + adherence ─────────────────────────────── */}
{report.medications.length > 0 ? (
<Section title={t("clinicianView.medications")}>
{report.medications.map((med) => {
const comp = report.compliance[med.name];
const rate =
sections.compliance && comp && comp.total > 0
? `${Math.round((comp.taken / comp.total) * 100)}%`
: null;
return (
<StatRow
key={med.name}
label={med.dose ? `${med.name} — ${med.dose}` : med.name}
value={
rate
? t("clinicianView.adherence", { rate })
: t("clinicianView.noAdherence")
}
/>
);
})}
{complianceEntries
.filter(
([name]) => !report.medications.some((m) => m.name === name),
)
.map(([name, c]) => (
<StatRow
key={name}
label={name}
value={t("clinicianView.adherence", {
rate: `${Math.round((c.taken / c.total) * 100)}%`,
})}
/>
))}
</Section>
) : null}
{/* ── Fenced wellness card (descriptive, NOT clinical) ────── */}
{wellness.length > 0 ? (
<section className="rounded-lg border border-dashed border-amber-500/50 bg-amber-500/5 p-5">
<h2 className="mb-1 text-base font-semibold text-muted-foreground">
{t("clinicianView.wellness.title")}
</h2>
<p className="mb-3 text-xs text-muted-foreground">
{t("clinicianView.wellness.disclaimer")}
</p>
<div>
{wellness.map((s) => (
<StatRow
key={s.type}
label={t(
`clinicianView.wellness.${WELLNESS_KEY[s.type] ?? "score"}`,
)}
value={t("clinicianView.statSummary", {
latest: fmtNum(s.latest),
avg: fmtNum(s.avg),
min: fmtNum(s.min),
max: fmtNum(s.max),
})}
/>
))}
</div>
</section>
) : null}
</div>
<footer className="mt-8 border-t border-border pt-4 text-center text-xs text-muted-foreground">
{t("clinicianView.footer")}
</footer>
</main>
);
}
+10 -1
View File
@@ -25,6 +25,11 @@ const PUBLIC_PATHS = [
"/auth/register",
"/privacy",
"/about",
// v1.11.0 — the public clinician view renders its own standalone chrome
// (no nav / coach / auth gate). Listed here so the shell hands it through
// bare; the route is also in `proxy.ts` PUBLIC_PATHS so it never round-trips
// the sign-in redirect.
"/c/",
];
export function AuthShell({ children }: { children: React.ReactNode }) {
@@ -41,8 +46,12 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
// auth gate above.
// v1.4.27 MB6 — `/about` follows the same shape (own header, own
// footer, full-width body), so it joins the standalone list.
// v1.11.0 — the clinician view (`/c/*`) renders edge-to-edge with its own
// standalone chrome and no app shell, like the legal pages.
const isStandalonePublicPage =
pathname.startsWith("/privacy") || pathname.startsWith("/about");
pathname.startsWith("/privacy") ||
pathname.startsWith("/about") ||
pathname.startsWith("/c/");
const isAdminPage = pathname.startsWith("/admin");
const isOnboardingPage = pathname === "/onboarding";
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
@@ -0,0 +1,61 @@
/**
* v1.11.0 — scoped data load for the public clinician view (Epic C, C5).
*
* Given a {@link ShareContext} (already proven by {@link resolveShareToken}),
* aggregate exactly the data the owner froze into the link: the doctor-report
* payload over the frozen `[rangeStart, rangeEnd]` window with the frozen
* section toggles. The owner `userId` comes ONLY from the share context — never
* from a session, never from the wire.
*
* KVNR is DEFAULT OFF: the clinician view never decrypts or surfaces the
* insurance number. The descriptive wellness scores are kept (the view fences
* them under an explicit "not a clinical assessment" card), but they are read
* straight from the aggregator — no AI call, no coach, no insight generation.
*/
import {
collectDoctorReportData,
type DoctorReportData,
type DoctorReportRange,
} from "@/lib/doctor-report-data";
import { parseDoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
import type { ShareContext } from "@/lib/clinician-share/resolve-share-token";
export interface ShareViewData {
/** The aggregated, owner-scoped report payload over the frozen window. */
report: DoctorReportData;
/** The resolved section toggles (mood opt-in, defaults otherwise). */
sections: ReturnType<typeof parseDoctorReportPrefs>;
}
/**
* Resolve the frozen reporting window from the share context. `rangeEnd` null
* means "rolling up to now"; the start is always the absolute instant the
* owner froze, so a rolling share can never reach data older than chosen.
*/
function frozenRange(context: ShareContext): DoctorReportRange {
const start = context.rangeStart;
const end = context.rangeEnd ?? new Date();
const spanDays = Math.max(
1,
Math.ceil((end.getTime() - start.getTime()) / 86_400_000),
);
return { start, end, days: spanDays };
}
/**
* Load the scoped read-only view for a resolved share token. Pure data
* assembly — no auth (the token was already proven), no rate-limit (the route
* owns that), no session, no AI.
*/
export async function loadShareViewData(
context: ShareContext,
): Promise<ShareViewData> {
const sections = parseDoctorReportPrefs(context.sectionsJson);
const range = frozenRange(context);
const report = await collectDoctorReportData(context.ownerUserId, range, {
sections,
});
return { report, sections };
}
+5
View File
@@ -32,6 +32,11 @@ const PUBLIC_PATHS = [
// alongside the project credits. The CC licence requires the
// attribution to be reachable without a sign-in.
"/about",
// v1.11.0 — `/c/<token>` is the public clinician view (Epic C). It is
// authenticated solely by the unguessable `hls_` token in the path, NOT
// by a session cookie, so it must reach the page without an auth gate.
// The page renders a flat 404 for any unknown / revoked / expired token.
"/c/",
// `/onboarding` itself + its subroutes are matched exactly via
// `isPublicPath()` so we don't admit `/onboarding-export` etc.
"/robots.txt",