Skip to content

File instruments can be assigned remotely, but the patient's files are discarded and the record stays pending forever #1708

Closed

Description

@joshunrau

A FILE instrument can be assigned remotely, but a remote assignment has no way to deliver the files. The patient picks files on the gateway and submits. The gateway's handleSubmit sends only result.data and drops the uploadMap, so the files go nowhere. When the synchronizer imports the submission, InstrumentRecordsService.create treats it like any other file record, with two outcomes:

  • Storage enabled: the record is written with pending: true and never leaves that state, because nothing will ever call upload-complete for it. The assignment is marked COMPLETE, but the record is hidden from every view that filters out pending records (the subject's table and graph, the subject hub counts). The clinician sees a completed assignment with no data, and the patient's files are lost.
  • Storage disabled: create throws ServiceUnavailableException. The synchronizer rolls back, keeps the assignment OUTSTANDING, and repeats the same create-session, fail, delete-session cycle on every pass (every 10 s by default) indefinitely.

Bulk upload already refuses FILE instruments for the same reason (instrument-records.service.ts:495-502: "Refuse it rather than write a record that can only ever be pending"). The assignment paths have no such check, and the web pickers offer FILE instruments for remote assignment.

Where

apps/api/src/assignments/assignments.service.ts:274-285: the only instrument checks are the group's opt-in list and archival:

const accessibleInstrumentIds = new Set(group.accessibleInstrumentIds);
const instrumentIds = timepoints.map(({ instrumentId }) => instrumentId);
const archivedInstrumentIds = new Set(await this.findArchivedInstrumentIds(instrumentIds));
const unavailableInstrumentIds = [
  ...new Set(instrumentIds.filter((id) => !accessibleInstrumentIds.has(id) || archivedInstrumentIds.has(id)))
];

apps/gateway/src/Root.tsx:64-69: a scalar result's files are not sent:

} else if (target.kind !== 'SERIES') {
  updateData = {
    data: result.data,
    kind: 'SCALAR',
    status: 'COMPLETE'
  };

apps/api/src/instrument-records/instrument-records.service.ts:93-97 and :140: the import then either throws or writes a pending record:

if (instrument.kind === 'FILE' && !this.storageService.isEnabled) {
  throw new ServiceUnavailableException(/* ... */);
}
// ...
pending: instrument.kind === 'FILE',

apps/web/src/utils/administrable-instruments.ts:15-25 (used by /session/remote-assignment and /group/remote-assignments) does not filter on kind.

Reproduce

  1. With storage enabled, start a session and open Remote Assignment. Pick a file instrument and create the assignment.
  2. Open the link, attach a file, submit, and wait one GATEWAY_REFRESH_INTERVAL.

Actual: the assignment shows COMPLETE, the subject has no new record in the datahub, no file was uploaded, and an InstrumentRecord with pending: true stays in the database. With storage disabled, the assignment stays OUTSTANDING, and the API logs Failed to synchronize remote assignment on every pass.
Expected: a file instrument cannot be assigned remotely. POST /v1/assignments and /v1/assignments/bulk refuse it with INSTRUMENT_UNAVAILABLE, and the remote-assignment pickers do not list it.

Tests

  • apps/api/src/assignments/__tests__/assignments.service.test.ts: it('should refuse to assign a file instrument, since the gateway has no way to upload its files') for both create and createBulk.
  • apps/web/src/utils/__tests__/administrable-instruments.test.ts (or a test for a new remote-assignment selector): a FILE instrument is excluded from the remote-assignment list but still offered for in-person sessions.
  • testing/src/specs/remote-assignment.spec.ts: a file instrument is not offered on the Remote Assignment page, and POST /v1/assignments for one returns 422.

Suggested fix

In resolveBulkRequest and the group-less branch of create, look up the kind of each requested instrument and report FILE instruments (and series containing a FILE item, which the series renderer cannot show either) as INSTRUMENT_UNAVAILABLE. Filter the same kinds out of the two remote-assignment pickers in apps/web.

Activity

  1. added
    BugType: existing behavior is wrong
    Difficulty: LowIsolated change, about 2 hours or less
    Area: APIapps/api (NestJS backend, Prisma models, CASL permissions)
    Area: Webapps/web (clinician-facing React SPA)
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). No open PR touches assignments.service.ts or administrable-instruments.ts.

    Where the kind check goes

    Instrument stores no kind (apps/api/prisma/schema.prisma:212-235), so it is only known after the bundle is evaluated. AssignmentsService does not depend on InstrumentsService, and reads the Instrument model only in findArchivedInstrumentIds (assignments.service.ts:244-250). InstrumentsService.findById won't do: it throws NotFoundException on an unknown id (instruments.service.ts:371), and resolveBulkRequest has to keep answering an unknown id with INSTRUMENT_UNAVAILABLE (testing/src/specs/bulk-remote-assignments.spec.ts:140).

    Plan: replace findArchivedInstrumentIds with one InstrumentsService method, e.g. findUnassignableIds(ids). It selects archivedAt, bundle and id for the ids, evaluates each row with getInstrumentInstance, and returns the ids that are archived or FILE. A series bundle does not say what kind its items are, so for a series it also maps getSeriesInstrumentItems through generateScalarInstrumentId and evaluates those items. Unknown ids are skipped, not thrown. Both call sites (:88 and :279) then get the rule. AssignmentsModule imports InstrumentsModule, which is no cycle because InstrumentsModule imports only VirtualizationModule. Once #1408 stores kind, the scalar half becomes a where.

    A series with a FILE item is broken in person too

    validateSeriesInstrument (instruments.service.ts:799-839) checks the item count and that each item exists, never its kind, and SeriesInstrumentRenderer renders null for a FILE item (SeriesInstrumentRenderer.tsx:225). Refusing FILE items there is the structural fix. It selects only id today, so it would have to evaluate the items too. The assignment check still needs its series branch for series already stored. The web series builder is passed only forms and interactive instruments (group/manage.tsx:599), so #1774's new FILE bucket must stay out of that prop.

    Web

    selectAdministrableInstruments also feeds the in-person picker (instruments/accessible-instruments.tsx:35), so the filter cannot go inside it. Add a sibling that also drops kind === 'FILE', and use it at session/remote-assignment.tsx:131 and group/remote-assignments.tsx:79. Leave series to the API. seriesItems is { id }[] (instrument.base.ts:218), and /v1/instruments/info returns only the latest edition of each scalar by default (instruments.service.ts:546, :613-620), so an item pinned to an older edition has no kind to look up on the client.

    Existing assignments

    The fix leaves them alone. An outstanding FILE assignment can be cancelled from the subject's Assignments tab; cancelling deletes the gateway copy, which also ends the retry loop when storage is off. A completed one left a pending: true record whose data is {} (FileInstrumentContent/store.ts:78), so nothing in it can be recovered.

    Tests

    • instruments.service.test.ts: the new method reports archived, FILE and FILE-containing series, and skips unknown ids.
    • assignments.service.test.ts: the two archived-series cases (:171, :291) mock instrumentModel.findMany today and move to a mocked InstrumentsService. Cover the FILE refusal in the group-less branch of create as well as through resolveBulkRequest.
    • administrable-instruments.test.ts: FILE is dropped from the remote list and kept in the in-person list.
    • e2e: put the 422 checks (preflight, bulk, and single with and without groupId) in bulk-remote-assignments.spec.ts, using api.findInstrumentId('FILE'). api.createGroup grants every instrument, FILE included, so the 422 comes from the new rule and not the opt-in list. Its instrumentIdsFor helper (:46-55) already keeps only FORM and INTERACTIVE, so the existing cases are unaffected. remote-assignment.spec.ts checks the picker, and accessible-instruments.spec.ts checks that Arbitrary File Upload is still offered in person.

    Triage note written with Claude Code. Code references point at main @ d86740e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: APIapps/api (NestJS backend, Prisma models, CASL permissions)Area: Webapps/web (clinician-facing React SPA)BugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessPriority: MediumReal value; schedule normally

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions