mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(modules): illness defaults on (remove born-gated mechanism)
This commit is contained in:
commit
6ba2d435c9
10 files changed
+37
-72
No files matched your search
@@ -6357,7 +6357,7 @@ paths:
|
||||
- Illness
|
||||
summary: Edit an illness episode (v1.18.1)
|
||||
description: Partial edit; omitted fields are left untouched. A re-parent must point at an owned, live episode (and
|
||||
never the episode itself). Audits as `illness.episode.update`. Owner-scoped + born-gated.
|
||||
never the episode itself). Audits as `illness.episode.update`. Owner-scoped + module-gated.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -6430,7 +6430,7 @@ paths:
|
||||
- Illness
|
||||
summary: Soft-delete an illness episode (v1.18.1)
|
||||
description: "Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }`
|
||||
envelope (the cross-module DELETE shape). Audits as `illness.episode.delete`. Owner-scoped + born-gated."
|
||||
envelope (the cross-module DELETE shape). Audits as `illness.episode.delete`. Owner-scoped + module-gated."
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -6455,7 +6455,7 @@ paths:
|
||||
summary: Mark an illness episode recovered (v1.18.1)
|
||||
description: Stamps `resolvedAt` (defaults to 'now' when the body omits it). A CHRONIC_ONGOING episode has no recovery
|
||||
date and 422s with `errorCode:"illness.episode.chronic-no-resolve"`. Audits as `illness.episode.resolve`.
|
||||
Owner-scoped + born-gated.
|
||||
Owner-scoped + module-gated.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -6622,7 +6622,7 @@ paths:
|
||||
nadir, per-vital physiological returns, the headline recovery-gap, and any red flags. The findings derive from
|
||||
the user's OWN baseline (median ± MAD) over a contamination-guarded window — never a population constant.
|
||||
`status:\"insufficient\"` carries coverage + a reason. Retrospective ONLY — never a predictor or diagnoser.
|
||||
Owner-scoped + born-gated."
|
||||
Owner-scoped + module-gated."
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
|
||||
@@ -78,12 +78,12 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
|
||||
"src/app/api/biomarkers",
|
||||
"src/app/api/cycle",
|
||||
// v1.18.1 (W-B) — the illness/condition journal. Every `/api/illness/*`
|
||||
// route gates on the born-gated `illness` module via the thin
|
||||
// route gates on the `illness` module via the thin
|
||||
// `requireIllnessEnabled(...)` wrapper (which re-stamps the
|
||||
// illness-specific errorCode over `requireModuleEnabled("illness")`),
|
||||
// recognised below as a delegated gate. Walking the tree means a NEW
|
||||
// ungated illness route fails this test BY NAME rather than leaking the
|
||||
// surface over a Bearer token when the account never opted in.
|
||||
// surface over a Bearer token when the account turned the module off.
|
||||
"src/app/api/illness",
|
||||
"src/app/api/gamification",
|
||||
// v1.18.1 (D3) — medications graduated from CORE to a toggleable module.
|
||||
|
||||
@@ -26,7 +26,7 @@ import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
|
||||
* The Labs module toggle is a UX-only nav preference; the data is owner-scoped
|
||||
* and read by cross-feature surfaces (Vorsorge reminder resolution, the
|
||||
* doctor-report PDF) regardless of the toggle. Deliberate opt-out, in contrast
|
||||
* to the born-gated `/api/illness/*` routes.
|
||||
* to the gated `/api/illness/*` routes.
|
||||
*
|
||||
* The catalog is the Labs feature's primary object: a marker is defined ONCE
|
||||
* (name, unit, reference bounds, optional context) and every later reading
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
/**
|
||||
* v1.17.1 — structured lab-result store (`/api/labs`).
|
||||
*
|
||||
* Module gate: unlike `/api/illness/*` (born-gated, every route 403s when the
|
||||
* Module gate: unlike `/api/illness/*` (gated, every route 403s when the
|
||||
* module is off), Labs is intentionally NOT server-gated. The Labs module
|
||||
* toggle is a UX-only nav/visibility preference — the data is always
|
||||
* owner-scoped and safe to read/write, and the Vorsorge lab-panel reminder
|
||||
|
||||
@@ -289,9 +289,9 @@ export interface DoctorReportData {
|
||||
}> | null;
|
||||
/**
|
||||
* v1.18.1 P4 — illness / condition episodes overlapping the report window.
|
||||
* Populated ONLY when the `illness` module is enabled (born-gated opt-in);
|
||||
* the user turned the journal on and is sharing it deliberately, so the
|
||||
* privacy stance matches labs (ON when the module is on), not mood / cycle.
|
||||
* Populated ONLY when the `illness` module is enabled (default-on, opt-out);
|
||||
* the journal is on and shared deliberately, so the privacy stance matches
|
||||
* labs (ON when the module is on), not mood / cycle.
|
||||
* Statistics + labels only — the free-text note is NEVER read here, so no
|
||||
* encrypted plaintext can leak into the report. Each entry feeds a FHIR
|
||||
* `Condition` (+ `Encounter`) and a PDF table row. Null/absent when the
|
||||
@@ -1348,7 +1348,7 @@ export async function collectDoctorReportData(
|
||||
}
|
||||
|
||||
// v1.18.1 P4 — illness / condition episodes overlapping the window. Gated
|
||||
// on the illness module (born-gated opt-in): when off, no read, no section.
|
||||
// on the illness module (default-on, opt-out): when off, no read, no section.
|
||||
// An episode overlaps when it began on or before the window end AND is
|
||||
// either still ongoing or resolved on or after the window start. Labels +
|
||||
// lifecycle + dates only — the encrypted note is never selected, so no
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Illness / condition-journal feature gate (v1.18.1).
|
||||
*
|
||||
* The illness journal is BORN-GATED (opt-in / default-off) and resolves
|
||||
* its enabled-state through the module foundation — there is no per-domain
|
||||
* profile row and no second source of truth. `isIllnessEnabled` and the
|
||||
* `requireIllnessEnabled` 403 guard both delegate to the module gate's
|
||||
* `isModuleEnabled(userId, "illness")`, which (for a born-gated key) reads
|
||||
* an explicit `modulePreferencesJson.illness === true` AND the operator
|
||||
* The illness journal is a standard optional module (default-on, opt-out)
|
||||
* and resolves its enabled-state through the module foundation — there is
|
||||
* no per-domain profile row and no second source of truth. `isIllnessEnabled`
|
||||
* and the `requireIllnessEnabled` 403 guard both delegate to the module
|
||||
* gate's `isModuleEnabled(userId, "illness")`, which reads the disabled
|
||||
* allowlist (`modulePreferencesJson.illness !== false`) AND the operator
|
||||
* server-wide availability. This mirrors `src/lib/cycle/gate.ts` so every
|
||||
* `/api/illness/*` route gates identically.
|
||||
*/
|
||||
@@ -17,7 +17,7 @@ import { isModuleEnabled, requireModuleEnabled } from "@/lib/modules/gate";
|
||||
export const ILLNESS_DISABLED_ERROR_CODE = "illness.disabled";
|
||||
|
||||
/**
|
||||
* Fully-resolved illness availability for an account: the born-gated opt-in
|
||||
* Fully-resolved illness availability for an account: the per-user opt-out
|
||||
* AND the operator server-wide kill-switch, via the module foundation.
|
||||
*/
|
||||
export function isIllnessEnabled(userId: string): Promise<boolean> {
|
||||
@@ -34,8 +34,8 @@ export type IllnessGateResult =
|
||||
|
||||
/**
|
||||
* Enforce the gate for an `/api/illness/*` route. Returns a 403
|
||||
* `illness.disabled` envelope when the account has not opted in (or the
|
||||
* operator turned the module off server-wide) — even with a valid Bearer
|
||||
* `illness.disabled` envelope when the account has turned the module off
|
||||
* (or the operator turned it off server-wide) — even with a valid Bearer
|
||||
* token. Delegates to `requireModuleEnabled` but re-stamps the
|
||||
* illness-specific `errorCode` so the iOS classifier branches cleanly.
|
||||
*/
|
||||
|
||||
@@ -78,14 +78,14 @@ describe("resolveModuleEnabled — disabled allowlist (default-on)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModuleEnabled — born-gated module (illness, default-off)", () => {
|
||||
it("is OFF when no preference is recorded (opt-in, not default-on)", () => {
|
||||
describe("resolveModuleEnabled — illness module (default-on, opt-out)", () => {
|
||||
it("is ON when no preference is recorded (default-on like its siblings)", () => {
|
||||
expect(resolveModuleEnabled("illness", inputs(), true, ALL_AVAILABLE)).toBe(
|
||||
false,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("is ON only on an explicit true", () => {
|
||||
it("stays ON on an explicit true", () => {
|
||||
expect(
|
||||
resolveModuleEnabled(
|
||||
"illness",
|
||||
@@ -96,7 +96,7 @@ describe("resolveModuleEnabled — born-gated module (illness, default-off)", ()
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("stays OFF on an explicit false", () => {
|
||||
it("is OFF only on an explicit false (opt-out)", () => {
|
||||
expect(
|
||||
resolveModuleEnabled(
|
||||
"illness",
|
||||
@@ -107,7 +107,7 @@ describe("resolveModuleEnabled — born-gated module (illness, default-off)", ()
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("operator-off short-circuits even when the user opted in", () => {
|
||||
it("operator-off short-circuits even when the user enabled it", () => {
|
||||
expect(
|
||||
resolveModuleEnabled(
|
||||
"illness",
|
||||
@@ -118,9 +118,9 @@ describe("resolveModuleEnabled — born-gated module (illness, default-off)", ()
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not affect sibling default-on modules", () => {
|
||||
it("matches its sibling default-on modules", () => {
|
||||
const i = inputs({ modulePreferences: {} });
|
||||
expect(resolveModuleEnabled("illness", i, true, ALL_AVAILABLE)).toBe(false);
|
||||
expect(resolveModuleEnabled("illness", i, true, ALL_AVAILABLE)).toBe(true);
|
||||
expect(resolveModuleEnabled("mood", i, true, ALL_AVAILABLE)).toBe(true);
|
||||
expect(resolveModuleEnabled("labs", i, true, ALL_AVAILABLE)).toBe(true);
|
||||
});
|
||||
|
||||
+1
-13
@@ -53,12 +53,7 @@ import {
|
||||
getOperatorModuleAvailability,
|
||||
type OperatorModuleAvailability,
|
||||
} from "./operator-availability";
|
||||
import {
|
||||
MODULE_KEYS,
|
||||
isBornGatedModule,
|
||||
moduleDelegatesTo,
|
||||
type ModuleKey,
|
||||
} from "./registry";
|
||||
import { MODULE_KEYS, moduleDelegatesTo, type ModuleKey } from "./registry";
|
||||
|
||||
export {
|
||||
getOperatorModuleAvailability,
|
||||
@@ -147,13 +142,6 @@ export function resolveModuleEnabled(
|
||||
return assistantCoach && !inputs.disableCoach;
|
||||
}
|
||||
|
||||
if (isBornGatedModule(key)) {
|
||||
// BORN-GATED (opt-in / default-off): inverse of the disabled allowlist.
|
||||
// The module stays OFF until the account explicitly enables it — only a
|
||||
// literal `true` turns it on, so the surface never appears unbidden.
|
||||
return inputs.modulePreferences[key] === true;
|
||||
}
|
||||
|
||||
// Disabled allowlist: only an explicit `false` turns the module off.
|
||||
return inputs.modulePreferences[key] !== false;
|
||||
}
|
||||
|
||||
@@ -73,17 +73,6 @@ export interface ModuleDefinition {
|
||||
* link; `labelKey` names the destination ("Account", "Coach settings").
|
||||
*/
|
||||
managedAt?: { href: string; labelKey: string };
|
||||
/**
|
||||
* v1.18.1 — BORN-GATED (opt-in / default-off). A born-gated module is
|
||||
* the inverse of the standard disabled-allowlist: it stays OFF until the
|
||||
* account explicitly enables it (`modulePreferencesJson[key] === true`).
|
||||
* Used for new optional verticals (the illness/condition journal) that
|
||||
* ship dark and only appear once the user opts in from the Modules hub.
|
||||
* The gate's `false`-disables contract still holds — an explicit `false`
|
||||
* (or simply the absent default) keeps it off — but here ONLY an explicit
|
||||
* `true` turns it on, so the surface never appears unbidden.
|
||||
*/
|
||||
bornGated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,9 +185,6 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
|
||||
labelKey: "modules.illness.label",
|
||||
descriptionKey: "modules.illness.description",
|
||||
category: "tracking",
|
||||
// Ships dark — only appears once the account opts in from the
|
||||
// Modules hub. Default-off, no nag, retrospective-only.
|
||||
bornGated: true,
|
||||
},
|
||||
sleep: {
|
||||
key: "sleep",
|
||||
@@ -267,12 +253,3 @@ export function isModuleKey(key: string): key is ModuleKey {
|
||||
export function moduleDelegatesTo(key: ModuleKey): ModuleDelegation | undefined {
|
||||
return MODULE_REGISTRY[key].delegatesTo;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.1 — true for a born-gated (opt-in / default-off) module. The gate
|
||||
* flips its default from on to off for these keys: they require an explicit
|
||||
* `modulePreferencesJson[key] === true` to enable.
|
||||
*/
|
||||
export function isBornGatedModule(key: ModuleKey): boolean {
|
||||
return MODULE_REGISTRY[key].bornGated === true;
|
||||
}
|
||||
@@ -9,9 +9,9 @@
|
||||
* carry; iOS renders the DTO, it never recomputes it).
|
||||
*
|
||||
* The journal is a CONDITION journal, retrospective-only — never a
|
||||
* predictor/diagnoser. Every route is born-gated: a non-opted-in account (or
|
||||
* an operator-disabled instance) 403s with `errorCode:"illness.disabled"`
|
||||
* even with a valid Bearer token.
|
||||
* predictor/diagnoser. Every route is module-gated: an account that turned
|
||||
* the module off (or an operator-disabled instance) 403s with
|
||||
* `errorCode:"illness.disabled"` even with a valid Bearer token.
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
@@ -342,7 +342,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Illness"],
|
||||
summary: "Edit an illness episode (v1.18.1)",
|
||||
description:
|
||||
"Partial edit; omitted fields are left untouched. A re-parent must point at an owned, live episode (and never the episode itself). Audits as `illness.episode.update`. Owner-scoped + born-gated.",
|
||||
"Partial edit; omitted fields are left untouched. A re-parent must point at an owned, live episode (and never the episode itself). Audits as `illness.episode.update`. Owner-scoped + module-gated.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -370,7 +370,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Illness"],
|
||||
summary: "Soft-delete an illness episode (v1.18.1)",
|
||||
description:
|
||||
"Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` envelope (the cross-module DELETE shape). Audits as `illness.episode.delete`. Owner-scoped + born-gated.",
|
||||
"Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` envelope (the cross-module DELETE shape). Audits as `illness.episode.delete`. Owner-scoped + module-gated.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
@@ -394,7 +394,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Illness"],
|
||||
summary: "Mark an illness episode recovered (v1.18.1)",
|
||||
description:
|
||||
"Stamps `resolvedAt` (defaults to 'now' when the body omits it). A CHRONIC_ONGOING episode has no recovery date and 422s with `errorCode:\"illness.episode.chronic-no-resolve\"`. Audits as `illness.episode.resolve`. Owner-scoped + born-gated.",
|
||||
"Stamps `resolvedAt` (defaults to 'now' when the body omits it). A CHRONIC_ONGOING episode has no recovery date and 422s with `errorCode:\"illness.episode.chronic-no-resolve\"`. Audits as `illness.episode.resolve`. Owner-scoped + module-gated.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: false,
|
||||
@@ -491,7 +491,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Illness"],
|
||||
summary: "Per-episode retrospective correlation (v1.18.1)",
|
||||
description:
|
||||
"Returns the coverage-gated `Derived<T>` correlation for one episode: the pre-onset anomaly scan, the nadir, per-vital physiological returns, the headline recovery-gap, and any red flags. The findings derive from the user's OWN baseline (median ± MAD) over a contamination-guarded window — never a population constant. `status:\"insufficient\"` carries coverage + a reason. Retrospective ONLY — never a predictor or diagnoser. Owner-scoped + born-gated.",
|
||||
"Returns the coverage-gated `Derived<T>` correlation for one episode: the pre-onset anomaly scan, the nadir, per-vital physiological returns, the headline recovery-gap, and any red flags. The findings derive from the user's OWN baseline (median ± MAD) over a contamination-guarded window — never a population constant. `status:\"insufficient\"` carries coverage + a reason. Retrospective ONLY — never a predictor or diagnoser. Owner-scoped + module-gated.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
|
||||
Reference in new issue
Block a user