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 options) { _db = db; _identifiers = identifiers; _audit = audit; _options = options.Value; } public async Task 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> 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(patients, page, pageSize, total); } public async Task 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 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 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 GenerateMrnAsync() { var seq = await _db.Database .SqlQueryRaw("SELECT nextval('mrn_seq') AS \"Value\"") .SingleAsync(); return $"{_options.MrnPrefix}-{seq.ToString($"D{_options.MrnDigits}")}"; } }