Files
vigilcare-clinical/VigilCareClinicalAPI/Services/PatientService.cs
T

263 lines
9.2 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;
public PatientService(
AppDbContext db,
IExternalIdentifierService identifiers,
IAuditService audit,
IOptions<PatientOptions> options)
{
_db = db;
_identifiers = identifiers;
_audit = audit;
_options = options.Value;
}
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
};
_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, patient.FirstName, patient.LastName });
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))
{
query = query.Where(p =>
p.Mrn == q ||
EF.Functions.ILike(p.FirstName, $"%{q}%") ||
EF.Functions.ILike(p.LastName, $"%{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();
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;
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
});
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");
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);
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;
await _db.SaveChangesAsync();
return patient;
}
// Use hospital identifier value as MRN when it fits the column constraint (max 20 chars).
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
};
_db.Patients.Add(newPatient);
await _db.SaveChangesAsync();
await _identifiers.LinkAsync(
ExternalResourceType.Patient,
newPatient.Id,
req.IdentifierSystem,
req.IdentifierValue);
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}")}";
}
}