From 47a5079cfabf3cda23d85666f621fac5db7469ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 04:41:38 +0200 Subject: [PATCH] feat(api): say why a module's surfaces are not there, not only that they are not `GET /api/auth/me` publishes a boolean per module, and three different situations collapse into the same `false`: the record has the module switched off, the active grant's sections do not open it, or the operator switched it off for the whole instance. A client holding the boolean has to guess between them, so the empty state either says nothing or says something that may be untrue -- offering a delegate a switch that is not theirs to reach, or telling an account holder to turn on a module the operator removed. `moduleAccess` carries the reason beside the booleans, which keep their exact meaning: `enabled`, `disabled`, `not_granted`, `unavailable`, resolved outside-in so the reason published is the one nothing further in can change. `modules[key]` stays exactly `moduleAccess[key] === "enabled"` for every key, and the two halves are built independently -- the boolean still runs through `maskModulesToSections` -- so the test of that pairing is an assertion rather than a restatement of a derivation. It is checked on the pure builder across a matrix of grants, records and operator blobs, and again on the real route for a scoped delegate, an unscoped one, and an operator-disabled module. Both ends in one change, because a payload field with no reader is the failure this repository keeps rediscovering. The reader is `ModuleDisabledNotice`, the shared module-off empty state the empty-states audit asks for: it names the reason in the reader's own language and offers the enable control only when the record's own switch is what is off, since neither of the other two is fixable from that page. The nutrients page adopts it and drops its bespoke block, which had been handing every delegate a button that would refuse them. Registered in the OpenAPI registry as `ModuleAccessMap` and carried on the account payload; the native contract gains a field and loses nothing, since a Bearer request carries no switch and never reads `not_granted`. --- docs/api/openapi.yaml | 192 +++++++++++++++++- messages/de.json | 15 +- messages/en.json | 15 +- messages/es.json | 15 +- messages/fr.json | 15 +- messages/it.json | 15 +- messages/ko.json | 15 +- messages/pl.json | 15 +- src/app/api/auth/me/route.ts | 21 +- src/app/insights/nutrients/page.tsx | 27 ++- .../layout/module-disabled-notice.tsx | 92 +++++++++ src/hooks/use-auth.ts | 25 +++ src/lib/openapi/routes/account-sharing.ts | 4 +- src/lib/openapi/routes/profile.ts | 26 +++ .../__tests__/module-disclosure.test.ts | 152 ++++++++++++++ src/lib/sharing/module-disclosure.ts | 97 ++++++++- .../managed-record-modules.test.ts | 139 +++++++++++++ 17 files changed, 859 insertions(+), 21 deletions(-) create mode 100644 src/components/layout/module-disabled-notice.tsx create mode 100644 src/lib/sharing/__tests__/module-disclosure.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a76c8db4c..a39f3e579 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -65763,6 +65763,8 @@ components: type: string accountAccess: $ref: "#/components/schemas/AccountAccess" + moduleAccess: + $ref: "#/components/schemas/ModuleAccessMap" recordSession: anyOf: - $ref: "#/components/schemas/RecordSessionState" @@ -65771,6 +65773,7 @@ components: - id - username - accountAccess + - moduleAccess - recordSession additionalProperties: {} description: "The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do. @@ -65780,7 +65783,9 @@ components: `cycleTrackingEnabled` describe the ACTIVE RECORD, since every surface they gate shows the record's data — and they are masked to the sections the active grant opens, so a scoped grant reads `false` for a module outside it rather than the record's true state. With no switch — which is every native request, since the Bearer transport - carries none — the two are the same account and nothing is masked." + carries none — the two are the same account and nothing is masked. `moduleAccess` says WHY each module is or is + not there — the record's own switch, the edge of the grant, or the operator's instance-wide one — beside the + `modules` booleans it never contradicts." AccountAccess: type: object properties: @@ -65985,6 +65990,191 @@ components: be a second program deciding one person's access to a health record, and the two answer differently the first time an expiry or a revocation appears. Always present — an account nobody has shared with gets an empty list, not a missing field." + ModuleAccessMap: + type: object + properties: + cycle: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "cycle" module is or is not available here. + mood: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "mood" module is or is not available here. + sleep: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "sleep" module is or is not available here. + glucose: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "glucose" module is or is not available here. + workouts: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "workouts" module is or is not available here. + recovery: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "recovery" module is or is not available here. + labs: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "labs" module is or is not available here. + illness: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "illness" module is or is not available here. + achievements: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "achievements" module is or is not available here. + coach: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "coach" module is or is not available here. + insights: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "insights" module is or is not available here. + medications: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "medications" module is or is not available here. + doctorReport: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "doctorReport" module is or is not available here. + environment: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "environment" module is or is not available here. + mcp: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "mcp" module is or is not available here. + inboundDocuments: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "inboundDocuments" module is or is not available here. + mentalHealth: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "mentalHealth" module is or is not available here. + nutrients: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "nutrients" module is or is not available here. + vaccinations: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "vaccinations" module is or is not available here. + required: + - cycle + - mood + - sleep + - glucose + - workouts + - recovery + - labs + - illness + - achievements + - coach + - insights + - medications + - doctorReport + - environment + - mcp + - inboundDocuments + - mentalHealth + - nutrients + - vaccinations + additionalProperties: false + description: "Why each toggleable module is or is not available for the record this session is inside. `enabled` — the + module is on and the client may paint it. `disabled` — the record has it switched off. `not_granted` — the + active grant's sections do not open the module's section, which also covers every module that reads across the + whole record (achievements, the assistant surfaces, the export, environment, the account's own MCP endpoint) + since no scoped grant opens one. `unavailable` — the operator switched the module off for the whole instance. + Precedence, highest first: `unavailable` > `not_granted` > `disabled` > `enabled`, so the reason published is + the one nothing further in can change. Own-record sessions and unscoped grants never produce `not_granted`. + Every key is present, and `modules[key] === (moduleAccess[key] === \"enabled\")` holds for every key — this + field adds a reason, never a different gate." RecordSessionState: type: object properties: diff --git a/messages/de.json b/messages/de.json index 5c326a8f0..7b73179a9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8682,6 +8682,20 @@ "description": "Der Impfpass in strukturierter Form: Dosis, Serie, nächste Auffrischung." } }, + "moduleOff": { + "disabled": { + "description": "{module} ist für diese Akte ausgeschaltet, deshalb bleiben die Seiten verborgen. Schalte das Modul wieder ein, um sie zurückzuholen.", + "title": "{module} ist ausgeschaltet" + }, + "notGranted": { + "description": "{module} liegt außerhalb der Bereiche, die mit dir geteilt wurden. Was ein geteilter Zugriff umfasst, legt die Person fest, der die Akte gehört.", + "title": "Gehört nicht zum geteilten Zugriff" + }, + "unavailable": { + "description": "Der Betreiber hat {module} für alle Konten auf diesem Server ausgeschaltet. Keine eigene Einstellung holt es zurück.", + "title": "Auf diesem Server nicht verfügbar" + } + }, "vaccinations": { "title": "Impfungen", "subtitle": "Dein Impfpass, Dosis für Dosis.", @@ -9920,7 +9934,6 @@ "page": { "title": "Nährstoffaufnahme", "description": "Flüssigkeits- und Vitamin-/Mineralstoffaufnahme aus der Gesundheits-App deines Telefons, plus manuelle Wassereinträge.", - "moduleOffTitle": "Nährstoffaufnahme ist aus", "moduleOffDescription": "Aktiviere das Nährstoffe-Modul, um Flüssigkeits- und Vitamin-/Mineralstoffaufnahme hier zu sehen. Synchronisierte Werte werden Teil der Aufzeichnung, die der Coach lesen kann.", "moduleOffCta": "Nährstoffe aktivieren", "emptyTitle": "Noch keine Nährstoffdaten", diff --git a/messages/en.json b/messages/en.json index 8c1e7452a..0d612b3d2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8682,6 +8682,20 @@ "description": "The vaccination record, structured: dose, series, next booster." } }, + "moduleOff": { + "disabled": { + "description": "{module} is off for this record, so its pages stay hidden. Turn it back on to bring them back.", + "title": "{module} is switched off" + }, + "notGranted": { + "description": "{module} sits outside the sections shared with you. What a shared record covers is decided by the person who owns it.", + "title": "Not part of the shared access" + }, + "unavailable": { + "description": "The operator switched {module} off for every account on this server. No setting of your own can bring it back.", + "title": "Not available on this server" + } + }, "vaccinations": { "title": "Vaccinations", "subtitle": "Your immunization record, dose by dose.", @@ -9920,7 +9934,6 @@ "page": { "title": "Nutrient intake", "description": "Hydration and vitamin/mineral intake synced from your phone's health app, plus manual water entries.", - "moduleOffTitle": "Nutrient intake is off", "moduleOffDescription": "Turn on the nutrients module to see hydration and vitamin/mineral intake here. Synced intake becomes part of the record the Coach can read.", "moduleOffCta": "Turn on nutrients", "emptyTitle": "No nutrient data yet", diff --git a/messages/es.json b/messages/es.json index a2244e52b..e51aa816a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8682,6 +8682,20 @@ "description": "La cartilla de vacunación estructurada: dosis, serie, próximo refuerzo." } }, + "moduleOff": { + "disabled": { + "description": "{module} está desactivado en este registro, así que sus páginas quedan ocultas. Vuelve a activarlo para recuperarlas.", + "title": "{module} está desactivado" + }, + "notGranted": { + "description": "{module} queda fuera de las secciones que se han compartido contigo. Lo que abarca un registro compartido lo decide la persona propietaria.", + "title": "No forma parte del acceso compartido" + }, + "unavailable": { + "description": "El operador ha desactivado {module} para todas las cuentas de este servidor. Ningún ajuste tuyo puede recuperarlo.", + "title": "No disponible en este servidor" + } + }, "vaccinations": { "title": "Vacunas", "subtitle": "Tu registro de vacunación, dosis a dosis.", @@ -9920,7 +9934,6 @@ "page": { "title": "Ingesta de nutrientes", "description": "Hidratación e ingesta de vitaminas/minerales sincronizadas desde la app de salud de tu teléfono, más entradas manuales de agua.", - "moduleOffTitle": "La ingesta de nutrientes está desactivada", "moduleOffDescription": "Activa el módulo de nutrientes para ver aquí la hidratación y la ingesta de vitaminas/minerales. Los datos sincronizados pasarán a formar parte del registro que el Coach puede leer.", "moduleOffCta": "Activar nutrientes", "emptyTitle": "Aún no hay datos de nutrientes", diff --git a/messages/fr.json b/messages/fr.json index 38342e4fa..c039d6fdf 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8682,6 +8682,20 @@ "description": "Le carnet de vaccination structuré : dose, série, prochain rappel." } }, + "moduleOff": { + "disabled": { + "description": "{module} est désactivé pour ce dossier, ses pages restent donc masquées. Réactivez le module pour les retrouver.", + "title": "{module} est désactivé" + }, + "notGranted": { + "description": "{module} se trouve en dehors des sections partagées avec vous. Ce que couvre un dossier partagé est décidé par la personne à qui il appartient.", + "title": "Ne fait pas partie de l'accès partagé" + }, + "unavailable": { + "description": "L'exploitant a désactivé {module} pour tous les comptes de ce serveur. Aucun réglage de votre côté ne peut le rétablir.", + "title": "Non disponible sur ce serveur" + } + }, "vaccinations": { "title": "Vaccinations", "subtitle": "Votre carnet de vaccination, dose par dose.", @@ -9920,7 +9934,6 @@ "page": { "title": "Apports en nutriments", "description": "Hydratation et apports en vitamines/minéraux synchronisés depuis l'appli santé de votre téléphone, plus les entrées manuelles d'eau.", - "moduleOffTitle": "Le suivi des nutriments est désactivé", "moduleOffDescription": "Activez le module nutriments pour voir ici l'hydratation et les apports en vitamines/minéraux. Les données synchronisées feront partie du dossier que le Coach peut lire.", "moduleOffCta": "Activer les nutriments", "emptyTitle": "Pas encore de données nutritionnelles", diff --git a/messages/it.json b/messages/it.json index 22784cfb7..6584b0bf8 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8682,6 +8682,20 @@ "description": "Il libretto vaccinale strutturato: dose, serie, prossimo richiamo." } }, + "moduleOff": { + "disabled": { + "description": "{module} è disattivato per questi dati, quindi le sue pagine restano nascoste. Riattivalo per ritrovarle.", + "title": "{module} è disattivato" + }, + "notGranted": { + "description": "{module} è fuori dalle sezioni condivise con te. Cosa comprende un accesso condiviso lo decide la persona a cui appartengono i dati.", + "title": "Non fa parte dell'accesso condiviso" + }, + "unavailable": { + "description": "Chi gestisce il server ha disattivato {module} per tutti gli account. Nessuna impostazione tua può riportarlo indietro.", + "title": "Non disponibile su questo server" + } + }, "vaccinations": { "title": "Vaccinazioni", "subtitle": "Il tuo libretto vaccinale, dose per dose.", @@ -9920,7 +9934,6 @@ "page": { "title": "Apporto di nutrienti", "description": "Idratazione e apporto di vitamine/minerali sincronizzati dall'app salute del telefono, più le voci di acqua inserite manualmente.", - "moduleOffTitle": "L'apporto di nutrienti è disattivato", "moduleOffDescription": "Attiva il modulo nutrienti per vedere qui idratazione e apporto di vitamine/minerali. I dati sincronizzati diventano parte dei dati che il Coach può leggere.", "moduleOffCta": "Attiva nutrienti", "emptyTitle": "Ancora nessun dato sui nutrienti", diff --git a/messages/ko.json b/messages/ko.json index 3c96f69dd..8e11070ec 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8682,6 +8682,20 @@ "description": "예방접종 기록을 구조화해서: 용량, 차수, 다음 추가 접종." } }, + "moduleOff": { + "disabled": { + "description": "이 기록에서 {module} 모듈이 꺼져 있어서 관련 페이지가 보이지 않아요. 다시 켜면 돌아와요.", + "title": "{module} 모듈이 꺼져 있어요" + }, + "notGranted": { + "description": "{module} 모듈은 공유된 영역 밖에 있어요. 공유된 기록이 어디까지 포함되는지는 기록의 주인이 정해요.", + "title": "공유 범위에 없는 항목" + }, + "unavailable": { + "description": "운영자가 이 서버의 모든 계정에서 {module} 모듈을 껐어요. 개인 설정으로는 되돌릴 수 없어요.", + "title": "이 서버에서는 사용할 수 없어요" + } + }, "vaccinations": { "title": "예방접종", "subtitle": "접종 한 건씩 남기는 예방접종 기록이에요.", @@ -9920,7 +9934,6 @@ "page": { "title": "영양소 섭취", "description": "휴대폰 건강 앱에서 동기화된 수분·비타민/미네랄 섭취량과 직접 입력한 수분 기록이에요.", - "moduleOffTitle": "영양소 섭취가 꺼져 있어요", "moduleOffDescription": "영양소 모듈을 켜면 수분과 비타민/미네랄 섭취량을 여기서 볼 수 있어요. 동기화된 섭취량은 코치가 읽는 기록에 포함돼요.", "moduleOffCta": "영양소 켜기", "emptyTitle": "아직 영양소 데이터가 없어요", diff --git a/messages/pl.json b/messages/pl.json index 1ab5e3098..eaa8bb7b9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8682,6 +8682,20 @@ "description": "Książeczka szczepień uporządkowana: dawka, seria, następne przypomnienie." } }, + "moduleOff": { + "disabled": { + "description": "{module} jest wyłączone dla tego zapisu, więc jego strony pozostają ukryte. Włącz moduł ponownie, aby je odzyskać.", + "title": "{module} jest wyłączone" + }, + "notGranted": { + "description": "{module} jest poza sekcjami udostępnionymi tobie. O tym, co obejmuje udostępniony zapis, decyduje osoba, do której należy.", + "title": "Nie należy do udostępnionego dostępu" + }, + "unavailable": { + "description": "Operator wyłączył {module} dla wszystkich kont na tym serwerze. Żadne twoje ustawienie tego nie przywróci.", + "title": "Niedostępne na tym serwerze" + } + }, "vaccinations": { "title": "Szczepienia", "subtitle": "Twoja karta szczepień, dawka po dawce.", @@ -9920,7 +9934,6 @@ "page": { "title": "Spożycie składników odżywczych", "description": "Nawodnienie oraz spożycie witamin/minerałów zsynchronizowane z aplikacji zdrowia telefonu, plus ręczne wpisy wody.", - "moduleOffTitle": "Śledzenie składników odżywczych jest wyłączone", "moduleOffDescription": "Włącz moduł składników odżywczych, aby zobaczyć tu nawodnienie i spożycie witamin/minerałów. Zsynchronizowane dane staną się częścią zapisu, który może odczytać Coach.", "moduleOffCta": "Włącz składniki odżywcze", "emptyTitle": "Brak jeszcze danych o składnikach odżywczych", diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 8f4b63a3c..90ca60fcc 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -57,7 +57,7 @@ import { import { parseTourProgress } from "@/lib/onboarding/tour-progress"; import { resolveAccountAccess } from "@/lib/sharing/account-access"; import { - maskModulesToSections, + buildModuleDisclosure, sectionsOpen, } from "@/lib/sharing/module-disclosure"; import { recordSessionForPayload } from "@/lib/sharing/record-session-fence"; @@ -144,7 +144,15 @@ export const GET = apiHandler(async () => { // opens. A closed section reads `false`, the same answer a module that is // off already gives, and the navigation the client builds from it drops // exactly the doors `isSharedRecordPathPresentable` drops anyway. - const modules = maskModulesToSections(resolvedModules, sections); + // Two maps out of one pass: `modules` is the boolean gate map, masked + // exactly as it was, and `moduleAccess` is the same answer with the reason + // attached — the record's own switch, the grant's edge, or the operator's. + // Every client that only wants "paint it or not" keeps reading the boolean. + const { modules, moduleAccess } = buildModuleDisclosure( + resolvedModules, + moduleAvailability, + sections, + ); // The cycle flag is masked on its own rather than read off `modules.cycle`: // that key also carries the operator's server-wide availability, and this // field never has. Same section, same answer, without borrowing a second @@ -249,6 +257,15 @@ export const GET = apiHandler(async () => { // this to show a "disabled server-wide" read-only row; everywhere else // the already-AND-ed `modules` map is the single gate to read. moduleAvailability, + // Why each module's surfaces are or are not there, for the record this + // session is inside: `enabled`, `disabled` (the record's own switch), + // `not_granted` (the active grant does not open the module's section) or + // `unavailable` (the operator switched it off for the whole instance). + // Precedence outside-in: unavailable > not_granted > disabled > enabled. + // `modules[key]` stays exactly `moduleAccess[key] === "enabled"`, so this + // adds a reason without moving a gate; empty states read it to say the + // honest thing rather than offering a switch the reader cannot reach. + moduleAccess, // v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu, // `active` is the record this session is inside (null when it is in its // own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds diff --git a/src/app/insights/nutrients/page.tsx b/src/app/insights/nutrients/page.tsx index 7e8b03008..db15686f9 100644 --- a/src/app/insights/nutrients/page.tsx +++ b/src/app/insights/nutrients/page.tsx @@ -7,6 +7,7 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { ModuleDisabledNotice } from "@/components/layout/module-disabled-notice"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { HydrationCard } from "@/components/insights/nutrients/hydration-card"; import { CaffeineCard } from "@/components/insights/nutrients/caffeine-card"; @@ -52,11 +53,12 @@ function lastAttemptReasonKey(reason: string): string { * (self-gates to nothing without data), micronutrients (self-gates to * an EmptyState without data). Degradation ladder: * - * - module off → one EmptyState with an in-context enable CTA. The - * module STAYS opt-in (2026-07-17 memo — the HealthKit read prompt - * on the device is not visible consent to a server / Coach holding - * a supplement pattern); this page just makes the toggle - * discoverable in context instead of buried in Settings. + * - module off → the shared `ModuleDisabledNotice`, which names the reason + * and carries the in-context enable CTA when it is the record's own + * switch that is off. The module STAYS opt-in (2026-07-17 memo — the + * HealthKit read prompt on the device is not visible consent to a + * server / Coach holding a supplement pattern); this page just makes + * the toggle discoverable in context instead of buried in Settings. * - module on, the overview read failed → a `QueryErrorCard` with a * retry. This branch has to sit ABOVE the empty-state check below: * `overview.data` is `undefined` on a failed read exactly like it is @@ -106,11 +108,18 @@ export default function InsightsNutrientsPage() { if (!nutrientsEnabled) { return ( - } - title={t("nutrients.page.moduleOffTitle")} - description={t("nutrients.page.moduleOffDescription")} - ctaSize="lg" action={