From c26ef22670239d1792d9a4e06b3249e73424da8d Mon Sep 17 00:00:00 2001 From: voltsrage Date: Sun, 28 Jun 2026 01:48:15 +0800 Subject: [PATCH] fix: Optional OCR-Assisted Draft Pre-Fill --- .../DigitizationBatchesController.cs | 74 ++++++++++++++----- VigilCareRecordsAPI/Services/BatchService.cs | 4 +- .../__tests__/components/EntryForm.test.ts | 4 +- .../components/VerificationForm.test.ts | 10 +-- .../src/__tests__/stores/batches.test.ts | 8 ++ vigilcare-records-web/src/api/client.ts | 6 ++ .../src/components/EntryForm.vue | 12 +-- .../src/components/ScanViewer.vue | 3 +- .../src/components/VerificationForm.vue | 8 +- .../src/composables/usePresignedUrl.ts | 52 ++++++++----- vigilcare-records-web/src/stores/batches.ts | 53 ++++++++++++- vigilcare-records-web/src/types/index.ts | 6 +- .../src/views/ApprovalView.vue | 4 +- vigilcare-records-web/src/views/EntryView.vue | 4 +- .../src/views/VerificationView.vue | 4 +- vigilcare-records-web/tailwind.config.js | 3 + 16 files changed, 186 insertions(+), 69 deletions(-) diff --git a/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs b/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs index 754c24c..0f8c218 100644 --- a/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs +++ b/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs @@ -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.Ok( BatchDetailResponse.FromEntity(batch, presignedUrl))); } + /// + /// 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. + /// + [HttpGet("{id:guid}/document")] + [Produces("application/pdf", "image/jpeg", "image/png", "application/octet-stream")] + [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task GetDocument(Guid id) + { + var batch = await _batches.GetByIdAsync(id); + + if (batch.DocumentRef == "live-capture") + return NotFound(ApiResponse.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); + } + /// /// Lists batches with optional filters and pagination. /// @@ -265,4 +276,27 @@ public class DigitizationBatchesController : ControllerBase var result = await _batchEventService.GetEventsAsync(id, afterCursor, pageSize); return Ok(ApiResponse>.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(); + } } \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/BatchService.cs b/VigilCareRecordsAPI/Services/BatchService.cs index e65aa93..0e128f1 100644 --- a/VigilCareRecordsAPI/Services/BatchService.cs +++ b/VigilCareRecordsAPI/Services/BatchService.cs @@ -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, diff --git a/vigilcare-records-web/src/__tests__/components/EntryForm.test.ts b/vigilcare-records-web/src/__tests__/components/EntryForm.test.ts index 1542947..f961b90 100644 --- a/vigilcare-records-web/src/__tests__/components/EntryForm.test.ts +++ b/vigilcare-records-web/src/__tests__/components/EntryForm.test.ts @@ -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, diff --git a/vigilcare-records-web/src/__tests__/components/VerificationForm.test.ts b/vigilcare-records-web/src/__tests__/components/VerificationForm.test.ts index 8bc0e25..512c3da 100644 --- a/vigilcare-records-web/src/__tests__/components/VerificationForm.test.ts +++ b/vigilcare-records-web/src/__tests__/components/VerificationForm.test.ts @@ -77,9 +77,9 @@ function mountWithDraft(batchOverrides: Partial = {}) { 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: { diff --git a/vigilcare-records-web/src/__tests__/stores/batches.test.ts b/vigilcare-records-web/src/__tests__/stores/batches.test.ts index c82ec1e..0d37c78 100644 --- a/vigilcare-records-web/src/__tests__/stores/batches.test.ts +++ b/vigilcare-records-web/src/__tests__/stores/batches.test.ts @@ -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, }) }) }) diff --git a/vigilcare-records-web/src/api/client.ts b/vigilcare-records-web/src/api/client.ts index 351d8ea..caed0e2 100644 --- a/vigilcare-records-web/src/api/client.ts +++ b/vigilcare-records-web/src/api/client.ts @@ -183,4 +183,10 @@ export async function postBlob(url: string, data?: unknown): Promise { return response.data as Blob } +/** GET and receive a binary response (e.g. scanned document). */ +export async function getBlob(url: string): Promise { + const response = await apiClient.get(url, { responseType: 'blob' }) + return response.data as Blob +} + export default apiClient \ No newline at end of file diff --git a/vigilcare-records-web/src/components/EntryForm.vue b/vigilcare-records-web/src/components/EntryForm.vue index 6326f6d..332842e 100644 --- a/vigilcare-records-web/src/components/EntryForm.vue +++ b/vigilcare-records-web/src/components/EntryForm.vue @@ -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) { diff --git a/vigilcare-records-web/src/components/ScanViewer.vue b/vigilcare-records-web/src/components/ScanViewer.vue index 61e2583..8eb9254 100644 --- a/vigilcare-records-web/src/components/ScanViewer.vue +++ b/vigilcare-records-web/src/components/ScanViewer.vue @@ -34,12 +34,13 @@ transition: isPanning ? 'none' : 'transform 0.2s', }" > - +