mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +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
@@ -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,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user