Skip to content

feat: add group-scoped bulk remote assignments #1500

Description

@thomasbeaudry

Description

Clinicians currently create remote assignments one subject at a time. Add a group-scoped wizard that accepts up to 500 subject identifiers, previews and validates the data, selects one accessible instrument and expiry date, reports conflicts, and creates the assignments in one operation.

This is the first release of bulk remote assignments. It deliberately supports existing subjects in the selected group only. Creating/linking subjects and sending assignment emails in bulk need separate designs because they change authorization, ownership, delivery, and throttling semantics.

Estimated difficulty: High.

User flow

Add /group/bulk-remote-assignments with six explicit states:

  1. Source — upload .csv/.xlsx, or paste comma-, tab-, or semicolon-separated data.
  2. Map and validate — preview the first four rows, correct header mappings, and report row-numbered validation errors.
  3. Instrument — choose one instrument from the current group's accessible instruments using InstrumentShowcase.
  4. Expiry — choose a future expiry, defaulted with getDefaultAssignmentExpiry().
  5. Review — show each subject as READY, CONFLICT, or SUBJECT_UNAVAILABLE; conflicts default to skip and may be explicitly allowed as duplicates.
  6. Results — show CREATED, SKIPPED, or FAILED per source row; allow formula-safe CSV download and starting over.

SUBJECT_UNAVAILABLE intentionally does not distinguish a missing subject from one outside the selected group. This avoids leaking subject existence across groups.

Input and privacy rules

Support two modes:

  • ID mode: one mapped subjectId column.
  • PII mode: mapped firstName, lastName, dateOfBirth, and sex columns. Resolve IDs in the browser with generateSubjectHash() from @opendatacapture/subject-utils.

Header matching is case-insensitive and normalizes whitespace, punctuation, and diacritics. Recognize English/French aliases for the subject ID and four PII fields. Each canonical field may be mapped at most once; extra columns, including email, are ignored in v1.

Validation must:

  • reject empty/invalid parser output and surface Papa Parse/XLSX errors;
  • skip fully empty rows;
  • reject ambiguous PII dates; CSV/paste dates must be ISO YYYY-MM-DD;
  • normalize sex only through the existing subject contract and reject unknown values;
  • reject duplicate resolved subject IDs;
  • reject more than 500 resolved subjects;
  • impose a documented client file-size limit;
  • never send, log, or place raw PII in API errors/toasts;
  • discard raw rows when the wizard resets or unmounts.

Use generateSubjectHash() as the hashing source of truth; do not duplicate its algorithm. Load xlsx with a dynamic import only after an Excel file is selected. Export results with Papa Parse escapeFormulae: true.

Shared contracts

Keep bulk contracts in packages/schemas/src/assignment/assignment.ts; do not add a second assignment package subpath.

Add boundary schemas/types for:

  • preflight request: required groupId, instrumentId, and 1–500 unique subjectIds;
  • preflight response: ordered per-subject discriminated items with READY | CONFLICT | SUBJECT_UNAVAILABLE;
  • create request: required groupId, instrumentId, dynamically validated future expiresAt, and 1–500 unique subjects containing subjectId and allowDuplicate;
  • create response: ordered per-subject discriminated results:
    • CREATED with the created $Assignment;
    • SKIPPED with CONFLICT | SUBJECT_UNAVAILABLE;
    • FAILED with a stable, sanitized machine-readable code;
  • API-to-gateway bulk request: shared instrumentContainer and activeLanguages once, plus assignment/public-key entries.

Do not use z.date().min(new Date()) at module initialization. Extract a shared dynamic future-date schema and use it in both single and bulk create contracts so long-running API processes compare expiry against the current time.

API behavior

Add static controller routes before :id/email:

  • POST /v1/assignments/bulk/preflight
  • POST /v1/assignments/bulk

Both routes require the actions they perform, including create Assignment, read Assignment, and read Subject. Service code must also enforce object scope:

  1. Resolve the required group through a scoped group query.
  2. Verify ability.can('create', forcedAppSubject('Assignment', { groupId })).
  3. Verify the instrument is in that group's accessibleInstrumentIds.
  4. Restrict subjects to the selected groupId and accessibleQuery(ability, 'read', 'Subject').
  5. Restrict conflict queries to the same groupId, instrument, subject IDs, OUTSTANDING status, and non-expired assignments.

Preflight is advisory. Bulk creation must repeat subject availability and conflict checks to close the race between review and submit. A conflict appearing after preflight is skipped unless that row has allowDuplicate: true.

For rows still eligible:

  1. Fetch the instrument bundle and active languages once.
  2. Generate keypairs with bounded concurrency.
  3. Stage Mongo assignment records, retaining per-row failures.
  4. Call one gateway bulk endpoint for all staged rows.
  5. If the gateway batch fails, delete the staged Mongo rows and return sanitized failures for them.
  6. Return results in source order.

Do not return raw exception messages. Record one audit event using the existing CREATE action and string metadata such as mode: BULK, requested count, and created count. Do not add unsupported audit action enum values.

Gateway behavior

Add POST /api/assignments/bulk in apps/gateway/src/routers/api.router.ts and a model/service createMany path. The API sends the instrument bundle and active languages once, not once per assignment. Gateway persistence for the accepted batch must be transactional/all-or-nothing.

Expose this through a deep GatewayService.createRemoteAssignments(...) API rather than a helper that merely accepts pre-fetched values while still making N HTTP requests.

The gateway currently has no Vitest project. Add its test project/configuration and prove collection, following the repository's add-Vitest-project playbook.

Web implementation

  • Keep the route file thin. Put the state machine and steps under apps/web/src/components/BulkRemoteAssignmentWizard/.
  • Put parsing/resolution/export logic in a pure utility under apps/web/src/utils/.
  • Add useBulkAssignmentPreflightMutation and useCreateBulkAssignmentsMutation; parse response schemas at the HTTP boundary and disable mutation retries.
  • Extract an assignments query-key constant and invalidate it after successful creation.
  • Reuse libui FileDropzone, ClientTable/DataTable, and Form date fields. No libui or libnest changes are required for v1.
  • Use inline bilingual t({ en, fr }) strings unless a string is genuinely reused. Add a Storybook story for the wizard's important states.
  • Add stable data-testid values needed by Playwright.

Convert the two flat group links into a Group Actions navigation group, but gate children independently:

  • Manage Group: existing manage-group rules.
  • Email Templates: existing manage-group and mail-enabled rules.
  • Bulk Remote Assignments: current group, gateway enabled, and the read/create abilities required by the page.

Render the parent if at least one child is available. Do not hide existing links because the user lacks assignment-create permission.

Add the route file but do not run or hand-edit apps/web/src/route-tree.ts; the user regenerates it manually. After that regeneration, update the testing route union through its normal generator.

Out of scope / follow-ups

  • Create or link missing subjects. Subject visibility is controlled by Subject.groupIds; Assignment.groupId does not make a subject visible. Define onboarding/linking authorization and audit semantics separately.
  • Email All. The existing throttle counts requests rather than recipients, and current mail delivery creates a transporter per send. A later issue should define recipient-weighted quota, pooled/bounded delivery, retry/durability, and partial-result behavior.
  • Changing libui FileDropzone rejection behavior. Track separately if its current rejection callback/UX blocks implementation.

Tests

Schemas

  • dynamic future-date validation in a long-running process;
  • 500 accepted, 501 rejected;
  • duplicate IDs rejected;
  • every discriminated request/result variant parsed.

API

  • ordered preflight categories;
  • exact group/subject/assignment scoping and inaccessible-subject non-disclosure;
  • inaccessible group/instrument rejected;
  • create-time recheck handles races;
  • conflict skip and explicit duplicate creation;
  • one gateway bulk call with one bundle;
  • partial staging failures remain isolated;
  • gateway failure rolls staged Mongo rows back;
  • raw exceptions are not exposed;
  • one existing-action audit entry contains bulk counts.

Gateway

  • bulk request validation;
  • transactional createMany persistence;
  • instrument bundle is accepted once for the batch;
  • test project appears in pnpm exec vitest --list output.

Web

  • CSV, paste, and XLSX parsing;
  • English/French aliases, normalization, unique mappings, and extra columns;
  • mode detection and row-numbered errors;
  • strict PII date/sex validation and hashes matching real generateSubjectHash() output;
  • duplicate/501-row rejection;
  • XLSX dynamic-import branch;
  • formula-safe CSV export;
  • mutation response parsing/query invalidation;
  • independent navigation-child gating;
  • wizard transitions and reset behavior.

End to end

Add a page object and Playwright test that:

  1. seeds existing subjects in the selected group;
  2. uploads a CSV and verifies mapping/preview;
  3. selects an accessible instrument and expiry;
  4. sees ready/conflict/unavailable states;
  5. verifies conflict defaults to skip and explicit duplicate works;
  6. creates assignments and verifies results/CSV download;
  7. proves an other-group subject is reported unavailable and receives no assignment.

Acceptance criteria

  • A permitted group manager can create one remote assignment for each of up to 500 existing subjects in the selected group.
  • PII mode sends only derived subject IDs to the API.
  • Unauthorized groups, instruments, and subjects cannot be used or distinguished.
  • Review conflicts are race-safe because creation revalidates them.
  • The API sends one bulk payload to the gateway and one shared instrument bundle per operation.
  • Partial local preparation errors are reported per row; gateway-wide failure leaves no staged Mongo assignments.
  • Result CSV is safe to open in spreadsheet software.
  • No new dependencies and no changes to ../libui or ../libnest.
  • Unit and E2E suites cover the behavior above; repository lint, unit, and E2E commands pass.

Suggested implementation order

  1. Assignment/gateway schemas and schema tests.
  2. Gateway bulk persistence/route plus a collected gateway Vitest project.
  3. API preflight/create, authorization, rollback, audit, and tests.
  4. Pure browser parsing/resolution/export utility and tests.
  5. Query-key/mutation hooks and tests.
  6. Navigation and thin route/wizard/story.
  7. User regenerates the web route tree.
  8. Playwright page object/fixture/test and full close-out checks.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions