mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix: recover managed sharing boundaries
This commit is contained in:
1 parent
01562b4aef
commit
d86358f3e4
96 files changed
+3578
-397
No files matched your search
+1150
-26
File diff suppressed because it is too large.
Load diff
@@ -12,9 +12,13 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
destroyedDetails,
|
||||
overwriteDetails,
|
||||
} from "@/lib/sharing/audit-details";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
@@ -49,12 +53,26 @@ export const GET = apiHandler(
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Correcting an allergy entry, with the replaced fields
|
||||
// on the audit row.
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
const { id } = await params;
|
||||
// The pre-image the audit row needs (C4). Encrypted columns stay out of
|
||||
// the select: the feed names them and never quotes them.
|
||||
const existing = await prisma.allergy.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, deletedAt: true },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
deletedAt: true,
|
||||
substance: true,
|
||||
category: true,
|
||||
type: true,
|
||||
severity: true,
|
||||
status: true,
|
||||
onsetAt: true,
|
||||
},
|
||||
});
|
||||
if (
|
||||
!existing ||
|
||||
@@ -101,7 +119,34 @@ export const PATCH = apiHandler(
|
||||
await auditLog("allergy.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { allergyId: id },
|
||||
// C4 — the replaced fields. The reaction and the note are named and
|
||||
// never quoted; both are encrypted at rest and the audit table is not a
|
||||
// second store for them.
|
||||
details: {
|
||||
allergyId: id,
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
substance: existing.substance,
|
||||
category: existing.category,
|
||||
type: existing.type,
|
||||
severity: existing.severity,
|
||||
status: existing.status,
|
||||
onsetAt: existing.onsetAt,
|
||||
},
|
||||
after: {
|
||||
substance: updated.substance,
|
||||
category: updated.category,
|
||||
type: updated.type,
|
||||
severity: updated.severity,
|
||||
status: updated.status,
|
||||
onsetAt: updated.onsetAt,
|
||||
},
|
||||
redacted: [
|
||||
...(entry.reaction !== undefined ? ["reaction"] : []),
|
||||
...(entry.note !== undefined ? ["note"] : []),
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
@@ -114,12 +159,27 @@ export const PATCH = apiHandler(
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE, and a hard delete: the row goes and the audit row is
|
||||
// what is left. C3 below is the condition this admission rests on.
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
const { id } = await params;
|
||||
// Widened for the audit row (C3): what was destroyed, in plaintext columns
|
||||
// only. `reactionEncrypted` and `notesEncrypted` are deliberately absent —
|
||||
// lifting a decrypted reaction into the audit table would give it a second
|
||||
// retention story and no rotation.
|
||||
const existing = await prisma.allergy.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
substance: true,
|
||||
category: true,
|
||||
type: true,
|
||||
severity: true,
|
||||
status: true,
|
||||
onsetAt: true,
|
||||
},
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Allergy not found", 404);
|
||||
@@ -136,7 +196,23 @@ export const DELETE = apiHandler(
|
||||
await auditLog("allergy.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { allergyId: id },
|
||||
// C3 — the allergen, its severity and its recorded date, and never the
|
||||
// encrypted reaction text.
|
||||
details: {
|
||||
allergyId: id,
|
||||
...destroyedDetails({
|
||||
model: "Allergy",
|
||||
id,
|
||||
label: existing.substance,
|
||||
effectiveAt: existing.onsetAt,
|
||||
extra: {
|
||||
category: existing.category,
|
||||
type: existing.type,
|
||||
severity: existing.severity,
|
||||
status: existing.status,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -91,13 +91,16 @@ async function postAllergy(request: NextRequest): Promise<Response> {
|
||||
// and family history have exactly one home today, that home is a personal
|
||||
// account surface a switch rightly closes, and bolting a second copy onto a
|
||||
// shared page would split one concept across two places. Re-admitting is
|
||||
// one line here plus one entry in `delegable-surface-guard.test.ts` plus the
|
||||
// one line here plus one entry in `sharing-surface-guard.test.ts` plus the
|
||||
// paragraph that argues it — which is exactly the reviewed diff that guard
|
||||
// exists to force.
|
||||
//
|
||||
// The half that was always the point is untouched: a caregiver can still
|
||||
// READ the allergy list inside the record.
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Recording an allergy for somebody whose record you
|
||||
// manage is the case the level exists for; on a managed profile it is the
|
||||
// guardian's ordinary act. Additive, audited, and the row is the record's.
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -38,6 +38,7 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { PUT, DELETE } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -78,6 +79,9 @@ function delReq(): NextRequest {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
// v1.37.0 — the 422 breadcrumb goes through `auditLog()` and the route
|
||||
// chains `.catch()` on it; `resetAllMocks` clears the factory's resolution.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
vi.mocked(prisma.$transaction).mockImplementation((async (
|
||||
ops: Promise<unknown>[],
|
||||
) => Promise.all(ops)) as never);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
@@ -83,7 +84,12 @@ export const GET = apiHandler(
|
||||
|
||||
export const PUT = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE, and the closest call in this domain: a reference range
|
||||
// recolours every reading under it, so this is the one admitted edit whose
|
||||
// error is silent rather than visible. C4 on the audit row is what puts it
|
||||
// in the owner's feed. The DELETE below stays refused — it takes the
|
||||
// readings with it and files an id.
|
||||
const { user } = await requireRecordAuth("manage", "labs");
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.biomarker.findFirst({ where: { id } });
|
||||
@@ -107,17 +113,16 @@ export const PUT = apiHandler(
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "labs.biomarker.update.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues, biomarkerId: id }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — the 422 response is the contract */
|
||||
});
|
||||
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
|
||||
// .create`, because that helper is the only writer that stamps
|
||||
// `actorUserId`. Filed under the resolved record either way; without the
|
||||
// stamp a manager's malformed payload would read as the owner's own.
|
||||
void auditLog("labs.biomarker.update.validation-failed", {
|
||||
userId: user.id,
|
||||
details: { issues: auditIssues, biomarkerId: id },
|
||||
}).catch(() => {
|
||||
/* swallow — the 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "labs.biomarker.update.invalid",
|
||||
});
|
||||
@@ -175,7 +180,32 @@ export const PUT = apiHandler(
|
||||
await auditLog("biomarker.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { biomarkerId: id },
|
||||
// C4, and this is the edit that needs it most: a reference range
|
||||
// recolours every reading under it, so the error is silent. The feed has
|
||||
// to show the range that was there before. The context note is named and
|
||||
// never quoted; it is encrypted on the row.
|
||||
details: {
|
||||
biomarkerId: id,
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
name: existing.name,
|
||||
unit: existing.unit,
|
||||
lowerBound: existing.lowerBound,
|
||||
upperBound: existing.upperBound,
|
||||
panel: existing.panel,
|
||||
hidden: existing.hidden,
|
||||
},
|
||||
after: {
|
||||
name: updated.name,
|
||||
unit: updated.unit,
|
||||
lowerBound: updated.lowerBound,
|
||||
upperBound: updated.upperBound,
|
||||
panel: updated.panel,
|
||||
hidden: updated.hidden,
|
||||
},
|
||||
redacted: d.context !== undefined ? ["context"] : [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -196,8 +196,11 @@ describe("POST /api/cycle/period — external-id stability floor", () => {
|
||||
});
|
||||
|
||||
it("accepts the real HealthKit id and reaches the write transaction", async () => {
|
||||
// v1.37.0 — the boundary transaction reports the anchors it moved so the
|
||||
// audit row can name them (C4); an empty set is the no-neighbour case.
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue({
|
||||
cycleId: "cycle-1",
|
||||
moved: {},
|
||||
} as never);
|
||||
const res = await postPeriod(
|
||||
req("/api/cycle/period", {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
@@ -16,7 +16,10 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Soft, audited, and it emits a sync tombstone, so the
|
||||
// cycle is reconstructable from its own row. The whole-domain eraser
|
||||
// (`DELETE /api/cycle/all`) stays the owner's at every level.
|
||||
const { user } = await requireRecordAuth("manage", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
@@ -32,7 +33,8 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Editing one day of the cycle log.
|
||||
const { user } = await requireRecordAuth("manage", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
@@ -44,6 +46,8 @@ export const PATCH = apiHandler(
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
// v1.37.0 — the day the audit row names (C4).
|
||||
date: true,
|
||||
sexualActivity: true,
|
||||
protectedSex: true,
|
||||
pregnancyTest: true,
|
||||
@@ -156,7 +160,21 @@ export const PATCH = apiHandler(
|
||||
await auditLog("cycle.day-log.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { dayLogId: id },
|
||||
// C4 in this domain's shape: the date and the fields replaced, named
|
||||
// and never valued. See the sibling create route for why the values
|
||||
// stay off the audit table in this domain.
|
||||
details: {
|
||||
dayLogId: id,
|
||||
date: existing.date,
|
||||
...overwriteDetails({
|
||||
before: {},
|
||||
after: {},
|
||||
redacted: Object.entries(body)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(([key]) => key)
|
||||
.sort(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
@@ -173,7 +191,9 @@ export const PATCH = apiHandler(
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Soft delete: the row stays and the sync feed carries
|
||||
// it as a tombstone.
|
||||
const { user } = await requireRecordAuth("manage", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
@@ -75,8 +76,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return apiSuccess(row ? toCycleDayLogDTO(row) : null);
|
||||
});
|
||||
|
||||
/**
|
||||
* The day-log fields a write actually carried, for the audit row. Names only:
|
||||
* every one of these is either intimate or encrypted at rest, and the audit
|
||||
* table is not a second store for either.
|
||||
*/
|
||||
function replacedDayLogFields(entry: Record<string, unknown>): string[] {
|
||||
const carried = ["date", "source", "externalId", "loggedAt"];
|
||||
return Object.entries(entry)
|
||||
.filter(([key, value]) => value !== undefined && !carried.includes(key))
|
||||
.map(([key]) => key)
|
||||
.sort();
|
||||
}
|
||||
|
||||
async function postDayLog(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. The upsert replaces the day, which is what edit means
|
||||
// here; the gate below resolves `user.gender` against the RECORD, so the
|
||||
// module follows the record rather than the caller.
|
||||
const { user } = await requireRecordAuth("manage", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
@@ -139,7 +156,21 @@ async function postDayLog(request: NextRequest): Promise<Response> {
|
||||
await auditLog("cycle.day-log.upsert", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { dayLogId: result.id, existed: result.existed },
|
||||
// C4 in this domain's shape: the date and the fields the write replaced,
|
||||
// named and never valued. This is the product's most sensitive domain and
|
||||
// §7.5's "no full-row snapshots" is at its sharpest here — a feed line
|
||||
// saying which day was rewritten is the whole requirement, and the values
|
||||
// behind it stay on the row where they are already encrypted.
|
||||
details: {
|
||||
dayLogId: result.id,
|
||||
existed: result.existed,
|
||||
date: entry.date,
|
||||
...overwriteDetails({
|
||||
before: {},
|
||||
after: {},
|
||||
redacted: result.existed ? replacedDayLogFields(entry) : [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
import {
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
@@ -40,8 +41,28 @@ import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postPeriod));
|
||||
|
||||
/**
|
||||
* v1.37.0 — the anchors a boundary write moves, carried out of the
|
||||
* transaction so the audit row can name them (C4).
|
||||
*/
|
||||
interface MovedAnchors {
|
||||
priorCycleId?: string;
|
||||
/** Day keys, the shape this domain stores its dates in. */
|
||||
priorEndDateBefore?: string | null;
|
||||
priorEndDateAfter?: string | null;
|
||||
priorLengthBefore?: number | null;
|
||||
priorLengthAfter?: number | null;
|
||||
openedEndDateBefore?: string | null;
|
||||
openedEndDateAfter?: string | null;
|
||||
periodEndDateBefore?: string | null;
|
||||
periodEndDateAfter?: string | null;
|
||||
}
|
||||
|
||||
async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Setting a boundary re-anchors the neighbouring cycles,
|
||||
// which is the destructive part of it: a handful of dates move and nothing
|
||||
// else records what they were. The audit row below carries them (C4).
|
||||
const { user } = await requireRecordAuth("manage", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
@@ -81,9 +102,23 @@ async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
const prior = await db.menstrualCycle.findFirst({
|
||||
where: { userId: user.id, deletedAt: null, startDate: { lt: date } },
|
||||
orderBy: { startDate: "desc" },
|
||||
select: { id: true, startDate: true },
|
||||
// v1.37.0 — `endDate` and `lengthDays` are read for the audit row:
|
||||
// this write overwrites both on the prior cycle and they are the
|
||||
// dates C4 exists to name.
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
lengthDays: true,
|
||||
},
|
||||
});
|
||||
const moved: MovedAnchors = {};
|
||||
if (prior) {
|
||||
moved.priorCycleId = prior.id;
|
||||
moved.priorEndDateBefore = prior.endDate;
|
||||
moved.priorEndDateAfter = addDays(date, -1);
|
||||
moved.priorLengthBefore = prior.lengthDays;
|
||||
moved.priorLengthAfter = dayDiff(date, prior.startDate);
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: prior.id },
|
||||
data: {
|
||||
@@ -117,6 +152,8 @@ async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
select: { startDate: true },
|
||||
});
|
||||
if (next) {
|
||||
moved.openedEndDateBefore = cycle.endDate;
|
||||
moved.openedEndDateAfter = addDays(next.startDate, -1);
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: cycle.id },
|
||||
data: {
|
||||
@@ -126,23 +163,30 @@ async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
},
|
||||
});
|
||||
}
|
||||
return { cycleId: cycle.id as string | null };
|
||||
return { cycleId: cycle.id as string | null, moved };
|
||||
}
|
||||
|
||||
// `end`: stamp the current cycle's periodEndDate.
|
||||
const current = await db.menstrualCycle.findFirst({
|
||||
where: { userId: user.id, deletedAt: null, startDate: { lte: date } },
|
||||
orderBy: { startDate: "desc" },
|
||||
select: { id: true },
|
||||
// v1.37.0 — `periodEndDate` for the audit row: this arm overwrites it.
|
||||
select: { id: true, periodEndDate: true },
|
||||
});
|
||||
if (!current) {
|
||||
return { cycleId: null };
|
||||
return { cycleId: null, moved: {} satisfies MovedAnchors };
|
||||
}
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: current.id },
|
||||
data: { periodEndDate: date, syncVersion: { increment: 1 } },
|
||||
});
|
||||
return { cycleId: current.id as string | null };
|
||||
return {
|
||||
cycleId: current.id as string | null,
|
||||
moved: {
|
||||
periodEndDateBefore: current.periodEndDate,
|
||||
periodEndDateAfter: date,
|
||||
} satisfies MovedAnchors,
|
||||
};
|
||||
});
|
||||
|
||||
if (txResult.cycleId === null) {
|
||||
@@ -188,7 +232,32 @@ async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
await auditLog("cycle.period.boundary", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { action, date, cycleId },
|
||||
// C4 — the dates this boundary moved on the neighbouring cycles. They are
|
||||
// the only unrecoverable part of the write: the day log it upserts is a
|
||||
// row, the re-anchoring is an overwrite of two columns on somebody else's
|
||||
// cycle history.
|
||||
details: {
|
||||
action,
|
||||
date,
|
||||
cycleId,
|
||||
...(txResult.moved.priorCycleId
|
||||
? { priorCycleId: txResult.moved.priorCycleId }
|
||||
: {}),
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
priorEndDate: txResult.moved.priorEndDateBefore,
|
||||
priorLengthDays: txResult.moved.priorLengthBefore,
|
||||
openedEndDate: txResult.moved.openedEndDateBefore,
|
||||
periodEndDate: txResult.moved.periodEndDateBefore,
|
||||
},
|
||||
after: {
|
||||
priorEndDate: txResult.moved.priorEndDateAfter,
|
||||
priorLengthDays: txResult.moved.priorLengthAfter,
|
||||
openedEndDate: txResult.moved.openedEndDateAfter,
|
||||
periodEndDate: txResult.moved.periodEndDateAfter,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -68,15 +68,21 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. The record's own symptom vocabulary, which the day-log
|
||||
// writes the level admits need in order to say anything.
|
||||
const { user, actor } = await requireRecordAuth("manage", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
// Cap mutation rate so a single session can't flood the encrypted-label
|
||||
// catalogue (each create runs an encrypt + audit write).
|
||||
// v1.37.0 — C1: the bucket keys on the ACTOR, the frozen precedent from
|
||||
// `medications/compliance`. A manager burns their own allowance rather than
|
||||
// locking the owner out of their own record, and cannot collect a fresh one
|
||||
// by switching records.
|
||||
const rl = await checkRateLimit(
|
||||
`cycle:symptom:custom:${user.id}`,
|
||||
`cycle:symptom:custom:${actor.id}`,
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
* map the shared snapshot builder uses (`disabledSummaryTypes`); the filter
|
||||
* runs OUTSIDE the cache because a module toggle does not evict it.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -347,7 +347,9 @@ interface ActivityDayRow {
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
annotate({ action: { name: "dashboard.summary" } });
|
||||
|
||||
// v1.4.25 W7b — anchor every day-bucket call to the user's display
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
* - `fhir` → `application/fhir+json` (HL7 FHIR R4 document Bundle)
|
||||
* - `package` → `application/zip` (PDF + Bundle + README)
|
||||
*
|
||||
* Auth: cookie session OR Bearer token (`requireAuth`).
|
||||
* Rate-limit: shared `export:<userId>` bucket (10/h).
|
||||
* Auth: cookie session OR Bearer token, resolved through `requireRecordAuth`
|
||||
* at MANAGE — the report is reachable on a record the caller manages.
|
||||
* Rate-limit: shared `export:<actorId>` bucket (10/h).
|
||||
* Audit: `health-record.export` — format, window, and the chosen leaf ids,
|
||||
* never the values behind them.
|
||||
*
|
||||
@@ -21,7 +22,7 @@
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -84,7 +85,10 @@ async function rememberChoices(
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE: the doctor report is the one export an invited manager
|
||||
// reaches, because it renders a prepared artefact from a declared selection
|
||||
// rather than handing over the record whole.
|
||||
const { user, actor } = await requireRecordAuth("manage", "record");
|
||||
annotate({ action: { name: "export.health-record.build" } });
|
||||
|
||||
// The whole doctor-report surface is the `doctorReport` module. Refuse with
|
||||
@@ -93,7 +97,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const gate = await requireModuleEnabled(user.id, "doctorReport");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
|
||||
// v1.37.0 — the shared export bucket keys on the ACTOR. A manager burns
|
||||
// their own hourly allowance rather than the owner's, and switching records
|
||||
// does not hand them a fresh one.
|
||||
const rl = await checkRateLimit(`export:${actor.id}`, 10, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
@@ -51,12 +52,21 @@ export const GET = apiHandler(
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Correcting one relative's entry.
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
const { id } = await params;
|
||||
// Widened for the audit row (C4); the encrypted note stays out.
|
||||
const existing = await prisma.familyHistoryEntry.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, deletedAt: true },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
deletedAt: true,
|
||||
relationship: true,
|
||||
condition: true,
|
||||
ageAtOnset: true,
|
||||
},
|
||||
});
|
||||
if (
|
||||
!existing ||
|
||||
@@ -98,7 +108,23 @@ export const PATCH = apiHandler(
|
||||
await auditLog("family-history.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { entryId: id },
|
||||
// C4 — the replaced fields; the note is named and never quoted.
|
||||
details: {
|
||||
entryId: id,
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
relationship: existing.relationship,
|
||||
condition: existing.condition,
|
||||
ageAtOnset: existing.ageAtOnset,
|
||||
},
|
||||
after: {
|
||||
relationship: updated.relationship,
|
||||
condition: updated.condition,
|
||||
ageAtOnset: updated.ageAtOnset,
|
||||
},
|
||||
redacted: entry.note !== undefined ? ["note"] : [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
@@ -115,7 +141,8 @@ export const PATCH = apiHandler(
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Soft delete: the row stays and reconstructs itself.
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.familyHistoryEntry.findUnique({
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -77,7 +77,9 @@ async function postFamilyHistory(request: NextRequest): Promise<Response> {
|
||||
// record. The classification admitted it and named the discomfort. Landing
|
||||
// it together with the surface that offers it means the copy on that surface
|
||||
// can say whose statement the row is, which no route comment can.
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Third-party data by design, admitted on the same
|
||||
// reasoning the read arm was: it is the record's own health background.
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -138,7 +138,11 @@ export const GET = apiHandler(
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE, and this one is an edit wearing a create's verb: the
|
||||
// upsert revives a tombstoned day and replaces that day's symptom links.
|
||||
// Admitted at the level that admits editing, on the condition that the
|
||||
// audit row says what the write replaced.
|
||||
const { user } = await requireRecordAuth("manage", "illness");
|
||||
|
||||
const gate = await requireIllnessEnabled(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
@@ -169,7 +173,18 @@ export const POST = apiHandler(
|
||||
await auditLog("illness.day-log.upsert", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { dayLogId: result.id, episodeId: id, existed: result.existed },
|
||||
// C3 in this domain's shape: the date, the symptom set that was there
|
||||
// before, and whether the write brought back a day the owner deleted.
|
||||
// Without those three the feed says "updated a day log" about a day
|
||||
// somebody deliberately removed.
|
||||
details: {
|
||||
dayLogId: result.id,
|
||||
episodeId: id,
|
||||
existed: result.existed,
|
||||
date: parsed.data.date,
|
||||
previousSymptoms: result.replaced.previousSymptomKeys,
|
||||
revived: result.replaced.revived,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -28,7 +28,9 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Closing an episode; the window it writes is the
|
||||
// record's own and the reopen is one route over.
|
||||
const { user } = await requireRecordAuth("manage", "illness");
|
||||
|
||||
const gate = await requireIllnessEnabled(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
@@ -25,7 +25,9 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(
|
||||
withIdempotency<[NextRequest, RouteParams]>(async (request, { params }) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Reopening a deleted episode with its day logs
|
||||
// intact; the undo beside a delete the level admits.
|
||||
const { user } = await requireRecordAuth("manage", "illness");
|
||||
|
||||
const gate = await requireIllnessEnabled(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
@@ -55,7 +56,10 @@ export const GET = apiHandler(
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. The module gate below resolves against the RECORD, so
|
||||
// a manager cannot edit an episode in a record whose owner switched the
|
||||
// module off.
|
||||
const { user } = await requireRecordAuth("manage", "illness");
|
||||
|
||||
const gate = await requireIllnessEnabled(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
@@ -69,6 +73,11 @@ export const PATCH = apiHandler(
|
||||
deletedAt: true,
|
||||
onsetAt: true,
|
||||
resolvedAt: true,
|
||||
// v1.37.0 — the pre-image the audit row carries (C4).
|
||||
label: true,
|
||||
type: true,
|
||||
lifecycle: true,
|
||||
parentConditionId: true,
|
||||
},
|
||||
});
|
||||
if (
|
||||
@@ -150,7 +159,29 @@ export const PATCH = apiHandler(
|
||||
await auditLog("illness.episode.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { episodeId: id },
|
||||
// C4 — the replaced fields; the note is named and never quoted.
|
||||
details: {
|
||||
episodeId: id,
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
label: existing.label,
|
||||
type: existing.type,
|
||||
lifecycle: existing.lifecycle,
|
||||
onsetAt: existing.onsetAt,
|
||||
resolvedAt: existing.resolvedAt,
|
||||
parentConditionId: existing.parentConditionId,
|
||||
},
|
||||
after: {
|
||||
label: updated.label,
|
||||
type: updated.type,
|
||||
lifecycle: updated.lifecycle,
|
||||
onsetAt: updated.onsetAt,
|
||||
resolvedAt: updated.resolvedAt,
|
||||
parentConditionId: updated.parentConditionId,
|
||||
},
|
||||
redacted: entry.note !== undefined ? ["note"] : [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
@@ -167,7 +198,9 @@ export const PATCH = apiHandler(
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Soft delete with a restore route beside it; the day
|
||||
// logs under the episode come back with it.
|
||||
const { user } = await requireRecordAuth("manage", "illness");
|
||||
|
||||
const gate = await requireIllnessEnabled(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
@@ -31,7 +31,10 @@ const querySchema = z.object({
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
|
||||
// record, which is not a section a scoped grant can name. The miss behind it
|
||||
// enqueues nothing while a delegate is holding the request.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
generateBloodPressureStatusForUser,
|
||||
resolveBloodPressureStatusLocale,
|
||||
} from "@/lib/insights/blood-pressure-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
@@ -13,7 +13,10 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
|
||||
// record, which is not a section a scoped grant can name. The miss behind it
|
||||
// enqueues nothing while a delegate is holding the request.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
generateBmiStatusForUser,
|
||||
resolveBmiStatusLocale,
|
||||
} from "@/lib/insights/bmi-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
@@ -13,7 +13,10 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
|
||||
// record, which is not a section a scoped grant can name. The miss behind it
|
||||
// enqueues nothing while a delegate is holding the request.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* session.
|
||||
*/
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
@@ -27,7 +27,9 @@ export const dynamic = "force-dynamic";
|
||||
const WINDOW_DAYS = 30;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* to the iOS Insight model (id, title, summary, severity, recommendations,
|
||||
* provider).
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
@@ -65,7 +65,9 @@ interface InsightCard {
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
// v1.4.31 — the iOS cards adapter feeds the same per-metric
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
@@ -33,7 +33,9 @@ const coachReadQuerySchema = z.object({
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
@@ -22,7 +22,11 @@ import {
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { getMedicationCategories } from "@/lib/medication-category";
|
||||
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
|
||||
import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
requireRecordAuth,
|
||||
type AuthContext,
|
||||
} from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
@@ -41,7 +45,9 @@ import { userDayKey } from "@/lib/tz/format";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
|
||||
// v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* lives in `src/lib/insights/correlation-discovery.ts`; this route only
|
||||
* fetches + day-keys + responds. No LLM, no narrative, no cache table.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
@@ -70,7 +70,9 @@ function tzDayKey(at: Date, tz: string): string {
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
|
||||
// v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
|
||||
@@ -24,7 +24,7 @@ import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import pLimit from "p-limit";
|
||||
import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
@@ -115,17 +115,24 @@ function parseBatchItems(csv: string): {
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: the deterministic batch form, no provider on
|
||||
// the path.
|
||||
const { user, actor } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
// Per-user limiter, same posture as the compliance routes: the cold
|
||||
// Per-caller limiter, same posture as the compliance routes: the cold
|
||||
// build fans out up to 24 rollup walks, so an unthrottled caller could
|
||||
// hammer the pool past the cache. 30/min covers the dashboard's one
|
||||
// canonical read plus retries with a wide margin.
|
||||
//
|
||||
// v1.37.0 — the bucket keys on the ACTOR, the frozen precedent from
|
||||
// `medications/compliance`: a manager hammering this route burns their own
|
||||
// allowance rather than locking the owner out, and cannot collect a fresh
|
||||
// one by switching records.
|
||||
const rl = await checkRateLimit(
|
||||
`insights-derived-batch:${user.id}`,
|
||||
`insights-derived-batch:${actor.id}`,
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
@@ -69,7 +69,10 @@ const derivedQuerySchema = z.object({
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
|
||||
// record, which is not a section a scoped grant can name. The miss behind it
|
||||
// enqueues nothing while a delegate is holding the request.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
@@ -43,7 +43,9 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
@@ -106,7 +106,11 @@ const ecgIngestSchema = z
|
||||
.strict();
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: the record's own recordings, listed without
|
||||
// touching the encrypted waveform. The POST below is a device ingest and
|
||||
// keeps `requireAuth()`: a delegate's phone must never move somebody else's
|
||||
// sync.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* beyond auth. Auth via cookie or Bearer.
|
||||
*/
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
@@ -24,7 +24,9 @@ import {
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
|
||||
const plateau = await detectGlp1Plateau(user.id);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -66,7 +66,9 @@ function parseTagList(raw: string | null): string[] {
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const url = new URL(request.url);
|
||||
const rawLimit = Number(url.searchParams.get("limit"));
|
||||
const limit =
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* MeasurementType, so the wire carries the type tokens only.
|
||||
*/
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
@@ -28,7 +28,9 @@ import { summariseHealthStatus } from "@/lib/insights/health-status";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* session; soft-deleted rows (`deletedAt`) are filtered out.
|
||||
*/
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
@@ -24,7 +24,9 @@ import { summariseLabChanges } from "@/lib/insights/labs-changes";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
generateMedicationComplianceStatusForUser,
|
||||
resolveMedicationComplianceStatusLocale,
|
||||
} from "@/lib/insights/medication-compliance-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
@@ -13,7 +13,10 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
|
||||
// record, which is not a section a scoped grant can name. The miss behind it
|
||||
// enqueues nothing while a delegate is holding the request.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
@@ -68,7 +68,10 @@ const metricQuerySchema = z.object({
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
|
||||
// record, which is not a section a scoped grant can name. The miss behind it
|
||||
// enqueues nothing while a delegate is holding the request.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
generateMoodStatusForUser,
|
||||
resolveMoodStatusLocale,
|
||||
} from "@/lib/insights/mood-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
@@ -13,7 +13,10 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
|
||||
// record, which is not a section a scoped grant can name. The miss behind it
|
||||
// enqueues nothing while a delegate is holding the request.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type NarrativePeriod,
|
||||
} from "@/lib/insights/narrative/period-narrative";
|
||||
import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared";
|
||||
import { delegatedGenerationSuppressed } from "@/lib/sharing/delegated-generation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -52,7 +53,10 @@ function narrowLocale(locale: Locale): Locale {
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
|
||||
// record, which is not a section a scoped grant can name. The miss behind it
|
||||
// enqueues nothing while a delegate is holding the request.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
@@ -87,15 +91,26 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// provider-less account (incl. the no-key demo) gets a non-empty
|
||||
// retrospective on the next read. The enqueue's singletonKey + the 20 h
|
||||
// freshness window bound this to at most one warm per period per ~day.
|
||||
//
|
||||
// v1.37.0 — except on a delegated request. This route warms unconditionally
|
||||
// rather than on a miss, so without the check a manager's first navigation
|
||||
// here is an egress of the owner's record that the owner did not ask for.
|
||||
// The stale row is still served; only the warm is withheld.
|
||||
let revalidating = false;
|
||||
if (!isFresh) {
|
||||
const generationSuppressed = delegatedGenerationSuppressed();
|
||||
if (!isFresh && !generationSuppressed) {
|
||||
void enqueueNarrativeWarm({ userId: user.id, period, locale });
|
||||
revalidating = true;
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.narrative" },
|
||||
meta: { period, has_narrative: existing !== null, revalidating },
|
||||
meta: {
|
||||
period,
|
||||
has_narrative: existing !== null,
|
||||
revalidating,
|
||||
generation_suppressed: generationSuppressed,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -21,7 +21,11 @@ const patternIdSchema = z.string().min(1).max(128);
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE: dismissing a pattern hides a finding on somebody
|
||||
// else's front door, so it opens at the level that manages the record and
|
||||
// at no level below. The dismissal stores its own evidence hash and effect
|
||||
// size, so it reads back and reverses.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const gate = await requireModuleEnabled(user.id, "insights");
|
||||
if (!gate.enabled) return gate.response;
|
||||
const parsedId = patternIdSchema.safeParse((await params).id);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -7,7 +7,9 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const gate = await requireModuleEnabled(user.id, "insights");
|
||||
if (!gate.enabled) return gate.response;
|
||||
const patterns = await prisma.correlationPattern.findMany({
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
generatePulseStatusForUser,
|
||||
resolvePulseStatusLocale,
|
||||
} from "@/lib/insights/pulse-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
@@ -13,7 +13,10 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
|
||||
// record, which is not a section a scoped grant can name. The miss behind it
|
||||
// enqueues nothing while a delegate is holding the request.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
|
||||
@@ -37,7 +37,9 @@ const querySchema = z.object({
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
* provider call — this is a pure DB read).
|
||||
*/
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
@@ -44,7 +44,9 @@ const EVENT_TYPE_LIST = Array.from(
|
||||
) as MeasurementType[];
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* walk can be unit-tested without the route shell.
|
||||
*/
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
|
||||
@@ -15,7 +15,9 @@ import { buildTargetsResponse } from "@/lib/targets/build-response";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
// v1.16.8 — stale-while-revalidate read. The cold build is the multi-query
|
||||
// walk in `buildTargetsResponse`; past the 60 s fresh TTL the prior body
|
||||
// serves immediately while ONE background rebuild warms the cell. Writes
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
generateWeightStatusForUser,
|
||||
resolveWeightStatusLocale,
|
||||
} from "@/lib/insights/weight-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
@@ -13,7 +13,10 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
|
||||
// record, which is not a section a scoped grant can name. The miss behind it
|
||||
// enqueues nothing while a delegate is holding the request.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
@@ -90,7 +91,10 @@ export const GET = apiHandler(
|
||||
|
||||
export const PUT = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Correcting a transcribed lab result is the ordinary
|
||||
// reason somebody manages a record; the previous numbers ride the audit
|
||||
// row below.
|
||||
const { user } = await requireRecordAuth("manage", "labs");
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.labResult.findFirst({
|
||||
@@ -116,17 +120,16 @@ export const PUT = apiHandler(
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "labs.update.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues, labResultId: id }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — the 422 response is the contract */
|
||||
});
|
||||
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
|
||||
// .create`, because that helper is the only writer that stamps
|
||||
// `actorUserId`. Filed under the resolved record either way; without the
|
||||
// stamp a manager's malformed payload would read as the owner's own.
|
||||
void auditLog("labs.update.validation-failed", {
|
||||
userId: user.id,
|
||||
details: { issues: auditIssues, labResultId: id },
|
||||
}).catch(() => {
|
||||
/* swallow — the 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "labs.update.invalid",
|
||||
});
|
||||
@@ -223,7 +226,34 @@ export const PUT = apiHandler(
|
||||
await auditLog("labResult.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { labResultId: id },
|
||||
// C4 — the replaced result. The note is named and never quoted: it is
|
||||
// encrypted on the row.
|
||||
details: {
|
||||
labResultId: id,
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
analyte: existing.analyte,
|
||||
value: existing.value,
|
||||
valueText: existing.valueText,
|
||||
unit: existing.unit,
|
||||
panel: existing.panel,
|
||||
referenceLow: existing.referenceLow,
|
||||
referenceHigh: existing.referenceHigh,
|
||||
takenAt: existing.takenAt,
|
||||
},
|
||||
after: {
|
||||
analyte: updated.analyte,
|
||||
value: updated.value,
|
||||
valueText: updated.valueText,
|
||||
unit: updated.unit,
|
||||
panel: updated.panel,
|
||||
referenceLow: updated.referenceLow,
|
||||
referenceHigh: updated.referenceHigh,
|
||||
takenAt: updated.takenAt,
|
||||
},
|
||||
redacted: d.note !== undefined ? ["note"] : [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({ action: { name: "labs.update" }, meta: { labResultId: id } });
|
||||
@@ -236,7 +266,9 @@ export const PUT = apiHandler(
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Soft-delete with `labs/restore` beside it, so the
|
||||
// reading is reconstructable from the row itself.
|
||||
const { user } = await requireRecordAuth("manage", "labs");
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.labResult.findFirst({
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
@@ -39,10 +39,17 @@ const restoreSchema = z.object({
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postRestore));
|
||||
|
||||
async function postRestore(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. The undo beside a delete the level already admits; the
|
||||
// `where` only ever clears a tombstone the record already carried, so a
|
||||
// foreign or live id is a no-op.
|
||||
const { user, actor } = await requireRecordAuth("manage", "labs");
|
||||
|
||||
// v1.37.0 — C1: the bucket keys on the ACTOR, the frozen precedent from
|
||||
// `medications/compliance`. A manager burns their own allowance rather than
|
||||
// locking the owner out of their own record, and cannot collect a fresh one
|
||||
// by switching records.
|
||||
const rl = await checkRateLimit(
|
||||
`labs:restore:${user.id}`,
|
||||
`labs:restore:${actor.id}`,
|
||||
RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
|
||||
@@ -18,6 +18,14 @@ import { NextRequest } from "next/server";
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
|
||||
// v1.37.0 — the mutating arms resolve the RECORD at MANAGE. `actor` is the
|
||||
// caller and equals `user` for everyone acting on their own reminders,
|
||||
// which is what this suite exercises.
|
||||
requireRecordAuth: vi.fn(async () => ({
|
||||
user: { id: "u1", locale: "en" },
|
||||
actor: { id: "u1", locale: "en" },
|
||||
grantId: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
|
||||
@@ -12,6 +12,14 @@ import { NextRequest } from "next/server";
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
|
||||
// v1.37.0 — the mutating arms resolve the RECORD at MANAGE. `actor` is the
|
||||
// caller and equals `user` for everyone acting on their own reminders,
|
||||
// which is what this suite exercises.
|
||||
requireRecordAuth: vi.fn(async () => ({
|
||||
user: { id: "u1", locale: "en" },
|
||||
actor: { id: "u1", locale: "en" },
|
||||
grantId: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
|
||||
@@ -24,7 +24,7 @@ import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
|
||||
@@ -36,7 +36,9 @@ const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Marking a reminder satisfied re-anchors a schedule the level
|
||||
// already admits arming and editing.
|
||||
const { user } = await requireRecordAuth("manage", "measurements");
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.measurementReminder.findFirst({
|
||||
|
||||
@@ -10,7 +10,11 @@ import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
destroyedDetails,
|
||||
overwriteDetails,
|
||||
} from "@/lib/sharing/audit-details";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
@@ -62,7 +66,9 @@ export const GET = apiHandler(
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Editing a reminder re-anchors when the record's own
|
||||
// phone rings, which is management of the schedule the level admits.
|
||||
const { user } = await requireRecordAuth("manage", "measurements");
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.measurementReminder.findFirst({
|
||||
@@ -87,17 +93,16 @@ export const PATCH = apiHandler(
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "measurement-reminders.update.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues, reminderId: id }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
|
||||
// .create`, because that helper is the only writer that stamps
|
||||
// `actorUserId`. Filed under the resolved record either way; without the
|
||||
// stamp a manager's malformed payload would read as the owner's own.
|
||||
void auditLog("measurement-reminders.update.validation-failed", {
|
||||
userId: user.id,
|
||||
details: { issues: auditIssues, reminderId: id },
|
||||
}).catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
@@ -183,7 +188,33 @@ export const PATCH = apiHandler(
|
||||
await auditLog("measurementReminder.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reminderId: id },
|
||||
// C4 — what the edit replaced. A cadence change moves when somebody's
|
||||
// phone rings; the feed has to be able to say what it used to be.
|
||||
details: {
|
||||
reminderId: id,
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
label: existing.label,
|
||||
measurementType: existing.measurementType,
|
||||
intervalDays: existing.intervalDays,
|
||||
rrule: existing.rrule,
|
||||
anchorDate: existing.anchorDate,
|
||||
notifyHour: existing.notifyHour,
|
||||
enabled: existing.enabled,
|
||||
nextDueAt: existing.nextDueAt,
|
||||
},
|
||||
after: {
|
||||
label: updated.label,
|
||||
measurementType: updated.measurementType,
|
||||
intervalDays: updated.intervalDays,
|
||||
rrule: updated.rrule,
|
||||
anchorDate: updated.anchorDate,
|
||||
notifyHour: updated.notifyHour,
|
||||
enabled: updated.enabled,
|
||||
nextDueAt: updated.nextDueAt,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
@@ -197,7 +228,10 @@ export const PATCH = apiHandler(
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE, and the one verb in this family that destroys without
|
||||
// a tombstone: the row goes, so the audit row below is the only thing left
|
||||
// that knows what it was for.
|
||||
const { user } = await requireRecordAuth("manage", "measurements");
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.measurementReminder.findUnique({
|
||||
@@ -218,7 +252,25 @@ export const DELETE = apiHandler(
|
||||
await auditLog("measurementReminder.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reminderId: id },
|
||||
// C3 — a hard delete, so the details carry the identity of what went:
|
||||
// the label the owner wrote, what it watched, when it was next due. An
|
||||
// id alone would say a preventive-care recall disappeared and never say
|
||||
// what it was for.
|
||||
details: {
|
||||
reminderId: id,
|
||||
...destroyedDetails({
|
||||
model: "MeasurementReminder",
|
||||
id,
|
||||
label: existing.label,
|
||||
effectiveAt: existing.nextDueAt,
|
||||
extra: {
|
||||
measurementType: existing.measurementType,
|
||||
intervalDays: existing.intervalDays,
|
||||
rrule: existing.rrule,
|
||||
origin: existing.origin,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -10,7 +10,7 @@ import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
|
||||
@@ -22,7 +22,9 @@ const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. The same primitive from the checklist side re-anchors a schedule the level
|
||||
// already admits arming and editing.
|
||||
const { user } = await requireRecordAuth("manage", "measurements");
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.measurementReminder.findFirst({
|
||||
|
||||
@@ -8,7 +8,7 @@ import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
@@ -57,7 +57,10 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
|
||||
async function postReminder(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. A reminder armed by a manager rings the record's own
|
||||
// phone, which is the person the reminder is about, and it is additive: the
|
||||
// row is listed, editable and removable by the owner.
|
||||
const { user } = await requireRecordAuth("manage", "measurements");
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
@@ -74,17 +77,16 @@ async function postReminder(request: NextRequest): Promise<Response> {
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "measurement-reminders.create.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
|
||||
// .create`, because that helper is the only writer that stamps
|
||||
// `actorUserId`. Filed under the resolved record either way; without the
|
||||
// stamp a manager's malformed payload would read as the owner's own.
|
||||
void auditLog("measurement-reminders.create.validation-failed", {
|
||||
userId: user.id,
|
||||
details: { issues: auditIssues },
|
||||
}).catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
|
||||
@@ -74,7 +75,11 @@ const EXISTING = {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
// v1.37.0 — the breadcrumb goes through `auditLog()` now (the only writer
|
||||
// that stamps the actor). `resetAllMocks` clears the factory's resolved
|
||||
// value and the route chains `.catch()` on the call, so it has to resolve
|
||||
// again here or every 422 path throws.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
// resetAllMocks clears the factory implementation; the route chains
|
||||
// `.catch()` on this fire-and-forget call, so it must resolve again.
|
||||
vi.mocked(invalidateStatusInsightsForTypes).mockResolvedValue(
|
||||
@@ -136,18 +141,17 @@ describe("PUT /api/measurements/[id] — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
);
|
||||
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.userId).toBe("user-1");
|
||||
expect(call.data.action).toBe("measurements.update.validation-failed");
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
const [action, opts] = vi.mocked(auditLog).mock.calls[0] as [
|
||||
string,
|
||||
{ userId: string },
|
||||
];
|
||||
expect(action).toBe("measurements.update.validation-failed");
|
||||
expect(opts.userId).toBe("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 PUT(
|
||||
putReq({ value: "junk", measuredAt: "junk" }),
|
||||
ROUTE_CTX,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
@@ -57,7 +58,10 @@ export const GET = apiHandler(
|
||||
|
||||
export const PUT = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Correcting somebody else's reading is management of
|
||||
// their record, not a contribution to it, and the previous value is gone
|
||||
// the moment this lands: the audit row below carries it.
|
||||
const { user } = await requireRecordAuth("manage", "measurements");
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
@@ -91,20 +95,19 @@ export const PUT = apiHandler(
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "measurements.update.validation-failed",
|
||||
details: JSON.stringify({
|
||||
issues: auditIssues,
|
||||
measurementId: id,
|
||||
}),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
|
||||
// .create`, because that helper is the only writer that stamps
|
||||
// `actorUserId`. Filed under the resolved record either way; without the
|
||||
// stamp a manager's malformed payload would read as the owner's own.
|
||||
void auditLog("measurements.update.validation-failed", {
|
||||
userId: user.id,
|
||||
details: {
|
||||
issues: auditIssues,
|
||||
measurementId: id,
|
||||
},
|
||||
}).catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
@@ -202,7 +205,21 @@ export const PUT = apiHandler(
|
||||
await auditLog("measurement.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { measurementId: id },
|
||||
// C4 — the overwritten scalars. Without them the owner's feed says a
|
||||
// reading changed and cannot say from what. The note is named and never
|
||||
// quoted: it is encrypted on the row and copying it here would make the
|
||||
// audit table a second store for it.
|
||||
details: {
|
||||
measurementId: id,
|
||||
...overwriteDetails({
|
||||
before: { value: existing.value, measuredAt: existing.measuredAt },
|
||||
after: {
|
||||
value: measurement.value,
|
||||
measuredAt: measurement.measuredAt,
|
||||
},
|
||||
redacted: data.notes !== undefined ? ["notes"] : [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
@@ -260,7 +277,10 @@ export const PUT = apiHandler(
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. The row tombstones and `measurements/restore` puts it
|
||||
// back, so the destruction is reversible by the owner and by whoever did
|
||||
// it; the audit row already names the reading and its type.
|
||||
const { user } = await requireRecordAuth("manage", "measurements");
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -48,10 +48,17 @@ const bulkDeleteSchema = z.object({
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulkDelete));
|
||||
|
||||
async function postBulkDelete(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Every row tombstones and `measurements/restore` puts
|
||||
// them back, so reconstruction rides the rows themselves rather than the
|
||||
// audit details.
|
||||
const { user, actor } = await requireRecordAuth("manage", "measurements");
|
||||
|
||||
// v1.37.0 — C1: the bucket keys on the ACTOR, the frozen precedent from
|
||||
// `medications/compliance`. A manager burns their own allowance rather than
|
||||
// locking the owner out of their own record, and cannot collect a fresh one
|
||||
// by switching records.
|
||||
const rl = await checkRateLimit(
|
||||
`measurements:bulk-delete:${user.id}`,
|
||||
`measurements:bulk-delete:${actor.id}`,
|
||||
RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -50,10 +50,16 @@ const restoreSchema = z.object({
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postRestore));
|
||||
|
||||
async function postRestore(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. A manager who holds the delete holds the undo; the
|
||||
// `where` below only ever clears a tombstone the record already carried.
|
||||
const { user, actor } = await requireRecordAuth("manage", "measurements");
|
||||
|
||||
// v1.37.0 — C1: the bucket keys on the ACTOR, the frozen precedent from
|
||||
// `medications/compliance`. A manager burns their own allowance rather than
|
||||
// locking the owner out of their own record, and cannot collect a fresh one
|
||||
// by switching records.
|
||||
const rl = await checkRateLimit(
|
||||
`measurements:restore:${user.id}`,
|
||||
`measurements:restore:${actor.id}`,
|
||||
RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
@@ -224,17 +224,22 @@ export const GET = apiHandler(
|
||||
*/
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. A titration step is record data and the dose is what
|
||||
// the server acts on; administering one is the case the level names.
|
||||
const { user, actor } = await requireRecordAuth("manage", "medications");
|
||||
const { id } = await params;
|
||||
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
// Per-user POST rate-limit — matches the 30/min sibling routes
|
||||
// Per-caller POST rate-limit — matches the 30/min sibling routes
|
||||
// (inventory, side-effects). Generous for a hand-driven session,
|
||||
// tight enough to cut off the spam case.
|
||||
//
|
||||
// v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own
|
||||
// allowance and cannot collect a fresh one by switching records.
|
||||
const rl = await checkRateLimit(
|
||||
`medication-glp1:post:${user.id}`,
|
||||
`medication-glp1:post:${actor.id}`,
|
||||
POST_RATE_LIMIT,
|
||||
POST_WINDOW_MS,
|
||||
);
|
||||
|
||||
@@ -87,6 +87,9 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
// v1.37.0 — `resetAllMocks` clears the factory's resolved value and the
|
||||
// route chains `.catch()` on this fire-and-forget call.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue({
|
||||
id: "e1",
|
||||
userId: "user-1",
|
||||
@@ -161,19 +164,19 @@ describe("PUT /api/medications/[id]/intake/[eventId] — 422 multi-issue (v1.4.4
|
||||
);
|
||||
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(
|
||||
// v1.37.0 — the breadcrumb goes through `auditLog()`, the only writer
|
||||
// that stamps `actorUserId`.
|
||||
const breadcrumbs = vi
|
||||
.mocked(auditLog)
|
||||
.mock.calls.filter(([name]) => name.endsWith("validation-failed"));
|
||||
expect(breadcrumbs).toHaveLength(1);
|
||||
expect(breadcrumbs[0]?.[0]).toBe(
|
||||
"medications.intake.event.update.validation-failed",
|
||||
);
|
||||
});
|
||||
|
||||
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 PUT(
|
||||
putReq({ takenAt: "not-iso", skipped: "string" }),
|
||||
ROUTE_CTX,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
@@ -32,7 +33,10 @@ type RouteParams = { params: Promise<{ id: string; eventId: string }> };
|
||||
|
||||
export const PUT = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Correcting a dose tombstones the original and
|
||||
// re-creates it on the corrected slot, so the write is reconstructable by
|
||||
// the rows; what it must not do is restamp where the dose came from (C8).
|
||||
const { user } = await requireRecordAuth("manage", "medications");
|
||||
|
||||
const { id, eventId } = await params;
|
||||
|
||||
@@ -65,21 +69,20 @@ export const PUT = apiHandler(
|
||||
event_id: eventId,
|
||||
},
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "medications.intake.event.update.validation-failed",
|
||||
details: JSON.stringify({
|
||||
issues,
|
||||
medicationId: id,
|
||||
eventId,
|
||||
}),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
|
||||
// .create`, because that helper is the only writer that stamps
|
||||
// `actorUserId`. Filed under the resolved record either way; without the
|
||||
// stamp a manager's malformed payload would read as the owner's own.
|
||||
void auditLog("medications.intake.event.update.validation-failed", {
|
||||
userId: user.id,
|
||||
details: {
|
||||
issues,
|
||||
medicationId: id,
|
||||
eventId,
|
||||
},
|
||||
}).catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
@@ -307,7 +310,11 @@ export const PUT = apiHandler(
|
||||
isExplicitTaken: !nextSkipped && nextTakenAt !== null,
|
||||
isExplicitSkip: nextSkipped,
|
||||
idempotencyKey: null,
|
||||
createSource: "WEB",
|
||||
// C8 — the converged row keeps the ORIGINAL's provenance. This used
|
||||
// to be a hardcoded `"WEB"`, which said "somebody typed this in the
|
||||
// browser" about an Apple Health dose whose time was corrected. Under
|
||||
// MANAGE the sentence would be wrong about a second person as well.
|
||||
createSource: event.source,
|
||||
attributionSource,
|
||||
// v1.16.9 — a slot move is a re-binding, not a new dose: the
|
||||
// original row's recorded injection site and dose override ride
|
||||
@@ -339,7 +346,28 @@ export const PUT = apiHandler(
|
||||
await auditLog("medication.intake.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { eventId, medicationId: id },
|
||||
// C4 — what the correction moved. A dose edit changes when a dose is
|
||||
// recorded as taken, which is exactly what a clinician reads back.
|
||||
details: {
|
||||
eventId,
|
||||
medicationId: id,
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
scheduledFor: event.scheduledFor,
|
||||
takenAt: event.takenAt,
|
||||
skipped: event.skipped,
|
||||
},
|
||||
// The slot-write result carries the canonical four. The injection
|
||||
// site and the dose override are not compared here because the move
|
||||
// path copies them onto the converged row by construction — there is
|
||||
// no edit of them to report.
|
||||
after: {
|
||||
scheduledFor: updated.scheduledFor,
|
||||
takenAt: updated.takenAt,
|
||||
skipped: updated.skipped,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
@@ -394,7 +422,9 @@ export const PUT = apiHandler(
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Soft delete: the row stays, the sync feed carries the
|
||||
// tombstone, and the inventory stamp is refunded.
|
||||
const { user } = await requireRecordAuth("manage", "medications");
|
||||
|
||||
const { id, eventId } = await params;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -59,17 +59,22 @@ const BULK_DELETE_WINDOW_MS = 60_000;
|
||||
*/
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Tombstoning a run of doses; every row stays and the
|
||||
// sync feed carries each one.
|
||||
const { user, actor } = await requireRecordAuth("manage", "medications");
|
||||
|
||||
const { id } = await params;
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
// v1.5.5 F-1 H-6 — per-user cap matches the glp1 POST so a caller
|
||||
// v1.5.5 F-1 H-6 — per-caller cap matches the glp1 POST so a caller
|
||||
// firing repeated 500-id bulk deletes cannot pin the per-dayKey
|
||||
// recompute path.
|
||||
//
|
||||
// v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own
|
||||
// allowance and cannot collect a fresh one by switching records.
|
||||
const rl = await checkRateLimit(
|
||||
`medication-intake-bulk-delete:${user.id}`,
|
||||
`medication-intake-bulk-delete:${actor.id}`,
|
||||
BULK_DELETE_RATE_LIMIT,
|
||||
BULK_DELETE_WINDOW_MS,
|
||||
);
|
||||
|
||||
@@ -59,6 +59,7 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -91,6 +92,9 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
// v1.37.0 — `resetAllMocks` clears the factory's resolved value and the
|
||||
// route chains `.catch()` on this fire-and-forget call.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 60,
|
||||
@@ -179,19 +183,19 @@ describe("POST /api/medications/[id]/intake/import — 422 multi-issue (v1.4.43
|
||||
);
|
||||
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(
|
||||
// v1.37.0 — the breadcrumb goes through `auditLog()`, the only writer
|
||||
// that stamps `actorUserId`.
|
||||
const breadcrumbs = vi
|
||||
.mocked(auditLog)
|
||||
.mock.calls.filter(([name]) => name.endsWith("validation-failed"));
|
||||
expect(breadcrumbs).toHaveLength(1);
|
||||
expect(breadcrumbs[0]?.[0]).toBe(
|
||||
"medications.intake.import.validation-failed",
|
||||
);
|
||||
});
|
||||
|
||||
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 POST(
|
||||
postReq([{ datum: "not-a-date", uhrzeit: "07:00:00" }]),
|
||||
ROUTE_CTX,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -98,7 +98,12 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. The import is additive with duplicates skipped, every
|
||||
// written row tombstones, and the provenance it stamps (`IMPORT`) stays
|
||||
// true under delegation because it describes where the data came from and
|
||||
// not who typed it. C9: the enqueued job outlives this request, so the
|
||||
// kickoff audit row below joins it by `jobId`.
|
||||
const { user, actor } = await requireRecordAuth("manage", "medications");
|
||||
|
||||
const { id } = await params;
|
||||
// v1.5.5 C-E3-3 — route ownership through the shared helper so the
|
||||
@@ -106,8 +111,10 @@ export const POST = apiHandler(
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
// v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own
|
||||
// allowance and cannot collect a fresh one by switching records.
|
||||
const rl = await checkRateLimit(
|
||||
`medications:intake:import:${user.id}`,
|
||||
`medications:intake:import:${actor.id}`,
|
||||
IMPORT_RATE_LIMIT_MAX,
|
||||
IMPORT_RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
@@ -147,20 +154,19 @@ export const POST = apiHandler(
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "medications.intake.import.validation-failed",
|
||||
details: JSON.stringify({
|
||||
issues: auditIssues,
|
||||
medicationId: id,
|
||||
}),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
|
||||
// .create`, because that helper is the only writer that stamps
|
||||
// `actorUserId`. Filed under the resolved record either way; without the
|
||||
// stamp a manager's malformed payload would read as the owner's own.
|
||||
void auditLog("medications.intake.import.validation-failed", {
|
||||
userId: user.id,
|
||||
details: {
|
||||
issues: auditIssues,
|
||||
medicationId: id,
|
||||
},
|
||||
}).catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "medication.intake.import.invalid_format",
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
@@ -12,7 +12,10 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. It tombstones rather than deletes (since the
|
||||
// delegated-write release) and drops only rollups that recompute, so the
|
||||
// history it clears is reconstructable from the rows themselves.
|
||||
const { user } = await requireRecordAuth("manage", "medications");
|
||||
|
||||
const { id } = await params;
|
||||
// v1.5.5 C-E3-3 — route purge through the same ownership predicate
|
||||
|
||||
@@ -21,8 +21,12 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
destroyedDetails,
|
||||
overwriteDetails,
|
||||
} from "@/lib/sharing/audit-details";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
apiError,
|
||||
@@ -45,7 +49,8 @@ type RouteParams = { params: Promise<{ id: string; itemId: string }> };
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Correcting a stock item's state or count.
|
||||
const { user } = await requireRecordAuth("manage", "medications");
|
||||
const { id, itemId } = await params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
@@ -193,20 +198,46 @@ export const PATCH = apiHandler(
|
||||
},
|
||||
});
|
||||
|
||||
return { prevState: existing.state, updated };
|
||||
return {
|
||||
prevState: existing.state,
|
||||
// v1.37.0 — the pre-image the audit row carries (C4).
|
||||
previous: {
|
||||
unitsRemaining: Number(existing.unitsRemaining),
|
||||
unitsTotal: Number(existing.unitsTotal),
|
||||
firstUseAt: existing.firstUseAt,
|
||||
printedExpiry: existing.printedExpiry,
|
||||
},
|
||||
updated,
|
||||
};
|
||||
});
|
||||
|
||||
if ("notFound" in outcome) return apiError("Inventory item not found", 404);
|
||||
const { prevState, updated } = outcome;
|
||||
const { prevState, previous, updated } = outcome;
|
||||
|
||||
await auditLog("medication.inventory.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
// C4 — the counts and dates this edit replaced, beside the state
|
||||
// transition the row already carried.
|
||||
details: {
|
||||
medicationId: id,
|
||||
itemId,
|
||||
prevState,
|
||||
nextState: updated.state,
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
unitsRemaining: previous.unitsRemaining,
|
||||
unitsTotal: previous.unitsTotal,
|
||||
firstUseAt: previous.firstUseAt,
|
||||
printedExpiry: previous.printedExpiry,
|
||||
},
|
||||
after: {
|
||||
unitsRemaining: Number(updated.unitsRemaining),
|
||||
unitsTotal: Number(updated.unitsTotal),
|
||||
firstUseAt: updated.firstUseAt,
|
||||
printedExpiry: updated.printedExpiry,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -231,7 +262,9 @@ export const PATCH = apiHandler(
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE, and a hard delete. The audit row already carried the
|
||||
// final state and the units left; C3 adds what the container WAS.
|
||||
const { user } = await requireRecordAuth("manage", "medications");
|
||||
const { id, itemId } = await params;
|
||||
|
||||
// v1.32.22 (M5) — take the same per-medication advisory lock the intake
|
||||
@@ -255,11 +288,27 @@ export const DELETE = apiHandler(
|
||||
await auditLog("medication.inventory.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
// C3 — the container's own identity: its label, its expiry, what it
|
||||
// held. The row is gone; this is what is left of it.
|
||||
details: {
|
||||
medicationId: id,
|
||||
itemId,
|
||||
finalState: existing.state,
|
||||
unitsRemaining: existing.unitsRemaining,
|
||||
unitsRemaining: Number(existing.unitsRemaining),
|
||||
...destroyedDetails({
|
||||
model: "MedicationInventoryItem",
|
||||
id: itemId,
|
||||
label:
|
||||
existing.doseStrength ??
|
||||
existing.manufacturer ??
|
||||
existing.containerType,
|
||||
effectiveAt: existing.expiresAt ?? existing.printedExpiry,
|
||||
extra: {
|
||||
containerType: existing.containerType,
|
||||
unitsTotal: Number(existing.unitsTotal),
|
||||
printedExpiry: existing.printedExpiry,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
*
|
||||
* Per-item operations live on the `[itemId]` sub-route.
|
||||
*
|
||||
* Auth: cookie-session via requireAuth(); the medication is verified
|
||||
* Auth: cookie-session or Bearer, resolved through `requireRecordAuth`; the
|
||||
* medication is verified
|
||||
* to belong to the caller before any read/write touches inventory.
|
||||
* Rate-limit 30/min/user on the POST path (creation is the only
|
||||
* surface that can spam writes; GET is read-only and benign).
|
||||
@@ -16,7 +17,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
@@ -91,16 +92,22 @@ export const GET = apiHandler(
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Recording the stock the low-stock notification reads;
|
||||
// that notification rings the record's own phone.
|
||||
const { user, actor } = await requireRecordAuth("manage", "medications");
|
||||
const { id } = await params;
|
||||
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
// Per-user POST rate-limit — 30/min is generous for normal pen
|
||||
// Per-caller POST rate-limit — 30/min is generous for normal pen
|
||||
// registrations but cuts off the spam case.
|
||||
//
|
||||
// v1.37.0 — C1: the bucket keys on the ACTOR, so a manager burns their
|
||||
// own allowance rather than the owner's and cannot collect a fresh one by
|
||||
// switching records.
|
||||
const rl = await checkRateLimit(
|
||||
`medication-inventory:post:${user.id}`,
|
||||
`medication-inventory:post:${actor.id}`,
|
||||
POST_RATE_LIMIT,
|
||||
POST_WINDOW_MS,
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
@@ -187,7 +188,13 @@ export const GET = apiHandler(
|
||||
|
||||
export const PUT = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE, and the strongest candidate in the domain: a manager
|
||||
// administering somebody's regimen is the case the level exists for. What
|
||||
// it destroys is scheduling anchors, not history — the tombstone `where`
|
||||
// below excludes every actioned row — and the previous cadence is the one
|
||||
// genuinely unrecoverable part, which is what C7 puts in the audit row.
|
||||
// The DELETE beside it stays refused.
|
||||
const { user } = await requireRecordAuth("manage", "medications");
|
||||
|
||||
const { id } = await params;
|
||||
// v1.5.5 C-E3-3 — route ownership check through the shared helper
|
||||
@@ -206,6 +213,10 @@ export const PUT = apiHandler(
|
||||
active: true,
|
||||
createdAt: true,
|
||||
asNeeded: true,
|
||||
// v1.37.0 — the pre-image the audit row carries (C4).
|
||||
name: true,
|
||||
dose: true,
|
||||
endsOn: true,
|
||||
_count: { select: { schedules: true } },
|
||||
},
|
||||
});
|
||||
@@ -440,6 +451,17 @@ export const PUT = apiHandler(
|
||||
};
|
||||
});
|
||||
|
||||
// v1.37.0 — C7. What a schedule replacement destroys, captured for the
|
||||
// audit row: the cadence that was there and the pending slots that were
|
||||
// tombstoned with it. A feed line saying "changed a medication" cannot
|
||||
// answer "back to what?", and unlike a row a schedule keeps ACTING after
|
||||
// the manager's grant ends.
|
||||
let replacedCadence: string | null = null;
|
||||
let replacedScheduleCount: number | null = null;
|
||||
let tombstonedSlotCount: number | null = null;
|
||||
let tombstonedFrom: Date | null = null;
|
||||
let tombstonedTo: Date | null = null;
|
||||
|
||||
// If schedules provided, replace all
|
||||
if (schedules && normalisedSchedules) {
|
||||
// v1.16.3 — effective dating. Before the wholesale replace below wipes
|
||||
@@ -467,6 +489,25 @@ export const PUT = apiHandler(
|
||||
reminderGraceMinutes: row.reminderGraceMinutes,
|
||||
}),
|
||||
);
|
||||
replacedScheduleCount = previousRows.length;
|
||||
replacedCadence =
|
||||
previousRows.length > 0
|
||||
? previousRows
|
||||
.map((row) =>
|
||||
[
|
||||
row.timesOfDay,
|
||||
row.rrule ?? "",
|
||||
row.rollingIntervalDays !== null
|
||||
? `every ${row.rollingIntervalDays}d`
|
||||
: "",
|
||||
row.daysOfWeek ?? "",
|
||||
]
|
||||
.filter((part) => part !== "")
|
||||
.join(" "),
|
||||
)
|
||||
.join(" | ")
|
||||
: null;
|
||||
|
||||
if (
|
||||
previousRows.length > 0 &&
|
||||
schedulesMateriallyDiffer(
|
||||
@@ -564,6 +605,16 @@ export const PUT = apiHandler(
|
||||
where: tombstoneWhere,
|
||||
select: { scheduledFor: true },
|
||||
});
|
||||
// C7 — the count and the day range of the anchors that went.
|
||||
tombstonedSlotCount = tombstoned.length;
|
||||
for (const row of tombstoned) {
|
||||
if (tombstonedFrom === null || row.scheduledFor < tombstonedFrom) {
|
||||
tombstonedFrom = row.scheduledFor;
|
||||
}
|
||||
if (tombstonedTo === null || row.scheduledFor > tombstonedTo) {
|
||||
tombstonedTo = row.scheduledFor;
|
||||
}
|
||||
}
|
||||
await prisma.medicationIntakeEvent.updateMany({
|
||||
where: tombstoneWhere,
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
@@ -729,7 +780,37 @@ export const PUT = apiHandler(
|
||||
await auditLog("medication.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { medicationId: id },
|
||||
// C4 on what the medication row itself lost, and C7 on the schedule:
|
||||
// the cadence that was replaced, and how many pending slots went with
|
||||
// it over which days.
|
||||
details: {
|
||||
medicationId: id,
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
name: existing.name,
|
||||
dose: existing.dose,
|
||||
active: existing.active,
|
||||
asNeeded: existing.asNeeded,
|
||||
endsOn: existing.endsOn,
|
||||
},
|
||||
after: {
|
||||
name: medication.name,
|
||||
dose: medication.dose,
|
||||
active: medication.active,
|
||||
asNeeded: medication.asNeeded,
|
||||
endsOn: medication.endsOn,
|
||||
},
|
||||
}),
|
||||
...(replacedScheduleCount !== null
|
||||
? {
|
||||
replacedCadence,
|
||||
replacedScheduleCount,
|
||||
tombstonedSlotCount,
|
||||
tombstonedFrom: tombstonedFrom?.toISOString() ?? null,
|
||||
tombstonedTo: tombstonedTo?.toISOString() ?? null,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -25,9 +25,13 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
destroyedDetails,
|
||||
overwriteDetails,
|
||||
} from "@/lib/sharing/audit-details";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -37,7 +41,10 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { scheduleRevisionUpdateSchema } from "@/lib/validations/schedule-revision";
|
||||
import { toRevisionPayloadEntry } from "@/lib/medications/scheduling/schedule-eras";
|
||||
import {
|
||||
toRevisionPayloadEntry,
|
||||
type ScheduleRevisionEntry,
|
||||
} from "@/lib/medications/scheduling/schedule-eras";
|
||||
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
@@ -46,7 +53,9 @@ type RouteParams = { params: Promise<{ id: string; revisionId: string }> };
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Correcting an era the compliance engine reads back
|
||||
// over months of somebody's history.
|
||||
const { user } = await requireRecordAuth("manage", "medications");
|
||||
const { id, revisionId } = await params;
|
||||
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
@@ -116,6 +125,8 @@ export const PATCH = apiHandler(
|
||||
source: true,
|
||||
supersededByRevisionId: true,
|
||||
validUntil: true,
|
||||
// v1.37.0 — the pre-image the audit row carries (C4).
|
||||
validFrom: true,
|
||||
},
|
||||
});
|
||||
if (!target || target.medicationId !== id) {
|
||||
@@ -182,6 +193,10 @@ export const PATCH = apiHandler(
|
||||
source: true,
|
||||
} as const;
|
||||
|
||||
const previousBounds = {
|
||||
validFrom: target.validFrom,
|
||||
validUntil: target.validUntil,
|
||||
};
|
||||
if (target.source === "MANUAL") {
|
||||
const revision = await tx.medicationScheduleRevision.update({
|
||||
where: { id: revisionId },
|
||||
@@ -192,7 +207,12 @@ export const PATCH = apiHandler(
|
||||
},
|
||||
select: revisionSelect,
|
||||
});
|
||||
return { ok: true as const, revision, mode: "in_place" as const };
|
||||
return {
|
||||
ok: true as const,
|
||||
revision,
|
||||
mode: "in_place" as const,
|
||||
previousBounds,
|
||||
};
|
||||
}
|
||||
|
||||
// ARCHIVED — immutable. Mint the correction as a MANUAL row and
|
||||
@@ -211,7 +231,12 @@ export const PATCH = apiHandler(
|
||||
where: { id: revisionId },
|
||||
data: { supersededByRevisionId: revision.id },
|
||||
});
|
||||
return { ok: true as const, revision, mode: "supersede" as const };
|
||||
return {
|
||||
ok: true as const,
|
||||
revision,
|
||||
mode: "supersede" as const,
|
||||
previousBounds,
|
||||
};
|
||||
});
|
||||
|
||||
if (!outcome.ok) {
|
||||
@@ -221,6 +246,9 @@ export const PATCH = apiHandler(
|
||||
await auditLog("medication.schedule_revision.updated", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
// C4 — the era boundaries the correction replaced. An era is a window
|
||||
// the compliance engine reads months of history through; moving it
|
||||
// silently re-scores days nobody looked at again.
|
||||
details: {
|
||||
medicationId: id,
|
||||
revisionId,
|
||||
@@ -228,6 +256,16 @@ export const PATCH = apiHandler(
|
||||
...(outcome.mode === "supersede" && {
|
||||
correctionRevisionId: outcome.revision.id,
|
||||
}),
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
validFrom: outcome.previousBounds.validFrom,
|
||||
validUntil: outcome.previousBounds.validUntil,
|
||||
},
|
||||
after: {
|
||||
validFrom: outcome.revision.validFrom,
|
||||
validUntil: outcome.revision.validUntil,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -273,9 +311,32 @@ export const PATCH = apiHandler(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* v1.37.0 — a one-line rendering of an era's dosing plan, for the audit row
|
||||
* a hard delete leaves behind (C3). Defensive by construction: a malformed
|
||||
* payload degrades to an empty string rather than throwing on a delete path.
|
||||
*/
|
||||
function summarisePayloadForAudit(payload: unknown): string {
|
||||
if (!Array.isArray(payload)) return "";
|
||||
return payload
|
||||
.map((raw) => {
|
||||
const entry = (raw ?? {}) as Partial<ScheduleRevisionEntry>;
|
||||
const times = Array.isArray(entry.timesOfDay)
|
||||
? entry.timesOfDay.filter((t): t is string => typeof t === "string")
|
||||
: [];
|
||||
const dose = typeof entry.dose === "string" ? entry.dose : null;
|
||||
return [times.join(","), dose].filter(Boolean).join(" ");
|
||||
})
|
||||
.filter((part) => part !== "")
|
||||
.join(" | ");
|
||||
}
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE, and a hard delete: the era row goes and the
|
||||
// supersede chain is repaired behind it. C3 below is what makes the era
|
||||
// readable back out of the feed.
|
||||
const { user } = await requireRecordAuth("manage", "medications");
|
||||
const { id, revisionId } = await params;
|
||||
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
@@ -283,7 +344,16 @@ export const DELETE = apiHandler(
|
||||
|
||||
const revision = await prisma.medicationScheduleRevision.findUnique({
|
||||
where: { id: revisionId },
|
||||
select: { id: true, medicationId: true, source: true },
|
||||
// v1.37.0 — the era's own dates and payload summary go into the audit
|
||||
// row (C3); the row itself does not survive the delete.
|
||||
select: {
|
||||
id: true,
|
||||
medicationId: true,
|
||||
source: true,
|
||||
validFrom: true,
|
||||
validUntil: true,
|
||||
payload: true,
|
||||
},
|
||||
});
|
||||
if (!revision || revision.medicationId !== id) {
|
||||
return apiError("Schedule revision not found", 404);
|
||||
@@ -309,7 +379,20 @@ export const DELETE = apiHandler(
|
||||
await auditLog("medication.schedule_revision.deleted", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { medicationId: id, revisionId },
|
||||
// C3 — the era's dates and its dosing summary. Without them the feed
|
||||
// says an era was deleted and the compliance history it explained
|
||||
// becomes unexplainable.
|
||||
details: {
|
||||
medicationId: id,
|
||||
revisionId,
|
||||
...destroyedDetails({
|
||||
model: "MedicationScheduleRevision",
|
||||
id: revisionId,
|
||||
label: summarisePayloadForAudit(revision.payload),
|
||||
effectiveAt: revision.validFrom,
|
||||
extra: { validUntil: revision.validUntil },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -16,14 +16,15 @@
|
||||
* min/max of the times) so the era minter — ledger, compliance,
|
||||
* cadence chips — reads it like any other revision.
|
||||
*
|
||||
* Auth: cookie-session or Bearer via requireAuth(); the medication is
|
||||
* Auth: cookie-session or Bearer, resolved through `requireRecordAuth`; the
|
||||
* medication is
|
||||
* verified to belong to the caller before any read or write. Deleting
|
||||
* a manual era lives in the `[revisionId]` sub-route.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -135,7 +136,9 @@ export const GET = apiHandler(
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Archiving a schedule era the compliance engine reads:
|
||||
// additive, audited, and reversible through the DELETE beside it.
|
||||
const { user } = await requireRecordAuth("manage", "medications");
|
||||
const { id } = await params;
|
||||
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
@@ -38,7 +38,10 @@ async function loadOwnedRow(
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. The one hard delete in the tree whose audit details
|
||||
// already reconstruct the row: entry, severity and the date it happened
|
||||
// are on the row this handler writes.
|
||||
const { user } = await requireRecordAuth("manage", "medications");
|
||||
const { id, logId } = await params;
|
||||
|
||||
const existing = await loadOwnedRow(id, logId, user.id);
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -97,7 +97,11 @@ function describePlan(plan: AutoExportPlan) {
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. The whole-regimen form of the per-medication import:
|
||||
// same worker, same additive-with-skip-duplicates properties, same honest
|
||||
// `IMPORT` provenance. C9: the kickoff audit row below joins the job by id,
|
||||
// so work that outlives the grant is still attributable.
|
||||
const { user, actor } = await requireRecordAuth("manage", "medications");
|
||||
const dryRunParam = new URL(request.url).searchParams.get("dryRun");
|
||||
const dryRun = dryRunParam === "1" || dryRunParam === "true";
|
||||
annotate({
|
||||
@@ -105,8 +109,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
meta: { dry_run: dryRun },
|
||||
});
|
||||
|
||||
// v1.37.0 — C1: keyed on the ACTOR. The bucket is shared with the other
|
||||
// import surfaces, so keying it on the record would let a manager consume
|
||||
// the owner's hourly import budget.
|
||||
const rl = await checkRateLimit(
|
||||
`import:${user.id}`,
|
||||
`import:${actor.id}`,
|
||||
RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -151,7 +151,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAssessment));
|
||||
|
||||
async function postAssessment(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. A screener administered by the manager and transcribed
|
||||
// into the record: the ordinary way this instrument is used when the person
|
||||
// it is about cannot run their own record. The row records what the person
|
||||
// in the room reported; the audit row says who typed it.
|
||||
const { user, actor } = await requireRecordAuth("manage", "mind");
|
||||
|
||||
// Opt-in module (default OFF) — same gate as GET.
|
||||
const gate = await requireModuleEnabled(user.id, "mentalHealth");
|
||||
@@ -160,8 +164,11 @@ async function postAssessment(request: NextRequest): Promise<Response> {
|
||||
// Write rate-limit: a screener is a deliberate, infrequent action, so the
|
||||
// bucket is tight — it only caps a double-tap / scripted loop minting
|
||||
// duplicate trend points (the recall window is 2 weeks; 30/hour is generous).
|
||||
// v1.37.0 — C1: the bucket keys on the ACTOR, so a manager burns their own
|
||||
// allowance rather than the owner's and cannot collect a fresh one by
|
||||
// switching records.
|
||||
const rl = await checkRateLimit(
|
||||
`mental-health-create:${user.id}`,
|
||||
`mental-health-create:${actor.id}`,
|
||||
30,
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
@@ -245,7 +252,14 @@ async function postAssessment(request: NextRequest): Promise<Response> {
|
||||
const total = scoreTotal(id, items);
|
||||
const band = severityBand(id, total);
|
||||
const flagged = isSafetyFlagged(id, items);
|
||||
const presentedLocale = locale ?? user.locale ?? "en";
|
||||
// C2 — the locale of the person holding the phone, not of the record.
|
||||
// `presentedLocale` is used twice: it is stored on the row as what was
|
||||
// shown, and it selects the crisis-resource copy returned on a positive
|
||||
// item 9. Under a switch the reader is the MANAGER, and crisis resources in
|
||||
// a language they cannot read are a safety failure, not a presentation bug.
|
||||
// `actor` is the caller in every case and equals `user` for everyone acting
|
||||
// on their own record, so this is the record's own locale for them.
|
||||
const presentedLocale = locale ?? actor.locale ?? "en";
|
||||
const when = takenAt ? new Date(takenAt) : new Date();
|
||||
|
||||
// Encrypt the per-item answers + the (unscored) functional-impairment follow-up.
|
||||
|
||||
@@ -60,6 +60,7 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import {
|
||||
RatedFactorOutOfRangeError,
|
||||
@@ -105,7 +106,9 @@ const ROUTE_CTX = { params: Promise.resolve({ id: "me1" }) };
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
// v1.37.0 — the breadcrumb goes through `auditLog()` now; `resetAllMocks`
|
||||
// clears the factory's resolved value and the route chains `.catch()`.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
vi.mocked(prisma.$transaction).mockImplementation(async (callback) =>
|
||||
callback(txClient as never),
|
||||
);
|
||||
@@ -170,17 +173,17 @@ describe("PUT /api/mood-entries/[id] — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
);
|
||||
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(auditLog).toHaveBeenCalledTimes(1);
|
||||
const [auditAction, auditOpts] = vi.mocked(auditLog).mock.calls[0] as [
|
||||
string,
|
||||
{ userId: string },
|
||||
];
|
||||
const call = { data: { userId: auditOpts.userId, action: auditAction } };
|
||||
expect(call.data.action).toBe("mood-entries.update.validation-failed");
|
||||
});
|
||||
|
||||
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 PUT(
|
||||
putReq({ mood: "junk", moodLoggedAt: "not-iso" }),
|
||||
ROUTE_CTX,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
import { updateMoodEntrySchema, getScoreForMood } from "@/lib/validations/mood";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, 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";
|
||||
@@ -62,7 +63,8 @@ export const GET = apiHandler(
|
||||
|
||||
export const PUT = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Correcting an entry in a record somebody manages.
|
||||
const { user } = await requireRecordAuth("manage", "mind");
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
@@ -97,17 +99,16 @@ export const PUT = apiHandler(
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "mood-entries.update.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues, moodEntryId: id }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
|
||||
// .create`, because that helper is the only writer that stamps
|
||||
// `actorUserId`. Filed under the resolved record either way; without the
|
||||
// stamp a manager's malformed payload would read as the owner's own.
|
||||
void auditLog("mood-entries.update.validation-failed", {
|
||||
userId: user.id,
|
||||
details: { issues: auditIssues, moodEntryId: id },
|
||||
}).catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "mood.update.invalid",
|
||||
});
|
||||
@@ -227,7 +228,29 @@ export const PUT = apiHandler(
|
||||
await auditLog("moodEntry.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { moodEntryId: id },
|
||||
// C4 — the replaced fields. The note and the free-text tags are named
|
||||
// and never quoted; the note is encrypted at rest.
|
||||
details: {
|
||||
moodEntryId: id,
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
mood: existing.mood,
|
||||
score: existing.score,
|
||||
moodLoggedAt: existing.moodLoggedAt,
|
||||
},
|
||||
after: {
|
||||
mood: entry.mood,
|
||||
score: entry.score,
|
||||
moodLoggedAt: entry.moodLoggedAt,
|
||||
},
|
||||
redacted: [
|
||||
...(data.note !== undefined ? ["note"] : []),
|
||||
...(data.tags !== undefined ? ["tags"] : []),
|
||||
...(data.tagKeys !== undefined ? ["tagKeys"] : []),
|
||||
...(data.ratedFactors !== undefined ? ["ratedFactors"] : []),
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
@@ -280,7 +303,8 @@ export const PUT = apiHandler(
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Soft delete with `mood-entries/restore` beside it.
|
||||
const { user } = await requireRecordAuth("manage", "mind");
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
|
||||
@@ -239,17 +239,17 @@ describe("POST /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
const res = await POST(postReq({ mood: "junk" }));
|
||||
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.create.validation-failed");
|
||||
// v1.37.0 — the breadcrumb goes through `auditLog()`, the only writer
|
||||
// that stamps `actorUserId`.
|
||||
const breadcrumbs = vi
|
||||
.mocked(auditLog)
|
||||
.mock.calls.filter(([name]) => name.endsWith("validation-failed"));
|
||||
expect(breadcrumbs).toHaveLength(1);
|
||||
expect(breadcrumbs[0]?.[0]).toBe("mood-entries.create.validation-failed");
|
||||
});
|
||||
|
||||
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 POST(postReq({ mood: "junk" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -43,10 +43,16 @@ const bulkDeleteSchema = z.object({
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulkDelete));
|
||||
|
||||
async function postBulkDelete(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Tombstoning a selection; every row stays and `mood-entries/restore`
|
||||
// puts it back.
|
||||
const { user, actor } = await requireRecordAuth("manage", "mind");
|
||||
|
||||
// v1.37.0 — C1: the bucket keys on the ACTOR, the frozen precedent from
|
||||
// `medications/compliance`. A manager burns their own allowance rather than
|
||||
// locking the owner out of their own record, and cannot collect a fresh one
|
||||
// by switching records.
|
||||
const rl = await checkRateLimit(
|
||||
`mood-entries:bulk-delete:${user.id}`,
|
||||
`mood-entries:bulk-delete:${actor.id}`,
|
||||
RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -45,10 +45,16 @@ const restoreSchema = z.object({
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postRestore));
|
||||
|
||||
async function postRestore(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. The undo beside a delete the level admits; the `where` only ever
|
||||
// clears a tombstone the record already carried.
|
||||
const { user, actor } = await requireRecordAuth("manage", "mind");
|
||||
|
||||
// v1.37.0 — C1: the bucket keys on the ACTOR, the frozen precedent from
|
||||
// `medications/compliance`. A manager burns their own allowance rather than
|
||||
// locking the owner out of their own record, and cannot collect a fresh one
|
||||
// by switching records.
|
||||
const rl = await checkRateLimit(
|
||||
`mood-entries:restore:${user.id}`,
|
||||
`mood-entries:restore:${actor.id}`,
|
||||
RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "@/lib/validations/external-id";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
@@ -151,7 +151,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postMoodEntry));
|
||||
|
||||
async function postMoodEntry(request: NextRequest) {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. A mood row entered by a manager is an observation
|
||||
// transcribed, and the audit trail says so for the retention window. What
|
||||
// the delegated arm does not get is the sync client's upsert handle; see
|
||||
// the `externalId` refusal below.
|
||||
const { user, actor } = await requireRecordAuth("manage", "mind");
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
@@ -177,17 +181,16 @@ async function postMoodEntry(request: NextRequest) {
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "mood-entries.create.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
|
||||
// .create`, because that helper is the only writer that stamps
|
||||
// `actorUserId`. Filed under the resolved record either way; without the
|
||||
// stamp a manager's malformed payload would read as the owner's own.
|
||||
void auditLog("mood-entries.create.validation-failed", {
|
||||
userId: user.id,
|
||||
details: { issues: auditIssues },
|
||||
}).catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "mood.create.invalid",
|
||||
});
|
||||
@@ -215,6 +218,23 @@ async function postMoodEntry(request: NextRequest) {
|
||||
// and the row write agree on the exact value.
|
||||
const resolvedSource = source ?? "MANUAL";
|
||||
|
||||
// C6 — the delegated arm refuses `externalId`. It is the sync client's dedup
|
||||
// handle: supplying it turns this create into an upsert on
|
||||
// `(userId, source, externalId)`, which is a device's contract with its own
|
||||
// rows and not something a second person should be able to aim at somebody
|
||||
// else's record. A person entering a mood through the UI never sends one.
|
||||
if (actor.id !== user.id && externalId) {
|
||||
annotate({
|
||||
action: { name: "mood-entries.create.external-id-refused" },
|
||||
meta: { delegated: true },
|
||||
});
|
||||
return apiError(
|
||||
"externalId cannot be supplied on a record you manage",
|
||||
422,
|
||||
{ errorCode: "mood.create.external_id_not_delegable" },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// v1.8.5 — write the entry and its structured-tag links in one
|
||||
// transaction. The links are user-intended content, not a cache, so a
|
||||
|
||||
@@ -12,6 +12,9 @@ import { NextRequest } from "next/server";
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
nutrientIntakeDay: {
|
||||
// v1.37.0 — the route reads the day total before the upsert so the
|
||||
// audit row can name what a `set` replaced (C4).
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
@@ -101,6 +104,11 @@ beforeEach(() => {
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
// No stored row unless a test says otherwise; the pre-image read is
|
||||
// best-effort and a missing day is the ordinary first write.
|
||||
vi.mocked(prisma.nutrientIntakeDay.findUnique).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.nutrientIntakeDay.upsert).mockResolvedValue({
|
||||
day: "2026-07-16",
|
||||
nutrient: "water",
|
||||
|
||||
@@ -17,9 +17,10 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -53,13 +54,18 @@ function isRealCalendarDay(day: string): boolean {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postWater));
|
||||
|
||||
async function postWater(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// v1.37.0 — MANAGE. Hydration is a day TOTAL with no per-entry ledger, so a
|
||||
// `set` overwrites the day with nothing behind it; C4 on the audit row is
|
||||
// what makes the previous total readable back.
|
||||
const { user, actor } = await requireRecordAuth("manage", "measurements");
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "nutrients");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
// v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own allowance
|
||||
// and cannot collect a fresh one by switching records.
|
||||
const rl = await checkRateLimit(
|
||||
`nutrients:water:${user.id}`,
|
||||
`nutrients:water:${actor.id}`,
|
||||
WRITE_RATE_LIMIT_MAX,
|
||||
WRITE_RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
@@ -103,6 +109,17 @@ async function postWater(request: NextRequest): Promise<Response> {
|
||||
},
|
||||
};
|
||||
|
||||
// v1.37.0 — the day total as this request found it, for the audit row (C4).
|
||||
// Read rather than derived: the upsert below is atomic and deliberately does
|
||||
// not read first, so this is a separate, best-effort pre-image. A concurrent
|
||||
// write can move the true total between the two statements; what the row
|
||||
// says is what this caller replaced as far as this request could see, which
|
||||
// is the honest claim and the one the feed needs.
|
||||
const before = await prisma.nutrientIntakeDay.findUnique({
|
||||
where: key,
|
||||
select: { amount: true },
|
||||
});
|
||||
|
||||
let row = await prisma.nutrientIntakeDay.upsert({
|
||||
where: key,
|
||||
create: {
|
||||
@@ -145,7 +162,18 @@ async function postWater(request: NextRequest): Promise<Response> {
|
||||
await auditLog("nutrient.water.write", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { day, mode, amountMl, resultingAmount: row.amount },
|
||||
// C4 — the day total that was there before. A `set` replaces it outright
|
||||
// and no per-entry ledger exists to reconstruct it from.
|
||||
details: {
|
||||
day,
|
||||
mode,
|
||||
amountMl,
|
||||
resultingAmount: row.amount,
|
||||
...overwriteDetails({
|
||||
before: { dayTotal: before?.amount ?? null },
|
||||
after: { dayTotal: row.amount },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -360,7 +360,7 @@ describe("linking a document to an illness episode", () => {
|
||||
// intent and false of the route: the card's list query hit
|
||||
// `GET /api/documents/inbound`, which resolved the CALLER and 403'd, so
|
||||
// the card that "stays" rendered a query-error tile. That GET is
|
||||
// delegable; `delegable-surface-guard.test.ts` freezes it and
|
||||
// delegable; `sharing-surface-guard.test.ts` freezes it and
|
||||
// `sharing-delegable-routes.test.ts` drives it.
|
||||
expect(html).toContain('data-slot="episode-documents-card"');
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
* no reachable caller. Logging a value on a tracked metric left for the same
|
||||
* reason one release later: its only form sits on `/custom-metrics/{id}`,
|
||||
* which a switch closes too.
|
||||
* `delegable-surface-guard.test.ts` carries that argument
|
||||
* `sharing-surface-guard.test.ts` carries that argument
|
||||
* in full and is the list that binds; this paragraph has to agree with it. Editing, deleting, restoring, purging, importing and
|
||||
* bulk-acting stay with the owner, including on an entry the delegate added
|
||||
* themselves a minute ago, and so does every create the list does not name
|
||||
|
||||
+11
-1
@@ -780,7 +780,7 @@ export interface RecordAuthContext extends AuthContext {
|
||||
* the fail-closed lever: a delegable route without a classification does not
|
||||
* typecheck, so the set of routes carrying one cannot fall behind the set of
|
||||
* routes that are delegable. The value is frozen per module by
|
||||
* `src/__tests__/delegable-surface-guard.test.ts` and reviewed against the
|
||||
* `src/__tests__/sharing-surface-guard.test.ts` and reviewed against the
|
||||
* design's clustering table; this function only enforces what was declared.
|
||||
*/
|
||||
export async function requireRecordAuth(
|
||||
@@ -934,6 +934,16 @@ async function resolveSwitchedRecord(
|
||||
if (refusal !== null) throw denied(refusal);
|
||||
|
||||
getEvent()?.setActingAs(owner.id);
|
||||
// v1.37.0 — and whether this request may spend the owner's provider budget.
|
||||
// MANAGE opens the generated reads; it does not open the generation behind
|
||||
// them, because the owner would pay for and consent to an egress they did
|
||||
// not cause. A managed profile is the exception and the reason the marker is
|
||||
// read here rather than the grant: it has no self to protect from its own
|
||||
// guardian. Stamped where the owner row is already loaded, so no generator
|
||||
// has to be told; see `src/lib/sharing/delegated-generation.ts`.
|
||||
if (owner.id !== auth.user.id && owner.managedProfileAt === null) {
|
||||
getEvent()?.setDelegatedGenerationSuppressed();
|
||||
}
|
||||
// Fire-and-forget, the `ApiToken.lastUsedAt` posture: the read must not wait
|
||||
// on the bookkeeping, and the bookkeeping failing must not fail the read.
|
||||
void touchGrantUsage(grant.id);
|
||||
|
||||
@@ -26,6 +26,21 @@ export interface IllnessDayLogWriteResult {
|
||||
existed: boolean;
|
||||
/** The full DTO of the written row — no second round-trip needed. */
|
||||
dto: IllnessDayLogDTO;
|
||||
/**
|
||||
* v1.37.0 — what this write destroyed, for the caller's audit row.
|
||||
*
|
||||
* The upsert is a rewrite wearing a create's verb: its UPDATE branch forces
|
||||
* `deletedAt: null`, so it revives a day the owner deliberately deleted, and
|
||||
* the symptom write is a full replacement of that day's links. Under MANAGE
|
||||
* somebody other than the owner can do both, so the two facts that would
|
||||
* otherwise vanish silently travel back with the result.
|
||||
*/
|
||||
replaced: {
|
||||
/** Symptom keys the day carried before this write, sorted. */
|
||||
previousSymptomKeys: string[];
|
||||
/** True when the write brought a tombstoned day back. */
|
||||
revived: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/** A symptom selection carrying its catalog key + optional 0–3 severity. */
|
||||
@@ -105,7 +120,7 @@ export async function upsertIllnessDayLog(
|
||||
// run in ONE interactive transaction: no torn state between the link delete
|
||||
// + create halves, and no second round-trip — the written row (with its
|
||||
// flattened symptom links) comes straight back as the DTO.
|
||||
const { row, existed } = await prisma.$transaction(async (tx) => {
|
||||
const { row, existed, replaced } = await prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.illnessDayLog.findUnique({
|
||||
where,
|
||||
select: {
|
||||
@@ -113,6 +128,11 @@ export async function upsertIllnessDayLog(
|
||||
functionalImpact: true,
|
||||
feverC: true,
|
||||
noteEncrypted: true,
|
||||
// v1.37.0 — the two facts the caller's audit row needs. Read inside
|
||||
// the same transaction as the write, so what is reported is what was
|
||||
// actually replaced rather than what a second read happened to see.
|
||||
deletedAt: true,
|
||||
symptomLinks: { select: { symptom: { select: { key: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -171,8 +191,17 @@ export async function upsertIllnessDayLog(
|
||||
include: dayLogSymptomInclude,
|
||||
});
|
||||
|
||||
return { row: full, existed: existing !== null };
|
||||
return {
|
||||
row: full,
|
||||
existed: existing !== null,
|
||||
replaced: {
|
||||
previousSymptomKeys: (existing?.symptomLinks ?? [])
|
||||
.map((link) => link.symptom.key)
|
||||
.sort(),
|
||||
revived: existing?.deletedAt != null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { id: row.id, existed, dto: toIllnessDayLogDTO(row) };
|
||||
return { id: row.id, existed, dto: toIllnessDayLogDTO(row), replaced };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* v1.37.0 — C5 at the choke point: a delegated read enqueues no generation.
|
||||
*
|
||||
* The MANAGE level opens the ten generated reads. Nine of them reach the
|
||||
* provider through `resolveReadOnlyStatusMiss`, one hop behind a GET that
|
||||
* looks like a cache read, so a manager's first visit to a metric page used to
|
||||
* ship the owner's health data to the owner's provider on the owner's budget
|
||||
* under the owner's consent receipt — with nothing in the response saying so.
|
||||
*
|
||||
* The suppression is implemented once, here, rather than in the ten generators
|
||||
* that call this function, so this file is where it has to be pinned. Both
|
||||
* directions, because either one alone is worthless: without the positive
|
||||
* control a broken function that never enqueues anything passes, and without
|
||||
* the negative one the condition is not tested at all.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const enqueueStatusGeneration = vi.fn(async () => {});
|
||||
const hasUsableStatusProvider = vi.fn(async () => true);
|
||||
const statusConsentBlocksGeneration = vi.fn(async () => false);
|
||||
const suppressed = vi.fn(() => false);
|
||||
|
||||
vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({
|
||||
enqueueStatusGeneration: (...args: unknown[]) =>
|
||||
enqueueStatusGeneration(...(args as [])),
|
||||
STATUS_GENERATE_QUEUE: "insight-status-generate",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/status-provider", () => ({
|
||||
hasUsableStatusProvider: (...args: unknown[]) =>
|
||||
hasUsableStatusProvider(...(args as [])),
|
||||
statusConsentBlocksGeneration: (...args: unknown[]) =>
|
||||
statusConsentBlocksGeneration(...(args as [])),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/sharing/delegated-generation", () => ({
|
||||
delegatedGenerationSuppressed: () => suppressed(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
// The last-good text and the negative-cache probe both read the audit
|
||||
// ledger; an empty ledger is the cold-cache case this function is for.
|
||||
auditLog: {
|
||||
findMany: vi.fn(async () => []),
|
||||
findFirst: vi.fn(async () => null),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { resolveReadOnlyStatusMiss } from "@/lib/insights/status-cache";
|
||||
|
||||
beforeEach(() => {
|
||||
// Every counter, not only the enqueue: the ordering assertion below reads
|
||||
// the provider probe's call count, and a counter carried over from the
|
||||
// previous test would make it fail for the wrong reason (or pass for one).
|
||||
enqueueStatusGeneration.mockClear();
|
||||
hasUsableStatusProvider.mockClear();
|
||||
statusConsentBlocksGeneration.mockClear();
|
||||
suppressed.mockReturnValue(false);
|
||||
hasUsableStatusProvider.mockResolvedValue(true);
|
||||
statusConsentBlocksGeneration.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
const ARGS = {
|
||||
userId: "owner-1",
|
||||
metric: "WEIGHT" as never,
|
||||
locale: "en" as never,
|
||||
};
|
||||
|
||||
describe("resolveReadOnlyStatusMiss — the delegated path", () => {
|
||||
it("enqueues a generation for the person whose record it is", async () => {
|
||||
// The positive control. If this stops holding, the assertion below stops
|
||||
// meaning anything: "nothing was enqueued" would be true of a function
|
||||
// that never enqueues.
|
||||
const outcome = await resolveReadOnlyStatusMiss(ARGS);
|
||||
|
||||
expect(enqueueStatusGeneration).toHaveBeenCalledTimes(1);
|
||||
expect(outcome.kind).toBe("preparing");
|
||||
});
|
||||
|
||||
it("enqueues nothing while somebody else is holding the request", async () => {
|
||||
suppressed.mockReturnValue(true);
|
||||
|
||||
const outcome = await resolveReadOnlyStatusMiss(ARGS);
|
||||
|
||||
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
|
||||
// Still served, not refused: the read is admitted at MANAGE and only the
|
||||
// generation behind it is withheld.
|
||||
expect(outcome.kind).toBe("preparing");
|
||||
if (outcome.kind === "preparing") {
|
||||
expect(outcome.revalidating).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("decides before it asks whether a provider exists", async () => {
|
||||
// Ordering matters for the money question, not only for the enqueue: the
|
||||
// provider probe reads the owner's configuration, and a suppressed
|
||||
// request has no business touching it.
|
||||
suppressed.mockReturnValue(true);
|
||||
|
||||
await resolveReadOnlyStatusMiss(ARGS);
|
||||
|
||||
expect(hasUsableStatusProvider).not.toHaveBeenCalled();
|
||||
expect(statusConsentBlocksGeneration).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/lib/insights/correlation-discovery";
|
||||
import { MAX_CUSTOM_CORRELATION_CHANNELS } from "@/lib/insights/correlation-channel-series";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { delegatedGenerationSuppressed } from "@/lib/sharing/delegated-generation";
|
||||
import type { SupportedLocale } from "@/lib/insights/status-shared";
|
||||
|
||||
/**
|
||||
@@ -559,6 +560,30 @@ export async function resolveReadOnlyStatusMiss(args: {
|
||||
metric: InsightStatusScope;
|
||||
locale: SupportedLocale;
|
||||
}): Promise<ReadOnlyMissOutcome> {
|
||||
// v1.37.0 — the one gate on the delegated path, and it sits here rather
|
||||
// than in the ten generators that reach this function precisely because it
|
||||
// is one gate: a generator added next year inherits it without knowing it
|
||||
// exists. A manager holding a MANAGE grant may READ the record's generated
|
||||
// assessments; the miss behind that read must not ship the owner's data to
|
||||
// the owner's provider on the owner's budget. Served the same way the
|
||||
// negative-cache branch below serves it: last-good text if there is any,
|
||||
// `preparing` if there is not, and nothing enqueued either way.
|
||||
if (delegatedGenerationSuppressed()) {
|
||||
const lastGood = await readLastGoodStatusText({
|
||||
userId: args.userId,
|
||||
cacheAction: statusCacheAction(args.metric, args.locale),
|
||||
});
|
||||
annotate({
|
||||
action: { name: "insights.status.preparing" },
|
||||
meta: {
|
||||
metric: args.metric,
|
||||
suppressed_enqueue: true,
|
||||
delegated: true,
|
||||
},
|
||||
});
|
||||
return { kind: "preparing", lastGood, revalidating: false };
|
||||
}
|
||||
|
||||
const hasProvider = await hasUsableStatusProvider(args.userId);
|
||||
if (!hasProvider) return { kind: "no-provider" };
|
||||
|
||||
|
||||
@@ -102,6 +102,21 @@ export class WideEventBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.37.0 — record that provider generation is closed on this request.
|
||||
*
|
||||
* Merges for the same reason `setActingAs` does, and is stamped in the same
|
||||
* place: the record resolver, which is where the owner row is in hand. It is
|
||||
* read back through `delegatedGenerationSuppressed()`.
|
||||
*/
|
||||
setDelegatedGenerationSuppressed(): this {
|
||||
this.event.auth = {
|
||||
...(this.event.auth ?? {}),
|
||||
delegated_generation: "suppressed",
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
setAction(action: WideEvent["action"]): this {
|
||||
this.event.action = action;
|
||||
return this;
|
||||
|
||||
@@ -66,6 +66,16 @@ export interface WideEvent {
|
||||
* dashboard that conflates them cannot answer either.
|
||||
*/
|
||||
acting_as?: string;
|
||||
/**
|
||||
* v1.37.0 — set when this request is acting on somebody else's record and
|
||||
* that somebody could have asked for the generation themselves. It is the
|
||||
* one fact the generated reads consult before enqueuing a provider job:
|
||||
* a manager's navigation must not spend the owner's budget or ship the
|
||||
* owner's record to the owner's provider. Absent means "generate as
|
||||
* usual", which is what every self-request, every guardian request on a
|
||||
* managed profile and every background job is.
|
||||
*/
|
||||
delegated_generation?: "suppressed";
|
||||
};
|
||||
|
||||
// Business-Daten (was wurde getan)
|
||||
|
||||
@@ -59,8 +59,15 @@ export interface ApplyCanonicalSlotWriteInput {
|
||||
* the worker-minted pending row like every other write, and a fresh
|
||||
* create keeps the historical reminder provenance. `APPLE_HEALTH`
|
||||
* (v1.28) is the bulk route's HealthKit dose-event import.
|
||||
*
|
||||
* v1.37.0 — `IMPORT` joined the union because the per-event edit route
|
||||
* re-creates a moved dose and must carry the ORIGINAL row's provenance onto
|
||||
* the converged row (C8). A dose that came in through a history import and
|
||||
* had its time corrected is still an imported dose; stamping it `WEB`
|
||||
* because a human moved it is a small lie the compliance history then
|
||||
* repeats forever.
|
||||
*/
|
||||
createSource: "WEB" | "API" | "REMINDER" | "APPLE_HEALTH";
|
||||
createSource: "WEB" | "API" | "REMINDER" | "APPLE_HEALTH" | "IMPORT";
|
||||
/**
|
||||
* v1.36.x — refuse a write that would change an outcome the owner has
|
||||
* already recorded, instead of applying it last-write-wins.
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* v1.37.0 — the two audit-detail shapes MANAGE is admitted on.
|
||||
*
|
||||
* The rule the level ships under is "reconstructable or refused": a manager
|
||||
* may change and remove things in somebody else's record, and the owner must
|
||||
* be able to read out of their own activity feed what went missing. The tree
|
||||
* did not carry that. Every edit filed an id and nothing else — `measurement.
|
||||
* update` said `{ measurementId }`, `medication.update` said `{ medicationId }`
|
||||
* — so the feed could say a reading changed and could not say from what, and
|
||||
* the hard deletes filed the id of a row that no longer exists.
|
||||
*
|
||||
* These two helpers are what the admitted verbs use instead. They are small on
|
||||
* purpose: the audit table is not a second copy of the record and must not
|
||||
* become one. Three columns, not thirty, and never a decrypted note.
|
||||
*
|
||||
* Both return a plain object meant to be SPREAD into a `details` literal at
|
||||
* the call site rather than to replace it. The literal keeps naming the row's
|
||||
* own id in the reviewer's line of sight, which is what the structural guard
|
||||
* reads, and the spread carries the part that is tedious to write by hand and
|
||||
* easy to get wrong.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What may enter an audit row.
|
||||
*
|
||||
* A closed union rather than `unknown`, because the failure it prevents is the
|
||||
* one that costs the most: `details: { previous: existing }` is one keystroke
|
||||
* from `details: { previous: { …, notesEncrypted } }`, and an encrypted note
|
||||
* copied into the audit table is PHI with a second retention policy and no
|
||||
* rotation story. A caller with a value outside this union has to decide what
|
||||
* the feed should say about it, which is the decision this type exists to
|
||||
* force.
|
||||
*/
|
||||
export type AuditScalar = string | number | boolean | Date | null | undefined;
|
||||
|
||||
/** The longest string that lands verbatim; anything longer is trimmed. */
|
||||
const MAX_VALUE_LENGTH = 120;
|
||||
|
||||
function coerce(value: AuditScalar): string | number | boolean | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (typeof value === "string") {
|
||||
return value.length > MAX_VALUE_LENGTH
|
||||
? `${value.slice(0, MAX_VALUE_LENGTH)}…`
|
||||
: value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function same(a: AuditScalar, b: AuditScalar): boolean {
|
||||
const left = a instanceof Date ? a.getTime() : (a ?? null);
|
||||
const right = b instanceof Date ? b.getTime() : (b ?? null);
|
||||
return left === right;
|
||||
}
|
||||
|
||||
/**
|
||||
* **C4** — what an overwrite replaced.
|
||||
*
|
||||
* `fields` names the family the write touched, so the owner's feed can say
|
||||
* "changed the value and the time" rather than "changed a reading".
|
||||
* `previous` carries the values that are gone, for the fields where a handful
|
||||
* of scalars is the whole fact.
|
||||
*
|
||||
* @param before the pre-image, caller-picked field by field. Never the row
|
||||
* object: picking is the review step, and a spread of the row is how an
|
||||
* encrypted column gets into an audit table.
|
||||
* @param after the post-image over the same keys. Fields whose value did not
|
||||
* move are left out of both halves — an edit that renamed nothing should not
|
||||
* file a line saying it did.
|
||||
* @param redacted fields that changed and whose values must never be written:
|
||||
* free text, anything encrypted at rest. They appear in `fields` and never
|
||||
* in `previous`, which is the honest answer — the feed says the note was
|
||||
* rewritten and does not say what it said.
|
||||
*/
|
||||
export function overwriteDetails(args: {
|
||||
before: Record<string, AuditScalar>;
|
||||
after: Record<string, AuditScalar>;
|
||||
redacted?: readonly string[];
|
||||
}): {
|
||||
fields: string[];
|
||||
previous: Record<string, string | number | boolean | null>;
|
||||
} {
|
||||
const previous: Record<string, string | number | boolean | null> = {};
|
||||
const fields: string[] = [];
|
||||
|
||||
for (const key of Object.keys(args.before)) {
|
||||
if (same(args.before[key], args.after[key])) continue;
|
||||
fields.push(key);
|
||||
previous[key] = coerce(args.before[key]);
|
||||
}
|
||||
for (const key of args.redacted ?? []) {
|
||||
if (!fields.includes(key)) fields.push(key);
|
||||
}
|
||||
|
||||
return { fields: fields.sort(), previous };
|
||||
}
|
||||
|
||||
/**
|
||||
* **C3** — what a hard delete destroyed.
|
||||
*
|
||||
* For the verbs whose domain does not tombstone, where the row is gone the
|
||||
* moment the transaction commits and the audit row is the only thing left. It
|
||||
* names the model, the id, a human label and the date the thing was about,
|
||||
* which is the difference between "a preventive-care reminder was deleted" and
|
||||
* "the colonoscopy recall for next March was deleted".
|
||||
*
|
||||
* `extra` is for the one or two domain facts a label cannot carry — a
|
||||
* severity, a dose, a count. Scalars only, same reasoning as above.
|
||||
*/
|
||||
export function destroyedDetails(args: {
|
||||
model: string;
|
||||
id: string;
|
||||
label: AuditScalar;
|
||||
effectiveAt: AuditScalar;
|
||||
extra?: Record<string, AuditScalar>;
|
||||
}): Record<string, string | number | boolean | null> {
|
||||
const details: Record<string, string | number | boolean | null> = {
|
||||
destroyedModel: args.model,
|
||||
destroyedId: args.id,
|
||||
destroyedLabel: coerce(args.label),
|
||||
destroyedAt: coerce(args.effectiveAt),
|
||||
};
|
||||
for (const [key, value] of Object.entries(args.extra ?? {})) {
|
||||
details[key] = coerce(value);
|
||||
}
|
||||
return details;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* v1.37.0 — whether this request may spend the record owner's provider budget.
|
||||
*
|
||||
* MANAGE opens the generated surfaces: a manager opens a metric page and reads
|
||||
* the assessment that is already there. What it must not open is the hop
|
||||
* behind that read. Nine of the ten generated reads enqueue a generation job
|
||||
* on a cache miss (`resolveReadOnlyStatusMiss`) and the narrative enqueues one
|
||||
* whenever the row is stale, so on a delegated request the sequence is: the
|
||||
* manager navigates, the owner's health data leaves the box under the owner's
|
||||
* consent receipt, against the owner's provider, on the owner's budget, and
|
||||
* the owner sees a bill line for something they did not do. The manager sees a
|
||||
* card. Nothing on the wire says the two are the same event.
|
||||
*
|
||||
* So the read stays and the enqueue goes, on exactly one condition: somebody
|
||||
* else is holding the request and the record has an owner who could have
|
||||
* pressed the button themselves. A managed profile has nobody in that
|
||||
* position — its guardian IS the person who decides what it generates — so the
|
||||
* suppression does not apply there, and the guardian surfaces that trigger
|
||||
* generation deliberately are its own list.
|
||||
*
|
||||
* Read from the request context rather than from an argument, the same
|
||||
* reasoning `actingActorFor` states in `src/lib/auth/audit.ts`: the fact is
|
||||
* settled once, in the resolver, where the owner row is in hand — and threading
|
||||
* it through ten generator modules would be a second answer to "who is asking",
|
||||
* with the routes that forgot to pass it being the ones nobody reviewed. A
|
||||
* caller with no request context (every worker, every cron pass) reads false
|
||||
* and generates as it always did, which is the correct answer for work nobody
|
||||
* navigated into.
|
||||
*/
|
||||
export function delegatedGenerationSuppressed(): boolean {
|
||||
return getEvent()?.getAuth()?.delegated_generation === "suppressed";
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* holds nothing else: no predicate, no storage, no route knowledge. The
|
||||
* predicates live in `grants.ts` beside the rest of what a grant means, and
|
||||
* the per-route classification lives on the call sites, frozen by
|
||||
* `src/__tests__/delegable-surface-guard.test.ts`.
|
||||
* `src/__tests__/sharing-surface-guard.test.ts`.
|
||||
*
|
||||
* Two properties are the whole reason the file exists separately.
|
||||
*
|
||||
|
||||
@@ -1769,7 +1769,7 @@ describe("GET /api/medications/compliance — the bucket follows the actor", ()
|
||||
* record, and reading it is much of the point of handing somebody the record.
|
||||
*
|
||||
* Five GET arms moved. The upload, the retype, the delete, the episode link
|
||||
* and every AI verb did not, and `delegable-surface-guard.test.ts` is what
|
||||
* and every AI verb did not, and `sharing-surface-guard.test.ts` is what
|
||||
* freezes that split — this file asserts the moved arms actually substitute.
|
||||
*/
|
||||
async function enableDocuments(userId: string, enabled = true) {
|
||||
|
||||
@@ -448,7 +448,7 @@ writeContract("POST /api/biomarkers", {
|
||||
* have noticed.
|
||||
*
|
||||
* Re-admitting means flipping `requireAuth()` back to `requireRecordAuth`,
|
||||
* re-listing the route in `delegable-surface-guard.test.ts`, and replacing this
|
||||
* re-listing the route in `sharing-surface-guard.test.ts`, and replacing this
|
||||
* block with a `writeContract` — in the same diff as the caregiver-reachable
|
||||
* surface that made it worth doing.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,719 @@
|
||||
/**
|
||||
* v1.37.0 — the MANAGE perimeter, driven as routes against real Postgres.
|
||||
*
|
||||
* MANAGE is the level where a delegate stops adding to somebody's record and
|
||||
* starts changing it. Seventy modules answer at it; forty-three of them can
|
||||
* destroy or rewrite something. The structural guard freezes WHICH routes say
|
||||
* `"manage"` and, since this release, freezes the conditions each admission
|
||||
* was granted on — but a frozen tag is a promise about code, not about
|
||||
* behaviour, and the promises here are the kind that fail quietly: an audit
|
||||
* row that names an id and nothing else, a rate bucket keyed on the wrong
|
||||
* person, a navigation that spends the owner's AI budget.
|
||||
*
|
||||
* So this file drives the shipped exports. Same construction as
|
||||
* `sharing-delegable-writes.test.ts`: the real `apiHandler`, the real
|
||||
* `requireRecordAuth`, the real grant table, the real Prisma writes. The grant
|
||||
* is minted through `inviteGrant` + `acceptGrant` because "a MANAGE grant
|
||||
* exists" and "a MANAGE grant can be created by the flow this release ships"
|
||||
* are different claims.
|
||||
*
|
||||
* Four legs per verb, then the conditions:
|
||||
*
|
||||
* 1. a manager with a live MANAGE grant changes the OWNER's record, and the
|
||||
* audit row carries `userId = owner` AND `actorUserId = manager`;
|
||||
* 2. the same person under a WRITE grant is refused, and nothing moved —
|
||||
* asserted by re-reading the row, not by the status alone;
|
||||
* 3. a caller who was never granted anything is refused the same way;
|
||||
* 4. a caller who has not switched gets exactly what they always got.
|
||||
*
|
||||
* The conditions each get their own test, and each is written so that removing
|
||||
* the implementation turns it red rather than leaving it green with a smaller
|
||||
* claim: the reconstruction legs read the audit `details` and assert the
|
||||
* VALUES, the scope leg reads the rate-limit table and asserts the key, and
|
||||
* the generation leg asserts both halves — nothing enqueued for the manager,
|
||||
* something enqueued for the owner doing the identical thing.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
/**
|
||||
* The narrative warm, stubbed at the seam the condition is about.
|
||||
*
|
||||
* C5 is the condition that costs the owner money when it is wrong, and what it
|
||||
* suppresses is an ENQUEUE — a pg-boss insert whose absence is invisible from
|
||||
* the response. Counting the calls is the only way to see it, and the count
|
||||
* has to be taken on both sides: zero for the manager is worth nothing unless
|
||||
* the same request by the owner is one.
|
||||
*/
|
||||
const enqueueNarrativeWarm = vi.fn(async (_payload: unknown) => {});
|
||||
vi.mock("@/lib/jobs/period-narrative-shared", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/jobs/period-narrative-shared")>();
|
||||
return {
|
||||
...actual,
|
||||
enqueueNarrativeWarm: (payload: unknown) => enqueueNarrativeWarm(payload),
|
||||
};
|
||||
});
|
||||
|
||||
let counter = 0;
|
||||
|
||||
async function makeUser(label: string) {
|
||||
const suffix = `${label}-${counter++}`;
|
||||
return getPrismaClient().user.create({
|
||||
data: {
|
||||
username: `mg-${suffix}`,
|
||||
email: `mg-${suffix}@example.test`,
|
||||
displayName: `MG ${suffix}`,
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "en",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function signIn(userId: string) {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** A live grant at the named level, accepted, the caller switched into it. */
|
||||
async function switchInto(
|
||||
ownerId: string,
|
||||
delegateId: string,
|
||||
access: "READ" | "WRITE" | "MANAGE",
|
||||
) {
|
||||
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
|
||||
const invited = await inviteGrant({
|
||||
grantorId: ownerId,
|
||||
granteeId: delegateId,
|
||||
access,
|
||||
scope: null,
|
||||
});
|
||||
const grant = await acceptGrant({
|
||||
grantId: invited.id,
|
||||
granteeId: delegateId,
|
||||
});
|
||||
expect(grant.access).toBe(access);
|
||||
expect(grant.acceptedAt).not.toBeNull();
|
||||
|
||||
const session = await signIn(delegateId);
|
||||
await getPrismaClient().session.update({
|
||||
where: { id: session.id },
|
||||
data: { actingAsUserId: ownerId },
|
||||
});
|
||||
return { grant, session };
|
||||
}
|
||||
|
||||
/** A session inside a record nobody ever shared with this caller. */
|
||||
async function claimWithoutGrant(ownerId: string, callerId: string) {
|
||||
const session = await signIn(callerId);
|
||||
await getPrismaClient().session.update({
|
||||
where: { id: session.id },
|
||||
data: { actingAsUserId: ownerId },
|
||||
});
|
||||
}
|
||||
|
||||
type Handler = (
|
||||
request: NextRequest,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
context: { params: Promise<any> },
|
||||
) => Promise<Response>;
|
||||
|
||||
function request(method: string, url: string, body?: unknown): NextRequest {
|
||||
return new NextRequest(`http://localhost${url}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
}
|
||||
|
||||
async function call(
|
||||
handler: Handler,
|
||||
method: string,
|
||||
url: string,
|
||||
body?: unknown,
|
||||
params: Record<string, string> = {},
|
||||
): Promise<Response> {
|
||||
return handler(request(method, url, body), {
|
||||
params: Promise.resolve(params),
|
||||
});
|
||||
}
|
||||
|
||||
async function payload(response: Response): Promise<{
|
||||
data: unknown;
|
||||
error: unknown;
|
||||
meta?: { errorCode?: string };
|
||||
}> {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/** The stable refusal, asserted by code rather than by status alone. */
|
||||
async function expectDenied(response: Response) {
|
||||
expect(response.status).toBe(403);
|
||||
expect((await payload(response)).meta?.errorCode).toBe(
|
||||
"sharing.access.denied",
|
||||
);
|
||||
}
|
||||
|
||||
/** The audit row a verb filed, with its details already parsed. */
|
||||
async function auditRow(action: string) {
|
||||
const row = await getPrismaClient().auditLog.findFirst({
|
||||
where: { action },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
expect(row, `no ${action} row was written`).not.toBeNull();
|
||||
return {
|
||||
row: row!,
|
||||
details: JSON.parse(row!.details ?? "{}") as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
const ISO = "2026-07-15T08:00:00.000Z";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* The shape of a MANAGE case */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface ManageCase<C> {
|
||||
/** Rows the verb acts on, created under the record it will change. */
|
||||
prepare: (recordOwnerId: string) => Promise<C>;
|
||||
/** Drive the shipped export against the current session. */
|
||||
act: (ctx: C) => Promise<Response>;
|
||||
/** The status a successful call answers with. */
|
||||
ok: number;
|
||||
/** The action the happy path files through `auditLog`. */
|
||||
auditAction: string;
|
||||
/** True once the verb has taken effect on the prepared rows. */
|
||||
applied: (ctx: C) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function manageContract<C>(name: string, c: ManageCase<C>) {
|
||||
describe(name, () => {
|
||||
it("changes the owner's record and names the manager as actor", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const manager = await makeUser("manager");
|
||||
const ctx = await c.prepare(owner.id);
|
||||
|
||||
await switchInto(owner.id, manager.id, "MANAGE");
|
||||
const response = await c.act(ctx);
|
||||
expect(response.status).toBe(c.ok);
|
||||
|
||||
// The change landed…
|
||||
expect(await c.applied(ctx)).toBe(true);
|
||||
// …and the trail says who made it, under whose record.
|
||||
const { row } = await auditRow(c.auditAction);
|
||||
expect(row.userId).toBe(owner.id);
|
||||
expect(row.actorUserId).toBe(manager.id);
|
||||
});
|
||||
|
||||
it("refuses a WRITE grant, and nothing moves", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const helper = await makeUser("helper");
|
||||
const ctx = await c.prepare(owner.id);
|
||||
|
||||
await switchInto(owner.id, helper.id, "WRITE");
|
||||
await expectDenied(await c.act(ctx));
|
||||
|
||||
// The mutation-check rule: assert it did NOT apply, by re-reading.
|
||||
expect(await c.applied(ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a caller who names a record they were never granted", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const stranger = await makeUser("stranger");
|
||||
const ctx = await c.prepare(owner.id);
|
||||
|
||||
await claimWithoutGrant(owner.id, stranger.id);
|
||||
await expectDenied(await c.act(ctx));
|
||||
expect(await c.applied(ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it("is unchanged for a caller who has not switched", async () => {
|
||||
const plain = await makeUser("plain");
|
||||
const ctx = await c.prepare(plain.id);
|
||||
await signIn(plain.id);
|
||||
|
||||
expect((await c.act(ctx)).status).toBe(c.ok);
|
||||
expect(await c.applied(ctx)).toBe(true);
|
||||
|
||||
const { row } = await auditRow(c.auditAction);
|
||||
expect(row.userId).toBe(plain.id);
|
||||
expect(row.actorUserId).toBeNull();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
enqueueNarrativeWarm.mockClear();
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Fixtures */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
async function makeMeasurement(userId: string, value = 80) {
|
||||
// The timestamp moves per row: `(userId, type, measuredAt, source,
|
||||
// sleepStage)` is unique, so two fixtures on the same instant collide.
|
||||
const measuredAt = new Date(Date.parse(ISO) + counter++ * 60_000);
|
||||
return getPrismaClient().measurement.create({
|
||||
data: {
|
||||
userId,
|
||||
type: "WEIGHT",
|
||||
value,
|
||||
unit: "kg",
|
||||
measuredAt,
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function makeLabResult(userId: string) {
|
||||
return getPrismaClient().labResult.create({
|
||||
data: {
|
||||
userId,
|
||||
analyte: "HbA1c",
|
||||
value: 5.2,
|
||||
unit: "%",
|
||||
takenAt: new Date(ISO),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function makeAllergy(userId: string) {
|
||||
return getPrismaClient().allergy.create({
|
||||
data: {
|
||||
userId,
|
||||
substance: "Penicillin",
|
||||
category: "MEDICATION",
|
||||
type: "ALLERGY",
|
||||
severity: "SEVERE",
|
||||
status: "ACTIVE",
|
||||
onsetAt: new Date("2020-03-01T00:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function makeReminder(userId: string) {
|
||||
return getPrismaClient().measurementReminder.create({
|
||||
data: {
|
||||
userId,
|
||||
label: "Colonoscopy recall",
|
||||
measurementType: null,
|
||||
intervalDays: 365,
|
||||
notifyHour: 9,
|
||||
nextDueAt: new Date("2027-03-01T08:00:00.000Z"),
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 1 — the four legs, one verb per domain family */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
manageContract("PUT /api/measurements/[id]", {
|
||||
prepare: (ownerId) => makeMeasurement(ownerId),
|
||||
act: async (row) => {
|
||||
const { PUT } = await import("@/app/api/measurements/[id]/route");
|
||||
return call(
|
||||
PUT as Handler,
|
||||
"PUT",
|
||||
`/api/measurements/${row.id}`,
|
||||
{
|
||||
value: 74.5,
|
||||
},
|
||||
{ id: row.id },
|
||||
);
|
||||
},
|
||||
ok: 200,
|
||||
auditAction: "measurement.update",
|
||||
applied: async (row) => {
|
||||
const after = await getPrismaClient().measurement.findUnique({
|
||||
where: { id: row.id },
|
||||
});
|
||||
return after?.value === 74.5;
|
||||
},
|
||||
});
|
||||
|
||||
manageContract("DELETE /api/measurements/[id]", {
|
||||
prepare: (ownerId) => makeMeasurement(ownerId),
|
||||
act: async (row) => {
|
||||
const { DELETE } = await import("@/app/api/measurements/[id]/route");
|
||||
return call(
|
||||
DELETE as Handler,
|
||||
"DELETE",
|
||||
`/api/measurements/${row.id}`,
|
||||
undefined,
|
||||
{ id: row.id },
|
||||
);
|
||||
},
|
||||
ok: 200,
|
||||
auditAction: "measurement.delete",
|
||||
applied: async (row) => {
|
||||
const after = await getPrismaClient().measurement.findUnique({
|
||||
where: { id: row.id },
|
||||
});
|
||||
return after?.deletedAt !== null;
|
||||
},
|
||||
});
|
||||
|
||||
manageContract("PUT /api/labs/[id]", {
|
||||
prepare: (ownerId) => makeLabResult(ownerId),
|
||||
act: async (row) => {
|
||||
const { PUT } = await import("@/app/api/labs/[id]/route");
|
||||
return call(
|
||||
PUT as Handler,
|
||||
"PUT",
|
||||
`/api/labs/${row.id}`,
|
||||
{ value: 6.1 },
|
||||
{
|
||||
id: row.id,
|
||||
},
|
||||
);
|
||||
},
|
||||
ok: 200,
|
||||
auditAction: "labResult.update",
|
||||
applied: async (row) => {
|
||||
const after = await getPrismaClient().labResult.findUnique({
|
||||
where: { id: row.id },
|
||||
});
|
||||
return after?.value === 6.1;
|
||||
},
|
||||
});
|
||||
|
||||
manageContract("PATCH /api/allergies/[id]", {
|
||||
prepare: (ownerId) => makeAllergy(ownerId),
|
||||
act: async (row) => {
|
||||
const { PATCH } = await import("@/app/api/allergies/[id]/route");
|
||||
return call(
|
||||
PATCH as Handler,
|
||||
"PATCH",
|
||||
`/api/allergies/${row.id}`,
|
||||
{ severity: "MILD" },
|
||||
{ id: row.id },
|
||||
);
|
||||
},
|
||||
ok: 200,
|
||||
auditAction: "allergy.update",
|
||||
applied: async (row) => {
|
||||
const after = await getPrismaClient().allergy.findUnique({
|
||||
where: { id: row.id },
|
||||
});
|
||||
return after?.severity === "MILD";
|
||||
},
|
||||
});
|
||||
|
||||
manageContract("DELETE /api/measurement-reminders/[id]", {
|
||||
prepare: (ownerId) => makeReminder(ownerId),
|
||||
act: async (row) => {
|
||||
const { DELETE } =
|
||||
await import("@/app/api/measurement-reminders/[id]/route");
|
||||
return call(
|
||||
DELETE as Handler,
|
||||
"DELETE",
|
||||
`/api/measurement-reminders/${row.id}`,
|
||||
undefined,
|
||||
{ id: row.id },
|
||||
);
|
||||
},
|
||||
ok: 200,
|
||||
auditAction: "measurementReminder.delete",
|
||||
applied: async (row) => {
|
||||
const after = await getPrismaClient().measurementReminder.findUnique({
|
||||
where: { id: row.id },
|
||||
});
|
||||
return after === null;
|
||||
},
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 2 — reconstructable or refused */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("what a destruction leaves behind", () => {
|
||||
it("C3: a hard delete files what it destroyed, and never the encrypted text", async () => {
|
||||
// The preventive-care reminder is the sharpest case in the tree: the row
|
||||
// is gone, no restore route reaches it, and the audit row used to say
|
||||
// `{ reminderId }` — a pointer at nothing. An owner reading their feed
|
||||
// could see that a recall disappeared and never learn what it was for.
|
||||
const owner = await makeUser("owner");
|
||||
const manager = await makeUser("manager");
|
||||
const reminder = await makeReminder(owner.id);
|
||||
|
||||
await switchInto(owner.id, manager.id, "MANAGE");
|
||||
const { DELETE } =
|
||||
await import("@/app/api/measurement-reminders/[id]/route");
|
||||
expect(
|
||||
(
|
||||
await call(
|
||||
DELETE as Handler,
|
||||
"DELETE",
|
||||
`/api/measurement-reminders/${reminder.id}`,
|
||||
undefined,
|
||||
{ id: reminder.id },
|
||||
)
|
||||
).status,
|
||||
).toBe(200);
|
||||
|
||||
const { details } = await auditRow("measurementReminder.delete");
|
||||
expect(details.destroyedModel).toBe("MeasurementReminder");
|
||||
expect(details.destroyedId).toBe(reminder.id);
|
||||
expect(details.destroyedLabel).toBe("Colonoscopy recall");
|
||||
expect(details.destroyedAt).toBe(reminder.nextDueAt?.toISOString());
|
||||
expect(details.intervalDays).toBe(365);
|
||||
});
|
||||
|
||||
it("C3: the allergy delete carries the allergen and never the reaction", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const manager = await makeUser("manager");
|
||||
const allergy = await getPrismaClient().allergy.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
substance: "Penicillin",
|
||||
category: "MEDICATION",
|
||||
type: "ALLERGY",
|
||||
severity: "SEVERE",
|
||||
status: "ACTIVE",
|
||||
onsetAt: new Date("2020-03-01T00:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
|
||||
await switchInto(owner.id, manager.id, "MANAGE");
|
||||
const { DELETE } = await import("@/app/api/allergies/[id]/route");
|
||||
expect(
|
||||
(
|
||||
await call(
|
||||
DELETE as Handler,
|
||||
"DELETE",
|
||||
`/api/allergies/${allergy.id}`,
|
||||
undefined,
|
||||
{ id: allergy.id },
|
||||
)
|
||||
).status,
|
||||
).toBe(200);
|
||||
|
||||
const { row, details } = await auditRow("allergy.delete");
|
||||
expect(details.destroyedLabel).toBe("Penicillin");
|
||||
expect(details.severity).toBe("SEVERE");
|
||||
expect(details.destroyedAt).toBe("2020-03-01T00:00:00.000Z");
|
||||
// …and the audit table did not become a second store for the encrypted
|
||||
// half of the row. Asserted on the raw string, because a nested key would
|
||||
// slip past a top-level property check.
|
||||
expect(row.details ?? "").not.toContain("reaction");
|
||||
});
|
||||
|
||||
it("C4: an edit files the field family and the value that is gone", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const manager = await makeUser("manager");
|
||||
const measurement = await makeMeasurement(owner.id, 80);
|
||||
|
||||
await switchInto(owner.id, manager.id, "MANAGE");
|
||||
const { PUT } = await import("@/app/api/measurements/[id]/route");
|
||||
expect(
|
||||
(
|
||||
await call(
|
||||
PUT as Handler,
|
||||
"PUT",
|
||||
`/api/measurements/${measurement.id}`,
|
||||
{ value: 74.5 },
|
||||
{ id: measurement.id },
|
||||
)
|
||||
).status,
|
||||
).toBe(200);
|
||||
|
||||
const { details } = await auditRow("measurement.update");
|
||||
expect(details.fields).toEqual(["value"]);
|
||||
expect((details.previous as Record<string, unknown>).value).toBe(80);
|
||||
});
|
||||
|
||||
it("a restore puts back the row it names, and leaves the others tombstoned", async () => {
|
||||
// The fake-that-ignores-its-`where` case: a count check passes on a
|
||||
// restore that clears every tombstone in the record. The id is what
|
||||
// defeats it.
|
||||
const owner = await makeUser("owner");
|
||||
const manager = await makeUser("manager");
|
||||
const kept = await makeMeasurement(owner.id, 81);
|
||||
const other = await makeMeasurement(owner.id, 82);
|
||||
await getPrismaClient().measurement.updateMany({
|
||||
where: { id: { in: [kept.id, other.id] } },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
await switchInto(owner.id, manager.id, "MANAGE");
|
||||
const { POST } = await import("@/app/api/measurements/restore/route");
|
||||
const response = await call(
|
||||
POST as Handler,
|
||||
"POST",
|
||||
"/api/measurements/restore",
|
||||
{
|
||||
ids: [kept.id],
|
||||
},
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const restored = await getPrismaClient().measurement.findUnique({
|
||||
where: { id: kept.id },
|
||||
});
|
||||
const untouched = await getPrismaClient().measurement.findUnique({
|
||||
where: { id: other.id },
|
||||
});
|
||||
expect(restored?.deletedAt).toBeNull();
|
||||
expect(untouched?.deletedAt).not.toBeNull();
|
||||
|
||||
const { row } = await auditRow("measurement.restore");
|
||||
expect(row.userId).toBe(owner.id);
|
||||
expect(row.actorUserId).toBe(manager.id);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 3 — the conditions that are not about the audit row */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("the conditions the admissions were granted on", () => {
|
||||
it("C1: the manager burns their own rate allowance, not the owner's", async () => {
|
||||
// The buckets live in Postgres, so the key itself is readable. A bucket
|
||||
// keyed on the record would let a manager lock the owner out of their own
|
||||
// restore button by hammering it.
|
||||
const owner = await makeUser("owner");
|
||||
const manager = await makeUser("manager");
|
||||
const row = await makeMeasurement(owner.id);
|
||||
await getPrismaClient().measurement.update({
|
||||
where: { id: row.id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
await switchInto(owner.id, manager.id, "MANAGE");
|
||||
const { POST } = await import("@/app/api/measurements/restore/route");
|
||||
expect(
|
||||
(
|
||||
await call(POST as Handler, "POST", "/api/measurements/restore", {
|
||||
ids: [row.id],
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
|
||||
const buckets = await getPrismaClient().rateLimit.findMany();
|
||||
expect(buckets.length).toBeGreaterThan(0);
|
||||
expect(buckets.some((b) => b.key.includes(manager.id))).toBe(true);
|
||||
expect(buckets.some((b) => b.key.includes(owner.id))).toBe(false);
|
||||
});
|
||||
|
||||
it("C5: a manager's navigation enqueues no generation; the owner's does", async () => {
|
||||
// Both halves, and the second is what makes the first mean anything: the
|
||||
// narrative route warms unconditionally, so a zero on the delegated side
|
||||
// could just as easily mean the route never reached the enqueue at all.
|
||||
const owner = await makeUser("owner");
|
||||
const manager = await makeUser("manager");
|
||||
const { GET } = await import("@/app/api/insights/narrative/route");
|
||||
|
||||
await signIn(owner.id);
|
||||
expect(
|
||||
(await call(GET as Handler, "GET", "/api/insights/narrative?period=week"))
|
||||
.status,
|
||||
).toBe(200);
|
||||
expect(enqueueNarrativeWarm).toHaveBeenCalledTimes(1);
|
||||
|
||||
enqueueNarrativeWarm.mockClear();
|
||||
|
||||
await switchInto(owner.id, manager.id, "MANAGE");
|
||||
const delegated = await call(
|
||||
GET as Handler,
|
||||
"GET",
|
||||
"/api/insights/narrative?period=week",
|
||||
);
|
||||
expect(delegated.status).toBe(200);
|
||||
expect(enqueueNarrativeWarm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("C5: a WRITE grant cannot read the generated surfaces at all", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const helper = await makeUser("helper");
|
||||
await switchInto(owner.id, helper.id, "WRITE");
|
||||
|
||||
const { GET } = await import("@/app/api/insights/narrative/route");
|
||||
await expectDenied(
|
||||
await call(GET as Handler, "GET", "/api/insights/narrative?period=week"),
|
||||
);
|
||||
expect(enqueueNarrativeWarm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("C6: the delegated mood create refuses the sync client's externalId", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const manager = await makeUser("manager");
|
||||
const body = {
|
||||
mood: "GUT",
|
||||
moodLoggedAt: ISO,
|
||||
// A stable, device-shaped id: the floor on `externalId` refuses an
|
||||
// unstable one at the schema, and this test is about the level, not
|
||||
// about that floor.
|
||||
externalId: "8B0F3C2E-1D4A-4F5B-9C6D-7E8F90A1B2C3",
|
||||
source: "MOODLOG",
|
||||
};
|
||||
|
||||
await switchInto(owner.id, manager.id, "MANAGE");
|
||||
const { POST } = await import("@/app/api/mood-entries/route");
|
||||
const refused = await call(
|
||||
POST as Handler,
|
||||
"POST",
|
||||
"/api/mood-entries",
|
||||
body,
|
||||
);
|
||||
expect(refused.status).toBe(422);
|
||||
expect((await payload(refused)).meta?.errorCode).toBe(
|
||||
"mood.create.external_id_not_delegable",
|
||||
);
|
||||
expect(
|
||||
await getPrismaClient().moodEntry.count({ where: { userId: owner.id } }),
|
||||
).toBe(0);
|
||||
|
||||
// The same body from the owner's own session still lands: C6 closes the
|
||||
// delegated branch, not the route.
|
||||
await signIn(owner.id);
|
||||
const accepted = await call(
|
||||
POST as Handler,
|
||||
"POST",
|
||||
"/api/mood-entries",
|
||||
body,
|
||||
);
|
||||
expect(accepted.status).toBe(201);
|
||||
expect(
|
||||
await getPrismaClient().moodEntry.count({ where: { userId: owner.id } }),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user