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 =>
|
builder.ToTable("digitization_events", t =>
|
||||||
{
|
{
|
||||||
t.HasCheckConstraint("chk_digitization_events_event_type",
|
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.HasKey(e => e.Id);
|
||||||
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
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 =>
|
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,
|
PromotionRetryExhausted,
|
||||||
PromotionFailed,
|
PromotionFailed,
|
||||||
DocumentAccessed,
|
DocumentAccessed,
|
||||||
Cancelled
|
Cancelled,
|
||||||
|
DraftFieldUpdated,
|
||||||
|
DraftObservationDeleted
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class DigitizationEventTypeExtensions
|
public static class DigitizationEventTypeExtensions
|
||||||
@@ -48,6 +50,8 @@ public static class DigitizationEventTypeExtensions
|
|||||||
DigitizationEventType.PromotionFailed => "promotion_failed",
|
DigitizationEventType.PromotionFailed => "promotion_failed",
|
||||||
DigitizationEventType.DocumentAccessed => "document_accessed",
|
DigitizationEventType.DocumentAccessed => "document_accessed",
|
||||||
DigitizationEventType.Cancelled => "cancelled",
|
DigitizationEventType.Cancelled => "cancelled",
|
||||||
|
DigitizationEventType.DraftFieldUpdated => "draft_field_updated",
|
||||||
|
DigitizationEventType.DraftObservationDeleted => "draft_observation_deleted",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,6 +78,8 @@ public static class DigitizationEventTypeExtensions
|
|||||||
"promotion_failed" => DigitizationEventType.PromotionFailed,
|
"promotion_failed" => DigitizationEventType.PromotionFailed,
|
||||||
"document_accessed" => DigitizationEventType.DocumentAccessed,
|
"document_accessed" => DigitizationEventType.DocumentAccessed,
|
||||||
"cancelled" => DigitizationEventType.Cancelled,
|
"cancelled" => DigitizationEventType.Cancelled,
|
||||||
|
"draft_field_updated" => DigitizationEventType.DraftFieldUpdated,
|
||||||
|
"draft_observation_deleted" => DigitizationEventType.DraftObservationDeleted,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown digitization event type: '{v}'")
|
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown digitization event type: '{v}'")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -57,6 +57,13 @@ public class DraftService : IDraftService
|
|||||||
parsedBloodType = parsed;
|
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);
|
var patient = await _db.DraftPatients.FirstOrDefaultAsync(p => p.BatchId == batchId);
|
||||||
|
|
||||||
if (patient is null)
|
if (patient is null)
|
||||||
@@ -70,13 +77,9 @@ public class DraftService : IDraftService
|
|||||||
Sex = req.Sex,
|
Sex = req.Sex,
|
||||||
BloodType = parsedBloodType,
|
BloodType = parsedBloodType,
|
||||||
EmergencyContact = req.EmergencyContact,
|
EmergencyContact = req.EmergencyContact,
|
||||||
AllergiesJson = req.Allergies is not null
|
AllergiesJson = newAllergiesJson,
|
||||||
? JsonSerializer.Serialize(req.Allergies)
|
|
||||||
: null,
|
|
||||||
NoKnownAllergies = req.NoKnownAllergies,
|
NoKnownAllergies = req.NoKnownAllergies,
|
||||||
MedicationsJson = req.Medications is not null
|
MedicationsJson = newMedicationsJson,
|
||||||
? JsonSerializer.Serialize(req.Medications)
|
|
||||||
: null,
|
|
||||||
NoActiveMedications = req.NoActiveMedications,
|
NoActiveMedications = req.NoActiveMedications,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow
|
UpdatedAt = DateTimeOffset.UtcNow
|
||||||
@@ -85,18 +88,28 @@ public class DraftService : IDraftService
|
|||||||
}
|
}
|
||||||
else
|
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.FullName = req.FullName;
|
||||||
patient.DateOfBirth = req.DateOfBirth;
|
patient.DateOfBirth = req.DateOfBirth;
|
||||||
patient.Sex = req.Sex;
|
patient.Sex = req.Sex;
|
||||||
patient.BloodType = parsedBloodType;
|
patient.BloodType = parsedBloodType;
|
||||||
patient.EmergencyContact = req.EmergencyContact;
|
patient.EmergencyContact = req.EmergencyContact;
|
||||||
patient.AllergiesJson = req.Allergies is not null
|
patient.AllergiesJson = newAllergiesJson;
|
||||||
? JsonSerializer.Serialize(req.Allergies)
|
|
||||||
: null;
|
|
||||||
patient.NoKnownAllergies = req.NoKnownAllergies;
|
patient.NoKnownAllergies = req.NoKnownAllergies;
|
||||||
patient.MedicationsJson = req.Medications is not null
|
patient.MedicationsJson = newMedicationsJson;
|
||||||
? JsonSerializer.Serialize(req.Medications)
|
|
||||||
: null;
|
|
||||||
patient.NoActiveMedications = req.NoActiveMedications;
|
patient.NoActiveMedications = req.NoActiveMedications;
|
||||||
patient.UpdatedAt = DateTimeOffset.UtcNow;
|
patient.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
@@ -147,6 +160,17 @@ public class DraftService : IDraftService
|
|||||||
}
|
}
|
||||||
else
|
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.AdmissionDate = req.AdmissionDate;
|
||||||
encounter.Department = parsedDepartment;
|
encounter.Department = parsedDepartment;
|
||||||
encounter.RoomBed = req.RoomBed;
|
encounter.RoomBed = req.RoomBed;
|
||||||
@@ -213,6 +237,16 @@ public class DraftService : IDraftService
|
|||||||
if (!PlausibilityValidator.IsPlausible(req.ObservationCode, req.Value, out var reason))
|
if (!PlausibilityValidator.IsPlausible(req.ObservationCode, req.Value, out var reason))
|
||||||
throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE");
|
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.ObservationCode = req.ObservationCode;
|
||||||
observation.Value = req.Value;
|
observation.Value = req.Value;
|
||||||
observation.Unit = req.Unit;
|
observation.Unit = req.Unit;
|
||||||
@@ -240,6 +274,23 @@ public class DraftService : IDraftService
|
|||||||
throw new NotFoundException(
|
throw new NotFoundException(
|
||||||
"Observation not found in this batch.", "OBSERVATION_NOT_FOUND");
|
"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);
|
_db.DraftObservations.Remove(observation);
|
||||||
await _db.SaveChangesAsync();
|
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)");
|
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 ─────────────────────────────────────────
|
// ─── Mapping helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
private static DraftPatientDto MapPatient(DraftPatient p) => new(
|
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
|
### 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
|
### Problem
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user