mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
feat(sharing): open the mood, custom-metric and record reads to a delegate
Six routes: the mood-entry list and detail, the custom-metric list, detail and entries, and the personal-record list. The custom-metric entries read establishes ownership on the metric before it queries by metric id, and that hop now resolves against the record — a delegate cannot reach their own metric from inside somebody else's account. `GET /api/mood/tags` stays refused. It looks like plain record vocabulary and one select in the middle of it pulls the owner's tag layout, which is a presentation preference and belongs to the person rather than the record. The mood list's validation breadcrumb moves onto `auditLog()` for the actor stamp, the same change the measurement reads took.
This commit is contained in:
1 parent
af0f749740
commit
8c070c953b
9 files changed
+258
-35
No files matched your search
@@ -280,6 +280,18 @@ const DELEGABLE_ROUTES: Record<string, string> = {
|
||||
"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.",
|
||||
"app/api/mood-entries/route.ts":
|
||||
"The record's mood entries. The nested include is the tag vocabulary — a key and a kind, no identifiers. Note that `GET /api/mood/tags` is refused and stays refused: it pulls the owner's tag LAYOUT, which is a presentation preference and belongs to the person, not the record.",
|
||||
"app/api/mood-entries/[id]/route.ts":
|
||||
"One mood entry of the record, fetch-then-guard against the resolved user.",
|
||||
"app/api/custom-metrics/route.ts":
|
||||
"The record's own metric definitions and their latest values. Every `where` carries the resolved user id.",
|
||||
"app/api/custom-metrics/[id]/route.ts":
|
||||
"One custom metric of the record, scoped by the resolved user id in the query itself.",
|
||||
"app/api/custom-metrics/[id]/entries/route.ts":
|
||||
"The entries of one custom metric, reached only after the metric itself resolves under the record's user id — the ownership hop runs before the entry query.",
|
||||
"app/api/personal-records/route.ts":
|
||||
"The record's personal bests, `where: { userId: user.id }`. Nothing else in the file.",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -303,7 +315,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 = 17;
|
||||
const FROZEN_ENTRY_COUNT = 23;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -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,
|
||||
@@ -35,7 +35,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 metric = await prisma.customMetric.findFirst({
|
||||
|
||||
@@ -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,
|
||||
@@ -38,7 +38,7 @@ function effectiveBound(
|
||||
|
||||
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.customMetric.findFirst({
|
||||
|
||||
@@ -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,
|
||||
@@ -29,7 +29,7 @@ import { createCustomMetricSchema } from "@/lib/validations/custom-metrics";
|
||||
*/
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read");
|
||||
|
||||
const rows = await prisma.customMetric.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { updateMoodEntrySchema, getScoreForMood } from "@/lib/validations/mood";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
@@ -36,7 +36,7 @@ function parseTags(tags: string | null): string[] {
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read");
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
|
||||
@@ -51,9 +51,7 @@ vi.mock("@/lib/mood/tag-links", async () => {
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
|
||||
@@ -80,6 +78,7 @@ vi.mock("next/headers", () => ({
|
||||
}));
|
||||
|
||||
import { GET, POST } from "../route";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { createTagLinks } from "@/lib/mood/tag-links";
|
||||
@@ -107,6 +106,11 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
// v1.36.0 — the GET's validation breadcrumb goes through the `auditLog`
|
||||
// helper now, because that helper is the only writer that stamps
|
||||
// `actorUserId`. `resetAllMocks` clears the factory's implementation, so the
|
||||
// resolved promise the route attaches a `.catch` to is restored here.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
// Re-establish the interactive-transaction passthrough after the reset.
|
||||
vi.mocked(prisma.$transaction).mockImplementation(async (fn: unknown) =>
|
||||
(fn as (tx: typeof txClient) => unknown)(txClient),
|
||||
@@ -152,17 +156,14 @@ describe("GET /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
const res = await GET(getReq("mood=junk&sortBy=garbage"));
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string };
|
||||
};
|
||||
expect(call.data.action).toBe("mood-entries.list.validation-failed");
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"mood-entries.list.validation-failed",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down"));
|
||||
const res = await GET(getReq("mood=junk&sortBy=garbage"));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "@/lib/validations/external-id";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
@@ -41,7 +41,7 @@ function parseTags(tags: string | null): string[] {
|
||||
}
|
||||
|
||||
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 = listMoodEntriesSchema.safeParse(params);
|
||||
@@ -61,17 +61,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "mood-entries.list.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
// v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog
|
||||
// .create`, because that helper is the only thing that stamps
|
||||
// `actorUserId`. Filed under the resolved record either way; without the
|
||||
// stamp a delegate's malformed query would read as the owner's own.
|
||||
void auditLog("mood-entries.list.validation-failed", {
|
||||
userId: user.id,
|
||||
details: { issues: auditIssues },
|
||||
}).catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "mood.list.invalid",
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
@@ -36,7 +36,7 @@ const DEFAULT_LIMIT = 100;
|
||||
const MAX_LIMIT = 500;
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read");
|
||||
annotate({ action: { name: "personalRecords.list" } });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
@@ -840,3 +840,214 @@ describe("GET /api/mental-health/assessments", () => {
|
||||
expect(payload.assessments.map((a) => a.totalScore)).toEqual([7]);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Mood, custom metrics, personal records */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
async function seedMood(userId: string, score: number) {
|
||||
await getPrismaClient().moodEntry.create({
|
||||
data: {
|
||||
userId,
|
||||
date: "2026-07-14",
|
||||
mood: "GUT",
|
||||
score,
|
||||
moodLoggedAt: new Date("2026-07-14T19:00:00Z"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
contract<number>("GET /api/mood-entries", {
|
||||
call: async () => {
|
||||
const { GET } = await import("@/app/api/mood-entries/route");
|
||||
return call(GET as Handler, "/api/mood-entries");
|
||||
},
|
||||
seed: seedMood,
|
||||
read: (payload: { entries: { score: number }[] }) =>
|
||||
payload.entries.map((e) => e.score),
|
||||
ownerMarker: 4,
|
||||
delegateMarker: 2,
|
||||
});
|
||||
|
||||
describe("GET /api/mood-entries/[id]", () => {
|
||||
it("resolves against the owner and refuses once the grant ends", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await seedMood(owner.id, 4);
|
||||
await seedMood(delegate.id, 2);
|
||||
const ownerRow = await getPrismaClient().moodEntry.findFirstOrThrow({
|
||||
where: { userId: owner.id },
|
||||
});
|
||||
const delegateRow = await getPrismaClient().moodEntry.findFirstOrThrow({
|
||||
where: { userId: delegate.id },
|
||||
});
|
||||
|
||||
const { GET } = await import("@/app/api/mood-entries/[id]/route");
|
||||
const { grant } = await switchInto(owner.id, delegate.id);
|
||||
|
||||
const mine = await call(
|
||||
GET as Handler,
|
||||
`/api/mood-entries/${ownerRow.id}`,
|
||||
{ id: ownerRow.id },
|
||||
);
|
||||
expect(mine.status).toBe(200);
|
||||
expect((await body(mine)).data).toMatchObject({ score: 4 });
|
||||
|
||||
const theirs = await call(
|
||||
GET as Handler,
|
||||
`/api/mood-entries/${delegateRow.id}`,
|
||||
{ id: delegateRow.id },
|
||||
);
|
||||
expect(theirs.status).toBe(404);
|
||||
|
||||
await revokeGrant(grant.id);
|
||||
await expectDenied(
|
||||
await call(GET as Handler, `/api/mood-entries/${ownerRow.id}`, {
|
||||
id: ownerRow.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("is unchanged for a caller who has not switched", async () => {
|
||||
const plain = await makeUser("plain");
|
||||
await seedMood(plain.id, 5);
|
||||
const row = await getPrismaClient().moodEntry.findFirstOrThrow({
|
||||
where: { userId: plain.id },
|
||||
});
|
||||
await signIn(plain.id);
|
||||
|
||||
const { GET } = await import("@/app/api/mood-entries/[id]/route");
|
||||
const response = await call(GET as Handler, `/api/mood-entries/${row.id}`, {
|
||||
id: row.id,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect((await body(response)).data).toMatchObject({ score: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
async function seedCustomMetric(userId: string, name: string) {
|
||||
const metric = await getPrismaClient().customMetric.create({
|
||||
data: { userId, name, unit: "ml" },
|
||||
});
|
||||
await getPrismaClient().customMetricEntry.create({
|
||||
data: {
|
||||
userId,
|
||||
customMetricId: metric.id,
|
||||
value: 250,
|
||||
unit: "ml",
|
||||
measuredAt: new Date("2026-07-14T12:00:00Z"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
contract<string>("GET /api/custom-metrics", {
|
||||
call: async () => {
|
||||
const { GET } = await import("@/app/api/custom-metrics/route");
|
||||
return call(GET as Handler, "/api/custom-metrics");
|
||||
},
|
||||
seed: seedCustomMetric,
|
||||
read: (payload: { customMetrics: { name: string }[] }) =>
|
||||
payload.customMetrics.map((m) => m.name),
|
||||
ownerMarker: "owner-water",
|
||||
delegateMarker: "delegate-water",
|
||||
});
|
||||
|
||||
describe("GET /api/custom-metrics/[id] and its entries", () => {
|
||||
it("reach the owner's metric and refuse once the grant ends", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await seedCustomMetric(owner.id, "owner-water");
|
||||
await seedCustomMetric(delegate.id, "delegate-water");
|
||||
const ownerMetric = await getPrismaClient().customMetric.findFirstOrThrow({
|
||||
where: { userId: owner.id },
|
||||
});
|
||||
const delegateMetric =
|
||||
await getPrismaClient().customMetric.findFirstOrThrow({
|
||||
where: { userId: delegate.id },
|
||||
});
|
||||
|
||||
const detail = await import("@/app/api/custom-metrics/[id]/route");
|
||||
const entries = await import("@/app/api/custom-metrics/[id]/entries/route");
|
||||
const { grant } = await switchInto(owner.id, delegate.id);
|
||||
|
||||
const mine = await call(
|
||||
detail.GET as Handler,
|
||||
`/api/custom-metrics/${ownerMetric.id}`,
|
||||
{ id: ownerMetric.id },
|
||||
);
|
||||
expect(mine.status).toBe(200);
|
||||
expect((await body(mine)).data).toMatchObject({ name: "owner-water" });
|
||||
|
||||
// The entries route establishes ownership on the METRIC first and only
|
||||
// then queries by `customMetricId`. That hop now resolves against the
|
||||
// owner, so the delegate's own metric is unreachable from in here.
|
||||
const theirEntries = await call(
|
||||
entries.GET as Handler,
|
||||
`/api/custom-metrics/${delegateMetric.id}/entries`,
|
||||
{ id: delegateMetric.id },
|
||||
);
|
||||
expect(theirEntries.status).toBe(404);
|
||||
|
||||
const ownerEntries = await call(
|
||||
entries.GET as Handler,
|
||||
`/api/custom-metrics/${ownerMetric.id}/entries`,
|
||||
{ id: ownerMetric.id },
|
||||
);
|
||||
expect(ownerEntries.status).toBe(200);
|
||||
const payload = (await body(ownerEntries)).data as {
|
||||
entries: { value: number }[];
|
||||
};
|
||||
expect(payload.entries.map((e) => e.value)).toEqual([250]);
|
||||
|
||||
await revokeGrant(grant.id);
|
||||
await expectDenied(
|
||||
await call(
|
||||
entries.GET as Handler,
|
||||
`/api/custom-metrics/${ownerMetric.id}/entries`,
|
||||
{ id: ownerMetric.id },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("are unchanged for a caller who has not switched", async () => {
|
||||
const plain = await makeUser("plain");
|
||||
await seedCustomMetric(plain.id, "plain-water");
|
||||
const metric = await getPrismaClient().customMetric.findFirstOrThrow({
|
||||
where: { userId: plain.id },
|
||||
});
|
||||
await signIn(plain.id);
|
||||
|
||||
const { GET } = await import("@/app/api/custom-metrics/[id]/route");
|
||||
const response = await call(
|
||||
GET as Handler,
|
||||
`/api/custom-metrics/${metric.id}`,
|
||||
{ id: metric.id },
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect((await body(response)).data).toMatchObject({ name: "plain-water" });
|
||||
});
|
||||
});
|
||||
|
||||
async function seedPersonalRecord(userId: string, value: number) {
|
||||
await getPrismaClient().personalRecord.create({
|
||||
data: {
|
||||
userId,
|
||||
metricType: "WEIGHT",
|
||||
direction: "MIN",
|
||||
value,
|
||||
unit: "kg",
|
||||
achievedAt: new Date("2026-06-01T08:00:00Z"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
contract<number>("GET /api/personal-records", {
|
||||
call: async () => {
|
||||
const { GET } = await import("@/app/api/personal-records/route");
|
||||
return call(GET as Handler, "/api/personal-records");
|
||||
},
|
||||
seed: seedPersonalRecord,
|
||||
read: (payload: { value: number }[]) => payload.map((r) => r.value),
|
||||
ownerMarker: 79.5,
|
||||
delegateMarker: 61.5,
|
||||
});
|
||||
Reference in new issue
Block a user