X-Ray Acquisition Workflow
Business view
Instead of capturing an X-ray on a separate computer and manually uploading the file, a staff member with the right permission can request the capture directly from the patient's chart. The request travels to a connected desktop scanning application, which captures the image with a real scanner, uploads it, and drops it into the exact slot the staff member asked for — all while the staff member watches a live status indicator.
The lifecycle a request moves through:
| Status | Meaning |
|---|---|
| Waiting | Sent to the desktop app; no response yet |
| Accepted | The desktop app acknowledged the request |
| Rejected | The desktop app or its operator declined (a reason may be shown) |
| In Progress | Capture is actively happening |
| Preview | Image(s) captured; the operator is reviewing/selecting a page before it's finalized (e.g. for multi-page captures) |
| Captured | A page has been selected and encrypted, not yet uploaded |
| Waiting for upload | Captured image is queued locally on the desktop app, waiting to be uploaded (manual or delayed-auto mode) |
| Uploading | File transfer to Dentolize's storage is underway |
| Completed | The file has been verified and attached to the patient's X-ray record |
| Failed | Something went wrong (an error message may be shown) |
| Timeout | No terminal update arrived before the request's per-state deadline — see Real-Time Status, Presence & Timeouts |
| Cancelled | Staff cancelled the request, or a captured-but-unfinished upload was voided by the server |
Only one active acquisition is allowed per X-ray slot at a time (and the room/lease layer separately prevents two staff members from occupying the same scanner — see below), so requests can't collide. Staff can cancel a request while it's Waiting or Accepted; once the desktop app starts actively capturing, cancellation is no longer offered in the UI, though an admin (DO_ALL) can still cancel any non-terminal request belonging to their company, not just their own.
A small floating widget stays visible in the bottom-right corner of the screen while a request is active, so staff can keep working elsewhere in the app without losing track of it. When the request finishes, a View X-Ray button jumps straight to the finished image.
Technical view
Data model
XrayAcquisitionRequest (packages/prisma/schema.prisma:7256) is the record of one capture request: patient, branch, room, the target Xray/slot (xrayId + slotOrder), status, statusUpdatedAt, a stateDeadlineAt used for timeout enforcement, a requestIdempotencyKey, and denormalized patient info (patientName, patientDOB, patientPhoto) captured at request time so the desktop app doesn't need a second round-trip to identify the patient. XrayAcquisitionStatus is the enum backing status (schema.prisma:7239, now including PREVIEW). Every request accumulates an append-only XrayAcquisitionLog trail (schema.prisma:7304) — one row per event, each tagged with either the acting userId or accessKeyId and an idempotencyKey unique per (acquisitionRequestId, idempotencyKey). A separate XrayUploadIntent model (schema.prisma:7335) tracks each individual upload attempt (see "Upload verification" below).
Request → response flow
- Staff requests a capture. The
requestXrayAcquisitionmutation (packages/server/src/resolvers/mutations/actions/xrayAcquisition/requestXrayAcquisition.js:27) resolves an authenticated web principal (resolveXrayWebPrincipal, requiringACQUIRE_PATIENTS_XRAYSplus eitherVIEW_PATIENTS_XRAYSorVIEW_CREATED_PATIENTS_XRAYS), confirms the beta feature flag is on for the company (isXrayAcquisitionEnabled, line 38), rate-limits to 30 requests/minute per user via a RedisINCR+EXPIREscript (lines 41-50), validates the target slot against a per-chart-type allow-list (VALID_SLOTS, lines 6-17 — e.g. a full-mouth seriesa1/a2layout allows all 20 slots, a bitewinga3layout only 5), confirms the slot isn't already filled or already has an active request, and confirms a desktop app is actually connected for that(companyId, branchId, room)viadesktopConnectionManager.isConnected. It then creates theXrayAcquisitionRequestrow withstatus: WAITINGand a 300-secondstateDeadlineAt, and publishes to both the request and status Redis pub/sub channels. The mutation is idempotent: retrying with the sameclientRequestIdfor the same user returns the original request rather than creating a duplicate (lines 19-25, 84-96, 122-132), using a(requestedById, requestIdempotencyKey)unique constraint with race-safe fallback handling onP2002. - The desktop app reports progress. It has no GraphQL mutation access for this — instead it calls plain REST endpoints under
/api/xray-acquisition/, all guarded byvalidateDesktopRequest(packages/server/src/utils/desktopPrincipal.js:50), which resolves a lease token (not the raw Access Key — see Access Keys & Security Model) bound to a specific company/branch/room/desktop-instance:
POST /api/xray-acquisition/:id/transitions(handleXrayAcquisitionStatus.js) — the single endpoint for every status change. It delegates totransitionAcquisition(packages/server/src/services/xrayAcquisitionTransitions.js:75), which enforces a strict allow-list of legal transitions per current status (ALLOWED_TRANSITIONS, lines 35-43 — e.g.WAITINGcan only go toACCEPTED/REJECTED/CANCELLED/TIMEOUT), rejects any transition once a request is already terminal, requires aneventIdused as an idempotency key (a replayedeventIdwith identical details returns the same result instead of erroring; a replayedeventIdwith different details is rejected as a conflict), and re-derives the per-state deadline (deadlineFor, lines 65-68 — 600s whilePREVIEW, 300s whileWAITING/ACCEPTED/IN_PROGRESS, no deadline onceCAPTUREDor later, since the upload-side flow takes over deadline enforcement at that point). The first non-WAITINGtransition also "claims" the request for that specific desktop instance (respondedByKeyId/respondedByInstanceId), and any other desktop instance's request quietly fails a matchingWHERE respondedByInstanceIdcheck going forward.POST /api/xray-acquisition/:id/upload-intentsandPOST /api/xray-acquisition/:id/complete— see "Upload verification and storage" below.
- Staff sees it live.
clinic-web'sAcquisitionProvider(packages/clinic-web/src/context/acquisitionContext.js) subscribes toxrayAcquisitionStatusUpdatedand merges incoming updates into local state;AcquisitionWidget(packages/clinic-web/src/components/common/AcquisitionWidget.js:15) maps every status (including the newerPREVIEW, folded into the same visual step asIN_PROGRESS) onto a 6-stepStepscomponent. On page refresh, the provider re-fetches active (non-terminal) requests for the selected branch (myActiveXrayAcquisitionsquery, scoped server-side torequestedById: principal.userId—packages/server/src/resolvers/queries/patientQueries.js:958) so an in-flight request survives a reload. - Cancellation.
cancelXrayAcquisition(packages/server/src/resolvers/mutations/actions/xrayAcquisition/cancelXrayAcquisition.js:5) resolves the same web principal pattern, is only valid for non-terminal statuses, and is scoped to the caller's own requests unless they haveDO_ALL. It also cancels any pendingXrayUploadIntentfor the request and refunds the storage quota that intent had reserved (company.sizeLeft, lines 39-50) before publishing the update.
Upload verification and storage (new since the last documentation draft)
The completion path is not "the desktop app tells us it uploaded a file, we believe it." It's a three-step, server-verified handoff:
- Reserve.
POST /api/xray-acquisition/:id/upload-intents(packages/server/src/apis/handleXrayUploadIntent.js:16) validates the request body with a strict Zod schema (clientRequestId,size,contentType— PNG or BMP only — and a SHA-256checksumSha256), checks the acquisition is in an uploadable state (CAPTURED/WAITING_UPLOAD/UPLOADING), atomically reservessizebytes against the company'ssizeLeftstorage quota (company.updateMany({ where: { sizeLeft: { gte: size } } }), line 77 — fails closed if the company is out of space), and creates anXrayUploadIntentrow with a 10-minute expiry. The endpoint is idempotent per(acquisitionRequestId, clientRequestId). - Upload. The response includes a presigned S3 POST form (
createXrayUploadUrl,packages/server/src/services/xrayUpload.js:21, built oncreatePublicFileUploadPostinpackages/server/src/utils/s3.js) — the desktop app uploads the file directly to S3, not through the Dentolize API. - Verify and complete.
POST /api/xray-acquisition/:id/complete(handleXrayAcquisitionComplete.js:11) does not trust the upload happened as described. It re-fetches the object from S3 (verifyXrayObject,xrayUpload.js:109), checks the actualContentLength/ContentTypeagainst what was declared, streams the body while re-computing its SHA-256 and comparing it to the declared checksum, and parses the raw PNG/BMP header bytes itself (inspectPng/inspectBmp, lines 56-102) to confirm the file really is a well-formed image of that type within configured dimension/decoded-size limits (XRAY_UPLOAD_LIMITS, defaults: 50 MB upload, 10000px max dimension, 200 MB max decoded size — all overridable via env vars but capped at those defaults). Only after all of that does it create theFilerow and flip the acquisition toCOMPLETEDinside a transaction that also claims the upload intent with a conditional update (race-safe against a duplicate/completecall).
Abandoned uploads are cleaned up automatically: a cron pass (see Real-Time Status, Presence & Timeouts) expires stale PENDING intents, refunds their reserved quota, and deletes the underlying S3 object — with a specific note in the code that a cancelled intent's presigned POST can still be "in flight" from the desktop app's side, so the object is deleted immediately but only marked permanently cleaned-up after the presigned URL's own expiry, closing a delete-before-upload race.
UI entry points
AcquirePopover(packages/clinic-web/src/components/dashboard/patients/Patient/components/PatientXrays/components/AcquirePopover.js) pollsconnectedDesktopRoomsevery 10s while open and auto-selects the room if exactly one is connected.XrayUploadBox(packages/clinic-web/src/components/dashboard/patients/Patient/components/PatientXrays/components/XrayUploadBox.js:254) gates the Acquire button behind three independent conditions:user.permissions.acquirePatientXray(mapped fromACQUIRE_PATIENTS_XRAYS), theFEATURE_XRAY_ACQUISITIONclient-side feature flag (useFeatureFlag, line 32), and no other acquisition already active for that slot — and disables it while any of those fail.- The Logs → X-Ray Acquisitions table (
packages/clinic-web/src/components/dashboard/logs/xrayAcquisitions/XrayAcquisitions.js) lists requests company-wide, live-updating via subscription when a single branch is selected, or polling every 10s when viewing "All Branches" (subscriptions require abranchId). Route and sidebar entry both gate onuser.permissions.patientXrays(packages/clinic-web/src/containers/DashboardRouter.js:856,Sidebar.js:571) — note this is the pre-existing X-ray viewing permission, notACQUIRE_PATIENTS_XRAYS, so anyone who can see X-rays can see the acquisitions log, whether or not they can request captures. Server-side, the underlyingxrayAcquisitionRequestsquery additionally row-scopes non-VIEW_PATIENTS_XRAYSviewers to only their own requests (patientQueries.js:938).