Files
vigilcare-records/docs/vigilcare-records-gap-analysis.md
T

50 KiB

VigilCare Records Platform — Gap Analysis

Comprehensive gap analysis of the VigilCare Records digitization system covering data integrity, infrastructure reliability, security posture, API completeness, Vue frontend coverage, observability, and test coverage. Analysis compares the current implementation against the PRD and identifies issues ordered by impact on data correctness and clinical safety first, then operational reliability, then feature completeness, then observability and polish.

Each item includes why it matters and how to fix it at an implementation-ready level.


Priority legend

Tier Meaning
P0 Data integrity or correctness bug; fix before expanding production usage
P1 Silent misconfiguration, transaction safety hole, or data loss risk; fix soon after P0
P2 Blocks common workflows or degrades operational reliability
P3 Security hardening, compliance, or defense-in-depth; no immediate patient-safety impact
P4 API/UI completeness, consistency, and developer/operator experience
P5 Observability and test coverage; does not change outcomes but makes incidents diagnosable

Part A — Data Integrity & Correctness


P0 — PromoteAsync (retry path) does not create clinical entities

Problem

PromotionService has two code paths for promotion:

  1. ApproveAndPromoteAsync (called by POST .../approve) — creates clinical Patient, Encounter, Observation entities plus LiveEncounter, LiveObservation, and OutboxEvent entries. This is the happy path.
  2. PromoteAsync (called by POST .../promote and PromotionRetryService) — creates only LiveEncounter and LiveObservation entries. It does not create clinical Patient, Encounter, or Observation records, and does not write OutboxEvent entries.

When initial promotion fails and the batch is deferred to APPROVED status (202 response), the PromotionRetryService retries via PromoteAsync. On success, the batch is marked PROMOTED but the clinical tables that VigilCareClinical depends on are never populated. The batch appears promoted, but observations are invisible to the downstream alert pipeline, ward dashboard, and scoring consumers.

Why fix

A successfully retried promotion that fails to populate clinical tables defeats the entire purpose of the digitization pipeline. Observations from deferred promotions never reach VigilCareClinical's alert engine — critical values entered during a live capture that was deferred due to transient infrastructure failure will silently disappear from clinical monitoring.

How to fix

  1. Unify the promotion logic: PromoteAsync should call the same core promotion method as ApproveAndPromoteAsync, with the only difference being that approval-specific validation (status check for Verified/AwaitingClinicalApproval, separation-of-duties) is already done.
  2. Extract a shared ExecutePromotionAsync(batch, actorUserId, enableRetroactiveAlerts) method that creates Patient, Encounter, Observation, LiveEncounter, LiveObservation, and OutboxEvent in a single transaction.
  3. Both ApproveAndPromoteAsync and PromoteAsync should call this shared method.
  4. Ensure the batch's EnableRetroactiveAlerts flag (set during DeferPromotionAsync) is read and passed to the shared method on retry.
  5. Add an integration test: defer promotion (simulate infra failure) → retry succeeds → verify clinical Observation rows exist.

Files: PromotionService.cs:529-658 (PromoteAsync), PromotionService.cs:24-220 (ApproveAndPromoteAsync).

Dependency: None.


P0 — Patient deduplication by exact name + DOB is fragile

Problem

PromotionService.CreateOrUpdatePatientAsync matches existing patients by exact FullName string equality and DateOfBirth. Two batches for the same physical patient with different name representations ("Maria Santos" vs "MARIA SANTOS" vs "Maria R. Santos" vs "Santos, Maria") create duplicate patient records with distinct MRNs.

Why fix

MRN is the primary patient identifier. Duplicate patients split their clinical history across multiple MRNs — observations, encounters, and alerts for the same person appear under different identities. This fragments the clinical picture and can lead to missed critical trend alerts (e.g., three potassium readings spread across two patient records don't trigger a trend).

How to fix

  1. Normalize name comparison: case-insensitive, whitespace-trimmed comparison as minimum. Consider ToUpperInvariant().Trim() normalization.
  2. Add fuzzy matching warning: if no exact match but a close match exists (e.g., Levenshtein distance < 3 on normalized name + exact DOB match), log a warning and return the match with a flag in the promotion result indicating a fuzzy match was used.
  3. Add a patient merge endpoint (future): POST /api/v1/patients/{targetId}/merge/{sourceId} for administrator-driven deduplication after the fact.
  4. Short term: at minimum, normalize the comparison to case-insensitive with trimming.

Files: PromotionService.cs:266-295 (CreateOrUpdatePatientAsync).

Dependency: None.


P1 — Batch assignment creates inconsistent interim state

Problem

BatchService.AssignAsync sets EnteredByUserId on a batch in UPLOADED status and acquires a Redis lock, but does not transition the batch to IN_ENTRY status. The status transition only happens later when DraftService processes the first draft save. Between assignment and first save, the batch is in UPLOADED status with an assigned user — a state not represented in the PRD's status machine.

If the Redis lock expires (1-hour TTL) before the clerk saves any draft, another assignment call could succeed but the original EnteredByUserId is already set on the batch row. The second AssignAsync would set a new EnteredByUserId without clearing the Redis lock from the first assignment (the lock already expired).

Why fix

The entry work queue (GET /api/v1/work-queue/entry) queries for batches in UPLOADED, IN_ENTRY, and REJECTED statuses. A batch in UPLOADED status with EnteredByUserId set appears in the entry queue as if it's being worked on, but the status suggests it hasn't been started. Supervisors cannot distinguish between "assigned but not started" and "not yet assigned" from the status alone.

How to fix

  1. Transition to IN_ENTRY during assignment: AssignAsync should set batch.Status = BatchStatus.InEntry immediately after acquiring the Redis lock and setting EnteredByUserId.
  2. Remove the implicit transition in DraftService: DraftService currently transitions UPLOADED → IN_ENTRY on first save. Since assignment now does this, the DraftService check becomes redundant (keep the IN_ENTRY status check as a guard).
  3. Handle Redis lock expiry: add a PATCH .../reassign endpoint (or extend AssignAsync for IN_ENTRY batches) that clears the old Redis lock, acquires a new one, updates EnteredByUserId, and writes a DigitizationEvent for reassignment.
  4. Validate that the work queue entry view only shows IN_ENTRY and REJECTED batches for the entry clerk, not UPLOADED.

Files: BatchService.cs:192-229 (AssignAsync), DraftService.cs (first-save transition logic).

Dependency: None.


P1 — No duplicate detection on concurrent batch creation

Problem

BatchService.CreateAsync checks for duplicate documents by querying DocumentSha256 for the same patient within 24 hours. However, two concurrent upload requests with the same file for the same patient can both pass the AnyAsync check before either commits. No database-level unique constraint on (DocumentSha256, PatientId) with a time window exists.

Why fix

FHIR integration engines or intake automation scripts submitting the same scan file simultaneously could create duplicate batches. While not a clinical safety issue (duplicate entry would be caught at verification), it wastes verifier time and clutters the work queue.

How to fix

  1. Add a database-level unique partial index: CREATE UNIQUE INDEX IX_batch_sha256_patient_24h ON digitization_batches (document_sha256, patient_id) WHERE created_at >= NOW() - INTERVAL '24 hours' — note: PostgreSQL partial indexes with volatile expressions are not directly supported; instead, use an advisory lock or a dedicated deduplication table.
  2. Alternative: use a Redis SET NX with key batch:dedup:{sha256}:{patientId} and 24-hour TTL as a first-line check. The database AnyAsync remains as a fallback.
  3. Wrap the upload + batch insert in a serializable transaction scope for the dedup check.

Files: BatchService.cs:71-85 (duplicate detection), DigitizationBatchConfiguration.cs (add index).

Dependency: None.


Part B — Infrastructure & Reliability


P2 — No health check endpoints

Problem

The API has no /health, /ready, or /startup endpoints. There are no health checks for PostgreSQL, Redis, or MinIO connectivity.

Why fix

Container orchestrators (Docker Compose health checks, Kubernetes probes) cannot detect unhealthy instances. If PostgreSQL or Redis becomes unreachable after startup, the API continues accepting requests that will fail with 500 errors. Load balancers cannot route traffic away from degraded instances.

How to fix

  1. Add Microsoft.Extensions.Diagnostics.HealthChecks and provider packages:
    • AspNetCore.HealthChecks.NpgSql (PostgreSQL)
    • AspNetCore.HealthChecks.Redis (Redis)
  2. Create a custom MinioHealthCheck that calls BucketExistsAsync.
  3. Register health checks in Program.cs with tags: startup, liveness, readiness.
  4. Map endpoints:
    • GET /health/live — is the process alive?
    • GET /health/ready — are PostgreSQL, Redis, and MinIO reachable?
    • GET /health/startup — has migration and seed completed?
  5. Add health check responses to Prometheus via AspNetCore.HealthChecks.Publisher.Prometheus.
  6. Update docker-compose.yml with health check configuration on the API service.

Files: Program.cs, VigilCareRecordsAPI.csproj, optional MinioHealthCheck.cs.

Dependency: None.


P2 — No CORS configuration for production deployment

Problem

The API has no CORS configuration. The Vue frontend works in development via Vite's proxy (/api → localhost:5217), but in production where the frontend is served from a different origin (e.g., https://records.vigilcare.local vs https://api.vigilcare.local), API requests will be blocked by the browser's same-origin policy.

Why fix

Without CORS, the digitization workstation cannot call the API in any deployment topology where the frontend is served from a different domain, port, or protocol than the API. This blocks every non-dev deployment.

How to fix

  1. Add CORS configuration to Program.cs:
    builder.Services.AddCors(options =>
    {
        options.AddPolicy("VigilCare", policy =>
        {
            policy.WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>()!)
                  .AllowAnyHeader()
                  .AllowAnyMethod()
                  .AllowCredentials();
        });
    });
    
  2. Add app.UseCors("VigilCare") before app.UseAuthentication().
  3. Add Cors:AllowedOrigins to appsettings.json (default: ["http://localhost:3028"] for dev).
  4. Document production CORS configuration in README.

Files: Program.cs, appsettings.json.

Dependency: None.


P2 — Redis connection failure crashes startup

Problem

Program.cs calls ConnectionMultiplexer.Connect(...) synchronously during DI registration. If Redis is unreachable at startup, this throws an unhandled RedisConnectionException that crashes the application. There is no retry logic.

Why fix

Redis is used for batch assignment locks and alert threshold caching — important but not essential for core API functionality. A transient Redis outage during deployment or container restart should not prevent the entire API from starting. The batch assignment lock is a convenience feature; the separation-of-duties enforcement at the service layer is the true guard.

How to fix

  1. Replace synchronous connection with lazy initialization:
    builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
    {
        var config = ConfigurationOptions.Parse(
            sp.GetRequiredService<IConfiguration>()["Redis:ConnectionString"]!);
        config.AbortOnConnectFail = false;
        return ConnectionMultiplexer.Connect(config);
    });
    
  2. Setting AbortOnConnectFail = false allows the multiplexer to be created even if Redis is unreachable — operations will fail gracefully until Redis recovers.
  3. In BatchService.AssignAsync, catch RedisConnectionException and log a warning; optionally fall back to a database-level advisory lock.
  4. In LiveCaptureService, handle Redis cache miss for thresholds (already falls back to database query).

Files: Program.cs:26-27, BatchService.cs:204.

Dependency: None.


P2 — PromotionRetryService does not record failure metrics

Problem

PromotionRetryService logs errors on retry failure but does not increment any Prometheus counter. There is no metric for retry attempts, retry successes, retry failures, or retries exhausted. The existing DiagnosticsMetrics class does not have any retry-related metrics.

Why fix

Operators monitoring Prometheus/Grafana have no visibility into whether deferred promotions are succeeding or failing on retry. A sustained promotion failure (e.g., due to a configuration issue) would only be visible in Seq logs, not in dashboards or alerts. This delays incident detection.

How to fix

  1. Add metrics to DiagnosticsMetrics:
    public static readonly Counter PromotionRetryTotal = Metrics.CreateCounter(
        "digitization_promotion_retry_total",
        "Total promotion retry attempts.",
        new CounterConfiguration { LabelNames = new[] { "outcome" } }); // success, failure, exhausted
    
  2. In PromotionRetryService.RetryPromotionAsync:
    • On success: DiagnosticsMetrics.PromotionRetryTotal.WithLabels("success").Inc().
    • On failure: DiagnosticsMetrics.PromotionRetryTotal.WithLabels("failure").Inc().
    • On exhausted: DiagnosticsMetrics.PromotionRetryTotal.WithLabels("exhausted").Inc().
  3. Add a gauge for pending retry count: digitization_promotion_pending_retries (update in MetricsCollectorService).

Files: DiagnosticsMetrics.cs, PromotionRetryService.cs:117-175, MetricsCollectorService.cs.

Dependency: None.


Part C — Security & Hardening


P3 — JWT signing key not validated on startup

Problem

Program.cs reads JwtOptions.Secret from configuration and creates a SymmetricSecurityKey without validating minimum length. A short or empty key causes a runtime SecurityTokenInvalidSignatureException on the first authentication attempt, not at startup. The dev default key (VigilCareRecordsDevSecretKeyAtLeast32Chars!) is 43 characters, which is sufficient, but nothing prevents a production deployment from using a shorter key.

Why fix

Fail-fast on misconfiguration prevents deploying a service that silently rejects all authentication. In production, a weak key means all JWTs can be forged by an attacker who brute-forces the HMAC.

How to fix

  1. Add a startup validation check in Program.cs after binding JwtOptions:
    if (string.IsNullOrEmpty(jwtOptions.Secret) ||
        Encoding.UTF8.GetByteCount(jwtOptions.Secret) < 32)
        throw new InvalidOperationException(
            "JWT Secret must be at least 256 bits (32 bytes).");
    
  2. Optionally implement IValidateOptions<JwtOptions> for structured validation.

Files: Program.cs:45-58, optionally JwtOptions.cs.

Dependency: None.


P3 — No rate limiting on authentication endpoints

Problem

The POST /api/v1/auth/login endpoint has no rate limiting. An attacker can attempt unlimited password guesses against known usernames (seeded users have predictable usernames: intake1, entry1, verifier1, etc.). There is no account lockout mechanism after failed attempts.

Why fix

The API handles PHI (Protected Health Information). Brute-force attacks on auth endpoints are a standard OWASP risk. With all seeded users sharing the password password, a single guess compromises any account.

How to fix

  1. Rate limiting: Add AspNetCoreRateLimit or the built-in .NET 7+ AddRateLimiter():
    builder.Services.AddRateLimiter(options =>
    {
        options.AddFixedWindowLimiter("auth", opt =>
        {
            opt.Window = TimeSpan.FromMinutes(5);
            opt.PermitLimit = 10;
            opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        });
    });
    
  2. Apply [EnableRateLimiting("auth")] to login and refresh endpoints.
  3. Account lockout: Add FailedLoginAttempts and LockedUntil columns to User. After 5 failed attempts in 15 minutes, lock the account for 30 minutes.
  4. Return 429 Too Many Requests on rate limit, 423 Locked on account lockout.

Files: Program.cs, AuthController.cs, AuthService.cs, User.cs, migration.

Dependency: None.


P3 — Credentials stored in plaintext in appsettings.json

Problem

appsettings.json contains plaintext credentials committed to source control:

  • JWT signing key: VigilCareRecordsDevSecretKeyAtLeast32Chars!
  • PostgreSQL password: password
  • MinIO access/secret keys: minioadmin/minioadmin
  • Redis connection string (no auth)

Why fix

Anyone with repository access can extract production credentials if the same configuration pattern is used in deployment. Even for development, committed secrets create a precedent that normalizes insecure practices.

How to fix

  1. Move all secrets to environment variables or a secrets manager.
  2. Add appsettings.Development.json with dev-only defaults (already gitignored by convention).
  3. Add appsettings.Production.json.example documenting required secrets.
  4. Document environment variable overrides:
    • ConnectionStrings__DefaultConnection
    • Jwt__Secret
    • Minio__AccessKey, Minio__SecretKey
  5. Add a .env.example file for docker-compose that documents required secrets without values.
  6. Consider Azure Key Vault or HashiCorp Vault integration for production.

Files: appsettings.json, new .env.example, docker-compose.yml.

Dependency: None.


P3 — No audit of document access

Problem

The PRD explicitly requires logging "who viewed a scan and when" as an audit requirement (Section 9: Authentication and Audit). The current implementation generates presigned URLs in BatchService.GetByIdAsync and DocumentStorageService.GetPresignedUrlAsync without recording who requested the URL or when.

Why fix

HIPAA and clinical compliance audits require demonstrating that access to PHI (including scanned patient documents) is logged. Without document access logging, there is no evidence of who viewed which patient's scan, making the system non-compliant with the PRD's stated audit requirements.

How to fix

  1. Add a DigitizationEventType.DocumentAccessed enum value.
  2. In DigitizationBatchesController.GetById, after generating the presigned URL, write a DigitizationEvent:
    _db.DigitizationEvents.Add(new DigitizationEvent
    {
        Id = Guid.NewGuid(),
        BatchId = id,
        EventType = DigitizationEventType.DocumentAccessed,
        ActorUserId = userId,
        OccurredAt = DateTimeOffset.UtcNow,
        MetadataJson = JsonSerializer.Serialize(new { expiresAt = presignedUrlExpiry })
    });
    
  3. Consider rate-limiting audit writes: if the same user accessed the same batch within the last 5 minutes, skip the duplicate event (prevents audit noise during entry/verification when the page auto-refreshes the URL).

Files: DigitizationBatchesController.cs, DigitizationEventType.cs, migration (enum update).

Dependency: None.


Part D — API Completeness & Consistency


P2 — No FluentValidation for request DTOs

Problem

The API relies entirely on inline validation in service methods and controllers. There are no FluentValidation validators for any of the 15+ request DTOs (CreateBatchRequest, UpsertDraftPatientRequest, UpsertDraftEncounterRequest, CreateDraftObservationRequest, VerifyBatchRequest, RejectBatchRequest, ApproveRequest, RecordObservationsRequest, OpenEncounterWithVitalsRequest, LoginRequest, etc.).

Invalid request bodies reach the service layer before being rejected, which means:

  • Missing required fields cause NullReferenceException instead of 422 responses.
  • String length violations hit database constraints instead of validation errors.
  • Validation error messages are inconsistent across endpoints.

Why fix

Consistent request validation is essential for integration reliability. FHIR integration engines, the Vue frontend, and CLI scripts all need predictable error shapes when submitting invalid data. Database constraint violations surfacing as 500 errors instead of 422s make debugging integration issues significantly harder.

How to fix

  1. Add FluentValidation.AspNetCore package.
  2. Create validators for all request DTOs. Priority validators:
    • LoginRequestValidator: username required, password required (min 1 char)
    • UpsertDraftPatientRequestValidator: fullName max 200, DOB not future
    • UpsertDraftEncounterRequestValidator: admissionDate not future, department valid enum
    • CreateDraftObservationRequestValidator: observationCode required, value required, recordedAt required and not future
    • VerifyBatchRequestValidator: fieldChecks non-empty, each status is valid
    • RejectBatchRequestValidator: reason required, min 10 chars (already enforced in service)
    • RecordObservationsRequestValidator: observations non-empty, max 10, each has code/value/unit/recordedAt
  3. Register validators with auto-discovery: builder.Services.AddValidatorsFromAssemblyContaining<Program>().
  4. Add app.UseFluentValidationExceptionHandler() or integrate with the existing ExceptionHandlerMiddleware.

Files: New Validators/ directory, Program.cs, VigilCareRecordsAPI.csproj.

Dependency: None.


P4 — No user management endpoints (create, update, deactivate)

Problem

UsersController has only GET /api/v1/users?role= to list users. There are no endpoints to create users, update user details, change passwords, or deactivate accounts. User management is only possible through the DataSeeder or direct database access.

The PRD assigns the Administrator role permission for "User management, batch type config, retroactive alert policy, work-queue reassignment."

Why fix

In a production deployment, new staff must be onboarded (new data entry clerks, verifiers) and departing staff must be deactivated. Without user management endpoints, every personnel change requires direct database access and a service restart to re-seed, which is unacceptable for a clinical system.

How to fix

  1. Add endpoints to UsersController:
    • POST /api/v1/users — create user (admin only): username, password, fullName, role
    • PATCH /api/v1/users/{id} — update user details: fullName, role, isActive
    • POST /api/v1/users/{id}/reset-password — admin password reset
    • POST /api/v1/users/{id}/change-password — self-service (current + new password)
  2. Create UserService with CreateAsync, UpdateAsync, ResetPasswordAsync, ChangePasswordAsync.
  3. Add CreateUserRequest, UpdateUserRequest, ChangePasswordRequest DTOs with FluentValidation validators.
  4. Enforce password complexity: min 8 chars, at least 1 uppercase, 1 digit.
  5. Log user management actions as AuthAuditEvent entries.

Files: UsersController.cs, new UserService.cs, new DTOs in Models/Records/User/.

Dependency: None.


P4 — No batch cancel/void operation

Problem

Once a batch is created, it can only move forward through the status machine or be rejected (which returns it to entry). There is no way to permanently cancel or void a batch that was created in error (wrong patient, wrong batch type, test upload). The status machine has no terminal state other than PROMOTED.

Why fix

Intake clerks create batches by uploading scans. Mistakes happen: wrong document scanned, wrong patient linked, test uploads during training. Without a cancel operation, these batches permanently occupy the work queue, cluttering the entry and verification views. Supervisors must use direct database access to clean up.

How to fix

  1. Add BatchStatus.Cancelled as a terminal state (alongside Promoted).
  2. Add allowed transitions: UPLOADED → CANCELLED, IN_ENTRY → CANCELLED, REJECTED → CANCELLED.
  3. Add POST /api/v1/digitization-batches/{id}/cancel — body: { "reason": "..." } — restricted to ADMINISTRATOR role.
  4. Write DigitizationEvent with EventType.Cancelled and the reason.
  5. Release Redis assignment lock on cancellation if one exists.
  6. CANCELLED batches are excluded from work queue queries.

Files: BatchService.cs (add allowed transitions), DigitizationBatchesController.cs, BatchStatus.cs, DigitizationEventType.cs, migration.

Dependency: None.


P4 — Pagination missing sortBy and sortDirection parameters

Problem

All list endpoints (GET /digitization-batches, work queue endpoints) sort by CreatedAt DESC or UpdatedAt ASC with no user-configurable sort. The supervisor dashboard cannot sort batches by status, type, patient, or age.

Why fix

Supervisors managing a work queue of 50+ batches need to sort by different criteria: oldest first for urgency, by type for batch processing, by assigned clerk for workload review. The Vue frontend's QueueDashboardView shows all batches in a flat list with no sort controls.

How to fix

  1. Add sortBy and sortDirection query parameters to GET /api/v1/digitization-batches and all work queue endpoints.
  2. Supported sort fields: createdAt, updatedAt, status, batchType, track.
  3. Default: createdAt DESC.
  4. Validate sort field against allowed list; reject unknown fields with 422.
  5. Update BatchService.ListAsync and WorkQueueService to apply dynamic ordering.

Files: BatchService.cs:167-190, WorkQueueService.cs, DigitizationBatchesController.cs, WorkQueueController.cs.

Dependency: None.


Part E — Vue Frontend Gaps


P2 — No clinical approval view

Problem

The PRD defines a distinct clinical approval step for high-stakes batch types (vitals, labs, encounter summaries, medications, mixed). The backend implements GET /api/v1/work-queue/clinical-approval and POST .../approve endpoints. However, the Vue frontend has no dedicated view for clinical approvers. The router assigns CLINICAL_APPROVER to the verification routes, meaning approvers use the same VerificationView that verifiers use.

The VerificationForm component has approve/reject buttons but calls verifyBatch() (which posts to POST .../verify), not approveBatch() (which posts to POST .../approve with an Idempotency-Key). There is no UI for the AWAITING_CLINICAL_APPROVAL queue.

Why fix

Clinical approvers (typically physicians) have a different workflow from verifiers. They are not comparing data entry against a scan — they are authorizing promotion of clinical data into the live system. Conflating the two roles in one view means clinical approval batches either sit in the verification queue unnoticed or are processed using the wrong action.

How to fix

  1. Add ApprovalView.vue at /approval route, accessible to CLINICAL_APPROVER and ADMINISTRATOR.
  2. The view loads batches from GET /api/v1/work-queue/clinical-approval.
  3. Each batch shows: patient summary, encounter context, observations (read-only), verification pass details, verifier name.
  4. Approve button calls POST .../approve with a generated Idempotency-Key header.
  5. Reject button calls POST .../reject with reason.
  6. Show promotion result (patient MRN, encounter ID, observation count) on success.
  7. Handle 202 PROMOTION_DEFERRED — show a banner: "Approved. Promotion will be retried automatically."
  8. Add the route to the router with meta.roles: ['CLINICAL_APPROVER', 'ADMINISTRATOR'].
  9. Update AppHeader.vue navigation to include the approval link.

Files: New views/ApprovalView.vue, router/index.ts, stores/batches.ts (add approveBatch action), components/AppHeader.vue.

Dependency: None.


P2 — No live capture view for bedside data entry DONE

Problem

The PRD describes Track B live capture as a core feature: "Credentialed clinician enters vitals or labs at point of care on a tablet." The backend implements POST /api/v1/live-capture/encounters/{encounterId}/observations and POST /api/v1/live-capture/encounters with clinician attestation and synchronous critical alert evaluation.

The Vue frontend has no live capture view. The router has no /live-capture route. Clinicians (CLINICIAN role) can log in but are redirected to /login because there is no default route for their role, and no route allows CLINICIAN access.

Why fix

Live capture is the path from paper-to-digital for current patient care. Without a bedside UI, clinicians cannot use the system for real-time vital sign entry — the primary workflow that makes VigilCare actionable in a clinical setting. The backend is fully implemented but inaccessible through the frontend.

How to fix

  1. Add LiveCaptureView.vue at /live-capture, accessible to CLINICIAN and ADMINISTRATOR.
  2. The view provides:
    • Patient search (reuse PatientSearch component)
    • Active encounter selector (or create-new-encounter flow)
    • Compact vitals entry form (optimized for tablet):
      • Observation rows for standard codes (HR, Temp, BP, RR, SpO2)
      • Password re-confirmation field (PRD requires password or PIN)
      • Clinician attestation checkbox
    • Submit button that calls POST /api/v1/live-capture/encounters/{encounterId}/observations
    • Critical alert display: if the response includes synchronous alerts, show them prominently with severity, threshold details, and recommended actions
  3. Add POST /api/v1/live-capture/encounters flow for creating a new encounter with initial vitals.
  4. Optimize for tablet: large touch targets, minimal scrolling, landscape layout.
  5. Update router default route for CLINICIAN/live-capture.

Files: New views/LiveCaptureView.vue, router/index.ts, stores/batches.ts or new stores/liveCapture.ts, components/AppHeader.vue.

Dependency: None.


P4 — EntryForm missing allergy, medication, and discharge fields DONE

Problem

The EntryForm.vue component captures patient demographics (name, DOB, sex, blood type, emergency contact), encounter context (admission date, department, room/bed, admission reason), and observations. However, it does not render fields for:

  • Allergies (allergiesJson, noKnownAllergies) — required for ALLERGY_UPDATE batches
  • Medications (medicationsJson, noActiveMedications) — required for MEDICATION_LIST batches
  • Discharge diagnosis — part of the encounter context
  • Encounter status (active/discharged) — affects encounter creation during promotion

The backend DraftService validates completeness for these batch types and will reject submission with BATCH_INCOMPLETE, but the entry clerk has no way to enter the required data.

Why fix

Two of the seven batch types (ALLERGY_UPDATE and MEDICATION_LIST) cannot be completed through the UI. Entry clerks working on these batch types will be blocked at submission with a validation error they cannot resolve without backend support.

How to fix

  1. Add conditional field sections to EntryForm.vue based on batchType:
    • All types: patient demographics (existing), encounter context (existing)
    • ALLERGY_UPDATE: allergies list (add/remove items) + noKnownAllergies checkbox
    • MEDICATION_LIST: medications JSON editor (add/remove entries) + noActiveMedications checkbox
    • ENCOUNTER_SUMMARY: discharge diagnosis text field, encounter status toggle
    • MIXED: all fields visible
  2. Update saveDraftPatient to include allergiesJson and noKnownAllergies.
  3. Add a medications section that saves to the draft patient's medicationsJson field.
  4. The verification form should also render these fields for review.

Files: components/EntryForm.vue, components/VerificationForm.vue, types/index.ts.

Dependency: None.


P4 — No corrections/supersession UI

Problem

The PRD describes a correction workflow: "A correction creates a new batch with supersedesBatchId pointing to the original." The backend fully implements supersession: upload with supersedesBatchId, promotion marks original observations as superseded, and GET /patients/{id}/digitization-history shows correction chains.

The Vue frontend has no UI to:

  • Create a correction batch (upload with supersedesBatchId)
  • View which batch superseded which
  • See superseded observations vs active observations in patient history
  • Navigate the correction chain

Why fix

Corrections are a daily clinical workflow. When a promoted batch has an error (wrong potassium value, incorrect admission date), the only recourse is direct API calls or database access. Without a corrections UI, the audit trail — one of the system's core value propositions — cannot be maintained through normal operator workflow.

How to fix

  1. Add a "Create Correction" button on promoted batch detail views.
  2. The button pre-fills supersedesBatchId and patientId in the upload form.
  3. Add a PatientHistoryView.vue at /patients/{id}/history:
    • Timeline showing all batches for the patient
    • Correction chains visualized (original → correction arrows)
    • Superseded observations shown with strikethrough styling
    • Active vs superseded observation counts
  4. Add batch detail panel showing supersession info when supersedesBatchId is set.

Files: New views/PatientHistoryView.vue, components/IntakeView.vue (correction button), router/index.ts, stores/batches.ts.

Dependency: None.


P4 — No toast notification system or success feedback

Problem

The Vue frontend has no toast/snackbar notification system. All success and error feedback is either:

  • Inline errorMessage refs that render as red text below forms
  • Implicit (redirect after success with no confirmation)
  • Console errors (invisible to users)

Operations like "batch submitted for verification," "batch verified," "observation saved," and "batch rejected" provide no positive feedback to the user.

Why fix

Clinical data entry is high-stakes work. Entry clerks need immediate confirmation that their saves succeeded, verifiers need confirmation that rejections were sent, and approvers need clear feedback that promotion completed (or was deferred). Silent success breeds anxiety and repeat submissions.

How to fix

  1. Add a toast notification composable (useToast) or install vue-toastification.
  2. Show success toasts for: observation saved, draft auto-saved, batch submitted, batch verified, batch rejected, batch approved.
  3. Show error toasts for: API failures, validation errors, auth errors.
  4. Show warning toasts for: presigned URL about to expire, batch already assigned.
  5. Show info toasts for: promotion deferred (202), session refreshed.
  6. Toast position: top-right, auto-dismiss after 4 seconds.

Files: New composables/useToast.ts or install package, update all views/components.

Dependency: None.


P4 — No frontend tests

Problem

The Vue frontend has zero tests. No unit tests (Vitest), no component tests (Vue Test Utils), and no end-to-end tests (Cypress/Playwright). package.json does not include any test dependencies or scripts.

Why fix

The frontend handles critical clinical workflows: data entry, verification, approval. Untested UI components mean regressions in form validation, API calls, auth flow, and role-based routing go undetected until a user encounters them in production.

How to fix

  1. Add Vitest + Vue Test Utils as dev dependencies.
  2. Priority unit tests:
    • Auth store: login flow, token refresh, logout, role-based permissions
    • Batch store: CRUD operations, error handling, pagination
    • Router guards: unauthenticated redirect, role-based access
  3. Priority component tests:
    • EntryForm: renders fields, saves on blur, validates observations, submits
    • VerificationForm: all-checked enables approve, reject requires reason
    • PatientSearch: debounced search, selection emits event
    • ObservationRow: renders observation codes, value validation
  4. Add a test script to package.json: "test": "vitest".
  5. Optionally add Playwright for E2E tests of the full login → entry → verify → approve flow.

Files: package.json, vitest.config.ts, new src/__tests__/ directory.

Dependency: None.


Part F — Observability & Test Coverage


P5 — No audit of field-level draft changes

Problem

The PRD requires "Who changed which draft field (field-level diff in event metadata on save)" as an audit requirement. The current DraftService saves draft data (patient, encounter, observations) but does not record which fields changed or the before/after values. The DigitizationEvent for EntryStarted is written once; subsequent saves produce no events.

Why fix

In a clinical data quality dispute ("the verifier says the temperature was entered as 38.7 but the original chart shows 37.7"), there is no way to determine whether the entry clerk made the error or whether the value was changed after initial entry. Field-level audit trail is essential for clinical accountability.

How to fix

  1. In DraftService save methods, load the existing draft before applying updates.
  2. Compare each field; build a fieldsChanged list with { field, oldValue, newValue }.
  3. If any field changed, write a DigitizationEvent with EventType.DraftFieldUpdated and the field diff in MetadataJson.
  4. Debounce at the service level: if a DraftFieldUpdated event was written for the same batch within the last 30 seconds, update the existing event's metadata rather than creating a new row (prevents audit noise from auto-save).

Files: DraftService.cs, DigitizationEventType.cs (add DraftFieldUpdated).

Dependency: None.


P5 — Integration test gaps for deferred promotion and retry

Problem

The test suite has 45 tests across 5 files: DraftEntryTests (7), VerificationTests (11), PromotionTests (10), CorrectionSupersessionTests (5), LiveCaptureIntegrationTests (12). Key untested scenarios:

  1. Deferred promotion: no test for the 202 PROMOTION_DEFERRED path — when ApproveAndPromoteAsync throws an infrastructure exception, the controller should set batch to APPROVED and create a PromotionAttempt.
  2. Promotion retry: no test for PromotionRetryService picking up deferred batches and retrying.
  3. Retry exhaustion: no test for the retry limit being reached (batch stuck in APPROVED permanently).
  4. Concurrent batch creation: no test for two simultaneous uploads with the same SHA-256.
  5. Concurrent assignment: no test for two PATCH .../assign calls for the same batch.
  6. Auth flow: no tests for login, token refresh, logout, or role-based access control.
  7. Work queue ordering: no test for FIFO ordering of work queue items.

Why fix

The deferred promotion path (P0 issue above) is the most critical untested code path. Without integration tests, the fix for the PromoteAsync gap cannot be verified. Concurrent operation tests are needed to validate the Redis-based assignment lock and SHA-256 deduplication under contention.

How to fix

  1. Deferred promotion tests (validates P0 fix):
    • Simulate infrastructure failure during ApproveAndPromoteAsync → verify batch is APPROVED with PromotionAttempt → verify retry creates clinical entities.
    • Verify exhausted retries leave batch in APPROVED with no NextRetryAt.
  2. Concurrent operation tests:
    • Parallel PATCH .../assign for same batch → verify exactly one succeeds.
    • Parallel upload with same SHA-256 and patient → verify exactly one succeeds.
  3. Auth tests:
    • Login with valid/invalid credentials → verify tokens.
    • Refresh with valid/expired/revoked token → verify behavior.
    • Access protected endpoint without token → verify 401.
    • Access role-restricted endpoint with wrong role → verify 403.
  4. Work queue tests:
    • Submit 3 batches at different times → verify verification queue returns oldest first.

Files: New PromotionRetryTests.cs, ConcurrencyTests.cs, AuthTests.cs, WorkQueueTests.cs in VigilCareRecordsAPI.Tests/.

Dependency: P0 fix (PromoteAsync).


P5 — MetricsCollectorService does not track APPROVED or retry-pending batches

Problem

MetricsCollectorService collects digitization_batches_by_status gauge for all 8 statuses and digitization_queue_age_seconds for the oldest PENDING_VERIFICATION batch. However, it does not report:

  • Age of the oldest APPROVED batch (waiting for promotion retry)
  • Count of PromotionAttempt records with NextRetryAt in the past (overdue retries)
  • Count of exhausted retries (NextRetryAt IS NULL AND NOT Succeeded)

Why fix

A batch stuck in APPROVED with exhausted retries is invisible in Prometheus dashboards. Operators won't know that a patient's vitals are trapped in limbo unless they query the database directly or check Seq logs.

How to fix

  1. Add gauge: digitization_promotion_pending_retries — count of PromotionAttempt records where !Succeeded && NextRetryAt != null.
  2. Add gauge: digitization_promotion_exhausted_total — count of batches in APPROVED with all PromotionAttempt records having NextRetryAt == null.
  3. Add gauge: digitization_approval_queue_age_seconds — age of oldest APPROVED batch (mirrors queue_age_seconds pattern).
  4. Update MetricsCollectorService.CollectMetricsAsync to query these metrics.

Files: DiagnosticsMetrics.cs, MetricsCollectorService.cs.

Dependency: None.


Summary matrix

# Issue Priority Part Status
1 PromoteAsync missing clinical entities P0 A Done
2 Patient dedup by exact name+DOB P0 A Done
3 Batch assignment inconsistent state P1 A Done
4 Concurrent batch creation race P1 A Done
5 No health check endpoints P2 B Done
6 No CORS configuration P2 B Done
7 Redis failure crashes startup P2 B Done
8 Promotion retry metrics missing P2 B Done
9 JWT key not validated on startup P3 C Done
10 No rate limiting on auth P3 C Done
11 Credentials in plaintext config P3 C Open
12 No document access audit P3 C Done
13 No FluentValidation P2 D Done
14 No user management endpoints P4 D Done
15 No batch cancel/void P4 D Done
16 No sort parameters on lists P4 D Done
17 No clinical approval view P2 E Done
18 No live capture view P2 E Open
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
23 No field-level draft audit P5 F Open
24 Integration test gaps P5 F Open
25 MetricsCollector missing retry gauges P5 F Open

Suggested implementation sequence

flowchart TD
    subgraph correctness [Part A — Correctness]
        P0A[P0: Unify PromoteAsync]
        P0B[P0: Patient dedup normalization]
        P1A[P1: Assignment state transition]
        P1B[P1: Concurrent batch creation guard]
    end

    subgraph infra [Part B — Infrastructure]
        P2H[P2: Health checks]
        P2C[P2: CORS configuration]
        P2R[P2: Redis graceful startup]
        P2M[P2: Promotion retry metrics]
    end

    subgraph security [Part C — Security]
        P3J[P3: JWT key validation]
        P3R[P3: Rate limiting]
        P3S[P3: Secrets management]
        P3D[P3: Document access audit]
    end

    subgraph api [Part D — API]
        P2V[P2: FluentValidation]
        P4U[P4: User management]
        P4X[P4: Batch cancel/void]
        P4S[P4: Sort parameters]
    end

    subgraph vue [Part E — Vue Frontend]
        P2A[P2: Clinical approval view]
        P2L[P2: Live capture view]
        P4E[P4: Entry form fields]
        P4F[P4: Corrections UI]
        P4T[P4: Toast notifications]
        P4FT[P4: Frontend tests]
    end

    subgraph obs [Part F — Observability]
        P5D[P5: Field-level draft audit]
        P5T[P5: Integration test gaps]
        P5M[P5: Metrics collector gaps]
    end

    P0A --> P5T
    P2A --> P4E
    P2L --> P4T
    P2V --> P4U

Sprint-sized batches

Batch Items Outcome
1 — Correctness P0 unify PromoteAsync, P0 patient dedup normalization, P1 assignment state transition, P1 concurrent batch creation guard Promotion retry creates complete clinical records; no duplicate patients from name variation
2 — Infrastructure P2 health checks, P2 CORS, P2 Redis graceful startup, P2 promotion retry metrics Production-deployable infrastructure; orchestration-ready
3 — Security P3 JWT validation, P3 rate limiting, P3 secrets management, P3 document access audit Compliance-ready auth; HIPAA audit trail for document access
4 — API hardening P2 FluentValidation, P4 user management, P4 batch cancel, P4 sort parameters Admin UI and integration teams unblocked; consistent validation
5 — Vue frontend P2 clinical approval view, P2 live capture view, P4 entry form fields, P4 corrections UI, P4 toast notifications All PRD workflows accessible through the UI
6 — Observability P5 field-level audit, P5 integration tests, P5 metrics collector gaps, P4 frontend tests Full audit trail; regression safety net for Batch 1 fixes

Testing strategy (cross-cutting)

For each fix, add or extend tests in VigilCareRecordsAPI.Tests/:

  • Promotion tests (Batch 1): Deferred promotion → retry → verify clinical entities created; patient name normalization dedup; concurrent assignment race.
  • Infrastructure tests (Batch 2): Health check endpoints return expected status; CORS headers present on cross-origin requests; app starts with Redis down.
  • Security tests (Batch 3): Rate limit on login; JWT with short key rejected at startup; document access event written on batch detail.
  • API validation tests (Batch 4): FluentValidation returns 422 with field-level errors; user CRUD lifecycle; batch cancel transitions.
  • Frontend tests (Batch 5): Vitest component tests for EntryForm, VerificationForm, approval flow; Playwright E2E for full workflow.
  • Integration tests (Batch 6): End-to-end deferred promotion → retry → verify clinical entities; field-level audit on draft save.

Out of scope (unless explicitly requested)

  • HL7v2 ADT message support (FHIR inbound only in VigilCareClinical)
  • OCR or automated field extraction (explicitly excluded in PRD v1)
  • Multi-facility federated identity (single-tenant per deployment in v1)
  • Full EMR functionality (billing, pharmacy inventory, scheduling)
  • SMART on FHIR authorization (OAuth2 scopes for EHR launch context)
  • Offline-first PWA for intermittent connectivity (documented as future extension)
  • Kubernetes manifests, Helm charts, or CI/CD pipeline definitions
  • Grafana dashboard provisioning (infrastructure exists but dashboards are manual)

Success criteria

When complete, the system should support:

Data Integrity (Part A)

  • Deferred promotion retries create complete clinical entities (Patient, Encounter, Observation) identical to first-attempt promotion.
  • Patient matching normalizes name comparison to prevent duplicates from case/spacing variations.
  • Batch assignment transitions to IN_ENTRY immediately, with no inconsistent interim state.
  • Concurrent uploads with same SHA-256 for same patient create exactly one batch.

Infrastructure (Part B)

  • Health checks report PostgreSQL, Redis, and MinIO status; orchestrators route around failures.
  • CORS allows the Vue frontend to call the API from any configured origin.
  • Application starts successfully even when Redis is temporarily unreachable.
  • Promotion retry attempts are countable via Prometheus metrics.

Security (Part C)

  • JWT misconfiguration (short key) fails at startup, not at first request.
  • Auth endpoints are rate-limited (10 attempts per 5-minute window).
  • Secrets are managed via environment variables, not plaintext config files.
  • Document access (presigned URL generation) is logged in the audit trail.

API (Part D)

  • All request DTOs have FluentValidation validators with consistent 422 error shapes.
  • Administrators can create, update, and deactivate users via API.
  • Erroneously created batches can be cancelled by administrators.
  • List endpoints support configurable sorting.

Vue Frontend (Part E)

  • Clinical approvers have a dedicated approval view with proper promotion flow.
  • Clinicians have a bedside live capture view for real-time vital sign entry.
  • Entry clerks can complete allergy, medication, and encounter summary batch types.
  • Correction batches can be created and tracked through the UI.
  • All operations provide toast notification feedback.

Observability (Part F)

  • Draft field changes are logged with before/after values for clinical accountability.
  • Integration tests cover deferred promotion, retry, concurrent operations, and auth flow.
  • MetricsCollector tracks promotion retry status for Prometheus dashboards.