mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(sharing): open the lab and clinical-record reads to a delegate
Ten routes: lab results and their detail, the biomarker catalogue and its detail, allergies, family history, the mental-health screener history, and the health-profile facts. All GET arms; every write arm in the same files keeps `requireAuth()` and refuses under a switch. The family-history payload is the one admitted read that carries third-party health information by design — it describes the owner's relatives — and it is admitted because a caregiver reading a family history is what the feature is for and the rows are the owner's own. The mental-health module gate resolves against the record, not the caller. That is the recorded decision: the module map says which parts of a record exist, so a delegate who enabled the screener on their own account gains no screener view inside somebody else's. The integration test asserts both directions.
This commit is contained in:
1 parent
255cbf5fe0
commit
af0f749740
13 files changed
+416
-21
No files matched your search
@@ -260,6 +260,26 @@ const DELEGABLE_ROUTES: Record<string, string> = {
|
||||
"The record's own reminder schedule — what the owner asked to be reminded of, not a notification channel or a device. The GET reads `userId: user.id`; creating a reminder stays on `requireAuth()`.",
|
||||
"app/api/measurement-reminders/[id]/route.ts":
|
||||
"One reminder of the record, fetched by id then guarded against the resolved user. The PUT and DELETE keep `requireAuth()`.",
|
||||
"app/api/labs/route.ts":
|
||||
"The record's lab results. The only nested include is the biomarker's own reference range — no third party, no credential. Creating a result stays on `requireAuth()`.",
|
||||
"app/api/labs/[id]/route.ts":
|
||||
"One lab result of the record, fetched by id then guarded against the resolved user before anything is serialised.",
|
||||
"app/api/biomarkers/route.ts":
|
||||
"The record's biomarker catalogue: the analytes this account tracks and the ranges it tracks them against. No writes on the read arm.",
|
||||
"app/api/biomarkers/[id]/route.ts":
|
||||
"One biomarker of the record, fetch-then-guard against the resolved user.",
|
||||
"app/api/allergies/route.ts":
|
||||
"The record's allergy list — the single most useful thing a caregiver can read, and a plain list of the owner's own rows.",
|
||||
"app/api/allergies/[id]/route.ts":
|
||||
"One allergy of the record, fetch-then-guard against the resolved user.",
|
||||
"app/api/family-history/route.ts":
|
||||
"The record's family history. The payload describes the owner's relatives, so it is the one admitted read where third-party health information is present by design rather than by accident; a caregiver reading it is the use the feature exists for, and the row is stored as the owner's.",
|
||||
"app/api/family-history/[id]/route.ts":
|
||||
"One family-history entry of the record, fetch-then-guard against the resolved user.",
|
||||
"app/api/mental-health/assessments/route.ts":
|
||||
"The record's screener history. The module gate resolves against the record, so a delegate sees the surface only where the owner switched it on; the rate limit on the POST arm is untouched and that arm still refuses under a switch.",
|
||||
"app/api/anamnesis/facts/route.ts":
|
||||
"The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -283,7 +303,7 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* a formality by accident: every addition has to be counted here as well as
|
||||
* listed above, which is one more place a careless admission has to pass.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 7;
|
||||
const FROZEN_ENTRY_COUNT = 17;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -31,7 +31,7 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read");
|
||||
|
||||
const { id } = await params;
|
||||
const row = await prisma.allergy.findUnique({ where: { id } });
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
import { toAllergyDTO } from "@/lib/records/dto";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read");
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = allergyListQuerySchema.safeParse({
|
||||
|
||||
@@ -12,6 +12,13 @@ const mocks = vi.hoisted(() => ({
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (handler: unknown) => handler,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })),
|
||||
// v1.36.0 — the GET resolves a record rather than a caller. Same shape when
|
||||
// nobody has switched, which is the case this file exercises.
|
||||
requireRecordAuth: vi.fn(async () => ({
|
||||
user: { id: "user-1" },
|
||||
actor: { id: "user-1" },
|
||||
grantId: null,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency: (handler: unknown) => handler,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { healthProfileFactWriteSchema } from "@/lib/validations/health-profile-facts";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const payload = await readHealthProfileFacts(user.id);
|
||||
annotate({
|
||||
action: { name: "anamnesis.facts.get" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -64,7 +64,7 @@ function serialiseBiomarker(row: {
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { id } = await params;
|
||||
|
||||
const row = await prisma.biomarker.findFirst({ where: { id } });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -71,7 +71,7 @@ function serialiseBiomarker(row: {
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read");
|
||||
|
||||
// Visible markers first, then hidden — the manager renders them in two
|
||||
// sections and the lab-entry pickers drop the hidden tail client-side.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -29,7 +29,7 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read");
|
||||
|
||||
const { id } = await params;
|
||||
const row = await prisma.familyHistoryEntry.findUnique({ where: { id } });
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import { toFamilyHistoryEntryDTO } from "@/lib/records/dto";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read");
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = familyHistoryListQuerySchema.safeParse({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -65,7 +65,7 @@ function toResolved(
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { id } = await params;
|
||||
|
||||
const row = await prisma.labResult.findFirst({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { fireAndForget } from "@/lib/logging/fire-and-forget";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -70,7 +70,7 @@ function toResolved(
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read");
|
||||
|
||||
const params = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = listLabResultsSchema.safeParse(params);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -99,7 +99,7 @@ function respondExisting(row: Parameters<typeof shapeRow>[0]): Response {
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read");
|
||||
// Opt-in module (default OFF): the screener history is unreachable until the
|
||||
// account turns the mental-health module on. Enforced server-side even for a
|
||||
// valid Bearer token (the nav entry / page redirect are UX-only).
|
||||
|
||||
Reference in new issue
Block a user