mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Merge remote-tracking branch 'origin/main' into feat/onboarding-wizard
# Conflicts: # docs/api/openapi.yaml # src/lib/openapi/error-codes.ts
This commit is contained in:
commit
55ff6df205
87 files changed
+2080
-399
No files matched your search
@@ -121,6 +121,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// Apple Watch wrist/skin temperature feeds the temperature-trend
|
||||
// ovulation layer. Read the WRIST_TEMPERATURE measurements as nightly
|
||||
// values; the engine derives the trailing-mean deviation itself.
|
||||
//
|
||||
// This is a measurements read on a route declared under `cycle`, and it
|
||||
// needs no section predicate for one reason: no value from it reaches the
|
||||
// response. It goes into `predictCycle` and what comes out the other side
|
||||
// is a shifted date. The crosstab next door reads the same channel and IS
|
||||
// fenced, because there the values are the answer.
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
@@ -50,7 +51,7 @@ const WINDOW_DAYS = 365;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read", "cycle");
|
||||
const { user, grantId } = await requireRecordAuth("read", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
@@ -68,6 +69,19 @@ export const GET = apiHandler(async () => {
|
||||
const today = moodDateKey(new Date(), tz);
|
||||
const from = addDays(today, -WINDOW_DAYS);
|
||||
|
||||
// The crosstab is a cycle surface that reads two other sections. The phase
|
||||
// labels are cycle data; the outcome columns are not. `lutealAvg`,
|
||||
// `follicularAvg` and `delta` ship resting heart rate, HRV, sleep duration,
|
||||
// steps, weight, temperature, glucose and the mood score in display units,
|
||||
// and a grant scoped to the cycle alone was never consent for any of them.
|
||||
// So the two foreign channels are fenced at the query, not at the response:
|
||||
// a section the caller is outside of is never read, so nothing downstream
|
||||
// can leak it back — the rows simply do not exist, exactly as they do not
|
||||
// for a record with no measurements.
|
||||
const visible = await actingDomainVisibility(prisma, grantId);
|
||||
const seesMeasurements = visible("measurements");
|
||||
const seesMind = visible("mind");
|
||||
|
||||
const [
|
||||
cycles,
|
||||
dayLogRows,
|
||||
@@ -116,25 +130,28 @@ export const GET = apiHandler(async () => {
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
// The outcome metrics the phase contrast compares — soft-delete-scoped,
|
||||
// canonical-source deduped per day inside `metricDayMap`.
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
|
||||
measuredAt: {
|
||||
gte: new Date(Date.parse(`${from}T00:00:00Z`)),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
}),
|
||||
// canonical-source deduped per day inside `metricDayMap`. Skipped whole
|
||||
// for a caller outside `measurements`.
|
||||
seesMeasurements
|
||||
? prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
|
||||
measuredAt: {
|
||||
gte: new Date(Date.parse(`${from}T00:00:00Z`)),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
})
|
||||
: [],
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { sourcePriorityJson: true },
|
||||
@@ -142,15 +159,18 @@ export const GET = apiHandler(async () => {
|
||||
// MOOD outcome (QA HIGH): mood lives in MoodEntry (1–5 score), not a
|
||||
// Measurement row, so read it here and inject it into the crosstab as a
|
||||
// synthetic MOOD_CHANNEL_KEY measurement (same FDR / day-floor guards).
|
||||
prisma.moodEntry.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
|
||||
},
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
}),
|
||||
// Skipped whole for a caller outside `mind`.
|
||||
seesMind
|
||||
? prisma.moodEntry.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
|
||||
},
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
// The latest open cycle runs to the predicted next-period start so the
|
||||
@@ -239,6 +259,8 @@ export const GET = apiHandler(async () => {
|
||||
lagged_discovered: lagged.discovered.length,
|
||||
lagged_pairs_tested: lagged.pairsTested,
|
||||
symptom_patterns: symptomPatterns.length,
|
||||
measurements_visible: seesMeasurements,
|
||||
mind_visible: seesMind,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -27,10 +27,8 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { encounterLinkSchema } from "@/lib/validations/encounters";
|
||||
import { linkTargets, unlinkTargets } from "@/lib/links";
|
||||
import {
|
||||
actingDomainVisibility,
|
||||
loadEncounterLinks,
|
||||
} from "@/lib/encounters/service";
|
||||
import { loadEncounterLinks } from "@/lib/encounters/service";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
|
||||
@@ -22,12 +22,12 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label";
|
||||
import { toEncounterDTO } from "@/lib/encounters/dto";
|
||||
import {
|
||||
ENCOUNTER_INCLUDE,
|
||||
actingDomainVisibility,
|
||||
loadEncounterLinks,
|
||||
resolveOwnerNotificationContext,
|
||||
shouldHaveReminder,
|
||||
} from "@/lib/encounters/service";
|
||||
import { reanchorAppointmentReminder } from "@/lib/encounters/appointment-reminder";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label";
|
||||
import { toEncounterDTO } from "@/lib/encounters/dto";
|
||||
import {
|
||||
ENCOUNTER_INCLUDE,
|
||||
actingDomainVisibility,
|
||||
applyEncounterLinks,
|
||||
closeCheckupForVisit,
|
||||
findOwnAppointmentReminderId,
|
||||
@@ -54,6 +53,7 @@ import {
|
||||
type RestoreSkipLog,
|
||||
} from "@/lib/export/restore-skips";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label";
|
||||
import { toEncounterDTO, type EncounterListDTO } from "@/lib/encounters/dto";
|
||||
import {
|
||||
ENCOUNTER_INCLUDE,
|
||||
actingDomainVisibility,
|
||||
applyEncounterLinks,
|
||||
loadEncounterLinksForMany,
|
||||
closeCheckupForVisit,
|
||||
@@ -51,6 +50,7 @@ import {
|
||||
summarizeRestoreSkips,
|
||||
type RestoreSkipLog,
|
||||
} from "@/lib/export/restore-skips";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user, grantId } = await requireRecordAuth("read", "profile");
|
||||
|
||||
@@ -159,6 +159,17 @@ async function postAssessment(request: NextRequest): Promise<Response> {
|
||||
// into the record: the ordinary way this instrument is used when the person
|
||||
// it is about cannot run their own record. The row records what the person
|
||||
// in the room reported; the audit row says who typed it.
|
||||
//
|
||||
// It writes ONE row outside its declared section, and the crossing is
|
||||
// deliberate rather than an oversight: the `*_SCORE` Measurement below is a
|
||||
// projection of this administration, minted `COMPUTED`, keyed on
|
||||
// `assessment:<id>`, and it exists only so the trend reads through the chart
|
||||
// and rollup tier without ever touching the encrypted item content. Fencing
|
||||
// it on `measurements` the way the booster mint is fenced would file a
|
||||
// screener whose own total is missing from the record — an inconsistency
|
||||
// inside the section the grant DOES open, to avoid a derived point in one it
|
||||
// does not. The consent copy for this section says so, which is where a
|
||||
// widening belongs.
|
||||
const { user, actor } = await requireRecordAuth("manage", "mind");
|
||||
|
||||
// Opt-in module (default OFF) — same gate as GET.
|
||||
|
||||
@@ -13,11 +13,21 @@
|
||||
* even when a delegate is transcribing. The antigen is read from the dose's
|
||||
* catalogue entry server-side, never from the body — a client cannot key a
|
||||
* reminder onto an antigen the dose does not contain.
|
||||
*
|
||||
* The row it mints is a `MeasurementReminder`, and that model belongs to the
|
||||
* `measurements` section: every direct route over it declares that domain, and
|
||||
* creating one needs MANAGE there. So the act crosses a seam, and a grant
|
||||
* scoped to the health background alone was never consent for the other side
|
||||
* of it — it would produce a row on the owner's checkup list that the delegate
|
||||
* can neither read nor change through the section that owns it. The seam is
|
||||
* fenced the way the visits service fences a checkup closure: the mint runs
|
||||
* only while the caller is inside `measurements`.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -36,9 +46,24 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("write", "profile");
|
||||
const { user, grantId } = await requireRecordAuth("write", "profile");
|
||||
const { id } = await params;
|
||||
|
||||
// Refused rather than skipped, and that is the difference from the visit's
|
||||
// checkup closure: there the closure is one effect of a larger save and
|
||||
// saying "not done" keeps the save honest, while here the reminder IS the
|
||||
// request. Reporting success over a row that was never written would tell
|
||||
// the person their booster is planned when nothing rings.
|
||||
const visible = await actingDomainVisibility(prisma, grantId);
|
||||
if (!visible("measurements")) {
|
||||
annotate({ meta: { sharing_refusal: "booster_out_of_scope" } });
|
||||
return apiError(
|
||||
"Planning a booster needs access to this record's measurements",
|
||||
403,
|
||||
{ errorCode: "vaccination.booster-out-of-scope" },
|
||||
);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 8 * 1024,
|
||||
});
|
||||
|
||||
@@ -26,10 +26,8 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { vaccinationLinkSchema } from "@/lib/validations/vaccinations";
|
||||
import { linkTargets, unlinkTargets } from "@/lib/links";
|
||||
import {
|
||||
actingDomainVisibility,
|
||||
loadVaccinationDocuments,
|
||||
} from "@/lib/vaccinations/service";
|
||||
import { loadVaccinationDocuments } from "@/lib/vaccinations/service";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { toVaccinationDTO } from "@/lib/vaccinations/dto";
|
||||
import {
|
||||
VACCINATION_INCLUDE,
|
||||
actingDomainVisibility,
|
||||
loadVaccinationDocuments,
|
||||
resolveSeriesFor,
|
||||
} from "@/lib/vaccinations/service";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ import { vaccinationUpdateSchema } from "@/lib/validations/vaccinations";
|
||||
import { toVaccinationDTO } from "@/lib/vaccinations/dto";
|
||||
import {
|
||||
VACCINATION_INCLUDE,
|
||||
actingDomainVisibility,
|
||||
applyVaccinationLinks,
|
||||
loadVaccinationDocuments,
|
||||
resolveOwnedEncounter,
|
||||
@@ -40,6 +39,7 @@ import {
|
||||
resolveSeriesFor,
|
||||
} from "@/lib/vaccinations/service";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Suspense, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { CycleView } from "@/components/cycle/cycle-view";
|
||||
|
||||
@@ -23,10 +22,9 @@ import { CycleView } from "@/components/cycle/cycle-view";
|
||||
*/
|
||||
export default function CyclePage() {
|
||||
const { user, isLoading, isAuthenticated } = useAuth();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const router = useRouter();
|
||||
|
||||
const enabled = inSharedRecord || user?.modules?.cycle !== false;
|
||||
const enabled = user?.modules?.cycle !== false;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
@@ -14,7 +14,6 @@ import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { IllnessEpisodeDetail } from "@/components/illness/illness-episode-detail";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -25,12 +24,11 @@ export default function IllnessEpisodePage({
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const enabled = inSharedRecord || user?.modules?.illness === true;
|
||||
const enabled = user?.modules?.illness === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { IllnessView } from "@/components/illness/illness-view";
|
||||
|
||||
@@ -21,11 +20,10 @@ import { IllnessView } from "@/components/illness/illness-view";
|
||||
*/
|
||||
export default function IllnessPage() {
|
||||
const { user, isLoading, isAuthenticated } = useAuth();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const router = useRouter();
|
||||
|
||||
// Born-gated: enabled ONLY when the resolved map says `true`.
|
||||
const enabled = inSharedRecord || user?.modules?.illness === true;
|
||||
const enabled = user?.modules?.illness === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
@@ -44,7 +44,8 @@ export default function LabsPage() {
|
||||
// Scanning a report is not: it files a document and commits through the OCR
|
||||
// route, and neither is delegated. So a WRITE delegate keeps the manual add
|
||||
// and loses the choice menu around it.
|
||||
const { canAdd, inSharedRecord } = useRecordCapabilities();
|
||||
const { canWriteDomain, inSharedRecord } = useRecordCapabilities();
|
||||
const canAddLab = canWriteDomain("labs");
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -62,7 +63,7 @@ export default function LabsPage() {
|
||||
// absent key reads as enabled, so a direct URL hit only bounces on an
|
||||
// explicit `false`. Every `/api/labs/*` route also enforces the gate
|
||||
// server-side, so this is a UX redirect, not the security boundary.
|
||||
const enabled = inSharedRecord || user?.modules?.labs !== false;
|
||||
const enabled = user?.modules?.labs !== false;
|
||||
const ocrCapability = useOcrCapability(
|
||||
shouldProbeOcrCapability({
|
||||
isAuthenticated,
|
||||
@@ -152,7 +153,7 @@ export default function LabsPage() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : canAdd ? (
|
||||
) : canAddLab ? (
|
||||
<Button
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
|
||||
@@ -35,7 +35,8 @@ export function resolveMeasurementReturnTo(
|
||||
|
||||
export default function MeasurementsPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const { canAdd } = useRecordCapabilities();
|
||||
const { canWriteDomain } = useRecordCapabilities();
|
||||
const canAddMeasurement = canWriteDomain("measurements");
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -148,7 +149,7 @@ export default function MeasurementsPage() {
|
||||
// admits, so this is the header action a delegate keeps. A read-only
|
||||
// delegate loses it entirely rather than meeting a disabled one.
|
||||
actions={
|
||||
canAdd ? (
|
||||
canAddMeasurement ? (
|
||||
<Button
|
||||
data-slot="measurement-add"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
@@ -168,12 +169,12 @@ export default function MeasurementsPage() {
|
||||
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
|
||||
also covers the first-paint window: `canAddMeasurement` 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 && canAdd}
|
||||
open={dialogOpen && canAddMeasurement}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) {
|
||||
|
||||
@@ -48,6 +48,7 @@ vi.mock("@/components/medications/log-intake-dialog", () => ({
|
||||
import MedicationsPage from "@/app/medications/page-client";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
|
||||
|
||||
const OWNER = {
|
||||
accountId: "acct-owner",
|
||||
@@ -75,7 +76,17 @@ const READ_ONLY: AccountAccess = {
|
||||
};
|
||||
const WRITABLE: AccountAccess = {
|
||||
accounts: [OWNER],
|
||||
active: { ...OWNER, access: "write", level: "write", canWrite: true },
|
||||
active: {
|
||||
...OWNER,
|
||||
access: "write",
|
||||
level: "write",
|
||||
canWrite: true,
|
||||
// Published the way the server publishes it: the sections a WRITE grant
|
||||
// over the whole record can add to. A fixture that said `canWrite: true`
|
||||
// over an empty list described a grant the server never mints, and the
|
||||
// control asks the list.
|
||||
writableDomains: delegatedDomains("write", null, "write"),
|
||||
},
|
||||
canSwitch: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -169,14 +169,14 @@ export default function MedicationsPageClient() {
|
||||
// to tapping each card in turn — so withholding it made a caregiver with
|
||||
// five morning tablets tap five times for nothing. The customize page stays
|
||||
// withheld; sharing does not cover it at all.
|
||||
const { canAdd, inSharedRecord } = useRecordCapabilities();
|
||||
const { canWriteDomain, inSharedRecord } = useRecordCapabilities();
|
||||
const canAddMedication = canWriteDomain("medications");
|
||||
// v1.18.1 (D3) — medications is an opt-out module. When the account has it
|
||||
// turned off the whole page disappears (the nav entry is hidden by the same
|
||||
// gate) and the list query never fires (the API would 403 anyway).
|
||||
// Default-on: an absent key reads as enabled, so the page only hides on an
|
||||
// explicit `false`.
|
||||
const medicationsEnabled =
|
||||
inSharedRecord || user?.modules?.medications !== false;
|
||||
const medicationsEnabled = user?.modules?.medications !== false;
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -417,7 +417,7 @@ export default function MedicationsPageClient() {
|
||||
card's own one-tap job). Calm outline variant so the primary
|
||||
Add button keeps the visual lead; same responsive 44-px
|
||||
mobile tap floor as its neighbours. */}
|
||||
{canAdd && dueMeds.length >= 2 && (
|
||||
{canAddMedication && dueMeds.length >= 2 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setTakeAllOpen(true)}
|
||||
@@ -455,7 +455,7 @@ export default function MedicationsPageClient() {
|
||||
button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so
|
||||
both primary "add" entry points clear the WCAG 2.5.5 44px mobile
|
||||
minimum identically. */}
|
||||
{canAdd && (
|
||||
{canAddMedication && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="min-h-11 sm:min-h-9">
|
||||
@@ -516,7 +516,7 @@ export default function MedicationsPageClient() {
|
||||
title={t("medications.emptyTitle")}
|
||||
description={t("medications.emptyDescription")}
|
||||
action={
|
||||
canAdd ? (
|
||||
canAddMedication ? (
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("medications.firstMedication")}
|
||||
@@ -614,9 +614,9 @@ export default function MedicationsPageClient() {
|
||||
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. */}
|
||||
so the wizard asks the same `canAddMedication` the control asks. */}
|
||||
<MedicationWizardDialog
|
||||
open={dialogOpen && canAdd}
|
||||
open={dialogOpen && canAddMedication}
|
||||
onOpenChange={setDialogOpen}
|
||||
mode="create"
|
||||
onSuccess={closeDialog}
|
||||
@@ -632,10 +632,10 @@ export default function MedicationsPageClient() {
|
||||
attribution + inventory consumption identical to N individual
|
||||
taps — see take-all-due.ts); failed medications stay due. */}
|
||||
{/* Gated on the same answer the button is, for the first-paint reason
|
||||
`/measurements` documents: `canAdd` reads true until `/api/auth/me`
|
||||
`/measurements` documents: `canAddMedication` reads true until `/api/auth/me`
|
||||
settles, and a dialog opened in that frame withdraws when the answer
|
||||
lands rather than standing on a confirm the server would refuse. */}
|
||||
{takeAllOpen && canAdd && (
|
||||
{takeAllOpen && canAddMedication && (
|
||||
<TakeAllDueDialog
|
||||
open={takeAllOpen}
|
||||
onOpenChange={setTakeAllOpen}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
|
||||
|
||||
@@ -27,10 +26,9 @@ import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
|
||||
*/
|
||||
export default function MentalWellbeingPage() {
|
||||
const { user, isLoading, isAuthenticated } = useAuth();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const router = useRouter();
|
||||
|
||||
const enabled = inSharedRecord || user?.modules?.mentalHealth === true;
|
||||
const enabled = user?.modules?.mentalHealth === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function MoodPageClient() {
|
||||
// absent key reads as enabled, so a direct URL hit only bounces on an
|
||||
// explicit `false`. Every `/api/mood-entries/*` route also enforces the gate
|
||||
// server-side, so this is a UX redirect, not the security boundary.
|
||||
const enabled = inSharedRecord || user?.modules?.mood !== false;
|
||||
const enabled = user?.modules?.mood !== false;
|
||||
|
||||
// v1.16.4 — PWA pull-to-refresh: a top-anchored touch pull refetches
|
||||
// whatever this page currently has mounted (`type: "active"` scopes the
|
||||
|
||||
@@ -254,7 +254,8 @@ export default function DashboardPageClient({
|
||||
value: number | null | undefined,
|
||||
): number | null =>
|
||||
value == null ? null : unitDisplay.toDisplayDelta(type, value);
|
||||
const { canAdd } = useRecordCapabilities();
|
||||
const { canWriteDomain } = useRecordCapabilities();
|
||||
const canAddMeasurement = canWriteDomain("measurements");
|
||||
const [quickEntryDialog, setQuickEntryDialog] =
|
||||
useState<QuickEntryDialog>(null);
|
||||
|
||||
@@ -2028,7 +2029,7 @@ export default function DashboardPageClient({
|
||||
title={t("dashboard.emptyTitle")}
|
||||
description={t("dashboard.emptyDescription")}
|
||||
action={
|
||||
canAdd ? (
|
||||
canAddMeasurement ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setQuickEntryDialog("measurement")}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { VaccinationsView } from "@/components/vaccinations/vaccinations-view";
|
||||
|
||||
@@ -22,13 +21,13 @@ import { VaccinationsView } from "@/components/vaccinations/vaccinations-view";
|
||||
*/
|
||||
export default function VaccinationsPage() {
|
||||
const { user, isLoading, isAuthenticated } = useAuth();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const router = useRouter();
|
||||
|
||||
// Default-on: hidden only when the resolved map says `false` explicitly. A
|
||||
// shared-record session always sees it — the delegate's own module map does
|
||||
// not govern the owner's record.
|
||||
const enabled = inSharedRecord || user?.modules?.vaccinations !== false;
|
||||
// Default-on: hidden only when the resolved map says `false` explicitly.
|
||||
// The map is the RECORD's, masked to what the grant opens, so a delegate is
|
||||
// answered about the record they are in rather than about their own — and a
|
||||
// module the owner has switched off closes this page for them too.
|
||||
const enabled = user?.modules?.vaccinations !== false;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
Reference in new issue
Block a user