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 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 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> 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(patients, page, pageSize, total); } public async Task 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 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 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); 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 GenerateMrnAsync() { var seq = await _db.Database .SqlQueryRaw("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); } }