fix: Optional OCR-Assisted Draft Pre-Fill

This commit is contained in:
voltsrage
2026-06-28 01:48:15 +08:00
parent c160c5f912
commit c26ef22670
16 changed files with 186 additions and 69 deletions
@@ -129,31 +129,42 @@ public class DigitizationBatchesController : ControllerBase
var batch = await _batches.GetByIdAsync(id); var batch = await _batches.GetByIdAsync(id);
var presignedUrl = await _storage.GetPresignedUrlAsync(batch.DocumentRef); var presignedUrl = await _storage.GetPresignedUrlAsync(batch.DocumentRef);
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); await RecordDocumentAccessAsync(id);
var cutoff = DateTimeOffset.UtcNow.AddMinutes(-5);
var recentAccess = await _db.DigitizationEvents.AnyAsync(e =>
e.BatchId == id &&
e.EventType == DigitizationEventType.DocumentAccessed &&
e.ActorUserId == userId &&
e.OccurredAt >= cutoff);
if (!recentAccess)
{
_db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = id,
EventType = DigitizationEventType.DocumentAccessed,
ActorUserId = userId,
OccurredAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync();
}
return Ok(ApiResponse<BatchDetailResponse>.Ok( return Ok(ApiResponse<BatchDetailResponse>.Ok(
BatchDetailResponse.FromEntity(batch, presignedUrl))); BatchDetailResponse.FromEntity(batch, presignedUrl)));
} }
/// <summary>
/// Streams the scanned document for in-app viewing (same auth and audit as GET batch).
/// Proxied through the API so the workstation can render PDFs without cross-origin iframe issues.
/// </summary>
[HttpGet("{id:guid}/document")]
[Produces("application/pdf", "image/jpeg", "image/png", "application/octet-stream")]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetDocument(Guid id)
{
var batch = await _batches.GetByIdAsync(id);
if (batch.DocumentRef == "live-capture")
return NotFound(ApiResponse<object>.Fail(
404, "This batch has no scanned document.", "NO_DOCUMENT"));
var scanned = await _db.ScannedDocuments
.AsNoTracking()
.FirstOrDefaultAsync(d => d.BatchId == id);
var contentType = scanned?.ContentType ?? "application/octet-stream";
await RecordDocumentAccessAsync(id);
var stream = await _storage.DownloadAsync(batch.DocumentRef);
Response.Headers.CacheControl = "private, max-age=300";
return File(stream, contentType);
}
/// <summary> /// <summary>
/// Lists batches with optional filters and pagination. /// Lists batches with optional filters and pagination.
/// </summary> /// </summary>
@@ -265,4 +276,27 @@ public class DigitizationBatchesController : ControllerBase
var result = await _batchEventService.GetEventsAsync(id, afterCursor, pageSize); var result = await _batchEventService.GetEventsAsync(id, afterCursor, pageSize);
return Ok(ApiResponse<CursorPagedResult<BatchEventResponse>>.Ok(result)); return Ok(ApiResponse<CursorPagedResult<BatchEventResponse>>.Ok(result));
} }
private async Task RecordDocumentAccessAsync(Guid batchId)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var cutoff = DateTimeOffset.UtcNow.AddMinutes(-5);
var recentAccess = await _db.DigitizationEvents.AnyAsync(e =>
e.BatchId == batchId &&
e.EventType == DigitizationEventType.DocumentAccessed &&
e.ActorUserId == userId &&
e.OccurredAt >= cutoff);
if (recentAccess) return;
_db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batchId,
EventType = DigitizationEventType.DocumentAccessed,
ActorUserId = userId,
OccurredAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync();
}
} }
+2 -2
View File
@@ -69,7 +69,8 @@ public class BatchService : IBatchService
patientId ??= supersededBatch.PatientId; patientId ??= supersededBatch.PatientId;
} }
var (objectKey, sha256, fileSize) = await _storage.UploadAsync(fileStream, contentType, Guid.NewGuid()); var batchId = Guid.NewGuid();
var (objectKey, sha256, fileSize) = await _storage.UploadAsync(fileStream, contentType, batchId);
// Duplicate detection: same SHA-256 for same patient within 24 hours // Duplicate detection: same SHA-256 for same patient within 24 hours
if (patientId.HasValue) if (patientId.HasValue)
@@ -101,7 +102,6 @@ public class BatchService : IBatchService
} }
} }
var batchId = Guid.NewGuid();
var batch = new DigitizationBatch var batch = new DigitizationBatch
{ {
Id = batchId, Id = batchId,
@@ -178,9 +178,9 @@ describe('EntryForm', () => {
sex: 'male', sex: 'male',
bloodType: 'O+', bloodType: 'O+',
emergencyContact: '555-1234', emergencyContact: '555-1234',
allergiesJson: null, allergies: null,
noKnownAllergies: false, noKnownAllergies: false,
medicationsJson: null, medications: null,
noActiveMedications: false, noActiveMedications: false,
}, },
encounter: null, encounter: null,
@@ -77,9 +77,9 @@ function mountWithDraft(batchOverrides: Partial<BatchDetailResponse> = {}) {
sex: 'female', sex: 'female',
bloodType: 'A+', bloodType: 'A+',
emergencyContact: '555-1234', emergencyContact: '555-1234',
allergiesJson: null, allergies: null,
noKnownAllergies: false, noKnownAllergies: false,
medicationsJson: null, medications: null,
noActiveMedications: false, noActiveMedications: false,
}, },
encounter: { encounter: {
@@ -340,7 +340,7 @@ describe('VerificationForm', () => {
it('shows allergy fields for ALLERGY_UPDATE batch', async () => { it('shows allergy fields for ALLERGY_UPDATE batch', async () => {
const { wrapper } = mountWithDraft({ batchType: 'ALLERGY_UPDATE' }) const { wrapper } = mountWithDraft({ batchType: 'ALLERGY_UPDATE' })
const store = useBatchStore() const store = useBatchStore()
store.currentDraft!.patient!.allergiesJson = JSON.stringify(['Penicillin', 'Latex']) store.currentDraft!.patient!.allergies = ['Penicillin', 'Latex']
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
// Re-trigger the watcher by resetting draft // Re-trigger the watcher by resetting draft
@@ -368,9 +368,9 @@ describe('VerificationForm', () => {
sex: 'female', sex: 'female',
bloodType: null, bloodType: null,
emergencyContact: null, emergencyContact: null,
allergiesJson: null, allergies: null,
noKnownAllergies: true, noKnownAllergies: true,
medicationsJson: null, medications: null,
noActiveMedications: false, noActiveMedications: false,
}, },
encounter: { encounter: {
@@ -237,6 +237,14 @@ describe('useBatchStore', () => {
expect(mockedPut).toHaveBeenCalledWith('digitization-batches/b1/draft/patient', { expect(mockedPut).toHaveBeenCalledWith('digitization-batches/b1/draft/patient', {
fullName: 'John Doe', fullName: 'John Doe',
dateOfBirth: null,
sex: null,
bloodType: null,
emergencyContact: null,
allergies: null,
noKnownAllergies: false,
medications: null,
noActiveMedications: false,
}) })
}) })
}) })
+6
View File
@@ -183,4 +183,10 @@ export async function postBlob(url: string, data?: unknown): Promise<Blob> {
return response.data as Blob return response.data as Blob
} }
/** GET and receive a binary response (e.g. scanned document). */
export async function getBlob(url: string): Promise<Blob> {
const response = await apiClient.get(url, { responseType: 'blob' })
return response.data as Blob
}
export default apiClient export default apiClient
@@ -349,8 +349,8 @@ watch(
noKnownAllergies: draft.patient.noKnownAllergies ?? false, noKnownAllergies: draft.patient.noKnownAllergies ?? false,
noActiveMedications: draft.patient.noActiveMedications ?? false, noActiveMedications: draft.patient.noActiveMedications ?? false,
}) })
allergies.value = parseJsonList(draft.patient.allergiesJson) allergies.value = draft.patient.allergies ?? []
medications.value = parseJsonList(draft.patient.medicationsJson) medications.value = draft.patient.medications ?? []
} }
if (draft.encounter) { if (draft.encounter) {
Object.assign(encounter, { Object.assign(encounter, {
@@ -380,7 +380,7 @@ async function saveAllergies() {
try { try {
await batchStore.saveDraftPatient(props.batchId, { await batchStore.saveDraftPatient(props.batchId, {
...patient, ...patient,
allergiesJson: JSON.stringify(allergies.value.filter(a => a.trim())), allergies: allergies.value.filter(a => a.trim()),
}) })
toast.success('Allergies saved') toast.success('Allergies saved')
} catch (e: unknown) { } catch (e: unknown) {
@@ -410,7 +410,7 @@ async function saveMedications() {
try { try {
await batchStore.saveDraftPatient(props.batchId, { await batchStore.saveDraftPatient(props.batchId, {
...patient, ...patient,
medicationsJson: JSON.stringify(medications.value.filter(m => m.trim())), medications: medications.value.filter(m => m.trim()),
}) })
toast.success('Medications saved') toast.success('Medications saved')
} catch (e: unknown) { } catch (e: unknown) {
@@ -431,8 +431,8 @@ async function savePatient() {
try { try {
await batchStore.saveDraftPatient(props.batchId, { await batchStore.saveDraftPatient(props.batchId, {
...patient, ...patient,
allergiesJson: patient.noKnownAllergies ? null : JSON.stringify(allergies.value.filter(a => a.trim())), allergies: patient.noKnownAllergies ? null : allergies.value.filter(a => a.trim()),
medicationsJson: patient.noActiveMedications ? null : JSON.stringify(medications.value.filter(m => m.trim())), medications: patient.noActiveMedications ? null : medications.value.filter(m => m.trim()),
}) })
toast.success('Patient demographics saved') toast.success('Patient demographics saved')
} catch (e: unknown) { } catch (e: unknown) {
@@ -34,12 +34,13 @@
transition: isPanning ? 'none' : 'transform 0.2s', transition: isPanning ? 'none' : 'transform 0.2s',
}" }"
> >
<!-- PDF rendering via iframe for simplicity; production would use pdf.js --> <!-- PDF/image rendered from same-origin blob URL (avoids cross-origin MinIO iframe issues) -->
<iframe <iframe
v-if="isPdf" v-if="isPdf"
:src="url" :src="url"
class="w-[800px] h-[1100px] bg-white" class="w-[800px] h-[1100px] bg-white"
frameborder="0" frameborder="0"
title="Scanned document"
/> />
<img <img
v-else v-else
@@ -287,7 +287,7 @@ watch(
{ path: 'patient.noKnownAllergies', label: 'No Known Allergies', value: 'Yes (NKA)' }, { path: 'patient.noKnownAllergies', label: 'No Known Allergies', value: 'Yes (NKA)' },
] ]
} else { } else {
const items = parseJsonList(draft.patient.allergiesJson) const items = draft.patient.allergies ?? []
allergyFields.value = items.map((item, i) => ({ allergyFields.value = items.map((item, i) => ({
path: `patient.allergies[${i}]`, path: `patient.allergies[${i}]`,
label: `Allergy ${i + 1}`, label: `Allergy ${i + 1}`,
@@ -295,7 +295,7 @@ watch(
})) }))
if (items.length === 0) { if (items.length === 0) {
allergyFields.value = [ allergyFields.value = [
{ path: 'patient.allergiesJson', label: 'Allergies', value: '(none entered)' }, { path: 'patient.allergies', label: 'Allergies', value: '(none entered)' },
] ]
} }
} }
@@ -309,7 +309,7 @@ watch(
{ path: 'patient.noActiveMedications', label: 'No Active Medications', value: 'Yes' }, { path: 'patient.noActiveMedications', label: 'No Active Medications', value: 'Yes' },
] ]
} else { } else {
const items = parseJsonList(draft.patient.medicationsJson) const items = draft.patient.medications ?? []
medicationFields.value = items.map((item, i) => ({ medicationFields.value = items.map((item, i) => ({
path: `patient.medications[${i}]`, path: `patient.medications[${i}]`,
label: `Medication ${i + 1}`, label: `Medication ${i + 1}`,
@@ -317,7 +317,7 @@ watch(
})) }))
if (items.length === 0) { if (items.length === 0) {
medicationFields.value = [ medicationFields.value = [
{ path: 'patient.medicationsJson', label: 'Medications', value: '(none entered)' }, { path: 'patient.medications', label: 'Medications', value: '(none entered)' },
] ]
} }
} }
@@ -1,40 +1,56 @@
import { computed, watch, onUnmounted, type Ref } from 'vue' import { ref, watch, onUnmounted, type Ref } from 'vue'
import { useIntervalFn } from '@vueuse/core' import { getBlob } from '../api/client'
import { useBatchStore } from '../stores/batches' import { useBatchStore } from '../stores/batches'
const PRESIGNED_URL_TTL_MS = 15 * 60 * 1000
const REFRESH_BUFFER_MS = 60_000
export function usePresignedUrl(batchId: Ref<string | undefined>) { export function usePresignedUrl(batchId: Ref<string | undefined>) {
const batchStore = useBatchStore() const batchStore = useBatchStore()
const documentUrl = ref<string | null>(null)
const documentError = ref<string | null>(null)
const documentUrl = computed(() => batchStore.documentUrl) let currentBlobUrl: string | null = null
async function refreshUrl(): Promise<void> { function revokeBlobUrl(): void {
if (batchId.value) { if (currentBlobUrl) {
await batchStore.getBatch(batchId.value) URL.revokeObjectURL(currentBlobUrl)
currentBlobUrl = null
} }
} }
const { pause, resume } = useIntervalFn( async function refreshUrl(): Promise<void> {
() => { void refreshUrl() }, revokeBlobUrl()
PRESIGNED_URL_TTL_MS - REFRESH_BUFFER_MS, documentUrl.value = null
{ immediate: false } documentError.value = null
)
if (!batchId.value) return
try {
await batchStore.getBatch(batchId.value)
const blob = await getBlob(`digitization-batches/${batchId.value}/document`)
if (!blob.size) {
throw new Error('Document is empty')
}
currentBlobUrl = URL.createObjectURL(blob)
documentUrl.value = currentBlobUrl
} catch (e: unknown) {
documentError.value = e instanceof Error ? e.message : 'Failed to load document'
}
}
watch( watch(
batchId, batchId,
async (id) => { async (id) => {
pause()
if (id) { if (id) {
await refreshUrl() await refreshUrl()
resume() } else {
revokeBlobUrl()
documentUrl.value = null
documentError.value = null
} }
}, },
{ immediate: true } { immediate: true }
) )
onUnmounted(pause) onUnmounted(revokeBlobUrl)
return { documentUrl, refreshUrl } return { documentUrl, documentError, refreshUrl }
} }
+51 -2
View File
@@ -12,6 +12,52 @@ import type {
PatientDigitizationHistoryResponse, PatientDigitizationHistoryResponse,
} from '../types' } from '../types'
function nullIfEmpty(value: string | null | undefined): string | null {
if (value == null) return null
const trimmed = value.trim()
return trimmed === '' ? null : trimmed
}
function parseJsonStringArray(json: string): string[] | null {
try {
const parsed = JSON.parse(json)
return Array.isArray(parsed) ? parsed : null
} catch {
return null
}
}
function toUpsertDraftPatientRequest(
patient: Partial<DraftPatient> & {
allergiesJson?: string | null
medicationsJson?: string | null
}
): Record<string, unknown> {
const allergies = Array.isArray(patient.allergies)
? patient.allergies
: patient.allergiesJson
? parseJsonStringArray(patient.allergiesJson)
: null
const medications = Array.isArray(patient.medications)
? patient.medications
: patient.medicationsJson
? parseJsonStringArray(patient.medicationsJson)
: null
return {
fullName: nullIfEmpty(patient.fullName),
dateOfBirth: nullIfEmpty(patient.dateOfBirth),
sex: nullIfEmpty(patient.sex),
bloodType: nullIfEmpty(patient.bloodType),
emergencyContact: nullIfEmpty(patient.emergencyContact),
allergies: allergies?.length ? allergies : null,
noKnownAllergies: patient.noKnownAllergies ?? false,
medications: medications?.length ? medications : null,
noActiveMedications: patient.noActiveMedications ?? false,
}
}
export const useBatchStore = defineStore('batches', () => { export const useBatchStore = defineStore('batches', () => {
const batches = ref<BatchDetailResponse[]>([]) const batches = ref<BatchDetailResponse[]>([])
const currentBatch = ref<BatchDetailResponse | null>(null) const currentBatch = ref<BatchDetailResponse | null>(null)
@@ -124,11 +170,14 @@ async function assignBatch(batchId: string, entryClerkUserId: string): Promise<v
async function saveDraftPatient( async function saveDraftPatient(
batchId: string, batchId: string,
patient: Partial<DraftPatient> patient: Partial<DraftPatient> & {
allergiesJson?: string | null
medicationsJson?: string | null
}
): Promise<void> { ): Promise<void> {
await put<DraftPatient>( await put<DraftPatient>(
`digitization-batches/${batchId}/draft/patient`, `digitization-batches/${batchId}/draft/patient`,
patient toUpsertDraftPatientRequest(patient)
) )
} }
+3 -3
View File
@@ -93,16 +93,16 @@ export interface UserProfileResponse {
export interface DraftPatient { export interface DraftPatient {
id: string id: string
batchId: string
fullName: string | null fullName: string | null
dateOfBirth: string | null dateOfBirth: string | null
sex: string | null sex: string | null
bloodType: string | null bloodType: string | null
emergencyContact: string | null emergencyContact: string | null
allergiesJson: string | null allergies: string[] | null
noKnownAllergies: boolean noKnownAllergies: boolean
medicationsJson: string | null medications: string[] | null
noActiveMedications: boolean noActiveMedications: boolean
updatedAt?: string
} }
export interface DraftEncounter { export interface DraftEncounter {
@@ -29,7 +29,7 @@
:url="documentUrl" :url="documentUrl"
/> />
<div v-else class="flex items-center justify-center bg-gray-100 rounded-lg"> <div v-else class="flex items-center justify-center bg-gray-100 rounded-lg">
<p class="text-gray-500">Loading document...</p> <p class="text-gray-500">{{ documentError ?? 'Loading document...' }}</p>
</div> </div>
<!-- Approval panel --> <!-- Approval panel -->
@@ -238,7 +238,7 @@ const toast = useToast()
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined)) const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
const currentBatch = computed(() => batchStore.currentBatch) const currentBatch = computed(() => batchStore.currentBatch)
const draft = computed(() => batchStore.currentDraft) const draft = computed(() => batchStore.currentDraft)
const { documentUrl } = usePresignedUrl(batchId) const { documentUrl, documentError } = usePresignedUrl(batchId)
const processing = ref(false) const processing = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
@@ -26,7 +26,7 @@
:url="documentUrl" :url="documentUrl"
/> />
<div v-else class="flex items-center justify-center bg-gray-100 rounded-lg"> <div v-else class="flex items-center justify-center bg-gray-100 rounded-lg">
<p class="text-gray-500">Loading document...</p> <p class="text-gray-500">{{ documentError ?? 'Loading document...' }}</p>
</div> </div>
<EntryForm <EntryForm
@@ -57,7 +57,7 @@ const router = useRouter()
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined)) const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
const currentBatch = computed(() => batchStore.currentBatch) const currentBatch = computed(() => batchStore.currentBatch)
const { documentUrl } = usePresignedUrl(batchId) const { documentUrl, documentError } = usePresignedUrl(batchId)
async function openBatch(id: string) { async function openBatch(id: string) {
router.push(`/entry/${id}`) router.push(`/entry/${id}`)
@@ -29,7 +29,7 @@
:url="documentUrl" :url="documentUrl"
/> />
<div v-else class="flex items-center justify-center bg-gray-100 rounded-lg"> <div v-else class="flex items-center justify-center bg-gray-100 rounded-lg">
<p class="text-gray-500">Loading document...</p> <p class="text-gray-500">{{ documentError ?? 'Loading document...' }}</p>
</div> </div>
<VerificationForm <VerificationForm
@@ -58,7 +58,7 @@ const router = useRouter()
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined)) const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
const currentBatch = computed(() => batchStore.currentBatch) const currentBatch = computed(() => batchStore.currentBatch)
const { documentUrl } = usePresignedUrl(batchId) const { documentUrl, documentError } = usePresignedUrl(batchId)
function openBatch(id: string) { function openBatch(id: string) {
router.push(`/verification/${id}`) router.push(`/verification/${id}`)
+3
View File
@@ -10,9 +10,12 @@ export default {
primary: { primary: {
50: '#eff6ff', 50: '#eff6ff',
100: '#dbeafe', 100: '#dbeafe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6', 500: '#3b82f6',
600: '#2563eb', 600: '#2563eb',
700: '#1d4ed8', 700: '#1d4ed8',
800: '#1e40af',
}, },
clinical: { clinical: {
safe: '#16a34a', safe: '#16a34a',