fix: No audit of field-level draft changes
This commit is contained in:
@@ -8,7 +8,7 @@ public class DigitizationEventConfiguration : IEntityTypeConfiguration<Digitizat
|
||||
builder.ToTable("digitization_events", 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')");
|
||||
"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')");
|
||||
});
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
+1461
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareRecordsAPI.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDraftAuditEventTypes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropCheckConstraint(
|
||||
name: "chk_digitization_events_event_type",
|
||||
table: "digitization_events");
|
||||
|
||||
migrationBuilder.AddCheckConstraint(
|
||||
name: "chk_digitization_events_event_type",
|
||||
table: "digitization_events",
|
||||
sql: "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')");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropCheckConstraint(
|
||||
name: "chk_digitization_events_event_type",
|
||||
table: "digitization_events");
|
||||
|
||||
migrationBuilder.AddCheckConstraint(
|
||||
name: "chk_digitization_events_event_type",
|
||||
table: "digitization_events",
|
||||
sql: "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')");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -397,7 +397,7 @@ namespace VigilCareRecordsAPI.Data.Migrations
|
||||
|
||||
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')");
|
||||
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')");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ public enum DigitizationEventType
|
||||
PromotionRetryExhausted,
|
||||
PromotionFailed,
|
||||
DocumentAccessed,
|
||||
Cancelled
|
||||
Cancelled,
|
||||
DraftFieldUpdated,
|
||||
DraftObservationDeleted
|
||||
}
|
||||
|
||||
public static class DigitizationEventTypeExtensions
|
||||
@@ -48,6 +50,8 @@ public static class DigitizationEventTypeExtensions
|
||||
DigitizationEventType.PromotionFailed => "promotion_failed",
|
||||
DigitizationEventType.DocumentAccessed => "document_accessed",
|
||||
DigitizationEventType.Cancelled => "cancelled",
|
||||
DigitizationEventType.DraftFieldUpdated => "draft_field_updated",
|
||||
DigitizationEventType.DraftObservationDeleted => "draft_observation_deleted",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
||||
};
|
||||
|
||||
@@ -74,6 +78,8 @@ public static class DigitizationEventTypeExtensions
|
||||
"promotion_failed" => DigitizationEventType.PromotionFailed,
|
||||
"document_accessed" => DigitizationEventType.DocumentAccessed,
|
||||
"cancelled" => DigitizationEventType.Cancelled,
|
||||
"draft_field_updated" => DigitizationEventType.DraftFieldUpdated,
|
||||
"draft_observation_deleted" => DigitizationEventType.DraftObservationDeleted,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown digitization event type: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -57,6 +57,13 @@ public class DraftService : IDraftService
|
||||
parsedBloodType = parsed;
|
||||
}
|
||||
|
||||
var newAllergiesJson = req.Allergies is not null
|
||||
? JsonSerializer.Serialize(req.Allergies)
|
||||
: null;
|
||||
var newMedicationsJson = req.Medications is not null
|
||||
? JsonSerializer.Serialize(req.Medications)
|
||||
: null;
|
||||
|
||||
var patient = await _db.DraftPatients.FirstOrDefaultAsync(p => p.BatchId == batchId);
|
||||
|
||||
if (patient is null)
|
||||
@@ -70,13 +77,9 @@ public class DraftService : IDraftService
|
||||
Sex = req.Sex,
|
||||
BloodType = parsedBloodType,
|
||||
EmergencyContact = req.EmergencyContact,
|
||||
AllergiesJson = req.Allergies is not null
|
||||
? JsonSerializer.Serialize(req.Allergies)
|
||||
: null,
|
||||
AllergiesJson = newAllergiesJson,
|
||||
NoKnownAllergies = req.NoKnownAllergies,
|
||||
MedicationsJson = req.Medications is not null
|
||||
? JsonSerializer.Serialize(req.Medications)
|
||||
: null,
|
||||
MedicationsJson = newMedicationsJson,
|
||||
NoActiveMedications = req.NoActiveMedications,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
@@ -85,18 +88,28 @@ public class DraftService : IDraftService
|
||||
}
|
||||
else
|
||||
{
|
||||
var diffs = new List<object>();
|
||||
DiffField(diffs, "patient.fullName", patient.FullName, req.FullName);
|
||||
DiffField(diffs, "patient.dateOfBirth", patient.DateOfBirth?.ToString("yyyy-MM-dd"), req.DateOfBirth?.ToString("yyyy-MM-dd"));
|
||||
DiffField(diffs, "patient.sex", patient.Sex, req.Sex);
|
||||
DiffField(diffs, "patient.bloodType", patient.BloodType?.ToDbString(), parsedBloodType?.ToDbString());
|
||||
DiffField(diffs, "patient.emergencyContact", patient.EmergencyContact, req.EmergencyContact);
|
||||
DiffField(diffs, "patient.allergiesJson", patient.AllergiesJson, newAllergiesJson);
|
||||
DiffField(diffs, "patient.noKnownAllergies", patient.NoKnownAllergies.ToString(), req.NoKnownAllergies.ToString());
|
||||
DiffField(diffs, "patient.medicationsJson", patient.MedicationsJson, newMedicationsJson);
|
||||
DiffField(diffs, "patient.noActiveMedications", patient.NoActiveMedications.ToString(), req.NoActiveMedications.ToString());
|
||||
|
||||
if (diffs.Count > 0)
|
||||
await WriteDraftFieldEventAsync(batchId, actorUserId, "patient", diffs);
|
||||
|
||||
patient.FullName = req.FullName;
|
||||
patient.DateOfBirth = req.DateOfBirth;
|
||||
patient.Sex = req.Sex;
|
||||
patient.BloodType = parsedBloodType;
|
||||
patient.EmergencyContact = req.EmergencyContact;
|
||||
patient.AllergiesJson = req.Allergies is not null
|
||||
? JsonSerializer.Serialize(req.Allergies)
|
||||
: null;
|
||||
patient.AllergiesJson = newAllergiesJson;
|
||||
patient.NoKnownAllergies = req.NoKnownAllergies;
|
||||
patient.MedicationsJson = req.Medications is not null
|
||||
? JsonSerializer.Serialize(req.Medications)
|
||||
: null;
|
||||
patient.MedicationsJson = newMedicationsJson;
|
||||
patient.NoActiveMedications = req.NoActiveMedications;
|
||||
patient.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -147,6 +160,17 @@ public class DraftService : IDraftService
|
||||
}
|
||||
else
|
||||
{
|
||||
var diffs = new List<object>();
|
||||
DiffField(diffs, "encounter.admissionDate", encounter.AdmissionDate?.ToString("O"), req.AdmissionDate?.ToString("O"));
|
||||
DiffField(diffs, "encounter.department", encounter.Department?.ToDbString(), parsedDepartment?.ToDbString());
|
||||
DiffField(diffs, "encounter.roomBed", encounter.RoomBed, req.RoomBed);
|
||||
DiffField(diffs, "encounter.admissionReason", encounter.AdmissionReason, req.AdmissionReason);
|
||||
DiffField(diffs, "encounter.dischargeDiagnosis", encounter.DischargeDiagnosis, req.DischargeDiagnosis);
|
||||
DiffField(diffs, "encounter.status", encounter.Status, req.Status);
|
||||
|
||||
if (diffs.Count > 0)
|
||||
await WriteDraftFieldEventAsync(batchId, actorUserId, "encounter", diffs);
|
||||
|
||||
encounter.AdmissionDate = req.AdmissionDate;
|
||||
encounter.Department = parsedDepartment;
|
||||
encounter.RoomBed = req.RoomBed;
|
||||
@@ -213,6 +237,16 @@ public class DraftService : IDraftService
|
||||
if (!PlausibilityValidator.IsPlausible(req.ObservationCode, req.Value, out var reason))
|
||||
throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE");
|
||||
|
||||
var diffs = new List<object>();
|
||||
DiffField(diffs, "observationCode", observation.ObservationCode, req.ObservationCode);
|
||||
DiffField(diffs, "value", observation.Value.ToString(), req.Value.ToString());
|
||||
DiffField(diffs, "unit", observation.Unit, req.Unit);
|
||||
DiffField(diffs, "recordedAt", observation.RecordedAt.ToString("O"), req.RecordedAt.ToString("O"));
|
||||
DiffField(diffs, "note", observation.Note, req.Note);
|
||||
|
||||
if (diffs.Count > 0)
|
||||
await WriteDraftFieldEventAsync(batchId, actorUserId, $"observation:{observationId}", diffs);
|
||||
|
||||
observation.ObservationCode = req.ObservationCode;
|
||||
observation.Value = req.Value;
|
||||
observation.Unit = req.Unit;
|
||||
@@ -240,6 +274,23 @@ public class DraftService : IDraftService
|
||||
throw new NotFoundException(
|
||||
"Observation not found in this batch.", "OBSERVATION_NOT_FOUND");
|
||||
|
||||
_db.DigitizationEvents.Add(new DigitizationEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
EventType = DigitizationEventType.DraftObservationDeleted,
|
||||
ActorUserId = actorUserId,
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
MetadataJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
observationId,
|
||||
observationCode = observation.ObservationCode,
|
||||
value = observation.Value,
|
||||
unit = observation.Unit,
|
||||
recordedAt = observation.RecordedAt
|
||||
})
|
||||
});
|
||||
|
||||
_db.DraftObservations.Remove(observation);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
@@ -538,6 +589,72 @@ public class DraftService : IDraftService
|
||||
errors.Add("Mixed batch requires at least one observation with recordedAt, or a complete encounter summary (admission date, department, admission reason)");
|
||||
}
|
||||
|
||||
// ─── Field-level audit helpers ────────────────────────────────
|
||||
|
||||
private static void DiffField(List<object> diffs, string field, string? oldValue, string? newValue)
|
||||
{
|
||||
if (string.Equals(oldValue ?? "", newValue ?? "", StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
diffs.Add(new { field, oldValue = oldValue ?? "", newValue = newValue ?? "" });
|
||||
}
|
||||
|
||||
private static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Writes a DraftFieldUpdated event. If an event of the same type was written
|
||||
/// for this batch within the last 30 seconds, merges the new fields into the
|
||||
/// existing event's metadata to prevent audit noise from auto-save.
|
||||
/// </summary>
|
||||
private async Task WriteDraftFieldEventAsync(
|
||||
Guid batchId, Guid actorUserId, string section, List<object> fieldsChanged)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow.Add(-DebounceWindow);
|
||||
|
||||
var recent = await _db.DigitizationEvents
|
||||
.Where(e => e.BatchId == batchId
|
||||
&& e.EventType == DigitizationEventType.DraftFieldUpdated
|
||||
&& e.ActorUserId == actorUserId
|
||||
&& e.OccurredAt >= cutoff)
|
||||
.OrderByDescending(e => e.OccurredAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (recent is not null)
|
||||
{
|
||||
var existing = !string.IsNullOrWhiteSpace(recent.MetadataJson)
|
||||
? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(recent.MetadataJson)
|
||||
: new Dictionary<string, JsonElement>();
|
||||
|
||||
var existingChanges = existing!.TryGetValue("fieldsChanged", out var fc)
|
||||
? JsonSerializer.Deserialize<List<object>>(fc.GetRawText()) ?? new List<object>()
|
||||
: new List<object>();
|
||||
|
||||
existingChanges.AddRange(fieldsChanged);
|
||||
|
||||
existing["section"] = JsonSerializer.SerializeToElement(section);
|
||||
existing["fieldsChanged"] = JsonSerializer.SerializeToElement(existingChanges);
|
||||
|
||||
recent.MetadataJson = JsonSerializer.Serialize(existing);
|
||||
recent.OccurredAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
_db.DigitizationEvents.Add(new DigitizationEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
EventType = DigitizationEventType.DraftFieldUpdated,
|
||||
ActorUserId = actorUserId,
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
MetadataJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
section,
|
||||
fieldsChanged
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Mapping helpers ─────────────────────────────────────────
|
||||
|
||||
private static DraftPatientDto MapPatient(DraftPatient p) => new(
|
||||
|
||||
@@ -637,7 +637,7 @@ Corrections are a daily clinical workflow. When a promoted batch has an error (w
|
||||
|
||||
---
|
||||
|
||||
## P4 — No toast notification system or success feedback
|
||||
## ~~P4 — No toast notification system or success feedback~~ DONE
|
||||
|
||||
### Problem
|
||||
|
||||
@@ -702,7 +702,7 @@ The frontend handles critical clinical workflows: data entry, verification, appr
|
||||
|
||||
---
|
||||
|
||||
## P5 — No audit of field-level draft changes
|
||||
## ~~P5 — No audit of field-level draft changes~~ DONE
|
||||
|
||||
### Problem
|
||||
|
||||
|
||||
Reference in New Issue
Block a user