mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge pull request #460 from MBombeck/release/v1.28.16
release/v1.28.16
This commit is contained in:
11 files changed
+121
-8
No files matched your search
@@ -2,6 +2,17 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.28.16] — 2026-07-10 — Documents-only shares stay documents-only
|
||||
|
||||
Hardens the "share a document, not the record" guarantee. A link created as
|
||||
documents-only now carries an explicit frozen flag, and the shared view reads
|
||||
that flag directly instead of inferring it from "are all report sections off?".
|
||||
The old inference would have re-opened an existing documents-only link the day a
|
||||
new report section was added — the link's frozen settings never mention the new
|
||||
section, so it would have defaulted on. The flag closes that path: a
|
||||
documents-only link can never begin serving health data later, whatever the
|
||||
report grows to include. Existing documents-only links are migrated to the flag.
|
||||
|
||||
## [1.28.15] — 2026-07-10 — Document thumbnails; admin-shared AI access
|
||||
|
||||
The documents library now shows a thumbnail for each file — a small preview
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.28.15
|
||||
version: 1.28.16
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.28.15",
|
||||
"version": "1.28.16",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- v1.28.16 — explicit `document_only` flag on a clinician share link.
|
||||
--
|
||||
-- Until now "this link serves only documents, no health record" was DERIVED at
|
||||
-- read time as "are all report sections off?" (`hasAnyReportSection`). That
|
||||
-- coupling is fragile: the day a new report section is added to the prefs shape,
|
||||
-- an OLD documents-only link — whose frozen `sections_json` never mentions the
|
||||
-- new key — would resolve the new key from the defaults (ON), the derived check
|
||||
-- would flip to "has a section", and the link would start serving that section's
|
||||
-- data. This column makes the guarantee authoritative and frozen at creation, so
|
||||
-- no future section can silently re-open an existing documents-only link.
|
||||
ALTER TABLE "clinician_share_links"
|
||||
ADD COLUMN "document_only" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Backfill: freeze the flag ON for every existing link whose sections are
|
||||
-- EXPLICITLY all-off (a non-empty object with NO `true` value at ANY depth — the
|
||||
-- shape the documents-only create flow persisted as `EMPTY_DOCTOR_REPORT_PREFS`).
|
||||
-- The recursive `$.** ? (@ == true)` JSONPath is deliberate: a record share can
|
||||
-- store a GROUPED shape (e.g. `{"vitals":{"bp":true}}`) whose only `true` sits in
|
||||
-- a nested object, so a top-level-only scan would misread it as empty and
|
||||
-- wrongly pin it documents-only. Checking every depth marks ONLY links that truly
|
||||
-- enable no section — so this can never remove report access a viewer legitimately
|
||||
-- had, only pin an already-empty report against a future schema growth. The `{}`
|
||||
-- case is excluded (empty object = "full-record defaults", NOT documents-only).
|
||||
-- Safe on a populated table (non-volatile default, no rewrite) and idempotent.
|
||||
UPDATE "clinician_share_links"
|
||||
SET "document_only" = true
|
||||
WHERE jsonb_typeof("sections_json") = 'object'
|
||||
AND "sections_json" <> '{}'::jsonb
|
||||
AND NOT jsonb_path_exists("sections_json", '$.** ? (@ == true)');
|
||||
@@ -4947,6 +4947,15 @@ model ClinicianShareLink {
|
||||
resourceTypes String[] @map("resource_types")
|
||||
/// Whether the FHIR API is reachable via this link at all (vs the view only).
|
||||
allowFhirApi Boolean @default(false) @map("allow_fhir_api")
|
||||
/// v1.28.16 — the authoritative "documents-only" flag, frozen write-once at
|
||||
/// create. When true the public view serves ONLY the attached documents and
|
||||
/// the aggregator is never called, INDEPENDENT of `sectionsJson`. Decoupling
|
||||
/// the guarantee from "are all section flags off?" means a future report
|
||||
/// section added to the prefs shape can never silently re-open an old
|
||||
/// documents-only link (the previous derived check would have leaked it once
|
||||
/// the new key defaulted on). Legacy links (pre-column) keep the derived
|
||||
/// all-off fallback in the view loader.
|
||||
documentOnly Boolean @default(false) @map("document_only")
|
||||
|
||||
/// REQUIRED — no never-expiring share (capped at `SHARE_LINK_MAX_DAYS`).
|
||||
expiresAt DateTime @map("expires_at")
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.28.15";
|
||||
/* @sw-version-fallback */ "v1.28.16";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -294,6 +294,10 @@ describe("POST /api/share-links — create", () => {
|
||||
// Sections persist as an explicit all-OFF blob (distinct from `{}` = full
|
||||
// record defaults). The clinician view reads this as "no report".
|
||||
expect(createArg.data.sectionsJson).toEqual(EMPTY_DOCTOR_REPORT_PREFS);
|
||||
// v1.28.16 — the frozen documents-only flag is persisted so the view path
|
||||
// gates on the column, not on inferring "all sections off". This is what a
|
||||
// future report section can never re-open.
|
||||
expect(createArg.data.documentOnly).toBe(true);
|
||||
// The picked document still rides along.
|
||||
expect(createArg.data.documents).toEqual({
|
||||
create: [{ documentId: "doc-a" }],
|
||||
|
||||
@@ -67,6 +67,7 @@ const shareLinkSummarySelect = {
|
||||
rangeEnd: true,
|
||||
resourceTypes: true,
|
||||
allowFhirApi: true,
|
||||
documentOnly: true,
|
||||
passphraseHash: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
@@ -84,6 +85,7 @@ function toSummary(row: {
|
||||
rangeEnd: Date | null;
|
||||
resourceTypes: string[];
|
||||
allowFhirApi: boolean;
|
||||
documentOnly: boolean;
|
||||
passphraseHash: string | null;
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
@@ -99,6 +101,9 @@ function toSummary(row: {
|
||||
rangeEnd: row.rangeEnd ? row.rangeEnd.toISOString() : null,
|
||||
resourceTypes: row.resourceTypes,
|
||||
allowFhirApi: row.allowFhirApi,
|
||||
// v1.28.16 — the frozen documents-only flag (owner-facing; the serve path
|
||||
// gates on the column, not on "are all sections off?").
|
||||
documentOnly: row.documentOnly,
|
||||
// v1.18.7 — surface only WHETHER a passphrase guards the link, never the
|
||||
// hash itself. Legacy (null-hash) links read `false` and stay ungated.
|
||||
protected: row.passphraseHash !== null,
|
||||
@@ -193,6 +198,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
sectionsJson: sectionsJson as Prisma.InputJsonValue,
|
||||
resourceTypes,
|
||||
allowFhirApi,
|
||||
documentOnly,
|
||||
expiresAt: new Date(input.expiresAt),
|
||||
documents:
|
||||
documentIds.length > 0
|
||||
|
||||
@@ -213,10 +213,50 @@ describe("loadShareViewData — documents-only share exposes no health data", ()
|
||||
// share), so the aggregator DOES run — the empty-scope short-circuit must
|
||||
// not swallow a normal record share.
|
||||
const { report, documentOnly } = await loadShareViewData(
|
||||
ctx({ sectionsJson: {} }),
|
||||
ctx({ sectionsJson: {}, documentOnly: false }),
|
||||
);
|
||||
expect(collect).toHaveBeenCalledTimes(1);
|
||||
expect(documentOnly).toBe(false);
|
||||
expect(report).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.28.16 — the frozen `documentOnly` COLUMN is authoritative. Once a link is
|
||||
* created documents-only, it stays documents-only regardless of what the report
|
||||
* sections resolve to — so a report section added to the prefs shape LATER can
|
||||
* never re-open an existing documents-only link. The legacy fallback (derive
|
||||
* from "all sections off") still holds for pre-column links where the flag is
|
||||
* false.
|
||||
*/
|
||||
describe("loadShareViewData — documentOnly column is authoritative", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
collect.mockResolvedValue({ patient: { displayName: "Shared record" } });
|
||||
findDocs.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("serves no report when the column is set, even if sections would aggregate", async () => {
|
||||
// Sections that resolve to an ENABLED scope (defaults) — the derived check
|
||||
// alone would run the aggregator. The frozen column must veto it. This is
|
||||
// the exact future-leak the column closes: a new section defaulting on can
|
||||
// no longer widen an old documents-only link.
|
||||
const { report, documentOnly } = await loadShareViewData(
|
||||
ctx({ sectionsJson: { bp: true }, documentOnly: true }),
|
||||
);
|
||||
expect(collect).not.toHaveBeenCalled();
|
||||
expect(report).toBeNull();
|
||||
expect(documentOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to the derived all-off check for a legacy link (column false)", async () => {
|
||||
// A pre-column documents-only link reads `documentOnly:false` from the row
|
||||
// but still has every section off — the derived fallback keeps it closed.
|
||||
const { report, documentOnly } = await loadShareViewData(
|
||||
ctx({ sectionsJson: EMPTY_DOCTOR_REPORT_PREFS, documentOnly: false }),
|
||||
);
|
||||
expect(collect).not.toHaveBeenCalled();
|
||||
expect(report).toBeNull();
|
||||
expect(documentOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,13 @@ export interface ShareContext {
|
||||
resourceTypes: string[];
|
||||
/** Whether the scoped FHIR API is reachable via this link at all. */
|
||||
allowFhirApi: boolean;
|
||||
/**
|
||||
* v1.28.16 — authoritative documents-only flag, frozen at create. When true
|
||||
* the view loader serves ONLY documents and never aggregates a health report,
|
||||
* regardless of `sectionsJson`. Legacy links minted before the column read
|
||||
* `false` here and fall back to the derived all-sections-off check.
|
||||
*/
|
||||
documentOnly: boolean;
|
||||
/** Absolute expiry instant. */
|
||||
expiresAt: Date;
|
||||
}
|
||||
@@ -84,6 +91,7 @@ export async function resolveShareToken(
|
||||
sectionsJson: true,
|
||||
resourceTypes: true,
|
||||
allowFhirApi: true,
|
||||
documentOnly: true,
|
||||
expiresAt: true,
|
||||
revokedAt: true,
|
||||
},
|
||||
@@ -114,6 +122,7 @@ export async function resolveShareToken(
|
||||
sectionsJson: row.sectionsJson,
|
||||
resourceTypes: row.resourceTypes,
|
||||
allowFhirApi: row.allowFhirApi,
|
||||
documentOnly: row.documentOnly,
|
||||
expiresAt: row.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,10 +95,15 @@ export async function loadShareViewData(
|
||||
const sections = parseDoctorReportPrefs(context.sectionsJson);
|
||||
const range = frozenRange(context);
|
||||
|
||||
// A share with NO report section enabled is a documents-only share: never
|
||||
// aggregate — no health metric is read from the DB, let alone served. This is
|
||||
// the load-bearing guarantee behind "share this document, not the record".
|
||||
const documentOnly = !hasAnyReportSection(sections);
|
||||
// A documents-only share never aggregates — no health metric is read from the
|
||||
// DB, let alone served. This is the load-bearing guarantee behind "share this
|
||||
// document, not the record". v1.28.16: the frozen `documentOnly` COLUMN is
|
||||
// authoritative; the derived all-sections-off check remains as the fallback
|
||||
// for legacy links minted before the column (where it reads `false`). Because
|
||||
// the column can only pin — never widen — this is strictly safer than the
|
||||
// derived check alone: a future report section can never re-open a link the
|
||||
// owner froze as documents-only.
|
||||
const documentOnly = context.documentOnly || !hasAnyReportSection(sections);
|
||||
|
||||
const [report, documents] = await Promise.all([
|
||||
documentOnly
|
||||
|
||||
Reference in new issue
Block a user