mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
fix(medications): let a manager end a course and serve End only to who may press it
POST /api/medications/{id}/courses/end resolved the caller with
requireAuth(), so a delegate managing the record got 403 while the
medication PUT that used to carry the end date admits MANAGE, and the
reads still served canEndCourse true. The action now resolves the record
at MANAGE over medications and files the previous end (C4) with the
count of tombstoned placeholders.
The medication reads narrow canEndCourse and canStartCourse to the
caller: End needs MANAGE, starting a course stays owner-only. The list
cell stays per record and the narrowing runs after it. Create and edit
answers pass the grant they were resolved under instead of null.
Registered in the sharing surface guard, the MANAGE handler matrix and
the activity feed's verb lines.
Refs #1280
This commit is contained in:
1 parent
3984ceb01c
commit
8fd1d543a6
28 files changed
+421
-102
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 351,
|
||||
"total": 113672,
|
||||
"total": 113619,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
|
||||
@@ -342,7 +342,7 @@
|
||||
"lib/notifications/senders/web-push.ts :: sendViaWebPush": 268,
|
||||
"lib/personal-records/pr-detection-worker.ts :: detectPersonalRecordsForUser": 239,
|
||||
"lib/personal-records/pr-direction.ts :: getPRDirection": 202,
|
||||
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 325,
|
||||
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 272,
|
||||
"lib/strava/sync.ts :: runStravaSync": 219,
|
||||
"lib/support/report.ts :: collectSupportReportFacts": 332,
|
||||
"lib/targets/build-response.ts :: buildTargetsResponse": 330,
|
||||
|
||||
@@ -1202,6 +1202,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "medications",
|
||||
why: "One medication of the record, fetch-then-guard against the resolved user before anything is serialised.",
|
||||
},
|
||||
"app/api/medications/[id]/courses/end/route.ts": {
|
||||
domain: "medications",
|
||||
why: "Ending one medication's running course now (#1280), behind the course write's own ownership check against the resolved record. The POST is the route's only verb and is manage-level, the level the medication's PUT carried the end date at; starting, editing and removing courses stay owner-only.",
|
||||
},
|
||||
"app/api/medications/[id]/ingredients/route.ts": {
|
||||
domain: "medications",
|
||||
why: "One medication's composition per unit (v1.43), behind the ownership check against the resolved record. The PUT is the route's only verb and is manage-level; the label read beside it stays owner-only, because provider work is never admitted inside somebody else's record.",
|
||||
@@ -1882,6 +1886,11 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
|
||||
conditions: ["C4", "C7"],
|
||||
why: "Replacing a medication and its schedule. C4, and C7 because the previous cadence and the tombstoned pending slots are the only unrecoverable part. The DELETE beside it stays refused.",
|
||||
},
|
||||
"app/api/medications/[id]/courses/end/route.ts": {
|
||||
domain: "medications",
|
||||
conditions: ["C4"],
|
||||
why: "Ending the running course at this instant, which the medication's PUT already admitted as an end date. C4: the end the course had before is filed beside the new one, with the count of pending slots it took.",
|
||||
},
|
||||
"app/api/medications/[id]/ingredients/route.ts": {
|
||||
domain: "medications",
|
||||
conditions: ["C1", "C4"],
|
||||
@@ -2273,8 +2282,12 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* side). The activity goals route does not: setting or changing goals stays
|
||||
* owner-only, and a delegate sees the goals only as the calendar reports them.
|
||||
* 270 -> 273.
|
||||
*
|
||||
* Ending a running course (#1280) joins the record list and the
|
||||
* manage literal: it was refused under a switch while the medication's PUT,
|
||||
* which used to carry the end, admitted a manager. 273 -> 275.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 273;
|
||||
const FROZEN_ENTRY_COUNT = 275;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
@@ -3052,7 +3065,7 @@ describe("(g) the MANAGE route set is frozen", () => {
|
||||
|
||||
it("keeps the admitted mutation inventory complete and discoverable", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(92);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(93);
|
||||
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(
|
||||
({ handlerModule, action, level }) =>
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
|
||||
});
|
||||
|
||||
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(92);
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(93);
|
||||
expect(
|
||||
new Set(
|
||||
ADMITTED_MUTATING_HANDLERS.map(
|
||||
|
||||
@@ -12,12 +12,17 @@
|
||||
* `medications.course.notRunning` while no course is running, the same rule
|
||||
* the reads publish as `canEndCourse`.
|
||||
*
|
||||
* Owner-only, like the other course routes.
|
||||
* MANAGE over the record's medications, the level the medication's own PUT
|
||||
* (which used to carry the end date) is written at: a manager running
|
||||
* somebody's regimen may end a course they could already end through the
|
||||
* edit. Starting, editing and removing courses stay owner-only. The reads
|
||||
* publish `canEndCourse` false to a caller below that level
|
||||
* (`course-authority.ts`), so no client offers the button this refuses.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
@@ -28,6 +33,7 @@ import {
|
||||
endCurrentCourse,
|
||||
} from "@/lib/medications/courses";
|
||||
import { dayKeyOfDate } from "@/lib/medications/course-window";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -42,11 +48,11 @@ async function postEndCourse(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("manage", "medications");
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const window = await endCurrentCourse({
|
||||
const { window, previous, droppedPlaceholders } = await endCurrentCourse({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
timeZone: user.timezone || DEFAULT_TIMEZONE,
|
||||
@@ -60,7 +66,16 @@ async function postEndCourse(
|
||||
await auditLog("medication.course.end", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { medicationId: id, ...body },
|
||||
// C4: the end the course had before, so a manager's End reads back
|
||||
// out of the trail; and how many pending slots it took with it.
|
||||
details: {
|
||||
medicationId: id,
|
||||
...overwriteDetails({
|
||||
before: { endsOn: previous.endsOn, endedAt: previous.endedAt },
|
||||
after: { endsOn: window.endsOn, endedAt: window.endedAt },
|
||||
}),
|
||||
droppedPlaceholders,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "medication.course.end" },
|
||||
|
||||
@@ -36,7 +36,7 @@ export const PUT = apiHandler(
|
||||
// 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, authMethod } = await requireRecordAuth(
|
||||
const { user, authMethod, grantId } = await requireRecordAuth(
|
||||
"manage",
|
||||
"medications",
|
||||
);
|
||||
@@ -60,6 +60,7 @@ export const PUT = apiHandler(
|
||||
|
||||
const outcome = await updateMedication({
|
||||
user,
|
||||
grantId,
|
||||
authMethod,
|
||||
id,
|
||||
existing,
|
||||
|
||||
@@ -13,12 +13,16 @@ import {
|
||||
unstableExternalIdShape,
|
||||
} from "@/lib/validations/external-id";
|
||||
import { readMedicationsListCached } from "@/lib/medications/list-read";
|
||||
import {
|
||||
courseActionAuthority,
|
||||
withCourseActionAuthority,
|
||||
} from "@/lib/medications/course-authority";
|
||||
import { createMedication } from "@/lib/medications/create-medication";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read", "medications");
|
||||
const { user, grantId } = await requireRecordAuth("read", "medications");
|
||||
|
||||
// Cache the list shape on userId. The 60 s fresh TTL bounds the
|
||||
// cross-midnight staleness window for `todayEventCount` and the
|
||||
@@ -30,7 +34,11 @@ export const GET = apiHandler(async () => {
|
||||
// `invalidateUserMedications({ evict: true })` before the next read
|
||||
// lands; background sync paths mark the bucket stale instead. The RSC
|
||||
// wrapper (`src/app/medications/page.tsx`) shares this exact cell.
|
||||
const result = await readMedicationsListCached(user);
|
||||
const cached = await readMedicationsListCached(user);
|
||||
// The cell is the record's; End and Start are narrowed to the caller after
|
||||
// it, so one caller's authority never lands in another's answer (#1280).
|
||||
const authority = await courseActionAuthority(grantId);
|
||||
const result = cached.map((row) => withCourseActionAuthority(row, authority));
|
||||
|
||||
annotate({
|
||||
action: { name: "medication.list" },
|
||||
@@ -55,7 +63,7 @@ async function postMedication(request: NextRequest): Promise<Response> {
|
||||
// it afterwards — `PUT /api/medications/[id]` and the DELETE beside it stay
|
||||
// on `requireAuth()` and refuse under a switch. Adding is help; rewriting
|
||||
// the owner's cabinet is not.
|
||||
const { user } = await requireRecordAuth("write", "medications");
|
||||
const { user, grantId } = await requireRecordAuth("write", "medications");
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
@@ -80,6 +88,7 @@ async function postMedication(request: NextRequest): Promise<Response> {
|
||||
|
||||
const outcome = await createMedication({
|
||||
user,
|
||||
grantId,
|
||||
data: parsed.data,
|
||||
ipAddress: getClientIp(request),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* #1280 — the course actions a medication read offers, narrowed to what the
|
||||
* CALLER may do.
|
||||
*
|
||||
* `canEndCourse` and `canStartCourse` are resolved from the course alone
|
||||
* (`intake-actionable.ts`, `courses.ts`), and the course is the record's.
|
||||
* The routes behind them are not open to everyone who can read the record:
|
||||
* End is a MANAGE write (`POST /courses/end`), and starting, editing or
|
||||
* removing a course stays with the owner (`/courses`, `/courses/{courseId}`).
|
||||
* A read that served the course's answer to a viewer would paint a button
|
||||
* whose press is refused, so every read that publishes the two flags passes
|
||||
* them through here with the caller's authority.
|
||||
*
|
||||
* The record-wide list is cached per record, never per caller, so the
|
||||
* narrowing runs after the cache and never lands in it.
|
||||
*/
|
||||
import { grantIdAllows } from "@/lib/sharing/grant-access";
|
||||
|
||||
export interface CourseActionAuthority {
|
||||
/** The caller may end the running course (MANAGE over medications). */
|
||||
mayEnd: boolean;
|
||||
/** The caller may start a course (the record's owner). */
|
||||
mayStart: boolean;
|
||||
}
|
||||
|
||||
/** The authority of the request resolved under `grantId` (null: the owner). */
|
||||
export async function courseActionAuthority(
|
||||
grantId: string | null,
|
||||
): Promise<CourseActionAuthority> {
|
||||
if (grantId === null) return { mayEnd: true, mayStart: true };
|
||||
return {
|
||||
mayEnd: await grantIdAllows(grantId, "manage", "medications"),
|
||||
mayStart: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The read row with its course actions narrowed to the caller's authority. */
|
||||
export function withCourseActionAuthority<T extends Record<string, unknown>>(
|
||||
row: T,
|
||||
authority: CourseActionAuthority,
|
||||
): T {
|
||||
if (authority.mayEnd && authority.mayStart) return row;
|
||||
return {
|
||||
...row,
|
||||
canEndCourse: authority.mayEnd && row.canEndCourse === true,
|
||||
canStartCourse: authority.mayStart && row.canStartCourse === true,
|
||||
};
|
||||
}
|
||||
@@ -482,9 +482,14 @@ function keptEndedAt(
|
||||
* (`canEndCourse`): ending an ended course would reopen it up to today, and
|
||||
* ending one that has not started would invent a one-day course.
|
||||
*/
|
||||
export async function endCurrentCourse(
|
||||
args: WriteContext,
|
||||
): Promise<ProjectedWindow> {
|
||||
export async function endCurrentCourse(args: WriteContext): Promise<{
|
||||
/** The window the medication reads after the end. */
|
||||
window: ProjectedWindow;
|
||||
/** The window it read before, for the audit row's overwrite (C4). */
|
||||
previous: ProjectedWindow;
|
||||
/** How many pending placeholders at or after the moment were tombstoned. */
|
||||
droppedPlaceholders: number;
|
||||
}> {
|
||||
const now = args.now ?? new Date();
|
||||
const todayKey = userDayKey(now, args.timeZone);
|
||||
const { endsOn: today, endedAt } = courseEndNow(now, args.timeZone);
|
||||
@@ -528,7 +533,7 @@ export async function endCurrentCourse(
|
||||
},
|
||||
});
|
||||
}
|
||||
await tx.medicationIntakeEvent.updateMany({
|
||||
const dropped = await tx.medicationIntakeEvent.updateMany({
|
||||
where: {
|
||||
userId: args.userId,
|
||||
medicationId: args.medicationId,
|
||||
@@ -540,7 +545,15 @@ export async function endCurrentCourse(
|
||||
},
|
||||
data: { deletedAt: now, syncVersion: { increment: 1 } },
|
||||
});
|
||||
return project(tx, args.medicationId);
|
||||
return {
|
||||
window: await project(tx, args.medicationId),
|
||||
previous: {
|
||||
startsOn: medication.startsOn,
|
||||
endsOn: medication.endsOn,
|
||||
endedAt: medication.endedAt,
|
||||
},
|
||||
droppedPlaceholders: dropped.count,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ export type CreateMedicationOutcome =
|
||||
*/
|
||||
async function existingMirrorBody(
|
||||
user: ReadUser,
|
||||
grantId: string | null,
|
||||
medication: Medication & { schedules: MedicationSchedule[] },
|
||||
): Promise<CreateMedicationOutcome> {
|
||||
annotate({
|
||||
@@ -81,7 +82,7 @@ async function existingMirrorBody(
|
||||
// The read model, exactly as `GET /api/medications/{id}` answers it.
|
||||
const body = await medicationDetail({
|
||||
user,
|
||||
grantId: null,
|
||||
grantId,
|
||||
medication,
|
||||
announce: false,
|
||||
});
|
||||
@@ -98,6 +99,7 @@ async function existingMirrorBody(
|
||||
*/
|
||||
async function existingMirrorOrCap(
|
||||
user: ReadUser,
|
||||
grantId: string | null,
|
||||
externalSource: NonNullable<CreateMedicationInput["externalSource"]>,
|
||||
externalId: string,
|
||||
): Promise<CreateMedicationOutcome | null> {
|
||||
@@ -107,7 +109,7 @@ async function existingMirrorOrCap(
|
||||
include: { schedules: { orderBy: MEDICATION_SCHEDULE_ORDER } },
|
||||
});
|
||||
if (existing) {
|
||||
return existingMirrorBody(user, existing);
|
||||
return existingMirrorBody(user, grantId, existing);
|
||||
}
|
||||
|
||||
// Growth guard. This point is only reached when the idempotency lookup
|
||||
@@ -410,10 +412,12 @@ function insertMedication(
|
||||
|
||||
export async function createMedication(input: {
|
||||
user: ReadUser;
|
||||
/** The grant the request was resolved under; null for the owner. */
|
||||
grantId: string | null;
|
||||
data: CreateMedicationInput;
|
||||
ipAddress: string | null;
|
||||
}): Promise<CreateMedicationOutcome> {
|
||||
const { user, data } = input;
|
||||
const { user, grantId, data } = input;
|
||||
const {
|
||||
name,
|
||||
category,
|
||||
@@ -439,7 +443,12 @@ export async function createMedication(input: {
|
||||
}
|
||||
|
||||
if (externalSource !== undefined && externalId !== undefined) {
|
||||
const mirror = await existingMirrorOrCap(user, externalSource, externalId);
|
||||
const mirror = await existingMirrorOrCap(
|
||||
user,
|
||||
grantId,
|
||||
externalSource,
|
||||
externalId,
|
||||
);
|
||||
if (mirror) return mirror;
|
||||
}
|
||||
|
||||
@@ -483,7 +492,7 @@ export async function createMedication(input: {
|
||||
where: { userId: user.id, externalSource, externalId },
|
||||
include: { schedules: { orderBy: MEDICATION_SCHEDULE_ORDER } },
|
||||
});
|
||||
if (winner) return existingMirrorBody(user, winner);
|
||||
if (winner) return existingMirrorBody(user, grantId, winner);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -517,7 +526,7 @@ export async function createMedication(input: {
|
||||
// The read model, exactly as `GET /api/medications/{id}` answers it.
|
||||
const body = await medicationDetail({
|
||||
user,
|
||||
grantId: null,
|
||||
grantId,
|
||||
medication,
|
||||
announce: false,
|
||||
});
|
||||
|
||||
@@ -36,6 +36,10 @@ import { floorWhole } from "@/lib/medications/units-per-dose";
|
||||
import { resolveIntakeActionability } from "@/lib/medications/intake-actionable";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { buildRelated } from "@/lib/links/related";
|
||||
import {
|
||||
courseActionAuthority,
|
||||
withCourseActionAuthority,
|
||||
} from "@/lib/medications/course-authority";
|
||||
|
||||
export type ReadUser = Pick<SessionUser, "id" | "timezone" | "locale">;
|
||||
type MedicationWithSchedules = Medication & {
|
||||
@@ -262,7 +266,7 @@ export async function medicationDetail(input: {
|
||||
)
|
||||
).get(id);
|
||||
|
||||
return {
|
||||
const body = {
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
// `schedules: []` plus `recordedSchedules` when intake tracking is off
|
||||
@@ -296,4 +300,6 @@ export async function medicationDetail(input: {
|
||||
id,
|
||||
}),
|
||||
};
|
||||
// End and Start as the CALLER may press them (#1280).
|
||||
return withCourseActionAuthority(body, await courseActionAuthority(grantId));
|
||||
}
|
||||
@@ -300,13 +300,15 @@ async function auditMedicationEdit(
|
||||
/** Edit one medication the route found and authorised. */
|
||||
export async function updateMedication(input: {
|
||||
user: EditUser;
|
||||
/** The grant the request was resolved under; null for the owner. */
|
||||
grantId: string | null;
|
||||
authMethod: string;
|
||||
id: string;
|
||||
existing: EditableMedication;
|
||||
data: MedicationEdit;
|
||||
ipAddress: string | null;
|
||||
}): Promise<MedicationEditOutcome> {
|
||||
const { user, id } = input;
|
||||
const { user, grantId, id } = input;
|
||||
const plan = await planMedicationEdit(input);
|
||||
if (!plan.ok) return plan;
|
||||
const { write } = plan;
|
||||
@@ -361,7 +363,7 @@ export async function updateMedication(input: {
|
||||
// The read model, exactly as `GET /api/medications/{id}` answers it.
|
||||
const body = await medicationDetail({
|
||||
user,
|
||||
grantId: null,
|
||||
grantId,
|
||||
medication,
|
||||
announce: false,
|
||||
});
|
||||
|
||||
@@ -1184,7 +1184,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Medications"],
|
||||
summary: "End the running course now",
|
||||
description:
|
||||
"#1280 — \"End course\". No body. The running course's last day becomes today on the person's clock and the moment is stored as its `endedAt`: a dose slot later today is not due, not reminded and not expected by compliance, a dose taken earlier today still counts, and `courseStatus` reads ENDED at once. Pending placeholders for slots at or after the moment are removed (tombstoned for sync). A medication with no dates gets the course its window describes, from creation to today. An end date set through `PUT /api/medications/{id}` or the course editor keeps meaning the whole day; only this action ends a course at an instant. Refused with 409 `medications.course.notRunning` while no course is running — the rule the reads publish as `canEndCourse`. Owner-only.",
|
||||
"#1280 — \"End course\". No body. The running course's last day becomes today on the person's clock and the moment is stored as its `endedAt`: a dose slot later today is not due, not reminded and not expected by compliance, a dose taken earlier today still counts, and `courseStatus` reads ENDED at once. Pending placeholders for slots at or after the moment are removed (tombstoned for sync). A medication with no dates gets the course its window describes, from creation to today. An end date set through `PUT /api/medications/{id}` or the course editor keeps meaning the whole day; only this action ends a course at an instant. Refused with 409 `medications.course.notRunning` while no course is running — the rule the reads publish as `canEndCourse`. The record's owner, or a delegate whose grant manages the record's medications (the level of the medication's PUT); any other delegate is refused with 403.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
@@ -1213,6 +1213,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"No course is running now (`medications.course.notRunning`): it has ended already or has not started.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...recordRefusal(),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -110,7 +110,7 @@ const medicationCourseFields = {
|
||||
canStartCourse: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"v1.40 — whether `POST /api/medications/{id}/courses` may start a new course now: the medication is active, not one-time, has a course and every course has ended.",
|
||||
"v1.40 — whether `POST /api/medications/{id}/courses` may start a new course now: the medication is active, not one-time, has a course and every course has ended, and the caller is the record's owner (starting a course is not delegable, so a delegate always reads false).",
|
||||
),
|
||||
};
|
||||
|
||||
@@ -463,7 +463,7 @@ export const medicationListEntry = medicationResource
|
||||
canEndCourse: z
|
||||
.boolean()
|
||||
.describe(
|
||||
'#1280 — whether `POST /api/medications/{id}/courses/end` may end the course now: true exactly while a course is running (`courseStatus` CURRENT; a medication with no dates is running). Clients offer "End course" from this and do not re-derive it; the action refuses with 409 `medications.course.notRunning` when it is false. Read-only — computed, not stored.',
|
||||
'#1280 — whether `POST /api/medications/{id}/courses/end` may end the course now: true exactly while a course is running (`courseStatus` CURRENT; a medication with no dates is running) and the caller may end it: the owner of the record, or a delegate whose grant manages its medications. False to every other caller. Clients offer "End course" from this and do not re-derive it; the action refuses with 409 `medications.course.notRunning` when it is false. Read-only — computed, not stored.',
|
||||
),
|
||||
lastTakenAt: isoDateTime()
|
||||
.nullable()
|
||||
@@ -534,7 +534,7 @@ export const medicationDetailEntry = medicationResource
|
||||
canEndCourse: z
|
||||
.boolean()
|
||||
.describe(
|
||||
'#1280 — whether `POST /api/medications/{id}/courses/end` may end the course now: true exactly while a course is running (`courseStatus` CURRENT; a medication with no dates is running). Clients offer "End course" from this and do not re-derive it; the action refuses with 409 `medications.course.notRunning` when it is false. Read-only — computed, not stored.',
|
||||
'#1280 — whether `POST /api/medications/{id}/courses/end` may end the course now: true exactly while a course is running (`courseStatus` CURRENT; a medication with no dates is running) and the caller may end it: the owner of the record, or a delegate whose grant manages its medications. False to every other caller. Clients offer "End course" from this and do not re-derive it; the action refuses with 409 `medications.course.notRunning` when it is false. Read-only — computed, not stored.',
|
||||
),
|
||||
stockUnitsRemaining: z
|
||||
.number()
|
||||
|
||||
@@ -63,6 +63,8 @@ export function recordActivityVerbLine(
|
||||
): string {
|
||||
const refused = refusedAttemptVerb(t, action, name);
|
||||
if (refused !== null) return refused;
|
||||
const medication = medicationManageVerb(t, action, name);
|
||||
if (medication !== null) return medication;
|
||||
switch (action) {
|
||||
case "measurement.create":
|
||||
return t("recordSharing.activityVerb.measurementCreate", { name });
|
||||
@@ -255,61 +257,6 @@ export function recordActivityVerbLine(
|
||||
name,
|
||||
});
|
||||
|
||||
case "medication.update":
|
||||
return t("recordSharing.activityVerb.medicationUpdate", { name });
|
||||
case "medication.category.custom.create":
|
||||
return t("recordSharing.activityVerb.medicationCategoryCreate", {
|
||||
name,
|
||||
});
|
||||
case "medication.delete":
|
||||
return t("recordSharing.activityVerb.medicationDelete", { name });
|
||||
case "medication.ingredients.update":
|
||||
return t("recordSharing.activityVerb.medicationIngredientsUpdate", {
|
||||
name,
|
||||
});
|
||||
case "medication.glp1.update":
|
||||
return t("recordSharing.activityVerb.medicationGlp1Update", { name });
|
||||
case "medication.schedule_revision.created":
|
||||
return t("recordSharing.activityVerb.medicationScheduleCreate", { name });
|
||||
case "medication.schedule_revision.updated":
|
||||
return t("recordSharing.activityVerb.medicationScheduleUpdate", { name });
|
||||
case "medication.schedule_revision.deleted":
|
||||
return t("recordSharing.activityVerb.medicationScheduleDelete", { name });
|
||||
case "medication.inventory.create":
|
||||
return t("recordSharing.activityVerb.medicationInventoryCreate", {
|
||||
name,
|
||||
});
|
||||
case "medication.inventory.update":
|
||||
return t("recordSharing.activityVerb.medicationInventoryUpdate", {
|
||||
name,
|
||||
});
|
||||
case "medication.inventory.delete":
|
||||
return t("recordSharing.activityVerb.medicationInventoryDelete", {
|
||||
name,
|
||||
});
|
||||
case "medication.sideEffect.delete":
|
||||
return t("recordSharing.activityVerb.medicationSideEffectDelete", {
|
||||
name,
|
||||
});
|
||||
case "medication.intake.update":
|
||||
return t("recordSharing.activityVerb.medicationIntakeUpdate", { name });
|
||||
case "medication.intake.delete":
|
||||
return t("recordSharing.activityVerb.medicationIntakeDelete", { name });
|
||||
case "medication.intake.bulk_delete":
|
||||
return t("recordSharing.activityVerb.medicationIntakeBulkDelete", {
|
||||
name,
|
||||
});
|
||||
// The one that empties a medication's whole history in one request. It
|
||||
// says so, because "removed several doses" would read as a correction.
|
||||
case "medication.intake.purge":
|
||||
return t("recordSharing.activityVerb.medicationIntakePurge", { name });
|
||||
case "medication.intake.import.kickoff":
|
||||
return t("recordSharing.activityVerb.medicationIntakeImport", { name });
|
||||
case "medication.intake.history.import.kickoff":
|
||||
return t("recordSharing.activityVerb.medicationIntakeHistoryImport", {
|
||||
name,
|
||||
});
|
||||
|
||||
case "moodEntry.create":
|
||||
return t("recordSharing.activityVerb.moodEntryCreate", { name });
|
||||
case "moodEntry.update":
|
||||
@@ -477,3 +424,75 @@ function refusedAttemptVerb(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentence for a change to a medication the owner made (the MANAGE verbs
|
||||
* of the medications section), or null when `action` is none of them. Asked
|
||||
* by `recordActivityVerbLine` after the refusals.
|
||||
*/
|
||||
function medicationManageVerb(
|
||||
t: Translator,
|
||||
action: string,
|
||||
name: string,
|
||||
): string | null {
|
||||
switch (action) {
|
||||
case "medication.update":
|
||||
return t("recordSharing.activityVerb.medicationUpdate", { name });
|
||||
case "medication.course.end":
|
||||
return t("recordSharing.activityVerb.medicationCourseEnd", { name });
|
||||
case "medication.category.custom.create":
|
||||
return t("recordSharing.activityVerb.medicationCategoryCreate", {
|
||||
name,
|
||||
});
|
||||
case "medication.delete":
|
||||
return t("recordSharing.activityVerb.medicationDelete", { name });
|
||||
case "medication.ingredients.update":
|
||||
return t("recordSharing.activityVerb.medicationIngredientsUpdate", {
|
||||
name,
|
||||
});
|
||||
case "medication.glp1.update":
|
||||
return t("recordSharing.activityVerb.medicationGlp1Update", { name });
|
||||
case "medication.schedule_revision.created":
|
||||
return t("recordSharing.activityVerb.medicationScheduleCreate", { name });
|
||||
case "medication.schedule_revision.updated":
|
||||
return t("recordSharing.activityVerb.medicationScheduleUpdate", { name });
|
||||
case "medication.schedule_revision.deleted":
|
||||
return t("recordSharing.activityVerb.medicationScheduleDelete", { name });
|
||||
case "medication.inventory.create":
|
||||
return t("recordSharing.activityVerb.medicationInventoryCreate", {
|
||||
name,
|
||||
});
|
||||
case "medication.inventory.update":
|
||||
return t("recordSharing.activityVerb.medicationInventoryUpdate", {
|
||||
name,
|
||||
});
|
||||
case "medication.inventory.delete":
|
||||
return t("recordSharing.activityVerb.medicationInventoryDelete", {
|
||||
name,
|
||||
});
|
||||
case "medication.sideEffect.delete":
|
||||
return t("recordSharing.activityVerb.medicationSideEffectDelete", {
|
||||
name,
|
||||
});
|
||||
case "medication.intake.update":
|
||||
return t("recordSharing.activityVerb.medicationIntakeUpdate", { name });
|
||||
case "medication.intake.delete":
|
||||
return t("recordSharing.activityVerb.medicationIntakeDelete", { name });
|
||||
case "medication.intake.bulk_delete":
|
||||
return t("recordSharing.activityVerb.medicationIntakeBulkDelete", {
|
||||
name,
|
||||
});
|
||||
// The one that empties a medication's whole history in one request. It
|
||||
// says so, because "removed several doses" would read as a correction.
|
||||
case "medication.intake.purge":
|
||||
return t("recordSharing.activityVerb.medicationIntakePurge", { name });
|
||||
case "medication.intake.import.kickoff":
|
||||
return t("recordSharing.activityVerb.medicationIntakeImport", { name });
|
||||
case "medication.intake.history.import.kickoff":
|
||||
return t("recordSharing.activityVerb.medicationIntakeHistoryImport", {
|
||||
name,
|
||||
});
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -265,3 +265,27 @@ export function touchGrantUsage(
|
||||
.then(() => undefined)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the request resolved under `grantId` may `need` over `domain` —
|
||||
* the question a READ answers when it publishes an action the caller may or
|
||||
* may not be allowed to take (`canEndCourse` on the medication reads).
|
||||
*
|
||||
* `null` is the record's owner, who may do everything the record allows. A
|
||||
* grant is re-read here rather than trusted from the resolver because the
|
||||
* resolver only proved the level the READ route declared; the action the
|
||||
* read advertises is declared on another route at another level, and the
|
||||
* answer has to be the one that route would give.
|
||||
*/
|
||||
export async function grantIdAllows(
|
||||
grantId: string | null,
|
||||
need: GrantNeed,
|
||||
domain: ShareScope,
|
||||
db: GrantDb = prisma,
|
||||
now: Date = new Date(),
|
||||
): Promise<boolean> {
|
||||
if (grantId === null) return true;
|
||||
const grant = await db.accountGrant.findUnique({ where: { id: grantId } });
|
||||
if (!grant) return false;
|
||||
return grantAllows(grant, need, now) && grantCoversDomain(grant, domain);
|
||||
}
|
||||
Reference in new issue
Block a user