diff --git a/messages/de.json b/messages/de.json index b80e23b18..527c4e5e2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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." } } diff --git a/messages/en.json b/messages/en.json index 26a45e608..f3ec7ab62 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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." } } diff --git a/messages/es.json b/messages/es.json index 2eac6c022..33323462c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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." } } diff --git a/messages/fr.json b/messages/fr.json index e86f01bd4..17456de80 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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é." } } diff --git a/messages/it.json b/messages/it.json index e3b9134bf..c4d7756b4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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." } } diff --git a/messages/pl.json b/messages/pl.json index a8b99cbda..8efa74dbe 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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." } } diff --git a/src/app/c/[token]/__tests__/page.test.tsx b/src/app/c/[token]/__tests__/page.test.tsx new file mode 100644 index 000000000..2403a4a28 --- /dev/null +++ b/src/app/c/[token]/__tests__/page.test.tsx @@ -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; +const loadData = loadShareViewData as ReturnType; + +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); + }); +}); diff --git a/src/app/c/[token]/page.tsx b/src/app/c/[token]/page.tsx new file mode 100644 index 000000000..f88b8fb2e --- /dev/null +++ b/src/app/c/[token]/page.tsx @@ -0,0 +1,85 @@ +/** + * v1.11.0 — public clinician view (Epic C, C5). + * + * A standalone, read-only RSC at `/c/`. 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 { + 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 ( + t(key, vars)} + label={context.label} + expiresAt={context.expiresAt.toISOString()} + report={report} + sections={sections} + /> + ); +} diff --git a/src/components/clinician/__tests__/clinician-view.test.tsx b/src/components/clinician/__tests__/clinician-view.test.tsx new file mode 100644 index 000000000..2ac9a7ff3 --- /dev/null +++ b/src/components/clinician/__tests__/clinician-view.test.tsx @@ -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 { + 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( + t(k, v)} + label="Cardiology clinic" + expiresAt="2026-03-01T00:00:00.000Z" + report={report} + sections={{ ...DEFAULT_DOCTOR_REPORT_PREFS }} + />, + ); +} + +describe("", () => { + 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"); + }); +}); diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx new file mode 100644 index 000000000..a2fcd5e47 --- /dev/null +++ b/src/components/clinician/clinician-view.tsx @@ -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; + +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 = { + 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 ( +
+ {label} + {value} +
+ ); +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

{title}

+ {children} +
+ ); +} + +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 ( +
+ {/* ── Provenance header ───────────────────────────────────────── */} +
+

+ {t("clinicianView.title")} +

+ {label ? ( +

{label}

+ ) : null} +

+ {t("clinicianView.period", { + start: fmtDate(report.period.start), + end: fmtDate(report.period.end), + })} +

+

+ {t("clinicianView.expires", { date: fmtDate(expiresAt) })} +

+

+ {t("clinicianView.provenance")} +

+
+ +
+ {/* ── Clinical vitals ─────────────────────────────────────── */} + {measurementEntries.length > 0 ? ( +
+ {measurementEntries.map(([type, s]) => ( + + ))} + {report.bmi !== null && report.bmi !== undefined && sections.bmi ? ( + + ) : null} +
+ ) : null} + + {/* ── Labs (glucose) ──────────────────────────────────────── */} + {glucoseEntries.length > 0 ? ( +
+ {glucoseEntries.map(([ctx, s]) => ( + + ))} +
+ ) : null} + + {/* ── Medications + adherence ─────────────────────────────── */} + {report.medications.length > 0 ? ( +
+ {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 ( + + ); + })} + {complianceEntries + .filter( + ([name]) => !report.medications.some((m) => m.name === name), + ) + .map(([name, c]) => ( + + ))} +
+ ) : null} + + {/* ── Fenced wellness card (descriptive, NOT clinical) ────── */} + {wellness.length > 0 ? ( +
+

+ {t("clinicianView.wellness.title")} +

+

+ {t("clinicianView.wellness.disclaimer")} +

+
+ {wellness.map((s) => ( + + ))} +
+
+ ) : null} +
+ +
+ {t("clinicianView.footer")} +
+
+ ); +} diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index be7cc9992..9d0a5aff3 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -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; diff --git a/src/lib/clinician-share/share-view-data.ts b/src/lib/clinician-share/share-view-data.ts new file mode 100644 index 000000000..6ecaba023 --- /dev/null +++ b/src/lib/clinician-share/share-view-data.ts @@ -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; +} + +/** + * 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 { + const sections = parseDoctorReportPrefs(context.sectionsJson); + const range = frozenRange(context); + + const report = await collectDoctorReportData(context.ownerUserId, range, { + sections, + }); + + return { report, sections }; +} diff --git a/src/proxy.ts b/src/proxy.ts index 61d12d745..07608a389 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -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/` 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",