mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +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
+132
@@ -1,5 +1,137 @@
|
||||
# Changelog
|
||||
|
||||
## [1.38.16] — 2026-09-10
|
||||
|
||||
The API says what it refused and why, on every route, and the admin page
|
||||
says whether last night's off-host copy actually landed for each account.
|
||||
|
||||
### Added
|
||||
|
||||
- **Off-host backup freshness per account.** The nightly job reported
|
||||
counts, which cannot name the failing account. It now keeps a ledger row
|
||||
per account (`OffhostBackupState`: last attempt, last success, size),
|
||||
written the moment the object is durably in the bucket and under its own
|
||||
error boundary, so a ledger write that fails cannot turn a landed backup
|
||||
into a failure. Admin → Backups shows one row per account with a state
|
||||
judged against the nightly schedule with six hours of grace for the Berlin
|
||||
clock: `fresh` (within one period), `due` (within two), `stale`, `never`
|
||||
(walked by a run, no object yet) and `unknown` (labelled "No record yet"
|
||||
in the console; the honest answer on the first day after this release).
|
||||
Stale first. Never a bucket listing; an empty state says so when off-host
|
||||
backup is not configured. The row is in the wipe plan and registered as
|
||||
not part of the backup itself. Runbook updated.
|
||||
|
||||
- **The server side of the needs-based onboarding.** A record now holds its
|
||||
onboarding answers (who the record is for, the areas to watch, medication
|
||||
on a schedule, where readings come from, an upcoming visit, units), the
|
||||
ordered steps with stable ids and a status each, when the flow completed
|
||||
and which first result was offered. `GET /api/auth/me` publishes it as
|
||||
`onboarding` for the record the payload describes;
|
||||
`PATCH /api/onboarding/answers` takes one step at a time and is
|
||||
idempotent, `POST /api/onboarding/complete` derives the module map from
|
||||
the answers exactly once and only when every question is answered or
|
||||
passed, `POST /api/onboarding/restart` re-asks the questions and keeps the
|
||||
modules. The derivation turns on what the answers name and writes an
|
||||
explicit off for the rest, but never turns off a module the person
|
||||
switched on by hand and never one whose domain already holds rows, so
|
||||
nothing is lost by a choice; the mapping from needs to modules lives in
|
||||
the module registry next to the keys. The getting-started checklist orders
|
||||
its items from the answers and no longer reads every account that never
|
||||
entered the flow as unfinished. The web wizard that asks these questions
|
||||
follows in a later release; today's first-run steps are unchanged.
|
||||
|
||||
- **The error-reporting card tells the truth.** A green "Configured" badge
|
||||
appeared from a DSN alone, even with sending off, and never said where
|
||||
reports go. The forwarder now records the last successful and the last
|
||||
failed delivery (timestamps and a short reason, never a response body);
|
||||
the badge is green only when a report left within fourteen days, amber
|
||||
otherwise with the reason, and the card names the target host, never the
|
||||
key. `docs/ops/deploy.md` gains a note on setting it for an instance you
|
||||
operate yourself; there is deliberately no default target.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A cycle-only share no longer sees the rest of the record.**
|
||||
`GET /api/cycle/insights` was declared for the `cycle` section but
|
||||
returned luteal and follicular averages for weight, resting heart rate,
|
||||
HRV, sleep, steps, glucose and mood score, which are `measurements` and
|
||||
`mind` data, to a delegate whose grant opened only the cycle. The route
|
||||
asks the grant per section now and skips the queries it does not cover;
|
||||
the owner and an unscoped grant get the same answer as before. One helper
|
||||
answers "which sections does this grant open" for every route; two
|
||||
verbatim copies of it are gone.
|
||||
|
||||
- **Controls a delegate could not use are not offered.** The vaccination
|
||||
pass showed add, edit and delete to a read-level delegate; a visit row
|
||||
opened an editable sheet with Save and Delete for read and write levels;
|
||||
the capture surfaces and the dashboard's quick entry offered kinds whose
|
||||
section the grant did not open; nine pages stayed open to any delegate
|
||||
because of a coarse "in a shared record" test. Each control asks the
|
||||
section its own route requires (`canWriteDomain` / `canManageDomain`), the
|
||||
coarse "may this person add" answer is retired, the nine page gates read
|
||||
the record's own module map, and a structural guard sweeps every component
|
||||
for the shape rather than naming files, because the first sweep found six
|
||||
and missed three.
|
||||
|
||||
- **A wipe, an account deletion and an encrypted export refuse under an
|
||||
active switch.** They always did (the fresh-second-factor gate opens with
|
||||
the plain authentication that refuses any switch) but the contract did not
|
||||
say so; the three operations now publish the refusal, and a test drives it
|
||||
through the browser's own carrier.
|
||||
|
||||
- **A booster reminder is not minted for a record its grant does not
|
||||
cover.** The mint refuses rather than silently skipping, with a test that
|
||||
no row is written.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Every shape refusal carries the issue list.** 59 refusal sites in 57
|
||||
routes answered a schema refusal with a bare 422 and a sentence; they go
|
||||
through the shared helper now, so `details.issues` names each field the
|
||||
way the rest of the tree already did. `error` and `meta` are unchanged;
|
||||
six of the eleven dotted tokens that are still the whole `error` string
|
||||
also ride as `meta.errorCode`, the five Coach reminder tokens do not yet.
|
||||
A structural sweep reads every `safeParse` refusal in `src/app/api` and
|
||||
fails when one bypasses the helper; its allowlist is empty. A second sweep
|
||||
inventories the eleven token-string 422s, each with a written reason: one
|
||||
is a published contract, five keep the string because the status did not
|
||||
move and the token rides in `meta.errorCode` as well, five are the Coach
|
||||
reminder surfaces that move as one piece or not at all.
|
||||
|
||||
- **A body that is not JSON at all is a 400 everywhere.** Seven preference
|
||||
routes answered 422 with a dotted token as the whole message; they answer
|
||||
400 with a sentence and the same token in `meta.errorCode`, the status
|
||||
roughly two hundred other routes already use. A body that parses but fails
|
||||
the schema is still the 422 beside it. The native client branches on
|
||||
`meta.errorCode`, which did not change; the health-score configuration
|
||||
route keeps its published 422 with a written reason.
|
||||
`PUT /api/auth/me/report-selection` also names the leaf ids it does not
|
||||
know in `meta.unknownLeaves` instead of refusing with a bare token.
|
||||
|
||||
- **Two more writes honour `Idempotency-Key`.** The GLP-1 log and the custom
|
||||
cycle symptom, the two row-creating writes the native client's offline
|
||||
queue is most likely to replay after a lost response, join the routes that
|
||||
return the first answer with `X-Idempotent-Replay: true` instead of a
|
||||
second row. Documented per route.
|
||||
|
||||
- **Single-record writes share one generous per-account bucket.** Eleven
|
||||
routes that create or edit one row had no rate limit at all; they share
|
||||
`record-write:<accountId>` at 300 per minute, keyed on the acting account,
|
||||
applied after authentication, with the 429 carrying `Retry-After`, the
|
||||
`X-RateLimit-*` triple and `meta.errorCode = record_write.rate_limited` so
|
||||
a client can tell the shared bucket from a route's own. The batch
|
||||
endpoints keep their own buckets. A test pulls the key and the window out
|
||||
of the limiter's query and holds the documentation to them.
|
||||
|
||||
- **The error codes are a published list.** 257 `errorCode` values across 47
|
||||
surfaces, plus a group for the handful that carry no prefix, are
|
||||
catalogued in `src/lib/openapi/error-codes.ts` and rendered into the error
|
||||
envelope's description, grouped by surface. A guard walks the import graph
|
||||
from every route (about fourteen hundred modules, the generated client
|
||||
never opened) and fails in both directions: a code emitted anywhere a
|
||||
route can reach but absent from the catalogue, and a catalogue entry
|
||||
nothing emits. Existing codes keep their spelling.
|
||||
|
||||
## [1.38.15] — 2026-09-10
|
||||
|
||||
The rest of #939 for managed profiles, one figure for medication adherence
|
||||
|
||||
+77
-13
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.38.15
|
||||
version: 1.38.16
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -581,7 +581,11 @@ paths:
|
||||
temperatures), plus the single headline finding (resting-heart-rate-by-phase, falling back to HRV). The same
|
||||
Welch t-test + Benjamini-Hochberg machinery the mood-factor crosstab runs; only rows with p < 0.05 AND q ≤ 0.10
|
||||
surface. Strictly gender-gated — phase never appears on the general `/api/insights/correlations` route.
|
||||
Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off."
|
||||
Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off. Declared for the `cycle`
|
||||
section; the outcome columns are fenced per section at the query, so a grant scoped to the cycle alone receives
|
||||
the phase rows with no `lutealAvg` / `follicularAvg` / `delta` for measurement or mood outcomes and no lagged
|
||||
discovery, exactly as a record with nothing logged would read. The owner and a whole-record grant are
|
||||
unchanged."
|
||||
responses:
|
||||
"200":
|
||||
description: Phase-correlation rows + headline.
|
||||
@@ -1789,6 +1793,20 @@ paths:
|
||||
type: string
|
||||
format: binary
|
||||
"401": *a1
|
||||
"403":
|
||||
description: >-
|
||||
The Bearer token carries a narrow scope. The encrypted archive needs a cookie session or a full-access
|
||||
token; the refusal happens before any second-factor proof is looked at.
|
||||
|
||||
|
||||
Refused: the request was made while the session is acting on another account (`meta.errorCode` =
|
||||
`sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it
|
||||
refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any
|
||||
level opens it.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/auth/check-user:
|
||||
@@ -12237,8 +12255,15 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"403":
|
||||
description: The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal
|
||||
description: >-
|
||||
The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal
|
||||
happens before any second-factor proof is looked at.
|
||||
|
||||
|
||||
Refused: the request was made while the session is acting on another account (`meta.errorCode` =
|
||||
`sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it
|
||||
refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any
|
||||
level opens it.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -12460,8 +12485,15 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"403":
|
||||
description: The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal
|
||||
description: >-
|
||||
The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal
|
||||
happens before any second-factor proof is looked at.
|
||||
|
||||
|
||||
Refused: the request was made while the session is acting on another account (`meta.errorCode` =
|
||||
`sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it
|
||||
refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any
|
||||
level opens it.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -14201,12 +14233,19 @@ paths:
|
||||
tags:
|
||||
- Records
|
||||
summary: Plan a booster reminder from a dose
|
||||
description: "Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin:
|
||||
description: >-
|
||||
Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin:
|
||||
VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the
|
||||
preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the
|
||||
existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A
|
||||
free-text-only dose has no antigen to remind on and is refused with 422. Audits as
|
||||
`vaccination.booster.planned`."
|
||||
`vaccination.booster.planned`.
|
||||
|
||||
|
||||
The operation is declared under the health background and the row it mints belongs to the measurements section,
|
||||
so it crosses a section boundary: a delegate whose grant does not also open `measurements` is refused with 403
|
||||
`vaccination.booster-out-of-scope` and nothing is written. The dose itself still logs — only the booster does
|
||||
not.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -14233,7 +14272,25 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MintVaccinationBoosterEnvelope"
|
||||
"401": *a1
|
||||
"403": *a9
|
||||
"403":
|
||||
description: >-
|
||||
Refused: the caller's grant does not open this record's measurements, and the booster reminder lives there
|
||||
(`meta.errorCode` = `vaccination.booster-out-of-scope`). Nothing was written.
|
||||
|
||||
|
||||
Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`).
|
||||
Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections
|
||||
do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it
|
||||
is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's
|
||||
audit trail instead. On the cookie transport the same code answers one further case: a session that has
|
||||
entered a shared record and sends a request without its record-context assertion, which is a client older
|
||||
than the fence — leave the record and reload. A request that names no record but the caller's own never
|
||||
reaches this response, so a client that never switches and never sends the per-request account selector will
|
||||
not see it.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"404": *a28
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
@@ -14720,10 +14777,17 @@ paths:
|
||||
tags:
|
||||
- Mental health
|
||||
summary: Record a completed screener
|
||||
description: Stores one administration (item answers encrypted) and writes the derived total as a server-owned
|
||||
PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5
|
||||
and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive
|
||||
PHQ-9 item-9 the response carries the locale-aware crisis-resource set.
|
||||
description: >-
|
||||
Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE /
|
||||
GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 and SCI totals
|
||||
run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive PHQ-9 item-9 the
|
||||
response carries the locale-aware crisis-resource set.
|
||||
|
||||
|
||||
The projection row is the one write this operation makes outside its declared section: the operation is fenced
|
||||
on `mind`, and the measurement it derives lands in `measurements`. It is deliberate and unconditional — a
|
||||
screener whose own total is missing from the record would be an inconsistency inside the section the grant does
|
||||
open. The consent copy for the mood-and-mind section names it.
|
||||
parameters:
|
||||
- *a6
|
||||
requestBody:
|
||||
@@ -29898,8 +29962,8 @@ components:
|
||||
sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority —
|
||||
source-priority.body.invalid_json, source-priority.body.invalid_shape; tokens —
|
||||
tokens.measurements.ceiling_reached; vaccination — vaccination.booster-invalid,
|
||||
vaccination.booster-no-antigen, vaccination.encounter-not-found, vaccination.identity-required,
|
||||
vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
|
||||
vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.encounter-not-found,
|
||||
vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
|
||||
vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large,
|
||||
workout.batch.too_large; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict,
|
||||
dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict,
|
||||
|
||||
@@ -49,16 +49,16 @@ Either the entire record, which is what sharing has always meant and what
|
||||
every grant written before v1.37.0 carries, or a set of named sections
|
||||
chosen when the invitation is written:
|
||||
|
||||
| Section | What it covers |
|
||||
| ----------------- | -------------------------------------------------------------------------- |
|
||||
| Readings | Weight, blood pressure, pulse, glucose, sleep, workouts and custom metrics |
|
||||
| Medications | The medication list, its schedules, doses taken, side effects and stock |
|
||||
| Lab results | Lab results and the analytes behind them |
|
||||
| Health background | Allergies, family history and the facts in the health profile |
|
||||
| Illness | Illness episodes and the day logs kept under them |
|
||||
| Mood and mind | Mood entries and the mental-health questionnaire history |
|
||||
| Cycle | Cycle tracking |
|
||||
| Documents | Everything in the document vault |
|
||||
| Section | What it covers |
|
||||
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Readings | Weight, blood pressure, pulse, glucose, sleep, workouts and custom metrics |
|
||||
| Medications | The medication list, its schedules, doses taken, side effects and stock |
|
||||
| Lab results | Lab results and the analytes behind them |
|
||||
| Health background | Allergies, family history, the facts in the health profile, visits and the practices behind them, and the vaccination record |
|
||||
| Illness | Illness episodes and the day logs kept under them |
|
||||
| Mood and mind | Mood entries and the mental-health questionnaire history |
|
||||
| Cycle | Cycle tracking |
|
||||
| Documents | Everything in the document vault |
|
||||
|
||||
A section that was not shared is refused exactly the way a record nobody
|
||||
shared is, so the shape of what was held back is not readable from
|
||||
@@ -68,8 +68,12 @@ shared. The invitation screen says this in as many words, because a note
|
||||
or a document name inside one section can be about any other.
|
||||
|
||||
Aggregate surfaces (the dashboard overview, the health score, the daily
|
||||
digest) appear only on a whole-record grant. A figure derived from part of
|
||||
a record reads as a figure about the person, and it would not be one.
|
||||
digest, every insight) read across sections, so a grant naming sections
|
||||
never opens one. A figure derived from part of a record reads as a figure
|
||||
about the person, and it would not be one. Whole-record grants do not all
|
||||
open them equally either: the daily digest and the dashboard's snapshot
|
||||
answer at read, while the health score, the dashboard's summary and the
|
||||
insights need manage.
|
||||
|
||||
A stored scope never grows. A section added by a later release is not in
|
||||
a set somebody ticked before it existed.
|
||||
@@ -80,11 +84,24 @@ Three levels, fixed when the invitation is written. Raising one means
|
||||
revoking and inviting again, because widening a grant in place would carry
|
||||
a consent the other person never gave.
|
||||
|
||||
| Level | Adds |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Read | Read what the grant opens, and change nothing. |
|
||||
| Write | Add entries: a reading, a lab result, an analyte, an illness entry, a side effect, a medication, and marking a dose taken or skipped. Editing, deleting and restoring stay with you. |
|
||||
| Manage | Change and remove entries, including ones the owner wrote; record the health background; read the insights generated from the record. Always the entire record. |
|
||||
| Level | Adds |
|
||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Read | Read what the grant opens, and change nothing. |
|
||||
| Write | Add entries: a reading, a lab result, an analyte, an illness entry, a side effect, a medication, marking a dose taken or skipped, a visit, a practice, and a vaccination dose. Editing, deleting and restoring stay with you. |
|
||||
| Manage | Change and remove entries, including ones the owner wrote; record the health background; read the insights generated from the record. Always the entire record. |
|
||||
|
||||
Some entries are creates only **manage** reaches, and the split runs by
|
||||
entry rather than by section: an allergy, a family-history entry, a mood
|
||||
entry, a screener, a cycle day, a water entry and a preventive-care
|
||||
reminder are all created at manage, in sections a write grant can
|
||||
otherwise add to. Nothing in the document vault is writable at any level.
|
||||
|
||||
One entry needs two sections rather than a higher level. Planning the
|
||||
booster a vaccination suggests files it as a preventive-care reminder,
|
||||
which lives under Readings, so a grant that opens Health background alone
|
||||
logs the dose and is refused the booster. Logging the dose is unaffected
|
||||
either way, and the app does not offer the booster to a grant that cannot
|
||||
plan it.
|
||||
|
||||
Offering **manage** asks the owner for a fresh second factor if they have
|
||||
one enrolled, which makes it a browser-only act either way: a Bearer
|
||||
@@ -106,12 +123,19 @@ whole feature is built on, and it is drawn on the server:
|
||||
| API tokens, the MCP connector | A durable credential that outlives the access |
|
||||
| Connected services (Withings, Fitbit, WHOOP, Google Health, Polar, Oura, Strava, Nightscout) | Re-pointing a sync, or feeding somebody else's wearable into this record |
|
||||
| Notification channels and push devices | The owner's health alerts redirected to another phone or chat |
|
||||
| Exports, the health-record archive, clinician share links | A one-click copy of everything, or a new door that outlives the access |
|
||||
| Full exports, the encrypted archive, clinician share links | A one-click copy of everything, or a new door that outlives the access |
|
||||
| Asking an AI provider anything | AI processing of the owner's data, under the owner's own consent |
|
||||
| Modules, thresholds, units, the record's language and timezone | Configuration of the account, not content of the record |
|
||||
| Sharing itself | Inviting somebody else, widening the access, or passing it on |
|
||||
| Locale, theme, dashboard layout | Presentation belongs to the person, not to the record |
|
||||
|
||||
One export is the exception, and it is deliberate: the doctor report
|
||||
(`POST /api/export/health-record`) answers at manage on a whole-record
|
||||
grant. It renders a prepared artefact from a selection the caller declares
|
||||
rather than handing the record over whole, which is the difference the row
|
||||
above is drawing. The full exports and the passphrase-encrypted archive
|
||||
stay with the owner.
|
||||
|
||||
This table is about a grant between two accounts. A managed profile is
|
||||
the one place where some of it is deliberately different, because a record
|
||||
with no login has nobody else to keep its own configuration: see "Records
|
||||
|
||||
@@ -231,8 +231,8 @@ async function openSeedingSurface(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* The two dose times the journey's medication runs on: now, and twelve hours
|
||||
* from now.
|
||||
* The two dose times the journey's medication runs on: now, and a twin later
|
||||
* the same day.
|
||||
*
|
||||
* They are derived from the clock rather than picked from the wizard's
|
||||
* suggestion chips, and that is the whole reason the flow is stable. The card
|
||||
@@ -241,17 +241,27 @@ async function openSeedingSurface(page: Page): Promise<void> {
|
||||
* belongs to yesterday or to tomorrow, and the day the write lands on decides
|
||||
* what the dashboard tile reads. A slot anchored at "now" is inside its own
|
||||
* on-time window by construction, so the take is always today's, whenever the
|
||||
* suite runs. Its twin twelve hours away is the second dose of the day.
|
||||
* suite runs.
|
||||
*
|
||||
* The twin must be on today's calendar as well, because the skip lands on it
|
||||
* and the tile reads the latest day: a twin twelve hours on wraps past
|
||||
* midnight for every run that starts after noon, the skip then lands on
|
||||
* tomorrow's slot, and today reads one taken over two expected (50) where the
|
||||
* skip-parity figure is 100. So the twin is twelve hours on before noon and
|
||||
* the last minute of the day after it; either way the card offers it next.
|
||||
*/
|
||||
async function clockAnchoredDoseTimes(
|
||||
page: Page,
|
||||
): Promise<{ open: string; other: string }> {
|
||||
return page.evaluate(() => {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const hhmm = (minutes: number) =>
|
||||
`${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`;
|
||||
const now = new Date();
|
||||
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
return {
|
||||
open: `${pad(now.getHours())}:${pad(now.getMinutes())}`,
|
||||
other: `${pad((now.getHours() + 12) % 24)}:${pad(now.getMinutes())}`,
|
||||
open: hhmm(nowMinutes),
|
||||
other: hhmm(Math.min(nowMinutes + 12 * 60, 24 * 60 - 1)),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -362,7 +372,7 @@ test.describe("medication adherence journey", () => {
|
||||
|
||||
// The tile reads THE LATEST day, and a day boundary crossed mid-flow moves
|
||||
// that day out from under both tile assertions: a fresh day has two doses
|
||||
// scheduled, none taken, and answers 0 where 50 is asserted. The flow is a
|
||||
// scheduled, none taken, and answers 0 where 100 is asserted. The flow is a
|
||||
// couple of minutes long, so declining to start in the last ten of the day
|
||||
// is enough. The clock is the browser's, which the config pins to the zone
|
||||
// the compliance engine falls back to.
|
||||
|
||||
+1
-1
@@ -10609,7 +10609,7 @@
|
||||
},
|
||||
"mind": {
|
||||
"label": "Stimmung und Psyche",
|
||||
"detail": "Stimmungseinträge und der Verlauf der psychologischen Fragebögen."
|
||||
"detail": "Stimmungseinträge und der Verlauf der psychologischen Fragebögen. Ein ausgefüllter Fragebogen legt seinen Summenwert zusätzlich als Messwert ab."
|
||||
},
|
||||
"cycle": {
|
||||
"label": "Zyklus",
|
||||
|
||||
+1
-1
@@ -10609,7 +10609,7 @@
|
||||
},
|
||||
"mind": {
|
||||
"label": "Mood and mind",
|
||||
"detail": "Mood entries and the history of the mental-health questionnaires."
|
||||
"detail": "Mood entries and the history of the mental-health questionnaires. A completed questionnaire also files its total score as a reading."
|
||||
},
|
||||
"cycle": {
|
||||
"label": "Cycle",
|
||||
|
||||
+1
-1
@@ -10609,7 +10609,7 @@
|
||||
},
|
||||
"mind": {
|
||||
"label": "Ánimo y bienestar mental",
|
||||
"detail": "Las entradas de ánimo y el historial de los cuestionarios de salud mental."
|
||||
"detail": "Las entradas de ánimo y el historial de los cuestionarios de salud mental. Un cuestionario completado también registra su puntuación total como una medición."
|
||||
},
|
||||
"cycle": {
|
||||
"label": "Ciclo",
|
||||
|
||||
+1
-1
@@ -10609,7 +10609,7 @@
|
||||
},
|
||||
"mind": {
|
||||
"label": "Humeur et santé mentale",
|
||||
"detail": "Les entrées d'humeur et l'historique des questionnaires de santé mentale."
|
||||
"detail": "Les entrées d'humeur et l'historique des questionnaires de santé mentale. Un questionnaire rempli enregistre aussi son score total comme une mesure."
|
||||
},
|
||||
"cycle": {
|
||||
"label": "Cycle",
|
||||
|
||||
+1
-1
@@ -10609,7 +10609,7 @@
|
||||
},
|
||||
"mind": {
|
||||
"label": "Umore e benessere mentale",
|
||||
"detail": "Le voci sull'umore e lo storico dei questionari sulla salute mentale."
|
||||
"detail": "Le voci sull'umore e lo storico dei questionari sulla salute mentale. Un questionario compilato registra anche il suo punteggio totale come una misurazione."
|
||||
},
|
||||
"cycle": {
|
||||
"label": "Ciclo",
|
||||
|
||||
+1
-1
@@ -10609,7 +10609,7 @@
|
||||
},
|
||||
"mind": {
|
||||
"label": "기분과 마음",
|
||||
"detail": "기분 기록과 정신건강 설문 이력이에요."
|
||||
"detail": "기분 기록과 정신건강 설문 이력이에요. 작성한 설문은 총점도 측정값으로 함께 저장돼요."
|
||||
},
|
||||
"cycle": {
|
||||
"label": "생리 주기",
|
||||
|
||||
+1
-1
@@ -10609,7 +10609,7 @@
|
||||
},
|
||||
"mind": {
|
||||
"label": "Nastrój i zdrowie psychiczne",
|
||||
"detail": "Wpisy o nastroju i historia kwestionariuszy zdrowia psychicznego."
|
||||
"detail": "Wpisy o nastroju i historia kwestionariuszy zdrowia psychicznego. Wypełniony kwestionariusz zapisuje też swój wynik łączny jako pomiar."
|
||||
},
|
||||
"cycle": {
|
||||
"label": "Cykl",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.38.15",
|
||||
"version": "1.38.16",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.38.15";
|
||||
/* @sw-version-fallback */ "v1.38.16";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -24,6 +24,16 @@
|
||||
* domain outside the vocabulary other than `record` fails the run outright,
|
||||
* because the table is keyed on the vocabulary and would silently lack it.
|
||||
*
|
||||
* What it deliberately does not hold, stated so nobody reads more into a green
|
||||
* run than it earns: the table is EXISTENTIAL per section. It answers "does
|
||||
* any route in this section accept this need", and within a section the answer
|
||||
* varies by verb — `POST /api/encounters` is WRITE while `POST /api/allergies`
|
||||
* beside it is MANAGE, both under `profile`. So this guard proves the table
|
||||
* and the routes agree about the section, never that one control's own verb is
|
||||
* covered. That is the control's job: it asks the hook for its own verb class,
|
||||
* and a control that read "my section is writable, therefore my create is"
|
||||
* would be wrong in three of the four sections marked writable.
|
||||
*
|
||||
* Mutation checks, run:
|
||||
* - set `mind.write` to `true` in the table → "agrees with the routes" red;
|
||||
* - change `requireRecordAuth("manage", "mind")` in `mood-entries/route.ts`
|
||||
|
||||
@@ -73,3 +73,70 @@ export function walkSourceFiles(
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* A source file with its comments removed, line comments FIRST.
|
||||
*
|
||||
* The order is the whole reason this is shared rather than re-spelled per
|
||||
* guard. Every private copy in this directory strips block comments first,
|
||||
* with one non-greedy `/\*[\s\S]*?\*\//` pass, and that pass cannot tell a
|
||||
* real block opener from a `/*` that happens to sit inside a line comment.
|
||||
* This tree has one: `src/app/mood/page-client.tsx` explains its gate with
|
||||
* "Every `/api/mood-entries/*` route also enforces the gate", and the `/*` in
|
||||
* that path opened a fake block that swallowed the next 2.2 kB of real code —
|
||||
* the module gate three lines below it included. A guard reading that source
|
||||
* was searching a file with a hole in it and reporting a clean sweep.
|
||||
*
|
||||
* So each line is walked once, left to right: whichever opener comes first
|
||||
* wins, a line comment ends the line, and a block comment carries its state to
|
||||
* the next one. Line-comment detection needs `//` at the start of a line or
|
||||
* after whitespace, so a URL inside a string survives.
|
||||
*
|
||||
* Its honest limit: it is a scanner, not a parser. A `/*` inside a string
|
||||
* literal still opens a block, and a guard that needs more than this needs a
|
||||
* real parse.
|
||||
*/
|
||||
export function stripComments(source: string): string {
|
||||
const lines: string[] = [];
|
||||
let inBlock = false;
|
||||
|
||||
for (const line of source.split("\n")) {
|
||||
let rest = line;
|
||||
let kept = "";
|
||||
|
||||
while (rest.length > 0) {
|
||||
if (inBlock) {
|
||||
const closes = rest.indexOf("*/");
|
||||
if (closes === -1) break;
|
||||
rest = rest.slice(closes + 2);
|
||||
inBlock = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const lineComment = rest.search(/(^|\s)\/\//);
|
||||
const blockComment = rest.indexOf("/*");
|
||||
|
||||
if (
|
||||
blockComment !== -1 &&
|
||||
(lineComment === -1 || blockComment < lineComment)
|
||||
) {
|
||||
kept += rest.slice(0, blockComment);
|
||||
rest = rest.slice(blockComment + 2);
|
||||
inBlock = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lineComment !== -1) {
|
||||
kept += rest.slice(0, rest.indexOf("//", lineComment));
|
||||
break;
|
||||
}
|
||||
|
||||
kept += rest;
|
||||
break;
|
||||
}
|
||||
|
||||
lines.push(kept);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* A module gate decides from the RECORD's map, never from "am I a delegate".
|
||||
*
|
||||
* Every surface that paints a module reads `user?.modules?.X`, and a run of
|
||||
* them used to widen that read with `inSharedRecord || …`. The prefix was
|
||||
* right when it was written: `GET /api/auth/me` published the ACTOR's module
|
||||
* map, so a delegate's own preferences would have closed a page in somebody
|
||||
* else's record. Since the map became the record's, masked to what the grant
|
||||
* opens, the same prefix fails open in the other direction — the navigation
|
||||
* correctly drops the entry while a typed URL still renders the page for a
|
||||
* record whose owner switched the module off, and the routes behind these
|
||||
* pages carry no module gate of their own, so the page works.
|
||||
*
|
||||
* A DISCOVERED set rather than a named one. The first version of this guard
|
||||
* froze the six files one sweep happened to find; three more were carrying the
|
||||
* identical expression at the time, and a named list is structurally unable to
|
||||
* say so. So the sweep walks every component under `src/app` and
|
||||
* `src/components`, and the property is about the SHAPE: no boolean `||` may
|
||||
* join `inSharedRecord` to a module read. The window the matcher searches
|
||||
* cannot cross a `;`, a `{` or a `}`, so it sees one expression at a time and
|
||||
* a delegate check elsewhere in the same file is none of its business.
|
||||
*
|
||||
* A structural check rather than a render, because the thing that regressed is
|
||||
* one `||`: reaching these pages in a unit test would mean standing up the
|
||||
* auth query, the router and the shell, and none of that is where the defect
|
||||
* lives.
|
||||
*
|
||||
* Its limit, stated: it holds the two spellings of the OR. A gate that widened
|
||||
* itself some other way — a ternary, a helper returning the delegate flag —
|
||||
* would pass. The non-vacuity floor below is what keeps a broken walker from
|
||||
* reading as a clean sweep.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { stripComments, walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
/** Floors pinned below the real counts, so a broken walk throws. */
|
||||
const ROOTS: ReadonlyArray<[string, number]> = [
|
||||
["src/app", 100],
|
||||
["src/components", 700],
|
||||
];
|
||||
|
||||
/** The module read every gated surface makes. */
|
||||
const MODULE_READ = "user?.modules?.";
|
||||
|
||||
/**
|
||||
* The two spellings of the widening, in one expression each.
|
||||
*
|
||||
* `[^;{}]*` is the whole point: it cannot cross a statement end or a block
|
||||
* boundary, so a file that reads the module map in one place and asks about
|
||||
* the delegate in another does not match.
|
||||
*/
|
||||
const WIDENED = [
|
||||
/inSharedRecord\s*\|\|[^;{}]*user\?\.modules\?\./,
|
||||
/user\?\.modules\?\.[^;{}]*\|\|\s*inSharedRecord/,
|
||||
];
|
||||
|
||||
/** Every `.tsx` under the roots, with its comment-stripped source. */
|
||||
function componentSources(): Array<{ file: string; source: string }> {
|
||||
const out: Array<{ file: string; source: string }> = [];
|
||||
for (const [root, floor] of ROOTS) {
|
||||
const files = walkSourceFiles(join(ROOT, root), {
|
||||
floor,
|
||||
extensions: [".tsx"],
|
||||
});
|
||||
for (const rel of files) {
|
||||
if (rel.includes("__tests__/")) continue;
|
||||
out.push({
|
||||
file: `${root}/${rel}`,
|
||||
source: stripComments(readFileSync(join(ROOT, root, rel), "utf8")),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("a module gate reads the record's map alone", () => {
|
||||
const sources = componentSources();
|
||||
const gated = sources.filter(({ source }) => source.includes(MODULE_READ));
|
||||
|
||||
it("discovers a real set of module-gated surfaces", () => {
|
||||
// The empty-match trap: a walker that stopped walking, or a spelling of
|
||||
// the module read that moved, would make every assertion below vacuous.
|
||||
expect(sources.length, "no component was walked at all").toBeGreaterThan(
|
||||
500,
|
||||
);
|
||||
expect(
|
||||
gated.length,
|
||||
"no surface was found reading the module map",
|
||||
).toBeGreaterThan(20);
|
||||
// Spot checks, so a sweep that found twenty of the wrong files still
|
||||
// fails: these are module-gated pages by construction.
|
||||
const files = new Set(gated.map((entry) => entry.file));
|
||||
for (const file of [
|
||||
"src/app/vaccinations/page.tsx",
|
||||
"src/app/labs/page.tsx",
|
||||
"src/app/mood/page-client.tsx",
|
||||
"src/components/documents/documents-view.tsx",
|
||||
]) {
|
||||
expect(files, `${file} was not discovered`).toContain(file);
|
||||
}
|
||||
});
|
||||
|
||||
it("finds no gate widened by the delegate flag", () => {
|
||||
const widened = gated
|
||||
.filter(({ source }) => WIDENED.some((pattern) => pattern.test(source)))
|
||||
.map(({ file }) => file);
|
||||
|
||||
expect(
|
||||
widened,
|
||||
`these surfaces widen a module gate for a delegate; the record's masked map already answers for both cases:\n${widened
|
||||
.map((file) => ` ❌ ${file}`)
|
||||
.join("\n")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -45,16 +45,16 @@
|
||||
* - Comments are stripped before matching. `.../guardians/route.ts` names both
|
||||
* fence helpers in a docblock explaining why it uses neither, and counting
|
||||
* prose would classify an actor surface as delegable.
|
||||
* - It covers the PUBLISHED surface, which is not the whole delegable surface.
|
||||
* 116 route modules resolve the fence; 90 of them are in the OpenAPI document
|
||||
* and 26 have never been (mood entries, the record-settings tree, the
|
||||
* medication intake tree, several insight reads). That gap predates this
|
||||
* release and is not closed here — "frozen" means the published contract is
|
||||
* complete and consistent, not that every delegable route is described. What
|
||||
* this guard does guarantee is that one of those 26 cannot be published
|
||||
* without its refusal, because publishing it puts it in scope on the next run.
|
||||
* - It covered the PUBLISHED surface and not the whole delegable one. That was
|
||||
* the honest limit at v1.37.0, when 116 route modules resolved the fence and
|
||||
* 26 of them were in no OpenAPI document at all. The gap is closed on this
|
||||
* tree, so the limit is now an assertion rather than a caveat: the last leg
|
||||
* below recomputes the fenced modules from the route sources and requires
|
||||
* every one of them to be published. Two counts is one too many, so it names
|
||||
* no number — a hard-coded figure is what turned the paragraph it replaces
|
||||
* into a false claim about the contract.
|
||||
*/
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -63,6 +63,8 @@ import { openApiPaths } from "@/lib/openapi/routes";
|
||||
import { SHARING_ACCESS_DENIED_DESCRIPTION } from "@/lib/openapi/routes/shared";
|
||||
import { RETIRED_ROUTES } from "@/lib/http/retired-routes";
|
||||
|
||||
import { stripComments } from "./helpers/source-files";
|
||||
|
||||
/** Published paths that deliberately have no handler behind them. */
|
||||
const retiredPaths = new Set(RETIRED_ROUTES.map((route) => route.path));
|
||||
|
||||
@@ -80,8 +82,40 @@ interface PublishedOperation {
|
||||
handlerSource: string;
|
||||
}
|
||||
|
||||
function stripComments(text: string): string {
|
||||
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1");
|
||||
/**
|
||||
* Every `route.ts` under `src/app/api` whose handlers resolve the record fence.
|
||||
*
|
||||
* Comments stripped first, for the reason the operation matcher strips them:
|
||||
* one module names both fence helpers in a docblock explaining why it uses
|
||||
* neither.
|
||||
*/
|
||||
function fencedRouteModules(): string[] {
|
||||
const out: string[] = [];
|
||||
const walk = (dir: string) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
if (entry.name !== "route.ts") continue;
|
||||
const source = stripComments(readFileSync(full, "utf8"));
|
||||
if (/require(RecordAuth|GuardianAuth)\s*\(/.test(source)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(join(ROOT, "src/app/api"));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `…/src/app/api/labs/[id]/route.ts` → `/api/labs/{id}`. */
|
||||
function apiPathFor(moduleFile: string): string {
|
||||
return moduleFile
|
||||
.slice(join(ROOT, "src/app").length)
|
||||
.replaceAll("\\", "/")
|
||||
.replace(/\/route\.ts$/, "")
|
||||
.replace(/\[([^\]]+)\]/g, "{$1}");
|
||||
}
|
||||
|
||||
function routeModulePath(apiPath: string): string {
|
||||
@@ -334,6 +368,33 @@ describe("the sharing refusal every delegable path publishes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes every module that resolves the fence", () => {
|
||||
// The reverse direction of the leg above, and the one the docblock used to
|
||||
// apologise for: that one holds every PUBLISHED fenced operation to the
|
||||
// refusal, and says nothing about a fenced module nobody published. A
|
||||
// module missing from the document is a delegable surface a generated
|
||||
// client cannot see and a reader of the contract cannot find, which is
|
||||
// exactly how the mood-entry tree and the record-settings tree stayed
|
||||
// invisible for a release.
|
||||
const modules = fencedRouteModules();
|
||||
expect(
|
||||
modules.length,
|
||||
"no fenced route module was discovered — the matcher found nothing",
|
||||
).toBeGreaterThan(100);
|
||||
|
||||
const published = new Set(Object.keys(openApiPaths));
|
||||
const missing = modules
|
||||
.map((rel) => apiPathFor(rel))
|
||||
.filter((apiPath) => !published.has(apiPath));
|
||||
|
||||
expect(
|
||||
missing,
|
||||
`these route modules resolve the record fence and are in no published path:\n${missing
|
||||
.map((apiPath) => ` ❌ ${apiPath}`)
|
||||
.join("\n")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries the refusal into the generated document", () => {
|
||||
const spec = readFileSync(join(ROOT, "docs/api/openapi.yaml"), "utf8");
|
||||
expect(spec).toContain(SHARING_ACCESS_DENIED_DESCRIPTION.slice(0, 60));
|
||||
|
||||
@@ -161,12 +161,11 @@ describe("what the canonical readers make of the same payload", () => {
|
||||
expect(capabilities.sections).toEqual(fixture.sections);
|
||||
expect(capabilities.recordKind).toBe(fixture.recordKind);
|
||||
expect(capabilities.inSharedRecord).toBe(true);
|
||||
// Resolved server-side and rendered, never re-derived: `canAdd` follows
|
||||
// `canWrite`, and `canManage` follows the published `manageableDomains`
|
||||
// list rather than the level — a delegate's management reach is the
|
||||
// server's answer, section by section.
|
||||
// Resolved server-side and rendered, never re-derived: `canWrite` is the
|
||||
// published level, and both per-section answers follow the published
|
||||
// lists rather than the level — a delegate's reach is the server's
|
||||
// answer, section by section.
|
||||
expect(capabilities.canWrite).toBe(fixture.canWrite);
|
||||
expect(capabilities.canAdd).toBe(fixture.canWrite);
|
||||
const manageable = active?.manageableDomains ?? [];
|
||||
expect(capabilities.canManage).toBe(manageable.length > 0);
|
||||
expect(manageable.length > 0).toBe(fixture.level === "manage");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
* path is not proven at all. That leg lives in `e2e/account-sharing.spec.ts`.
|
||||
*
|
||||
* Mutation checks, run:
|
||||
* - `useRecordCapabilities` returning `canAdd: true` unconditionally → the
|
||||
* read-only legs for the intake row and the card menu go red.
|
||||
* - `useRecordCapabilities` answering `canWriteDomain` for every section →
|
||||
* the read-only legs for the intake row and the card menu go red.
|
||||
* - `DeleteButton` dropping its `canManageDomain` bail → "no row delete inside
|
||||
* somebody else's record" goes red; answering it for every section → the
|
||||
* vault leg of "a guardian's row delete follows the section" goes red.
|
||||
@@ -31,8 +31,12 @@ 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";
|
||||
import type {
|
||||
AccountAccess,
|
||||
AccountAccessEntry,
|
||||
} from "@/lib/sharing/account-access-view";
|
||||
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
const OWNER = {
|
||||
accountId: "acct-owner",
|
||||
@@ -87,6 +91,44 @@ const MANAGING: AccountAccess = {
|
||||
canSwitch: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* A grant with a SCOPE, published the way the server publishes one.
|
||||
*
|
||||
* Every fixture above carries `sections: null`, and that is the hole the
|
||||
* section-blind controls lived in: a WRITE grant scoped to one section answers
|
||||
* `canWrite: true` like any other, so a control asking the coarse question was
|
||||
* offered in sections the grant never opened. The two lists are the level ×
|
||||
* scope × route-table intersection, derived here by the same function the
|
||||
* server derives them with rather than by hand.
|
||||
*/
|
||||
function grant(
|
||||
level: AccountAccessEntry["level"],
|
||||
sections: ShareDomain[] | null,
|
||||
): AccountAccess {
|
||||
return {
|
||||
accounts: [OWNER],
|
||||
active: {
|
||||
...OWNER,
|
||||
access: level === "read" ? "read" : "write",
|
||||
level,
|
||||
sections,
|
||||
canWrite: level !== "read",
|
||||
writableDomains: delegatedDomains(level, sections, "write"),
|
||||
manageableDomains: delegatedDomains(level, sections, "manage"),
|
||||
},
|
||||
canSwitch: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** The write answer per section, for the pure helpers that take only that. */
|
||||
function writeCaps(
|
||||
level: AccountAccessEntry["level"],
|
||||
sections: ShareDomain[] | null,
|
||||
) {
|
||||
const writable = new Set(delegatedDomains(level, sections, "write"));
|
||||
return { canWriteDomain: (domain: ShareDomain) => writable.has(domain) };
|
||||
}
|
||||
|
||||
const mockAccessRef: { value: AccountAccess } = { value: OWN_RECORD };
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
@@ -140,6 +182,9 @@ 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 { LedgerRowItem } from "@/components/medications/dose-history-ledger";
|
||||
import { VaccinationsView } from "@/components/vaccinations/vaccinations-view";
|
||||
import { VisitsSection } from "@/components/encounters/visits-section";
|
||||
import type { EncounterDTO } from "@/lib/encounters/dto";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { DailyDigest } from "@/lib/daily/digest";
|
||||
import type { MeasurementReminder } from "@/hooks/use-measurement-reminders";
|
||||
@@ -450,17 +495,13 @@ describe("linking a document to an illness episode", () => {
|
||||
describe("the capture picker's kinds", () => {
|
||||
const ALL = ["measurement", "medication", "mood"] as const;
|
||||
|
||||
const OWNER_CAPS = { canAdd: true, canManageDomain: () => true };
|
||||
const WRITER_CAPS = { canAdd: true, canManageDomain: () => false };
|
||||
const READER_CAPS = { canAdd: false, canManageDomain: () => false };
|
||||
/** The picker asks one question per kind, of that kind's own section. */
|
||||
const caps = writeCaps;
|
||||
const OWNER_CAPS = { canWriteDomain: () => true };
|
||||
const WRITER_CAPS = caps("write", null);
|
||||
const READER_CAPS = caps("read", null);
|
||||
// A guardian: every section with a delegated route answers at MANAGE.
|
||||
const GUARDIAN_CAPS = {
|
||||
canAdd: true,
|
||||
canManageDomain: (domain: string) =>
|
||||
delegatedDomains("manage", null, "manage").includes(
|
||||
domain as "measurements",
|
||||
),
|
||||
};
|
||||
const GUARDIAN_CAPS = caps("manage", null);
|
||||
|
||||
it("offers everything in the caller's own record", () => {
|
||||
expect(visibleCaptureKinds(OWNER_CAPS, [...ALL])).toEqual([
|
||||
@@ -490,6 +531,37 @@ describe("the capture picker's kinds", () => {
|
||||
it("offers a read-only delegate nothing", () => {
|
||||
expect(visibleCaptureKinds(READER_CAPS, [...ALL])).toEqual([]);
|
||||
});
|
||||
|
||||
it("offers a scoped WRITE delegate only the kinds its sections cover", () => {
|
||||
// The gap this closes. The coarse answer was the grant's LEVEL with no
|
||||
// scope term, so a labs-scoped WRITE grant read as true and the picker
|
||||
// offered a weight form and a dose the server refuses at
|
||||
// `grantCoversDomain`.
|
||||
expect(visibleCaptureKinds(caps("write", ["labs"]), [...ALL])).toEqual([]);
|
||||
expect(
|
||||
visibleCaptureKinds(caps("write", ["measurements"]), [...ALL]),
|
||||
).toEqual(["measurement"]);
|
||||
expect(
|
||||
visibleCaptureKinds(caps("write", ["measurements", "medications"]), [
|
||||
...ALL,
|
||||
]),
|
||||
).toEqual(["measurement", "medication"]);
|
||||
});
|
||||
|
||||
it("offers a documents-scoped WRITE delegate nothing at all", () => {
|
||||
// The worst case: the vault takes no delegated write at any level, so the
|
||||
// grant's writable list is empty while `canWrite` still answers true.
|
||||
expect(visibleCaptureKinds(caps("write", ["documents"]), [...ALL])).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("offers a scoped MANAGE delegate the mood entry only with the mind section", () => {
|
||||
expect(visibleCaptureKinds(caps("manage", ["mind"]), [...ALL])).toEqual([
|
||||
"mood",
|
||||
]);
|
||||
expect(visibleCaptureKinds(caps("manage", ["labs"]), [...ALL])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -577,7 +649,7 @@ describe("a form opened before the record answered", () => {
|
||||
});
|
||||
|
||||
it("withdraws a dashboard quick-entry sheet the delegation does not admit", () => {
|
||||
const DELEGATE = { canAdd: true, canManageDomain: () => false };
|
||||
const DELEGATE = writeCaps("write", null);
|
||||
expect(admittedQuickEntry("mood", DELEGATE)).toBe(null);
|
||||
expect(admittedQuickEntry("measurement", DELEGATE)).toBe("measurement");
|
||||
expect(admittedQuickEntry("medicationIntake", DELEGATE)).toBe(
|
||||
@@ -586,25 +658,209 @@ describe("a form opened before the record answered", () => {
|
||||
});
|
||||
|
||||
it("keeps the mood sheet for a guardian, whose mind routes answer at MANAGE", () => {
|
||||
const GUARDIAN = {
|
||||
canAdd: true,
|
||||
canManageDomain: (domain: string) => domain === "mind",
|
||||
};
|
||||
expect(admittedQuickEntry("mood", GUARDIAN)).toBe("mood");
|
||||
expect(admittedQuickEntry("mood", writeCaps("manage", null))).toBe("mood");
|
||||
});
|
||||
|
||||
it("withdraws a sheet whose section the grant's scope leaves out", () => {
|
||||
const LABS_ONLY = writeCaps("write", ["labs"]);
|
||||
for (const sheet of ["measurement", "mood", "medicationIntake"] as const) {
|
||||
expect(admittedQuickEntry(sheet, LABS_ONLY), sheet).toBe(null);
|
||||
}
|
||||
});
|
||||
|
||||
it("withdraws every quick-entry sheet from a read-only delegate", () => {
|
||||
const READER = { canAdd: false, canManageDomain: () => false };
|
||||
const READER = writeCaps("read", null);
|
||||
for (const sheet of ["measurement", "mood", "medicationIntake"] as const) {
|
||||
expect(admittedQuickEntry(sheet, READER), sheet).toBe(null);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the owner's own sheets alone", () => {
|
||||
const OWNER_CAPS = { canAdd: true, canManageDomain: () => true };
|
||||
const OWNER_CAPS = { canWriteDomain: () => true };
|
||||
for (const sheet of ["measurement", "mood", "medicationIntake"] as const) {
|
||||
expect(admittedQuickEntry(sheet, OWNER_CAPS), sheet).toBe(sheet);
|
||||
}
|
||||
expect(admittedQuickEntry(null, OWNER_CAPS)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* The immunization log */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("the immunization log's add, edit and delete", () => {
|
||||
const DOSE = {
|
||||
id: "dose-1",
|
||||
occurredAt: "2026-03-01T00:00:00.000Z",
|
||||
antigenSlug: null,
|
||||
vaccineName: "Tetanus",
|
||||
doseNumber: null,
|
||||
seriesDoses: null,
|
||||
lotNumber: null,
|
||||
site: null,
|
||||
catalogEntry: null,
|
||||
series: [],
|
||||
practitioner: null,
|
||||
encounter: null,
|
||||
reminderId: null,
|
||||
note: null,
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const seedList = (client: QueryClient) => {
|
||||
client.setQueryData(queryKeys.vaccinationList(null), {
|
||||
vaccinations: [DOSE],
|
||||
});
|
||||
};
|
||||
|
||||
/** The row carries the edit affordance only while it opens something. */
|
||||
const rowIsTappable = (html: string) =>
|
||||
html.includes('data-slot-open="vaccination-row-open"');
|
||||
|
||||
it("offers all three in the caller's own record", () => {
|
||||
const html = render(OWN_RECORD, <VaccinationsView />, seedList);
|
||||
expect(html).toContain('data-slot="vaccination-add"');
|
||||
// The row opens the edit sheet, which is where the delete lives.
|
||||
expect(rowIsTappable(html)).toBe(true);
|
||||
});
|
||||
|
||||
it("offers a read-only delegate none of them", () => {
|
||||
// `POST /api/vaccinations` is WRITE and everything else is MANAGE, so a
|
||||
// READ delegate was being shown three controls and refused by all three.
|
||||
const html = render(
|
||||
grant("read", ["profile"]),
|
||||
<VaccinationsView />,
|
||||
seedList,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="vaccination-add"');
|
||||
expect(rowIsTappable(html)).toBe(false);
|
||||
});
|
||||
|
||||
it("offers a WRITE delegate the add and not the row", () => {
|
||||
const html = render(
|
||||
grant("write", ["profile"]),
|
||||
<VaccinationsView />,
|
||||
seedList,
|
||||
);
|
||||
expect(html).toContain('data-slot="vaccination-add"');
|
||||
expect(rowIsTappable(html), "editing and deleting a dose are MANAGE").toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("offers a MANAGE delegate holding the section both", () => {
|
||||
const html = render(
|
||||
grant("manage", ["profile"]),
|
||||
<VaccinationsView />,
|
||||
seedList,
|
||||
);
|
||||
expect(html).toContain('data-slot="vaccination-add"');
|
||||
expect(rowIsTappable(html)).toBe(true);
|
||||
});
|
||||
|
||||
it("offers a MANAGE delegate outside the section neither", () => {
|
||||
// The page is presentable to a `profile` grant; a grant scoped elsewhere
|
||||
// that still lands on the URL gets a log it can read and nothing else.
|
||||
const html = render(
|
||||
grant("manage", ["labs"]),
|
||||
<VaccinationsView />,
|
||||
seedList,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="vaccination-add"');
|
||||
expect(rowIsTappable(html)).toBe(false);
|
||||
});
|
||||
|
||||
it("offers the empty state's add on the same rule", () => {
|
||||
const seedEmpty = (client: QueryClient) => {
|
||||
client.setQueryData(queryKeys.vaccinationList(null), {
|
||||
vaccinations: [],
|
||||
});
|
||||
};
|
||||
expect(render(OWN_RECORD, <VaccinationsView />, seedEmpty)).toContain(
|
||||
'data-slot="vaccination-add-empty"',
|
||||
);
|
||||
expect(
|
||||
render(grant("read", ["profile"]), <VaccinationsView />, seedEmpty),
|
||||
).not.toContain('data-slot="vaccination-add-empty"');
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* The visits list */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("a visit row's edit sheet", () => {
|
||||
const VISIT: EncounterDTO = {
|
||||
id: "visit-1",
|
||||
occurredAt: "2026-08-01T09:00:00.000Z",
|
||||
status: "DONE",
|
||||
kind: "ROUTINE",
|
||||
practitioner: null,
|
||||
reason: null,
|
||||
outcome: null,
|
||||
reminderNextDueAt: null,
|
||||
createdAt: "2026-08-01T09:00:00.000Z",
|
||||
updatedAt: "2026-08-01T09:00:00.000Z",
|
||||
};
|
||||
|
||||
const seedList = (client: QueryClient) => {
|
||||
client.setQueryData(
|
||||
queryKeys.encounterList(null, null, undefined, undefined),
|
||||
{
|
||||
upcoming: [],
|
||||
past: [VISIT],
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/** The row's content is a button only while it opens something. */
|
||||
const rowIsTappable = (html: string) =>
|
||||
html.includes('data-slot="visit-card-open"');
|
||||
|
||||
it("opens for the owner", () => {
|
||||
expect(rowIsTappable(render(OWN_RECORD, <VisitsSection />, seedList))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not open for a READ or a WRITE delegate", () => {
|
||||
// The add button was the only thing asked, and it asks the right question
|
||||
// — `POST /api/encounters` is WRITE. The row underneath opens a sheet
|
||||
// whose Save is `PATCH` and whose Delete is `DELETE`, both MANAGE.
|
||||
for (const level of ["read", "write"] as const) {
|
||||
const html = render(
|
||||
grant(level, ["profile"]),
|
||||
<VisitsSection />,
|
||||
seedList,
|
||||
);
|
||||
expect(html, `${level} still sees the row`).toContain(
|
||||
'data-slot="visit-card"',
|
||||
);
|
||||
expect(rowIsTappable(html), `${level} may open the sheet`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the add button for a WRITE delegate", () => {
|
||||
// The two answers are different questions and must not collapse into one.
|
||||
expect(
|
||||
render(grant("write", ["profile"]), <VisitsSection />, seedList),
|
||||
).toContain('data-slot="visits-add"');
|
||||
expect(
|
||||
render(grant("read", ["profile"]), <VisitsSection />, seedList),
|
||||
).not.toContain('data-slot="visits-add"');
|
||||
});
|
||||
|
||||
it("opens for a MANAGE delegate holding the section, and not one outside it", () => {
|
||||
expect(
|
||||
rowIsTappable(
|
||||
render(grant("manage", ["profile"]), <VisitsSection />, seedList),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
rowIsTappable(
|
||||
render(grant("manage", ["labs"]), <VisitsSection />, seedList),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -44,9 +44,21 @@ import { formatBytes } from "@/lib/format/bytes";
|
||||
const FRESHNESS_STYLE: Record<OffhostBackupFreshness, string> = {
|
||||
fresh: "border-success/30 bg-success/10 text-success",
|
||||
due: "border-warning/30 bg-warning/10 text-warning",
|
||||
stale: "border-destructive/30 bg-destructive/10 text-destructive",
|
||||
never: "border-border bg-muted text-muted-foreground",
|
||||
unknown: "border-border bg-muted text-muted-foreground",
|
||||
stale: "border-destructive/30 bg-destructive/10 text-foreground",
|
||||
never: "border-border bg-muted text-foreground",
|
||||
unknown: "border-border bg-muted text-foreground",
|
||||
};
|
||||
|
||||
/**
|
||||
* `--destructive` as 12px badge text on its own wash measures 4.08:1 in the
|
||||
* light theme and 3.99:1 in the dark one, and `--muted-foreground` on
|
||||
* `bg-muted` 3.6:1 in the dark one, all under the AA floor. So the badge
|
||||
* takes the shape the admin status rows already use: the token paints a small
|
||||
* indicator and the wording stays in `text-foreground`. The word states the
|
||||
* condition, so nothing hangs on the colour alone.
|
||||
*/
|
||||
const FRESHNESS_INDICATOR: Partial<Record<OffhostBackupFreshness, string>> = {
|
||||
stale: "bg-destructive",
|
||||
};
|
||||
|
||||
function freshnessLabel(
|
||||
@@ -88,6 +100,16 @@ function AccountRow({ row }: { row: OffhostAccountRow }) {
|
||||
variant="outline"
|
||||
className={cn("shrink-0", FRESHNESS_STYLE[row.freshness])}
|
||||
>
|
||||
{FRESHNESS_INDICATOR[row.freshness] ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-slot="offhost-freshness-indicator"
|
||||
className={cn(
|
||||
"size-2 shrink-0 rounded-full",
|
||||
FRESHNESS_INDICATOR[row.freshness],
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{freshnessLabel(row.freshness, t)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock("next/navigation", () => ({
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({
|
||||
canManage: true,
|
||||
canAdd: true,
|
||||
canWriteDomain: () => true,
|
||||
canManageDomain: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -36,13 +36,19 @@ export function DashboardHeader({
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const mounted = useMounted();
|
||||
// v1.36.x — the dashboard quick-add offers three kinds; a delegation admits
|
||||
// two of them (a reading, a dose). Mood stays with the account whose record
|
||||
// it is, and the customize shortcut points at a settings page sharing does
|
||||
// not cover at all.
|
||||
const { canAdd, canManageDomain, inSharedRecord } = useRecordCapabilities();
|
||||
// The quick-add offers three kinds, and each one is asked about its own
|
||||
// section: a reading under `measurements`, a dose under `medications`, a
|
||||
// mood entry under `mind`. The coarse `canAdd` this used to gate the whole
|
||||
// menu on carries the grant's LEVEL and no scope, so it answered true for a
|
||||
// grant that opens neither of the two sections the menu never asked about.
|
||||
// The customize shortcut points at a settings page sharing does not cover at
|
||||
// all.
|
||||
const { canWriteDomain, inSharedRecord } = useRecordCapabilities();
|
||||
const canAddMeasurement = canWriteDomain("measurements");
|
||||
const canAddIntake = canWriteDomain("medications");
|
||||
// A mood entry is created at MANAGE under the mind section.
|
||||
const canAddMood = canManageDomain("mind");
|
||||
const canAddMood = canWriteDomain("mind");
|
||||
const canAddAnything = canAddMeasurement || canAddIntake || canAddMood;
|
||||
|
||||
// The pre-hero greeting derivation, kept hydration-safe: `user` comes
|
||||
// from the auth query, which can resolve before this boundary
|
||||
@@ -117,7 +123,7 @@ export function DashboardHeader({
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{canAdd && (
|
||||
{canAddAnything && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{/* v1.4.33 maintainer-item-7 — restore proportional sizing
|
||||
@@ -152,10 +158,12 @@ export function DashboardHeader({
|
||||
phase-A3 fix #1 hardened this with a unit guard at
|
||||
`src/app/__tests__/quick-add-labels.test.ts` — both labels
|
||||
must differ from each other AND from `common.add`. */}
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
|
||||
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMeasurement")}
|
||||
</DropdownMenuItem>
|
||||
{canAddMeasurement && (
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
|
||||
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMeasurement")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canAddMood && (
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
|
||||
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
@@ -167,12 +175,14 @@ export function DashboardHeader({
|
||||
the other two; the menu label is a self-contained
|
||||
verb-phrase so it doesn't collide with the trigger or
|
||||
the sibling rows (cf. quick-add-labels.test.ts). */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => onQuickEntry("medicationIntake")}
|
||||
>
|
||||
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMedicationIntake")}
|
||||
</DropdownMenuItem>
|
||||
{canAddIntake && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onQuickEntry("medicationIntake")}
|
||||
>
|
||||
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMedicationIntake")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
@@ -34,17 +34,7 @@ import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
export type QuickEntryDialog =
|
||||
"measurement" | "mood" | "medicationIntake" | null;
|
||||
|
||||
/**
|
||||
* v1.36.x — which of the three the delegation admits, and the same rule the
|
||||
* capture picker states at length.
|
||||
*
|
||||
* A WRITE grant covers entering a reading and marking a dose. A mood entry is
|
||||
* not an admitted verb and the server refuses it under a switch.
|
||||
*/
|
||||
const DELEGABLE_QUICK_ENTRIES: ReadonlySet<NonNullable<QuickEntryDialog>> =
|
||||
new Set(["measurement", "medicationIntake"]);
|
||||
|
||||
/** v1.38.12 — the section each sheet writes to; see the capture picker. */
|
||||
/** The section each sheet writes to; see the capture picker. */
|
||||
const QUICK_ENTRY_DOMAIN: Readonly<
|
||||
Record<NonNullable<QuickEntryDialog>, ShareDomain>
|
||||
> = {
|
||||
@@ -69,11 +59,10 @@ const QUICK_ENTRY_DOMAIN: Readonly<
|
||||
*/
|
||||
export function admittedQuickEntry(
|
||||
open: QuickEntryDialog,
|
||||
caps: Pick<RecordCapabilities, "canAdd" | "canManageDomain">,
|
||||
caps: Pick<RecordCapabilities, "canWriteDomain">,
|
||||
): QuickEntryDialog {
|
||||
if (open === null) return open;
|
||||
if (caps.canManageDomain(QUICK_ENTRY_DOMAIN[open])) return open;
|
||||
return caps.canAdd && DELEGABLE_QUICK_ENTRIES.has(open) ? open : null;
|
||||
return caps.canWriteDomain(QUICK_ENTRY_DOMAIN[open]) ? open : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -251,8 +251,7 @@ export function DocumentsView() {
|
||||
const queryClient = useQueryClient();
|
||||
const coachLaunch = useCoachLaunch();
|
||||
|
||||
const moduleEnabled =
|
||||
inSharedRecord || user?.modules?.inboundDocuments === true;
|
||||
const moduleEnabled = user?.modules?.inboundDocuments === true;
|
||||
|
||||
// UX redirect only — the API routes enforce the gate server-side.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -20,6 +20,16 @@
|
||||
* saved and a plain sentence saying the checkup stayed open. That is reported
|
||||
* rather than skipped in silence: somebody who believes a checkup was closed
|
||||
* will not look at it again.
|
||||
*
|
||||
* Capability note: this sheet carries no capability check of its own, by
|
||||
* decision. Save is a create on one mount and an edit on the other, and Delete
|
||||
* exists only in edit mode — so the question belongs to whoever opens it.
|
||||
* `VisitsSection` hands it a record only under `canManageDomain("profile")`,
|
||||
* and the two mounts that can only create (`EpisodeVisitsCard`,
|
||||
* `VorsorgeSection`) pass `encounter={null}` behind their own write check. A
|
||||
* THIRD mount that hands this sheet a record without asking the manage
|
||||
* question reintroduces an ungated Delete, and nothing here will fail. Ask at
|
||||
* the caller, or move the check in here for everyone.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
@@ -35,7 +35,14 @@ export function VisitCard({
|
||||
onOpen,
|
||||
}: {
|
||||
encounter: Encounter;
|
||||
onOpen: (encounter: Encounter) => void;
|
||||
/**
|
||||
* Opens the edit sheet. Omitted when the caller may not change the visit —
|
||||
* `PATCH` and `DELETE /api/encounters/{id}` are both `("manage", "profile")`,
|
||||
* so a READ or WRITE delegate gets the card as a card and not as a button.
|
||||
* A row that reads as tappable and answers 403 is the affordance saying
|
||||
* something about the grant that is not true.
|
||||
*/
|
||||
onOpen?: (encounter: Encounter) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
@@ -71,11 +78,7 @@ export function VisitCard({
|
||||
className="gap-2 py-3 md:py-4"
|
||||
>
|
||||
<CardContent className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(encounter)}
|
||||
className="focus-visible:ring-ring/50 w-full space-y-2 text-left focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<Body onOpen={onOpen} encounter={encounter}>
|
||||
<div className="flex items-start gap-2">
|
||||
<CalendarClock
|
||||
className="text-foreground mt-0.5 size-5 shrink-0"
|
||||
@@ -120,8 +123,37 @@ export function VisitCard({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
</Body>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's content, tappable or not.
|
||||
*
|
||||
* One element either way rather than two copies of the content: the read-only
|
||||
* form keeps the same spacing the button had, so a card that loses its edit
|
||||
* affordance does not also change shape.
|
||||
*/
|
||||
function Body({
|
||||
onOpen,
|
||||
encounter,
|
||||
children,
|
||||
}: {
|
||||
onOpen?: (encounter: Encounter) => void;
|
||||
encounter: Encounter;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (!onOpen) return <div className="w-full space-y-2">{children}</div>;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="visit-card-open"
|
||||
onClick={() => onOpen(encounter)}
|
||||
className="focus-visible:ring-ring/50 w-full space-y-2 text-left focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -30,9 +30,13 @@ import { VisitCard } from "./visit-card";
|
||||
|
||||
export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const { canWriteDomain } = useRecordCapabilities();
|
||||
// `POST /api/encounters` answers at WRITE under the profile section.
|
||||
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
|
||||
// `POST /api/encounters` answers at WRITE under the profile section; the
|
||||
// edit and the delete behind the row are both MANAGE. Only the add was
|
||||
// asked, so every row stayed tappable into a sheet whose Save and Delete
|
||||
// the server refuses.
|
||||
const canAddVisit = canWriteDomain("profile");
|
||||
const canManageVisits = canManageDomain("profile");
|
||||
const list = useEncounters(enabled);
|
||||
|
||||
// `null` closed, `"new"` create, a row edits it.
|
||||
@@ -109,7 +113,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
|
||||
<VisitCard
|
||||
key={encounter.id}
|
||||
encounter={encounter}
|
||||
onOpen={openSheet}
|
||||
onOpen={canManageVisits ? openSheet : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -125,7 +129,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
|
||||
<VisitCard
|
||||
key={encounter.id}
|
||||
encounter={encounter}
|
||||
onOpen={openSheet}
|
||||
onOpen={canManageVisits ? openSheet : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -320,7 +320,8 @@ export function IllnessView() {
|
||||
// v1.36.x — opening an illness entry is an admitted delegated write. Logging
|
||||
// a day against one, editing it, resolving it and deleting it are not, so a
|
||||
// delegate can start the record of an illness and nothing more.
|
||||
const { canAdd, inSharedRecord } = useRecordCapabilities();
|
||||
const { canWriteDomain, inSharedRecord } = useRecordCapabilities();
|
||||
const canAddEpisode = canWriteDomain("illness");
|
||||
const {
|
||||
data: episodes,
|
||||
isLoading,
|
||||
@@ -412,7 +413,7 @@ export function IllnessView() {
|
||||
)}
|
||||
{/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
|
||||
other module, not the bespoke "neue Episode". */}
|
||||
{canAdd && (
|
||||
{canAddEpisode && (
|
||||
<Button
|
||||
onClick={() => setNewOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
@@ -476,7 +477,7 @@ export function IllnessView() {
|
||||
description={t("illness.empty.body")}
|
||||
ctaSize="lg"
|
||||
action={
|
||||
canAdd ? (
|
||||
canAddEpisode ? (
|
||||
<Button onClick={() => setNewOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("illness.newEpisode")}
|
||||
|
||||
@@ -95,7 +95,8 @@ const READINGS_PAGE_SIZE = 200;
|
||||
*/
|
||||
export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
const { user } = useAuth();
|
||||
const { canAdd, canManageDomain } = useRecordCapabilities();
|
||||
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
|
||||
const canAddLab = canWriteDomain("labs");
|
||||
const canManageLabs = canManageDomain("labs");
|
||||
const { t, locale } = useTranslations();
|
||||
const router = useRouter();
|
||||
@@ -378,7 +379,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
) : null}
|
||||
{/* v1.36.x — entering a result is an admitted delegated write;
|
||||
renaming or removing the marker is not. */}
|
||||
{canAdd && (
|
||||
{canAddLab && (
|
||||
<Button
|
||||
onClick={() => setAddOpen(true)}
|
||||
// v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
|
||||
@@ -436,7 +437,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
title={t("labs.detail.emptyTitle")}
|
||||
description={t("labs.detail.emptyDescription")}
|
||||
action={
|
||||
canAdd ? (
|
||||
canAddLab ? (
|
||||
<Button onClick={() => setAddOpen(true)}>
|
||||
{t("labs.addResult")}
|
||||
</Button>
|
||||
|
||||
@@ -93,7 +93,8 @@ function LabRangeBarSlot({ reading }: { reading: LabResultDto }) {
|
||||
|
||||
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
const { t } = useTranslations();
|
||||
const { canAdd } = useRecordCapabilities();
|
||||
const { canWriteDomain } = useRecordCapabilities();
|
||||
const canAddLab = canWriteDomain("labs");
|
||||
const { prefs } = useModuleListPrefs("labs");
|
||||
|
||||
// v1.22 — a short, factual line under each marker heading describing what the
|
||||
@@ -184,7 +185,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
title={t("labs.emptyTitle")}
|
||||
description={t("labs.emptyDescription")}
|
||||
action={
|
||||
onAddFirst && canAdd ? (
|
||||
onAddFirst && canAddLab ? (
|
||||
<Button onClick={onAddFirst}>{t("labs.addFirst")}</Button>
|
||||
) : undefined
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({
|
||||
inSharedRecord: false,
|
||||
canWrite: false,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
canWriteDomain: () => true,
|
||||
canManageDomain: () => true,
|
||||
|
||||
@@ -11,7 +11,6 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({
|
||||
inSharedRecord: false,
|
||||
canWrite: false,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
canWriteDomain: () => true,
|
||||
canManageDomain: () => true,
|
||||
|
||||
@@ -51,24 +51,18 @@ import {
|
||||
type CaptureKind = "measurement" | "medication" | "mood";
|
||||
|
||||
/**
|
||||
* v1.36.x — which of the capture surfaces a delegation admits.
|
||||
* The section each capture surface writes to.
|
||||
*
|
||||
* A WRITE grant covers entering a reading and marking a dose. It does not
|
||||
* cover a mood entry: that verb stays on the owner's own authentication and
|
||||
* the server refuses it under a switch. The picker is the fastest path into
|
||||
* all three on a phone, so it is the surface where offering an unadmitted one
|
||||
* costs the most.
|
||||
*/
|
||||
const DELEGABLE_CAPTURE_KINDS: ReadonlySet<CaptureKind> = new Set([
|
||||
"measurement",
|
||||
"medication",
|
||||
]);
|
||||
|
||||
/**
|
||||
* v1.38.12 — the section each capture surface writes to. A MANAGE grant that
|
||||
* reaches the section's routes is offered the surface; a mood entry is a
|
||||
* MANAGE create under `mind`, so a guardian gets it and a WRITE delegate does
|
||||
* not.
|
||||
* One question per kind, asked of that kind's own section. The picker used to
|
||||
* ask a coarse one as well — a closed list of kinds a WRITE grant admits,
|
||||
* offered whenever the grant could write anywhere — and that answer is the
|
||||
* grant's LEVEL with no scope term at all. A WRITE grant scoped to `["labs"]`
|
||||
* read as true and got a weight form the server refuses; one scoped to
|
||||
* `["documents"]`, which takes no delegated write at any level, got both.
|
||||
*
|
||||
* `writableDomains` is already the level × scope × route-table intersection,
|
||||
* so asking it per section answers both cases and the mood entry's
|
||||
* MANAGE-only create as well.
|
||||
*/
|
||||
const CAPTURE_KIND_DOMAIN: Readonly<Record<CaptureKind, ShareDomain>> = {
|
||||
measurement: "measurements",
|
||||
@@ -117,13 +111,10 @@ export function admittedCaptureKind(
|
||||
* cannot tap the button that opens it.
|
||||
*/
|
||||
export function visibleCaptureKinds(
|
||||
caps: Pick<RecordCapabilities, "canAdd" | "canManageDomain">,
|
||||
caps: Pick<RecordCapabilities, "canWriteDomain">,
|
||||
kinds: ReadonlyArray<CaptureKind>,
|
||||
): CaptureKind[] {
|
||||
return kinds.filter((kind) => {
|
||||
if (caps.canManageDomain(CAPTURE_KIND_DOMAIN[kind])) return true;
|
||||
return caps.canAdd && DELEGABLE_CAPTURE_KINDS.has(kind);
|
||||
});
|
||||
return kinds.filter((kind) => caps.canWriteDomain(CAPTURE_KIND_DOMAIN[kind]));
|
||||
}
|
||||
|
||||
interface CapturePickerProps {
|
||||
|
||||
@@ -224,11 +224,28 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
tourId: "nav-documents",
|
||||
requiresModule: "inboundDocuments",
|
||||
},
|
||||
// v1.36.0 — Insights and the Coach carry no `sharedRecord` flag, so they
|
||||
// drop out under a switch. Both are AI surfaces, and AI egress of a
|
||||
// person's health data rides the consent THAT person gave for their own
|
||||
// use; a delegate triggering it would create a consent-shaped act the owner
|
||||
// never made. Non-delegable in v1, server-side and here (design §4).
|
||||
// Insights and the Coach carry no `sharedRecord` flag, so they drop out
|
||||
// under a switch. The reason has changed since v1.36.0 and the old wording
|
||||
// ("non-delegable, server-side and here") is no longer true of the tiles:
|
||||
// twenty-seven `/api/insights/*` reads plus `/api/dashboard/summary` and
|
||||
// `/api/export/health-record` declare `("manage", "record")`, and the
|
||||
// AI-egress objection they were closed for is answered by construction —
|
||||
// the record resolver stamps `setDelegatedGenerationSuppressed`, so a
|
||||
// delegate reads what the owner's own account generated and causes no
|
||||
// egress.
|
||||
//
|
||||
// What keeps the door shut is the OVERVIEW's own reads rather than the
|
||||
// tiles': `/api/insights/layout` (`insights/layout/route.ts`) and
|
||||
// `/api/insights/generate` (`insights/generate/route.ts`) both resolve
|
||||
// `requireAuth()`, which refuses any switch at any level. Opening the entry
|
||||
// without deciding what those two mean for a delegate — whose saved layout,
|
||||
// and whether the briefing reads or regenerates — would hand a MANAGE
|
||||
// delegate a page whose chrome 403s while its tiles answer. That is a
|
||||
// decision about two more routes, not a flag on this one.
|
||||
//
|
||||
// The Coach stays closed on the original argument, unchanged:
|
||||
// `/api/insights/chat` is genuinely not delegable, and the page is a
|
||||
// generation surface rather than a read of one.
|
||||
{
|
||||
href: "/insights",
|
||||
tKey: "nav.insights",
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ vi.mock("@/hooks/use-encounters", () => ({
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({
|
||||
canManage: true,
|
||||
canAdd: true,
|
||||
canWriteDomain: () => true,
|
||||
canManageDomain: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -292,8 +292,9 @@ export function MeasurementList({
|
||||
// v1.36.x — inside somebody else's record, only the add path survives:
|
||||
// entering a reading is an admitted delegated write, editing and deleting
|
||||
// one are not. The row controls are dropped rather than disabled.
|
||||
const { canAdd, canManage, inSharedRecord, canWrite } =
|
||||
const { canWriteDomain, canManage, inSharedRecord, canWrite } =
|
||||
useRecordCapabilities();
|
||||
const canAddMeasurement = canWriteDomain("measurements");
|
||||
const activeRecord = user?.accountAccess?.active ?? null;
|
||||
// Named only when the sentence below is owed: a delegate who may add but
|
||||
// not change. In the caller's own record, and for a read-only delegate who
|
||||
@@ -1068,7 +1069,7 @@ export function MeasurementList({
|
||||
>
|
||||
{t("measurements.emptyResetFilter")}
|
||||
</Button>
|
||||
) : onAddFirst && canAdd ? (
|
||||
) : onAddFirst && canAddMeasurement ? (
|
||||
<Button onClick={onAddFirst}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("measurements.emptyAddFirst")}
|
||||
|
||||
@@ -36,8 +36,9 @@ export function MedicationIntakeActions({
|
||||
// v1.36.x — marking a dose taken or skipped is the verb the delegation was
|
||||
// written for. A read-only delegate sees the card and its schedule and no
|
||||
// action row; the card is otherwise unchanged.
|
||||
const { canAdd } = useRecordCapabilities();
|
||||
if (!canAdd) return null;
|
||||
const { canWriteDomain } = useRecordCapabilities();
|
||||
const canRecordIntake = canWriteDomain("medications");
|
||||
if (!canRecordIntake) return null;
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -175,7 +175,7 @@ export function LedgerAddDialog({
|
||||
// confirmation names the record, because "Taken" alone is the one
|
||||
// confirmation a person acting for somebody else does not need. The
|
||||
// ledger's "+ Eintrag" button that opens this dialog is gated on
|
||||
// `canAdd`, so a delegate reaches it.
|
||||
// `canWriteDomain("medications")`, so a delegate reaches it.
|
||||
const toastOptions = intakeToastOptions({
|
||||
recordName,
|
||||
eventId: undefined,
|
||||
|
||||
@@ -173,7 +173,8 @@ export function DoseHistoryLedger({
|
||||
// v1.36.x — a delegate may record a dose, which is what "add" and the
|
||||
// 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();
|
||||
const { canWriteDomain } = useRecordCapabilities();
|
||||
const canAddIntake = canWriteDomain("medications");
|
||||
// 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
|
||||
@@ -434,7 +435,7 @@ export function DoseHistoryLedger({
|
||||
{t("medications.detail.verlauf.summaryEmpty")}
|
||||
</p>
|
||||
)}
|
||||
{canAdd && (
|
||||
{canAddIntake && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -598,7 +599,8 @@ export function LedgerRowItem({
|
||||
onUnpin: (eventId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { canAdd, canManageDomain } = useRecordCapabilities();
|
||||
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
|
||||
const canAddIntake = canWriteDomain("medications");
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
const fmt = useFormatters();
|
||||
|
||||
@@ -705,7 +707,7 @@ export function LedgerRowItem({
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{showTakenButton && canAdd && (
|
||||
{showTakenButton && canAddIntake && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -734,7 +736,7 @@ export function LedgerRowItem({
|
||||
items in one menu, a delegate gets skip as its own control beside
|
||||
take, which is the shape the card already uses. The owner keeps it
|
||||
where it was. */}
|
||||
{showTakenButton && canAdd && !canManageMedications && (
|
||||
{showTakenButton && canAddIntake && !canManageMedications && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -62,9 +62,10 @@ export function MedicationCardMenu({
|
||||
// medication and opening its history editor are not. When nothing in the
|
||||
// menu survives, the trigger goes with it rather than opening onto an empty
|
||||
// sheet. The card itself is untouched: no wash, no badge, no colour change.
|
||||
const { canAdd, canManageDomain } = useRecordCapabilities();
|
||||
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
|
||||
const canAddSideEffect = canWriteDomain("medications");
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
const showSideEffect = onLogSideEffect != null && canAdd;
|
||||
const showSideEffect = onLogSideEffect != null && canAddSideEffect;
|
||||
if (!canManageMedications && !showSideEffect) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -407,7 +407,8 @@ function MedicationTableRowItem({
|
||||
const { intakeLoading, recordIntake } = useMedicationIntake({ medication });
|
||||
// v1.36.x — marking a dose is admitted; a read-only delegate sees the row
|
||||
// without its action pair.
|
||||
const { canAdd } = useRecordCapabilities();
|
||||
const { canWriteDomain } = useRecordCapabilities();
|
||||
const canAddIntake = canWriteDomain("medications");
|
||||
|
||||
// SAME batched compliance source as the cards.
|
||||
const { data: compliance } = useMedicationComplianceSummary(medication.id);
|
||||
@@ -685,7 +686,7 @@ function MedicationTableRowItem({
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{stockCell}</TableCell>
|
||||
<TableCell>
|
||||
{medication.active && canAdd ? (
|
||||
{medication.active && canAddIntake ? (
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
size="icon"
|
||||
|
||||
@@ -112,7 +112,8 @@ function entryI18nKey(entry: MedicationSideEffectEntry): string {
|
||||
|
||||
export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
const { canAdd, canManageDomain } = useRecordCapabilities();
|
||||
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
|
||||
const canAddSideEffect = canWriteDomain("medications");
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -250,7 +251,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
)}
|
||||
{/* v1.36.x — noting a side effect is an admitted delegated write.
|
||||
Removing one that is already noted is not. */}
|
||||
{canAdd && (
|
||||
{canAddSideEffect && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
||||
@@ -157,6 +157,10 @@ function DoseRow({
|
||||
<Card
|
||||
className="hover:bg-muted/40 gap-0 transition-colors"
|
||||
data-slot="vaccination-row"
|
||||
// A second, stable marker for the state rather than for the row: the
|
||||
// `role` is markup that could legitimately change, and a guard matching
|
||||
// on it would pass the day a real `<button>` replaced the card.
|
||||
data-slot-open={onEdit ? "vaccination-row-open" : undefined}
|
||||
data-vaccination-id={record.id}
|
||||
onClick={onEdit ? () => onEdit(record) : undefined}
|
||||
role={onEdit ? "button" : undefined}
|
||||
|
||||
@@ -10,6 +10,13 @@
|
||||
* On a successful create the caller may raise the booster-mint prompt; this
|
||||
* sheet completes and closes regardless of what the person does with it. The
|
||||
* mint is an offer, never a step.
|
||||
*
|
||||
* Capability note: the same posture as the visit sheet. No check here; Delete
|
||||
* renders only in edit mode, and the one mount that can reach edit mode
|
||||
* (`VaccinationsView`) asks `canManageDomain("profile")` before handing over a
|
||||
* record, while its create path asks `canWriteDomain("profile")`. A second
|
||||
* mount that skips either question reintroduces an ungated control with
|
||||
* nothing failing.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
@@ -18,6 +18,7 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { VaccinationList } from "./vaccination-list";
|
||||
@@ -27,6 +28,19 @@ import { useVaccinations, type Vaccination } from "./use-vaccinations";
|
||||
|
||||
export function VaccinationsView() {
|
||||
const { t } = useTranslations();
|
||||
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
|
||||
// `POST /api/vaccinations` answers at WRITE; the edit, the delete and the
|
||||
// restore behind it are all MANAGE. The page is presentable to every
|
||||
// `profile`-scoped grant, so without this a READ delegate was shown add,
|
||||
// edit and delete, and every one of them was refused by a server that was
|
||||
// right to refuse. Same split, same domain, same shape as the address book
|
||||
// next door.
|
||||
const canAddDose = canWriteDomain("profile");
|
||||
const canManageProfile = canManageDomain("profile");
|
||||
// Planning the booster mints a `MeasurementReminder`, which lives in the
|
||||
// measurements section rather than this one. A grant that opened only the
|
||||
// health background is refused there, so the offer is not made.
|
||||
const canPlanBooster = canWriteDomain("measurements");
|
||||
const { data, isLoading, isError, refetch } = useVaccinations();
|
||||
const records = data?.vaccinations ?? [];
|
||||
|
||||
@@ -50,6 +64,17 @@ export function VaccinationsView() {
|
||||
setSheetOpen(true);
|
||||
};
|
||||
|
||||
const addButton = canAddDose ? (
|
||||
<Button
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="vaccination-add"
|
||||
onClick={openCreate}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
@@ -59,16 +84,7 @@ export function VaccinationsView() {
|
||||
</span>
|
||||
}
|
||||
description={t("vaccinations.subtitle")}
|
||||
actions={
|
||||
<Button
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="vaccination-add"
|
||||
onClick={openCreate}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
}
|
||||
actions={addButton}
|
||||
/>
|
||||
|
||||
<VaccinationSheet
|
||||
@@ -79,7 +95,8 @@ export function VaccinationsView() {
|
||||
onCreated={(created) => {
|
||||
// Offer the booster only when the catalogue entry carries an
|
||||
// interval; a free-text or one-off dose never prompts.
|
||||
if (boosterOfferFor(created)) setBoosterFor(created);
|
||||
if (canPlanBooster && boosterOfferFor(created))
|
||||
setBoosterFor(created);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -102,21 +119,26 @@ export function VaccinationsView() {
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : records.length > 0 ? (
|
||||
<VaccinationList records={records} onEdit={openEdit} />
|
||||
<VaccinationList
|
||||
records={records}
|
||||
onEdit={canManageProfile ? openEdit : undefined}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Syringe className="size-6" />}
|
||||
title={t("vaccinations.empty.title")}
|
||||
description={t("vaccinations.empty.description")}
|
||||
action={
|
||||
<Button
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="vaccination-add-empty"
|
||||
onClick={openCreate}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
canAddDose ? (
|
||||
<Button
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="vaccination-add-empty"
|
||||
onClick={openCreate}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
* Mutation checks, run:
|
||||
* - return `canManage: true` for a WRITE record → "a delegate may not change
|
||||
* what is already there" goes red.
|
||||
* - return `canAdd: true` for a READ record → "a read-only delegate adds
|
||||
* nothing" goes red.
|
||||
* - make the `!active` branch return `canAdd: false` → "the caller's own
|
||||
* record keeps everything" goes red.
|
||||
* - answer `canWriteDomain` from `level` instead of the published list →
|
||||
* "a read-only delegate adds nothing" and the scoped legs go red.
|
||||
* - make the `!active` branch answer `canWriteDomain: false` → "the caller's
|
||||
* own record keeps everything" goes red.
|
||||
* - answer `canManageDomain` from `level === "manage"` instead of the list →
|
||||
* "the vault is never manageable" goes red.
|
||||
*/
|
||||
@@ -76,7 +76,6 @@ describe("resolveRecordCapabilities", () => {
|
||||
expect(own).toMatchObject({
|
||||
inSharedRecord: false,
|
||||
canWrite: false,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
// No grant, so no level and no narrowing — not a fabricated "manage"
|
||||
// over all eight sections, which a consumer would then have to tell
|
||||
@@ -91,10 +90,12 @@ describe("resolveRecordCapabilities", () => {
|
||||
}
|
||||
expect(resolveRecordCapabilities(undefined)).toMatchObject({
|
||||
inSharedRecord: false,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
recordKind: "self",
|
||||
});
|
||||
expect(resolveRecordCapabilities(undefined).canWriteDomain("labs")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a malformed published block instead of treating it as own record", () => {
|
||||
@@ -102,7 +103,6 @@ describe("resolveRecordCapabilities", () => {
|
||||
expect(refused).toMatchObject({
|
||||
inSharedRecord: true,
|
||||
canWrite: false,
|
||||
canAdd: false,
|
||||
canManage: false,
|
||||
level: null,
|
||||
sections: [],
|
||||
@@ -120,7 +120,6 @@ describe("resolveRecordCapabilities", () => {
|
||||
expect(reader).toMatchObject({
|
||||
inSharedRecord: true,
|
||||
canWrite: false,
|
||||
canAdd: false,
|
||||
canManage: false,
|
||||
level: "read",
|
||||
sections: null,
|
||||
@@ -139,7 +138,6 @@ describe("resolveRecordCapabilities", () => {
|
||||
expect(writer).toMatchObject({
|
||||
inSharedRecord: true,
|
||||
canWrite: true,
|
||||
canAdd: true,
|
||||
canManage: false,
|
||||
level: "write",
|
||||
sections: null,
|
||||
@@ -155,6 +153,26 @@ describe("resolveRecordCapabilities", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("a grant that can write nothing is still a WRITE grant, and offers nothing", () => {
|
||||
// The reason the coarse answer is gone. `canWrite` is the LEVEL and has no
|
||||
// scope term, so a WRITE grant scoped to the vault — which takes no
|
||||
// delegated write at any level — answers true while every section answers
|
||||
// false. A control gating on the level would paint an upload button that
|
||||
// 403s at `grantCoversDomain`.
|
||||
const vaultOnly = resolveRecordCapabilities({
|
||||
...WRITABLE,
|
||||
sections: ["documents"],
|
||||
writableDomains: [],
|
||||
manageableDomains: [],
|
||||
});
|
||||
expect(vaultOnly.canWrite).toBe(true);
|
||||
for (const domain of SHARE_DOMAINS) {
|
||||
expect(vaultOnly.canWriteDomain(domain), domain).toBe(false);
|
||||
expect(vaultOnly.canManageDomain(domain), domain).toBe(false);
|
||||
}
|
||||
expect(vaultOnly.canManage).toBe(false);
|
||||
});
|
||||
|
||||
it("carries the level and the sections through untouched", () => {
|
||||
// The two facts v1.37.0 adds are bound, not interpreted. `sections` is
|
||||
// the server's list in the server's order; `level` is the server's word.
|
||||
@@ -177,7 +195,6 @@ describe("resolveRecordCapabilities", () => {
|
||||
expect(manager).toMatchObject({
|
||||
inSharedRecord: true,
|
||||
canWrite: true,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
level: "manage",
|
||||
sections: null,
|
||||
@@ -219,7 +236,12 @@ describe("resolveRecordCapabilities", () => {
|
||||
access: "write",
|
||||
canWrite: false,
|
||||
};
|
||||
expect(resolveRecordCapabilities(disagreeing).canAdd).toBe(false);
|
||||
for (const domain of SHARE_DOMAINS) {
|
||||
expect(
|
||||
resolveRecordCapabilities(disagreeing).canWriteDomain(domain),
|
||||
domain,
|
||||
).toBe(false);
|
||||
}
|
||||
// The same posture for the lists: a scoped WRITE grant is writable only
|
||||
// where its list says so, not wherever its level would reach.
|
||||
const scopedWriter: AccountAccessEntry = {
|
||||
@@ -246,7 +268,7 @@ describe("resolveRecordCapabilities", () => {
|
||||
* every delegable route will refuse, while `active` being null would otherwise
|
||||
* make this hook answer "your own record, all controls". That combination
|
||||
* paints an add button on a page whose every write is about to 403 — the exact
|
||||
* failure `canAdd` exists to end.
|
||||
* failure the per-section answers exist to end.
|
||||
*/
|
||||
describe("an unprovable record context withholds every control", () => {
|
||||
it("holds when the raw selector names a record the grant no longer opens", () => {
|
||||
@@ -254,7 +276,9 @@ describe("an unprovable record context withholds every control", () => {
|
||||
recordContextIsUnproven({ epoch: 3, scope: "acct-owner" }, null),
|
||||
).toBe(true);
|
||||
const held = resolveRecordCapabilities(null, false, false, true);
|
||||
expect(held.canAdd).toBe(false);
|
||||
for (const domain of SHARE_DOMAINS) {
|
||||
expect(held.canWriteDomain(domain), domain).toBe(false);
|
||||
}
|
||||
expect(held.canManage).toBe(false);
|
||||
// REFUSED, not pending. `recordSessionPending` renders a bare spinner with
|
||||
// no controls, which is right for a switch in flight and a wedge here: this
|
||||
|
||||
@@ -43,9 +43,8 @@ const ALWAYS = () => true;
|
||||
* server's table (`domain-write-support.ts`), published per grant as two
|
||||
* lists on `accountAccess.active`. Four answers carry that:
|
||||
*
|
||||
* - `canAdd` — may this person add one of the admitted kinds.
|
||||
* - `canWriteDomain(domain)` — is there a delegated write in this section
|
||||
* the grant satisfies.
|
||||
* the grant satisfies. The only question a create affordance asks.
|
||||
* - `canManageDomain(domain)` — may this person change what is already
|
||||
* there in this section, or add what a WRITE grant does not cover.
|
||||
* - `canManage` — the coarse switch: is there ANY section this person may
|
||||
@@ -53,7 +52,11 @@ const ALWAYS = () => true;
|
||||
* because a control belongs to one section and the answer differs per
|
||||
* section (the vault is read-only under every grant).
|
||||
*
|
||||
* A surface that offers an admitted create asks `canAdd`. A control that
|
||||
* A surface that offers an admitted create asks `canWriteDomain` for its own
|
||||
* section. There is deliberately no coarse "may this person add anything":
|
||||
* `canWrite` is the grant's level with no scope term, so a WRITE grant scoped
|
||||
* to `["labs"]` answers true and a weight form built on that answer 403s at
|
||||
* `grantCoversDomain`. A control that
|
||||
* edits, deletes or creates what WRITE does not cover asks `canManageDomain`
|
||||
* for its own section. A control whose route resolves the caller rather than
|
||||
* the record (settings, credentials, AI budget, chart preferences) asks
|
||||
@@ -92,10 +95,13 @@ export interface RecordCapabilities {
|
||||
accessRefused?: boolean;
|
||||
/** Is this browser acting on somebody else's record right now. */
|
||||
inSharedRecord: boolean;
|
||||
/** The grant's resolved level for the record on screen. */
|
||||
/**
|
||||
* The grant's resolved level for the record on screen — LEVEL ONLY, with no
|
||||
* scope term. A WRITE grant scoped to the document vault answers true here
|
||||
* and can write nothing anywhere. Never gate a control on it; ask
|
||||
* {@link canWriteDomain} for the control's own section.
|
||||
*/
|
||||
canWrite: boolean;
|
||||
/** May the caller add an entry of a kind the delegation admits. */
|
||||
canAdd: boolean;
|
||||
/**
|
||||
* May the caller change what exists somewhere in this record, or add what
|
||||
* the delegation excludes. In one's own record, always. In a shared record,
|
||||
@@ -166,7 +172,6 @@ export function resolveRecordCapabilities(
|
||||
recordSessionPending: recordSessionPending || undefined,
|
||||
inSharedRecord: true,
|
||||
canWrite: false,
|
||||
canAdd: false,
|
||||
canManage: false,
|
||||
canWriteDomain: NEVER,
|
||||
canManageDomain: NEVER,
|
||||
@@ -179,7 +184,6 @@ export function resolveRecordCapabilities(
|
||||
return {
|
||||
inSharedRecord: false,
|
||||
canWrite: false,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
canWriteDomain: ALWAYS,
|
||||
canManageDomain: ALWAYS,
|
||||
@@ -201,7 +205,6 @@ export function resolveRecordCapabilities(
|
||||
return {
|
||||
inSharedRecord: true,
|
||||
canWrite: active.canWrite,
|
||||
canAdd: active.canWrite,
|
||||
canManage: manageable.size > 0,
|
||||
canWriteDomain: (domain) => writable.has(domain),
|
||||
canManageDomain: (domain) => manageable.has(domain),
|
||||
@@ -243,7 +246,7 @@ export function resolveRecordCapabilities(
|
||||
* opens: every delegable route will refuse, while `active` being null makes
|
||||
* this hook answer "your own record, all controls". That combination paints an
|
||||
* add button on a page whose every write is about to 403 — the exact failure
|
||||
* `canAdd` was introduced to end. So a disagreement holds instead.
|
||||
* the per-section answers exist to end. So a disagreement holds instead.
|
||||
*
|
||||
* A null or absent `recordSession` is not a disagreement. It is the Bearer
|
||||
* transport (no session row, no switch state) or a server image that predates
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
replaceTargets,
|
||||
type LinkTargetKind,
|
||||
} from "@/lib/links";
|
||||
import { grantCoversDomain } from "@/lib/sharing/grants";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
import type { EncounterLinksDTO } from "@/lib/encounters/dto";
|
||||
import {
|
||||
@@ -59,27 +58,6 @@ export const ENCOUNTER_INCLUDE = {
|
||||
reminder: { select: { nextDueAt: true } },
|
||||
} as const satisfies Prisma.EncounterInclude;
|
||||
|
||||
/**
|
||||
* Which sections of the record the caller is actually inside.
|
||||
*
|
||||
* Returns a predicate rather than a set so the owner path costs nothing: with
|
||||
* no grant there is nothing to look up and every domain is open. A grant that
|
||||
* vanished between the auth check and here answers "no" to everything, which
|
||||
* is the fail-closed direction.
|
||||
*/
|
||||
export async function actingDomainVisibility(
|
||||
tx: Prisma.TransactionClient,
|
||||
grantId: string | null,
|
||||
): Promise<(domain: ShareDomain) => boolean> {
|
||||
if (grantId === null) return () => true;
|
||||
const grant = await tx.accountGrant.findUnique({
|
||||
where: { id: grantId },
|
||||
select: { scopeJson: true },
|
||||
});
|
||||
if (!grant) return () => false;
|
||||
return (domain) => grantCoversDomain(grant, domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which section of the record each link family's TARGET belongs to.
|
||||
*
|
||||
|
||||
@@ -110,23 +110,3 @@ export function summariseGlitchtipDelivery(
|
||||
windowHours: GLITCHTIP_DELIVERY_WINDOW_HOURS,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getGlitchtipDeliveryState(): Promise<GlitchtipDeliveryState> {
|
||||
try {
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
glitchtipLastOkAt: true,
|
||||
glitchtipLastFailureAt: true,
|
||||
glitchtipLastFailureReason: true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
lastOkAt: settings?.glitchtipLastOkAt ?? null,
|
||||
lastFailureAt: settings?.glitchtipLastFailureAt ?? null,
|
||||
lastFailureReason: settings?.glitchtipLastFailureReason ?? null,
|
||||
};
|
||||
} catch {
|
||||
return { lastOkAt: null, lastFailureAt: null, lastFailureReason: null };
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,11 @@
|
||||
* `meta.errorCode` is the mechanism this API chose for machine-readable errors
|
||||
* — the envelope's own description tells a client to branch on it rather than
|
||||
* on the prose — and until this file existed a client could not enumerate it.
|
||||
* Sixteen of the two hundred and forty-two codes appeared somewhere in
|
||||
* `docs/api/openapi.yaml`; the rest existed only in server source, so the only
|
||||
* way to learn one was to trigger the error in testing and the only way to
|
||||
* learn a new one had appeared was to trigger that too.
|
||||
* When this file was written, sixteen of its two hundred and forty-two codes
|
||||
* appeared somewhere in `docs/api/openapi.yaml`; the rest existed only in
|
||||
* server source, so the only way to learn one was to trigger the error in
|
||||
* testing and the only way to learn a new one had appeared was to trigger
|
||||
* that too.
|
||||
*
|
||||
* The list is generated from the code and then committed as a literal, so it is
|
||||
* reviewable in a diff and stable across a release. `error-code-catalogue.test.ts`
|
||||
@@ -297,6 +298,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
vaccination: [
|
||||
"vaccination.booster-invalid",
|
||||
"vaccination.booster-no-antigen",
|
||||
"vaccination.booster-out-of-scope",
|
||||
"vaccination.encounter-not-found",
|
||||
"vaccination.identity-required",
|
||||
"vaccination.invalid",
|
||||
|
||||
@@ -874,7 +874,7 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Cycle"],
|
||||
summary: "Cycle-phase correlation insights (v1.15.0)",
|
||||
description:
|
||||
"FDR-guarded luteal-vs-follicular phase contrast per outcome metric (RHR / HRV / sleep / steps / weight / temperatures), plus the single headline finding (resting-heart-rate-by-phase, falling back to HRV). The same Welch t-test + Benjamini-Hochberg machinery the mood-factor crosstab runs; only rows with p < 0.05 AND q ≤ 0.10 surface. Strictly gender-gated — phase never appears on the general `/api/insights/correlations` route. Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off.",
|
||||
"FDR-guarded luteal-vs-follicular phase contrast per outcome metric (RHR / HRV / sleep / steps / weight / temperatures), plus the single headline finding (resting-heart-rate-by-phase, falling back to HRV). The same Welch t-test + Benjamini-Hochberg machinery the mood-factor crosstab runs; only rows with p < 0.05 AND q ≤ 0.10 surface. Strictly gender-gated — phase never appears on the general `/api/insights/correlations` route. Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off. Declared for the `cycle` section; the outcome columns are fenced per section at the query, so a grant scoped to the cycle alone receives the phase rows with no `lutealAvg` / `follicularAvg` / `delta` for measurement or mood outcomes and no lagged discovery, exactly as a record with nothing logged would read. The owner and a whole-record grant are unchanged.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Phase-correlation rows + headline.",
|
||||
|
||||
@@ -20,7 +20,11 @@
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { backupPayloadSchema } from "@/lib/validations/backup";
|
||||
import { errorEnvelope, stdResponses } from "./shared";
|
||||
import {
|
||||
errorEnvelope,
|
||||
SHARING_NOT_PERMITTED_DESCRIPTION,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
const exportQuery = z
|
||||
.object({
|
||||
@@ -305,6 +309,12 @@ export const exportPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The Bearer token carries a narrow scope. The encrypted archive needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" +
|
||||
SHARING_NOT_PERMITTED_DESCRIPTION,
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -135,7 +135,8 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Mental health"],
|
||||
summary: "Record a completed screener",
|
||||
description:
|
||||
"Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.",
|
||||
"Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.\n\n" +
|
||||
"The projection row is the one write this operation makes outside its declared section: the operation is fenced on `mind`, and the measurement it derives lands in `measurements`. It is deliberate and unconditional — a screener whose own total is missing from the record would be an inconsistency inside the section the grant does open. The consent copy for the mood-and-mind section names it.",
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
content: {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
recordRefusal,
|
||||
SHARING_NOT_PERMITTED_DESCRIPTION,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
@@ -615,7 +616,8 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.",
|
||||
"The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" +
|
||||
SHARING_NOT_PERMITTED_DESCRIPTION,
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
@@ -801,7 +803,8 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.",
|
||||
"The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" +
|
||||
SHARING_NOT_PERMITTED_DESCRIPTION,
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -431,6 +431,25 @@ export function idempotentWrite(): IdempotentConflictResponse {
|
||||
export const SHARING_ACCESS_DENIED_DESCRIPTION =
|
||||
"Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's audit trail instead. On the cookie transport the same code answers one further case: a session that has entered a shared record and sends a request without its record-context assertion, which is a client older than the fence — leave the record and reload. A request that names no record but the caller's own never reaches this response, so a client that never switches and never sends the per-request account selector will not see it.";
|
||||
|
||||
/**
|
||||
* The OTHER sharing refusal, for a route that names no record at all.
|
||||
*
|
||||
* A route resolving through `requireAuth` serves the caller and only the
|
||||
* caller, and refuses outright while the browser is inside somebody else's
|
||||
* record rather than quietly answering with the caller's own rows. That
|
||||
* posture matters most on the routes where being wrong is unrecoverable — the
|
||||
* record wipe, the account deletion, the encrypted archive — and it was
|
||||
* documented nowhere on them. A client that switches records has to know which
|
||||
* of its calls stop working, and finding out by wiping the wrong record is not
|
||||
* a contract.
|
||||
*
|
||||
* One sentence, spliced onto whatever else the operation's 403 already says,
|
||||
* for the same reason the delegable refusal is one string: a paraphrase per
|
||||
* path invites a client to tell them apart.
|
||||
*/
|
||||
export const SHARING_NOT_PERMITTED_DESCRIPTION =
|
||||
"Refused: the request was made while the session is acting on another account (`meta.errorCode` = `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any level opens it.";
|
||||
|
||||
/**
|
||||
* The 403 above, optionally sharing the status with the reasons a route already
|
||||
* refuses for.
|
||||
|
||||
@@ -364,14 +364,17 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Records"],
|
||||
summary: "Plan a booster reminder from a dose",
|
||||
description:
|
||||
"Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A free-text-only dose has no antigen to remind on and is refused with 422. Audits as `vaccination.booster.planned`.",
|
||||
"Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A free-text-only dose has no antigen to remind on and is refused with 422. Audits as `vaccination.booster.planned`.\n\n" +
|
||||
"The operation is declared under the health background and the row it mints belongs to the measurements section, so it crosses a section boundary: a delegate whose grant does not also open `measurements` is refused with 403 `vaccination.booster-out-of-scope` and nothing is written. The dose itself still logs — only the booster does not.",
|
||||
requestParams: idPath,
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: vaccinationBoosterRequest } },
|
||||
},
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
...recordRefusal(
|
||||
"Refused: the caller's grant does not open this record's measurements, and the booster reminder lives there (`meta.errorCode` = `vaccination.booster-out-of-scope`). Nothing was written.",
|
||||
),
|
||||
"200": {
|
||||
description: "Existing booster reminder re-anchored.",
|
||||
content: {
|
||||
|
||||
@@ -96,19 +96,39 @@ export interface AccountAccessEntry {
|
||||
*/
|
||||
sections: ShareDomain[] | null;
|
||||
/**
|
||||
* May this caller ADD to that record. Resolved server-side. It does not by
|
||||
* itself mean edit or delete — a WRITE grant adds and does nothing more, and
|
||||
* that stayed true when a third level arrived. A MANAGE grant answers true
|
||||
* here as well, and what it additionally admits is said by `level` rather
|
||||
* than smuggled into this boolean.
|
||||
* The grant's LEVEL, resolved server-side: is it at or above WRITE.
|
||||
*
|
||||
* It is not "may this caller add something", and reading it that way is how
|
||||
* section-blind controls happen. There is no scope term in it: a WRITE grant
|
||||
* scoped to the document vault answers `true` here with
|
||||
* `writableDomains: []`, because the vault takes no delegated write at any
|
||||
* level. Ask {@link AccountAccessEntry.writableDomains} for the section the
|
||||
* control belongs to; this boolean answers only whether the grant is a
|
||||
* reading one.
|
||||
*
|
||||
* It does not by itself mean edit or delete — a WRITE grant adds and does
|
||||
* nothing more, and that stayed true when a third level arrived. A MANAGE
|
||||
* grant answers true here as well, and what it additionally admits is said
|
||||
* by `level` rather than smuggled into this boolean.
|
||||
*/
|
||||
canWrite: boolean;
|
||||
/**
|
||||
* v1.38.12 — the sections this grant can add to, resolved server-side from
|
||||
* the grant's level and scope against the delegated write routes that
|
||||
* exist (`domain-write-support.ts`). In the consent screen's order. Empty
|
||||
* for a READ grant, and empty for any section whose routes accept no
|
||||
* delegated write at all — the vault, today.
|
||||
* v1.38.12 — the sections with AT LEAST ONE delegated write route this
|
||||
* grant satisfies, resolved server-side from its level and scope against
|
||||
* the routes that exist (`domain-write-support.ts`). In the consent
|
||||
* screen's order. Empty for a READ grant, and empty for any section whose
|
||||
* routes accept no delegated write at all — the vault, today.
|
||||
*
|
||||
* Existential, and the word matters: within a section the answer varies by
|
||||
* verb, and this list cannot say which. `profile` is writable because a
|
||||
* visit, a practitioner and a dose answer at WRITE, while an allergy and a
|
||||
* family-history entry are MANAGE creates; `measurements` is writable
|
||||
* because a reading is, while a water entry and a checkup reminder are
|
||||
* MANAGE. So a control does not read "my section is writable, therefore my
|
||||
* create is": it asks the hook for its own verb class —
|
||||
* `canWriteDomain` for a create the WRITE level covers, `canManageDomain`
|
||||
* for one it does not. A per-verb table would be a second place the route
|
||||
* declarations live, and the declarations are the thing that decides.
|
||||
*
|
||||
* A list rather than a boolean because the answer differs per section, and
|
||||
* the client's per-control question is "this section", never "any".
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Which sections of the record the caller is actually inside.
|
||||
*
|
||||
* Lives here rather than beside one feature because a route that reads or
|
||||
* writes across its own declared section is a shape the product keeps growing,
|
||||
* and its callers are already spread over four features. No list of them here:
|
||||
* a comment that enumerates its own callers is a comment that goes stale on
|
||||
* the next one. Two verbatim copies of this existed in the visits and the
|
||||
* vaccination service, with a note on the second saying a second spelling
|
||||
* would be a second thing to keep in step.
|
||||
*
|
||||
* Returns a predicate rather than a set so the owner path costs nothing: with
|
||||
* no grant there is nothing to look up and every domain is open. A grant that
|
||||
* vanished between the auth check and here answers "no" to everything, which
|
||||
* is the fail-closed direction.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
import { grantCoversDomain } from "@/lib/sharing/grants";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
export async function actingDomainVisibility(
|
||||
tx: Prisma.TransactionClient,
|
||||
grantId: string | null,
|
||||
): Promise<(domain: ShareDomain) => boolean> {
|
||||
if (grantId === null) return () => true;
|
||||
const grant = await tx.accountGrant.findUnique({
|
||||
where: { id: grantId },
|
||||
select: { scopeJson: true },
|
||||
});
|
||||
if (!grant) return () => false;
|
||||
return (domain) => grantCoversDomain(grant, domain);
|
||||
}
|
||||
@@ -74,6 +74,15 @@ const LEVEL_RANK: Record<AccountAccessLevel, number> = {
|
||||
* `sections === null` is the entire record. A MANAGE grant always carries it
|
||||
* (`grants.ts` refuses a scope on a MANAGE invitation), a WRITE grant may.
|
||||
*
|
||||
* The answer is EXISTENTIAL per section, never per verb: "is there a route in
|
||||
* this section at this need the grant satisfies". Three of the four sections
|
||||
* that answer `write: true` also carry MANAGE-only creates — an allergy and a
|
||||
* family-history entry under `profile`, a water entry and a checkup reminder
|
||||
* under `measurements`, a snooze and a re-recorded dose under `medications` —
|
||||
* and no list keyed on the section can distinguish them. A control asks the
|
||||
* hook for its own verb class instead; the route declarations stay the one
|
||||
* place the split is decided.
|
||||
*
|
||||
* For `need === "write"` the answer is "is there a non-read route in this
|
||||
* section the grant satisfies": a WRITE grant reaches the section's `"write"`
|
||||
* routes, a MANAGE grant reaches those and its `"manage"` routes as well.
|
||||
|
||||
@@ -68,7 +68,16 @@ export const SHARE_DOMAINS = [
|
||||
"profile",
|
||||
/** Illness episodes and the day logs under them. */
|
||||
"illness",
|
||||
/** Mood entries and mental-health screener history. */
|
||||
/**
|
||||
* Mood entries and mental-health screener history.
|
||||
*
|
||||
* One write in this section lands outside it, and the consent copy names it:
|
||||
* completing a screener also files the instrument's total as a `COMPUTED`
|
||||
* measurement, so the trend reads through the chart and rollup tier without
|
||||
* the encrypted item content going anywhere. It is a projection of the
|
||||
* administration rather than a second reading, and withholding it would file
|
||||
* a screener whose own total is missing from the record.
|
||||
*/
|
||||
"mind",
|
||||
/** Cycle tracking. */
|
||||
"cycle",
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { listTargets, replaceTargets } from "@/lib/links";
|
||||
import { grantCoversDomain } from "@/lib/sharing/grants";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
import type { VaccinationDocumentDTO } from "@/lib/vaccinations/dto";
|
||||
import {
|
||||
@@ -37,29 +36,6 @@ export const SERIES_SELECT = {
|
||||
seriesDoses: true,
|
||||
} as const satisfies Prisma.VaccinationRecordSelect;
|
||||
|
||||
/**
|
||||
* Which sections of the record the caller is actually inside.
|
||||
*
|
||||
* Returns a predicate rather than a set so the owner path costs nothing: with
|
||||
* no grant there is nothing to look up and every domain is open. A grant that
|
||||
* vanished between the auth check and here answers "no" to everything, which
|
||||
* is the fail-closed direction. Same shape as the visits service, deliberately
|
||||
* — the two features sit on the same seam and a second spelling of this would
|
||||
* be a second thing to keep in step.
|
||||
*/
|
||||
export async function actingDomainVisibility(
|
||||
tx: Prisma.TransactionClient,
|
||||
grantId: string | null,
|
||||
): Promise<(domain: ShareDomain) => boolean> {
|
||||
if (grantId === null) return () => true;
|
||||
const grant = await tx.accountGrant.findUnique({
|
||||
where: { id: grantId },
|
||||
select: { scopeJson: true },
|
||||
});
|
||||
if (!grant) return () => false;
|
||||
return (domain) => grantCoversDomain(grant, domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole live history for one account, in the shape the derivation wants.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* What a cycle-only delegate learns from `GET /api/cycle/insights`.
|
||||
*
|
||||
* The route is declared `("read", "cycle")`, and everything about the phase
|
||||
* labels belongs there. Its outcome columns do not: `lutealAvg`,
|
||||
* `follicularAvg` and `delta` carry resting heart rate, HRV, sleep, steps,
|
||||
* weight, temperature, glucose and the mood score in display units, and those
|
||||
* are `measurements` and `mind` data reached through a cycle grant.
|
||||
*
|
||||
* So two properties, both about somebody holding `["cycle"]` and nothing else:
|
||||
*
|
||||
* 1. **No foreign figure crosses the seam.** No row whose `metricKey` names
|
||||
* a measurement channel, no mood row, and no lagged discovery built out
|
||||
* of either.
|
||||
* 2. **The cycle-native answer still arrives.** The symptom-by-phase
|
||||
* patterns are cycle data and stay, so the fix is a fence rather than a
|
||||
* blanket refusal.
|
||||
*
|
||||
* The unscoped grant is asserted in the same fixture, because a change that
|
||||
* hid the figures from everyone would satisfy the first property and break
|
||||
* the feature.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const OWNER_ID = "cycle-insights-owner";
|
||||
const DELEGATE_ID = "cycle-insights-delegate";
|
||||
|
||||
/** Cycle geometry the fixture logs, chosen so both phases clear the day floor. */
|
||||
const CYCLE_LENGTH = 28;
|
||||
const PERIOD_LENGTH = 5;
|
||||
const OBSERVED_CYCLES = 5;
|
||||
|
||||
function isoDay(offsetDays: number): string {
|
||||
const d = new Date(Date.now() + offsetDays * 24 * 60 * 60 * 1000);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function atNoon(day: string): Date {
|
||||
return new Date(`${day}T12:00:00Z`);
|
||||
}
|
||||
|
||||
async function signIn(userId: string) {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Sign in as the delegate and switch into the owner's record. */
|
||||
async function switchInto(scope: ShareDomain[] | null) {
|
||||
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
|
||||
const invited = await inviteGrant({
|
||||
grantorId: OWNER_ID,
|
||||
granteeId: DELEGATE_ID,
|
||||
access: "READ",
|
||||
scope,
|
||||
});
|
||||
await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID });
|
||||
const session = await signIn(DELEGATE_ID);
|
||||
await switchSessionTo(session.id, OWNER_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Five observed cycles, then a weight and a mood value on every labelled day
|
||||
* whose size depends on the phase.
|
||||
*
|
||||
* The separation is deliberately huge (80 kg vs 70 kg, mood 2 vs 4) so the
|
||||
* Welch + Benjamini-Hochberg gate the route runs cannot be the reason a row is
|
||||
* missing: with this fixture a missing row means the fence removed it.
|
||||
*/
|
||||
async function seedRecord() {
|
||||
const prisma = getPrismaClient();
|
||||
for (const [id, name] of [
|
||||
[OWNER_ID, "owner"],
|
||||
[DELEGATE_ID, "delegate"],
|
||||
] as const) {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
username: `cycle-insights-${name}`,
|
||||
email: `cycle-insights-${name}@example.test`,
|
||||
gender: "FEMALE",
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Prediction off, so the phase map the route builds is the one this fixture
|
||||
// can reproduce from the logged cycles alone.
|
||||
await prisma.cycleProfile.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
cycleTrackingEnabled: true,
|
||||
predictionEnabled: false,
|
||||
lutealPhaseLength: 14,
|
||||
},
|
||||
});
|
||||
|
||||
const starts: string[] = [];
|
||||
for (let i = OBSERVED_CYCLES; i >= 1; i--) {
|
||||
starts.push(isoDay(-i * CYCLE_LENGTH));
|
||||
}
|
||||
for (const [index, startDate] of starts.entries()) {
|
||||
const next = starts[index + 1] ?? null;
|
||||
await prisma.menstrualCycle.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
startDate,
|
||||
endDate: next
|
||||
? isoDay(-(OBSERVED_CYCLES - index) * CYCLE_LENGTH - 1 + CYCLE_LENGTH)
|
||||
: null,
|
||||
periodEndDate: isoDay(
|
||||
-(OBSERVED_CYCLES - index) * CYCLE_LENGTH + PERIOD_LENGTH - 1,
|
||||
),
|
||||
ovulationDate: isoDay(
|
||||
-(OBSERVED_CYCLES - index) * CYCLE_LENGTH + CYCLE_LENGTH - 15,
|
||||
),
|
||||
tz: "UTC",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { buildPhaseDayMap } = await import("@/lib/cycle/engine-adapter");
|
||||
const cycles = await prisma.menstrualCycle.findMany({
|
||||
where: { userId: OWNER_ID },
|
||||
orderBy: { startDate: "asc" },
|
||||
});
|
||||
const today = isoDay(0);
|
||||
const phaseByDay = buildPhaseDayMap(
|
||||
cycles,
|
||||
null,
|
||||
14,
|
||||
isoDay(-OBSERVED_CYCLES * CYCLE_LENGTH),
|
||||
today,
|
||||
today,
|
||||
);
|
||||
|
||||
// One catalogue symptom, logged on enough phase-labelled days to clear the
|
||||
// pattern floor. Without it `symptomPatterns` is empty in every leg and the
|
||||
// "the cycle-native answer still arrives" property could not be shown even
|
||||
// by a leg that asserted it.
|
||||
//
|
||||
// Upserted rather than created: the symptom catalogue is seeded by migration
|
||||
// and survives the per-test truncation, so a plain create passes on the
|
||||
// first leg and collides on the second.
|
||||
const category = await prisma.cycleSymptomCategory.upsert({
|
||||
where: { key: "cycle-insights-cat" },
|
||||
update: {},
|
||||
create: { key: "cycle-insights-cat", labelKey: "cycle.category.test" },
|
||||
});
|
||||
const symptom = await prisma.cycleSymptom.upsert({
|
||||
where: { key: "cycle-insights-cramps" },
|
||||
update: {},
|
||||
create: {
|
||||
categoryId: category.id,
|
||||
key: "cycle-insights-cramps",
|
||||
labelKey: "cycle.symptom.cramps",
|
||||
},
|
||||
});
|
||||
|
||||
let luteal = 0;
|
||||
let follicular = 0;
|
||||
for (const [day, phase] of phaseByDay) {
|
||||
if (phase !== "LUTEAL" && phase !== "FOLLICULAR") continue;
|
||||
const high = phase === "LUTEAL";
|
||||
if (high) luteal++;
|
||||
else follicular++;
|
||||
// A little jitter in both channels, so the Welch test has a variance to
|
||||
// work with and the row is not dropped for a reason the fence did not
|
||||
// cause.
|
||||
const jitter = ((luteal + follicular) % 3) * 0.1;
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
type: "WEIGHT",
|
||||
value: (high ? 80 : 70) + jitter,
|
||||
unit: "kg",
|
||||
measuredAt: atNoon(day),
|
||||
},
|
||||
});
|
||||
await prisma.moodEntry.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
mood: high ? "SCHLECHT" : "GUT",
|
||||
score: (high ? 1 : 4) + ((luteal + follicular) % 2),
|
||||
date: day,
|
||||
moodLoggedAt: atNoon(day),
|
||||
tz: "UTC",
|
||||
},
|
||||
});
|
||||
// Cycle-native data, logged on the luteal days only so the pattern has a
|
||||
// dominant phase to report.
|
||||
if (high) {
|
||||
await prisma.cycleDayLog.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
date: day,
|
||||
tz: "UTC",
|
||||
symptomLinks: { create: [{ symptomId: symptom.id }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
luteal,
|
||||
"the fixture labels enough luteal days to clear the crosstab floor",
|
||||
).toBeGreaterThanOrEqual(5);
|
||||
expect(
|
||||
follicular,
|
||||
"the fixture labels enough follicular days to clear the crosstab floor",
|
||||
).toBeGreaterThanOrEqual(5);
|
||||
}
|
||||
|
||||
interface InsightsBody {
|
||||
rows: Array<{ metricKey: string }>;
|
||||
headline: { metricKey: string } | null;
|
||||
lagged: { discovered: unknown[] };
|
||||
symptomPatterns: Array<{ symptomKey: string; topPhase: string }>;
|
||||
}
|
||||
|
||||
async function readInsights(): Promise<InsightsBody> {
|
||||
const { GET } = await import("@/app/api/cycle/insights/route");
|
||||
// The handler declares no parameters; the wrapper still reads the request
|
||||
// off its first argument, and the method it carries is what the record
|
||||
// resolver escalates on.
|
||||
const handler = GET as unknown as (request: Request) => Promise<Response>;
|
||||
const res = await handler(new Request("http://localhost/api/cycle/insights"));
|
||||
expect(res.status).toBe(200);
|
||||
const parsed = (await res.json()) as { data: InsightsBody };
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await seedRecord();
|
||||
});
|
||||
|
||||
describe("cycle insights fence the sections it reads across", () => {
|
||||
it("gives the owner the measurement and the mood contrast", async () => {
|
||||
await signIn(OWNER_ID);
|
||||
const body = await readInsights();
|
||||
|
||||
const keys = body.rows.map((row) => row.metricKey);
|
||||
expect(keys, "the weight contrast is the fixture's whole point").toContain(
|
||||
"weight",
|
||||
);
|
||||
expect(keys, "and the mood contrast beside it").toContain("mood");
|
||||
expect(
|
||||
body.symptomPatterns.map((row) => row.symptomKey),
|
||||
"and the cycle-native patterns the fence must not touch",
|
||||
).toContain("cycle-insights-cramps");
|
||||
});
|
||||
|
||||
it("gives an entire-record delegate the same figures", async () => {
|
||||
await switchInto(null);
|
||||
const body = await readInsights();
|
||||
|
||||
const keys = body.rows.map((row) => row.metricKey);
|
||||
expect(keys).toContain("weight");
|
||||
expect(keys).toContain("mood");
|
||||
});
|
||||
|
||||
it("gives a cycle-only delegate no measurement and no mood figure", async () => {
|
||||
await switchInto(["cycle"]);
|
||||
const body = await readInsights();
|
||||
|
||||
expect(
|
||||
body.rows,
|
||||
"every crosstab row is a measurement or a mood column",
|
||||
).toHaveLength(0);
|
||||
expect(body.headline).toBeNull();
|
||||
expect(
|
||||
body.lagged.discovered,
|
||||
"the lagged matrix is built from the same two sections",
|
||||
).toHaveLength(0);
|
||||
// The other half of the property, and the one a blanket refusal would
|
||||
// break: the symptom-by-phase patterns are cycle data and the grant opens
|
||||
// cycle, so they arrive in full.
|
||||
expect(
|
||||
body.symptomPatterns.map((row) => row.symptomKey),
|
||||
"the cycle-native answer still arrives",
|
||||
).toContain("cycle-insights-cramps");
|
||||
});
|
||||
|
||||
it("gives a cycle+mind delegate the mood figure and not the weight", async () => {
|
||||
await switchInto(["cycle", "mind"]);
|
||||
const body = await readInsights();
|
||||
|
||||
const keys = body.rows.map((row) => row.metricKey);
|
||||
expect(
|
||||
keys,
|
||||
"mind is held, so the mood column is within the grant",
|
||||
).toContain("mood");
|
||||
expect(keys, "measurements is not held").not.toContain("weight");
|
||||
});
|
||||
});
|
||||
@@ -79,7 +79,17 @@ describe("record settings authorization", () => {
|
||||
expect(route).toContain("details: { changed }");
|
||||
expect(route).not.toContain("details: { recordId:");
|
||||
expect(route).toContain("invalidateUserMedications(access.recordId, {");
|
||||
expect(route).toContain("invalidateUserHealthScore(access.recordId)");
|
||||
// The module write is one record-keyed function shared with the setup
|
||||
// flow's derivation; the health-score invalidation lives there, so the
|
||||
// route must call it with the record and the helper must still invalidate.
|
||||
expect(route).toContain("writeRecordModulePreferences({");
|
||||
expect(route).toContain("recordId: access.recordId,");
|
||||
expect(route).not.toContain("invalidateUserHealthScore(");
|
||||
const moduleWrite = await readFile(
|
||||
fromRoot("src/lib/record-settings/modules.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(moduleWrite).toContain("invalidateUserHealthScore(input.recordId)");
|
||||
expect(route).toContain("getAllEffectiveRanges(record, overrides)");
|
||||
expect(route).toContain(
|
||||
"effective: getAllEffectiveRanges(record, overrides)",
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* The three destructive account routes, driven with a live record switch on.
|
||||
*
|
||||
* `DELETE /api/settings/data` wipes a record, `DELETE /api/settings/account`
|
||||
* erases an account, and `POST /api/export/encrypted` puts a whole record into
|
||||
* one file. None of them names a record: they resolve the CALLER and act on
|
||||
* whatever that caller owns. So the question a switched session asks of them is
|
||||
* not "may I do this to the record I am inside" — it is "what happens when the
|
||||
* browser believes it is somewhere else and the route does not check".
|
||||
*
|
||||
* The answer must be a refusal, and the same one every undeclared route gives:
|
||||
* 403, `sharing.not_permitted`, "cannot be used while acting on another
|
||||
* account". Silently serving the delegate's own record is the failure this
|
||||
* pins against, and it is the worst-shaped one in the product — a person who
|
||||
* believes they are looking after somebody else's record presses "delete
|
||||
* everything" and erases their own.
|
||||
*
|
||||
* Both halves are asserted: the status AND that nothing moved. A refusal that
|
||||
* happens after the transaction would satisfy the first alone.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const OWNER_ID = "switch-destructive-owner";
|
||||
const DELEGATE_ID = "switch-destructive-delegate";
|
||||
|
||||
/** The delegate's own reading. It must still be there afterwards. */
|
||||
async function seedRecords() {
|
||||
const prisma = getPrismaClient();
|
||||
for (const [id, name] of [
|
||||
[OWNER_ID, "owner"],
|
||||
[DELEGATE_ID, "delegate"],
|
||||
] as const) {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
username: `switch-destructive-${name}`,
|
||||
email: `switch-destructive-${name}@example.test`,
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
}
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: DELEGATE_ID,
|
||||
type: "WEIGHT",
|
||||
value: 72,
|
||||
unit: "kg",
|
||||
measuredAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign the delegate in and switch into the owner's record at MANAGE — the
|
||||
* widest grant the product mints, so nothing below can be explained away by
|
||||
* the grant simply being too narrow.
|
||||
*/
|
||||
async function switchIntoOwnersRecord() {
|
||||
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
|
||||
const invited = await inviteGrant({
|
||||
grantorId: OWNER_ID,
|
||||
granteeId: DELEGATE_ID,
|
||||
access: "MANAGE",
|
||||
scope: null,
|
||||
});
|
||||
await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID });
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: DELEGATE_ID,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
await switchSessionTo(session.id, OWNER_ID);
|
||||
}
|
||||
|
||||
/** The one refusal all three must give. */
|
||||
async function expectSwitchRefusal(response: Response) {
|
||||
expect(response.status).toBe(403);
|
||||
const body = (await response.json()) as {
|
||||
error: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("sharing.not_permitted");
|
||||
expect(body.error).toBe(
|
||||
"This endpoint cannot be used while acting on another account",
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await seedRecords();
|
||||
await switchIntoOwnersRecord();
|
||||
});
|
||||
|
||||
describe("a switched session reaches none of the record-wide destructive routes", () => {
|
||||
it("refuses the record wipe and wipes neither record", async () => {
|
||||
const { DELETE } = await import("@/app/api/settings/data/route");
|
||||
await expectSwitchRefusal(
|
||||
await DELETE(
|
||||
new Request("http://localhost/api/settings/data", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ confirm: "DELETE" }),
|
||||
}) as never,
|
||||
),
|
||||
);
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
expect(
|
||||
await prisma.measurement.count({ where: { userId: DELEGATE_ID } }),
|
||||
"the delegate's own reading survived",
|
||||
).toBe(1);
|
||||
expect(
|
||||
await prisma.user.count({
|
||||
where: { id: { in: [OWNER_ID, DELEGATE_ID] } },
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("refuses the account deletion and deletes neither account", async () => {
|
||||
const { DELETE } = await import("@/app/api/settings/account/route");
|
||||
await expectSwitchRefusal(
|
||||
await DELETE(
|
||||
new Request("http://localhost/api/settings/account", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }),
|
||||
}) as never,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
await getPrismaClient().user.count({
|
||||
where: { id: { in: [OWNER_ID, DELEGATE_ID] } },
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("refuses the encrypted export and hands out no archive", async () => {
|
||||
const { POST } = await import("@/app/api/export/encrypted/route");
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/export/encrypted", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ passphrase: "a-long-enough-passphrase-123" }),
|
||||
}) as never,
|
||||
);
|
||||
await expectSwitchRefusal(response);
|
||||
// Not an archive under any content type: a refusal that still streamed the
|
||||
// bytes would read as a 403 to a status check and as an export to a user.
|
||||
expect(response.headers.get("content-type")).not.toContain(
|
||||
"application/octet-stream",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* `POST /api/vaccinations/{id}/booster` crosses a section boundary, and the
|
||||
* grant has to be on both sides of it.
|
||||
*
|
||||
* The operation is declared `("write", "profile")`, which is right for the act
|
||||
* — filing the Impfpass and arming the booster it suggests are one thing to
|
||||
* the person doing them. The ROW it mints is not: a `MeasurementReminder`
|
||||
* belongs to the measurements section, every direct route over that model says
|
||||
* so, and creating one there needs MANAGE. So a grant opening the health
|
||||
* background alone would otherwise produce a reminder on the owner's checkup
|
||||
* list that the delegate can neither read nor change through the section that
|
||||
* owns it.
|
||||
*
|
||||
* Refused rather than silently skipped: the reminder is the whole request, and
|
||||
* a 201 over a row that was never written would tell the person their booster
|
||||
* is planned while nothing rings.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const OWNER_ID = "booster-scope-owner";
|
||||
const DELEGATE_ID = "booster-scope-delegate";
|
||||
|
||||
/** A catalogue antigen that carries a booster interval. */
|
||||
const ANTIGEN = "tetanus";
|
||||
|
||||
let doseId: string;
|
||||
|
||||
async function seedRecords() {
|
||||
const prisma = getPrismaClient();
|
||||
for (const [id, name] of [
|
||||
[OWNER_ID, "owner"],
|
||||
[DELEGATE_ID, "delegate"],
|
||||
] as const) {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
username: `booster-scope-${name}`,
|
||||
email: `booster-scope-${name}@example.test`,
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
}
|
||||
const dose = await prisma.vaccinationRecord.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
occurredAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||
antigenSlug: ANTIGEN,
|
||||
},
|
||||
});
|
||||
doseId = dose.id;
|
||||
}
|
||||
|
||||
async function signIn(userId: string) {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return session;
|
||||
}
|
||||
|
||||
async function switchInto(
|
||||
access: "WRITE" | "MANAGE",
|
||||
scope: ShareDomain[] | null,
|
||||
) {
|
||||
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
|
||||
const invited = await inviteGrant({
|
||||
grantorId: OWNER_ID,
|
||||
granteeId: DELEGATE_ID,
|
||||
access,
|
||||
scope,
|
||||
});
|
||||
await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID });
|
||||
const session = await signIn(DELEGATE_ID);
|
||||
await switchSessionTo(session.id, OWNER_ID);
|
||||
}
|
||||
|
||||
async function planBooster(): Promise<Response> {
|
||||
const { POST } = await import("@/app/api/vaccinations/[id]/booster/route");
|
||||
return POST(
|
||||
new NextRequest(`http://localhost/api/vaccinations/${doseId}/booster`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ intervalMonths: 120, label: "Tetanus booster" }),
|
||||
}),
|
||||
{ params: Promise.resolve({ id: doseId }) },
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await seedRecords();
|
||||
});
|
||||
|
||||
describe("planning a booster asks for the section the reminder lives in", () => {
|
||||
it("mints for the owner", async () => {
|
||||
await signIn(OWNER_ID);
|
||||
expect((await planBooster()).status).toBe(201);
|
||||
expect(
|
||||
await getPrismaClient().measurementReminder.count({
|
||||
where: { userId: OWNER_ID },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("mints for a delegate holding both sections", async () => {
|
||||
await switchInto("WRITE", ["profile", "measurements"]);
|
||||
expect((await planBooster()).status).toBe(201);
|
||||
expect(
|
||||
await getPrismaClient().measurementReminder.count({
|
||||
where: { userId: OWNER_ID },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses a profile-only delegate and writes no reminder", async () => {
|
||||
await switchInto("WRITE", ["profile"]);
|
||||
const response = await planBooster();
|
||||
expect(response.status).toBe(403);
|
||||
const body = (await response.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("vaccination.booster-out-of-scope");
|
||||
expect(
|
||||
await getPrismaClient().measurementReminder.count({
|
||||
where: { userId: OWNER_ID },
|
||||
}),
|
||||
"no reminder was left on the owner's checkup list",
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("mints for a whole-record delegate", async () => {
|
||||
await switchInto("MANAGE", null);
|
||||
expect((await planBooster()).status).toBe(201);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user