mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(sharing): open the cycle reads to a delegate
Four routes: the cycle history, the day log, the profile and the custom symptom vocabulary. All four gate on `requireCycleEnabled(user.id, user.gender)`, and both arguments now come off the record. That is the correct reading and the one that had to be verified rather than assumed: whether cycle tracking applies is a property of the record, so a delegate who tracks nothing still sees the owner's cycle, and a delegate who tracks their own sees nothing when the owner does not. Both directions are asserted.
This commit is contained in:
1 parent
10f9a83356
commit
fe765a7ae2
6 files changed
+165
-9
No files matched your search
@@ -308,6 +308,14 @@ const DELEGABLE_ROUTES: Record<string, string> = {
|
||||
"The day logs of one episode. The episode-scoped queries are safe because `loadOwnedEpisode(id, user.id)` runs first and now resolves against the record; the upsert arm keeps `requireAuth()`.",
|
||||
"app/api/workouts/route.ts":
|
||||
"The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.",
|
||||
"app/api/cycle/cycles/route.ts":
|
||||
"The record's cycle history. The gate reads `user.gender` off the resolved record, which is the point: whether cycle tracking applies is a property of the record, not of whoever is looking at it.",
|
||||
"app/api/cycle/day-logs/route.ts":
|
||||
"One day of the record's cycle log. The `auditLog(\"cycle.day-log.upsert\")` in this file belongs to the PUT, which keeps `requireAuth()`.",
|
||||
"app/api/cycle/profile/route.ts":
|
||||
"The record's cycle profile, supplied by the gate itself. No query of its own.",
|
||||
"app/api/cycle/symptoms/custom/route.ts":
|
||||
"The record's own custom symptom vocabulary. The create arm carries the rate limit and keeps `requireAuth()`.",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -331,7 +339,7 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* a formality by accident: every addition has to be counted here as well as
|
||||
* listed above, which is one more place a careless admission has to pass.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 31;
|
||||
const FROZEN_ENTRY_COUNT = 35;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -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 { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
@@ -29,7 +29,7 @@ const DEFAULT_LIMIT = 24;
|
||||
const REGULAR_MAD_THRESHOLD = 2;
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -48,7 +48,7 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postDayLog));
|
||||
* row id. Gated + owner-scoped + `deletedAt:null`.
|
||||
*/
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Gated like every cycle route; returns the lazily-upserted profile with
|
||||
* the resolved `cycleTrackingEnabled`.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
@@ -14,7 +14,7 @@ import { toCycleProfileDTO } from "@/lib/cycle/dto";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -24,7 +24,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 { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -1315,3 +1315,151 @@ contract<number>("GET /api/workouts", {
|
||||
ownerMarker: 3600,
|
||||
delegateMarker: 1800,
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Cycle — the gate reads the record's own gender */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A cycle-tracking account. `cycleTrackingEnabled` is an explicit opt-in that
|
||||
* overrides the gender derivation, so the fixture does not depend on the enum
|
||||
* normalisation the gate's own comment flags as still pending.
|
||||
*/
|
||||
async function seedCycle(userId: string, startDate: string) {
|
||||
await getPrismaClient().user.update({
|
||||
where: { id: userId },
|
||||
data: { gender: "FEMALE" },
|
||||
});
|
||||
await getPrismaClient().cycleProfile.upsert({
|
||||
where: { userId },
|
||||
create: { userId, cycleTrackingEnabled: true },
|
||||
update: { cycleTrackingEnabled: true },
|
||||
});
|
||||
await getPrismaClient().menstrualCycle.create({
|
||||
data: { userId, startDate },
|
||||
});
|
||||
}
|
||||
|
||||
contract<string>("GET /api/cycle/cycles", {
|
||||
call: async () => {
|
||||
const { GET } = await import("@/app/api/cycle/cycles/route");
|
||||
return call(GET as Handler, "/api/cycle/cycles");
|
||||
},
|
||||
seed: seedCycle,
|
||||
read: (payload: { cycles: { startDate: string }[] }) =>
|
||||
payload.cycles.map((c) => c.startDate),
|
||||
ownerMarker: "2026-07-01",
|
||||
delegateMarker: "2026-06-02",
|
||||
});
|
||||
|
||||
describe("GET /api/cycle/profile", () => {
|
||||
it("resolves the gate against the record, not the caller", async () => {
|
||||
// The delegate does not track a cycle. The owner does. A gate reading the
|
||||
// caller's own gender and profile would refuse this request — the module
|
||||
// decision belongs to the record.
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await seedCycle(owner.id, "2026-07-01");
|
||||
|
||||
await switchInto(owner.id, delegate.id);
|
||||
const { GET } = await import("@/app/api/cycle/profile/route");
|
||||
const response = await call(GET as Handler, "/api/cycle/profile");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect((await body(response)).data).toMatchObject({ cycleTrackingEnabled: true });
|
||||
});
|
||||
|
||||
it("refuses when the RECORD does not track a cycle, whatever the caller does", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await seedCycle(delegate.id, "2026-06-02");
|
||||
|
||||
await switchInto(owner.id, delegate.id);
|
||||
const { GET } = await import("@/app/api/cycle/profile/route");
|
||||
const response = await call(GET as Handler, "/api/cycle/profile");
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it("refuses the next request after the grant is revoked", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await seedCycle(owner.id, "2026-07-01");
|
||||
|
||||
const { GET } = await import("@/app/api/cycle/profile/route");
|
||||
const { grant } = await switchInto(owner.id, delegate.id);
|
||||
expect((await call(GET as Handler, "/api/cycle/profile")).status).toBe(200);
|
||||
|
||||
await revokeGrant(grant.id);
|
||||
await expectDenied(await call(GET as Handler, "/api/cycle/profile"));
|
||||
});
|
||||
|
||||
it("is unchanged for a caller who has not switched", async () => {
|
||||
const plain = await makeUser("plain");
|
||||
await seedCycle(plain.id, "2026-07-01");
|
||||
await signIn(plain.id);
|
||||
|
||||
const { GET } = await import("@/app/api/cycle/profile/route");
|
||||
const response = await call(GET as Handler, "/api/cycle/profile");
|
||||
expect(response.status).toBe(200);
|
||||
expect((await body(response)).data).toMatchObject({ cycleTrackingEnabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/cycle/day-logs and /api/cycle/symptoms/custom", () => {
|
||||
it("read the owner's rows and refuse once the grant ends", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await seedCycle(owner.id, "2026-07-01");
|
||||
await seedCycle(delegate.id, "2026-06-02");
|
||||
await getPrismaClient().cycleDayLog.create({
|
||||
data: { userId: owner.id, date: "2026-07-03", basalBodyTempC: 36.7 },
|
||||
});
|
||||
await getPrismaClient().cycleDayLog.create({
|
||||
data: { userId: delegate.id, date: "2026-07-03", basalBodyTempC: 36.1 },
|
||||
});
|
||||
|
||||
const logs = await import("@/app/api/cycle/day-logs/route");
|
||||
const symptoms = await import("@/app/api/cycle/symptoms/custom/route");
|
||||
const { grant } = await switchInto(owner.id, delegate.id);
|
||||
|
||||
const log = await call(
|
||||
logs.GET as Handler,
|
||||
"/api/cycle/day-logs?date=2026-07-03",
|
||||
);
|
||||
expect(log.status).toBe(200);
|
||||
expect((await body(log)).data).toMatchObject({ basalBodyTempC: 36.7 });
|
||||
|
||||
expect(
|
||||
(await call(symptoms.GET as Handler, "/api/cycle/symptoms/custom"))
|
||||
.status,
|
||||
).toBe(200);
|
||||
|
||||
await revokeGrant(grant.id);
|
||||
await expectDenied(
|
||||
await call(logs.GET as Handler, "/api/cycle/day-logs?date=2026-07-03"),
|
||||
);
|
||||
await expectDenied(
|
||||
await call(symptoms.GET as Handler, "/api/cycle/symptoms/custom"),
|
||||
);
|
||||
});
|
||||
|
||||
it("are unchanged for a caller who has not switched", async () => {
|
||||
const plain = await makeUser("plain");
|
||||
await seedCycle(plain.id, "2026-07-01");
|
||||
await getPrismaClient().cycleDayLog.create({
|
||||
data: { userId: plain.id, date: "2026-07-03", basalBodyTempC: 36.4 },
|
||||
});
|
||||
await signIn(plain.id);
|
||||
|
||||
const { GET } = await import("@/app/api/cycle/day-logs/route");
|
||||
const response = await call(
|
||||
GET as Handler,
|
||||
"/api/cycle/day-logs?date=2026-07-03",
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect((await body(response)).data).toMatchObject({
|
||||
basalBodyTempC: 36.4,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user