fix: Optional OCR-Assisted Draft Pre-Fill
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
function revokeBlobUrl(): void {
|
||||
if (currentBlobUrl) {
|
||||
URL.revokeObjectURL(currentBlobUrl)
|
||||
currentBlobUrl = null
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUrl(): Promise<void> {
|
||||
if (batchId.value) {
|
||||
await batchStore.getBatch(batchId.value)
|
||||
}
|
||||
}
|
||||
revokeBlobUrl()
|
||||
documentUrl.value = null
|
||||
documentError.value = null
|
||||
|
||||
const { pause, resume } = useIntervalFn(
|
||||
() => { void refreshUrl() },
|
||||
PRESIGNED_URL_TTL_MS - REFRESH_BUFFER_MS,
|
||||
{ immediate: false }
|
||||
)
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user