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