Skip to content

Bulk Export: the Patients scope offers no way to name specific patient ids #655

Description

@smunini

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 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

  1. 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.

  2. Conditional reveal. The Group ID field is shown by pure CSS, no JS: crates/ui/assets/app.css:2450-2456

    .bulk-export-form .field--group-id { display: none; }
    .bulk-export-form:has(input[name="scope"][value="group"]:checked) .field--group-id { display: block; }

    Mirror that with value="patient". Keep the same graceful degradation the existing comment records — browsers without :has() show the field always, which is harmless.

  3. 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.

  4. 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.

  5. 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.

  6. Strings. New label and hint keys in locales/{en,es,de}/main.ftl, alongside bulk-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

  • 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.

Related: #537 built this workspace.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions