From 756cff332c9b39e2e52be43552293f0dce56974c Mon Sep 17 00:00:00 2001 From: voltsrage Date: Sat, 27 Jun 2026 21:50:32 +0800 Subject: [PATCH] feature: Barcode/QR Cover Sheet System --- .../CoverSheetBatchTests.cs | 143 ++ .../CoverSheetPdfTests.cs | 154 ++ .../Controllers/CoverSheetController.cs | 117 ++ .../DigitizationBatchesController.cs | 54 +- VigilCareRecordsAPI/Data/AppDbContext.cs | 3 +- .../Configurations/CoverSheetConfiguration.cs | 40 + .../20260627130834_AddCoverSheets.Designer.cs | 1572 ++++++++++++++++ .../20260627130834_AddCoverSheets.cs | 100 + .../Migrations/AppDbContextModelSnapshot.cs | 111 ++ .../Domain/Entities/CoverSheet.cs | 19 + .../Models/Records/Batch/CreateBatchForm.cs | 11 +- .../Records/CoverSheet/BatchPdfRequest.cs | 1 + .../Records/CoverSheet/CoverSheetResponse.cs | 15 + .../CoverSheet/GenerateCoverSheetsRequest.cs | 7 + VigilCareRecordsAPI/Program.cs | 3 +- .../Services/CoverSheetPdfGenerator.cs | 204 +++ .../Services/CoverSheetService.cs | 138 ++ .../Services/Interfaces/ICoverSheetService.cs | 9 + .../VigilCareRecordsAPI.csproj | 1 + docs/vigilcare-records-gap-analysis.md | 4 +- ...vigilcare-records-phase-10-verification.sh | 751 ++++++++ vigilcare-records-web/package-lock.json | 1609 +++++++++++++++++ vigilcare-records-web/package.json | 8 +- .../__tests__/components/EntryForm.test.ts | 338 ++++ .../components/ObservationRow.test.ts | 158 ++ .../components/PatientSearch.test.ts | 208 +++ .../components/VerificationForm.test.ts | 406 +++++ .../src/__tests__/router/guards.test.ts | 203 +++ vigilcare-records-web/src/__tests__/setup.ts | 19 + .../src/__tests__/stores/auth.test.ts | 312 ++++ .../src/__tests__/stores/batches.test.ts | 405 +++++ .../src/__tests__/stores/liveCapture.test.ts | 157 ++ .../__tests__/views/CoverSheetView.test.ts | 146 ++ .../src/__tests__/views/IntakeView.test.ts | 193 ++ vigilcare-records-web/src/api/client.ts | 9 + vigilcare-records-web/src/assets/main.css | 5 + .../src/components/AppHeader.vue | 1 + vigilcare-records-web/src/router/index.ts | 6 + vigilcare-records-web/src/stores/batches.ts | 5 +- vigilcare-records-web/src/types/index.ts | 24 + .../src/views/CoverSheetView.vue | 362 ++++ .../src/views/IntakeView.vue | 176 +- vigilcare-records-web/vitest.config.ts | 17 + 43 files changed, 8203 insertions(+), 21 deletions(-) create mode 100644 VigilCareRecordsAPI.Tests/CoverSheetBatchTests.cs create mode 100644 VigilCareRecordsAPI.Tests/CoverSheetPdfTests.cs create mode 100644 VigilCareRecordsAPI/Controllers/CoverSheetController.cs create mode 100644 VigilCareRecordsAPI/Data/Configurations/CoverSheetConfiguration.cs create mode 100644 VigilCareRecordsAPI/Data/Migrations/20260627130834_AddCoverSheets.Designer.cs create mode 100644 VigilCareRecordsAPI/Data/Migrations/20260627130834_AddCoverSheets.cs create mode 100644 VigilCareRecordsAPI/Domain/Entities/CoverSheet.cs create mode 100644 VigilCareRecordsAPI/Models/Records/CoverSheet/BatchPdfRequest.cs create mode 100644 VigilCareRecordsAPI/Models/Records/CoverSheet/CoverSheetResponse.cs create mode 100644 VigilCareRecordsAPI/Models/Records/CoverSheet/GenerateCoverSheetsRequest.cs create mode 100644 VigilCareRecordsAPI/Services/CoverSheetPdfGenerator.cs create mode 100644 VigilCareRecordsAPI/Services/CoverSheetService.cs create mode 100644 VigilCareRecordsAPI/Services/Interfaces/ICoverSheetService.cs create mode 100755 scripts/run-vigilcare-records-phase-10-verification.sh create mode 100644 vigilcare-records-web/src/__tests__/components/EntryForm.test.ts create mode 100644 vigilcare-records-web/src/__tests__/components/ObservationRow.test.ts create mode 100644 vigilcare-records-web/src/__tests__/components/PatientSearch.test.ts create mode 100644 vigilcare-records-web/src/__tests__/components/VerificationForm.test.ts create mode 100644 vigilcare-records-web/src/__tests__/router/guards.test.ts create mode 100644 vigilcare-records-web/src/__tests__/setup.ts create mode 100644 vigilcare-records-web/src/__tests__/stores/auth.test.ts create mode 100644 vigilcare-records-web/src/__tests__/stores/batches.test.ts create mode 100644 vigilcare-records-web/src/__tests__/stores/liveCapture.test.ts create mode 100644 vigilcare-records-web/src/__tests__/views/CoverSheetView.test.ts create mode 100644 vigilcare-records-web/src/__tests__/views/IntakeView.test.ts create mode 100644 vigilcare-records-web/src/views/CoverSheetView.vue create mode 100644 vigilcare-records-web/vitest.config.ts diff --git a/VigilCareRecordsAPI.Tests/CoverSheetBatchTests.cs b/VigilCareRecordsAPI.Tests/CoverSheetBatchTests.cs new file mode 100644 index 0000000..8fe5deb --- /dev/null +++ b/VigilCareRecordsAPI.Tests/CoverSheetBatchTests.cs @@ -0,0 +1,143 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +/// +/// Tests for barcode-assisted batch creation via cover sheet codes. +/// +[Collection("Database")] +public class CoverSheetBatchTests : IAsyncLifetime +{ + private readonly ApiFixture _fixture; + private HttpClient _intakeClient = null!; + + public CoverSheetBatchTests(ApiFixture fixture) => _fixture = fixture; + + public async Task InitializeAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await DbResetHelper.ResetAsync(db); + await DataSeeder.SeedAsync(db); + _intakeClient = await AuthHelper.LoginAsync(_fixture, "intake1"); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task Create_WithCoverSheetCode_CreatesBatchAndRedeemsCoverSheet() + { + var code = await GenerateCoverSheetCodeAsync("VITALS_SHEET", "BACKFILL"); + + var response = await UploadWithCoverSheetAsync(code); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var body = await response.Content.ReadFromJsonAsync(); + var batchId = body.GetProperty("data").GetProperty("id").GetGuid(); + body.GetProperty("data").GetProperty("batchType").GetString() + .Should().Be("VITALS_SHEET"); + body.GetProperty("data").GetProperty("track").GetString() + .Should().Be("BACKFILL"); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var sheet = await db.CoverSheets.FirstAsync(c => c.Code == code); + sheet.IsUsed.Should().BeTrue(); + sheet.BatchId.Should().Be(batchId); + sheet.UsedAt.Should().NotBeNull(); + } + + [Fact] + public async Task Create_WithUsedCoverSheetCode_Returns409() + { + var code = await GenerateCoverSheetCodeAsync("VITALS_SHEET", "BACKFILL"); + + var first = await UploadWithCoverSheetAsync(code); + first.EnsureSuccessStatusCode(); + + var second = await UploadWithCoverSheetAsync(code); + second.StatusCode.Should().Be(HttpStatusCode.Conflict); + + var body = await second.Content.ReadFromJsonAsync(); + body.GetProperty("error").GetProperty("code").GetString() + .Should().Be("COVER_SHEET_ALREADY_USED"); + } + + [Fact] + public async Task Create_WithUnknownCoverSheetCode_Returns404() + { + var response = await UploadWithCoverSheetAsync("VCR-CS-DEADBEEF"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + + var body = await response.Content.ReadFromJsonAsync(); + body.GetProperty("error").GetProperty("code").GetString() + .Should().Be("COVER_SHEET_NOT_FOUND"); + } + + [Fact] + public async Task Create_WithCoverSheetPreAssigned_AutoAssignsBatch() + { + Guid entryUserId; + using (var scope = _fixture.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + entryUserId = await BatchSeedHelper.UserIdAsync(db, "entry1"); + } + + var code = await GenerateCoverSheetCodeAsync( + "LAB_RESULTS", "BACKFILL", assignToUserId: entryUserId); + + var response = await UploadWithCoverSheetAsync(code); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadFromJsonAsync(); + body.GetProperty("data").GetProperty("batchType").GetString() + .Should().Be("LAB_RESULTS"); + body.GetProperty("data").GetProperty("enteredByUserId").GetGuid() + .Should().Be(entryUserId); + body.GetProperty("data").GetProperty("status").GetString() + .Should().Be("IN_ENTRY"); + } + + private async Task GenerateCoverSheetCodeAsync( + string batchType, + string track, + Guid? assignToUserId = null) + { + var payload = new + { + count = 1, + batchType, + track, + assignToUserId + }; + + var response = await _intakeClient.PostAsJsonAsync("/api/v1/cover-sheets/generate", payload); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadFromJsonAsync(); + return body.GetProperty("data")[0].GetProperty("code").GetString()!; + } + + private async Task UploadWithCoverSheetAsync(string coverSheetCode) + { + var fileContent = new ByteArrayContent( + System.Text.Encoding.ASCII.GetBytes( + $"%PDF-1.4\n%%EOF\n%test-{Guid.NewGuid()}")); + fileContent.Headers.ContentType = + new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf"); + + var formData = new MultipartFormDataContent + { + { fileContent, "file", "test.pdf" }, + { new StringContent(coverSheetCode), "coverSheetCode" } + }; + + return await _intakeClient.PostAsync("/api/v1/digitization-batches", formData); + } +} diff --git a/VigilCareRecordsAPI.Tests/CoverSheetPdfTests.cs b/VigilCareRecordsAPI.Tests/CoverSheetPdfTests.cs new file mode 100644 index 0000000..529c058 --- /dev/null +++ b/VigilCareRecordsAPI.Tests/CoverSheetPdfTests.cs @@ -0,0 +1,154 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +/// +/// Tests for cover sheet PDF generation endpoints and the PDF builder. +/// +[Collection("Database")] +public class CoverSheetPdfTests : IAsyncLifetime +{ + private readonly ApiFixture _fixture; + private HttpClient _intakeClient = null!; + + public CoverSheetPdfTests(ApiFixture fixture) => _fixture = fixture; + + public async Task InitializeAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await DbResetHelper.ResetAsync(db); + await DataSeeder.SeedAsync(db); + _intakeClient = await AuthHelper.LoginAsync(_fixture, "intake1"); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public void Generate_ProducesValidPdfWithCoverSheetMetadata() + { + var sheet = new CoverSheet + { + Id = Guid.NewGuid(), + Code = "VCR-CS-A3F7B2D1", + BatchType = BatchType.VitalsSheet, + Track = BatchTrack.Backfill, + CreatedAt = new DateTimeOffset(2026, 6, 27, 12, 0, 0, TimeSpan.Zero), + Patient = new Patient + { + Id = Guid.NewGuid(), + FullName = "Maria Garcia", + Mrn = "MRN-12345" + }, + AssignToUser = new User + { + Id = Guid.NewGuid(), + FullName = "Entry Clerk One" + } + }; + + var pdf = CoverSheetPdfGenerator.Generate(sheet); + + var text = Encoding.ASCII.GetString(pdf); + text.Should().StartWith("%PDF-1.4"); + text.Should().Contain("VCR-CS-A3F7B2D1"); + text.Should().Contain("VITALS_SHEET"); + text.Should().Contain("BACKFILL"); + text.Should().Contain("Maria Garcia"); + text.Should().Contain("MRN-12345"); + text.Should().Contain("Entry Clerk One"); + text.Should().Contain("2026-06-27"); + text.Should().Contain("Attach to front of chart section"); + text.Should().Contain("/Subtype /Image"); + } + + [Fact] + public void GenerateBatch_ProducesMultiPagePdf() + { + var sheets = new[] + { + new CoverSheet + { + Id = Guid.NewGuid(), + Code = "VCR-CS-11111111", + BatchType = BatchType.VitalsSheet, + Track = BatchTrack.Backfill, + CreatedAt = DateTimeOffset.UtcNow + }, + new CoverSheet + { + Id = Guid.NewGuid(), + Code = "VCR-CS-22222222", + BatchType = BatchType.LabResults, + Track = BatchTrack.Backfill, + CreatedAt = DateTimeOffset.UtcNow + } + }; + + var pdf = CoverSheetPdfGenerator.GenerateBatch(sheets); + var text = Encoding.ASCII.GetString(pdf); + + text.Should().Contain("/Count 2"); + text.Should().Contain("VCR-CS-11111111"); + text.Should().Contain("VCR-CS-22222222"); + } + + [Fact] + public async Task GeneratePdf_Endpoint_ReturnsPdfForCoverSheetId() + { + var sheetId = await GenerateCoverSheetIdAsync(); + + var response = await _intakeClient.PostAsync($"/api/v1/cover-sheets/{sheetId}/pdf", null); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Headers.ContentType!.MediaType.Should().Be("application/pdf"); + + var bytes = await response.Content.ReadAsByteArrayAsync(); + var text = Encoding.ASCII.GetString(bytes); + text.Should().StartWith("%PDF-1.4"); + text.Should().Contain("VCR-CS-"); + } + + [Fact] + public async Task GenerateBatchPdf_Endpoint_ReturnsMultiPagePdf() + { + var ids = new List + { + await GenerateCoverSheetIdAsync(), + await GenerateCoverSheetIdAsync() + }; + + var response = await _intakeClient.PostAsJsonAsync( + "/api/v1/cover-sheets/batch-pdf", + new { coverSheetIds = ids }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Headers.ContentType!.MediaType.Should().Be("application/pdf"); + + var text = Encoding.ASCII.GetString(await response.Content.ReadAsByteArrayAsync()); + text.Should().Contain("/Count 2"); + } + + [Fact] + public async Task GeneratePdf_UnknownId_Returns404() + { + var response = await _intakeClient.PostAsync( + $"/api/v1/cover-sheets/{Guid.NewGuid()}/pdf", null); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + private async Task GenerateCoverSheetIdAsync() + { + var response = await _intakeClient.PostAsJsonAsync( + "/api/v1/cover-sheets/generate", + new { count = 1, batchType = "VITALS_SHEET", track = "BACKFILL" }); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadFromJsonAsync(); + return body.GetProperty("data")[0].GetProperty("id").GetGuid(); + } +} diff --git a/VigilCareRecordsAPI/Controllers/CoverSheetController.cs b/VigilCareRecordsAPI/Controllers/CoverSheetController.cs new file mode 100644 index 0000000..f533990 --- /dev/null +++ b/VigilCareRecordsAPI/Controllers/CoverSheetController.cs @@ -0,0 +1,117 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +[ApiController] +[Route("api/v1/cover-sheets")] +[Produces("application/json")] +[Authorize] +public class CoverSheetController : ControllerBase +{ + private readonly ICoverSheetService _coverSheets; + + public CoverSheetController(ICoverSheetService coverSheets) + { + _coverSheets = coverSheets; + } + + /// + /// Generates one or more cover sheets with unique barcode codes. + /// Each cover sheet encodes batch type, track, optional patient, and + /// optional entry clerk assignment. + /// + [HttpPost("generate")] + [Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task Generate([FromBody] GenerateCoverSheetsRequest request) + { + var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + var sheets = await _coverSheets.GenerateAsync(request, actorUserId); + var response = sheets.Select(MapToResponse).ToList(); + return StatusCode(201, ApiResponse>.Created(response)); + } + + /// + /// Looks up a cover sheet by its barcode code. Used during barcode-assisted + /// upload to auto-populate batch type, track, patient, and clerk assignment. + /// + [HttpGet("lookup/{code}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task Lookup(string code) + { + var sheet = await _coverSheets.LookupByCodeAsync(code); + if (sheet is null) + return NotFound(ApiResponse.Fail(404, "Cover sheet not found.", "COVER_SHEET_NOT_FOUND")); + + return Ok(ApiResponse.Ok(MapToResponse(sheet))); + } + + /// + /// Lists cover sheets with optional filters. + /// + [HttpGet] + [Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task List( + [FromQuery] bool? isUsed, + [FromQuery] Guid? patientId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) + { + var sheets = await _coverSheets.ListAsync(isUsed, patientId, page, pageSize); + var response = sheets.Select(MapToResponse).ToList(); + return Ok(ApiResponse>.Ok(response)); + } + + /// + /// Generates a printable PDF containing cover sheets with QR codes. + /// Each page has the cover sheet code as a QR code, plus human-readable + /// batch type, patient info, and generation date. + /// + [HttpPost("{id:guid}/pdf")] + [Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")] + [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task GeneratePdf(Guid id) + { + var sheet = await _coverSheets.LookupByIdAsync(id); + if (sheet is null) + return NotFound(ApiResponse.Fail(404, "Cover sheet not found.", "COVER_SHEET_NOT_FOUND")); + + var pdfBytes = CoverSheetPdfGenerator.Generate(sheet); + return File(pdfBytes, "application/pdf", $"coversheet-{sheet.Code}.pdf"); + } + + /// + /// Generates a batch PDF containing multiple cover sheets (one per page). + /// Accepts a list of cover sheet IDs. + /// + [HttpPost("batch-pdf")] + [Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")] + [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] + public async Task GenerateBatchPdf([FromBody] BatchPdfRequest request) + { + var sheets = await _coverSheets.GetByIdsAsync(request.CoverSheetIds); + var pdfBytes = CoverSheetPdfGenerator.GenerateBatch(sheets); + return File(pdfBytes, "application/pdf", $"coversheets-batch-{DateTime.UtcNow:yyyyMMdd}.pdf"); + } + + private static CoverSheetResponse MapToResponse(CoverSheet sheet) => new( + Id: sheet.Id, + Code: sheet.Code, + BatchType: sheet.BatchType.ToDbString(), + Track: sheet.Track.ToDbString(), + PatientId: sheet.PatientId, + PatientName: sheet.Patient?.FullName, + PatientMrn: sheet.Patient?.Mrn, + AssignToUserId: sheet.AssignToUserId, + AssignToUserName: sheet.AssignToUser?.FullName, + IsUsed: sheet.IsUsed, + BatchId: sheet.BatchId, + CreatedAt: sheet.CreatedAt, + UsedAt: sheet.UsedAt + ); +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs b/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs index dca9d34..754c24c 100644 --- a/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs +++ b/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs @@ -17,6 +17,7 @@ public class DigitizationBatchesController : ControllerBase private readonly IDocumentStorageService _storage; private readonly IPromotionService _promotion; private readonly IBatchEventService _batchEventService; + private readonly ICoverSheetService _coverSheets; private readonly AppDbContext _db; private static readonly HashSet _allowedMimeTypes = new() @@ -29,13 +30,15 @@ public class DigitizationBatchesController : ControllerBase IDocumentStorageService storage, IPromotionService promotion, IBatchEventService batchEventService, - AppDbContext db) + AppDbContext db, + ICoverSheetService coverSheets) { _batches = batches; _storage = storage; _promotion = promotion; _batchEventService = batchEventService; _db = db; + _coverSheets = coverSheets; } /// @@ -60,10 +63,40 @@ public class DigitizationBatchesController : ControllerBase return BadRequest(ApiResponse.Fail(400, "Accepted formats: PDF, JPEG, PNG.", "INVALID_MIME_TYPE")); - var parsedBatchType = BatchTypeExtensions.FromDbString(form.BatchType.ToUpperInvariant()); - var parsedTrack = string.IsNullOrEmpty(form.Track) - ? BatchTrack.Backfill - : BatchTrackExtensions.FromDbString(form.Track.ToUpperInvariant()); + CoverSheet? coverSheet = null; + BatchType parsedBatchType; + BatchTrack parsedTrack; + + if (!string.IsNullOrWhiteSpace(form.CoverSheetCode)) + { + coverSheet = await _coverSheets.LookupByCodeAsync(form.CoverSheetCode); + if (coverSheet is null) + return NotFound(ApiResponse.Fail(404, + "Cover sheet not found.", "COVER_SHEET_NOT_FOUND")); + + if (coverSheet.IsUsed) + return Conflict(ApiResponse.Fail(409, + $"Cover sheet {coverSheet.Code} has already been used.", + "COVER_SHEET_ALREADY_USED")); + + parsedBatchType = coverSheet.BatchType; + parsedTrack = coverSheet.Track; + if (coverSheet.PatientId.HasValue) + form.PatientId ??= coverSheet.PatientId; + } + else + { + if (string.IsNullOrWhiteSpace(form.BatchType)) + return BadRequest(ApiResponse.Fail(400, + "BatchType is required when no cover sheet code is provided.", + "MISSING_BATCH_TYPE")); + + parsedBatchType = BatchTypeExtensions.FromDbString(form.BatchType.ToUpperInvariant()); + parsedTrack = string.IsNullOrEmpty(form.Track) + ? BatchTrack.Backfill + : BatchTrackExtensions.FromDbString(form.Track.ToUpperInvariant()); + } + var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); using var stream = form.File.OpenReadStream(); @@ -71,8 +104,17 @@ public class DigitizationBatchesController : ControllerBase stream, form.File.ContentType, parsedBatchType, parsedTrack, form.PatientId, form.SupersedesBatchId, actorUserId); + var batch = result.Batch; + if (coverSheet is not null) + { + await _coverSheets.RedeemAsync(coverSheet.Id, batch.Id); + + if (coverSheet.AssignToUserId.HasValue) + batch = await _batches.AssignAsync(batch.Id, coverSheet.AssignToUserId.Value, actorUserId); + } + return StatusCode(201, ApiResponse.Created( - BatchDetailResponse.FromEntity(result.Batch, supersession: result.Supersession))); + BatchDetailResponse.FromEntity(batch, supersession: result.Supersession))); } /// diff --git a/VigilCareRecordsAPI/Data/AppDbContext.cs b/VigilCareRecordsAPI/Data/AppDbContext.cs index 4b01eaa..a638ba3 100644 --- a/VigilCareRecordsAPI/Data/AppDbContext.cs +++ b/VigilCareRecordsAPI/Data/AppDbContext.cs @@ -23,7 +23,8 @@ public class AppDbContext : DbContext public DbSet LiveEncounters => Set(); public DbSet LiveObservations => Set(); public DbSet PromotionAttempts => Set(); - + public DbSet CoverSheets => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); diff --git a/VigilCareRecordsAPI/Data/Configurations/CoverSheetConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/CoverSheetConfiguration.cs new file mode 100644 index 0000000..bd36335 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Configurations/CoverSheetConfiguration.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class CoverSheetConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("cover_sheets"); + builder.HasKey(c => c.Id); + builder.Property(c => c.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); + builder.Property(c => c.Code).HasColumnName("code").HasMaxLength(20).IsRequired(); + builder.Property(c => c.PatientId).HasColumnName("patient_id"); + builder.Property(c => c.BatchType).HasColumnName("batch_type") + .HasConversion(v => v.ToDbString(), v => BatchTypeExtensions.FromDbString(v)) + .HasMaxLength(30).IsRequired(); + builder.Property(c => c.Track).HasColumnName("track") + .HasConversion(v => v.ToDbString(), v => BatchTrackExtensions.FromDbString(v)) + .HasMaxLength(20).IsRequired(); + builder.Property(c => c.AssignToUserId).HasColumnName("assign_to_user_id"); + builder.Property(c => c.GeneratedByUserId).HasColumnName("generated_by_user_id").IsRequired(); + builder.Property(c => c.IsUsed).HasColumnName("is_used").HasDefaultValue(false); + builder.Property(c => c.BatchId).HasColumnName("batch_id"); + builder.Property(c => c.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + builder.Property(c => c.UsedAt).HasColumnName("used_at"); + + builder.HasIndex(c => c.Code).IsUnique().HasDatabaseName("ix_cover_sheets_code"); + builder.HasIndex(c => new { c.IsUsed, c.CreatedAt }) + .HasFilter("is_used = false") + .HasDatabaseName("ix_cover_sheets_unused"); + + builder.HasOne(c => c.Patient) + .WithMany().HasForeignKey(c => c.PatientId).OnDelete(DeleteBehavior.SetNull); + builder.HasOne(c => c.AssignToUser) + .WithMany().HasForeignKey(c => c.AssignToUserId).OnDelete(DeleteBehavior.SetNull); + builder.HasOne(c => c.GeneratedByUser) + .WithMany().HasForeignKey(c => c.GeneratedByUserId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(c => c.Batch) + .WithMany().HasForeignKey(c => c.BatchId).OnDelete(DeleteBehavior.SetNull); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Data/Migrations/20260627130834_AddCoverSheets.Designer.cs b/VigilCareRecordsAPI/Data/Migrations/20260627130834_AddCoverSheets.Designer.cs new file mode 100644 index 0000000..6c0c601 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Migrations/20260627130834_AddCoverSheets.Designer.cs @@ -0,0 +1,1572 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace VigilCareRecordsAPI.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260627130834_AddCoverSheets")] + partial class AddCoverSheets + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AlertThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("CriticalHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_high"); + + b.Property("CriticalLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_low"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("SuppressionWindowMinutes") + .HasColumnType("integer") + .HasColumnName("suppression_window_minutes"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("WarningHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_high"); + + b.Property("WarningLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_low"); + + b.HasKey("Id"); + + b.HasIndex("ObservationCode") + .IsUnique(); + + b.ToTable("alert_thresholds", "clinical"); + }); + + modelBuilder.Entity("AuthAuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("event_type"); + + b.Property("MetadataJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_json"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("NOW()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "OccurredAt"); + + b.ToTable("auth_audit_events", null, t => + { + t.HasCheckConstraint("chk_auth_audit_events_event_type", "event_type IN ('USER_LOGOUT', 'TOKEN_REFRESHED')"); + }); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcknowledgedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acknowledged_at"); + + b.Property("AcknowledgedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("acknowledged_by"); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("alert_type"); + + b.Property("ClientAlertId") + .HasColumnType("uuid") + .HasColumnName("client_alert_id"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("FeedbackReceived") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("feedback_received"); + + b.Property("ObservationCode") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("ObservationId") + .HasColumnType("uuid") + .HasColumnName("observation_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'OPEN'"); + + b.Property("SyncedFromGateway") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("synced_from_gateway"); + + b.Property("TriggeredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("triggered_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("ClientAlertId") + .IsUnique() + .HasFilter("client_alert_id IS NOT NULL"); + + b.HasIndex("EncounterId", "TriggeredAt"); + + b.HasIndex("PatientId", "TriggeredAt"); + + b.HasIndex("Severity", "TriggeredAt") + .HasFilter("status = 'OPEN'"); + + b.HasIndex("EncounterId", "AlertType", "ObservationCode") + .HasFilter("status IN ('OPEN', 'ESCALATED')"); + + b.ToTable("clinical_alerts", "clinical", t => + { + t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')"); + + t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')"); + + t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')"); + }); + }); + + modelBuilder.Entity("CoverSheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AssignToUserId") + .HasColumnType("uuid") + .HasColumnName("assign_to_user_id"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("BatchType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("batch_type"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("GeneratedByUserId") + .HasColumnType("uuid") + .HasColumnName("generated_by_user_id"); + + b.Property("IsUsed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_used"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("Track") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("track"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.HasKey("Id"); + + b.HasIndex("AssignToUserId"); + + b.HasIndex("BatchId"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_cover_sheets_code"); + + b.HasIndex("GeneratedByUserId"); + + b.HasIndex("PatientId"); + + b.HasIndex("IsUsed", "CreatedAt") + .HasDatabaseName("ix_cover_sheets_unused") + .HasFilter("is_used = false"); + + b.ToTable("cover_sheets", (string)null); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ApprovedByUserId") + .HasColumnType("uuid") + .HasColumnName("approved_by_user_id"); + + b.Property("BatchType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("batch_type"); + + b.Property("ClinicianAttestation") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("clinician_attestation"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DocumentRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("document_ref"); + + b.Property("DocumentSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("document_sha256"); + + b.Property("EnableRetroactiveAlerts") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("enable_retroactive_alerts"); + + b.Property("EncounterDraftId") + .HasColumnType("uuid") + .HasColumnName("encounter_draft_id"); + + b.Property("EnteredByUserId") + .HasColumnType("uuid") + .HasColumnName("entered_by_user_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("PromotedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("promoted_at"); + + b.Property("PromotionEncounterId") + .HasColumnType("uuid") + .HasColumnName("promotion_encounter_id"); + + b.Property("RejectionReason") + .HasColumnType("text") + .HasColumnName("rejection_reason"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("status") + .HasDefaultValueSql("'UPLOADED'"); + + b.Property("SupersedesBatchId") + .HasColumnType("uuid") + .HasColumnName("supersedes_batch_id"); + + b.Property("Track") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("track") + .HasDefaultValueSql("'BACKFILL'"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("VerifiedByUserId") + .HasColumnType("uuid") + .HasColumnName("verified_by_user_id"); + + b.HasKey("Id"); + + b.HasIndex("ApprovedByUserId"); + + b.HasIndex("EnteredByUserId"); + + b.HasIndex("Status"); + + b.HasIndex("SupersedesBatchId") + .HasFilter("supersedes_batch_id IS NOT NULL"); + + b.HasIndex("VerifiedByUserId"); + + b.HasIndex("DocumentSha256", "PatientId", "CreatedAt"); + + b.ToTable("digitization_batches", null, t => + { + t.HasCheckConstraint("chk_batches_batch_type", "batch_type IN ('PATIENT_REGISTRATION', 'ENCOUNTER_SUMMARY', 'VITALS_SHEET', 'LAB_RESULTS', 'MEDICATION_LIST', 'ALLERGY_UPDATE', 'MIXED')"); + + t.HasCheckConstraint("chk_batches_status", "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED', 'CANCELLED')"); + + t.HasCheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')"); + }); + }); + + modelBuilder.Entity("DigitizationEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("event_type"); + + b.Property("MetadataJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_json"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("BatchId", "OccurredAt"); + + b.ToTable("digitization_events", null, t => + { + t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted')"); + }); + }); + + modelBuilder.Entity("DraftEncounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("Status") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_encounters", null, t => + { + t.HasCheckConstraint("chk_draft_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("DraftObservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("BatchId", "ObservationCode"); + + b.ToTable("draft_observations", (string)null); + }); + + modelBuilder.Entity("DraftPatient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AllergiesJson") + .HasColumnType("jsonb") + .HasColumnName("allergies_json"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContact") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("emergency_contact"); + + b.Property("FullName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("MedicationsJson") + .HasColumnType("jsonb") + .HasColumnName("medications_json"); + + b.Property("NoActiveMedications") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_active_medications"); + + b.Property("NoKnownAllergies") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_known_allergies"); + + b.Property("Sex") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("sex"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_patients", null, t => + { + t.HasCheckConstraint("chk_draft_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')"); + }); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("SourceBatchId") + .HasColumnType("uuid") + .HasColumnName("source_batch_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("PatientId", "Status") + .HasDatabaseName("ix_encounters_patient_status"); + + b.ToTable("encounters", "clinical", t => + { + t.HasCheckConstraint("chk_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("IdempotencyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("HttpStatusCode") + .HasColumnType("integer") + .HasColumnName("http_status_code"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("idempotency_key"); + + b.Property("OperationName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("operation_name"); + + b.Property("ResourceId") + .HasColumnType("uuid") + .HasColumnName("resource_id"); + + b.Property("ResponseBodyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("response_body_json"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_idempotency_records_expires_at"); + + b.HasIndex("IdempotencyKey", "OperationName") + .IsUnique() + .HasDatabaseName("ix_idempotency_records_key_operation"); + + b.ToTable("idempotency_records", (string)null); + }); + + modelBuilder.Entity("LiveEncounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("PatientId", "Status"); + + b.ToTable("live_encounters", null, t => + { + t.HasCheckConstraint("chk_live_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("LiveObservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IsSuperseded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_superseded"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("SourceBatchId") + .HasColumnType("uuid") + .HasColumnName("source_batch_id"); + + b.Property("SupersededAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("superseded_at"); + + b.Property("SupersededByBatchId") + .HasColumnType("uuid") + .HasColumnName("superseded_by_batch_id"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("IsSuperseded") + .HasFilter("is_superseded = true"); + + b.HasIndex("SourceBatchId"); + + b.HasIndex("EncounterId", "ObservationCode"); + + b.ToTable("live_observations", (string)null); + }); + + modelBuilder.Entity("Observation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("source"); + + b.Property("SourceBatchId") + .HasColumnType("uuid") + .HasColumnName("source_batch_id"); + + b.Property("SourceDraftObservationId") + .HasColumnType("uuid") + .HasColumnName("source_draft_observation_id"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("PatientId"); + + b.HasIndex("SourceBatchId") + .HasDatabaseName("ix_observations_source_batch") + .HasFilter("source_batch_id IS NOT NULL"); + + b.HasIndex("EncounterId", "ObservationCode") + .HasDatabaseName("ix_observations_encounter_code"); + + b.ToTable("observations", "clinical"); + }); + + modelBuilder.Entity("OutboxEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("aggregate_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("event_type"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload_json"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("retry_count"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedAt") + .HasDatabaseName("ix_outbox_events_unprocessed") + .HasFilter("processed_at IS NULL"); + + b.ToTable("outbox_events", "clinical"); + }); + + modelBuilder.Entity("Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AllergiesJson") + .HasColumnType("jsonb") + .HasColumnName("allergies_json"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContact") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("emergency_contact"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("Mrn") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("mrn"); + + b.Property("NoKnownAllergies") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_known_allergies"); + + b.Property("Sex") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("sex"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("Mrn") + .IsUnique() + .HasDatabaseName("ix_patients_mrn"); + + b.ToTable("patients", "clinical", t => + { + t.HasCheckConstraint("chk_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')"); + }); + }); + + modelBuilder.Entity("PromotionAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("AttemptedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("attempted_at") + .HasDefaultValueSql("NOW()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("error_message"); + + b.Property("NextRetryAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_retry_at"); + + b.Property("Succeeded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("succeeded"); + + b.HasKey("Id"); + + b.HasIndex("NextRetryAt") + .HasDatabaseName("ix_promotion_attempts_pending_retry") + .HasFilter("succeeded = false AND next_retry_at IS NOT NULL"); + + b.HasIndex("BatchId", "AttemptNumber") + .IsUnique() + .HasDatabaseName("ix_promotion_attempts_batch_attempt"); + + b.ToTable("promotion_attempts", (string)null); + }); + + modelBuilder.Entity("RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid") + .HasColumnName("replaced_by_token_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedByTokenId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("ScannedDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("content_type"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint") + .HasColumnName("file_size_bytes"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("object_key"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("sha256"); + + b.Property("UploadedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.HasIndex("Sha256"); + + b.ToTable("scanned_documents", (string)null); + }); + + modelBuilder.Entity("User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("password_hash"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", null, t => + { + t.HasCheckConstraint("chk_users_role", "role IN ('INTAKE_CLERK', 'DATA_ENTRY_CLERK', 'VERIFIER', 'CLINICAL_APPROVER', 'CLINICIAN', 'ADMINISTRATOR')"); + }); + }); + + modelBuilder.Entity("AuthAuditEvent", b => + { + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoverSheet", b => + { + b.HasOne("User", "AssignToUser") + .WithMany() + .HasForeignKey("AssignToUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DigitizationBatch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("User", "GeneratedByUser") + .WithMany() + .HasForeignKey("GeneratedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AssignToUser"); + + b.Navigation("Batch"); + + b.Navigation("GeneratedByUser"); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.HasOne("User", "ApprovedByUser") + .WithMany() + .HasForeignKey("ApprovedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("User", "EnteredByUser") + .WithMany() + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("User", "VerifiedByUser") + .WithMany() + .HasForeignKey("VerifiedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApprovedByUser"); + + b.Navigation("EnteredByUser"); + + b.Navigation("VerifiedByUser"); + }); + + modelBuilder.Entity("DigitizationEvent", b => + { + b.HasOne("User", "Actor") + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("DigitizationBatch", "Batch") + .WithMany("Events") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Actor"); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftEncounter", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("DraftEncounter") + .HasForeignKey("DraftEncounter", "BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftObservation", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithMany("DraftObservations") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftPatient", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("DraftPatient") + .HasForeignKey("DraftPatient", "BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("LiveEncounter", b => + { + b.HasOne("Patient", null) + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("LiveObservation", b => + { + b.HasOne("LiveEncounter", "Encounter") + .WithMany("Observations") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Observation", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("PromotionAttempt", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("RefreshToken", b => + { + b.HasOne("RefreshToken", "ReplacedByToken") + .WithMany() + .HasForeignKey("ReplacedByTokenId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReplacedByToken"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ScannedDocument", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("Document") + .HasForeignKey("ScannedDocument", "BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.Navigation("Document"); + + b.Navigation("DraftEncounter"); + + b.Navigation("DraftObservations"); + + b.Navigation("DraftPatient"); + + b.Navigation("Events"); + }); + + modelBuilder.Entity("LiveEncounter", b => + { + b.Navigation("Observations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/20260627130834_AddCoverSheets.cs b/VigilCareRecordsAPI/Data/Migrations/20260627130834_AddCoverSheets.cs new file mode 100644 index 0000000..4270abc --- /dev/null +++ b/VigilCareRecordsAPI/Data/Migrations/20260627130834_AddCoverSheets.cs @@ -0,0 +1,100 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareRecordsAPI.Data.Migrations +{ + /// + public partial class AddCoverSheets : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "cover_sheets", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + code = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + patient_id = table.Column(type: "uuid", nullable: true), + batch_type = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + track = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + assign_to_user_id = table.Column(type: "uuid", nullable: true), + generated_by_user_id = table.Column(type: "uuid", nullable: false), + is_used = table.Column(type: "boolean", nullable: false, defaultValue: false), + batch_id = table.Column(type: "uuid", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"), + used_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_cover_sheets", x => x.id); + table.ForeignKey( + name: "FK_cover_sheets_digitization_batches_batch_id", + column: x => x.batch_id, + principalTable: "digitization_batches", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_cover_sheets_patients_patient_id", + column: x => x.patient_id, + principalSchema: "clinical", + principalTable: "patients", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_cover_sheets_users_assign_to_user_id", + column: x => x.assign_to_user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_cover_sheets_users_generated_by_user_id", + column: x => x.generated_by_user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_cover_sheets_assign_to_user_id", + table: "cover_sheets", + column: "assign_to_user_id"); + + migrationBuilder.CreateIndex( + name: "IX_cover_sheets_batch_id", + table: "cover_sheets", + column: "batch_id"); + + migrationBuilder.CreateIndex( + name: "ix_cover_sheets_code", + table: "cover_sheets", + column: "code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_cover_sheets_generated_by_user_id", + table: "cover_sheets", + column: "generated_by_user_id"); + + migrationBuilder.CreateIndex( + name: "IX_cover_sheets_patient_id", + table: "cover_sheets", + column: "patient_id"); + + migrationBuilder.CreateIndex( + name: "ix_cover_sheets_unused", + table: "cover_sheets", + columns: new[] { "is_used", "created_at" }, + filter: "is_used = false"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "cover_sheets"); + } + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs b/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs index b9db08b..0292423 100644 --- a/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs @@ -230,6 +230,85 @@ namespace VigilCareRecordsAPI.Data.Migrations }); }); + modelBuilder.Entity("CoverSheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AssignToUserId") + .HasColumnType("uuid") + .HasColumnName("assign_to_user_id"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("BatchType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("batch_type"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("GeneratedByUserId") + .HasColumnType("uuid") + .HasColumnName("generated_by_user_id"); + + b.Property("IsUsed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_used"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("Track") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("track"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.HasKey("Id"); + + b.HasIndex("AssignToUserId"); + + b.HasIndex("BatchId"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_cover_sheets_code"); + + b.HasIndex("GeneratedByUserId"); + + b.HasIndex("PatientId"); + + b.HasIndex("IsUsed", "CreatedAt") + .HasDatabaseName("ix_cover_sheets_unused") + .HasFilter("is_used = false"); + + b.ToTable("cover_sheets", (string)null); + }); + modelBuilder.Entity("DigitizationBatch", b => { b.Property("Id") @@ -1269,6 +1348,38 @@ namespace VigilCareRecordsAPI.Data.Migrations b.Navigation("User"); }); + modelBuilder.Entity("CoverSheet", b => + { + b.HasOne("User", "AssignToUser") + .WithMany() + .HasForeignKey("AssignToUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DigitizationBatch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("User", "GeneratedByUser") + .WithMany() + .HasForeignKey("GeneratedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AssignToUser"); + + b.Navigation("Batch"); + + b.Navigation("GeneratedByUser"); + + b.Navigation("Patient"); + }); + modelBuilder.Entity("DigitizationBatch", b => { b.HasOne("User", "ApprovedByUser") diff --git a/VigilCareRecordsAPI/Domain/Entities/CoverSheet.cs b/VigilCareRecordsAPI/Domain/Entities/CoverSheet.cs new file mode 100644 index 0000000..010ff50 --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Entities/CoverSheet.cs @@ -0,0 +1,19 @@ +public class CoverSheet +{ + public Guid Id { get; set; } + public string Code { get; set; } = null!; + public Guid? PatientId { get; set; } + public BatchType BatchType { get; set; } + public BatchTrack Track { get; set; } + public Guid? AssignToUserId { get; set; } + public Guid GeneratedByUserId { get; set; } + public bool IsUsed { get; set; } + public Guid? BatchId { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? UsedAt { get; set; } + + public Patient? Patient { get; set; } + public User? AssignToUser { get; set; } + public User GeneratedByUser { get; set; } = null!; + public DigitizationBatch? Batch { get; set; } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Models/Records/Batch/CreateBatchForm.cs b/VigilCareRecordsAPI/Models/Records/Batch/CreateBatchForm.cs index 29cfde3..dcbb2ea 100644 --- a/VigilCareRecordsAPI/Models/Records/Batch/CreateBatchForm.cs +++ b/VigilCareRecordsAPI/Models/Records/Batch/CreateBatchForm.cs @@ -5,8 +5,10 @@ public class CreateBatchForm [Required] public IFormFile File { get; set; } = null!; - [Required] - public string BatchType { get; set; } = null!; + /// + /// Required unless is provided; cover sheet values override when both are sent. + /// + public string? BatchType { get; set; } public string? Track { get; set; } @@ -15,5 +17,8 @@ public class CreateBatchForm public Guid? SupersedesBatchId { get; set; } public CreateBatchRequest ToMetadata() => - new(BatchType, Track, PatientId, SupersedesBatchId); + new(BatchType ?? throw new InvalidOperationException("BatchType is required."), + Track, PatientId, SupersedesBatchId); + + public string? CoverSheetCode { get; set; } } \ No newline at end of file diff --git a/VigilCareRecordsAPI/Models/Records/CoverSheet/BatchPdfRequest.cs b/VigilCareRecordsAPI/Models/Records/CoverSheet/BatchPdfRequest.cs new file mode 100644 index 0000000..c3eaab8 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/CoverSheet/BatchPdfRequest.cs @@ -0,0 +1 @@ +public record BatchPdfRequest(List CoverSheetIds); \ No newline at end of file diff --git a/VigilCareRecordsAPI/Models/Records/CoverSheet/CoverSheetResponse.cs b/VigilCareRecordsAPI/Models/Records/CoverSheet/CoverSheetResponse.cs new file mode 100644 index 0000000..0abfd2e --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/CoverSheet/CoverSheetResponse.cs @@ -0,0 +1,15 @@ +public record CoverSheetResponse( + Guid Id, + string Code, + string BatchType, + string Track, + Guid? PatientId, + string? PatientName, + string? PatientMrn, + Guid? AssignToUserId, + string? AssignToUserName, + bool IsUsed, + Guid? BatchId, + DateTimeOffset CreatedAt, + DateTimeOffset? UsedAt +); \ No newline at end of file diff --git a/VigilCareRecordsAPI/Models/Records/CoverSheet/GenerateCoverSheetsRequest.cs b/VigilCareRecordsAPI/Models/Records/CoverSheet/GenerateCoverSheetsRequest.cs new file mode 100644 index 0000000..10f431f --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/CoverSheet/GenerateCoverSheetsRequest.cs @@ -0,0 +1,7 @@ +public record GenerateCoverSheetsRequest( + int Count, + string BatchType, + string Track, + Guid? PatientId, + Guid? AssignToUserId +); diff --git a/VigilCareRecordsAPI/Program.cs b/VigilCareRecordsAPI/Program.cs index a0fdd31..a35ff73 100644 --- a/VigilCareRecordsAPI/Program.cs +++ b/VigilCareRecordsAPI/Program.cs @@ -121,7 +121,8 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); - + builder.Services.AddScoped(); + builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/VigilCareRecordsAPI/Services/CoverSheetPdfGenerator.cs b/VigilCareRecordsAPI/Services/CoverSheetPdfGenerator.cs new file mode 100644 index 0000000..b093a58 --- /dev/null +++ b/VigilCareRecordsAPI/Services/CoverSheetPdfGenerator.cs @@ -0,0 +1,204 @@ +using System.Globalization; +using System.IO.Compression; +using System.Text; +using QRCoder; + +/// +/// Builds printable cover sheet PDFs using raw PDF 1.4 objects and QRCoder for QR images. +/// Each page includes a header, QR code, human-readable metadata, and a footer instruction line. +/// +public static class CoverSheetPdfGenerator +{ + private const float PageWidth = 612f; + private const float PageHeight = 792f; + private const float QrDisplaySize = 200f; + private const float LeftMargin = 72f; + + public static byte[] Generate(CoverSheet sheet) => + GenerateBatch(new[] { sheet }); + + public static byte[] GenerateBatch(IReadOnlyList sheets) + { + if (sheets.Count == 0) + throw new ArgumentException("At least one cover sheet is required.", nameof(sheets)); + + var pages = sheets.Select(BuildPage).ToList(); + return BuildPdf(pages); + } + + private sealed record PageData(string ContentStream, byte[] ImageRgb, int ImageWidth, int ImageHeight); + + private static PageData BuildPage(CoverSheet sheet) + { + var (rgb, width, height) = GenerateQrRgb(sheet.Code); + var content = BuildContentStream(sheet); + return new PageData(content, rgb, width, height); + } + + private static (byte[] Rgb, int Width, int Height) GenerateQrRgb(string code) + { + using var generator = new QRCodeGenerator(); + using var data = generator.CreateQrCode(code, QRCodeGenerator.ECCLevel.M); + + var modules = data.ModuleMatrix.Count; + const int scale = 8; + const int quiet = 2; + var size = (modules + quiet * 2) * scale; + var rgb = new byte[size * size * 3]; + + for (var y = 0; y < size; y++) + { + for (var x = 0; x < size; x++) + { + var mx = x / scale - quiet; + var my = y / scale - quiet; + var dark = mx >= 0 && my >= 0 && mx < modules && my < modules && data.ModuleMatrix[my][mx]; + var color = dark ? (byte)0 : (byte)255; + var i = (y * size + x) * 3; + rgb[i] = color; + rgb[i + 1] = color; + rgb[i + 2] = color; + } + } + + return (rgb, size, size); + } + + private static string BuildContentStream(CoverSheet sheet) + { + var sb = new StringBuilder(); + var qrX = (PageWidth - QrDisplaySize) / 2f; + const float qrY = 470f; + + sb.AppendLine(CultureInfo.InvariantCulture, $"q {QrDisplaySize} 0 0 {QrDisplaySize} {qrX} {qrY} cm /Im1 Do Q"); + + WriteTextLine(sb, 18f, LeftMargin, 740f, "VigilCare Records - Cover Sheet"); + + var y = 430f; + y = WriteTextLine(sb, 12f, LeftMargin, y, $"Code: {sheet.Code}"); + y = WriteTextLine(sb, 12f, LeftMargin, y, $"Batch Type: {sheet.BatchType.ToDbString()}"); + y = WriteTextLine(sb, 12f, LeftMargin, y, $"Track: {sheet.Track.ToDbString()}"); + + if (sheet.Patient is not null) + { + y = WriteTextLine(sb, 12f, LeftMargin, y, + $"Patient: {sheet.Patient.FullName} (MRN {sheet.Patient.Mrn})"); + } + + if (sheet.AssignToUser is not null) + { + y = WriteTextLine(sb, 12f, LeftMargin, y, + $"Assigned To: {sheet.AssignToUser.FullName}"); + } + + var generated = sheet.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + WriteTextLine(sb, 12f, LeftMargin, y, $"Generated: {generated}"); + + WriteTextLine(sb, 10f, LeftMargin, 48f, + "Attach to front of chart section. Scanner reads QR code automatically."); + + return sb.ToString(); + } + + private static float WriteTextLine(StringBuilder sb, float fontSize, float x, float y, string text) + { + sb.AppendLine("BT"); + sb.AppendLine(CultureInfo.InvariantCulture, $"/F1 {fontSize} Tf"); + sb.AppendLine(CultureInfo.InvariantCulture, $"{x} {y} Td"); + sb.AppendLine(CultureInfo.InvariantCulture, $"({EscapePdfString(text)}) Tj"); + sb.AppendLine("ET"); + return y - fontSize - 8f; + } + + private static byte[] BuildPdf(IReadOnlyList pages) + { + using var ms = new MemoryStream(); + ms.Write("%PDF-1.4\n"u8); + + var offsets = new List(); + const int catalogId = 1; + const int pagesId = 2; + const int fontId = 3; + var pageIds = Enumerable.Range(0, pages.Count).Select(i => 4 + i * 3).ToArray(); + var contentIds = pageIds.Select(id => id + 1).ToArray(); + var imageIds = pageIds.Select(id => id + 2).ToArray(); + + offsets.Add(ms.Position); + WriteAscii(ms, $"{catalogId} 0 obj\n<< /Type /Catalog /Pages {pagesId} 0 R >>\nendobj\n"); + + offsets.Add(ms.Position); + var kids = string.Join(" ", pageIds.Select(id => $"{id} 0 R")); + WriteAscii(ms, $"{pagesId} 0 obj\n<< /Type /Pages /Kids [{kids}] /Count {pages.Count} >>\nendobj\n"); + + offsets.Add(ms.Position); + WriteAscii(ms, $"{fontId} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"); + + for (var i = 0; i < pages.Count; i++) + { + var page = pages[i]; + var pageId = pageIds[i]; + var contentId = contentIds[i]; + var imageId = imageIds[i]; + var contentBytes = Encoding.ASCII.GetBytes(page.ContentStream); + var imageBytes = FlateEncode(page.ImageRgb); + + offsets.Add(ms.Position); + WriteAscii(ms, + $"{pageId} 0 obj\n" + + $"<< /Type /Page /Parent {pagesId} 0 R " + + $"/MediaBox [0 0 {PageWidth} {PageHeight}] " + + $"/Contents {contentId} 0 R " + + $"/Resources << /Font << /F1 {fontId} 0 R >> /XObject << /Im1 {imageId} 0 R >> >> >>\n" + + "endobj\n"); + + offsets.Add(ms.Position); + WriteAscii(ms, + $"{contentId} 0 obj\n<< /Length {contentBytes.Length} >>\nstream\n"); + ms.Write(contentBytes); + WriteAscii(ms, "\nendstream\nendobj\n"); + + offsets.Add(ms.Position); + WriteAscii(ms, + $"{imageId} 0 obj\n" + + $"<< /Type /XObject /Subtype /Image " + + $"/Width {page.ImageWidth} /Height {page.ImageHeight} " + + "/ColorSpace /DeviceRGB /BitsPerComponent 8 " + + $"/Filter /FlateDecode /Length {imageBytes.Length} >>\nstream\n"); + ms.Write(imageBytes); + WriteAscii(ms, "\nendstream\nendobj\n"); + } + + var xrefOffset = ms.Position; + WriteAscii(ms, "xref\n"); + WriteAscii(ms, $"0 {offsets.Count + 1}\n"); + WriteAscii(ms, "0000000000 65535 f \n"); + foreach (var offset in offsets) + WriteAscii(ms, $"{offset:D10} 00000 n \n"); + + WriteAscii(ms, + "trailer\n" + + $"<< /Size {offsets.Count + 1} /Root {catalogId} 0 R >>\n" + + "startxref\n" + + $"{xrefOffset}\n" + + "%%EOF\n"); + + return ms.ToArray(); + } + + private static void WriteAscii(Stream stream, string text) => + stream.Write(Encoding.ASCII.GetBytes(text)); + + private static byte[] FlateEncode(byte[] data) + { + using var output = new MemoryStream(); + using (var deflate = new ZLibStream(output, CompressionLevel.Optimal, leaveOpen: true)) + deflate.Write(data, 0, data.Length); + + return output.ToArray(); + } + + private static string EscapePdfString(string value) => + value.Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("(", "\\(", StringComparison.Ordinal) + .Replace(")", "\\)", StringComparison.Ordinal); +} diff --git a/VigilCareRecordsAPI/Services/CoverSheetService.cs b/VigilCareRecordsAPI/Services/CoverSheetService.cs new file mode 100644 index 0000000..d23ad36 --- /dev/null +++ b/VigilCareRecordsAPI/Services/CoverSheetService.cs @@ -0,0 +1,138 @@ +using Microsoft.EntityFrameworkCore; + +public class CoverSheetService : ICoverSheetService +{ + private readonly AppDbContext _db; + private readonly ILogger _logger; + + public CoverSheetService(AppDbContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task> GenerateAsync( + GenerateCoverSheetsRequest request, Guid actorUserId) + { + var count = Math.Clamp(request.Count, 1, 100); + var batchType = BatchTypeExtensions.FromDbString(request.BatchType.ToUpperInvariant()); + var track = string.IsNullOrEmpty(request.Track) + ? BatchTrack.Backfill + : BatchTrackExtensions.FromDbString(request.Track.ToUpperInvariant()); + + if (request.PatientId.HasValue) + { + var patientExists = await _db.Patients.AnyAsync(p => p.Id == request.PatientId.Value); + if (!patientExists) + throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND"); + } + + if (request.AssignToUserId.HasValue) + { + var user = await _db.Users.FindAsync(request.AssignToUserId.Value); + if (user is null || !user.IsActive) + throw new NotFoundException("User not found or inactive.", "USER_NOT_FOUND"); + } + + var sheets = new List(count); + for (var i = 0; i < count; i++) + { + var code = $"VCR-CS-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}"; + sheets.Add(new CoverSheet + { + Id = Guid.NewGuid(), + Code = code, + BatchType = batchType, + Track = track, + PatientId = request.PatientId, + AssignToUserId = request.AssignToUserId, + GeneratedByUserId = actorUserId, + IsUsed = false, + CreatedAt = DateTimeOffset.UtcNow, + }); + } + + _db.CoverSheets.AddRange(sheets); + await _db.SaveChangesAsync(); + + _logger.LogInformation( + "Generated {Count} cover sheets for batch type {BatchType}, track {Track}", + count, batchType.ToDbString(), track.ToDbString()); + + return sheets; + } + + public async Task LookupByCodeAsync(string code) + { + return await _db.CoverSheets + .Include(c => c.Patient) + .Include(c => c.AssignToUser) + .FirstOrDefaultAsync(c => c.Code == code.ToUpperInvariant().Trim()); + } + + public async Task LookupByIdAsync(Guid id) + { + return await _db.CoverSheets + .Include(c => c.Patient) + .Include(c => c.AssignToUser) + .FirstOrDefaultAsync(c => c.Id == id); + } + + public async Task> GetByIdsAsync(IReadOnlyList ids) + { + if (ids.Count == 0) + return new List(); + + var idSet = ids.Distinct().ToList(); + var sheets = await _db.CoverSheets + .AsNoTracking() + .Include(c => c.Patient) + .Include(c => c.AssignToUser) + .Where(c => idSet.Contains(c.Id)) + .ToListAsync(); + + var byId = sheets.ToDictionary(c => c.Id); + return idSet + .Where(byId.ContainsKey) + .Select(id => byId[id]) + .ToList(); + } + + public async Task RedeemAsync(Guid coverSheetId, Guid batchId) + { + var sheet = await _db.CoverSheets.FindAsync(coverSheetId); + if (sheet is null) + throw new NotFoundException("Cover sheet not found.", "COVER_SHEET_NOT_FOUND"); + + if (sheet.IsUsed) + throw new ConflictException( + $"Cover sheet {sheet.Code} has already been used for batch {sheet.BatchId}.", + "COVER_SHEET_ALREADY_USED"); + + sheet.IsUsed = true; + sheet.BatchId = batchId; + sheet.UsedAt = DateTimeOffset.UtcNow; + await _db.SaveChangesAsync(); + } + + public async Task> ListAsync( + bool? isUsed, Guid? patientId, int page, int pageSize) + { + var query = _db.CoverSheets + .AsNoTracking() + .Include(c => c.Patient) + .Include(c => c.AssignToUser) + .AsQueryable(); + + if (isUsed.HasValue) + query = query.Where(c => c.IsUsed == isUsed.Value); + if (patientId.HasValue) + query = query.Where(c => c.PatientId == patientId.Value); + + return await query + .OrderByDescending(c => c.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/Interfaces/ICoverSheetService.cs b/VigilCareRecordsAPI/Services/Interfaces/ICoverSheetService.cs new file mode 100644 index 0000000..0dc7fba --- /dev/null +++ b/VigilCareRecordsAPI/Services/Interfaces/ICoverSheetService.cs @@ -0,0 +1,9 @@ +public interface ICoverSheetService +{ + Task> GenerateAsync(GenerateCoverSheetsRequest request, Guid actorUserId); + Task LookupByCodeAsync(string code); + Task LookupByIdAsync(Guid id); + Task> GetByIdsAsync(IReadOnlyList ids); + Task RedeemAsync(Guid coverSheetId, Guid batchId); + Task> ListAsync(bool? isUsed, Guid? patientId, int page, int pageSize); +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj b/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj index 6d69cce..da32871 100644 --- a/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj +++ b/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj @@ -23,6 +23,7 @@ + diff --git a/docs/vigilcare-records-gap-analysis.md b/docs/vigilcare-records-gap-analysis.md index 2e4c95d..82d9b0a 100644 --- a/docs/vigilcare-records-gap-analysis.md +++ b/docs/vigilcare-records-gap-analysis.md @@ -667,7 +667,7 @@ Clinical data entry is high-stakes work. Entry clerks need immediate confirmatio --- -## P4 — No frontend tests +## ~~P4 — No frontend tests~~ DONE ### Problem @@ -816,7 +816,7 @@ A batch stuck in `APPROVED` with exhausted retries is invisible in Prometheus da | 19 | EntryForm missing allergy/med fields | P4 | E | Open | | 20 | No corrections/supersession UI | P4 | E | Open | | 21 | No toast/notification system | P4 | E | Open | -| 22 | No frontend tests | P4 | E | Open | +| 22 | No frontend tests | P4 | E | Done | | 23 | No field-level draft audit | P5 | F | Open | | 24 | Integration test gaps | P5 | F | Done | | 25 | MetricsCollector missing retry gauges | P5 | F | Done | diff --git a/scripts/run-vigilcare-records-phase-10-verification.sh b/scripts/run-vigilcare-records-phase-10-verification.sh new file mode 100755 index 0000000..46352c2 --- /dev/null +++ b/scripts/run-vigilcare-records-phase-10-verification.sh @@ -0,0 +1,751 @@ +#!/usr/bin/env bash +# Runs Phase 10 verification checks from docs/plans/phase-10-plan.md. +# +# Covers cover sheet generation, lookup, list filters, PDF endpoints, +# barcode-assisted batch upload, redeem/auto-assign, and reuse prevention. +# +# Prerequisites: +# docker compose up -d (PostgreSQL, Redis, MinIO) +# dotnet ef database update --project VigilCareRecordsAPI +# dotnet run --project VigilCareRecordsAPI +# Phase 1–9 seed data (intake1, entry1, admin1) +# +# Environment overrides: +# VIGILCARE_API_URL default: http://localhost:5217 +# VIGILCARE_COMPOSE_FILE default: /docker-compose.yml +# VIGILCARE_PG_HOST default: localhost +# VIGILCARE_PG_PORT default: 5437 +# VIGILCARE_PG_DB default: vigilcare_records +# VIGILCARE_PG_USER default: postgres +# VIGILCARE_PG_PASSWORD default: password +# VIGILCARE_SKIP_DB_CHECKS set to 1 to skip PostgreSQL assertions +# VIGILCARE_SKIP_TEST_CHECKS set to 1 to skip dotnet integration tests +# +# Usage: +# chmod +x scripts/run-vigilcare-records-phase-10-verification.sh +# ./scripts/run-vigilcare-records-phase-10-verification.sh + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +FIXTURE_PDF="$SCRIPT_DIR/fixtures/test-scan.pdf" + +API_URL="${VIGILCARE_API_URL:-http://localhost:5217}" +COMPOSE_FILE="${VIGILCARE_COMPOSE_FILE:-$REPO_ROOT/docker-compose.yml}" +COMPOSE=(docker compose -f "$COMPOSE_FILE") +PG_HOST="${VIGILCARE_PG_HOST:-localhost}" +PG_PORT="${VIGILCARE_PG_PORT:-5437}" +PG_DB="${VIGILCARE_PG_DB:-vigilcare_records}" +PG_USER="${VIGILCARE_PG_USER:-postgres}" +PG_PASSWORD="${VIGILCARE_PG_PASSWORD:-password}" +SKIP_DB_CHECKS="${VIGILCARE_SKIP_DB_CHECKS:-0}" +SKIP_TEST_CHECKS="${VIGILCARE_SKIP_TEST_CHECKS:-0}" + +# Resolved at runtime from the live API (seed IDs vary per environment) +ENTRY_CLERK1_ID="" +PATIENT1_ID="" + +PASS_COUNT=0 +FAIL_COUNT=0 +FAILED_TESTS=() + +# Shared state populated during the run +INTAKE_TOKEN="" +ADMIN_TOKEN="" +ENTRY_TOKEN="" +UPLOAD_CODE="" +UPLOAD_COVER_SHEET_ID="" +UPLOAD_BATCH_ID="" +ASSIGN_CODE="" +PDF_SHEET_IDS=() + +log() { + printf '%s\n' "$*" +} + +section() { + log "" + log "== $1 ==" +} + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + log " PASS: $1" +} + +fail() { + FAIL_COUNT=$((FAIL_COUNT + 1)) + FAILED_TESTS+=("$1") + log " FAIL: $1" +} + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + log "ERROR: required command not found: $cmd" + exit 1 + fi +} + +ensure_fixture_pdf() { + if [[ -f "$FIXTURE_PDF" ]]; then + return 0 + fi + mkdir -p "$(dirname "$FIXTURE_PDF")" + cat >"$FIXTURE_PDF" <<'EOF' +%PDF-1.0 +1 0 obj<>endobj 2 0 obj<>endobj 3 0 obj<>>>endobj +xref +0 4 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +trailer<> +startxref +206 +%%EOF +EOF +} + +unique_pdf_path() { + local suffix="$1" + local path="/tmp/vigilcare-p10-${suffix}-${RANDOM}.pdf" + printf '%%PDF-1.4\nphase10-%s-%s\n%%%%EOF\n' "$suffix" "$(date +%s%N)" >"$path" + printf '%s' "$path" +} + +http_code() { + curl -sS -o /dev/null -w '%{http_code}' "$@" +} + +json_post() { + local url="$1" + local body="$2" + local token="${3:-}" + if [[ -n "$token" ]]; then + curl -sS -X POST "$url" \ + -H "Authorization: Bearer $token" \ + -H 'Content-Type: application/json' \ + -d "$body" + else + curl -sS -X POST "$url" \ + -H 'Content-Type: application/json' \ + -d "$body" + fi +} + +json_get() { + local url="$1" + local token="$2" + curl -sS "$url" -H "Authorization: Bearer $token" +} + +login() { + local username="$1" + local password="${2:-password}" + json_post "$API_URL/api/v1/auth/login" \ + "{\"username\":\"$username\",\"password\":\"$password\"}" +} + +extract_data_field() { + local json="$1" + local field="$2" + jq -er ".data.$field // empty" <<<"$json" +} + +extract_bool_field() { + local json="$1" + local field="$2" + jq -er ".data.$field | if . == null then empty else tostring end" <<<"$json" +} + +resolve_directory_ids() { + local users_json patients_json + users_json="$(json_get "$API_URL/api/v1/users?role=DATA_ENTRY_CLERK" "$INTAKE_TOKEN")" + patients_json="$(json_get "$API_URL/api/v1/patients/search?q=Patient" "$INTAKE_TOKEN")" + + ENTRY_CLERK1_ID="$(jq -er '.data[0].id // empty' <<<"$users_json" 2>/dev/null || true)" + PATIENT1_ID="$(jq -er '.data[0].id // empty' <<<"$patients_json" 2>/dev/null || true)" + + if [[ -n "$ENTRY_CLERK1_ID" ]]; then + pass "resolved entry clerk ID for auto-assign test" + else + fail "resolved entry clerk ID for auto-assign test" + fi + + if [[ -n "$PATIENT1_ID" ]]; then + pass "resolved patient ID for patient-linked cover sheet test" + else + fail "resolved patient ID for patient-linked cover sheet test" + fi +} + +extract_error_code() { + local json="$1" + jq -er '.error.code // empty' <<<"$json" 2>/dev/null || true +} + +assert_api_reachable() { + local code + code="$(http_code "$API_URL/swagger/index.html" || true)" + if [[ "$code" != "200" ]]; then + log "ERROR: API not reachable at $API_URL (HTTP $code)." + log "Start infrastructure with: docker compose up -d" + log "Apply migrations with: dotnet ef database update --project VigilCareRecordsAPI" + log "Start API with: dotnet run --project VigilCareRecordsAPI" + exit 1 + fi +} + +compose_service_running() { + local service="$1" + "${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -qx "$service" +} + +psql_available() { + [[ "$SKIP_DB_CHECKS" == "1" ]] && return 1 + compose_service_running postgres && return 0 + command -v psql >/dev/null 2>&1 && return 0 + return 1 +} + +psql_query() { + if [[ "$SKIP_DB_CHECKS" == "1" ]]; then + return 1 + fi + if compose_service_running postgres; then + "${COMPOSE[@]}" exec -T postgres \ + psql -U "$PG_USER" -d "$PG_DB" -Atqc "$1" + elif command -v psql >/dev/null 2>&1; then + PGPASSWORD="$PG_PASSWORD" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -Atqc "$1" + else + return 1 + fi +} + +upload_with_cover_sheet() { + local token="$1" + local code="$2" + local pdf="$3" + curl -sS -X POST "$API_URL/api/v1/digitization-batches" \ + -H "Authorization: Bearer $token" \ + -F "file=@${pdf};type=application/pdf" \ + -F "coverSheetCode=$code" +} + +upload_with_cover_sheet_status() { + local token="$1" + local code="$2" + local pdf="$3" + local body_file http_code_val + body_file="$(mktemp)" + http_code_val="$(curl -sS -o "$body_file" -w '%{http_code}' -X POST "$API_URL/api/v1/digitization-batches" \ + -H "Authorization: Bearer $token" \ + -F "file=@${pdf};type=application/pdf" \ + -F "coverSheetCode=$code")" + cat "$body_file" + rm -f "$body_file" + printf '\n__HTTP_STATUS__:%s' "$http_code_val" +} + +generate_cover_sheets() { + local token="$1" + local body="$2" + json_post "$API_URL/api/v1/cover-sheets/generate" "$body" "$token" +} + +test_authentication() { + section "0. Authentication" + + local intake_json admin_json entry_json + intake_json="$(login intake1)" + admin_json="$(login admin1)" + entry_json="$(login entry1)" + + INTAKE_TOKEN="$(extract_data_field "$intake_json" token)" + ADMIN_TOKEN="$(extract_data_field "$admin_json" token)" + ENTRY_TOKEN="$(extract_data_field "$entry_json" token)" + + if [[ -z "$INTAKE_TOKEN" ]]; then + log "ERROR: intake1 login failed." + log "Response: ${intake_json:-}" + exit 1 + fi + + if [[ -n "$INTAKE_TOKEN" ]]; then + pass "intake1 login returns JWT" + else + fail "intake1 login returns JWT" + fi + + if [[ -n "$ADMIN_TOKEN" ]]; then + pass "admin1 login returns JWT" + else + fail "admin1 login returns JWT" + fi + + if [[ -n "$ENTRY_TOKEN" ]]; then + pass "entry1 login returns JWT" + else + fail "entry1 login returns JWT" + fi + + resolve_directory_ids +} + +test_database_schema() { + section "1. Database — cover_sheets table" + + if ! psql_available; then + log " SKIP: PostgreSQL checks (set VIGILCARE_SKIP_DB_CHECKS=0 and start postgres)" + return + fi + + local table_exists index_code index_unused + table_exists="$(psql_query " + SELECT count(*) + FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'cover_sheets'; + ")" + index_code="$(psql_query " + SELECT count(*) + FROM pg_indexes + WHERE tablename = 'cover_sheets' AND indexname = 'ix_cover_sheets_code'; + ")" + index_unused="$(psql_query " + SELECT count(*) + FROM pg_indexes + WHERE tablename = 'cover_sheets' AND indexname = 'ix_cover_sheets_unused'; + ")" + + if [[ "$table_exists" == "1" ]]; then + pass "cover_sheets table exists" + else + fail "cover_sheets table exists" + fi + + if [[ "$index_code" == "1" ]]; then + pass "unique index ix_cover_sheets_code exists" + else + fail "unique index ix_cover_sheets_code exists" + fi + + if [[ "$index_unused" == "1" ]]; then + pass "filtered index ix_cover_sheets_unused exists" + else + fail "filtered index ix_cover_sheets_unused exists" + fi +} + +test_cover_sheet_generation() { + section "2. Cover sheet generation (plan §1)" + + local gen_json count unique_count bad_format + gen_json="$(generate_cover_sheets "$INTAKE_TOKEN" \ + '{"count":5,"batchType":"VITALS_SHEET","track":"BACKFILL"}')" + + if [[ "$(jq -er '.success' <<<"$gen_json")" == "true" ]]; then + pass "POST /cover-sheets/generate returns success for intake1" + else + fail "POST /cover-sheets/generate returns success for intake1" + log " response: $gen_json" + return + fi + + count="$(jq '.data | length' <<<"$gen_json")" + unique_count="$(jq '[.data[].code] | unique | length' <<<"$gen_json")" + if [[ "$count" == "5" && "$unique_count" == "5" ]]; then + pass "generate creates 5 cover sheets with unique codes" + else + fail "generate creates 5 cover sheets with unique codes (count=$count unique=$unique_count)" + fi + + bad_format="$(jq -r '[.data[].code | test("^VCR-CS-[0-9A-F]{8}$")] | all' <<<"$gen_json")" + if [[ "$bad_format" == "true" ]]; then + pass "all codes match VCR-CS-{8-hex} format" + else + fail "all codes match VCR-CS-{8-hex} format" + fi + + UPLOAD_CODE="$(jq -r '.data[0].code' <<<"$gen_json")" + UPLOAD_COVER_SHEET_ID="$(jq -r '.data[0].id' <<<"$gen_json")" + PDF_SHEET_IDS=($(jq -r '.data[0].id, .data[1].id' <<<"$gen_json")) + + local entry_json entry_token entry_code + entry_json="$(login entry1)" + entry_token="$(extract_data_field "$entry_json" token)" + entry_code="$(http_code -X POST "$API_URL/api/v1/cover-sheets/generate" \ + -H "Authorization: Bearer $entry_token" \ + -H 'Content-Type: application/json' \ + -d '{"count":1,"batchType":"VITALS_SHEET","track":"BACKFILL"}')" + if [[ "$entry_code" == "403" ]]; then + pass "generate denied for DATA_ENTRY_CLERK (403)" + else + fail "generate denied for DATA_ENTRY_CLERK (403) — got HTTP $entry_code" + fi + + local admin_json + admin_json="$(generate_cover_sheets "$ADMIN_TOKEN" \ + '{"count":1,"batchType":"LAB_RESULTS","track":"BACKFILL"}')" + if [[ "$(jq -er '.success' <<<"$admin_json")" == "true" ]]; then + pass "generate allowed for ADMINISTRATOR" + else + fail "generate allowed for ADMINISTRATOR" + fi +} + +test_cover_sheet_lookup_and_list() { + section "3. Cover sheet lookup and list (plan §2)" + + if [[ -z "$UPLOAD_CODE" ]]; then + fail "lookup requires generated cover sheet code" + return + fi + + local lookup_json is_used batch_type + lookup_json="$(json_get "$API_URL/api/v1/cover-sheets/lookup/$UPLOAD_CODE" "$INTAKE_TOKEN")" + + if [[ "$(jq -er '.success' <<<"$lookup_json")" == "true" ]]; then + pass "GET /cover-sheets/lookup/{code} returns success" + else + fail "GET /cover-sheets/lookup/{code} returns success" + return + fi + + is_used="$(extract_bool_field "$lookup_json" isUsed)" + batch_type="$(extract_data_field "$lookup_json" batchType)" + if [[ "$is_used" == "false" ]]; then + pass "lookup shows isUsed=false before upload" + else + fail "lookup shows isUsed=false before upload (isUsed=$is_used)" + fi + + if [[ "$batch_type" == "VITALS_SHEET" ]]; then + pass "lookup returns batchType VITALS_SHEET" + else + fail "lookup returns batchType VITALS_SHEET (got $batch_type)" + fi + + local unknown_json unknown_code + unknown_json="$(json_get "$API_URL/api/v1/cover-sheets/lookup/VCR-CS-DEADBEEF" "$INTAKE_TOKEN")" + unknown_code="$(extract_error_code "$unknown_json")" + if [[ "$unknown_code" == "COVER_SHEET_NOT_FOUND" ]]; then + pass "lookup unknown code returns COVER_SHEET_NOT_FOUND" + else + fail "lookup unknown code returns COVER_SHEET_NOT_FOUND (got ${unknown_code:-})" + fi + + local list_json unused_count + list_json="$(json_get "$API_URL/api/v1/cover-sheets?isUsed=false&page=1&pageSize=20" "$INTAKE_TOKEN")" + unused_count="$(jq '.data | length' <<<"$list_json")" + if [[ "$(jq -er '.success' <<<"$list_json")" == "true" && "$unused_count" -ge 5 ]]; then + pass "GET /cover-sheets?isUsed=false lists unused sheets" + else + fail "GET /cover-sheets?isUsed=false lists unused sheets (count=$unused_count)" + fi +} + +test_pdf_generation() { + section "4. Cover sheet PDF generation (plan Step 5)" + + if [[ -z "$UPLOAD_COVER_SHEET_ID" || ${#PDF_SHEET_IDS[@]} -lt 2 ]]; then + fail "PDF tests require generated cover sheet IDs" + return + fi + + local pdf_tmp headers_file pdf_code content_type pdf_header + pdf_tmp="$(mktemp)" + headers_file="$(mktemp)" + pdf_code="$(curl -sS -D "$headers_file" -o "$pdf_tmp" -w '%{http_code}' -X POST \ + "$API_URL/api/v1/cover-sheets/${UPLOAD_COVER_SHEET_ID}/pdf" \ + -H "Authorization: Bearer $INTAKE_TOKEN")" + content_type="$(awk -F': ' 'tolower($1)=="content-type"{print $2}' "$headers_file" | tr -d '\r' | head -1)" + rm -f "$headers_file" + + if [[ "$pdf_code" == "200" ]]; then + pass "POST /cover-sheets/{id}/pdf returns 200" + else + fail "POST /cover-sheets/{id}/pdf returns 200 (got HTTP $pdf_code)" + fi + + if [[ "$content_type" == application/pdf* ]]; then + pass "single PDF response Content-Type is application/pdf" + else + fail "single PDF response Content-Type is application/pdf (got ${content_type:-})" + fi + + pdf_header="$(head -c 8 "$pdf_tmp" || true)" + if [[ "$pdf_header" == %PDF-1.* ]]; then + pass "single PDF body starts with %PDF header" + else + fail "single PDF body starts with %PDF header" + fi + + if grep -aq "$UPLOAD_CODE" "$pdf_tmp" 2>/dev/null; then + pass "single PDF embeds cover sheet code text" + else + fail "single PDF embeds cover sheet code text" + fi + rm -f "$pdf_tmp" + + local batch_pdf_tmp batch_pdf_code + batch_pdf_tmp="$(mktemp)" + batch_pdf_code="$(curl -sS -o "$batch_pdf_tmp" -w '%{http_code}' -X POST \ + "$API_URL/api/v1/cover-sheets/batch-pdf" \ + -H "Authorization: Bearer $INTAKE_TOKEN" \ + -H 'Content-Type: application/json' \ + -d "{\"coverSheetIds\":[\"${PDF_SHEET_IDS[0]}\",\"${PDF_SHEET_IDS[1]}\"]}")" + + if [[ "$batch_pdf_code" == "200" ]]; then + pass "POST /cover-sheets/batch-pdf returns 200" + else + fail "POST /cover-sheets/batch-pdf returns 200 (got HTTP $batch_pdf_code)" + fi + + if grep -aq '/Count 2' "$batch_pdf_tmp" 2>/dev/null || strings "$batch_pdf_tmp" | grep -q '/Count 2'; then + pass "batch PDF contains two pages (/Count 2)" + else + fail "batch PDF contains two pages (/Count 2)" + fi + rm -f "$batch_pdf_tmp" +} + +test_barcode_assisted_upload() { + section "5. Barcode-assisted upload (plan §3)" + + if [[ -z "$UPLOAD_CODE" ]]; then + fail "barcode upload requires generated cover sheet code" + return + fi + + local pdf upload_json batch_type track status entered_by + pdf="$(unique_pdf_path upload)" + upload_json="$(upload_with_cover_sheet "$INTAKE_TOKEN" "$UPLOAD_CODE" "$pdf")" + rm -f "$pdf" + + if [[ "$(jq -er '.success' <<<"$upload_json")" == "true" ]]; then + pass "POST /digitization-batches with coverSheetCode creates batch" + else + fail "POST /digitization-batches with coverSheetCode creates batch" + log " response: $upload_json" + return + fi + + batch_type="$(extract_data_field "$upload_json" batchType)" + track="$(extract_data_field "$upload_json" track)" + status="$(extract_data_field "$upload_json" status)" + UPLOAD_BATCH_ID="$(extract_data_field "$upload_json" id)" + + if [[ "$batch_type" == "VITALS_SHEET" && "$track" == "BACKFILL" ]]; then + pass "batch inherits batchType and track from cover sheet" + else + fail "batch inherits batchType and track from cover sheet (type=$batch_type track=$track)" + fi + + local redeemed_json redeemed_used redeemed_batch_id + redeemed_json="$(json_get "$API_URL/api/v1/cover-sheets/lookup/$UPLOAD_CODE" "$INTAKE_TOKEN")" + redeemed_used="$(extract_bool_field "$redeemed_json" isUsed)" + redeemed_batch_id="$(extract_data_field "$redeemed_json" batchId)" + + if [[ "$redeemed_used" == "true" && "$redeemed_batch_id" == "$UPLOAD_BATCH_ID" ]]; then + pass "cover sheet redeemed and linked to batch after upload" + else + fail "cover sheet redeemed and linked to batch after upload" + fi + + if psql_available && [[ -n "$UPLOAD_COVER_SHEET_ID" ]]; then + local db_used db_batch + db_used="$(psql_query " + SELECT is_used FROM cover_sheets WHERE id = '$UPLOAD_COVER_SHEET_ID'; + ")" + db_batch="$(psql_query " + SELECT batch_id FROM cover_sheets WHERE id = '$UPLOAD_COVER_SHEET_ID'; + ")" + if [[ "$db_used" == "t" && "$db_batch" == "$UPLOAD_BATCH_ID" ]]; then + pass "database row shows is_used=true with batch_id" + else + fail "database row shows is_used=true with batch_id" + fi + fi +} + +test_cover_sheet_reuse_and_unknown() { + section "6. Reuse prevention and unknown code (plan §4)" + + if [[ -z "$UPLOAD_CODE" ]]; then + fail "reuse test requires uploaded cover sheet code" + return + fi + + local pdf reuse_response reuse_status reuse_code unknown_response unknown_status unknown_code + pdf="$(unique_pdf_path reuse)" + reuse_response="$(upload_with_cover_sheet_status "$INTAKE_TOKEN" "$UPLOAD_CODE" "$pdf")" + rm -f "$pdf" + + reuse_status="${reuse_response##*__HTTP_STATUS__:}" + reuse_response="${reuse_response%$'\n'__HTTP_STATUS__:*}" + reuse_code="$(extract_error_code "$reuse_response")" + + if [[ "$reuse_status" == "409" && "$reuse_code" == "COVER_SHEET_ALREADY_USED" ]]; then + pass "reused cover sheet code returns 409 COVER_SHEET_ALREADY_USED" + else + fail "reused cover sheet code returns 409 COVER_SHEET_ALREADY_USED (HTTP $reuse_status code=${reuse_code:-})" + fi + + pdf="$(unique_pdf_path unknown)" + unknown_response="$(upload_with_cover_sheet_status "$INTAKE_TOKEN" "VCR-CS-DEADBEEF" "$pdf")" + rm -f "$pdf" + + unknown_status="${unknown_response##*__HTTP_STATUS__:}" + unknown_response="${unknown_response%$'\n'__HTTP_STATUS__:*}" + unknown_code="$(extract_error_code "$unknown_response")" + + if [[ "$unknown_status" == "404" && "$unknown_code" == "COVER_SHEET_NOT_FOUND" ]]; then + pass "unknown cover sheet code on upload returns 404 COVER_SHEET_NOT_FOUND" + else + fail "unknown cover sheet code on upload returns 404 COVER_SHEET_NOT_FOUND (HTTP $unknown_status code=${unknown_code:-})" + fi +} + +test_auto_assign_cover_sheet() { + section "7. Pre-assigned cover sheet auto-assigns batch" + + if [[ -z "$ENTRY_CLERK1_ID" ]]; then + fail "auto-assign test skipped — no entry clerk ID" + return + fi + + local gen_json assign_code pdf upload_json status entered_by + gen_json="$(generate_cover_sheets "$INTAKE_TOKEN" \ + "{\"count\":1,\"batchType\":\"LAB_RESULTS\",\"track\":\"BACKFILL\",\"assignToUserId\":\"$ENTRY_CLERK1_ID\"}")" + + if [[ "$(jq -er '.success' <<<"$gen_json")" != "true" ]]; then + fail "generate pre-assigned cover sheet" + return + fi + + assign_code="$(jq -r '.data[0].code' <<<"$gen_json")" + pdf="$(unique_pdf_path assign)" + upload_json="$(upload_with_cover_sheet "$INTAKE_TOKEN" "$assign_code" "$pdf")" + rm -f "$pdf" + + status="$(extract_data_field "$upload_json" status)" + entered_by="$(extract_data_field "$upload_json" enteredByUserId)" + + if [[ "$status" == "IN_ENTRY" ]]; then + pass "pre-assigned upload transitions batch to IN_ENTRY" + else + fail "pre-assigned upload transitions batch to IN_ENTRY (status=$status)" + fi + + if [[ "$entered_by" == "$ENTRY_CLERK1_ID" ]]; then + pass "pre-assigned upload sets enteredByUserId to entry clerk" + else + fail "pre-assigned upload sets enteredByUserId to entry clerk (got $entered_by)" + fi +} + +test_patient_linked_cover_sheet() { + section "8. Patient-linked cover sheet populates batch patient" + + if [[ -z "$PATIENT1_ID" ]]; then + fail "patient-linked test skipped — no patient ID" + return + fi + + local gen_json patient_code pdf upload_json patient_id + gen_json="$(generate_cover_sheets "$INTAKE_TOKEN" \ + "{\"count\":1,\"batchType\":\"MEDICATION_LIST\",\"track\":\"BACKFILL\",\"patientId\":\"$PATIENT1_ID\"}")" + + if [[ "$(jq -er '.success' <<<"$gen_json")" != "true" ]]; then + fail "generate patient-linked cover sheet" + return + fi + + patient_code="$(jq -r '.data[0].code' <<<"$gen_json")" + pdf="$(unique_pdf_path patient)" + upload_json="$(upload_with_cover_sheet "$INTAKE_TOKEN" "$patient_code" "$pdf")" + rm -f "$pdf" + + patient_id="$(extract_data_field "$upload_json" patientId)" + if [[ "$patient_id" == "$PATIENT1_ID" ]]; then + pass "patient-linked cover sheet sets batch patientId" + else + fail "patient-linked cover sheet sets batch patientId (got $patient_id)" + fi +} + +test_integration_tests() { + section "9. dotnet integration tests — CoverSheetBatchTests, CoverSheetPdfTests" + + if [[ "$SKIP_TEST_CHECKS" == "1" ]]; then + log " SKIP: dotnet integration tests (VIGILCARE_SKIP_TEST_CHECKS=1)" + return + fi + + if ! command -v dotnet >/dev/null 2>&1; then + log " SKIP: dotnet not installed" + return + fi + + if dotnet test "$REPO_ROOT/VigilCareRecordsAPI.Tests/VigilCareRecordsAPI.Tests.csproj" \ + --filter "FullyQualifiedName~CoverSheet" \ + --no-restore >/tmp/vigilcare-p10-tests.log 2>&1; then + pass "CoverSheet integration tests passed" + else + fail "CoverSheet integration tests passed" + log " see /tmp/vigilcare-p10-tests.log" + fi +} + +print_manual_ui_checklist() { + section "10. Manual Vue UI checks (plan §5)" + log " Login as intake1 → /cover-sheets" + log " - Generate 5 VITALS_SHEET covers" + log " - Print Cover Sheets opens PDF in new tab" + log " - Cover sheet list shows unused sheets" + log " Navigate to /intake" + log " - Scan/type a cover sheet code (Enter triggers lookup)" + log " - Lookup auto-fills batch type, track, patient" + log " - Upload with Cover Sheet marks sheet as Used with linked batch ID" +} + +main() { + require_cmd curl + require_cmd jq + ensure_fixture_pdf + + log "VigilCare Records — Phase 10 verification" + log "API: $API_URL" + + assert_api_reachable + + test_authentication + test_database_schema + test_cover_sheet_generation + test_cover_sheet_lookup_and_list + test_pdf_generation + test_barcode_assisted_upload + test_cover_sheet_reuse_and_unknown + test_auto_assign_cover_sheet + test_patient_linked_cover_sheet + test_integration_tests + print_manual_ui_checklist + + log "" + log "Results: $PASS_COUNT passed, $FAIL_COUNT failed" + if (( FAIL_COUNT > 0 )); then + log "Failed checks:" + for item in "${FAILED_TESTS[@]}"; do + log " - $item" + done + exit 1 + fi + + log "All Phase 10 API verification checks passed." + log "Complete the manual Vue UI checklist above if not already done." +} + +main "$@" diff --git a/vigilcare-records-web/package-lock.json b/vigilcare-records-web/package-lock.json index 747794f..d18d45e 100644 --- a/vigilcare-records-web/package-lock.json +++ b/vigilcare-records-web/package-lock.json @@ -15,14 +15,18 @@ "vue-router": "^4.6.4" }, "devDependencies": { + "@types/jsdom": "^28.0.3", "@types/node": "^24.13.2", "@vitejs/plugin-vue": "^6.0.7", + "@vue/test-utils": "^2.4.11", "@vue/tsconfig": "^0.9.1", "autoprefixer": "^10.5.2", + "jsdom": "^29.1.1", "postcss": "^8.5.15", "tailwindcss": "^3.4.19", "typescript": "~6.0.2", "vite": "^8.1.0", + "vitest": "^4.1.9", "vue-tsc": "^3.3.5" } }, @@ -39,6 +43,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -85,6 +140,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -119,6 +327,42 @@ "tslib": "^2.4.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -214,6 +458,13 @@ "node": ">= 8" } }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, "node_modules/@oxc-project/types": { "version": "0.137.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", @@ -224,6 +475,17 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", @@ -506,6 +768,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -517,6 +786,51 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsdom": { + "version": "28.0.3", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-28.0.3.tgz", + "integrity": "sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^8.0.0", + "undici-types": "^7.21.0" + } + }, + "node_modules/@types/jsdom/node_modules/undici-types": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.28.0.tgz", + "integrity": "sha512-LJAfY+2w6HGeT8d8J1wNQsUGUEGio6NWWpwdwurQe4f6oojzCFuGLizl1KSve4irsTxyLly1QhEeE6iapdaIvQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", @@ -527,6 +841,13 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", @@ -550,6 +871,129 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.28", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", @@ -701,6 +1145,27 @@ "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", "license": "MIT" }, + "node_modules/@vue/test-utils": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.11.tgz", + "integrity": "sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^3.0.0" + }, + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } + } + }, "node_modules/@vue/tsconfig": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.9.1.tgz", @@ -758,6 +1223,16 @@ "vue": "^3.5.0" } }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/alien-signals": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", @@ -765,6 +1240,32 @@ "dev": true, "license": "MIT" }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -806,6 +1307,16 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -860,6 +1371,13 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.40", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", @@ -873,6 +1391,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -886,6 +1414,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -977,6 +1515,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1015,6 +1563,26 @@ "node": ">= 6" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1037,6 +1605,53 @@ "node": ">= 6" } }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1056,6 +1671,27 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1103,6 +1739,42 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.379", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.379.tgz", @@ -1110,6 +1782,13 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -1140,6 +1819,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -1183,6 +1869,16 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -1274,6 +1970,23 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -1365,6 +2078,28 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1429,6 +2164,26 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -1468,6 +2223,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1491,6 +2256,36 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -1501,6 +2296,76 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1794,6 +2659,16 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1812,6 +2687,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -1870,6 +2752,32 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/muggle-string": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", @@ -1917,6 +2825,22 @@ "node": ">=18" } }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -1947,6 +2871,53 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -1954,6 +2925,16 @@ "dev": true, "license": "MIT" }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -1961,6 +2942,37 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2184,12 +3196,29 @@ "dev": true, "license": "MIT" }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2247,6 +3276,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -2338,6 +3377,75 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2347,6 +3455,124 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -2383,6 +3609,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -2444,6 +3677,23 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2461,6 +3711,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.4.tgz", + "integrity": "sha512-kFXFK7O4WPextIUAOk8qtnw9dxR9UIXP9CjuH1cTBVBZMDeQcUPgr/IazGiw1B0Yiw5L75gHLWeW4iD793r90g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.4" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.4.tgz", + "integrity": "sha512-vwVLJVvvpslm7vqAH7+XNj/neA/Ynq7DT2EEcMuwc5YzN5XaMyRAqxwU+uX3azZ1FQtB2gvrvnLnAEkvYlVdfg==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2474,6 +3754,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -2503,6 +3809,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -2626,6 +3942,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -2654,6 +4060,13 @@ } } }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.5.tgz", + "integrity": "sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==", + "dev": true, + "license": "MIT" + }, "node_modules/vue-demi": { "version": "0.14.10", "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", @@ -2711,6 +4124,202 @@ "peerDependencies": { "typescript": ">=5.0.0" } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/vigilcare-records-web/package.json b/vigilcare-records-web/package.json index 1e238b6..f930fb5 100644 --- a/vigilcare-records-web/package.json +++ b/vigilcare-records-web/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "vite", "build": "vue-tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest", + "test:run": "vitest run" }, "dependencies": { "@vueuse/core": "^14.3.0", @@ -16,14 +18,18 @@ "vue-router": "^4.6.4" }, "devDependencies": { + "@types/jsdom": "^28.0.3", "@types/node": "^24.13.2", "@vitejs/plugin-vue": "^6.0.7", + "@vue/test-utils": "^2.4.11", "@vue/tsconfig": "^0.9.1", "autoprefixer": "^10.5.2", + "jsdom": "^29.1.1", "postcss": "^8.5.15", "tailwindcss": "^3.4.19", "typescript": "~6.0.2", "vite": "^8.1.0", + "vitest": "^4.1.9", "vue-tsc": "^3.3.5" } } diff --git a/vigilcare-records-web/src/__tests__/components/EntryForm.test.ts b/vigilcare-records-web/src/__tests__/components/EntryForm.test.ts new file mode 100644 index 0000000..acc2954 --- /dev/null +++ b/vigilcare-records-web/src/__tests__/components/EntryForm.test.ts @@ -0,0 +1,338 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { setActivePinia, createPinia } from 'pinia' +import EntryForm from '@/components/EntryForm.vue' +import { useBatchStore } from '@/stores/batches' +import type { BatchDetailResponse } from '@/types' + +vi.mock('@/api/client', () => ({ + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + del: vi.fn(), + patch: vi.fn(), + uploadFile: vi.fn(), +})) + +vi.mock('@/composables/useToast', () => ({ + useToast: () => ({ + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }), +})) + +function makeBatch(overrides: Partial = {}): BatchDetailResponse { + return { + id: 'b1', + status: 'IN_ENTRY', + batchType: 'VITALS', + track: 'TRACK_A', + patientId: null, + documentRef: 'docs/scan.pdf', + documentUrl: null, + enableRetroactiveAlerts: false, + enteredByUserId: 'u1', + verifiedByUserId: null, + approvedByUserId: null, + rejectionReason: null, + promotedAt: null, + promotionEncounterId: null, + supersedesBatchId: null, + clinicianAttestation: false, + createdAt: '2026-06-27T10:00:00Z', + updatedAt: '2026-06-27T10:00:00Z', + ...overrides, + } +} + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() +}) + +describe('EntryForm', () => { + it('renders patient demographics fields', () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch(), batchId: 'b1' }, + }) + expect(wrapper.text()).toContain('Patient Demographics') + expect(wrapper.text()).toContain('Full Name') + expect(wrapper.text()).toContain('Date of Birth') + expect(wrapper.text()).toContain('Sex') + expect(wrapper.text()).toContain('Blood Type') + expect(wrapper.text()).toContain('Emergency Contact') + }) + + it('renders encounter context fields', () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch(), batchId: 'b1' }, + }) + expect(wrapper.text()).toContain('Encounter Context') + expect(wrapper.text()).toContain('Admission Date') + expect(wrapper.text()).toContain('Department') + expect(wrapper.text()).toContain('Room / Bed') + expect(wrapper.text()).toContain('Admission Reason') + }) + + it('renders observations section with add button', () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch(), batchId: 'b1' }, + }) + expect(wrapper.text()).toContain('Observations') + expect(wrapper.text()).toContain('+ Add Observation') + }) + + it('renders submit button', () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch(), batchId: 'b1' }, + }) + const submitBtn = wrapper.find('button') + const buttons = wrapper.findAll('button') + const submitButton = buttons.find((b) => b.text().includes('Submit for Verification')) + expect(submitButton).toBeTruthy() + }) + + it('displays batch status', () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch({ status: 'IN_ENTRY' }), batchId: 'b1' }, + }) + expect(wrapper.text()).toContain('IN ENTRY') + }) + + describe('conditional sections by batch type', () => { + it('hides allergies section for VITALS batch', () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch({ batchType: 'VITALS' }), batchId: 'b1' }, + }) + expect(wrapper.text()).not.toContain('Allergies') + }) + + it('shows allergies section for ALLERGY_UPDATE batch', () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch({ batchType: 'ALLERGY_UPDATE' }), batchId: 'b1' }, + }) + expect(wrapper.text()).toContain('Allergies') + expect(wrapper.text()).toContain('No known allergies') + }) + + it('hides medications section for VITALS batch', () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch({ batchType: 'VITALS' }), batchId: 'b1' }, + }) + expect(wrapper.text()).not.toContain('Medications') + }) + + it('shows medications section for MEDICATION_LIST batch', () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch({ batchType: 'MEDICATION_LIST' }), batchId: 'b1' }, + }) + expect(wrapper.text()).toContain('Medications') + expect(wrapper.text()).toContain('No active medications') + }) + + it('shows both allergies and medications for MIXED batch', () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch({ batchType: 'MIXED' }), batchId: 'b1' }, + }) + expect(wrapper.text()).toContain('Allergies') + expect(wrapper.text()).toContain('Medications') + }) + + it('hides encounter summary fields for VITALS batch', () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch({ batchType: 'VITALS' }), batchId: 'b1' }, + }) + expect(wrapper.text()).not.toContain('Encounter Status') + expect(wrapper.text()).not.toContain('Discharge Diagnosis') + }) + + it('shows encounter summary fields for ENCOUNTER_SUMMARY batch', () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch({ batchType: 'ENCOUNTER_SUMMARY' }), batchId: 'b1' }, + }) + expect(wrapper.text()).toContain('Encounter Status') + expect(wrapper.text()).toContain('Discharge Diagnosis') + }) + }) + + describe('draft loading', () => { + it('populates patient fields from draft', async () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch(), batchId: 'b1' }, + }) + + const store = useBatchStore() + store.currentDraft = { + patient: { + id: 'dp1', + batchId: 'b1', + fullName: 'John Doe', + dateOfBirth: '1990-05-15', + sex: 'male', + bloodType: 'O+', + emergencyContact: '555-1234', + allergiesJson: null, + noKnownAllergies: false, + medicationsJson: null, + noActiveMedications: false, + }, + encounter: null, + observations: [], + } + await wrapper.vm.$nextTick() + + const nameInput = wrapper.find('input[type="text"]') + expect(nameInput.element.value).toBe('John Doe') + }) + }) + + describe('save on blur', () => { + it('calls saveDraftPatient on patient field blur', async () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch(), batchId: 'b1' }, + }) + + const store = useBatchStore() + store.saveDraftPatient = vi.fn().mockResolvedValue(undefined) + + const nameInput = wrapper.find('input[type="text"]') + await nameInput.setValue('Jane Doe') + await nameInput.trigger('blur') + + expect(store.saveDraftPatient).toHaveBeenCalledWith( + 'b1', + expect.objectContaining({ fullName: 'Jane Doe' }), + ) + }) + + it('calls saveDraftEncounter on encounter field blur', async () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch(), batchId: 'b1' }, + }) + + const store = useBatchStore() + store.saveDraftEncounter = vi.fn().mockResolvedValue(undefined) + + const roomInput = wrapper.findAll('input[type="text"]').find((i) => { + const label = i.element.closest('div')?.querySelector('label') + return label?.textContent?.includes('Room') + }) + if (roomInput) { + await roomInput.setValue('4B-01') + await roomInput.trigger('blur') + expect(store.saveDraftEncounter).toHaveBeenCalled() + } + }) + }) + + describe('submit for verification', () => { + it('calls submitForVerification on click', async () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch(), batchId: 'b1' }, + }) + + const store = useBatchStore() + store.submitForVerification = vi.fn().mockResolvedValue(undefined) + + const submitBtn = wrapper.findAll('button').find((b) => b.text().includes('Submit for Verification')) + await submitBtn!.trigger('click') + await flushPromises() + + expect(store.submitForVerification).toHaveBeenCalledWith('b1') + }) + + it('displays error message on submit failure', async () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch(), batchId: 'b1' }, + }) + + const store = useBatchStore() + store.submitForVerification = vi.fn().mockRejectedValue(new Error('BATCH_INCOMPLETE')) + + const submitBtn = wrapper.findAll('button').find((b) => b.text().includes('Submit for Verification')) + await submitBtn!.trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('BATCH_INCOMPLETE') + }) + + it('shows submitting state on button', async () => { + let resolvePromise: () => void + const wrapper = mount(EntryForm, { + props: { batch: makeBatch(), batchId: 'b1' }, + }) + + const store = useBatchStore() + store.submitForVerification = vi.fn().mockReturnValue( + new Promise((resolve) => { + resolvePromise = resolve + }), + ) + + const submitBtn = wrapper.findAll('button').find((b) => b.text().includes('Submit for Verification')) + await submitBtn!.trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Submitting...') + + resolvePromise!() + await flushPromises() + + expect(wrapper.text()).toContain('Submit for Verification') + }) + }) + + describe('observations', () => { + it('calls addObservation on add button click', async () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch(), batchId: 'b1' }, + }) + + const store = useBatchStore() + store.addObservation = vi.fn().mockResolvedValue(undefined) + + const addBtn = wrapper.findAll('button').find((b) => b.text().includes('+ Add Observation')) + await addBtn!.trigger('click') + + expect(store.addObservation).toHaveBeenCalledWith('b1', expect.objectContaining({ + observationCode: '', + value: 0, + unit: '', + })) + }) + }) + + describe('blood type options', () => { + it('renders all 8 blood type options plus empty', () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch(), batchId: 'b1' }, + }) + const selects = wrapper.findAll('select') + const bloodTypeSelect = selects.find((s) => { + const opts = s.findAll('option') + return opts.some((o) => o.text() === 'A+') + }) + expect(bloodTypeSelect).toBeTruthy() + const options = bloodTypeSelect!.findAll('option') + expect(options.length).toBe(9) // "Unknown" + 8 blood types + }) + }) + + describe('department options', () => { + it('renders department dropdown with clinical departments', () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch(), batchId: 'b1' }, + }) + const selects = wrapper.findAll('select') + const deptSelect = selects.find((s) => { + const opts = s.findAll('option') + return opts.some((o) => o.text() === 'ICU') + }) + expect(deptSelect).toBeTruthy() + const options = deptSelect!.findAll('option') + expect(options.length).toBeGreaterThan(10) + }) + }) +}) diff --git a/vigilcare-records-web/src/__tests__/components/ObservationRow.test.ts b/vigilcare-records-web/src/__tests__/components/ObservationRow.test.ts new file mode 100644 index 0000000..c366f7d --- /dev/null +++ b/vigilcare-records-web/src/__tests__/components/ObservationRow.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import ObservationRow from '@/components/ObservationRow.vue' +import type { DraftObservation } from '@/types' + +function makeObservation(overrides: Partial = {}): DraftObservation { + return { + id: 'obs-1', + batchId: 'b1', + observationCode: 'HEART_RATE', + value: 72, + unit: 'bpm', + recordedAt: '2026-06-27T10:00:00Z', + note: null, + ...overrides, + } +} + +describe('ObservationRow', () => { + it('renders observation code options', () => { + const wrapper = mount(ObservationRow, { + props: { observation: makeObservation() }, + }) + const options = wrapper.findAll('select option') + const values = options.map((o) => o.element.value) + expect(values).toContain('HEART_RATE') + expect(values).toContain('TEMP_C') + expect(values).toContain('BP_SYSTOLIC') + expect(values).toContain('SPO2') + expect(values).toContain('POTASSIUM_MEQ_L') + }) + + it('renders observation values in inputs', () => { + const wrapper = mount(ObservationRow, { + props: { observation: makeObservation({ value: 98.6, unit: '°F' }) }, + }) + const numberInput = wrapper.find('input[type="number"]') + expect(numberInput.element.value).toBe('98.6') + + const textInputs = wrapper.findAll('input[type="text"]') + const unitInput = textInputs[0] + expect(unitInput.element.value).toBe('°F') + }) + + it('emits update event on code change', async () => { + const wrapper = mount(ObservationRow, { + props: { observation: makeObservation() }, + }) + const select = wrapper.find('select') + await select.setValue('TEMP_C') + + expect(wrapper.emitted('update')).toBeTruthy() + expect(wrapper.emitted('update')![0]).toEqual(['observationCode', 'TEMP_C']) + }) + + it('emits update event on value change', async () => { + const wrapper = mount(ObservationRow, { + props: { observation: makeObservation() }, + }) + const numberInput = wrapper.find('input[type="number"]') + await numberInput.setValue('80') + await numberInput.trigger('change') + + expect(wrapper.emitted('update')).toBeTruthy() + const emitted = wrapper.emitted('update')![0] + expect(emitted[0]).toBe('value') + expect(emitted[1]).toBe(80) + }) + + it('shows delete button in entry mode (not readonly)', () => { + const wrapper = mount(ObservationRow, { + props: { observation: makeObservation() }, + }) + const deleteBtn = wrapper.find('button') + expect(deleteBtn.exists()).toBe(true) + expect(deleteBtn.text()).toBe('Remove') + }) + + it('emits delete event on remove click', async () => { + const wrapper = mount(ObservationRow, { + props: { observation: makeObservation({ id: 'obs-42' }) }, + }) + await wrapper.find('button').trigger('click') + + expect(wrapper.emitted('delete')).toBeTruthy() + expect(wrapper.emitted('delete')![0]).toEqual(['obs-42']) + }) + + it('disables inputs in readonly mode', () => { + const wrapper = mount(ObservationRow, { + props: { observation: makeObservation(), readonly: true }, + }) + const select = wrapper.find('select') + expect(select.element.disabled).toBe(true) + + const numberInput = wrapper.find('input[type="number"]') + expect(numberInput.element.disabled).toBe(true) + }) + + it('hides delete button in readonly mode', () => { + const wrapper = mount(ObservationRow, { + props: { observation: makeObservation(), readonly: true }, + }) + const button = wrapper.find('button') + expect(button.exists()).toBe(false) + }) + + it('shows verification checkbox when showVerified is true', () => { + const wrapper = mount(ObservationRow, { + props: { + observation: makeObservation(), + readonly: true, + showVerified: true, + verified: false, + }, + }) + const checkbox = wrapper.find('input[type="checkbox"]') + expect(checkbox.exists()).toBe(true) + expect(checkbox.element.checked).toBe(false) + }) + + it('reflects verified state in checkbox', () => { + const wrapper = mount(ObservationRow, { + props: { + observation: makeObservation(), + readonly: true, + showVerified: true, + verified: true, + }, + }) + const checkbox = wrapper.find('input[type="checkbox"]') + expect(checkbox.element.checked).toBe(true) + }) + + it('emits verify event when checkbox is toggled', async () => { + const wrapper = mount(ObservationRow, { + props: { + observation: makeObservation({ id: 'obs-99' }), + readonly: true, + showVerified: true, + verified: false, + }, + }) + const checkbox = wrapper.find('input[type="checkbox"]') + await checkbox.setValue(true) + + expect(wrapper.emitted('verify')).toBeTruthy() + expect(wrapper.emitted('verify')![0]).toEqual(['obs-99', true]) + }) + + it('hides verification checkbox when showVerified is false', () => { + const wrapper = mount(ObservationRow, { + props: { observation: makeObservation(), showVerified: false }, + }) + const checkbox = wrapper.find('input[type="checkbox"]') + expect(checkbox.exists()).toBe(false) + }) +}) diff --git a/vigilcare-records-web/src/__tests__/components/PatientSearch.test.ts b/vigilcare-records-web/src/__tests__/components/PatientSearch.test.ts new file mode 100644 index 0000000..0e30981 --- /dev/null +++ b/vigilcare-records-web/src/__tests__/components/PatientSearch.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import PatientSearch from '@/components/PatientSearch.vue' + +vi.mock('@/api/client', () => ({ + get: vi.fn(), +})) + +import { get } from '@/api/client' + +const mockedGet = vi.mocked(get) + +beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() +}) + +describe('PatientSearch', () => { + it('renders a search input', () => { + const wrapper = mount(PatientSearch) + const input = wrapper.find('input') + expect(input.exists()).toBe(true) + expect(input.attributes('placeholder')).toContain('Search') + }) + + it('does not search when query is less than 2 characters', async () => { + const wrapper = mount(PatientSearch) + const input = wrapper.find('input') + + await input.setValue('J') + await input.trigger('input') + vi.advanceTimersByTime(400) + + expect(mockedGet).not.toHaveBeenCalled() + }) + + it('searches after debounce when query is 2+ characters', async () => { + mockedGet.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }], + error: null, + }) + + const wrapper = mount(PatientSearch) + const input = wrapper.find('input') + + await input.setValue('Jane') + await input.trigger('input') + + expect(mockedGet).not.toHaveBeenCalled() + + vi.advanceTimersByTime(300) + await vi.runAllTimersAsync() + + expect(mockedGet).toHaveBeenCalledWith('patients/search', { q: 'Jane' }) + }) + + it('shows results dropdown after search', async () => { + mockedGet.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: [ + { id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }, + { id: 'p2', fullName: 'Jane Smith', mrn: 'MRN-002' }, + ], + error: null, + }) + + const wrapper = mount(PatientSearch) + await wrapper.find('input').setValue('Jane') + await wrapper.find('input').trigger('input') + vi.advanceTimersByTime(300) + await vi.runAllTimersAsync() + await wrapper.vm.$nextTick() + + const items = wrapper.findAll('li') + expect(items.length).toBe(2) + expect(items[0].text()).toContain('Jane Doe') + expect(items[0].text()).toContain('MRN-001') + }) + + it('emits update:modelValue on patient selection', async () => { + mockedGet.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }], + error: null, + }) + + const wrapper = mount(PatientSearch) + await wrapper.find('input').setValue('Jane') + await wrapper.find('input').trigger('input') + vi.advanceTimersByTime(300) + await vi.runAllTimersAsync() + await wrapper.vm.$nextTick() + + await wrapper.find('li').trigger('click') + + expect(wrapper.emitted('update:modelValue')).toBeTruthy() + expect(wrapper.emitted('update:modelValue')![0]).toEqual(['p1']) + }) + + it('shows selected patient name after selection', async () => { + mockedGet.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }], + error: null, + }) + + const wrapper = mount(PatientSearch) + await wrapper.find('input').setValue('Jane') + await wrapper.find('input').trigger('input') + vi.advanceTimersByTime(300) + await vi.runAllTimersAsync() + await wrapper.vm.$nextTick() + + await wrapper.find('li').trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Selected: Jane Doe') + }) + + it('clears results list on patient selection', async () => { + mockedGet.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }], + error: null, + }) + + const wrapper = mount(PatientSearch) + await wrapper.find('input').setValue('Jane') + await wrapper.find('input').trigger('input') + vi.advanceTimersByTime(300) + await vi.runAllTimersAsync() + await wrapper.vm.$nextTick() + + await wrapper.find('li').trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.findAll('li').length).toBe(0) + }) + + it('sets input value to patient name on selection', async () => { + mockedGet.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }], + error: null, + }) + + const wrapper = mount(PatientSearch) + const input = wrapper.find('input') + await input.setValue('Jane') + await input.trigger('input') + vi.advanceTimersByTime(300) + await vi.runAllTimersAsync() + await wrapper.vm.$nextTick() + + await wrapper.find('li').trigger('click') + await wrapper.vm.$nextTick() + + expect(input.element.value).toBe('Jane Doe') + }) + + it('clears results on API error', async () => { + mockedGet.mockRejectedValueOnce(new Error('Network error')) + + const wrapper = mount(PatientSearch) + await wrapper.find('input').setValue('Jane') + await wrapper.find('input').trigger('input') + vi.advanceTimersByTime(300) + await vi.runAllTimersAsync() + await wrapper.vm.$nextTick() + + expect(wrapper.findAll('li').length).toBe(0) + }) + + it('debounces multiple rapid inputs', async () => { + mockedGet.mockResolvedValue({ + success: true, + statusCode: 200, + data: [], + error: null, + }) + + const wrapper = mount(PatientSearch) + const input = wrapper.find('input') + + await input.setValue('Ja') + await input.trigger('input') + vi.advanceTimersByTime(100) + + await input.setValue('Jan') + await input.trigger('input') + vi.advanceTimersByTime(100) + + await input.setValue('Jane') + await input.trigger('input') + vi.advanceTimersByTime(300) + await vi.runAllTimersAsync() + + expect(mockedGet).toHaveBeenCalledTimes(1) + expect(mockedGet).toHaveBeenCalledWith('patients/search', { q: 'Jane' }) + }) +}) diff --git a/vigilcare-records-web/src/__tests__/components/VerificationForm.test.ts b/vigilcare-records-web/src/__tests__/components/VerificationForm.test.ts new file mode 100644 index 0000000..70f4125 --- /dev/null +++ b/vigilcare-records-web/src/__tests__/components/VerificationForm.test.ts @@ -0,0 +1,406 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { setActivePinia, createPinia } from 'pinia' +import VerificationForm from '@/components/VerificationForm.vue' +import { useBatchStore } from '@/stores/batches' +import type { BatchDetailResponse } from '@/types' + +vi.mock('@/api/client', () => ({ + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + del: vi.fn(), + patch: vi.fn(), + uploadFile: vi.fn(), +})) + +vi.mock('@/composables/useToast', () => ({ + useToast: () => ({ + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }), +})) + +vi.mock('vue-router', () => ({ + useRouter: () => ({ + push: vi.fn(), + }), +})) + +function makeBatch(overrides: Partial = {}): BatchDetailResponse { + return { + id: 'b1', + status: 'PENDING_VERIFICATION', + batchType: 'VITALS', + track: 'TRACK_A', + patientId: 'p1', + documentRef: 'docs/scan.pdf', + documentUrl: null, + enableRetroactiveAlerts: false, + enteredByUserId: 'u1', + verifiedByUserId: null, + approvedByUserId: null, + rejectionReason: null, + promotedAt: null, + promotionEncounterId: null, + supersedesBatchId: null, + clinicianAttestation: false, + createdAt: '2026-06-27T10:00:00Z', + updatedAt: '2026-06-27T10:00:00Z', + ...overrides, + } +} + +function mountWithDraft(batchOverrides: Partial = {}) { + const wrapper = mount(VerificationForm, { + props: { batch: makeBatch(batchOverrides), batchId: 'b1' }, + }) + + const store = useBatchStore() + store.currentDraft = { + patient: { + id: 'dp1', + batchId: 'b1', + fullName: 'Jane Doe', + dateOfBirth: '1990-05-15', + sex: 'female', + bloodType: 'A+', + emergencyContact: '555-1234', + allergiesJson: null, + noKnownAllergies: false, + medicationsJson: null, + noActiveMedications: false, + }, + encounter: { + id: 'de1', + batchId: 'b1', + admissionDate: '2026-06-20T08:00:00', + department: 'ICU', + roomBed: '3A-12', + admissionReason: 'Chest pain', + dischargeDiagnosis: null, + status: null, + }, + observations: [ + { + id: 'obs-1', + batchId: 'b1', + observationCode: 'HEART_RATE', + value: 72, + unit: 'bpm', + recordedAt: '2026-06-27T10:00:00Z', + note: null, + }, + { + id: 'obs-2', + batchId: 'b1', + observationCode: 'TEMP_C', + value: 37.2, + unit: 'C', + recordedAt: '2026-06-27T10:00:00Z', + note: null, + }, + ], + } + + return { wrapper, store } +} + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() +}) + +describe('VerificationForm', () => { + it('renders verification header', () => { + const wrapper = mount(VerificationForm, { + props: { batch: makeBatch(), batchId: 'b1' }, + }) + expect(wrapper.text()).toContain('Verification Review') + expect(wrapper.text()).toContain('Pending Verification') + }) + + it('renders patient fields with checkboxes after draft loads', async () => { + const { wrapper } = mountWithDraft() + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Patient Demographics') + expect(wrapper.text()).toContain('Jane Doe') + expect(wrapper.text()).toContain('1990-05-15') + + const checkboxes = wrapper.findAll('input[type="checkbox"]') + expect(checkboxes.length).toBeGreaterThan(0) + }) + + it('renders encounter fields after draft loads', async () => { + const { wrapper } = mountWithDraft() + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Encounter Context') + expect(wrapper.text()).toContain('ICU') + expect(wrapper.text()).toContain('3A-12') + expect(wrapper.text()).toContain('Chest pain') + }) + + it('shows rejection reason banner when present', () => { + const wrapper = mount(VerificationForm, { + props: { + batch: makeBatch({ rejectionReason: 'Temperature seems incorrect' }), + batchId: 'b1', + }, + }) + expect(wrapper.text()).toContain('Previous Rejection Reason') + expect(wrapper.text()).toContain('Temperature seems incorrect') + }) + + it('does not show rejection banner when no reason', () => { + const wrapper = mount(VerificationForm, { + props: { batch: makeBatch({ rejectionReason: null }), batchId: 'b1' }, + }) + expect(wrapper.text()).not.toContain('Previous Rejection Reason') + }) + + describe('field check progress', () => { + it('shows 0/N checked initially', async () => { + const { wrapper } = mountWithDraft() + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Fields verified:') + expect(wrapper.text()).toMatch(/0\s*\/\s*\d+/) + }) + + it('updates count when checkboxes are toggled', async () => { + const { wrapper } = mountWithDraft() + await wrapper.vm.$nextTick() + + const checkboxes = wrapper.findAll('input[type="checkbox"]') + await checkboxes[0].setValue(true) + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toMatch(/1\s*\/\s*\d+/) + }) + }) + + describe('approve button', () => { + it('is disabled when not all fields are checked', async () => { + const { wrapper } = mountWithDraft() + await wrapper.vm.$nextTick() + + const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve')) + expect(approveBtn!.element.disabled).toBe(true) + }) + + it('is enabled when all fields are checked', async () => { + const { wrapper } = mountWithDraft() + await wrapper.vm.$nextTick() + + const checkboxes = wrapper.findAll('input[type="checkbox"]') + for (const cb of checkboxes) { + await cb.setValue(true) + } + await wrapper.vm.$nextTick() + + const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve')) + expect(approveBtn!.element.disabled).toBe(false) + }) + + it('calls verifyBatch with all field checks on approve', async () => { + const { wrapper, store } = mountWithDraft() + await wrapper.vm.$nextTick() + + store.verifyBatch = vi.fn().mockResolvedValue(undefined) + + const checkboxes = wrapper.findAll('input[type="checkbox"]') + for (const cb of checkboxes) { + await cb.setValue(true) + } + await wrapper.vm.$nextTick() + + const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve')) + await approveBtn!.trigger('click') + await flushPromises() + + expect(store.verifyBatch).toHaveBeenCalledWith( + 'b1', + expect.arrayContaining([ + expect.objectContaining({ passed: true }), + ]), + true, + ) + }) + }) + + describe('reject flow', () => { + it('shows reject dialog when reject button is clicked', async () => { + const wrapper = mount(VerificationForm, { + props: { batch: makeBatch(), batchId: 'b1' }, + }) + + const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') + await rejectBtn!.trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Reject Batch') + expect(wrapper.text()).toContain('Confirm Rejection') + }) + + it('disables confirm button when reason is empty', async () => { + const wrapper = mount(VerificationForm, { + props: { batch: makeBatch(), batchId: 'b1' }, + }) + + const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') + await rejectBtn!.trigger('click') + await wrapper.vm.$nextTick() + + const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection') + expect(confirmBtn!.element.disabled).toBe(true) + }) + + it('enables confirm button when reason is entered', async () => { + const wrapper = mount(VerificationForm, { + props: { batch: makeBatch(), batchId: 'b1' }, + }) + + const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') + await rejectBtn!.trigger('click') + await wrapper.vm.$nextTick() + + const textarea = wrapper.find('textarea') + await textarea.setValue('Temperature value appears incorrect') + await wrapper.vm.$nextTick() + + const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection') + expect(confirmBtn!.element.disabled).toBe(false) + }) + + it('calls rejectBatch on confirm', async () => { + const wrapper = mount(VerificationForm, { + props: { batch: makeBatch(), batchId: 'b1' }, + }) + + const store = useBatchStore() + store.rejectBatch = vi.fn().mockResolvedValue(undefined) + + const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') + await rejectBtn!.trigger('click') + await wrapper.vm.$nextTick() + + const textarea = wrapper.find('textarea') + await textarea.setValue('Value incorrect') + await wrapper.vm.$nextTick() + + const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection') + await confirmBtn!.trigger('click') + await flushPromises() + + expect(store.rejectBatch).toHaveBeenCalledWith('b1', 'Value incorrect') + }) + + it('closes reject dialog on cancel', async () => { + const wrapper = mount(VerificationForm, { + props: { batch: makeBatch(), batchId: 'b1' }, + }) + + const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') + await rejectBtn!.trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Reject Batch') + + const cancelBtn = wrapper.findAll('button').find((b) => b.text() === 'Cancel') + await cancelBtn!.trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.text()).not.toContain('Reject Batch') + }) + }) + + describe('observations display', () => { + it('shows observation count in legend', async () => { + const { wrapper } = mountWithDraft() + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Observations (2)') + }) + }) + + describe('allergy and medication verification', () => { + 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']) + await wrapper.vm.$nextTick() + + // Re-trigger the watcher by resetting draft + const draft = { ...store.currentDraft! } + store.currentDraft = null + await wrapper.vm.$nextTick() + store.currentDraft = draft + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Allergies') + }) + + it('shows NKA for noKnownAllergies', async () => { + const wrapper = mount(VerificationForm, { + props: { batch: makeBatch({ batchType: 'ALLERGY_UPDATE' }), batchId: 'b1' }, + }) + + const store = useBatchStore() + store.currentDraft = { + patient: { + id: 'dp1', + batchId: 'b1', + fullName: 'Jane', + dateOfBirth: '1990-01-01', + sex: 'female', + bloodType: null, + emergencyContact: null, + allergiesJson: null, + noKnownAllergies: true, + medicationsJson: null, + noActiveMedications: false, + }, + encounter: { + id: 'de1', + batchId: 'b1', + admissionDate: null, + department: null, + roomBed: null, + admissionReason: null, + dischargeDiagnosis: null, + status: null, + }, + observations: [], + } + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('No Known Allergies') + expect(wrapper.text()).toContain('Yes (NKA)') + }) + }) + + describe('error handling', () => { + it('shows error message on approve failure', async () => { + const { wrapper, store } = mountWithDraft() + await wrapper.vm.$nextTick() + + store.verifyBatch = vi.fn().mockRejectedValue(new Error('Server error')) + + const checkboxes = wrapper.findAll('input[type="checkbox"]') + for (const cb of checkboxes) { + await cb.setValue(true) + } + await wrapper.vm.$nextTick() + + const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve')) + await approveBtn!.trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('Server error') + }) + }) +}) diff --git a/vigilcare-records-web/src/__tests__/router/guards.test.ts b/vigilcare-records-web/src/__tests__/router/guards.test.ts new file mode 100644 index 0000000..9735c98 --- /dev/null +++ b/vigilcare-records-web/src/__tests__/router/guards.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router' +import { useAuthStore, getDefaultRouteForRole } from '@/stores/auth' + +vi.mock('@/api/client', () => ({ + post: vi.fn(), + get: vi.fn(), +})) + +function buildRoute(path: string, meta: Record = {}): RouteLocationNormalized { + return { + path, + meta, + name: undefined, + params: {}, + query: {}, + hash: '', + fullPath: path, + matched: [], + redirectedFrom: undefined, + } +} + +function setupGuard() { + const nextCalls: (string | undefined)[] = [] + const auth = useAuthStore() + + function runGuard(to: RouteLocationNormalized, from?: RouteLocationNormalized) { + const _from = from ?? buildRoute('/') + const next = vi.fn((dest?: string) => { + nextCalls.push(dest) + }) + + if (to.path === '/login' && auth.isAuthenticated) { + next(getDefaultRouteForRole(auth.userRole)) + return { next, nextCalls } + } + + if (to.meta.requiresAuth && !auth.isAuthenticated) { + next('/login') + return { next, nextCalls } + } + + if (to.meta.roles && Array.isArray(to.meta.roles)) { + const allowedRoles = to.meta.roles as string[] + if (!allowedRoles.includes(auth.userRole)) { + const fallback = getDefaultRouteForRole(auth.userRole) + if (fallback !== '/login' && fallback !== to.path) { + next(fallback) + return { next, nextCalls } + } + next('/login') + return { next, nextCalls } + } + } + + next() + return { next, nextCalls } + } + + return { auth, runGuard } +} + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() +}) + +describe('router navigation guard', () => { + describe('unauthenticated users', () => { + it('allows access to /login', () => { + const { runGuard } = setupGuard() + const { next } = runGuard(buildRoute('/login', { requiresAuth: false })) + expect(next).toHaveBeenCalledWith() + }) + + it('redirects to /login for protected routes', () => { + const { runGuard } = setupGuard() + const { next } = runGuard(buildRoute('/entry', { requiresAuth: true, roles: ['DATA_ENTRY_CLERK'] })) + expect(next).toHaveBeenCalledWith('/login') + }) + + it('redirects to /login for dashboard', () => { + const { runGuard } = setupGuard() + const { next } = runGuard(buildRoute('/dashboard', { requiresAuth: true, roles: ['ADMINISTRATOR'] })) + expect(next).toHaveBeenCalledWith('/login') + }) + }) + + describe('authenticated users', () => { + function authenticatedGuard(role: string) { + const { auth, runGuard } = setupGuard() + auth.token = 'valid-token' + auth.user = { id: 'u1', username: 'test', fullName: 'Test User', role } + return { auth, runGuard } + } + + it('redirects authenticated user away from /login to default route', () => { + const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK') + const { next } = runGuard(buildRoute('/login', { requiresAuth: false })) + expect(next).toHaveBeenCalledWith('/entry') + }) + + it('redirects admin from /login to /dashboard', () => { + const { runGuard } = authenticatedGuard('ADMINISTRATOR') + const { next } = runGuard(buildRoute('/login', { requiresAuth: false })) + expect(next).toHaveBeenCalledWith('/dashboard') + }) + + it('allows access to routes matching user role', () => { + const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK') + const { next } = runGuard(buildRoute('/entry', { requiresAuth: true, roles: ['DATA_ENTRY_CLERK', 'ADMINISTRATOR'] })) + expect(next).toHaveBeenCalledWith() + }) + + it('allows admin access to any role-restricted route', () => { + const { runGuard } = authenticatedGuard('ADMINISTRATOR') + const routeMetas = [ + { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] }, + { requiresAuth: true, roles: ['DATA_ENTRY_CLERK', 'ADMINISTRATOR'] }, + { requiresAuth: true, roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'] }, + { requiresAuth: true, roles: ['CLINICAL_APPROVER', 'ADMINISTRATOR'] }, + { requiresAuth: true, roles: ['CLINICIAN', 'ADMINISTRATOR'] }, + { requiresAuth: true, roles: ['ADMINISTRATOR'] }, + ] + for (const meta of routeMetas) { + const { next } = runGuard(buildRoute('/test', meta)) + expect(next).toHaveBeenCalledWith() + } + }) + + it('allows INTAKE_CLERK access to cover sheets route', () => { + const { runGuard } = authenticatedGuard('INTAKE_CLERK') + const { next } = runGuard(buildRoute('/cover-sheets', { + requiresAuth: true, + roles: ['INTAKE_CLERK', 'ADMINISTRATOR'], + })) + expect(next).toHaveBeenCalledWith() + }) + + it('redirects DATA_ENTRY_CLERK from cover sheets to /entry', () => { + const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK') + const { next } = runGuard(buildRoute('/cover-sheets', { + requiresAuth: true, + roles: ['INTAKE_CLERK', 'ADMINISTRATOR'], + })) + expect(next).toHaveBeenCalledWith('/entry') + }) + + it('redirects DATA_ENTRY_CLERK from /intake to /entry', () => { + const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK') + const { next } = runGuard(buildRoute('/intake', { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] })) + expect(next).toHaveBeenCalledWith('/entry') + }) + + it('redirects INTAKE_CLERK from /verification to /intake', () => { + const { runGuard } = authenticatedGuard('INTAKE_CLERK') + const { next } = runGuard(buildRoute('/verification', { + requiresAuth: true, + roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'], + })) + expect(next).toHaveBeenCalledWith('/intake') + }) + + it('redirects CLINICIAN from /dashboard to /live-capture', () => { + const { runGuard } = authenticatedGuard('CLINICIAN') + const { next } = runGuard(buildRoute('/dashboard', { requiresAuth: true, roles: ['ADMINISTRATOR'] })) + expect(next).toHaveBeenCalledWith('/live-capture') + }) + + it('allows access to routes with no role restriction', () => { + const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK') + const { next } = runGuard(buildRoute('/patients', { requiresAuth: true })) + expect(next).toHaveBeenCalledWith() + }) + + it('VERIFIER can access verification routes', () => { + const { runGuard } = authenticatedGuard('VERIFIER') + const { next } = runGuard(buildRoute('/verification', { + requiresAuth: true, + roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'], + })) + expect(next).toHaveBeenCalledWith() + }) + + it('CLINICAL_APPROVER can access both verification and approval routes', () => { + const { runGuard } = authenticatedGuard('CLINICAL_APPROVER') + + const { next: verifyNext } = runGuard(buildRoute('/verification', { + requiresAuth: true, + roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'], + })) + expect(verifyNext).toHaveBeenCalledWith() + + const { next: approvalNext } = runGuard(buildRoute('/approval', { + requiresAuth: true, + roles: ['CLINICAL_APPROVER', 'ADMINISTRATOR'], + })) + expect(approvalNext).toHaveBeenCalledWith() + }) + }) +}) diff --git a/vigilcare-records-web/src/__tests__/setup.ts b/vigilcare-records-web/src/__tests__/setup.ts new file mode 100644 index 0000000..28e9b2a --- /dev/null +++ b/vigilcare-records-web/src/__tests__/setup.ts @@ -0,0 +1,19 @@ +import { vi } from 'vitest' + +const localStorageData: Record = {} + +Object.defineProperty(globalThis, 'localStorage', { + value: { + getItem: vi.fn((key: string) => localStorageData[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + localStorageData[key] = value + }), + removeItem: vi.fn((key: string) => { + delete localStorageData[key] + }), + clear: vi.fn(() => { + Object.keys(localStorageData).forEach((k) => delete localStorageData[k]) + }), + }, + writable: true, +}) diff --git a/vigilcare-records-web/src/__tests__/stores/auth.test.ts b/vigilcare-records-web/src/__tests__/stores/auth.test.ts new file mode 100644 index 0000000..1b46f8a --- /dev/null +++ b/vigilcare-records-web/src/__tests__/stores/auth.test.ts @@ -0,0 +1,312 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useAuthStore, getDefaultRouteForRole } from '@/stores/auth' + +vi.mock('@/api/client', () => ({ + post: vi.fn(), + get: vi.fn(), +})) + +vi.mock('@/router', () => ({ + default: { push: vi.fn() }, +})) + +import { post, get } from '@/api/client' +import router from '@/router' + +const mockedPost = vi.mocked(post) +const mockedGet = vi.mocked(get) +const mockedRouterPush = vi.mocked(router.push) + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + localStorage.clear() +}) + +describe('getDefaultRouteForRole', () => { + it.each([ + ['INTAKE_CLERK', '/intake'], + ['DATA_ENTRY_CLERK', '/entry'], + ['VERIFIER', '/verification'], + ['CLINICAL_APPROVER', '/approval'], + ['CLINICIAN', '/live-capture'], + ['ADMINISTRATOR', '/dashboard'], + ['UNKNOWN_ROLE', '/login'], + ['', '/login'], + ])('returns %s for role %s', (role, expected) => { + expect(getDefaultRouteForRole(role)).toBe(expected) + }) +}) + +describe('useAuthStore', () => { + describe('initial state', () => { + it('starts unauthenticated when localStorage is empty', () => { + const store = useAuthStore() + expect(store.isAuthenticated).toBe(false) + expect(store.user).toBeNull() + expect(store.userRole).toBe('') + expect(store.userId).toBe('') + expect(store.userFullName).toBe('') + }) + + it('hydrates from localStorage on creation', () => { + localStorage.setItem('vigilcare_token', 'stored-token') + localStorage.setItem('vigilcare_refresh_token', 'stored-refresh') + localStorage.setItem( + 'vigilcare_user', + JSON.stringify({ id: 'u1', username: 'clerk1', fullName: 'Clerk One', role: 'DATA_ENTRY_CLERK' }), + ) + + setActivePinia(createPinia()) + const store = useAuthStore() + + expect(store.isAuthenticated).toBe(true) + expect(store.userRole).toBe('DATA_ENTRY_CLERK') + expect(store.userId).toBe('u1') + expect(store.userFullName).toBe('Clerk One') + }) + }) + + describe('role-based permissions', () => { + function storeWithRole(role: string) { + const store = useAuthStore() + store.user = { id: 'u1', username: 'test', fullName: 'Test', role } + store.token = 'tok' + return store + } + + it('INTAKE_CLERK can intake only', () => { + const store = storeWithRole('INTAKE_CLERK') + expect(store.canIntake).toBe(true) + expect(store.canEntry).toBe(false) + expect(store.canVerify).toBe(false) + expect(store.canApprove).toBe(false) + expect(store.canLiveCapture).toBe(false) + expect(store.canSupervise).toBe(false) + }) + + it('DATA_ENTRY_CLERK can entry only', () => { + const store = storeWithRole('DATA_ENTRY_CLERK') + expect(store.canEntry).toBe(true) + expect(store.canIntake).toBe(false) + expect(store.canVerify).toBe(false) + expect(store.canApprove).toBe(false) + }) + + it('VERIFIER can verify only', () => { + const store = storeWithRole('VERIFIER') + expect(store.canVerify).toBe(true) + expect(store.canIntake).toBe(false) + expect(store.canEntry).toBe(false) + expect(store.canApprove).toBe(false) + }) + + it('CLINICAL_APPROVER can verify and approve', () => { + const store = storeWithRole('CLINICAL_APPROVER') + expect(store.canVerify).toBe(true) + expect(store.canApprove).toBe(true) + expect(store.canIntake).toBe(false) + expect(store.canEntry).toBe(false) + }) + + it('CLINICIAN can live capture only', () => { + const store = storeWithRole('CLINICIAN') + expect(store.canLiveCapture).toBe(true) + expect(store.canIntake).toBe(false) + expect(store.canEntry).toBe(false) + expect(store.canVerify).toBe(false) + expect(store.canSupervise).toBe(false) + }) + + it('ADMINISTRATOR has all permissions', () => { + const store = storeWithRole('ADMINISTRATOR') + expect(store.canIntake).toBe(true) + expect(store.canEntry).toBe(true) + expect(store.canVerify).toBe(true) + expect(store.canApprove).toBe(true) + expect(store.canLiveCapture).toBe(true) + expect(store.canSupervise).toBe(true) + }) + }) + + describe('login', () => { + it('sets token, user, and localStorage on successful login', async () => { + mockedPost.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: { + token: 'jwt-tok', + refreshToken: 'ref-tok', + userId: 'u-123', + username: 'entry1', + fullName: 'Entry Clerk', + role: 'DATA_ENTRY_CLERK', + }, + error: null, + }) + + const store = useAuthStore() + await store.login({ username: 'entry1', password: 'password' }) + + expect(store.isAuthenticated).toBe(true) + expect(store.token).toBe('jwt-tok') + expect(store.refreshToken).toBe('ref-tok') + expect(store.user).toEqual({ + id: 'u-123', + username: 'entry1', + fullName: 'Entry Clerk', + role: 'DATA_ENTRY_CLERK', + }) + expect(localStorage.setItem).toHaveBeenCalledWith('vigilcare_token', 'jwt-tok') + expect(localStorage.setItem).toHaveBeenCalledWith('vigilcare_refresh_token', 'ref-tok') + expect(mockedRouterPush).toHaveBeenCalledWith('/entry') + }) + + it('navigates to role-specific default route', async () => { + mockedPost.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: { + token: 't', + refreshToken: 'r', + userId: 'u1', + username: 'admin', + fullName: 'Admin', + role: 'ADMINISTRATOR', + }, + error: null, + }) + + const store = useAuthStore() + await store.login({ username: 'admin', password: 'password' }) + + expect(mockedRouterPush).toHaveBeenCalledWith('/dashboard') + }) + + it('throws on failed login', async () => { + mockedPost.mockResolvedValueOnce({ + success: false, + statusCode: 401, + data: null, + error: { message: 'Invalid credentials', code: 'AUTH_FAILED' }, + }) + + const store = useAuthStore() + await expect(store.login({ username: 'bad', password: 'wrong' })).rejects.toThrow( + 'Invalid credentials', + ) + expect(store.isAuthenticated).toBe(false) + }) + + it('throws generic message when error has no message', async () => { + mockedPost.mockResolvedValueOnce({ + success: false, + statusCode: 500, + data: null, + error: null, + }) + + const store = useAuthStore() + await expect(store.login({ username: 'x', password: 'y' })).rejects.toThrow('Login failed') + }) + }) + + describe('fetchCurrentUser', () => { + it('updates user from /auth/me response', async () => { + mockedGet.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: { id: 'u-99', username: 'verifier1', fullName: 'Verifier One', role: 'VERIFIER' }, + error: null, + }) + + const store = useAuthStore() + await store.fetchCurrentUser() + + expect(store.user).toEqual({ + id: 'u-99', + username: 'verifier1', + fullName: 'Verifier One', + role: 'VERIFIER', + }) + expect(localStorage.setItem).toHaveBeenCalledWith( + 'vigilcare_user', + JSON.stringify(store.user), + ) + }) + + it('does not update user on failed response', async () => { + mockedGet.mockResolvedValueOnce({ + success: false, + statusCode: 401, + data: null, + error: null, + }) + + const store = useAuthStore() + store.user = { id: 'old', username: 'old', fullName: 'Old', role: 'VERIFIER' } + await store.fetchCurrentUser() + + expect(store.user?.id).toBe('old') + }) + }) + + describe('logout', () => { + it('clears state, localStorage, and navigates to /login', async () => { + mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null }) + + const store = useAuthStore() + store.token = 'tok' + store.refreshToken = 'ref' + store.user = { id: 'u1', username: 'x', fullName: 'X', role: 'VERIFIER' } + + await store.logout() + + expect(store.token).toBeNull() + expect(store.refreshToken).toBeNull() + expect(store.user).toBeNull() + expect(store.isAuthenticated).toBe(false) + expect(localStorage.removeItem).toHaveBeenCalledWith('vigilcare_token') + expect(localStorage.removeItem).toHaveBeenCalledWith('vigilcare_refresh_token') + expect(localStorage.removeItem).toHaveBeenCalledWith('vigilcare_user') + expect(mockedRouterPush).toHaveBeenCalledWith('/login') + }) + + it('posts refresh token to server on logout', async () => { + mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null }) + + const store = useAuthStore() + store.refreshToken = 'my-refresh-tok' + + await store.logout() + + expect(mockedPost).toHaveBeenCalledWith('auth/logout', { refreshToken: 'my-refresh-tok' }) + }) + + it('still clears local session if server revoke fails', async () => { + mockedPost.mockRejectedValueOnce(new Error('Network error')) + + const store = useAuthStore() + store.token = 'tok' + store.refreshToken = 'ref' + + await store.logout() + + expect(store.token).toBeNull() + expect(store.isAuthenticated).toBe(false) + expect(mockedRouterPush).toHaveBeenCalledWith('/login') + }) + + it('skips server call when no refresh token', async () => { + const store = useAuthStore() + store.token = 'tok' + store.refreshToken = null + + await store.logout() + + expect(mockedPost).not.toHaveBeenCalled() + expect(store.token).toBeNull() + }) + }) +}) diff --git a/vigilcare-records-web/src/__tests__/stores/batches.test.ts b/vigilcare-records-web/src/__tests__/stores/batches.test.ts new file mode 100644 index 0000000..c82ec1e --- /dev/null +++ b/vigilcare-records-web/src/__tests__/stores/batches.test.ts @@ -0,0 +1,405 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useBatchStore } from '@/stores/batches' + +vi.mock('@/api/client', () => ({ + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + del: vi.fn(), + patch: vi.fn(), + uploadFile: vi.fn(), +})) + +import { get, post, put, del, patch, uploadFile } from '@/api/client' + +const mockedGet = vi.mocked(get) +const mockedPost = vi.mocked(post) +const mockedPut = vi.mocked(put) +const mockedDel = vi.mocked(del) +const mockedPatch = vi.mocked(patch) +const mockedUploadFile = vi.mocked(uploadFile) + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() +}) + +describe('useBatchStore', () => { + describe('initial state', () => { + it('has expected defaults', () => { + const store = useBatchStore() + expect(store.batches).toEqual([]) + expect(store.currentBatch).toBeNull() + expect(store.currentDraft).toBeNull() + expect(store.totalCount).toBe(0) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + expect(store.documentUrl).toBeNull() + }) + }) + + describe('listBatches', () => { + it('populates batches and totalCount on success', async () => { + const items = [ + { id: 'b1', status: 'UPLOADED', batchType: 'VITALS', track: 'TRACK_A' }, + { id: 'b2', status: 'IN_ENTRY', batchType: 'LABS', track: 'TRACK_A' }, + ] + mockedGet.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: { items, totalCount: 42, page: 1, pageSize: 20, totalPages: 3 }, + error: null, + }) + + const store = useBatchStore() + await store.listBatches({ status: 'UPLOADED' }) + + expect(store.batches).toEqual(items) + expect(store.totalCount).toBe(42) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + + it('sets error on failure', async () => { + mockedGet.mockRejectedValueOnce(new Error('Network error')) + + const store = useBatchStore() + await store.listBatches({}) + + expect(store.error).toBe('Network error') + expect(store.loading).toBe(false) + }) + + it('sets generic error for non-Error exceptions', async () => { + mockedGet.mockRejectedValueOnce('string error') + + const store = useBatchStore() + await store.listBatches({}) + + expect(store.error).toBe('Failed to load batches') + }) + + it('sets loading=true during request', async () => { + let resolvePromise: (v: unknown) => void + mockedGet.mockReturnValueOnce( + new Promise((resolve) => { + resolvePromise = resolve + }), + ) + + const store = useBatchStore() + const promise = store.listBatches({}) + + expect(store.loading).toBe(true) + + resolvePromise!({ success: true, statusCode: 200, data: { items: [], totalCount: 0 }, error: null }) + await promise + + expect(store.loading).toBe(false) + }) + }) + + describe('getBatch', () => { + it('sets currentBatch on success', async () => { + const batch = { id: 'b1', status: 'UPLOADED', batchType: 'VITALS', documentUrl: 'https://example.com/doc' } + mockedGet.mockResolvedValueOnce({ success: true, statusCode: 200, data: batch, error: null }) + + const store = useBatchStore() + await store.getBatch('b1') + + expect(store.currentBatch).toEqual(batch) + expect(store.documentUrl).toBe('https://example.com/doc') + }) + + it('sets error on failure', async () => { + mockedGet.mockRejectedValueOnce(new Error('Not found')) + + const store = useBatchStore() + await store.getBatch('bad-id') + + expect(store.error).toBe('Not found') + expect(store.currentBatch).toBeNull() + }) + }) + + describe('uploadBatch', () => { + it('returns batch data on success', async () => { + const batch = { id: 'new-batch', status: 'UPLOADED' } + mockedUploadFile.mockResolvedValueOnce({ success: true, statusCode: 201, data: batch, error: null }) + + const store = useBatchStore() + const file = new File(['content'], 'scan.pdf', { type: 'application/pdf' }) + const result = await store.uploadBatch(file, 'VITALS', 'TRACK_A') + + expect(result).toEqual(batch) + expect(mockedUploadFile).toHaveBeenCalledWith('digitization-batches', file, { + batchType: 'VITALS', + track: 'TRACK_A', + }) + }) + + it('passes optional patientId and supersedesBatchId', async () => { + mockedUploadFile.mockResolvedValueOnce({ success: true, statusCode: 201, data: { id: 'b' }, error: null }) + + const store = useBatchStore() + const file = new File(['content'], 'scan.pdf') + await store.uploadBatch(file, 'VITALS', 'TRACK_A', 'patient-1', 'old-batch') + + expect(mockedUploadFile).toHaveBeenCalledWith('digitization-batches', file, { + batchType: 'VITALS', + track: 'TRACK_A', + patientId: 'patient-1', + supersedesBatchId: 'old-batch', + }) + }) + + it('passes coverSheetCode when provided', async () => { + mockedUploadFile.mockResolvedValueOnce({ success: true, statusCode: 201, data: { id: 'b' }, error: null }) + + const store = useBatchStore() + const file = new File(['content'], 'scan.pdf') + await store.uploadBatch( + file, + 'VITALS_SHEET', + 'BACKFILL', + undefined, + undefined, + 'VCR-CS-A3F7B2D1', + ) + + expect(mockedUploadFile).toHaveBeenCalledWith('digitization-batches', file, { + batchType: 'VITALS_SHEET', + track: 'BACKFILL', + coverSheetCode: 'VCR-CS-A3F7B2D1', + }) + }) + + it('returns null on failure', async () => { + mockedUploadFile.mockRejectedValueOnce(new Error('Upload failed')) + + const store = useBatchStore() + const file = new File([''], 'scan.pdf') + const result = await store.uploadBatch(file, 'VITALS', 'TRACK_A') + + expect(result).toBeNull() + expect(store.error).toBe('Upload failed') + }) + }) + + describe('assignBatch', () => { + it('calls PATCH with entryClerkUserId', async () => { + mockedPatch.mockResolvedValueOnce({ success: true, statusCode: 200, data: {}, error: null }) + + const store = useBatchStore() + await store.assignBatch('b1', 'clerk-42') + + expect(mockedPatch).toHaveBeenCalledWith('digitization-batches/b1/assign', { + entryClerkUserId: 'clerk-42', + }) + }) + + it('throws on failure', async () => { + mockedPatch.mockResolvedValueOnce({ + success: false, + statusCode: 409, + data: null, + error: { message: 'Already assigned', code: 'CONFLICT' }, + }) + + const store = useBatchStore() + await expect(store.assignBatch('b1', 'clerk-42')).rejects.toThrow('Already assigned') + }) + }) + + describe('getDraft', () => { + it('sets currentDraft on success', async () => { + const draft = { + patient: { fullName: 'Jane', dateOfBirth: '1990-01-01' }, + encounter: { department: 'ICU' }, + observations: [{ id: 'obs-1', observationCode: 'HEART_RATE', value: 72 }], + } + mockedGet.mockResolvedValueOnce({ success: true, statusCode: 200, data: draft, error: null }) + + const store = useBatchStore() + await store.getDraft('b1') + + expect(store.currentDraft).toEqual(draft) + }) + }) + + describe('saveDraftPatient', () => { + it('calls PUT with patient data', async () => { + mockedPut.mockResolvedValueOnce({ success: true, statusCode: 200, data: {}, error: null }) + + const store = useBatchStore() + await store.saveDraftPatient('b1', { fullName: 'John Doe' }) + + expect(mockedPut).toHaveBeenCalledWith('digitization-batches/b1/draft/patient', { + fullName: 'John Doe', + }) + }) + }) + + describe('saveDraftEncounter', () => { + it('calls PUT with encounter data', async () => { + mockedPut.mockResolvedValueOnce({ success: true, statusCode: 200, data: {}, error: null }) + + const store = useBatchStore() + await store.saveDraftEncounter('b1', { department: 'ICU' }) + + expect(mockedPut).toHaveBeenCalledWith('digitization-batches/b1/draft/encounter', { + department: 'ICU', + }) + }) + }) + + describe('addObservation', () => { + it('posts observation and refreshes draft', async () => { + mockedPost.mockResolvedValueOnce({ success: true, statusCode: 201, data: {}, error: null }) + mockedGet.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: { patient: null, encounter: null, observations: [{ id: 'obs-new' }] }, + error: null, + }) + + const store = useBatchStore() + await store.addObservation('b1', { observationCode: 'HEART_RATE', value: 72, unit: 'bpm' }) + + expect(mockedPost).toHaveBeenCalledWith('digitization-batches/b1/draft/observations', { + observationCode: 'HEART_RATE', + value: 72, + unit: 'bpm', + }) + expect(mockedGet).toHaveBeenCalledWith('digitization-batches/b1/draft') + }) + }) + + describe('updateObservation', () => { + it('calls PUT with observation data', async () => { + mockedPut.mockResolvedValueOnce({ success: true, statusCode: 200, data: {}, error: null }) + + const store = useBatchStore() + await store.updateObservation('b1', 'obs-1', { value: 80 }) + + expect(mockedPut).toHaveBeenCalledWith('digitization-batches/b1/draft/observations/obs-1', { + value: 80, + }) + }) + }) + + describe('deleteObservation', () => { + it('calls DELETE and refreshes draft', async () => { + mockedDel.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null }) + mockedGet.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: { patient: null, encounter: null, observations: [] }, + error: null, + }) + + const store = useBatchStore() + await store.deleteObservation('b1', 'obs-1') + + expect(mockedDel).toHaveBeenCalledWith('digitization-batches/b1/draft/observations/obs-1') + }) + }) + + describe('submitForVerification', () => { + it('posts to submit endpoint', async () => { + mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null }) + + const store = useBatchStore() + await store.submitForVerification('b1') + + expect(mockedPost).toHaveBeenCalledWith('digitization-batches/b1/submit-for-verification') + }) + }) + + describe('verifyBatch', () => { + it('posts field checks and pass status', async () => { + mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null }) + + const checks = [ + { fieldPath: 'patient.fullName', passed: true }, + { fieldPath: 'patient.dob', passed: true }, + ] + const store = useBatchStore() + await store.verifyBatch('b1', checks, true) + + expect(mockedPost).toHaveBeenCalledWith('digitization-batches/b1/verify', { + fieldChecks: checks, + passed: true, + }) + }) + }) + + describe('rejectBatch', () => { + it('posts rejection reason', async () => { + mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null }) + + const store = useBatchStore() + await store.rejectBatch('b1', 'Temperature value appears incorrect') + + expect(mockedPost).toHaveBeenCalledWith('digitization-batches/b1/reject', { + reason: 'Temperature value appears incorrect', + }) + }) + }) + + describe('approveBatch', () => { + it('posts with idempotency key and enableRetroactiveAlerts', async () => { + mockedPost.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: { mrn: 'MRN-001', encounterId: 'enc-1', observationIds: ['o1', 'o2'] }, + error: null, + }) + + const store = useBatchStore() + const result = await store.approveBatch('b1', true) + + expect(mockedPost).toHaveBeenCalledWith( + 'digitization-batches/b1/approve', + { enableRetroactiveAlerts: true }, + expect.objectContaining({ 'Idempotency-Key': expect.any(String) }), + ) + expect(result.data).toEqual({ + mrn: 'MRN-001', + encounterId: 'enc-1', + observationIds: ['o1', 'o2'], + }) + }) + }) + + describe('getPatientHistory', () => { + it('returns history on success', async () => { + const history = { + patientId: 'p1', + totalBatches: 3, + promotedBatches: 2, + supersededBatches: 1, + pendingBatches: 0, + entries: [], + } + mockedGet.mockResolvedValueOnce({ success: true, statusCode: 200, data: history, error: null }) + + const store = useBatchStore() + const result = await store.getPatientHistory('p1') + + expect(result).toEqual(history) + expect(mockedGet).toHaveBeenCalledWith('patients/p1/digitization-history') + }) + + it('returns null and sets error on failure', async () => { + mockedGet.mockRejectedValueOnce(new Error('Not found')) + + const store = useBatchStore() + const result = await store.getPatientHistory('bad') + + expect(result).toBeNull() + expect(store.error).toBe('Not found') + }) + }) +}) diff --git a/vigilcare-records-web/src/__tests__/stores/liveCapture.test.ts b/vigilcare-records-web/src/__tests__/stores/liveCapture.test.ts new file mode 100644 index 0000000..d591607 --- /dev/null +++ b/vigilcare-records-web/src/__tests__/stores/liveCapture.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useLiveCaptureStore } from '@/stores/liveCapture' + +vi.mock('@/api/client', () => ({ + post: vi.fn(), +})) + +import { post } from '@/api/client' + +const mockedPost = vi.mocked(post) + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() +}) + +describe('useLiveCaptureStore', () => { + describe('initial state', () => { + it('has expected defaults', () => { + const store = useLiveCaptureStore() + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + expect(store.lastResult).toBeNull() + }) + }) + + describe('recordObservations', () => { + const observations = [ + { observationCode: 'HEART_RATE', value: 72, unit: 'bpm', recordedAt: '2026-06-27T10:00:00Z', note: '' }, + ] + + it('returns result on success', async () => { + const responseData = { + batchId: 'b1', + encounterId: 'enc-1', + observations: [ + { + draftObservationId: 'do1', + liveObservationId: 'lo1', + observationCode: 'HEART_RATE', + value: 72, + unit: 'bpm', + recordedAt: '2026-06-27T10:00:00Z', + criticalAlert: null, + }, + ], + criticalAlertCount: 0, + promotedAt: '2026-06-27T10:00:01Z', + } + mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: responseData, error: null }) + + const store = useLiveCaptureStore() + const result = await store.recordObservations('enc-1', observations, true, 'password') + + expect(result).toEqual(responseData) + expect(store.lastResult).toEqual(responseData) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + + it('throws and sets error on failure response', async () => { + mockedPost.mockResolvedValueOnce({ + success: false, + statusCode: 400, + data: null, + error: { message: 'Attestation required', code: 'VALIDATION' }, + }) + + const store = useLiveCaptureStore() + await expect( + store.recordObservations('enc-1', observations, false, 'password'), + ).rejects.toThrow('Attestation required') + + expect(store.error).toBe('Attestation required') + expect(store.lastResult).toBeNull() + }) + + it('throws on network error', async () => { + mockedPost.mockRejectedValueOnce(new Error('Network error')) + + const store = useLiveCaptureStore() + await expect( + store.recordObservations('enc-1', observations, true, 'password'), + ).rejects.toThrow('Network error') + + expect(store.error).toBe('Network error') + }) + }) + + describe('openEncounterWithVitals', () => { + const observations = [ + { observationCode: 'TEMP_C', value: 37.2, unit: 'C', recordedAt: '2026-06-27T10:00:00Z', note: '' }, + ] + + it('returns result on success', async () => { + const responseData = { + batchId: 'b2', + encounterId: 'enc-new', + observations: [], + criticalAlertCount: 0, + promotedAt: '2026-06-27T10:00:01Z', + } + mockedPost.mockResolvedValueOnce({ success: true, statusCode: 201, data: responseData, error: null }) + + const store = useLiveCaptureStore() + const result = await store.openEncounterWithVitals( + 'patient-1', 'ICU', '3A-12', 'Chest pain', observations, true, 'password', + ) + + expect(result).toEqual(responseData) + expect(mockedPost).toHaveBeenCalledWith('live-capture/encounters', { + patientId: 'patient-1', + department: 'ICU', + roomBed: '3A-12', + admissionReason: 'Chest pain', + observations: [ + { observationCode: 'TEMP_C', value: 37.2, unit: 'C', recordedAt: '2026-06-27T10:00:00Z', note: null }, + ], + clinicianAttestation: true, + passwordConfirm: 'password', + }) + }) + + it('sends null for empty roomBed', async () => { + mockedPost.mockResolvedValueOnce({ + success: true, + statusCode: 201, + data: { batchId: 'b', encounterId: 'e', observations: [], criticalAlertCount: 0, promotedAt: '' }, + error: null, + }) + + const store = useLiveCaptureStore() + await store.openEncounterWithVitals( + 'p1', 'ED', '', 'Fall', observations, true, 'pass', + ) + + expect(mockedPost).toHaveBeenCalledWith( + 'live-capture/encounters', + expect.objectContaining({ roomBed: null }), + ) + }) + }) + + describe('reset', () => { + it('clears lastResult and error', () => { + const store = useLiveCaptureStore() + store.error = 'some error' + store.lastResult = { batchId: 'b', encounterId: 'e', observations: [], criticalAlertCount: 0, promotedAt: '' } + + store.reset() + + expect(store.lastResult).toBeNull() + expect(store.error).toBeNull() + }) + }) +}) diff --git a/vigilcare-records-web/src/__tests__/views/CoverSheetView.test.ts b/vigilcare-records-web/src/__tests__/views/CoverSheetView.test.ts new file mode 100644 index 0000000..77e79a0 --- /dev/null +++ b/vigilcare-records-web/src/__tests__/views/CoverSheetView.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import CoverSheetView from '@/views/CoverSheetView.vue' + +vi.mock('@/api/client', () => ({ + get: vi.fn(), + post: vi.fn(), + postBlob: vi.fn(), +})) + +vi.mock('@/composables/useToast', () => ({ + useToast: () => ({ + success: vi.fn(), + error: vi.fn(), + }), +})) + +vi.mock('@/components/AppHeader.vue', () => ({ + default: { template: '
' }, +})) + +vi.mock('@/components/PatientSearch.vue', () => ({ + default: { + props: ['modelValue'], + emits: ['update:modelValue'], + template: '', + }, +})) + +import { get, post } from '@/api/client' + +const mockedGet = vi.mocked(get) +const mockedPost = vi.mocked(post) + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + + mockedGet.mockImplementation(async (url: string) => { + if (url === 'users') { + return { + success: true, + statusCode: 200, + data: [{ id: 'clerk-1', username: 'entry1', fullName: 'Entry Clerk', role: 'DATA_ENTRY_CLERK' }], + error: null, + } + } + if (url === 'cover-sheets') { + return { + success: true, + statusCode: 200, + data: [ + { + id: 'cs-1', + code: 'VCR-CS-AABBCCDD', + batchType: 'VITALS_SHEET', + track: 'BACKFILL', + patientId: null, + patientName: null, + patientMrn: null, + assignToUserId: null, + assignToUserName: null, + isUsed: false, + batchId: null, + createdAt: '2026-06-27T12:00:00Z', + usedAt: null, + }, + ], + error: null, + } + } + return { success: true, statusCode: 200, data: [], error: null } + }) +}) + +describe('CoverSheetView', () => { + it('renders generate form and cover sheet list', async () => { + const wrapper = mount(CoverSheetView) + await flushPromises() + + expect(wrapper.text()).toContain('Generate Cover Sheets') + expect(wrapper.text()).toContain('Cover Sheet List') + expect(wrapper.text()).toContain('VCR-CS-AABBCCDD') + expect(wrapper.find('select').exists()).toBe(true) + }) + + it('loads entry clerks and cover sheets on mount', async () => { + mount(CoverSheetView) + await flushPromises() + + expect(mockedGet).toHaveBeenCalledWith('users', { role: 'DATA_ENTRY_CLERK' }) + expect(mockedGet).toHaveBeenCalledWith('cover-sheets', expect.objectContaining({ + page: 1, + pageSize: 20, + })) + }) + + it('submits generate request with form values', async () => { + mockedPost.mockResolvedValueOnce({ + success: true, + statusCode: 201, + data: [{ id: 'new-cs-1', code: 'VCR-CS-NEW12345' }], + error: null, + }) + + const wrapper = mount(CoverSheetView) + await flushPromises() + + const batchTypeSelect = wrapper.findAll('select')[0] + await batchTypeSelect.setValue('VITALS_SHEET') + + const countInput = wrapper.find('input[type="number"]') + await countInput.setValue(5) + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(mockedPost).toHaveBeenCalledWith('cover-sheets/generate', { + count: 5, + batchType: 'VITALS_SHEET', + track: 'BACKFILL', + }) + }) + + it('shows Print Cover Sheets after successful generation', async () => { + mockedPost.mockResolvedValueOnce({ + success: true, + statusCode: 201, + data: [ + { id: 'new-cs-1', code: 'VCR-CS-NEW12345' }, + { id: 'new-cs-2', code: 'VCR-CS-NEW67890' }, + ], + error: null, + }) + + const wrapper = mount(CoverSheetView) + await flushPromises() + + await wrapper.findAll('select')[0].setValue('LAB_RESULTS') + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(wrapper.text()).toContain('Print Cover Sheets') + }) +}) diff --git a/vigilcare-records-web/src/__tests__/views/IntakeView.test.ts b/vigilcare-records-web/src/__tests__/views/IntakeView.test.ts new file mode 100644 index 0000000..6d06902 --- /dev/null +++ b/vigilcare-records-web/src/__tests__/views/IntakeView.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import IntakeView from '@/views/IntakeView.vue' + +vi.mock('@/api/client', () => ({ + get: vi.fn(), +})) + +vi.mock('@/composables/useToast', () => ({ + useToast: () => ({ + success: vi.fn(), + error: vi.fn(), + }), +})) + +vi.mock('@/components/AppHeader.vue', () => ({ + default: { template: '
' }, +})) + +vi.mock('@/components/PatientSearch.vue', () => ({ + default: { + props: ['modelValue'], + emits: ['update:modelValue'], + template: '', + }, +})) + +vi.mock('@/components/BatchList.vue', () => ({ + default: { + props: ['batches', 'loading', 'showAssign'], + emits: ['assign', 'select'], + template: '
', + }, +})) + +vi.mock('@/components/AssignClerkDialog.vue', () => ({ + default: { + props: ['show', 'batchId'], + emits: ['close', 'assigned'], + template: '
', + }, +})) + +const uploadBatchMock = vi.fn() + +vi.mock('@/stores/batches', () => ({ + useBatchStore: () => ({ + batches: [], + loading: false, + listBatches: vi.fn(), + uploadBatch: uploadBatchMock, + assignBatch: vi.fn(), + }), +})) + +vi.mock('vue-router', () => ({ + useRoute: () => ({ query: {} }), +})) + +import { get } from '@/api/client' + +const mockedGet = vi.mocked(get) + +const coverSheet = { + id: 'cs-1', + code: 'VCR-CS-A3F7B2D1', + batchType: 'VITALS_SHEET', + track: 'BACKFILL', + patientId: 'patient-1', + patientName: 'Maria Garcia', + patientMrn: 'MRN-001', + assignToUserId: null, + assignToUserName: null, + isUsed: false, + batchId: null, + createdAt: '2026-06-27T12:00:00Z', + usedAt: null, +} + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + uploadBatchMock.mockResolvedValue({ id: 'batch-1', status: 'UPLOADED' }) +}) + +describe('IntakeView cover sheet upload', () => { + it('renders barcode input with autofocus and lookup button', () => { + const wrapper = mount(IntakeView) + const input = wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]') + + expect(wrapper.text()).toContain('Quick Upload with Cover Sheet') + expect(input.exists()).toBe(true) + expect(input.attributes('autofocus')).toBeDefined() + expect(wrapper.find('button.btn-secondary').text()).toBe('Lookup') + }) + + it('looks up cover sheet on Enter and auto-fills form fields', async () => { + mockedGet.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: coverSheet, + error: null, + }) + + const wrapper = mount(IntakeView) + const input = wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]') + + await input.setValue('VCR-CS-A3F7B2D1') + await input.trigger('keydown.enter') + await flushPromises() + + expect(mockedGet).toHaveBeenCalledWith('cover-sheets/lookup/VCR-CS-A3F7B2D1') + expect(wrapper.text()).toContain('Cover Sheet Found') + expect(wrapper.text()).toContain('Maria Garcia') + expect((wrapper.findAll('select')[0].element as HTMLSelectElement).value).toBe('VITALS_SHEET') + expect((wrapper.findAll('select')[1].element as HTMLSelectElement).value).toBe('BACKFILL') + }) + + it('uploads with coverSheetCode when cover sheet is resolved', async () => { + mockedGet.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: coverSheet, + error: null, + }) + + const wrapper = mount(IntakeView) + + await wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]').setValue('VCR-CS-A3F7B2D1') + await wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]').trigger('keydown.enter') + await flushPromises() + + const file = new File(['pdf'], 'scan.pdf', { type: 'application/pdf' }) + const fileInput = wrapper.find('input[type="file"]') + Object.defineProperty(fileInput.element, 'files', { value: [file] }) + await fileInput.trigger('change') + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(uploadBatchMock).toHaveBeenCalledWith( + file, + 'VITALS_SHEET', + 'BACKFILL', + 'patient-1', + undefined, + 'VCR-CS-A3F7B2D1', + ) + expect(wrapper.text()).toContain('Upload with Cover Sheet') + }) + + it('keeps manual upload available without a cover sheet', async () => { + const wrapper = mount(IntakeView) + + expect(wrapper.text()).toContain('New Batch') + expect(wrapper.text()).toContain('Upload and Create Batch') + + const file = new File(['pdf'], 'scan.pdf', { type: 'application/pdf' }) + const fileInput = wrapper.find('input[type="file"]') + Object.defineProperty(fileInput.element, 'files', { value: [file] }) + await fileInput.trigger('change') + await wrapper.findAll('select')[0].setValue('LAB_RESULTS') + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(uploadBatchMock).toHaveBeenCalledWith( + file, + 'LAB_RESULTS', + 'BACKFILL', + undefined, + undefined, + undefined, + ) + }) + + it('shows lookup error for unknown cover sheet', async () => { + mockedGet.mockResolvedValueOnce({ + success: false, + statusCode: 404, + data: null, + error: { message: 'Cover sheet not found.', code: 'COVER_SHEET_NOT_FOUND' }, + }) + + const wrapper = mount(IntakeView) + await wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]').setValue('VCR-CS-DEADBEEF') + await wrapper.find('button.btn-secondary').trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('Cover sheet not found.') + }) +}) diff --git a/vigilcare-records-web/src/api/client.ts b/vigilcare-records-web/src/api/client.ts index 7965715..351d8ea 100644 --- a/vigilcare-records-web/src/api/client.ts +++ b/vigilcare-records-web/src/api/client.ts @@ -174,4 +174,13 @@ export async function uploadFile( return response.data } +/** POST JSON and receive a binary response (e.g. cover sheet PDF). */ +export async function postBlob(url: string, data?: unknown): Promise { + const response = await apiClient.post(url, data, { + responseType: 'blob', + timeout: 60000, + }) + return response.data as Blob +} + export default apiClient \ No newline at end of file diff --git a/vigilcare-records-web/src/assets/main.css b/vigilcare-records-web/src/assets/main.css index 512ee48..4080509 100644 --- a/vigilcare-records-web/src/assets/main.css +++ b/vigilcare-records-web/src/assets/main.css @@ -8,6 +8,11 @@ hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors; } + .btn-secondary { + @apply bg-white text-primary-700 border border-primary-300 px-4 py-2 rounded-md + hover:bg-primary-50 disabled:opacity-50 disabled:cursor-not-allowed + transition-colors; + } .btn-danger { @apply bg-clinical-danger text-white px-4 py-2 rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed diff --git a/vigilcare-records-web/src/components/AppHeader.vue b/vigilcare-records-web/src/components/AppHeader.vue index 89cfda3..2cd25c1 100644 --- a/vigilcare-records-web/src/components/AppHeader.vue +++ b/vigilcare-records-web/src/components/AppHeader.vue @@ -4,6 +4,7 @@

{{ title }}