Dentolize · X-Ray Integration Walkthrough
On this pageBusiness viewTechnical view

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:

StatusMeaning
WaitingSent to the desktop app; no response yet
AcceptedThe desktop app acknowledged the request
RejectedThe desktop app or its operator declined (a reason may be shown)
In ProgressCapture is actively happening
PreviewImage(s) captured; the operator is reviewing/selecting a page before it's finalized (e.g. for multi-page captures)
CapturedA page has been selected and encrypted, not yet uploaded
Waiting for uploadCaptured image is queued locally on the desktop app, waiting to be uploaded (manual or delayed-auto mode)
UploadingFile transfer to Dentolize's storage is underway
CompletedThe file has been verified and attached to the patient's X-ray record
FailedSomething went wrong (an error message may be shown)
TimeoutNo terminal update arrived before the request's per-state deadline — see Real-Time Status, Presence & Timeouts
CancelledStaff 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

  1. Staff requests a capture. The requestXrayAcquisition mutation (packages/server/src/resolvers/mutations/actions/xrayAcquisition/requestXrayAcquisition.js:27) resolves an authenticated web principal (resolveXrayWebPrincipal, requiring ACQUIRE_PATIENTS_XRAYS plus either VIEW_PATIENTS_XRAYS or VIEW_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 Redis INCR+EXPIRE script (lines 41-50), validates the target slot against a per-chart-type allow-list (VALID_SLOTS, lines 6-17 — e.g. a full-mouth series a1/a2 layout allows all 20 slots, a bitewing a3 layout 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) via desktopConnectionManager.isConnected. It then creates the XrayAcquisitionRequest row with status: WAITING and a 300-second stateDeadlineAt, and publishes to both the request and status Redis pub/sub channels. The mutation is idempotent: retrying with the same clientRequestId for 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 on P2002.
  2. 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 by validateDesktopRequest (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 to transitionAcquisition (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. WAITING can only go to ACCEPTED/REJECTED/CANCELLED/TIMEOUT), rejects any transition once a request is already terminal, requires an eventId used as an idempotency key (a replayed eventId with identical details returns the same result instead of erroring; a replayed eventId with different details is rejected as a conflict), and re-derives the per-state deadline (deadlineFor, lines 65-68 — 600s while PREVIEW, 300s while WAITING/ACCEPTED/IN_PROGRESS, no deadline once CAPTURED or later, since the upload-side flow takes over deadline enforcement at that point). The first non-WAITING transition also "claims" the request for that specific desktop instance (respondedByKeyId/respondedByInstanceId), and any other desktop instance's request quietly fails a matching WHERE respondedByInstanceId check going forward.
  • POST /api/xray-acquisition/:id/upload-intents and POST /api/xray-acquisition/:id/complete — see "Upload verification and storage" below.
  1. Staff sees it live. clinic-web's AcquisitionProvider (packages/clinic-web/src/context/acquisitionContext.js) subscribes to xrayAcquisitionStatusUpdated and merges incoming updates into local state; AcquisitionWidget (packages/clinic-web/src/components/common/AcquisitionWidget.js:15) maps every status (including the newer PREVIEW, folded into the same visual step as IN_PROGRESS) onto a 6-step Steps component. On page refresh, the provider re-fetches active (non-terminal) requests for the selected branch (myActiveXrayAcquisitions query, scoped server-side to requestedById: principal.userIdpackages/server/src/resolvers/queries/patientQueries.js:958) so an in-flight request survives a reload.
  2. 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 have DO_ALL. It also cancels any pending XrayUploadIntent for 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:

  1. 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-256 checksumSha256), checks the acquisition is in an uploadable state (CAPTURED/WAITING_UPLOAD/UPLOADING), atomically reserves size bytes against the company's sizeLeft storage quota (company.updateMany({ where: { sizeLeft: { gte: size } } }), line 77 — fails closed if the company is out of space), and creates an XrayUploadIntent row with a 10-minute expiry. The endpoint is idempotent per (acquisitionRequestId, clientRequestId).
  2. Upload. The response includes a presigned S3 POST form (createXrayUploadUrl, packages/server/src/services/xrayUpload.js:21, built on createPublicFileUploadPost in packages/server/src/utils/s3.js) — the desktop app uploads the file directly to S3, not through the Dentolize API.
  3. 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 actual ContentLength/ContentType against 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 the File row and flip the acquisition to COMPLETED inside a transaction that also claims the upload intent with a conditional update (race-safe against a duplicate /complete call).

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) polls connectedDesktopRooms every 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 from ACQUIRE_PATIENTS_XRAYS), the FEATURE_XRAY_ACQUISITION client-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 a branchId). Route and sidebar entry both gate on user.permissions.patientXrays (packages/clinic-web/src/containers/DashboardRouter.js:856, Sidebar.js:571) — note this is the pre-existing X-ray viewing permission, not ACQUIRE_PATIENTS_XRAYS, so anyone who can see X-rays can see the acquisitions log, whether or not they can request captures. Server-side, the underlying xrayAcquisitionRequests query additionally row-scopes non-VIEW_PATIENTS_XRAYS viewers to only their own requests (patientQueries.js:938).