312 lines
11 KiB
C#
312 lines
11 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
public class PatientService : IPatientService
|
|
{
|
|
private readonly AppDbContext _db;
|
|
private readonly IExternalIdentifierService _identifiers;
|
|
private readonly IAuditService _audit;
|
|
private readonly PatientOptions _options;
|
|
private readonly IPhiEncryptionService _crypto;
|
|
private readonly IPhiAccessLogService _phiAccess;
|
|
private readonly IHttpContextAccessor _http;
|
|
|
|
public PatientService(
|
|
AppDbContext db,
|
|
IExternalIdentifierService identifiers,
|
|
IAuditService audit,
|
|
IOptions<PatientOptions> options,
|
|
IPhiEncryptionService crypto,
|
|
IPhiAccessLogService phiAccess,
|
|
IHttpContextAccessor http)
|
|
{
|
|
_db = db;
|
|
_identifiers = identifiers;
|
|
_audit = audit;
|
|
_options = options.Value;
|
|
_crypto = crypto;
|
|
_phiAccess = phiAccess;
|
|
_http = http;
|
|
}
|
|
|
|
public async Task<Patient> RegisterAsync(RegisterPatientRequest req)
|
|
{
|
|
var patient = new Patient
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Mrn = await GenerateMrnAsync(),
|
|
FirstName = req.FirstName,
|
|
LastName = req.LastName,
|
|
DateOfBirth = req.DateOfBirth,
|
|
Gender = req.Gender,
|
|
BloodType = req.BloodType,
|
|
Allergies = req.Allergies,
|
|
EmergencyContactName = req.EmergencyContactName,
|
|
EmergencyContactPhone = req.EmergencyContactPhone,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
SetNameSearchToken(patient);
|
|
_db.Patients.Add(patient);
|
|
|
|
try
|
|
{
|
|
await _db.SaveChangesAsync();
|
|
}
|
|
catch (DbUpdateException ex) when (IsMrnUniqueViolation(ex))
|
|
{
|
|
_db.Entry(patient).State = EntityState.Detached;
|
|
patient.Mrn = await GenerateMrnAsync();
|
|
_db.Patients.Add(patient);
|
|
await _db.SaveChangesAsync();
|
|
}
|
|
|
|
await _audit.WriteAsync(
|
|
AuditAction.PatientRegistered,
|
|
"Patient",
|
|
patient.Id,
|
|
newValue: new { patient.Mrn });
|
|
|
|
var path = _http.HttpContext?.Request.Path.Value ?? "/api/v1/patients";
|
|
await _phiAccess.LogCreateAsync(patient.Id, path);
|
|
|
|
return patient;
|
|
}
|
|
|
|
private static bool IsMrnUniqueViolation(DbUpdateException ex)
|
|
{
|
|
return ex.InnerException is Npgsql.PostgresException pg
|
|
&& pg.SqlState == "23505"
|
|
&& pg.ConstraintName?.Contains("mrn") == true;
|
|
}
|
|
|
|
public async Task<PagedResult<Patient>> ListAsync(
|
|
string? q, int page, int pageSize)
|
|
{
|
|
var query = _db.Patients.AsQueryable();
|
|
|
|
if (!string.IsNullOrWhiteSpace(q))
|
|
{
|
|
var parts = q.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
|
if (parts.Length == 2)
|
|
{
|
|
var token = _crypto.ComputeNameSearchToken(parts[0], parts[1]);
|
|
query = query.Where(p => p.Mrn == q || p.NameSearchToken == token);
|
|
}
|
|
else
|
|
{
|
|
query = query.Where(p =>
|
|
p.Mrn == q ||
|
|
(p.NameSearchToken != null &&
|
|
(p.NameSearchToken == _crypto.ComputeNameSearchToken(q, "") ||
|
|
p.NameSearchToken == _crypto.ComputeNameSearchToken("", q))));
|
|
}
|
|
}
|
|
|
|
var total = await query.CountAsync();
|
|
var patients = await query
|
|
.OrderBy(p => p.LastName).ThenBy(p => p.FirstName)
|
|
.Skip((page - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.ToListAsync();
|
|
|
|
var path = _http.HttpContext?.Request.Path.Value ?? "/api/v1/patients";
|
|
await _phiAccess.LogListAsync(path, patients.Count, q);
|
|
|
|
foreach (var p in patients)
|
|
await _phiAccess.LogViewAsync(p.Id, $"{path}?page={page}");
|
|
|
|
return new PagedResult<Patient>(patients, page, pageSize, total);
|
|
}
|
|
|
|
public async Task<Patient> UpdateAsync(Guid id, UpdatePatientRequest req)
|
|
{
|
|
var patient = await _db.Patients.FindAsync(id)
|
|
?? throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
|
|
|
|
var before = new
|
|
{
|
|
patient.FirstName, patient.LastName, patient.DateOfBirth,
|
|
patient.Gender, patient.BloodType, patient.Allergies,
|
|
patient.EmergencyContactName, patient.EmergencyContactPhone
|
|
};
|
|
|
|
if (req.FirstName is not null) patient.FirstName = req.FirstName;
|
|
if (req.LastName is not null) patient.LastName = req.LastName;
|
|
if (req.DateOfBirth is not null) patient.DateOfBirth = req.DateOfBirth.Value;
|
|
if (req.Gender is not null) patient.Gender = req.Gender;
|
|
if (req.BloodType is not null) patient.BloodType = req.BloodType;
|
|
if (req.Allergies is not null) patient.Allergies = req.Allergies;
|
|
if (req.EmergencyContactName is not null) patient.EmergencyContactName = req.EmergencyContactName;
|
|
if (req.EmergencyContactPhone is not null) patient.EmergencyContactPhone = req.EmergencyContactPhone;
|
|
|
|
if (req.FirstName is not null || req.LastName is not null)
|
|
SetNameSearchToken(patient);
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
await _audit.WriteAsync(
|
|
AuditAction.PatientUpdated,
|
|
"Patient",
|
|
patient.Id,
|
|
previousValue: before,
|
|
newValue: new
|
|
{
|
|
patient.FirstName, patient.LastName, patient.DateOfBirth,
|
|
patient.Gender, patient.BloodType, patient.Allergies,
|
|
patient.EmergencyContactName, patient.EmergencyContactPhone
|
|
});
|
|
|
|
var path = _http.HttpContext?.Request.Path.Value ?? $"/api/v1/patients/{id}";
|
|
await _phiAccess.LogUpdateAsync(patient.Id, path);
|
|
|
|
return patient;
|
|
}
|
|
|
|
public async Task<Patient> GetByIdAsync(Guid id)
|
|
{
|
|
var patient = await _db.Patients
|
|
.Include(p => p.Encounters.Where(e => e.Status == EncounterStatus.Active))
|
|
.FirstOrDefaultAsync(p => p.Id == id);
|
|
|
|
if (patient is null)
|
|
throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
|
|
|
|
var path = _http.HttpContext?.Request.Path.Value ?? $"/api/v1/patients/{id}";
|
|
await _phiAccess.LogViewAsync(patient.Id, path);
|
|
|
|
return patient;
|
|
}
|
|
|
|
public async Task<Encounter> OpenEncounterAsync(Guid patientId, OpenEncounterRequest req)
|
|
{
|
|
var patient = await _db.Patients.FindAsync(patientId);
|
|
if (patient is null)
|
|
throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
|
|
|
|
var hasActiveOfType = await _db.Encounters.AnyAsync(e =>
|
|
e.PatientId == patientId &&
|
|
e.EncounterType == req.EncounterType &&
|
|
e.Status == EncounterStatus.Active);
|
|
|
|
if (hasActiveOfType)
|
|
throw new ConflictException(
|
|
"Patient already has an active encounter of this type.",
|
|
"DUPLICATE_ACTIVE_ENCOUNTER");
|
|
|
|
var encounter = new Encounter
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
PatientId = patientId,
|
|
EncounterType = req.EncounterType,
|
|
Status = EncounterStatus.Active,
|
|
Department = req.Department,
|
|
AttendingPhysician = req.AttendingPhysician,
|
|
RoomBed = req.RoomBed,
|
|
AdmissionReason = req.AdmissionReason,
|
|
AdmittedAt = DateTimeOffset.UtcNow,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
_db.Encounters.Add(encounter);
|
|
|
|
_db.OutboxEvents.Add(new OutboxEvent
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Topic = "encounter.status.changed",
|
|
Payload = JsonSerializer.Serialize(new
|
|
{
|
|
encounterId = encounter.Id,
|
|
patientId = patient.Id,
|
|
mrn = patient.Mrn,
|
|
patientName = $"{patient.FirstName} {patient.LastName}",
|
|
previousStatus = (string?)null,
|
|
newStatus = encounter.Status.ToDbString(),
|
|
department = encounter.Department.ToDbString(),
|
|
attendingPhysician = encounter.AttendingPhysician,
|
|
roomBed = encounter.RoomBed,
|
|
admissionReason = encounter.AdmissionReason,
|
|
admittedAt = encounter.AdmittedAt,
|
|
changedAt = DateTimeOffset.UtcNow
|
|
}),
|
|
PartitionKey = encounter.Id.ToString(),
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
|
|
await _db.SaveChangesAsync();
|
|
return encounter;
|
|
}
|
|
|
|
public async Task<Patient> RegisterOrUpdateByIdentifierAsync(FhirPatientUpsertRequest req)
|
|
{
|
|
var existingId = await _identifiers.ResolveInternalIdAsync(
|
|
ExternalResourceType.Patient, req.IdentifierSystem, req.IdentifierValue);
|
|
|
|
var fhirPath = _http.HttpContext?.Request.Path.Value ?? "/fhir/R4/Patient";
|
|
|
|
if (existingId.HasValue)
|
|
{
|
|
var patient = await _db.Patients.FindAsync(existingId.Value)
|
|
?? throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
|
|
|
|
patient.FirstName = req.FirstName;
|
|
patient.LastName = req.LastName;
|
|
patient.DateOfBirth = req.DateOfBirth;
|
|
patient.Gender = req.Gender;
|
|
patient.BloodType = req.BloodType;
|
|
patient.Allergies = req.Allergies;
|
|
patient.EmergencyContactName = req.EmergencyContactName;
|
|
patient.EmergencyContactPhone = req.EmergencyContactPhone;
|
|
SetNameSearchToken(patient);
|
|
|
|
await _db.SaveChangesAsync();
|
|
await _phiAccess.LogUpdateAsync(patient.Id, fhirPath);
|
|
return patient;
|
|
}
|
|
|
|
var mrn = req.IdentifierValue.Length <= 20
|
|
? req.IdentifierValue
|
|
: await GenerateMrnAsync();
|
|
|
|
var newPatient = new Patient
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Mrn = mrn,
|
|
FirstName = req.FirstName,
|
|
LastName = req.LastName,
|
|
DateOfBirth = req.DateOfBirth,
|
|
Gender = req.Gender,
|
|
BloodType = req.BloodType,
|
|
Allergies = req.Allergies,
|
|
EmergencyContactName = req.EmergencyContactName,
|
|
EmergencyContactPhone = req.EmergencyContactPhone,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
SetNameSearchToken(newPatient);
|
|
|
|
_db.Patients.Add(newPatient);
|
|
await _db.SaveChangesAsync();
|
|
|
|
await _identifiers.LinkAsync(
|
|
ExternalResourceType.Patient,
|
|
newPatient.Id,
|
|
req.IdentifierSystem,
|
|
req.IdentifierValue);
|
|
|
|
await _phiAccess.LogCreateAsync(newPatient.Id, fhirPath);
|
|
return newPatient;
|
|
}
|
|
|
|
private async Task<string> GenerateMrnAsync()
|
|
{
|
|
var seq = await _db.Database
|
|
.SqlQueryRaw<long>("SELECT nextval('mrn_seq') AS \"Value\"")
|
|
.SingleAsync();
|
|
return $"{_options.MrnPrefix}-{seq.ToString($"D{_options.MrnDigits}")}";
|
|
}
|
|
|
|
private void SetNameSearchToken(Patient patient)
|
|
{
|
|
patient.NameSearchToken = _crypto.ComputeNameSearchToken(
|
|
patient.FirstName, patient.LastName);
|
|
}
|
|
} |