99 lines
3.9 KiB
C#
99 lines
3.9 KiB
C#
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<ElasticIndexProvisioner> _logger;
|
|
|
|
public ElasticIndexProvisioner(
|
|
ElasticsearchClient elastic,
|
|
IOptions<ElasticsearchOptions> options,
|
|
ILogger<ElasticIndexProvisioner> logger)
|
|
{
|
|
_elastic = elastic;
|
|
_options = options.Value;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task StartAsync(CancellationToken cancellationToken)
|
|
{
|
|
await EnsureIndexAsync<PatientEncounterDocument>(
|
|
_options.Indices.PatientEncounters, BuildPatientEncountersMapping());
|
|
await EnsureIndexAsync<ObservationDocument>(
|
|
_options.Indices.Observations, BuildObservationsMapping());
|
|
await EnsureIndexAsync<ClinicalAlertDocument>(
|
|
_options.Indices.ClinicalAlerts, BuildClinicalAlertsMapping());
|
|
}
|
|
|
|
private async Task EnsureIndexAsync<T>(
|
|
string indexName,
|
|
Action<CreateIndexRequestDescriptor<T>> 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<T>(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<CreateIndexRequestDescriptor<PatientEncounterDocument>> 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!)
|
|
.IntegerNumber(i => i.News2Score!)
|
|
.Keyword(k => k.News2RiskLevel!)
|
|
.Date(d => d.AdmittedAt)
|
|
.IntegerNumber(i => i.OpenAlertCount)
|
|
.Date(d => d.LastObservationAt!)
|
|
));
|
|
|
|
private Action<CreateIndexRequestDescriptor<ObservationDocument>> 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<CreateIndexRequestDescriptor<ClinicalAlertDocument>> 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;
|
|
}
|