mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge remote-tracking branch 'origin/fix/delegated-fix2' into work/sharing-write-integration
This commit is contained in:
commit
b817a2d47e
25 files changed
+956
-204
No files matched your search
@@ -9,19 +9,22 @@
|
||||
*
|
||||
* ## Why it gates itself instead of seeding a WRITE grant directly
|
||||
*
|
||||
* The grant level is chosen in the invitation form, which is another chunk's
|
||||
* work. Rather than mint a WRITE row behind the UI's back — which would prove
|
||||
* the journey works for a grant no person can create — the journey looks for
|
||||
* the level control and stands down when it is not there yet.
|
||||
* The grant level is chosen in the invitation form. Rather than mint a WRITE
|
||||
* row behind the UI's back — which would prove the journey works for a grant
|
||||
* no person can create — the journey looks for the level control and stands
|
||||
* down when it is not there yet.
|
||||
*
|
||||
* **To enable this once the invite form ships its level control:** if it lands
|
||||
* under a different `data-slot` than the constant below, change that one
|
||||
* string. Nothing else in this file assumes anything about the control except
|
||||
* that picking WRITE and submitting mints a WRITE grant.
|
||||
* That guard was written against a `data-slot` the form never shipped under
|
||||
* (`grant-invite-level`; the control landed as `grant-invite-access-option`),
|
||||
* so from the day the control arrived until 2026-08-03 every test in this file
|
||||
* skipped and the whole delegated-write journey ran nowhere. The file said out
|
||||
* loud what to change and nobody changed it, which is the standing lesson
|
||||
* about a check that cannot fail: the skip is quiet, and a quiet skip and a
|
||||
* passing suite look identical in a CI summary.
|
||||
*
|
||||
* The skip is deliberately loud rather than silent: it names the missing
|
||||
* control, so a run where the control exists and the journey still does not
|
||||
* execute reads as a bug in this file rather than as an absence upstream.
|
||||
* The constant is the real one now. If it moves again, change that one string:
|
||||
* nothing else here assumes anything about the control except that choosing
|
||||
* WRITE and submitting mints a WRITE grant.
|
||||
*
|
||||
* ## What this spec cannot cover
|
||||
*
|
||||
@@ -45,10 +48,10 @@ import {
|
||||
* The invitation form's grant-level control. The journey runs when this is on
|
||||
* the page and stands down when it is not. One string, one place.
|
||||
*/
|
||||
const GRANT_LEVEL_SLOT = "grant-invite-level";
|
||||
const GRANT_LEVEL_SLOT = "grant-invite-access-option";
|
||||
|
||||
/** The value the level control carries for a grant that may add entries. */
|
||||
const WRITE_LEVEL_VALUE = "write";
|
||||
const WRITE_LEVEL_VALUE = "WRITE";
|
||||
|
||||
test.describe("delegated writes", () => {
|
||||
// One journey in order, like the read-only sibling: each step is the next
|
||||
@@ -110,9 +113,11 @@ test.describe("delegated writes", () => {
|
||||
await expect(submit).toBeEnabled({ timeout: 1000 });
|
||||
}).toPass({ timeout: 15_000 });
|
||||
|
||||
await ownerPage
|
||||
.locator(`[data-slot="${GRANT_LEVEL_SLOT}"]`)
|
||||
.selectOption(WRITE_LEVEL_VALUE);
|
||||
const writeOption = ownerPage.locator(
|
||||
`[data-slot="${GRANT_LEVEL_SLOT}"][data-access="${WRITE_LEVEL_VALUE}"]`,
|
||||
);
|
||||
await writeOption.click();
|
||||
await expect(writeOption).toHaveAttribute("data-selected", "true");
|
||||
|
||||
// Read the posted body: a level control that renders and sends a hardcoded
|
||||
// level would pass every render assertion and ship a read-only grant.
|
||||
@@ -125,7 +130,7 @@ test.describe("delegated writes", () => {
|
||||
access?: string;
|
||||
};
|
||||
expect(
|
||||
posted.access?.toLowerCase(),
|
||||
posted.access,
|
||||
"the invitation must carry the level the owner chose",
|
||||
).toBe(WRITE_LEVEL_VALUE);
|
||||
});
|
||||
@@ -162,8 +167,14 @@ test.describe("delegated writes", () => {
|
||||
res.request().method() === "POST" &&
|
||||
res.url().endsWith("/api/measurements"),
|
||||
);
|
||||
await page.getByRole("button", { name: /add measurement/i }).click();
|
||||
await page.locator('input[name="value"], #value').first().fill("71.5");
|
||||
// Stable attributes and the form's real fields, neither of which this
|
||||
// step had. It looked for a button named "Add measurement" (the header
|
||||
// reads "Add") and then for a `value` input (the form opens on blood
|
||||
// pressure, which has three). Both were wrong from the day they were
|
||||
// written and nobody found out, because the whole file was skipping.
|
||||
await page.locator('[data-slot="measurement-add"]').click();
|
||||
await page.locator("#sys").fill("124");
|
||||
await page.locator("#dia").fill("78");
|
||||
await page.getByRole("button", { name: /^save$/i }).click();
|
||||
expect((await post).status(), "the write must be accepted").toBeLessThan(
|
||||
300,
|
||||
@@ -180,6 +191,28 @@ test.describe("delegated writes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("a deep link opens exactly what the level admits", async ({ page }) => {
|
||||
// The gate binds to the level the server resolved, not to a blanket
|
||||
// "somebody else's record" flag. Both halves matter and only a browser
|
||||
// can show either: the SSR suite holds a component's paint, never a URL.
|
||||
//
|
||||
// Admitted: entering a reading, so `?add=` opens the same sheet the
|
||||
// header button opens.
|
||||
await page.goto("/measurements?add=WEIGHT");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="responsive-sheet-content"]').first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Also admitted: adding a medication with its schedule.
|
||||
await page.goto("/medications?new=1");
|
||||
await expect(
|
||||
page.locator('[data-slot="medication-wizard-dialog"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("the owner sees that somebody else was in their record", async () => {
|
||||
await ownerPage.goto("/settings/access");
|
||||
const rows = ownerPage.locator('[data-slot="record-activity-row"]');
|
||||
|
||||
@@ -428,11 +428,11 @@ const DELEGABLE_ROUTES: Record<string, string> = {
|
||||
"app/api/biomarkers/[id]/route.ts":
|
||||
"One biomarker of the record, fetch-then-guard against the resolved user.",
|
||||
"app/api/allergies/route.ts":
|
||||
"The record's allergy list — the single most useful thing a caregiver can read, and a plain list of the owner's own rows.",
|
||||
"The record's allergy list — the single most useful thing a caregiver can read, and a plain list of the owner's own rows. The POST beside it is NOT delegable and is deliberately absent from the write literal below: the only surface that posts to it lives under `/settings`, which a switch closes, so admitting the write would freeze a permission ahead of any caller for it. The route comment carries the argument.",
|
||||
"app/api/allergies/[id]/route.ts":
|
||||
"One allergy of the record, fetch-then-guard against the resolved user.",
|
||||
"app/api/family-history/route.ts":
|
||||
"The record's family history. The payload describes the owner's relatives, so it is the one admitted read where third-party health information is present by design rather than by accident; a caregiver reading it is the use the feature exists for, and the row is stored as the owner's.",
|
||||
"The record's family history. The payload describes the owner's relatives, so it is the one admitted read where third-party health information is present by design rather than by accident; a caregiver reading it is the use the feature exists for, and the row is stored as the owner's. Its POST is not delegable, for the reason its allergy sibling gives plus one of its own — see the route comment.",
|
||||
"app/api/family-history/[id]/route.ts":
|
||||
"One family-history entry of the record, fetch-then-guard against the resolved user.",
|
||||
"app/api/mental-health/assessments/route.ts":
|
||||
@@ -512,6 +512,17 @@ const DELEGABLE_ROUTES: Record<string, string> = {
|
||||
* thirty-one read-only delegable modules do not do, and it is the difference
|
||||
* the identifier matcher above cannot see.
|
||||
*
|
||||
* v1.36.x — `POST /api/allergies` and `POST /api/family-history` left this
|
||||
* list, and the removal is worth reading before either is proposed again. The
|
||||
* argument for admitting them was never wrong; what they lacked was a caller.
|
||||
* The only surface in the product that posts to either lives in Settings →
|
||||
* Anamnese, and a switch closes `/settings` — so a delegate could not reach
|
||||
* the form at any grant level, and the two entries were a permission frozen
|
||||
* ahead of the surface that would exercise it. Both delegable READ arms stay:
|
||||
* a caregiver reading the allergy list is what the feature is for. Whoever
|
||||
* builds a caregiver-reachable medical-history surface adds them back in the
|
||||
* same diff, which is the two-ended change this list is meant to hold to.
|
||||
*
|
||||
* Every member also has to call `auditLog`, asserted below. That is not a
|
||||
* stylistic preference. The decision not to add a `writtenBy` column to eleven
|
||||
* tables rests entirely on the audit trail carrying the actor, and `auditLog`
|
||||
@@ -528,10 +539,6 @@ const DELEGABLE_WRITE_ROUTES: Record<string, string> = {
|
||||
"Entering a lab result. The free-text path may mint a biomarker, and mints it into the RECORD's catalogue — a result added to somebody's record that left the marker on the helper's account would be worse than useless to the owner.",
|
||||
"app/api/biomarkers/route.ts":
|
||||
"Adding an analyte to the record's catalogue. A name the record already tracks is the ordinary 409 from the same `(userId, name)` uniqueness the owner would hit themselves.",
|
||||
"app/api/allergies/route.ts":
|
||||
"Adding an allergy. The cleanest admission in the set: a plain statement about the record's own body, and the single most useful thing a caregiver can contribute.",
|
||||
"app/api/family-history/route.ts":
|
||||
"Adding a family-history entry. Third-party health data by design — the row describes the record's relatives — and it is stored as the record's own, which is exactly how the delegable read arm already serves it.",
|
||||
"app/api/illness/episodes/route.ts":
|
||||
"Opening an illness episode. The module gate runs against the RECORD before the write, so a delegate cannot create an episode inside a record whose owner switched the module off.",
|
||||
"app/api/custom-metrics/[id]/entries/route.ts":
|
||||
@@ -569,7 +576,7 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* stay 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 = 57;
|
||||
const FROZEN_ENTRY_COUNT = 55;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -256,7 +256,9 @@ const PINNED_AFFORDANCES: Record<
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/medications/take-all-due.ts": { "toast.success": 1 },
|
||||
"src/components/medications/use-medication-intake.ts": { "toast.success": 5 },
|
||||
// v1.36.x — one fewer: the log-intake path's three-armed toast collapsed
|
||||
// into the shared `intakeToastOptions` decision plus a single call pair.
|
||||
"src/components/medications/use-medication-intake.ts": { "toast.success": 4 },
|
||||
"src/components/medications/wizard/medication-wizard-dialog.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } 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 {
|
||||
@@ -67,10 +67,37 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAllergy));
|
||||
|
||||
async function postAllergy(request: NextRequest): Promise<Response> {
|
||||
// v1.36.x — a delegated write, and the cleanest of them: the row is a plain
|
||||
// statement about the record's own body, and the caller appears in the audit
|
||||
// trail rather than in the row.
|
||||
const { user } = await requireRecordAuth("write");
|
||||
// v1.36.x — the GET above is delegable and this is not, which is the
|
||||
// opposite of where the classification landed and worth the paragraph.
|
||||
//
|
||||
// Nothing about the row changed: it is still a plain statement about the
|
||||
// record's own body, still the single most useful thing a caregiver could
|
||||
// contribute, and the argument for admitting it still holds. What it does
|
||||
// not have is a caller. The only place in the product that posts here is the
|
||||
// allergy manager in Settings → Anamnese, and `/settings/*` is not a
|
||||
// shared-record destination — the shell shows the "not part of what was
|
||||
// shared" panel there, so no delegate can reach the form at any level.
|
||||
//
|
||||
// An admitted write with no reachable surface is the one-ended change this
|
||||
// repository keeps rediscovering (CLAUDE.md, "A two-ended change carries
|
||||
// both ends"): the permission ships, the consumer is the follow-up, and
|
||||
// nothing in the gate notices because every other check proves the other
|
||||
// end. The frozen list is built the other way round on purpose — its own
|
||||
// actor-surface note says the rest "arrive as their own diffs; naming them
|
||||
// before they exist would freeze a guess."
|
||||
//
|
||||
// So this arm waits for the surface that would exercise it, and the two
|
||||
// land together. Choosing that surface is design work, not a fix: allergies
|
||||
// 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
|
||||
// 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();
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } 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 {
|
||||
@@ -62,11 +62,22 @@ export const POST = apiHandler(
|
||||
);
|
||||
|
||||
async function postFamilyHistory(request: NextRequest): Promise<Response> {
|
||||
// v1.36.x — a delegated write, and the one where third-party health data is
|
||||
// present by design: the row describes the record's relatives. It is stored
|
||||
// as the record's own, exactly as the delegable READ arm above already
|
||||
// serves it, and the audit trail names who entered it.
|
||||
const { user } = await requireRecordAuth("write");
|
||||
// v1.36.x — not a delegated write, though the GET above is delegable. The
|
||||
// reasoning is written out at the sibling arm in `api/allergies/route.ts`
|
||||
// and is the same here: the only surface that posts to either route is the
|
||||
// manager in Settings → Anamnese, and `/settings/*` is closed inside a
|
||||
// shared record, so no delegate can reach the form at any level. An
|
||||
// admitted write with no reachable caller is a permission frozen ahead of
|
||||
// the surface that would exercise it.
|
||||
//
|
||||
// This arm carried an extra reason to wait. The row describes the record's
|
||||
// RELATIVES, so it is the one admitted write where third-party health
|
||||
// information is present by design — and frequently the delegate is that
|
||||
// relative, asserting a condition about themselves into somebody else's
|
||||
// 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();
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -157,6 +157,7 @@ export default function MeasurementsPage() {
|
||||
actions={
|
||||
canAdd ? (
|
||||
<Button
|
||||
data-slot="measurement-add"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => {
|
||||
setReturnTo(null);
|
||||
@@ -170,8 +171,16 @@ export default function MeasurementsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* v1.36.x — the sheet answers the same question the header button
|
||||
answers. `?add=<TYPE>` opens it without passing the button, and a
|
||||
deep link is the same affordance as the control that produces it, so
|
||||
it gets the same gate. Gating the open rather than only the param
|
||||
also covers the first-paint window: `canAdd` reads true until
|
||||
`/api/auth/me` settles, and a sheet opened in that frame withdraws
|
||||
when the answer lands instead of standing on a form the server
|
||||
refuses. */}
|
||||
<ResponsiveSheet
|
||||
open={dialogOpen}
|
||||
open={dialogOpen && canAdd}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) {
|
||||
|
||||
@@ -617,8 +617,11 @@ export default function MedicationsPageClient() {
|
||||
"Vollständig bearbeiten" reopens the wizard in edit mode from
|
||||
there); the list page wizard only ever creates. The wizard owns
|
||||
its own ResponsiveSheet shell with the sticky footer. */}
|
||||
{/* v1.36.x — `?new=1` (the retired `/medications/new` route redirects
|
||||
here) opens the create wizard without passing the gated Add control,
|
||||
so the wizard asks the same `canAdd` the control asks. */}
|
||||
<MedicationWizardDialog
|
||||
open={dialogOpen}
|
||||
open={dialogOpen && canAdd}
|
||||
onOpenChange={setDialogOpen}
|
||||
mode="create"
|
||||
onSuccess={closeDialog}
|
||||
|
||||
@@ -18,9 +18,16 @@
|
||||
* read-only legs for the intake row and the card menu go red.
|
||||
* - `DeleteButton` dropping its `canManage` bail → "no row delete inside
|
||||
* somebody else's record" goes red.
|
||||
* - `TodayHero` passing `onDismiss` / `onAction` unconditionally → the rail
|
||||
* legs go red, printing the dismiss control and the check-in buttons.
|
||||
* - `VorsorgeDashboardCard` dropping its `canManage` bail → the mark-done
|
||||
* leg goes red.
|
||||
* - `EpisodeDocumentsCard` dropping its `canManage` bail → the link +
|
||||
* upload leg goes red.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AccountAccess } from "@/lib/sharing/account-access-view";
|
||||
@@ -59,7 +66,9 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
email: null,
|
||||
role: "USER",
|
||||
avatarUrl: null,
|
||||
modules: {},
|
||||
// The episode-documents card renders only for an account with the
|
||||
// vault switched on; every other surface here ignores the map.
|
||||
modules: { inboundDocuments: true },
|
||||
accountAccess: mockAccessRef.value,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
@@ -68,16 +77,56 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// The Vorsorge summary pushes to the check-in page for a screening reminder.
|
||||
// Nothing below clicks, so a stub router is enough to let it mount.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
// Never resolves inside the synchronous render, so every query below stays
|
||||
// pending and each component paints its own loading branch. The controls this
|
||||
// file is about live outside that branch.
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiPost: () => new Promise(() => {}),
|
||||
apiPut: () => new Promise(() => {}),
|
||||
apiPatch: () => new Promise(() => {}),
|
||||
apiDelete: () => new Promise(() => {}),
|
||||
}));
|
||||
|
||||
import { DeleteButton } from "@/components/data-list/delete-button";
|
||||
import { SelectionActionBar } from "@/components/data-list/selection-action-bar";
|
||||
import { MedicationCardMenu } from "@/components/medications/medication-card-menu";
|
||||
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
|
||||
import { visibleCaptureKinds } from "@/components/layout/capture-picker";
|
||||
import { TodayHero } from "@/components/daily/today-hero";
|
||||
import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card";
|
||||
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { DailyDigest } from "@/lib/daily/digest";
|
||||
import type { MeasurementReminder } from "@/hooks/use-measurement-reminders";
|
||||
|
||||
function render(access: AccountAccess, node: React.ReactNode): string {
|
||||
function render(
|
||||
access: AccountAccess,
|
||||
node: React.ReactNode,
|
||||
/**
|
||||
* Seed the cache a query-backed component reads, so it paints its populated
|
||||
* branch inside a synchronous render instead of its skeleton. Cheaper and
|
||||
* truer than mocking the hook: the component under test is the real one.
|
||||
*/
|
||||
seed?: (client: QueryClient) => void,
|
||||
): string {
|
||||
mockAccessRef.value = access;
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
seed?.(queryClient);
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -167,6 +216,143 @@ describe("the medication card menu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* The Today rail */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const DIGEST: DailyDigest = {
|
||||
generatedAt: "2026-08-03T06:00:00.000Z",
|
||||
phase: "final",
|
||||
sleepPending: false,
|
||||
score: null,
|
||||
topSignal: null,
|
||||
briefingLead: "A steady week so far.",
|
||||
line: "A steady week so far.",
|
||||
justIn: null,
|
||||
reactionLine: null,
|
||||
worthALook: [
|
||||
{
|
||||
kind: "milestone",
|
||||
itemKey: "milestone:steps-10k",
|
||||
title: "Ten thousand steps",
|
||||
actions: [],
|
||||
},
|
||||
{
|
||||
kind: "coach_checkin",
|
||||
title: "Still worth keeping?",
|
||||
actions: [
|
||||
{
|
||||
labelKey: "daily.action.checkinKeep",
|
||||
intent: "coach.plan.keep:plan-1",
|
||||
},
|
||||
{
|
||||
labelKey: "daily.action.viewCheckups",
|
||||
intent: "checkup.view",
|
||||
href: "/checkups",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("the Today rail's mutating affordances", () => {
|
||||
const node = <TodayHero digest={DIGEST} />;
|
||||
|
||||
it("offers the dismiss and the check-in answer in the caller's own record", () => {
|
||||
const html = render(OWN_RECORD, node);
|
||||
expect(html).toContain('data-slot="priority-card-dismiss"');
|
||||
expect(html).toContain("Keep it");
|
||||
});
|
||||
|
||||
it("withholds both inside somebody else's record, at either level", () => {
|
||||
// Dismissing an observation and answering a coach check-in are neither of
|
||||
// them an admitted create, and both write through routes that resolve the
|
||||
// CALLER — so a delegate tapping either would file it against their own
|
||||
// record if the route allowed it, and gets a 403 because it does not.
|
||||
for (const access of [READ_ONLY, WRITABLE]) {
|
||||
const html = render(access, node);
|
||||
expect(html).not.toContain('data-slot="priority-card-dismiss"');
|
||||
expect(html).not.toContain("Keep it");
|
||||
// The rail itself stays: reading what is worth a look is the point of
|
||||
// opening somebody's record, and the navigation action survives with it.
|
||||
expect(html).toContain('data-slot="today-hero-rail"');
|
||||
expect(html).toContain('href="/checkups"');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Vorsorge */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const REMINDER: MeasurementReminder = {
|
||||
id: "rem-1",
|
||||
label: "Dental check-up",
|
||||
measurementType: null,
|
||||
intervalDays: 180,
|
||||
rrule: null,
|
||||
anchorDate: null,
|
||||
endsOn: null,
|
||||
origin: "VORSORGE",
|
||||
notifyHour: 9,
|
||||
location: null,
|
||||
nextDueAt: "2026-08-10T09:00:00.000Z",
|
||||
lastSatisfiedAt: null,
|
||||
enabled: true,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("the Vorsorge summary's mark-done", () => {
|
||||
const node = <VorsorgeDashboardCard />;
|
||||
const seed = (client: QueryClient) =>
|
||||
client.setQueryData(queryKeys.measurementReminders(), [REMINDER]);
|
||||
|
||||
it("is offered in the caller's own record", () => {
|
||||
const html = render(OWN_RECORD, node, seed);
|
||||
expect(html).toContain("Dental check-up");
|
||||
expect(html).toContain("Done");
|
||||
});
|
||||
|
||||
it("is absent inside somebody else's record, at either level", () => {
|
||||
// `/checkups` already withheld this exact action; the dashboard summary
|
||||
// offering it was the inconsistency that showed nobody had asked.
|
||||
for (const access of [READ_ONLY, WRITABLE]) {
|
||||
const html = render(access, node, seed);
|
||||
expect(html).toContain("Dental check-up");
|
||||
expect(html).not.toContain("Done");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Documents on an illness episode */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("linking a document to an illness episode", () => {
|
||||
const node = <EpisodeDocumentsCard episodeId="ep-1" />;
|
||||
|
||||
it("offers the link and the upload in the caller's own record", () => {
|
||||
const html = render(OWN_RECORD, node);
|
||||
expect(html).toContain(">Link<");
|
||||
expect(html).toContain("Upload");
|
||||
});
|
||||
|
||||
it("offers neither inside somebody else's record, at either level", () => {
|
||||
// Both write through `POST /api/documents/inbound/bulk`, which the vault
|
||||
// gates on the same answer throughout — the episode side of the same link
|
||||
// had no gate at all, and the upload deep-linked to a page whose own
|
||||
// upload control is already withheld.
|
||||
for (const access of [READ_ONLY, WRITABLE]) {
|
||||
const html = render(access, node);
|
||||
expect(html).not.toContain(">Link<");
|
||||
expect(html).not.toContain("Upload");
|
||||
// The card itself stays — reading the episode's documents is a read.
|
||||
expect(html).toContain('data-slot="episode-documents-card"');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the capture picker's kinds", () => {
|
||||
const ALL = ["measurement", "medication", "mood", "water"] as const;
|
||||
|
||||
|
||||
@@ -1,13 +1,48 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AccountAccess } from "@/lib/sharing/account-access-view";
|
||||
|
||||
import {
|
||||
const OWNER = {
|
||||
accountId: "acct-owner",
|
||||
username: "owner",
|
||||
displayName: "Margarethe",
|
||||
access: "write" as const,
|
||||
canWrite: true,
|
||||
};
|
||||
|
||||
const OWN_RECORD: AccountAccess = {
|
||||
accounts: [OWNER],
|
||||
active: null,
|
||||
canSwitch: true,
|
||||
};
|
||||
|
||||
const mockAccessRef: { value: AccountAccess } = { value: OWN_RECORD };
|
||||
|
||||
// The cog asks `useRecordCapabilities`, which reads the account payload.
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "delegate",
|
||||
username: "delegate",
|
||||
email: null,
|
||||
role: "USER",
|
||||
avatarUrl: null,
|
||||
modules: {},
|
||||
accountAccess: mockAccessRef.value,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
ChartOverlayControls,
|
||||
ChartOverlayControlsBody,
|
||||
DEFAULT_CHART_OVERLAY_PREFS,
|
||||
type ChartOverlayPrefs,
|
||||
} from "../chart-overlay-controls";
|
||||
} = await import("../chart-overlay-controls");
|
||||
type ChartOverlayPrefs = import("../chart-overlay-controls").ChartOverlayPrefs;
|
||||
|
||||
/**
|
||||
* v1.4.18 — per-chart overlay-controls popover.
|
||||
@@ -48,6 +83,34 @@ describe("<ChartOverlayControls>", () => {
|
||||
expect(html).toContain('aria-label="Chart overlay settings"');
|
||||
});
|
||||
|
||||
it("is absent inside somebody else's record, at both levels", () => {
|
||||
// v1.36.x — every toggle persists through
|
||||
// `PUT /api/dashboard/chart-overlay-prefs`, which resolves the caller and
|
||||
// refuses under a switch, and the preference belongs to the person rather
|
||||
// than to the record. Absent, not disabled: a greyed cog would still
|
||||
// claim the chart is the delegate's to configure.
|
||||
//
|
||||
// Mutation check, run: dropping the `if (!canManage) return null` bail
|
||||
// from `<ChartOverlayControls>` → both legs go red on the trigger slot.
|
||||
for (const access of ["read", "write"] as const) {
|
||||
mockAccessRef.value = {
|
||||
accounts: [OWNER],
|
||||
active: { ...OWNER, access, canWrite: access === "write" },
|
||||
canSwitch: true,
|
||||
};
|
||||
const html = renderToStaticMarkup(
|
||||
withProvider(
|
||||
<ChartOverlayControls
|
||||
prefs={DEFAULT_CHART_OVERLAY_PREFS}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(html, `grant level: ${access}`).toBe("");
|
||||
}
|
||||
mockAccessRef.value = OWN_RECORD;
|
||||
});
|
||||
|
||||
it("default prefs match the clean-line baseline (every toggle OFF)", () => {
|
||||
expect(DEFAULT_CHART_OVERLAY_PREFS).toEqual({
|
||||
showTrendIndicator: false,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
COMPARISON_BASELINES,
|
||||
@@ -89,8 +90,18 @@ export function ChartOverlayControls({
|
||||
onChange,
|
||||
triggerClassName,
|
||||
hasComparisonData = true,
|
||||
}: ChartOverlayControlsProps): ReactElement {
|
||||
}: ChartOverlayControlsProps): ReactElement | null {
|
||||
const { t } = useTranslations();
|
||||
// The cog is gated here rather than at each of the eight charts that mount
|
||||
// it, so the three chart wrappers cannot drift apart on the same question.
|
||||
// Every toggle persists through `PUT /api/dashboard/chart-overlay-prefs`,
|
||||
// which resolves the caller and refuses under a switch; and the preference
|
||||
// belongs to the person reading the chart, not to the record — the same
|
||||
// reasoning that keeps the mood tag layout off the delegable list. So a
|
||||
// delegate reads the owner's chart in its stored shape and is not offered a
|
||||
// dial that would silently fail.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
if (!canManage) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -111,12 +111,38 @@ describe("<PriorityCard>", () => {
|
||||
kind: "coach_checkin",
|
||||
actions: [{ labelKey: "daily.action.logDose", intent: "dose.log" }],
|
||||
})}
|
||||
onAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("<button");
|
||||
expect(html).toContain("Log dose");
|
||||
});
|
||||
|
||||
it("omits a non-navigation action entirely when no handler is passed", () => {
|
||||
// v1.36.x — the caller withholds `onAction` inside somebody else's
|
||||
// record. The mutating half of the rail must then be absent, not an
|
||||
// inert button: `TodayHero` passes no handler at either grant level, and
|
||||
// a rendered control that calls nothing is worse than one that 403s.
|
||||
const html = render(
|
||||
<PriorityCard
|
||||
item={item({
|
||||
kind: "coach_checkin",
|
||||
actions: [
|
||||
{ labelKey: "daily.action.logDose", intent: "dose.log" },
|
||||
{
|
||||
labelKey: "daily.action.viewCheckups",
|
||||
intent: "checkup.view",
|
||||
href: "/checkups",
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain("Log dose");
|
||||
// Navigation is untouched — reading is never what a delegation withholds.
|
||||
expect(html).toContain('href="/checkups"');
|
||||
});
|
||||
|
||||
it("resolves action label keys through the active locale", () => {
|
||||
const html = render(
|
||||
<PriorityCard
|
||||
|
||||
@@ -95,6 +95,11 @@ export interface PriorityCardProps {
|
||||
* Tap handler for non-navigation actions. Receives the action's stable
|
||||
* `intent` token. Navigation actions (those carrying an `href`) render as
|
||||
* links and do not call this.
|
||||
*
|
||||
* Omitting it does not render an inert button: an action with no `href` and
|
||||
* no handler is not offered at all. A button whose only job is to call a
|
||||
* handler nobody passed is a control that says "this works" and does
|
||||
* nothing, which is the one outcome worse than a refusal.
|
||||
*/
|
||||
onAction?: (intent: string) => void;
|
||||
/**
|
||||
@@ -129,7 +134,13 @@ export function PriorityCard({
|
||||
}: PriorityCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const Icon = KIND_ICON[item.kind];
|
||||
const actions = item.actions.slice(0, 3);
|
||||
// Navigation always survives; a mutating action survives only when somebody
|
||||
// is there to run it. That is what removes the whole non-navigation half of
|
||||
// this card inside a record the caller may not change, without this file
|
||||
// learning anything about grants.
|
||||
const actions = item.actions
|
||||
.filter((action) => action.href || onAction)
|
||||
.slice(0, 3);
|
||||
const hue = KIND_HUE[item.kind];
|
||||
// S12 — the quiet "reached" moment: the milestone card swaps the generic
|
||||
// fade-in for the `.milestone-reached` treatment (a soft `--success` halo
|
||||
@@ -187,15 +198,23 @@ export function PriorityCard({
|
||||
) : null}
|
||||
{actions.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
{actions.map((action) => (
|
||||
<ActionButton
|
||||
key={action.intent}
|
||||
action={action}
|
||||
label={t(action.labelKey)}
|
||||
onAction={onAction}
|
||||
pending={actionsPending}
|
||||
/>
|
||||
))}
|
||||
{actions.map((action) =>
|
||||
action.href ? (
|
||||
<ActionLink
|
||||
key={action.intent}
|
||||
href={action.href}
|
||||
label={t(action.labelKey)}
|
||||
/>
|
||||
) : onAction ? (
|
||||
<ActionButton
|
||||
key={action.intent}
|
||||
action={action}
|
||||
label={t(action.labelKey)}
|
||||
onAction={onAction}
|
||||
pending={actionsPending}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
@@ -203,6 +222,26 @@ export function PriorityCard({
|
||||
);
|
||||
}
|
||||
|
||||
/** A navigation action. Always offered — reading is never delegated away. */
|
||||
function ActionLink({ href, label }: { href: string; label: string }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
ACTION_SIZE,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A mutating action. `onAction` is required rather than optional so the type
|
||||
* carries the rule: this button cannot be rendered without the handler that
|
||||
* makes it do something.
|
||||
*/
|
||||
function ActionButton({
|
||||
action,
|
||||
label,
|
||||
@@ -211,22 +250,9 @@ function ActionButton({
|
||||
}: {
|
||||
action: PriorityItemAction;
|
||||
label: string;
|
||||
onAction?: (intent: string) => void;
|
||||
onAction: (intent: string) => void;
|
||||
pending?: boolean;
|
||||
}) {
|
||||
if (action.href) {
|
||||
return (
|
||||
<Link
|
||||
href={action.href}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
ACTION_SIZE,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -43,6 +43,7 @@ import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
import { PriorityCard } from "@/components/daily/priority-card";
|
||||
import { useCoachCheckinAction } from "@/hooks/use-coach-checkin";
|
||||
import { usePriorityItemDismiss } from "@/hooks/use-priority-item-dismiss";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DailyDigest } from "@/lib/daily/digest";
|
||||
@@ -99,6 +100,14 @@ export function TodayHero({
|
||||
// the reason a mount gate lived here at all.
|
||||
const { keep, letGo } = useCoachCheckinAction();
|
||||
const dismissItem = usePriorityItemDismiss();
|
||||
// The rail's two mutating affordances — dismissing an observation and
|
||||
// answering a coach check-in — are neither of them an admitted create, so
|
||||
// they ask `canManage` and are absent at both grant levels. Both write
|
||||
// through routes that resolve the CALLER (`POST /api/daily/digest/dismiss`,
|
||||
// `PATCH /api/coach/plans/[id]`), which under a switch means a 403 rather
|
||||
// than a stray row; the control still goes, because an affordance the
|
||||
// server is on record refusing should not be on the page.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
|
||||
// The coach check-in card's keep / let-go intents carry the plan id after the
|
||||
// ":" (a closed two-intent allowlist); adjust is an href handled by the card
|
||||
@@ -325,8 +334,12 @@ export function TodayHero({
|
||||
<PriorityCard
|
||||
key={`${item.kind}-${i}`}
|
||||
item={item}
|
||||
onAction={handleAction}
|
||||
onDismiss={(itemKey) => dismissItem.mutate(itemKey)}
|
||||
onAction={canManage ? handleAction : undefined}
|
||||
onDismiss={
|
||||
canManage
|
||||
? (itemKey) => dismissItem.mutate(itemKey)
|
||||
: undefined
|
||||
}
|
||||
actionsPending={
|
||||
item.kind === "coach_checkin" &&
|
||||
(keep.isPending || letGo.isPending)
|
||||
|
||||
@@ -533,7 +533,12 @@ export function DocumentDetailSheet({
|
||||
<span />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{aiEnabled ? (
|
||||
{/* v1.36.x — `launch` is the one entry point that rendered
|
||||
without asking whether a drawer exists to open. It is null
|
||||
inside somebody else's record (the shell mounts no drawer
|
||||
there), and `/documents` is a shared-record destination, so
|
||||
this is the Coach button a delegate actually meets. */}
|
||||
{aiEnabled && launch ? (
|
||||
// v1.28.52 (Documents R3) — "Ask the Coach" opens the REAL
|
||||
// coach conversation scoped to this document in the SIDE
|
||||
// DRAWER (the maximize control there expands it to the full
|
||||
@@ -545,7 +550,7 @@ export function DocumentDetailSheet({
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
launch?.askCoach(null, undefined, false, doc.id);
|
||||
launch.askCoach(null, undefined, false, doc.id);
|
||||
}}
|
||||
data-slot="document-chat-open"
|
||||
aria-label={t("documents.chat.openAria")}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { ListRow } from "@/components/ui/list-row";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -46,6 +47,12 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const { user } = useAuth();
|
||||
// Linking a document to an episode writes through
|
||||
// `POST /api/documents/inbound/bulk`, the same endpoint the vault gates on
|
||||
// `canManage` throughout. The episode side of the same link had no gate at
|
||||
// all, so the vault refused what this card offered. Reading the linked
|
||||
// documents stays: the list rows and "show all" are pure navigation.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const moduleEnabled = user?.modules?.inboundDocuments === true;
|
||||
@@ -139,20 +146,28 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
<Link2 className="size-4" aria-hidden />
|
||||
{t("documents.episodeCard.link")}
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={vaultHref}>
|
||||
<Upload className="size-4" aria-hidden />
|
||||
{t("documents.episodeCard.upload")}
|
||||
</Link>
|
||||
</Button>
|
||||
{canManage ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
<Link2 className="size-4" aria-hidden />
|
||||
{t("documents.episodeCard.link")}
|
||||
</Button>
|
||||
{/* The upload affordance is a deep link into the vault with
|
||||
this episode pre-filtered. The vault's own upload control
|
||||
is `canManage`-gated, so an ungated link here would send a
|
||||
delegate to a page with nothing to press. */}
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={vaultHref}>
|
||||
<Upload className="size-4" aria-hidden />
|
||||
{t("documents.episodeCard.upload")}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{hasMore ? (
|
||||
<Button
|
||||
asChild
|
||||
@@ -169,11 +184,13 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DocumentLinkPicker
|
||||
episodeId={episodeId}
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
/>
|
||||
{canManage ? (
|
||||
<DocumentLinkPicker
|
||||
episodeId={episodeId}
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CalendarClock, CheckCircle2, Plus } from "lucide-react";
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations, useDisplayTimezone } from "@/lib/i18n/context";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -54,6 +55,12 @@ export function VorsorgeDashboardCard() {
|
||||
// phrase in, so the two screens still agree when the device sits elsewhere.
|
||||
const displayTz = useDisplayTimezone();
|
||||
const router = useRouter();
|
||||
// The same answer `/checkups` gives the same action. Satisfying a reminder
|
||||
// is not one of the verbs a delegation admits at any level
|
||||
// (`POST /api/measurement-reminders/[id]/satisfy` resolves the caller), and
|
||||
// the dashboard summary offering what the dedicated page withholds was the
|
||||
// tell that this row had never been asked.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { data: reminders, isLoading } = useMeasurementReminders();
|
||||
const { satisfy } = useMeasurementReminderMutations();
|
||||
const [now] = useState(() => Date.now());
|
||||
@@ -137,29 +144,31 @@ export function VorsorgeDashboardCard() {
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="default"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
onClick={() => onPrimaryAction(reminder)}
|
||||
disabled={satisfy.isPending}
|
||||
>
|
||||
{isLinked ? (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t(
|
||||
isScreening
|
||||
? "measurementReminders.startCheckIn"
|
||||
: "measurementReminders.captureValue",
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{t("measurementReminders.markDone")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="default"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
onClick={() => onPrimaryAction(reminder)}
|
||||
disabled={satisfy.isPending}
|
||||
>
|
||||
{isLinked ? (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t(
|
||||
isScreening
|
||||
? "measurementReminders.startCheckIn"
|
||||
: "measurementReminders.captureValue",
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{t("measurementReminders.markDone")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
</ListRow>
|
||||
);
|
||||
|
||||
@@ -983,21 +983,28 @@ function VorsorgeCard({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={onPrimaryAction}
|
||||
disabled={busy}
|
||||
>
|
||||
{isLinked
|
||||
? t(
|
||||
isScreening
|
||||
? "measurementReminders.startCheckIn"
|
||||
: "measurementReminders.measureNow",
|
||||
)
|
||||
: t("measurementReminders.markDone")}
|
||||
</Button>
|
||||
{/* The same gate the cards branch applies through
|
||||
`primaryButton`. The list branch used to inline its own
|
||||
ungated copy, and which branch a person sees is a per-browser
|
||||
preference that survives the switch — so the identical action
|
||||
was offered or withheld depending on a view toggle. */}
|
||||
{canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={onPrimaryAction}
|
||||
disabled={busy}
|
||||
>
|
||||
{isLinked
|
||||
? t(
|
||||
isScreening
|
||||
? "measurementReminders.startCheckIn"
|
||||
: "measurementReminders.measureNow",
|
||||
)
|
||||
: t("measurementReminders.markDone")}
|
||||
</Button>
|
||||
) : null}
|
||||
{headerActions}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
intakeToastOptions,
|
||||
runLogIntake,
|
||||
runRecordIntake,
|
||||
runUndoIntake,
|
||||
@@ -475,3 +476,67 @@ describe("runUndoIntake — shared soft-delete", () => {
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.36.x — the one decision every intake surface shares.
|
||||
*
|
||||
* Three surfaces record a dose and all three showed the same success toast:
|
||||
* the cards, the log-intake dialog, and the dose-history ledger. Two of them
|
||||
* learned to name the record and drop Undo inside somebody else's; the ledger
|
||||
* built its own copy of the ternary and learned neither, so a delegate met a
|
||||
* dead Undo on every dose they marked. This is the copy that is left.
|
||||
*
|
||||
* Whole objects rather than field probes: when one person's record is being
|
||||
* separated from another's, a failure should print what would have been shown
|
||||
* rather than `false !== true`.
|
||||
*
|
||||
* Mutation check, run: making `intakeToastOptions` ignore `recordName` → the
|
||||
* two shared-record legs go red, printing the Undo action they returned.
|
||||
*/
|
||||
describe("intakeToastOptions — the shared toast decision", () => {
|
||||
const onUndo = vi.fn();
|
||||
|
||||
it("carries an Undo in the caller's own record", () => {
|
||||
expect(
|
||||
intakeToastOptions({ recordName: null, eventId: "evt-1", t, onUndo }),
|
||||
).toEqual({
|
||||
action: {
|
||||
label: "medications.intakeUndo",
|
||||
onClick: expect.any(Function),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("names the record and offers no Undo inside somebody else's", () => {
|
||||
expect(
|
||||
intakeToastOptions({
|
||||
recordName: "Margarethe",
|
||||
eventId: "evt-1",
|
||||
t,
|
||||
onUndo,
|
||||
}),
|
||||
).toEqual({ description: "recordSharing.toast.savedTo:Margarethe" });
|
||||
});
|
||||
|
||||
it("still names the record when the write returned no event id", () => {
|
||||
// The two arms are not alternatives: the receipt is owed either way, and
|
||||
// an absent event id must not fall through to a bare "Saved".
|
||||
expect(
|
||||
intakeToastOptions({
|
||||
recordName: "Margarethe",
|
||||
eventId: undefined,
|
||||
t,
|
||||
onUndo,
|
||||
}),
|
||||
).toEqual({ description: "recordSharing.toast.savedTo:Margarethe" });
|
||||
});
|
||||
|
||||
it("carries nothing when there is neither a record to name nor an undo", () => {
|
||||
expect(
|
||||
intakeToastOptions({ recordName: null, eventId: undefined, t, onUndo }),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
intakeToastOptions({ recordName: null, eventId: "evt-1", t }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import type {
|
||||
MedicationEfficacyDTO,
|
||||
@@ -388,6 +389,11 @@ function RetargetControl({
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
// Repointing what a medication is judged against rewrites a setting the
|
||||
// owner chose — not an admitted create, and `PUT .../efficacy/target`
|
||||
// resolves the caller, so it is refused at both grant levels. The Wirkung
|
||||
// tab itself stays readable; only the dial goes.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const [value, setValue] = useState<string>("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
@@ -437,7 +443,7 @@ function RetargetControl({
|
||||
}
|
||||
};
|
||||
|
||||
if (items.length === 0) return null;
|
||||
if (!canManage || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -893,8 +893,12 @@ export function MedicationDetailTabs({
|
||||
{/* Structural edit — the wizard stays the name / class / cadence-kind
|
||||
/ schedule tool. Hydrates from the medication payload in edit
|
||||
mode; closing it leaves the user on the same tab. */}
|
||||
{/* v1.36.x — `?edit=1` opens this wizard straight from the list kebab.
|
||||
Correcting an existing medication is refused at both grant levels
|
||||
(`PUT /api/medications/[id]` is not delegable), so the deep link is
|
||||
gated exactly like the hero button it stands in for. */}
|
||||
<MedicationWizardDialog
|
||||
open={editOpen}
|
||||
open={editOpen && canManage}
|
||||
onOpenChange={setEditOpen}
|
||||
mode="edit"
|
||||
initial={payload}
|
||||
|
||||
@@ -39,7 +39,10 @@
|
||||
* the ledger uses the semantic feedback vocabulary on the row, not the card.
|
||||
*/
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
useActiveRecordName,
|
||||
useRecordCapabilities,
|
||||
} from "@/hooks/use-record-capabilities";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -91,7 +94,10 @@ import {
|
||||
refetchInactiveDailyReads,
|
||||
} from "@/lib/query-keys";
|
||||
import type { DoseHistoryStatus } from "@/lib/medications/scheduling/dose-history";
|
||||
import { runUndoIntake } from "@/components/medications/use-medication-intake";
|
||||
import {
|
||||
intakeToastOptions,
|
||||
runUndoIntake,
|
||||
} from "@/components/medications/use-medication-intake";
|
||||
import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog";
|
||||
import { LedgerAddDialog } from "@/components/medications/dose-history-add-dialog";
|
||||
import {
|
||||
@@ -167,6 +173,12 @@ export function DoseHistoryLedger({
|
||||
// inline "mark taken" do. Editing, deleting, pinning and un-skipping an
|
||||
// existing event are the owner's, so the row menu is absent for them.
|
||||
const { canAdd } = useRecordCapabilities();
|
||||
// The record this ledger's writes land in, when it is not the caller's own.
|
||||
// The two sibling intake paths in `use-medication-intake.ts` already drop
|
||||
// Undo and name the record; this third one builds its own toast and missed
|
||||
// both, so a delegate met an Undo the server refuses on every dose they
|
||||
// marked — the most-encountered of the set.
|
||||
const recordName = useActiveRecordName();
|
||||
const queryClient = useQueryClient();
|
||||
const timeZone = user?.timezone || "Europe/Berlin";
|
||||
|
||||
@@ -282,20 +294,20 @@ export function DoseHistoryLedger({
|
||||
: "medications.intakeToastTaken",
|
||||
{ name: medicationName },
|
||||
),
|
||||
eventId
|
||||
? {
|
||||
action: {
|
||||
label: t("medications.intakeUndo"),
|
||||
onClick: () =>
|
||||
void runUndoIntake({
|
||||
medication: { id: medicationId, name: medicationName },
|
||||
eventId,
|
||||
t,
|
||||
queryClient,
|
||||
}),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
// The shared decision, not a fourth copy of it: name the record it
|
||||
// landed in, and withhold an Undo the server would refuse there.
|
||||
intakeToastOptions({
|
||||
recordName,
|
||||
eventId,
|
||||
t,
|
||||
onUndo: (id) =>
|
||||
void runUndoIntake({
|
||||
medication: { id: medicationId, name: medicationName },
|
||||
eventId: id,
|
||||
t,
|
||||
queryClient,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await invalidateKeys(queryClient, [
|
||||
...medicationDependentKeys,
|
||||
@@ -314,7 +326,15 @@ export function DoseHistoryLedger({
|
||||
setMarking(null);
|
||||
}
|
||||
},
|
||||
[marking, medicationId, medicationName, queryClient, queryKey, t],
|
||||
[
|
||||
marking,
|
||||
medicationId,
|
||||
medicationName,
|
||||
queryClient,
|
||||
queryKey,
|
||||
recordName,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,6 +29,54 @@ interface MedicationIntakeIdentity {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What rides alongside a successful intake toast — and the one place that
|
||||
* decides it.
|
||||
*
|
||||
* Three surfaces record a dose: the medication cards through
|
||||
* {@link runRecordIntake}, the log-intake dialog through {@link runLogIntake},
|
||||
* and the dose-history ledger, which builds its own POST because it also has
|
||||
* an optimistic cache patch to make. All three showed the same success toast
|
||||
* and all three had to reach the same two conclusions:
|
||||
*
|
||||
* - inside somebody else's record, name the record. "Saved" alone is the one
|
||||
* confirmation a person acting for somebody else does not need.
|
||||
* - and drop Undo there. A delegate may record a dose and may not remove
|
||||
* one, so an Undo they can see is an Undo the server refuses.
|
||||
*
|
||||
* Two of the three learned that; the ledger did not, which is a delegate
|
||||
* meeting a dead Undo on every dose they mark. Three copies of one ternary is
|
||||
* how that happens, so there is one now.
|
||||
*/
|
||||
export function intakeToastOptions(input: {
|
||||
/** The record the dose landed in, or null in the caller's own. */
|
||||
recordName: string | null | undefined;
|
||||
/** The created event, when the POST returned one. */
|
||||
eventId: string | undefined;
|
||||
t: Translator;
|
||||
/** Reverse the write. Omitted where the caller offers no undo at all. */
|
||||
onUndo?: (eventId: string) => void;
|
||||
}):
|
||||
| { description: string }
|
||||
| { action: { label: string; onClick: () => void } }
|
||||
| undefined {
|
||||
const { recordName, eventId, t, onUndo } = input;
|
||||
if (recordName) {
|
||||
return {
|
||||
description: t("recordSharing.toast.savedTo", { name: recordName }),
|
||||
};
|
||||
}
|
||||
if (eventId && onUndo) {
|
||||
return {
|
||||
action: {
|
||||
label: t("medications.intakeUndo"),
|
||||
onClick: () => onUndo(eventId),
|
||||
},
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.12.2 — the take / skip + Undo intake orchestration shared by the
|
||||
* generic {@link MedicationCard} and the {@link Glp1MedicationCard}.
|
||||
@@ -146,20 +194,12 @@ export async function runRecordIntake(deps: {
|
||||
: "medications.intakeToastTaken",
|
||||
{ name: medication.name },
|
||||
),
|
||||
recordName
|
||||
? {
|
||||
description: t("recordSharing.toast.savedTo", {
|
||||
name: recordName,
|
||||
}),
|
||||
}
|
||||
: eventId
|
||||
? {
|
||||
action: {
|
||||
label: t("medications.intakeUndo"),
|
||||
onClick: () => void undoIntake(eventId),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
intakeToastOptions({
|
||||
recordName,
|
||||
eventId,
|
||||
t,
|
||||
onUndo: (id) => void undoIntake(id),
|
||||
}),
|
||||
);
|
||||
await invalidateMedicationReads(queryClient);
|
||||
onRecorded?.(eventId, skipped);
|
||||
@@ -248,17 +288,14 @@ export async function runLogIntake(deps: {
|
||||
// a real Undo action to attach; keeps the no-undo call signature
|
||||
// identical to the pre-fix behaviour (existing unit tests assert the
|
||||
// single-argument call).
|
||||
if (recordName) {
|
||||
toast.success(message, {
|
||||
description: t("recordSharing.toast.savedTo", { name: recordName }),
|
||||
});
|
||||
} else if (eventId && undoIntake) {
|
||||
toast.success(message, {
|
||||
action: {
|
||||
label: t("medications.intakeUndo"),
|
||||
onClick: () => void undoIntake(eventId),
|
||||
},
|
||||
});
|
||||
const options = intakeToastOptions({
|
||||
recordName,
|
||||
eventId,
|
||||
t,
|
||||
onUndo: undoIntake ? (id) => void undoIntake(id) : undefined,
|
||||
});
|
||||
if (options) {
|
||||
toast.success(message, options);
|
||||
} else {
|
||||
toast.success(message);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import {
|
||||
CoachLaunchProvider,
|
||||
resolveLaunchState,
|
||||
useCoachLaunch,
|
||||
} from "../coach-launch-context";
|
||||
import type { AccountAccess } from "@/lib/sharing/account-access-view";
|
||||
|
||||
/**
|
||||
* v1.4.27 R3d MB4 — Coach launch context smoke contract.
|
||||
@@ -19,8 +15,48 @@ import {
|
||||
* 3. The hook returns `null` when called outside the provider —
|
||||
* consumers degrade gracefully (e.g. the launch button renders
|
||||
* nothing rather than crashing).
|
||||
* 4. v1.36.x — the provider publishes nothing inside somebody else's
|
||||
* record, so (3) also covers every consumer under a switch. The shell
|
||||
* mounts no drawer there, and a launch call that opened nothing was a
|
||||
* button that silently did nothing on every page that offers one.
|
||||
*
|
||||
* Mutation check, run: dropping the `inSharedRecord` arm from the provider's
|
||||
* value memo → "publishes nothing inside somebody else's record" goes red
|
||||
* with the full context shape in the diff.
|
||||
*/
|
||||
|
||||
const OWNER = {
|
||||
accountId: "acct-owner",
|
||||
username: "owner",
|
||||
displayName: "Margarethe",
|
||||
access: "write" as const,
|
||||
canWrite: true,
|
||||
};
|
||||
|
||||
const mockAccessRef: { value: AccountAccess } = {
|
||||
value: { accounts: [OWNER], active: null, canSwitch: true },
|
||||
};
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "delegate",
|
||||
username: "delegate",
|
||||
email: null,
|
||||
role: "USER",
|
||||
avatarUrl: null,
|
||||
modules: {},
|
||||
accountAccess: mockAccessRef.value,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { CoachLaunchProvider, resolveLaunchState, useCoachLaunch } =
|
||||
await import("../coach-launch-context");
|
||||
|
||||
function Probe({ output }: { output: string[] }) {
|
||||
const launch = useCoachLaunch();
|
||||
if (!launch) {
|
||||
@@ -72,6 +108,41 @@ describe("CoachLaunchProvider", () => {
|
||||
expect(html).toContain('data-slot="child-mount"');
|
||||
expect(html).toContain("child");
|
||||
});
|
||||
|
||||
it("publishes nothing inside somebody else's record, at both levels", () => {
|
||||
for (const access of ["read", "write"] as const) {
|
||||
mockAccessRef.value = {
|
||||
accounts: [OWNER],
|
||||
active: { ...OWNER, access, canWrite: access === "write" },
|
||||
canSwitch: true,
|
||||
};
|
||||
const output: string[] = [];
|
||||
renderToStaticMarkup(
|
||||
<CoachLaunchProvider>
|
||||
<Probe output={output} />
|
||||
</CoachLaunchProvider>,
|
||||
);
|
||||
// The whole recorded value, not a boolean: a failure prints the shape
|
||||
// that leaked rather than `false !== true`.
|
||||
expect(output, `grant level: ${access}`).toEqual(["null"]);
|
||||
}
|
||||
mockAccessRef.value = { accounts: [OWNER], active: null, canSwitch: true };
|
||||
});
|
||||
|
||||
it("still renders its children there — only the launch value is withheld", () => {
|
||||
mockAccessRef.value = {
|
||||
accounts: [OWNER],
|
||||
active: OWNER,
|
||||
canSwitch: true,
|
||||
};
|
||||
const html = renderToStaticMarkup(
|
||||
<CoachLaunchProvider>
|
||||
<div data-slot="child-mount">child</div>
|
||||
</CoachLaunchProvider>,
|
||||
);
|
||||
mockAccessRef.value = { accounts: [OWNER], active: null, canSwitch: true };
|
||||
expect(html).toContain('data-slot="child-mount"');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import type { CoachScopeSource, CoachScopeWindow } from "@/lib/ai/coach/types";
|
||||
|
||||
/**
|
||||
@@ -191,7 +192,30 @@ export interface CoachLaunchProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the drawer's launch state — and publishes it only where a drawer
|
||||
* exists to receive it.
|
||||
*
|
||||
* v1.36.x — the shell stopped mounting `<LayoutCoachMount>` inside somebody
|
||||
* else's record, and this provider kept answering: `askCoach()` set an open
|
||||
* flag that nothing was reading, so every per-page Coach entry point became a
|
||||
* button that did nothing at all. A control that errors tells a person where
|
||||
* they stand; one that silently does nothing tells them the product is
|
||||
* broken.
|
||||
*
|
||||
* The gate lives here, on the publisher, rather than in `useCoachLaunch()`.
|
||||
* Six components read this context and five already treat `null` as "no Coach
|
||||
* here", so withholding the value fixes all of them at once and a seventh
|
||||
* inherits the rule. Putting it in the hook instead would drag the account
|
||||
* query into every one of those components for an answer that is the same in
|
||||
* all of them.
|
||||
*
|
||||
* Nothing is taken away by this: `/insights` and `/coach` are not
|
||||
* shared-record destinations, so the Coach is outside what sharing covers to
|
||||
* begin with.
|
||||
*/
|
||||
export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [closeIntent, setCloseIntent] = useState<CoachCloseIntent | null>(null);
|
||||
const [prefill, setPrefill] = useState<string | null>(null);
|
||||
@@ -277,20 +301,24 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
|
||||
[],
|
||||
);
|
||||
|
||||
const value = useMemo<CoachLaunchValue>(
|
||||
() => ({
|
||||
open,
|
||||
closeIntent,
|
||||
prefill,
|
||||
autoSend,
|
||||
scope,
|
||||
documentId,
|
||||
workoutId,
|
||||
askCoach,
|
||||
registerScope,
|
||||
setOpen: handleSetOpen,
|
||||
}),
|
||||
const value = useMemo<CoachLaunchValue | null>(
|
||||
() =>
|
||||
inSharedRecord
|
||||
? null
|
||||
: {
|
||||
open,
|
||||
closeIntent,
|
||||
prefill,
|
||||
autoSend,
|
||||
scope,
|
||||
documentId,
|
||||
workoutId,
|
||||
askCoach,
|
||||
registerScope,
|
||||
setOpen: handleSetOpen,
|
||||
},
|
||||
[
|
||||
inSharedRecord,
|
||||
closeIntent,
|
||||
open,
|
||||
prefill,
|
||||
@@ -316,6 +344,12 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
|
||||
* `<CoachLaunchProvider>` so consumer components can degrade gracefully
|
||||
* (e.g. the hero strip's "Ask the coach" action stays disabled until
|
||||
* the provider mounts).
|
||||
*
|
||||
* v1.36.x — also `null` inside somebody else's record, and by the same
|
||||
* mechanism: the provider publishes nothing there (see its own docblock), so
|
||||
* every consumer's existing `if (!launch) return null` becomes right without
|
||||
* being told. Kept as a plain context read on purpose — the answer is decided
|
||||
* once, in the provider, rather than by a hook that six components call.
|
||||
*/
|
||||
export function useCoachLaunch(): CoachLaunchValue | null {
|
||||
return useContext(CoachLaunchContext);
|
||||
|
||||
@@ -426,10 +426,76 @@ writeContract("POST /api/biomarkers", {
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 4 — allergies */
|
||||
/* 4 + 5 — allergies and family history, the two that are NOT delegable */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
writeContract("POST /api/allergies", {
|
||||
/**
|
||||
* The opposite contract, and it earns its place beside the admitted ones.
|
||||
*
|
||||
* Both of these verbs were admitted and then withdrawn, and the argument for
|
||||
* admitting them was never wrong: an allergy is a plain statement about the
|
||||
* record's own body and the single most useful thing a caregiver could
|
||||
* contribute. What they lack is a caller. The only surface in the product that
|
||||
* posts to either lives in Settings, and a switch closes `/settings` — so no
|
||||
* delegate can reach the form at any grant level, and admitting the write
|
||||
* would freeze a permission ahead of anything that exercises it.
|
||||
*
|
||||
* Both READ arms stay delegable; the read suite pins them. What is pinned here
|
||||
* is the pair that has to move together: the route refuses, and the owner's own
|
||||
* unswitched write still lands. Withdrawing a delegated write by breaking the
|
||||
* ordinary one would be the worse bug, and no other test in this file would
|
||||
* have noticed.
|
||||
*
|
||||
* Re-admitting means flipping `requireAuth()` back to `requireRecordAuth`,
|
||||
* re-listing the route in `delegable-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.
|
||||
*/
|
||||
function refusedWriteContract(
|
||||
name: string,
|
||||
c: {
|
||||
call: () => Promise<Response>;
|
||||
ok: number;
|
||||
count: (userId: string) => Promise<number>;
|
||||
},
|
||||
) {
|
||||
describe(name, () => {
|
||||
for (const access of ["READ", "WRITE"] as const) {
|
||||
it(`refuses a delegate holding a ${access} grant, and writes nothing`, async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
|
||||
await switchInto(owner.id, delegate.id, access);
|
||||
const response = await c.call();
|
||||
|
||||
// The undeclared-mode refusal, not the no-grant one: the route names
|
||||
// no sharing mode at all, so the carrier is refused before any grant
|
||||
// is consulted. A WRITE grant makes no difference, which is the point.
|
||||
expect(response.status).toBe(403);
|
||||
expect((await payload(response)).meta?.errorCode).toBe(
|
||||
"sharing.not_permitted",
|
||||
);
|
||||
|
||||
// Neither account, not just not the owner's — a handler that fell
|
||||
// back to the caller would have written a row somewhere.
|
||||
expect(await c.count(owner.id)).toBe(0);
|
||||
expect(await c.count(delegate.id)).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
it("still lets the owner write it themselves", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
await signIn(owner.id);
|
||||
|
||||
const response = await c.call();
|
||||
|
||||
expect(response.status).toBe(c.ok);
|
||||
expect(await c.count(owner.id)).toBe(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
refusedWriteContract("POST /api/allergies", {
|
||||
call: async () => {
|
||||
const { POST } = await import("@/app/api/allergies/route");
|
||||
return post(POST as Handler, "/api/allergies", {
|
||||
@@ -439,15 +505,10 @@ writeContract("POST /api/allergies", {
|
||||
});
|
||||
},
|
||||
ok: 201,
|
||||
auditAction: "allergy.create",
|
||||
count: (userId) => getPrismaClient().allergy.count({ where: { userId } }),
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 5 — family history */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
writeContract("POST /api/family-history", {
|
||||
refusedWriteContract("POST /api/family-history", {
|
||||
call: async () => {
|
||||
const { POST } = await import("@/app/api/family-history/route");
|
||||
return post(POST as Handler, "/api/family-history", {
|
||||
@@ -456,7 +517,6 @@ writeContract("POST /api/family-history", {
|
||||
});
|
||||
},
|
||||
ok: 201,
|
||||
auditAction: "family-history.create",
|
||||
count: (userId) =>
|
||||
getPrismaClient().familyHistoryEntry.count({ where: { userId } }),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user