clinical-api¶
Purpose¶
clinical-api is the primary data-persistence and retrieval service for clinical records. It owns the canonical models for patients, cases, findings, diagnoses, images, histology, and organisations. It emits case.created to start the downstream workflow and projects results back into its own Diagnosis table when ai_review.completed and human_review.completed events arrive. All external clients interact with clinical data through this service.
Key endpoints¶
POST /v1/clinical-api/cases — create a case; emits case.created
GET /v1/clinical-api/cases/:id — fetch a case
PATCH /v1/clinical-api/cases/:id — update a case
GET /v1/clinical-api/patients/:patientId/cases — list cases for a patient
POST /v1/clinical-api/patients — register a patient
GET /v1/clinical-api/patients/:id — fetch a patient
PATCH /v1/clinical-api/patients/:id — update a patient
POST /v1/clinical-api/patients/search — search patients by identifier
POST /v1/clinical-api/cases/:caseId/findings — add a finding to a case
GET /v1/clinical-api/findings/:id — fetch a finding
PATCH /v1/clinical-api/findings/:id — update a finding
POST /v1/clinical-api/findings/:findingId/diagnoses — add a diagnosis to a finding
GET /v1/clinical-api/findings/:findingId/diagnoses — list diagnoses for a finding
POST /v1/clinical-api/images\:initiate — initiate an image upload (returns presigned S3 URL)
GET /v1/clinical-api/cases/:caseId/images/presigned-urls — get presigned download URLs for case images
POST /v1/clinical-api/histology\:initiate — initiate a histology report upload
GET /v1/clinical-api/audit — query the audit log
GET /v1/clinical-api/status/:resourceType/:resourceId — poll async operation status
POST /v1/clinical-api/retention/erase — trigger a data erasure run
POST /v1/clinical-api/admin/organisations — create an organisation
GET /v1/clinical-api/admin/products?organisation_id=&include_deleted=false — list products for an org
GET /v1/clinical-api/admin/products/:id — fetch one product (returns deletedAt so retired state is visible)
POST /v1/clinical-api/admin/products — create a product
PATCH /v1/clinical-api/admin/products/:id — update display_name + status (code is rejected with 400)
DELETE /v1/clinical-api/admin/products/:id — soft-delete (sets deletedAt); idempotent; 204
GET /v1/clinical-api/admin/patients?org_id=&search=&page=&limit= — admin-scoped paginated list of patients across an org. Response items expose id, organisation_id, given_name, family_name, dob, email, status, case_count, created_at (PII decrypted server-side). Search omitted server-side (encrypted columns); apply on the client.
POST /v1/clinical-api/admin/organisations/:id/purge-data — synthetic-only bulk wipe; clears the DB rows AND the org's S3 image / image_derivative / report_file objects (best-effort, errors logged). Response includes purgedCounts.s3Objects + s3ObjectsFailed.
Patient merge — admin-scoped¶
GET /v1/clinical-api/admin/patient-merge-candidates?org_id= — pending PatientMergeCandidate rows for review, with provisional + ranked candidate PII decrypted (admin:read).
POST /v1/clinical-api/admin/patient-merge-candidates/:id/merge?org_id= — body { surviving_patient_id, reason? }. Absorbs the provisional into the chosen surviving patient, moves identifiers (re-encrypting with the surviving DEK), writes PatientMergeHistory with actor + 12-month retentionExpiresAt. admin:write.
POST /v1/clinical-api/admin/patient-merge-candidates/:id/dismiss?org_id= — body { reason? }. Marks "not a duplicate" — promotes the provisional patient to active, sets the candidate row to status='dismissed'. admin:write.
The tenant-scoped equivalents at /v1/clinical-api/patients/merge (patient_merge_review scope) remain available for product integrations.
Workflow resolution — for product integrations¶
GET /v1/orchestrator/workflow-definitions/resolve?product_id=…[&org_id=…] — relayed from orchestrator. Returns the active workflow for the (org, product) pair using the org-specific → product-wide-default fallback, or 404. Lets consumers fetch the workflow without knowing its id.
Products: localised display_name {#products}¶
Product.displayName accepts sidecar translations via the JSON column display_name_translations ({ "fr": "...", "de": "..." }). The list + detail endpoints accept an optional ?lang=<locale> query param:
GET /v1/clinical-api/admin/productsandGET /v1/clinical-api/admin/products/:id- With
?lang=fr:displayNameresolves to the French variant (falls back to the Englishdisplay_namecolumn when not translated);displayNameTranslationsis hidden in the response. - Without
?lang=: both fields come through so the admin UI can render every variant. - Supported locales:
en,fr,de,es,pt-BR,it,nl,et(validated against the shared list inpackages/common/src/i18n.ts; unsupported keys → 400).
Writes (POST / PATCH /v1/clinical-api/admin/products) accept display_name_translations alongside display_name. See orchestrator.md#i18n for the full wire shape + supported-locales list.
Per-org database isolation (clinical data) {#per-org-db}¶
The clinical-data store is being prepared for per-organisation isolation so customers can stipulate "our data on its own MySQL", deploy a customer DB in a different region, or run the database in a customer-owned AWS account. The seam landed in Phase 1; the actual routing comes in later phases.
What's clinical-domain vs platform-domain. Clinical-domain tables — Patient, PatientIdentifier, PatientMergeCandidate, PatientMergeHistory, Case, SkinFinding, Diagnosis, DiagnosisCodeMapping, MedicationStatement, Image, HistologyReport, DermReview, Event (outbox), WebhookSubscription, WebhookDelivery, AuditLog, RetentionPolicy, LegalHold — will live in the per-org database when an org is marked isolated. Platform-domain tables — Organisation, Product, OrganisationProduct, OrganisationDermConfig — stay on the shared platform database in every deployment.
ClinicalPrismaResolver. Lives in services/clinical-api/src/prisma/clinical-prisma-resolver.ts. Every clinical-domain code path injects this resolver instead of the bare PrismaService. Two methods:
forOrg(orgId)returns the PrismaClient that owns this org's clinical data. Phase 1 always returns the shared client; Phase 3 will branch onOrganisation.clinicalDbConnectionRefand return a dedicated pool when set.shared()returns the bare shared client. Use only when the caller legitimately wants the shared platform database (cross-org admin code that aggregates across non-isolated tenants). Code that needs a true cross-org view in the isolated world will have to fan out viaforOrg(eachOrgId).
Platform-domain services (organisations, products, org-product assignments, derm config) continue to inject PrismaService directly — they always read/write the platform database.
Migration status (Phase 1b). All clinical-domain services are now wired through the resolver: AuditService, PatientsService, MergeService, FuzzyMatchService, DekResolver, CasesService, DiagnosesService, MedicationsService, ImageService, ImageStatusAdapter, RetentionService, ErasureService, AdminCasesService, AdminPatientMergeService, AdminStatsService, DermReviewService, AiReviewTriggerService, AiReviewConsumerService, WebhookSubscriptionsController, AdminWebhookSubsController, AdminCodeMappingsController. The resolver still returns the shared client for every call — behaviour unchanged. Platform-domain services (organisations, products, org-product assignments, derm-config, derm-client) continue to inject PrismaService directly. Services that touch both domains (e.g. CasesService for the OrganisationProduct gate, AdminStatsService for the Organisation + Product joins) inject both — ClinicalPrismaResolver for clinical reads, PrismaService (named platform) for platform reads.
Cross-schema decoupling (Phase 2). Prisma @relation declarations that crossed the clinical↔platform boundary have been removed. Clinical models keep their scalar FK columns (organisationId, productId, …) and DB-level FK constraints in migrations, but Prisma can no longer resolve a case.organisation or case.product join in a single query. Three include sites that previously did so — AdminCasesService.list, AdminCasesService.findById, and AdminOrganisationsController.list/.findById — now run a separate platform-side findMany / groupBy (typically one extra round-trip per page) and merge in code. Forward-relation back-references on Organisation and Product (patients[], cases[], …) are likewise dropped. This makes the codebase ready for the per-org isolation work in later phases.
Type-narrowed clinical client (Phase 3). ClinicalPrismaResolver.forOrg() and .shared() now return a ClinicalPrismaClient — Omit<PrismaClient, 'organisation' | 'product' | 'organisationProduct' | 'organisationDermConfig'>. The compiler now rejects any new code that tries to reach a platform table through the resolver. Existing call sites already only used clinical models (Phase 1b finished that migration), so this is a pure compile-time guard with no behavioural impact. A symmetric PlatformPrismaClient type is exported from the same module for future use by services that want the inverse narrowing. Both types live in services/clinical-api/src/prisma/clinical-prisma-client.ts.
Per-org connection routing (Phase 4). Organisation gains a nullable clinical_db_connection_ref column (VarChar(64)). NULL means "clinical data lives on the shared platform DB" — the default for every existing tenant, and behaviour is unchanged. Non-NULL is a short token (e.g. acme-eu-west-1) that the new ClinicalConnectionRegistry resolves to env var CLINICAL_DB_URL_<TOKEN_UPPER_DASHES_TO_UNDERSCORES> and uses to instantiate a dedicated PrismaClient pool. The registry preloads at boot, refreshes every 30 seconds, and is consulted synchronously by ClinicalPrismaResolver.forOrg(orgId). Missing env vars degrade gracefully — the registry logs a warning and the resolver falls back to the shared client, so a misconfigured tenant doesn't 500 the whole service. FEATURE_PER_ORG_CLINICAL_DB=false disables the entire mechanism (panic-button rollback).
DekResolver is now isolation-aware. It reads the current request's organisationId from RequestContext.currentClient() and uses forOrg so encryption/decryption for an isolated tenant's patients goes to the right DB.
Cross-org fan-out for admin reads and background workers (Phase 5). Admin read endpoints and background workers now cover isolated tenants' clinical data — there are no longer any silent gaps.
ClinicalConnectionRegistry.isolatedClients()returns the distinct isolated PrismaClient instances (deduped by ref, since two orgs may share one isolated DB).ClinicalPrismaResolver.allClients()returns[shared(), ...isolatedClients()].- Admin read endpoints (
/admin/cases,/admin/patients,/admin/patient-merge-candidates,/admin/stats,/admin/organisations,/admin/diagnosis-code-mappings, derm-reviews): when noorg_idfilter is present the handler fans out across all clients, merges and sorts results in-app, and sums aggregate counts. When anorg_idfilter is present the handler routes directly toforOrg(org_id)— a single DB, no fan-out overhead. - Background workers (
DermReviewWorker,WebhookRetryWorker,AiReviewSubscriber.handleSuperseded, queue consumersCaseImagesCheckConsumer,CaseSetStatusConsumer,CaseWorkflowCompletedConsumer): each worker now establishes aRequestContextper org it processes so thatforOrg,DekResolver, and storage resolution all work consistently in background paths. Workers that operate across all orgs loop overallClients(). - When
FEATURE_PER_ORG_CLINICAL_DB=falseor no tenant is isolated,allClients()returns[shared()]— behaviour is identical to pre-Phase-5.
Storage isolation (Phase 6). Organisation gains two further nullable columns that isolate an org's image and histology storage:
clinical_storage_bucket VARCHAR(255)— per-org S3 bucket for images and histology reports. NULL = global default (S3_BUCKET_IMAGES).clinical_kms_key_arn VARCHAR(512)— per-org KMS CMK ARN used to wrap that org's patient data-encryption keys (DEKs). NULL = global default (KMS_CMK_ARN).
OrgStorageRegistry mirrors ClinicalConnectionRegistry: a 30-second refresh loop, orgId → { bucket?, kmsKeyArn? } map, falling back to the global config defaults when columns are NULL.
S3 bucket isolation. Only the write path changes. ImageService.initiate and HistologyService.initiate resolve the bucket from OrgStorageRegistry; the resolved bucket name is persisted on each Image.s3Bucket / ReportFile.s3Bucket row. Read paths (presigned GET URLs) use the stored bucket value, so existing objects in the global bucket keep working after an org is migrated. SSE-KMS encryption is enforced through the bucket's default encryption policy, set when the operator provisions the bucket.
KMS / DEK wrapping isolation. A KeyProviderResolver selects the org's KmsKeyProvider (bound to clinicalKmsKeyArn) for wrapping new DEKs, falling back to the global provider. DEKs are now scheme-tagged (kms:<payload> / local:<payload>); legacy un-prefixed values are treated as local:. AWS Decrypt for symmetric CMKs is self-describing, so any KmsKeyProvider with IAM kms:Decrypt can unwrap any KMS-wrapped blob regardless of which specific key was used. This means lazy migration works transparently: when an org is assigned a per-org CMK, its existing DEKs keep decrypting via the old key (provided clinical-api's IAM role retains kms:Decrypt on it), and all new DEKs use the org's key — no re-wrap job is needed.
Operator runbook. See ../runbooks/onboarding-an-isolated-tenant.md for the end-to-end procedure covering all three Organisation columns, the 30-second refresh cycle, and rollback.
Database tables¶
Patient — patient demographics (fields encrypted at rest with per-patient DEK; status: active | provisional | merged)
PatientIdentifier — strong-ID rows per patient (NHS number, NPI, passport, internal ids). Unique on (organisationId, scheme, valueHash) for deterministic same-org lookups.
PatientMergeCandidate — produced when fuzzy matching at create-time classifies as ambiguous. Status: pending_review | merged | dismissed. Holds encrypted ranked candidate list + match features.
PatientMergeHistory — append-only audit of completed merges with absorbed snapshot + retentionExpiresAt (12 months) so a merge can be reversed within the window.
Case — clinical case linking a patient, organisation, and product
SkinFinding — a single finding within a case (lesion, region, etc.)
Diagnosis — diagnosis record attached to a finding (source: ai, human, or manual)
Image — image metadata; binary stored in S3
HistologyReport — histology report metadata and file references
AuditLog — immutable audit trail for all mutating operations
RetentionPolicy — data-retention rules per org / product
LegalHold — legal holds that prevent erasure of specific records
Organisation — organisation registry
Event — outbox for events published via Redis Streams
Events¶
case.created — emit — published via Redis Streams (@sa-platform/events) when a case is created; consumed by orchestrator
ai_review.requested — emit — published via Redis Pub/Sub after case completion triggers AI dispatch
ai_review.completed — consume — projects AI diagnoses into the Diagnosis table
ai_review.failed — consume — recorded in audit log
ai_review.superseded — consume — recorded in audit log
human_review.completed — consume — projects human diagnoses into the Diagnosis table (via context snapshot)
Dependencies¶
- S3 — image and histology file storage (presigned URLs generated by this service)
- Redis — outbound event publishing and inbound event consumption via
@sa-platform/eventsand@sa-platform/common - MySQL — primary store, accessed via Prisma 7 driver-adapter pattern
- auth — JWT verification via
@sa-platform/auth-client
Where to learn more¶
- Design spec
- Encryption at rest spec
- Source:
services/clinical-api/(in this repo)