mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(share-links): expose the frozen document set on the share view + OpenAPI
P3-1-T6. loadShareViewData now returns the link's document set as metadata only
(id, title, kind, documentDate, byteSize, servingClass) — scoped to the
membership rows, the owner, and live rows; the blob column is never selected
(the serve route is the only decrypt path). Register documentCount on the share
summary, documentIds on create, and the public /c/{token}/d/{id} serve route in
the OpenAPI contract; regenerate docs/api/openapi.yaml.
This commit is contained in:
1 parent
0885f331b0
commit
de84994a95
4 files changed
+240
-6
No files matched your search
+69
-2
@@ -998,8 +998,11 @@ paths:
|
||||
ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with
|
||||
`shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase
|
||||
cannot open the record. Every scope column (window, sections, FHIR resource types, API toggle) is frozen
|
||||
write-once. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited
|
||||
(`share-link:<userId>`, 20/h). Strict: unknown keys 422."
|
||||
write-once. v1.28 accepts an optional `documentIds` array (bounded ≤50): each id is validated as the caller's
|
||||
own live document and frozen onto the link at create (a foreign/deleted id → 422); the summary DTO then reports
|
||||
`documentCount`. A documents-only share (empty report sections + non-empty `documentIds`) is valid. `expiresAt`
|
||||
is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:<userId>`, 20/h).
|
||||
Strict: unknown keys 422."
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -1096,6 +1099,13 @@ paths:
|
||||
- MedicationAdministration
|
||||
allowFhirApi:
|
||||
type: boolean
|
||||
documentIds:
|
||||
maxItems: 50
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
expiresAt:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -1204,6 +1214,57 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/c/{token}/d/{id}:
|
||||
get:
|
||||
tags:
|
||||
- Export
|
||||
summary: Serve a shared document blob (v1.28, public)
|
||||
description: "Anonymous, no-session share-scoped document serve route. The raw `hls_` share token in the path is the
|
||||
ONLY credential — never a session; it never elevates a normal authed route. Serves ONLY a document id present in
|
||||
the link's frozen `documentIds` set (a guessed / foreign / soft-deleted id → flat 404). A passphrase-protected
|
||||
link additionally requires the short-lived, token-scoped unlock cookie; revocation and expiry collapse to the
|
||||
same flat 404. The serving-class posture is identical to the owner `/api/documents/inbound/{id}/original` route:
|
||||
Class A (PDF/JPEG/PNG/WebP/GIF) inline with its true type, everything else `application/octet-stream` +
|
||||
`Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`; `Cache-Control: private, no-store`.
|
||||
Images (JPEG/PNG/WebP) are EXIF/XMP/GPS-stripped at egress (the stored original is untouched);
|
||||
PDF/TIFF/HEIC/Office metadata passes through. Per-link rate-limited."
|
||||
parameters:
|
||||
- in: path
|
||||
name: token
|
||||
schema:
|
||||
type: string
|
||||
description: Raw `hls_` share token.
|
||||
required: true
|
||||
description: Raw `hls_` share token.
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
description: A document id on the link's frozen set.
|
||||
required: true
|
||||
description: A document id on the link's frozen set.
|
||||
responses:
|
||||
"200":
|
||||
description: The document bytes. Content-Type is the true stored type for Class A (inline) or `application/octet-stream`
|
||||
for Class B (attachment).
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
"404":
|
||||
description: Flat 404 for every miss class (unknown/revoked/expired token, locked passphrase gate, or an id not on the
|
||||
frozen set).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"429":
|
||||
description: Per-link serve rate limit exceeded.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
/api/fhir/metadata:
|
||||
get:
|
||||
tags:
|
||||
@@ -12896,6 +12957,8 @@ components:
|
||||
type: boolean
|
||||
protected:
|
||||
type: boolean
|
||||
documentCount:
|
||||
type: number
|
||||
expiresAt:
|
||||
type: string
|
||||
createdAt:
|
||||
@@ -12933,6 +12996,7 @@ components:
|
||||
- resourceTypes
|
||||
- allowFhirApi
|
||||
- protected
|
||||
- documentCount
|
||||
- expiresAt
|
||||
- createdAt
|
||||
- revokedAt
|
||||
@@ -12985,6 +13049,8 @@ components:
|
||||
type: boolean
|
||||
protected:
|
||||
type: boolean
|
||||
documentCount:
|
||||
type: number
|
||||
expiresAt:
|
||||
type: string
|
||||
createdAt:
|
||||
@@ -13009,6 +13075,7 @@ components:
|
||||
- resourceTypes
|
||||
- allowFhirApi
|
||||
- protected
|
||||
- documentCount
|
||||
- expiresAt
|
||||
- createdAt
|
||||
- revokedAt
|
||||
|
||||
@@ -16,12 +16,21 @@ vi.mock("@/lib/doctor-report-data", () => ({
|
||||
vi.mock("@/lib/validations/doctor-report-prefs", () => ({
|
||||
parseDoctorReportPrefs: vi.fn((s: unknown) => s ?? {}),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
clinicianShareLinkDocument: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
import { loadShareViewData } from "../share-view-data";
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { ShareContext } from "../resolve-share-token";
|
||||
|
||||
const collect = collectDoctorReportData as ReturnType<typeof vi.fn>;
|
||||
const findDocs = prisma.clinicianShareLinkDocument.findMany as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
function ctx(overrides: Partial<ShareContext> = {}): ShareContext {
|
||||
return {
|
||||
@@ -44,6 +53,7 @@ describe("loadShareViewData — KVNR default OFF", () => {
|
||||
collect.mockResolvedValue({
|
||||
patient: { displayName: "Shared record" },
|
||||
});
|
||||
findDocs.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("scopes the aggregator to the OWNER from the share context, never the wire", async () => {
|
||||
@@ -71,6 +81,63 @@ describe("loadShareViewData — KVNR default OFF", () => {
|
||||
expect(patient).not.toHaveProperty("kvnr");
|
||||
});
|
||||
|
||||
it("lists the frozen document set as metadata only (never bytes)", async () => {
|
||||
findDocs.mockResolvedValue([
|
||||
{
|
||||
document: {
|
||||
id: "doc-a",
|
||||
title: "Blood panel",
|
||||
kind: "LAB_REPORT",
|
||||
documentDate: new Date("2026-01-15T00:00:00Z"),
|
||||
byteSize: 12345,
|
||||
mimeType: "application/pdf",
|
||||
},
|
||||
},
|
||||
{
|
||||
document: {
|
||||
id: "doc-b",
|
||||
title: null,
|
||||
kind: "OTHER",
|
||||
documentDate: null,
|
||||
byteSize: 6789,
|
||||
mimeType: "application/msword",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const { documents } = await loadShareViewData(ctx());
|
||||
|
||||
// Scoped to THIS link + owner + live rows; the blob column is never named.
|
||||
const arg = findDocs.mock.calls[0]![0] as {
|
||||
where: Record<string, unknown>;
|
||||
select: { document: { select: Record<string, boolean> } };
|
||||
};
|
||||
expect(arg.where).toEqual({
|
||||
shareLinkId: "link-1",
|
||||
document: { userId: "owner-1", deletedAt: null },
|
||||
});
|
||||
expect(arg.select.document.select).not.toHaveProperty("contentEncrypted");
|
||||
|
||||
expect(documents).toEqual([
|
||||
{
|
||||
id: "doc-a",
|
||||
title: "Blood panel",
|
||||
kind: "LAB_REPORT",
|
||||
documentDate: "2026-01-15",
|
||||
byteSize: 12345,
|
||||
servingClass: "inline",
|
||||
},
|
||||
{
|
||||
id: "doc-b",
|
||||
title: null,
|
||||
kind: "OTHER",
|
||||
documentDate: null,
|
||||
byteSize: 6789,
|
||||
servingClass: "attachment",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the frozen rangeStart and resolves a rolling rangeEnd to now", async () => {
|
||||
const now = Date.now();
|
||||
await loadShareViewData(ctx({ rangeEnd: null }));
|
||||
|
||||
@@ -12,19 +12,40 @@
|
||||
* 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 { prisma } from "@/lib/db";
|
||||
import {
|
||||
collectDoctorReportData,
|
||||
type DoctorReportData,
|
||||
type DoctorReportRange,
|
||||
} from "@/lib/doctor-report-data";
|
||||
import { servingClassFor } from "@/lib/documents/upload-policy";
|
||||
import type { DocumentServingClass } from "@/lib/documents/upload-policy";
|
||||
import { parseDoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
|
||||
import type { ShareContext } from "@/lib/clinician-share/resolve-share-token";
|
||||
|
||||
/**
|
||||
* v1.28 — metadata for one document on the share's frozen set. NEVER carries
|
||||
* bytes: the share serve route (`/c/<token>/d/<id>`) is the only decrypt path
|
||||
* (P3-D5). The recipient view renders this list and points each entry at that
|
||||
* route (Class A inline preview / Class B download).
|
||||
*/
|
||||
export interface ShareViewDocument {
|
||||
id: string;
|
||||
title: string | null;
|
||||
kind: string;
|
||||
/** Filing date (YYYY-MM-DD at UTC) or null. */
|
||||
documentDate: string | null;
|
||||
byteSize: number;
|
||||
servingClass: DocumentServingClass;
|
||||
}
|
||||
|
||||
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>;
|
||||
/** v1.28 — the hand-picked documents on this link (metadata only). */
|
||||
documents: ShareViewDocument[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,9 +74,51 @@ export async function loadShareViewData(
|
||||
const sections = parseDoctorReportPrefs(context.sectionsJson);
|
||||
const range = frozenRange(context);
|
||||
|
||||
const report = await collectDoctorReportData(context.ownerUserId, range, {
|
||||
sections,
|
||||
const [report, documents] = await Promise.all([
|
||||
collectDoctorReportData(context.ownerUserId, range, { sections }),
|
||||
loadShareDocuments(context),
|
||||
]);
|
||||
|
||||
return { report, sections, documents };
|
||||
}
|
||||
|
||||
/**
|
||||
* The frozen document set for a resolved share, as metadata only. Scoped to
|
||||
* the link's membership rows AND the owner (defence in depth) AND live rows —
|
||||
* a document the owner soft-deleted after sharing drops out of the list, just
|
||||
* as it 404s at the serve route. The blob column is never selected.
|
||||
*/
|
||||
async function loadShareDocuments(
|
||||
context: ShareContext,
|
||||
): Promise<ShareViewDocument[]> {
|
||||
const rows = await prisma.clinicianShareLinkDocument.findMany({
|
||||
where: {
|
||||
shareLinkId: context.shareLinkId,
|
||||
document: { userId: context.ownerUserId, deletedAt: null },
|
||||
},
|
||||
select: {
|
||||
document: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
kind: true,
|
||||
documentDate: true,
|
||||
byteSize: true,
|
||||
mimeType: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
return { report, sections };
|
||||
return rows.map(({ document }) => ({
|
||||
id: document.id,
|
||||
title: document.title,
|
||||
kind: document.kind,
|
||||
documentDate: document.documentDate
|
||||
? document.documentDate.toISOString().slice(0, 10)
|
||||
: null,
|
||||
byteSize: document.byteSize,
|
||||
servingClass: servingClassFor(document.mimeType),
|
||||
}));
|
||||
}
|
||||
@@ -25,6 +25,9 @@ const shareLinkSummary = z.object({
|
||||
// v1.18.7 — whether a passphrase second factor guards this link. Always true
|
||||
// for links created on v1.18.7+; false only for legacy links.
|
||||
protected: z.boolean(),
|
||||
// v1.28 — how many documents this share carries. Never the ids, never the
|
||||
// bytes; the share serve route is the only decrypt path.
|
||||
documentCount: z.number(),
|
||||
expiresAt: z.string(),
|
||||
createdAt: z.string(),
|
||||
revokedAt: z.string().nullable(),
|
||||
@@ -103,7 +106,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Export"],
|
||||
summary: "Create a clinician share link (v1.11.0)",
|
||||
description:
|
||||
"Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with `shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase cannot open the record. Every scope column (window, sections, FHIR resource types, API toggle) is frozen write-once. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:<userId>`, 20/h). Strict: unknown keys 422.",
|
||||
"Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with `shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase cannot open the record. Every scope column (window, sections, FHIR resource types, API toggle) is frozen write-once. v1.28 accepts an optional `documentIds` array (bounded ≤50): each id is validated as the caller's own live document and frozen onto the link at create (a foreign/deleted id → 422); the summary DTO then reports `documentCount`. A documents-only share (empty report sections + non-empty `documentIds`) is valid. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:<userId>`, 20/h). Strict: unknown keys 422.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -204,6 +207,40 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/c/{token}/d/{id}": {
|
||||
get: {
|
||||
tags: ["Export"],
|
||||
summary: "Serve a shared document blob (v1.28, public)",
|
||||
description:
|
||||
"Anonymous, no-session share-scoped document serve route. The raw `hls_` share token in the path is the ONLY credential — never a session; it never elevates a normal authed route. Serves ONLY a document id present in the link's frozen `documentIds` set (a guessed / foreign / soft-deleted id → flat 404). A passphrase-protected link additionally requires the short-lived, token-scoped unlock cookie; revocation and expiry collapse to the same flat 404. The serving-class posture is identical to the owner `/api/documents/inbound/{id}/original` route: Class A (PDF/JPEG/PNG/WebP/GIF) inline with its true type, everything else `application/octet-stream` + `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`; `Cache-Control: private, no-store`. Images (JPEG/PNG/WebP) are EXIF/XMP/GPS-stripped at egress (the stored original is untouched); PDF/TIFF/HEIC/Office metadata passes through. Per-link rate-limited.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
token: z.string().describe("Raw `hls_` share token."),
|
||||
id: z.string().describe("A document id on the link's frozen set."),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The document bytes. Content-Type is the true stored type for Class A (inline) or `application/octet-stream` for Class B (attachment).",
|
||||
content: {
|
||||
"application/octet-stream": {
|
||||
schema: z.string().meta({ format: "binary" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"Flat 404 for every miss class (unknown/revoked/expired token, locked passphrase gate, or an id not on the frozen set).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
description: "Per-link serve rate limit exceeded.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/fhir/metadata": {
|
||||
get: {
|
||||
tags: ["FHIR"],
|
||||
|
||||
Reference in new issue
Block a user