You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The Bulk Export builder's Patients scope exports every patient on the server, with no way to name specific ones. The server already accepts a repeating patient parameter on $export and validates it; the UI just never sends it.
Current behavior
At /ui/bulk-export, selecting the Patients scope reveals no additional input. The Group scope, by contrast, reveals a Group ID field. Submitting with Patients selected always kicks off an unfiltered GET /Patient/$export.
crates/ui/src/bulk_export.rs:361-364:
let path = match job.scope.as_str(){"patient" => format!("{base}/Patient/$export"),"group" => format!("{base}/Group/{}/$export", job.group_id),
_ => format!("{base}/$export"),};
The form has no field to carry patient ids: crates/ui/templates/pages/bulk-export.html:36-52 offers the three scope radios and a group_id input, nothing else. StartForm (bulk_export.rs:284-293) and parse_start_form (bulk_export.rs:295-312) have no corresponding field.
Expected behavior
Selecting Patients reveals a Patients input. Zero or more patient ids may be entered. Zero entries keeps today's behavior — every patient — which is what the scope hint already promises (bulk-export-scope-patient-hint: "Every patient and the records that belong to them"). One or more entries narrows the export to exactly those patients.
Evidence — the server side already exists
crates/rest/src/handlers/bulk_export.rs:147-182 implements the Bulk Data patient parameter in full:
Collects every repetition: let patient_refs = collect_multi(&pairs, "patient");
Rejects it for system-level export: "the patient parameter is not valid for system-level export".
Validates each reference resolves, accepting either Patient/123 or a bare 123 (pref.strip_prefix("Patient/").unwrap_or(pref)), and returns unknown patient reference '{pref}' otherwise.
For group-level, additionally requires membership in the group.
Passes the result through to ExportRequest.patient_refs (bulk_export.rs:247-258).
POST /Patient/$export is routed at crates/rest/src/routing/fhir_routes.rs:259-262, and POST bodies are parsed as a Parameters resource at bulk_export.rs:393-407.
Suggested approach
Form field. Add a .field--patient-ids block to bulk-export.html beside the existing .field--group-id. Accept a comma- or newline-separated list; a <textarea> suits a long list better than an <input>. Bare ids and Patient/{id} references both work — the server strips the prefix.
Conditional reveal. The Group ID field is shown by pure CSS, no JS: crates/ui/assets/app.css:2450-2456
Mirror that with value="patient". Keep the same graceful degradation the existing comment records — browsers without :has() show the field always, which is harmless.
Plumbing. Add patient_ids to StartForm (bulk_export.rs:284-293) and to the hand-rolled parse_start_form match (bulk_export.rs:295-312) — note the form is parsed by hand precisely because repeated fields defeat axum::Form, so repeated patient_ids inputs would work too if a repeating-row control is preferred over one text box.
Kick-off must become a POST when ids are supplied. This is the one non-trivial part. kickoff() builds a GET with query parameters (bulk_export.rs:358-368, .get(&path).query(&query)), and the spec makes patient a POST-only parameter. Send POST /Patient/$export with a Parameters body when the list is non-empty, keeping the GET path when it is empty so today's behavior is untouched. Everything else — Prefer: respond-async, the 202 + Content-Location handling at bulk_export.rs:377-402 — stays as is.
Worth confirming while in there: parse_query_pairs(raw_query) runs unconditionally at bulk_export.rs:90, before the is_post branch that merges the body, so collect_multi(&pairs, "patient") also picks up a patient value from a GET query string today. The comment at bulk_export.rs:147 says "POST only" but nothing enforces it. That is a separate spec-conformance question — do not rely on the GET path here.
Errors. An unknown patient id comes back as a 400 with unknown patient reference '{pref}'. kickoff() already records a non-202 response onto the job (bulk_export.rs:405-412), so the failure surfaces on the Active Exports card — but a bad id is user error, and catching it before kick-off would read better than a failed job.
Selecting Patients reveals a Patients input; selecting Everything or Group hides it.
Leaving it empty exports every patient, exactly as today.
One or more ids kicks off a POST /Patient/$export carrying one patient parameter per id, and the resulting export contains only those patients.
Bare ids and Patient/{id} references are both accepted.
An unknown id produces a clear message rather than a silently failed job.
The field is not offered for the Everything scope — the server rejects patient at system level.
Test coverage alongside crates/ui/tests/bulk_export_http.rs:231 (patient_and_group_scopes_hit_their_export_paths), which currently asserts the scope-to-path mapping.
crates/ui/e2e/tests/a11y.spec.ts passes with the new field.
Scope / out of scope
Out of scope: extending the Group scope's own patient narrowing (the server supports it with a membership check at bulk_export.rs:168-181, but that is a second control on a second scope), and the patient-on-GET conformance question noted above.
Summary
The Bulk Export builder's Patients scope exports every patient on the server, with no way to name specific ones. The server already accepts a repeating
patientparameter on$exportand validates it; the UI just never sends it.Current behavior
At
/ui/bulk-export, selecting the Patients scope reveals no additional input. The Group scope, by contrast, reveals a Group ID field. Submitting with Patients selected always kicks off an unfilteredGET /Patient/$export.crates/ui/src/bulk_export.rs:361-364:The form has no field to carry patient ids:
crates/ui/templates/pages/bulk-export.html:36-52offers the three scope radios and agroup_idinput, nothing else.StartForm(bulk_export.rs:284-293) andparse_start_form(bulk_export.rs:295-312) have no corresponding field.Expected behavior
Selecting Patients reveals a Patients input. Zero or more patient ids may be entered. Zero entries keeps today's behavior — every patient — which is what the scope hint already promises (
bulk-export-scope-patient-hint: "Every patient and the records that belong to them"). One or more entries narrows the export to exactly those patients.Evidence — the server side already exists
crates/rest/src/handlers/bulk_export.rs:147-182implements the Bulk Datapatientparameter in full:let patient_refs = collect_multi(&pairs, "patient");"thepatientparameter is not valid for system-level export".Patient/123or a bare123(pref.strip_prefix("Patient/").unwrap_or(pref)), and returnsunknown patient reference '{pref}'otherwise.ExportRequest.patient_refs(bulk_export.rs:247-258).POST /Patient/$exportis routed atcrates/rest/src/routing/fhir_routes.rs:259-262, and POST bodies are parsed as aParametersresource atbulk_export.rs:393-407.Suggested approach
Form field. Add a
.field--patient-idsblock tobulk-export.htmlbeside the existing.field--group-id. Accept a comma- or newline-separated list; a<textarea>suits a long list better than an<input>. Bare ids andPatient/{id}references both work — the server strips the prefix.Conditional reveal. The Group ID field is shown by pure CSS, no JS:
crates/ui/assets/app.css:2450-2456Mirror that with
value="patient". Keep the same graceful degradation the existing comment records — browsers without:has()show the field always, which is harmless.Plumbing. Add
patient_idstoStartForm(bulk_export.rs:284-293) and to the hand-rolledparse_start_formmatch (bulk_export.rs:295-312) — note the form is parsed by hand precisely because repeated fields defeataxum::Form, so repeatedpatient_idsinputs would work too if a repeating-row control is preferred over one text box.Kick-off must become a POST when ids are supplied. This is the one non-trivial part.
kickoff()builds aGETwith query parameters (bulk_export.rs:358-368,.get(&path).query(&query)), and the spec makespatienta POST-only parameter. SendPOST /Patient/$exportwith aParametersbody when the list is non-empty, keeping the GET path when it is empty so today's behavior is untouched. Everything else —Prefer: respond-async, the 202 +Content-Locationhandling atbulk_export.rs:377-402— stays as is.Worth confirming while in there:
parse_query_pairs(raw_query)runs unconditionally atbulk_export.rs:90, before theis_postbranch that merges the body, socollect_multi(&pairs, "patient")also picks up apatientvalue from a GET query string today. The comment atbulk_export.rs:147says "POST only" but nothing enforces it. That is a separate spec-conformance question — do not rely on the GET path here.Errors. An unknown patient id comes back as a 400 with
unknown patient reference '{pref}'.kickoff()already records a non-202 response onto the job (bulk_export.rs:405-412), so the failure surfaces on the Active Exports card — but a bad id is user error, and catching it before kick-off would read better than a failed job.Strings. New label and hint keys in
locales/{en,es,de}/main.ftl, alongsidebulk-export-field-group-id/-hint(locales/en/main.ftl:680-681). UI copy: no capitalization convention — headings and buttons mix Title Case and sentence case #652 is settling the capitalization convention; new labels should land conforming.Acceptance criteria
POST /Patient/$exportcarrying onepatientparameter per id, and the resulting export contains only those patients.Patient/{id}references are both accepted.patientat system level.crates/ui/tests/bulk_export_http.rs:231(patient_and_group_scopes_hit_their_export_paths), which currently asserts the scope-to-path mapping.crates/ui/e2e/tests/a11y.spec.tspasses with the new field.Scope / out of scope
Out of scope: extending the Group scope's own
patientnarrowing (the server supports it with a membership check atbulk_export.rs:168-181, but that is a second control on a second scope), and thepatient-on-GET conformance question noted above.Related: #537 built this workspace.