using Elastic.Clients.Elasticsearch; using Elastic.Clients.Elasticsearch.IndexManagement; using Microsoft.Extensions.Options; public class ElasticIndexProvisioner : IHostedService { private readonly ElasticsearchClient _elastic; private readonly ElasticsearchOptions _options; private readonly ILogger _logger; public ElasticIndexProvisioner( ElasticsearchClient elastic, IOptions options, ILogger logger) { _elastic = elastic; _options = options.Value; _logger = logger; } public async Task StartAsync(CancellationToken cancellationToken) { await EnsureIndexAsync( _options.Indices.PatientEncounters, BuildPatientEncountersMapping()); await EnsureIndexAsync( _options.Indices.Observations, BuildObservationsMapping()); await EnsureIndexAsync( _options.Indices.ClinicalAlerts, BuildClinicalAlertsMapping()); } private async Task EnsureIndexAsync( string indexName, Action> configure) where T : class { var exists = await _elastic.Indices.ExistsAsync(indexName); if (exists.Exists) { _logger.LogInformation("Elasticsearch index '{Index}' already exists — skipping", indexName); return; } var resp = await _elastic.Indices.CreateAsync(indexName, configure); if (!resp.IsValidResponse) throw new InvalidOperationException( $"Failed to create Elasticsearch index '{indexName}': {resp.DebugInformation}"); _logger.LogInformation("Created Elasticsearch index '{Index}'", indexName); } private Action> BuildPatientEncountersMapping() => d => d.Mappings(m => m.Properties(p => p .Keyword(k => k.EncounterId) .Keyword(k => k.PatientId) .Keyword(k => k.Mrn) // text for full-text search + keyword sub-field for exact sort/filter .Text(t => t.PatientName, tf => tf .Fields(f => f.Keyword(k => k.PatientName))) .Keyword(k => k.Department) .Keyword(k => k.Status) .Keyword(k => k.AttendingPhysician) .Keyword(k => k.RoomBed) .Text(t => t.AdmissionReason) .Date(d => d.AdmittedAt) .IntegerNumber(i => i.OpenAlertCount) .Date(d => d.LastObservationAt!) )); private Action> BuildObservationsMapping() => d => d.Mappings(m => m.Properties(p => p .Keyword(k => k.ObservationId) .Keyword(k => k.EncounterId) .Keyword(k => k.PatientId) .Keyword(k => k.Mrn) .Keyword(k => k.ObservationCode) // float: observations are decimal values like 97.3, 2.5, 118.0 // auto-mapped 'long' would truncate fractional parts silently .FloatNumber(f => f.Value) .Keyword(k => k.Unit) .Keyword(k => k.Source) .Date(d => d.RecordedAt) )); private Action> BuildClinicalAlertsMapping() => d => d.Mappings(m => m.Properties(p => p .Keyword(k => k.AlertId) .Keyword(k => k.EncounterId) .Keyword(k => k.PatientId) .Keyword(k => k.Department) .Keyword(k => k.AlertType) .Keyword(k => k.Severity) .Keyword(k => k.Status) .Date(d => d.TriggeredAt) )); public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; }