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 presignedUrl = await _storage.GetPresignedUrlAsync(batch.DocumentRef);
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
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();
}
await RecordDocumentAccessAsync(id);
return Ok(ApiResponse<BatchDetailResponse>.Ok(
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>
/// Lists batches with optional filters and pagination.
/// </summary>
@@ -265,4 +276,27 @@ public class DigitizationBatchesController : ControllerBase
var result = await _batchEventService.GetEventsAsync(id, afterCursor, pageSize);
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;
}
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
if (patientId.HasValue)
@@ -101,7 +102,6 @@ public class BatchService : IBatchService
}
}
var batchId = Guid.NewGuid();
var batch = new DigitizationBatch
{
Id = batchId,
@@ -178,9 +178,9 @@ describe('EntryForm', () => {
sex: 'male',
bloodType: 'O+',
emergencyContact: '555-1234',
allergiesJson: null,
allergies: null,
noKnownAllergies: false,
medicationsJson: null,
medications: null,
noActiveMedications: false,
},
encounter: null,
@@ -77,9 +77,9 @@ function mountWithDraft(batchOverrides: Partial<BatchDetailResponse> = {}) {
sex: 'female',
bloodType: 'A+',
emergencyContact: '555-1234',
allergiesJson: null,
allergies: null,
noKnownAllergies: false,
medicationsJson: null,
medications: null,
noActiveMedications: false,
},
encounter: {
@@ -340,7 +340,7 @@ describe('VerificationForm', () => {
it('shows allergy fields for ALLERGY_UPDATE batch', async () => {
const { wrapper } = mountWithDraft({ batchType: 'ALLERGY_UPDATE' })
const store = useBatchStore()
store.currentDraft!.patient!.allergiesJson = JSON.stringify(['Penicillin', 'Latex'])
store.currentDraft!.patient!.allergies = ['Penicillin', 'Latex']
await wrapper.vm.$nextTick()
// Re-trigger the watcher by resetting draft
@@ -368,9 +368,9 @@ describe('VerificationForm', () => {
sex: 'female',
bloodType: null,
emergencyContact: null,
allergiesJson: null,
allergies: null,
noKnownAllergies: true,
medicationsJson: null,
medications: null,
noActiveMedications: false,
},
encounter: {
@@ -237,6 +237,14 @@ describe('useBatchStore', () => {
expect(mockedPut).toHaveBeenCalledWith('digitization-batches/b1/draft/patient', {
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
}
/** 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
@@ -349,8 +349,8 @@ watch(
noKnownAllergies: draft.patient.noKnownAllergies ?? false,
noActiveMedications: draft.patient.noActiveMedications ?? false,
})
allergies.value = parseJsonList(draft.patient.allergiesJson)
medications.value = parseJsonList(draft.patient.medicationsJson)
allergies.value = draft.patient.allergies ?? []
medications.value = draft.patient.medications ?? []
}
if (draft.encounter) {
Object.assign(encounter, {
@@ -380,7 +380,7 @@ async function saveAllergies() {
try {
await batchStore.saveDraftPatient(props.batchId, {
...patient,
allergiesJson: JSON.stringify(allergies.value.filter(a => a.trim())),
allergies: allergies.value.filter(a => a.trim()),
})
toast.success('Allergies saved')
} catch (e: unknown) {
@@ -410,7 +410,7 @@ async function saveMedications() {
try {
await batchStore.saveDraftPatient(props.batchId, {
...patient,
medicationsJson: JSON.stringify(medications.value.filter(m => m.trim())),
medications: medications.value.filter(m => m.trim()),
})
toast.success('Medications saved')
} catch (e: unknown) {
@@ -431,8 +431,8 @@ async function savePatient() {
try {
await batchStore.saveDraftPatient(props.batchId, {
...patient,
allergiesJson: patient.noKnownAllergies ? null : JSON.stringify(allergies.value.filter(a => a.trim())),
medicationsJson: patient.noActiveMedications ? null : JSON.stringify(medications.value.filter(m => m.trim())),
allergies: patient.noKnownAllergies ? null : allergies.value.filter(a => a.trim()),
medications: patient.noActiveMedications ? null : medications.value.filter(m => m.trim()),
})
toast.success('Patient demographics saved')
} catch (e: unknown) {
@@ -34,12 +34,13 @@
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
v-if="isPdf"
:src="url"
class="w-[800px] h-[1100px] bg-white"
frameborder="0"
title="Scanned document"
/>
<img
v-else
@@ -287,7 +287,7 @@ watch(
{ path: 'patient.noKnownAllergies', label: 'No Known Allergies', value: 'Yes (NKA)' },
]
} else {
const items = parseJsonList(draft.patient.allergiesJson)
const items = draft.patient.allergies ?? []
allergyFields.value = items.map((item, i) => ({
path: `patient.allergies[${i}]`,
label: `Allergy ${i + 1}`,
@@ -295,7 +295,7 @@ watch(
}))
if (items.length === 0) {
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' },
]
} else {
const items = parseJsonList(draft.patient.medicationsJson)
const items = draft.patient.medications ?? []
medicationFields.value = items.map((item, i) => ({
path: `patient.medications[${i}]`,
label: `Medication ${i + 1}`,
@@ -317,7 +317,7 @@ watch(
}))
if (items.length === 0) {
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 { useIntervalFn } from '@vueuse/core'
import { ref, watch, onUnmounted, type Ref } from 'vue'
import { getBlob } from '../api/client'
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>) {
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> {
if (batchId.value) {
await batchStore.getBatch(batchId.value)
function revokeBlobUrl(): void {
if (currentBlobUrl) {
URL.revokeObjectURL(currentBlobUrl)
currentBlobUrl = null
}
}
const { pause, resume } = useIntervalFn(
() => { void refreshUrl() },
PRESIGNED_URL_TTL_MS - REFRESH_BUFFER_MS,
{ immediate: false }
)
async function refreshUrl(): Promise<void> {
revokeBlobUrl()
documentUrl.value = null
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(
batchId,
async (id) => {
pause()
if (id) {
await refreshUrl()
resume()
} else {
revokeBlobUrl()
documentUrl.value = null
documentError.value = null
}
},
{ immediate: true }
)
onUnmounted(pause)
onUnmounted(revokeBlobUrl)
return { documentUrl, refreshUrl }
return { documentUrl, documentError, refreshUrl }
}
+51 -2
View File
@@ -12,6 +12,52 @@ import type {
PatientDigitizationHistoryResponse,
} 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', () => {
const batches = ref<BatchDetailResponse[]>([])
const currentBatch = ref<BatchDetailResponse | null>(null)
@@ -124,11 +170,14 @@ async function assignBatch(batchId: string, entryClerkUserId: string): Promise<v
async function saveDraftPatient(
batchId: string,
patient: Partial<DraftPatient>
patient: Partial<DraftPatient> & {
allergiesJson?: string | null
medicationsJson?: string | null
}
): Promise<void> {
await put<DraftPatient>(
`digitization-batches/${batchId}/draft/patient`,
patient
toUpsertDraftPatientRequest(patient)
)
}
+3 -3
View File
@@ -93,16 +93,16 @@ export interface UserProfileResponse {
export interface DraftPatient {
id: string
batchId: string
fullName: string | null
dateOfBirth: string | null
sex: string | null
bloodType: string | null
emergencyContact: string | null
allergiesJson: string | null
allergies: string[] | null
noKnownAllergies: boolean
medicationsJson: string | null
medications: string[] | null
noActiveMedications: boolean
updatedAt?: string
}
export interface DraftEncounter {
@@ -29,7 +29,7 @@
:url="documentUrl"
/>
<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>
<!-- Approval panel -->
@@ -238,7 +238,7 @@ const toast = useToast()
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
const currentBatch = computed(() => batchStore.currentBatch)
const draft = computed(() => batchStore.currentDraft)
const { documentUrl } = usePresignedUrl(batchId)
const { documentUrl, documentError } = usePresignedUrl(batchId)
const processing = ref(false)
const errorMessage = ref('')
@@ -26,7 +26,7 @@
:url="documentUrl"
/>
<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>
<EntryForm
@@ -57,7 +57,7 @@ const router = useRouter()
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
const currentBatch = computed(() => batchStore.currentBatch)
const { documentUrl } = usePresignedUrl(batchId)
const { documentUrl, documentError } = usePresignedUrl(batchId)
async function openBatch(id: string) {
router.push(`/entry/${id}`)
@@ -29,7 +29,7 @@
:url="documentUrl"
/>
<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>
<VerificationForm
@@ -58,7 +58,7 @@ const router = useRouter()
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
const currentBatch = computed(() => batchStore.currentBatch)
const { documentUrl } = usePresignedUrl(batchId)
const { documentUrl, documentError } = usePresignedUrl(batchId)
function openBatch(id: string) {
router.push(`/verification/${id}`)
+3
View File
@@ -10,9 +10,12 @@ export default {
primary: {
50: '#eff6ff',
100: '#dbeafe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
},
clinical: {
safe: '#16a34a',