Merge remote-tracking branch 'origin/fix/delegated-fix2' into work/sharing-write-integration

This commit is contained in:
Marc-André Bombeck committed 2026-08-03 21:15:01 +02:00
commit b817a2d47e
25 files changed
+956 -204

No files matched your search

@@ -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
+50 -24
View File
@@ -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"
+15 -2
View File
@@ -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);
}