04-clinicalwave: W2filled16 citations

Clinical Notes — Visit Notes with Audio Transcription

Audiences: doctor, developer

Clinical Notes — Visit Notes with Audio Transcription

Clinical notes in Dudoxx HMS are FHIR R4 DocumentReference resources that store structured SOAP-style visit notes (chief complaint, diagnosis, treatment plan) as base64-encoded HTML in FHIR content attachments, with file attachments and MinIO-backed audio transcriptions linked as additional content entries.

Business Purpose

Documenting a clinical encounter is the most time-intensive part of a doctor's workflow — studies show clinicians spend 1–2 hours per day on documentation after patient hours. Dudoxx HMS reduces this through:

  1. Structured note templates: SOAP, progress, referral, and discharge notes with typed fields (chief complaint, diagnosis, treatment plan), reducing free-text entry.
  2. AI-assisted transcription: Voice recordings captured during the visit are transcribed via the on-premise STT engine and linked as attachments to the note, enabling retrospective review.
  3. Encounter-linked retrieval: Notes are linked to FHIR Encounters (visits), so the full clinical narrative for any appointment is retrievable in one query.
  4. FHIR-native storage: Notes stored as DocumentReference are shareable with referral networks, EHR systems, and patient portals using standard FHIR queries.

Audiences

  • Investor: Structured clinical documentation is a key differentiator — most German EMRs still use unstructured free-text or PDF uploads. AI transcription + structured SOAP notes demonstrate a workflow advantage that reduces documentation burden, a top pain point for clinicians.
  • Clinical buyer (doctor/nurse/receptionist): Doctors create a note during or after each visit, filling structured fields. Audio recordings from the voice assistant are automatically linked. Notes are searchable by note type and filterable by author. Receptionists can list all notes for their organization.
  • Developer/partner: REST at GET|POST|PATCH|DELETE /api/v1/clinical-notes. Notes are identified by FHIR DocumentReference numeric IDs. Encounter linking uses context.encounter FHIR field — pass visit UUID; the service resolves to FHIR Encounter ID. Attachments are referenced by MinIO file IDs (minio://) or existing DocumentReference IDs.
  • Internal (ops/support): ClinicalNotesService implements a FHIR SoT feature flag (featureFlag table, name = FHIR_SOT_FLAG_NAME). When the flag is ON, a fhirIndex Prisma row tracks each DocumentReference. Parity logs (msg: 'fhir-parity') are emitted on every create/update/delete — use these to audit migration progress.

Architecture

diagram
ClinicalNotesController  (/api/v1/clinical-notes)
  └── ClinicalNotesService
        ├── IFhirClient (FHIR_CLIENT)          ← DocumentReference CRUD
        ├── PatientIdResolverService            ← UUID → FHIR Patient ID
        ├── PractitionerIdMapper                ← UUID → FHIR Practitioner ID
        ├── OrganizationResolverService         ← slug → UUID for fhirIndex
        └── PrismaService
              ├── featureFlag                   ← FHIR SoT flag + rollout %
              └── fhirIndex                     ← bookkeeping when flag ON

DocumentReference structure:
  type.coding[0]    = LOINC note type code (e.g. '34131-3' for encounter note)
  category[0]       = { system: 'http://dudoxx.com/...', code: 'clinical-note' }
  subject.reference = Patient/{fhirPatientId}
  content[0]        = HTML attachment (base64) — chief complaint, notes, diagnosis, treatment plan
  content[1..n]     = file attachments (minio:// or DocumentReference/ URLs)
  context.encounter = Encounter/{fhirEncounterId}
  extension[]       = structured fields as FHIR extensions (chief-complaint, diagnosis, treatment-plan, title, content)

The FHIR SoT flag enables a gradual migration from FHIR-only storage to FHIR + Prisma fhirIndex bookkeeping. Flag OFF: plain FHIR write. Flag ON: FHIR write first, then fhirIndex.create() / upsert() / delete(). Either path emits a structured fhir-parity log line.

See coding_context/ddx-hms-context.md §Clinical Data for the full module map.

Tech Stack & Choices

LayerTechnologyRationale
APINestJS 11, @Controller('clinical-notes')Uniform REST with audit decorators (@AuditMeta) on create/update/delete
FHIR resourceDocumentReference (R4)Standard HL7 type for clinical documents; supports structured attachments, encounter context, and category tagging
Note typesLOINC system codesNOTE_TYPE_DISPLAY map translates codes to human labels (SOAP, progress, referral, discharge)
Content encodingBase64 HTML in content[0].attachment.dataFHIR-portable; embedded content survives export without external URL resolution
AttachmentsMinIO file IDs (minio://) or DocumentReference refsFiles stored in MinIO via VisitAttachmentService; referenced by URL in content entries
SoT migrationFeature flag FHIR_SOT_FLAG_NAME + fhirIndex Prisma tablePercentage-based rollout; fail-closed (defaults OFF on error)
Audit@AuditMeta on create/update/deleteStructured audit rows in ddx_api_log schema
Bulk operationsPOST /clinical-notes/bulk — DELETE, ARCHIVE, STATUS_UPDATEPromise.allSettled() — partial success reported per-ID

Key design decision: Structured note fields (chief complaint, diagnosis, treatment plan) are stored BOTH in the base64 HTML attachment (for display) AND in FHIR extensions (for programmatic access). mergeUpdates() rebuilds the HTML attachment from the extensions on every update, keeping them in sync.

Data Flow

Doctor creates a visit note

Business outcome: Doctor dictates a SOAP note and links it to the current visit; within 2 seconds, the note appears on the patient chart.

Technical mechanism:

  1. POST /api/v1/clinical-notes with { patientId, noteType, content, chiefComplaint, diagnosis, treatmentPlan, encounterId, attachments[] } and X-Clinic-ID.
  2. ClinicalNotesController.create() (clinical-notes.controller.ts:138) auto-fills practitionerId from user.fhirPractitionerId if not provided.
  3. ClinicalNotesService.create() (clinical-notes.service.ts:244) resolves org UUID, builds DocumentReference payload via buildDocumentReferenceForDto():
    • patientIdResolver.resolveFhirPatientId(patientId) → FHIR Patient numeric ID
    • resolveEncounterId(encounterId) → FHIR Encounter numeric ID (from prisma.visit.fhirEncounterId)
    • practitionerIdMapper.userUuidToFhirId(practitionerId) → FHIR Practitioner ID
  4. Flag check: isFhirSotEnabled(orgUuid) — routes to fhirFirstCreate() (flag ON) or legacyFhirOnlyCreate() (flag OFF).
  5. fhirClient.create('DocumentReference', ref, clinicSlug) writes to HAPI.
  6. If flag ON: prisma.fhirIndex.create(...) adds bookkeeping row.
  7. logParity({ op: 'create', flagState, success, fhirId, versionId }) — emitted regardless of path.

Doctor finds notes for a patient

GET /api/v1/clinical-notes/patient/:patientIdfindByPatient() (clinical-notes.service.ts:385) → FHIR search DocumentReference?subject=Patient/{fhirId}&category=clinical-note&_sort=-date → for each note, resolves practitioner name via fhirClient.read('Practitioner', fhirPractitionerId, orgId).

Find notes linked to an encounter

GET /api/v1/clinical-notes/encounter/:encounterIdfindByEncounter() (clinical-notes.service.ts:447) → resolves visit UUID to FHIR Encounter integer ID → FHIR search DocumentReference?encounter=Encounter/{fhirEncounterId}. Critical: HAPI indexes by FHIR integer ID; searching with a UUID reference returns empty.

Bulk archive

POST /api/v1/clinical-notes/bulk with { action: 'ARCHIVE', ids: [...] }bulkOperation() (clinical-notes.service.ts:577) → Promise.allSettled(ids.map(id => fhirClient.update(..., { status: 'superseded' }))) → returns { succeeded: [], failed: [], successCount, failureCount }.

Implicated Code

  • ddx-api/src/clinical-data/clinical-notes/clinical-notes.controller.ts:39ClinicalNotesController@ResourceMeta definition: list/get/create/update/delete/count/search on noteType and authorId fields
  • ddx-api/src/clinical-data/clinical-notes/clinical-notes.controller.ts:96bulkOperation()POST /clinical-notes/bulk — DELETE, ARCHIVE, STATUS_UPDATE in one call
  • ddx-api/src/clinical-data/clinical-notes/clinical-notes.controller.ts:138create() — auto-fills practitionerId from gateway user if missing
  • ddx-api/src/clinical-data/clinical-notes/clinical-notes.controller.ts:188findByPatient()GET /clinical-notes/patient/:patientId — resolves practitioner names per-note
  • ddx-api/src/clinical-data/clinical-notes/clinical-notes.controller.ts:224findByEncounter()GET /clinical-notes/encounter/:encounterId — UUID→FHIR Encounter ID resolution required
  • ddx-api/src/clinical-data/clinical-notes/clinical-notes.service.ts:129ClinicalNotesService — injects FHIR_CLIENT, PatientIdResolverService, PractitionerIdMapper, PrismaService, OrganizationResolverService
  • ddx-api/src/clinical-data/clinical-notes/clinical-notes.service.ts:148isFhirSotEnabled() — percentage-based feature flag lookup; fail-closed (returns false on DB error)
  • ddx-api/src/clinical-data/clinical-notes/clinical-notes.service.ts:244create() — flag-gated: routes to fhirFirstCreate() or legacyFhirOnlyCreate()
  • ddx-api/src/clinical-data/clinical-notes/clinical-notes.service.ts:447findByEncounter() — resolves UUID → FHIR Encounter ID before HAPI search (prevents empty results)
  • ddx-api/src/clinical-data/clinical-notes/clinical-notes.service.ts:577bulkOperation()Promise.allSettled() for partial-success bulk ops
  • ddx-api/src/clinical-data/clinical-notes/clinical-notes.service.ts:692buildDocumentReference() — builds FHIR DocumentReference from DTO, encoding structured fields in both HTML content and FHIR extensions
  • ddx-api/src/clinical-data/clinical-blobs/ — clinical blob storage (large binary clinical data linked to notes)
  • ddx-api/src/clinical/visit-attachments/ — attachment management for visit-scoped files (images, PDFs linked to notes)
  • ddx-api/src/voice-video/recordings/ — audio recordings and transcriptions; linked to notes via minio:// attachment references

Operational Notes

  • Encounter ID resolution is mandatory: Passing a visit UUID to the FHIR search encounter param returns empty results — HAPI indexes by its own numeric Encounter ID. resolveEncounterId() handles this by reading prisma.visit.fhirEncounterId. If the Prisma visit row has no fhirEncounterId, the UUID is passed through unchanged (HAPI will not match anything).
  • Practitioner ID drift: The author field in DocumentReference is stored as Practitioner/{practitionerId}. If the practitioner ID was stored as a Postgres UUID instead of the FHIR numeric ID (pre-CR-021), findByPatient() attempts to remap it via practitionerIdMapper.userUuidToFhirId(). Missing mapping → null practitioner name (not an error).
  • FHIR SoT flag is fail-closed: If the featureFlag DB lookup fails, isFhirSotEnabled() returns false, falling back to the legacy path. This prevents a DB outage from blocking note creation.
  • HTML rebuild on update: When content, chiefComplaint, diagnosis, or treatmentPlan changes, mergeUpdates() calls rebuildHtmlContent() to regenerate the base64 HTML content[0] attachment from the current extensions. Failing to do this creates a split: extensions hold new data but the rendered HTML shows stale content.
  • Bulk ARCHIVE semantics: ARCHIVE sets FHIR status: 'superseded' (not deleted). Resources remain in HAPI and are retrievable by direct ID lookup but excluded from active status=current searches.
  • Category field required for list: findAll() searches DocumentReference?category=clinical-note. Notes created without the Dudoxx category code will not appear in list results but will appear in findByPatient() which also applies category filtering.
    Clinical Notes — Visit Notes with Audio Transcription — Dudoxx Docs | Dudoxx