fix: No audit of field-level draft changes

This commit is contained in:
voltsrage
2026-06-27 20:23:27 +08:00
parent 2d36d1a5dd
commit b50a0ec305
7 changed files with 1637 additions and 17 deletions
@@ -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()");
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}'")
};
}
+129 -12
View File
@@ -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(