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:
- Source — upload
.csv/.xlsx, or paste comma-, tab-, or semicolon-separated data.
- Map and validate — preview the first four rows, correct header mappings, and report row-numbered validation errors.
- Instrument — choose one instrument from the current group's accessible instruments using
InstrumentShowcase.
- Expiry — choose a future expiry, defaulted with
getDefaultAssignmentExpiry().
- Review — show each subject as
READY, CONFLICT, or SUBJECT_UNAVAILABLE; conflicts default to skip and may be explicitly allowed as duplicates.
- 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:
- Resolve the required group through a scoped group query.
- Verify
ability.can('create', forcedAppSubject('Assignment', { groupId })).
- Verify the instrument is in that group's
accessibleInstrumentIds.
- Restrict subjects to the selected
groupId and accessibleQuery(ability, 'read', 'Subject').
- 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:
- Fetch the instrument bundle and active languages once.
- Generate keypairs with bounded concurrency.
- Stage Mongo assignment records, retaining per-row failures.
- Call one gateway bulk endpoint for all staged rows.
- If the gateway batch fails, delete the staged Mongo rows and return sanitized failures for them.
- 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:
- seeds existing subjects in the selected group;
- uploads a CSV and verifies mapping/preview;
- selects an accessible instrument and expiry;
- sees ready/conflict/unavailable states;
- verifies conflict defaults to skip and explicit duplicate works;
- creates assignments and verifies results/CSV download;
- 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
- Assignment/gateway schemas and schema tests.
- Gateway bulk persistence/route plus a collected gateway Vitest project.
- API preflight/create, authorization, rollback, audit, and tests.
- Pure browser parsing/resolution/export utility and tests.
- Query-key/mutation hooks and tests.
- Navigation and thin route/wizard/story.
- User regenerates the web route tree.
- Playwright page object/fixture/test and full close-out checks.
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-assignmentswith six explicit states:.csv/.xlsx, or paste comma-, tab-, or semicolon-separated data.InstrumentShowcase.getDefaultAssignmentExpiry().READY,CONFLICT, orSUBJECT_UNAVAILABLE; conflicts default to skip and may be explicitly allowed as duplicates.CREATED,SKIPPED, orFAILEDper source row; allow formula-safe CSV download and starting over.SUBJECT_UNAVAILABLEintentionally 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:
subjectIdcolumn.firstName,lastName,dateOfBirth, andsexcolumns. Resolve IDs in the browser withgenerateSubjectHash()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:
YYYY-MM-DD;Use
generateSubjectHash()as the hashing source of truth; do not duplicate its algorithm. Loadxlsxwith a dynamic import only after an Excel file is selected. Export results with Papa ParseescapeFormulae: 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:
groupId,instrumentId, and 1–500 uniquesubjectIds;READY | CONFLICT | SUBJECT_UNAVAILABLE;groupId,instrumentId, dynamically validated futureexpiresAt, and 1–500 unique subjects containingsubjectIdandallowDuplicate;CREATEDwith the created$Assignment;SKIPPEDwithCONFLICT | SUBJECT_UNAVAILABLE;FAILEDwith a stable, sanitized machine-readable code;instrumentContainerandactiveLanguagesonce, 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/preflightPOST /v1/assignments/bulkBoth routes require the actions they perform, including
create Assignment,read Assignment, andread Subject. Service code must also enforce object scope:ability.can('create', forcedAppSubject('Assignment', { groupId })).accessibleInstrumentIds.groupIdandaccessibleQuery(ability, 'read', 'Subject').groupId, instrument, subject IDs,OUTSTANDINGstatus, 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:
Do not return raw exception messages. Record one audit event using the existing
CREATEaction and string metadata such asmode: BULK, requested count, and created count. Do not add unsupported audit action enum values.Gateway behavior
Add
POST /api/assignments/bulkinapps/gateway/src/routers/api.router.tsand a model/servicecreateManypath. 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
apps/web/src/components/BulkRemoteAssignmentWizard/.apps/web/src/utils/.useBulkAssignmentPreflightMutationanduseCreateBulkAssignmentsMutation; parse response schemas at the HTTP boundary and disable mutation retries.FileDropzone,ClientTable/DataTable, andFormdate fields. No libui or libnest changes are required for v1.t({ en, fr })strings unless a string is genuinely reused. Add a Storybook story for the wizard's important states.data-testidvalues needed by Playwright.Convert the two flat group links into a
Group Actionsnavigation group, but gate children independently: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
Subjectvisibility is controlled bySubject.groupIds;Assignment.groupIddoes not make a subject visible. Define onboarding/linking authorization and audit semantics separately.FileDropzonerejection behavior. Track separately if its current rejection callback/UX blocks implementation.Tests
Schemas
API
Gateway
createManypersistence;pnpm exec vitest --listoutput.Web
generateSubjectHash()output;End to end
Add a page object and Playwright test that:
Acceptance criteria
../libuior../libnest.Suggested implementation order