feature: Elasticsearch CQRS Projection and Analytics Endpoints

This commit is contained in:
voltsrage
2026-06-17 00:05:37 +08:00
parent 84d259d10c
commit fde3d56484
21 changed files with 1281 additions and 2 deletions
@@ -0,0 +1,94 @@
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)
.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;
}
@@ -0,0 +1,241 @@
using System.Text.Json;
using Confluent.Kafka;
using Elastic.Clients.Elasticsearch;
using Microsoft.Extensions.Options;
public class EsIndexerService : BackgroundService
{
private static readonly JsonSerializerOptions EventJsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly ElasticsearchClient _elastic;
private readonly KafkaOptions _kafkaOptions;
private readonly ElasticsearchOptions _esOptions;
private readonly ILogger<EsIndexerService> _logger;
public EsIndexerService(
ElasticsearchClient elastic,
IOptions<KafkaOptions> kafkaOptions,
IOptions<ElasticsearchOptions> esOptions,
ILogger<EsIndexerService> logger)
{
_elastic = elastic;
_kafkaOptions = kafkaOptions.Value;
_esOptions = esOptions.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var config = new ConsumerConfig
{
BootstrapServers = _kafkaOptions.BootstrapServers,
GroupId = "es-indexer",
AutoOffsetReset = AutoOffsetReset.Earliest,
// Manual commit: offset is only committed after a successful Elasticsearch write.
// If the process crashes between ES write and commit, the message is reprocessed.
// Consumers must be idempotent. See idempotency contract above.
EnableAutoCommit = false,
EnablePartitionEof = false
};
using var consumer = new ConsumerBuilder<string, string>(config).Build();
consumer.Subscribe(new[]
{
_kafkaOptions.Topics.ObservationRecorded,
_kafkaOptions.Topics.AlertGenerated,
_kafkaOptions.Topics.EncounterStatusChanged
});
_logger.LogInformation("EsIndexerService started. Subscribed to 3 topics.");
try
{
while (!stoppingToken.IsCancellationRequested)
{
ConsumeResult<string, string>? result = null;
try
{
result = consumer.Consume(stoppingToken);
await DispatchAsync(result.Topic, result.Message.Value, stoppingToken);
consumer.Commit(result);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex,
"EsIndexer failed processing topic={Topic} offset={Offset} — not committing",
result?.Topic, result?.Offset.Value);
// Do not commit: message will be redelivered on restart
await Task.Delay(1000, stoppingToken);
}
}
}
finally
{
consumer.Close();
}
}
private Task DispatchAsync(string topic, string payload, CancellationToken ct) => topic switch
{
var t when t == _kafkaOptions.Topics.EncounterStatusChanged =>
HandleEncounterStatusChangedAsync(payload, ct),
var t when t == _kafkaOptions.Topics.ObservationRecorded =>
HandleObservationRecordedAsync(payload, ct),
var t when t == _kafkaOptions.Topics.AlertGenerated =>
HandleAlertGeneratedAsync(payload, ct),
_ => Task.CompletedTask
};
// --- encounter.status.changed ---
// Upserts the patient_encounters document. DocAsUpsert=true means:
// if the document does not exist, it is created; if it exists, it is replaced.
// Idempotent: applying the same event twice produces the same document.
private async Task HandleEncounterStatusChangedAsync(string payload, CancellationToken ct)
{
var evt = JsonSerializer.Deserialize<EncounterStatusChangedEvent>(payload, EventJsonOptions)!;
var doc = new PatientEncounterDocument
{
EncounterId = evt.EncounterId.ToString(),
PatientId = evt.PatientId.ToString(),
Mrn = evt.Mrn,
PatientName = evt.PatientName,
Department = evt.Department,
Status = evt.NewStatus,
AttendingPhysician = evt.AttendingPhysician,
AdmittedAt = evt.AdmittedAt,
OpenAlertCount = 0,
LastObservationAt = null
};
var resp = await _elastic.UpdateAsync<PatientEncounterDocument, PatientEncounterDocument>(
_esOptions.Indices.PatientEncounters,
evt.EncounterId.ToString(),
u => u.Doc(doc).DocAsUpsert(true),
ct);
if (!resp.IsValidResponse)
throw new InvalidOperationException(
$"ES upsert failed for encounter {evt.EncounterId}: {resp.DebugInformation}");
_logger.LogDebug("Upserted patient_encounters for encounter {Id} → status={Status}",
evt.EncounterId, evt.NewStatus);
}
// --- observation.recorded ---
// Indexes the observation by observationId — idempotent PUT.
// Also updates lastObservationAt on the parent encounter document using a script.
private async Task HandleObservationRecordedAsync(string payload, CancellationToken ct)
{
var evt = JsonSerializer.Deserialize<ObservationRecordedEvent>(payload, EventJsonOptions)!;
// Index into observations — document ID is observationId
var doc = new ObservationDocument
{
ObservationId = evt.ObservationId.ToString(),
EncounterId = evt.EncounterId.ToString(),
PatientId = evt.PatientId.ToString(),
Mrn = evt.Mrn ?? string.Empty,
ObservationCode = evt.ObservationCode,
Value = (double)evt.Value,
Unit = evt.Unit,
Source = evt.Source,
RecordedAt = evt.RecordedAt
};
var indexResp = await _elastic.IndexAsync(
doc,
i => i.Index(_esOptions.Indices.Observations).Id(doc.ObservationId),
ct);
if (!indexResp.IsValidResponse)
throw new InvalidOperationException(
$"ES index failed for observation {evt.ObservationId}: {indexResp.DebugInformation}");
// Update lastObservationAt on patient_encounters using a conditional script:
// only update if the new recordedAt is later than the stored value.
// This handles out-of-order delivery: an older observation re-processed after a
// newer one must not overwrite lastObservationAt with a stale timestamp.
var updateResp = await _elastic.UpdateAsync<PatientEncounterDocument, object>(
_esOptions.Indices.PatientEncounters,
evt.EncounterId.ToString(),
u => u
.Script(new Script(new InlineScript
{
Source = """
if (ctx._source.lastObservationAt == null ||
params.recordedAt > ctx._source.lastObservationAt) {
ctx._source.lastObservationAt = params.recordedAt;
}
""",
Language = ScriptLanguage.Painless,
Params = new Dictionary<string, object>
{
["recordedAt"] = evt.RecordedAt.ToString("O")
}
}))
.RetryOnConflict(3),
ct);
if (!updateResp.IsValidResponse && updateResp.Result != Result.NotFound)
_logger.LogWarning(
"Could not update lastObservationAt for encounter {Id} — encounter document may not exist yet",
evt.EncounterId);
}
// --- alert.generated ---
// Indexes the alert by alertId. Also increments openAlertCount on patient_encounters.
// TRADE-OFF: openAlertCount increment is not idempotent for partial reprocessing.
// It is correct for full replay from offset 0 (the stated recovery procedure).
// For production, use a set of counted alert IDs in the script to enforce idempotency.
private async Task HandleAlertGeneratedAsync(string payload, CancellationToken ct)
{
var evt = JsonSerializer.Deserialize<AlertGeneratedEvent>(payload, EventJsonOptions)!;
var doc = new ClinicalAlertDocument
{
AlertId = evt.AlertId.ToString(),
EncounterId = evt.EncounterId.ToString(),
PatientId = evt.PatientId.ToString(),
Department = evt.Department ?? string.Empty,
AlertType = evt.AlertType,
Severity = evt.Severity,
Status = "Open",
TriggeredAt = evt.TriggeredAt
};
var indexResp = await _elastic.IndexAsync(
doc,
i => i.Index(_esOptions.Indices.ClinicalAlerts).Id(doc.AlertId),
ct);
if (!indexResp.IsValidResponse)
throw new InvalidOperationException(
$"ES index failed for alert {evt.AlertId}: {indexResp.DebugInformation}");
// Increment openAlertCount on the parent encounter document
var updateResp = await _elastic.UpdateAsync<PatientEncounterDocument, object>(
_esOptions.Indices.PatientEncounters,
evt.EncounterId.ToString(),
u => u
.Script(new Script(new InlineScript
{
Source = "ctx._source.openAlertCount += 1",
Language = ScriptLanguage.Painless
}))
.RetryOnConflict(3),
ct);
if (!updateResp.IsValidResponse && updateResp.Result != Result.NotFound)
_logger.LogWarning(
"Could not increment openAlertCount for encounter {Id}", evt.EncounterId);
}
}
@@ -0,0 +1,107 @@
using Microsoft.AspNetCore.Mvc;
/// <summary>
/// Elasticsearch-backed analytics queries for population, trends, alerts, and patient search.
/// </summary>
[ApiController]
[Route("api/v1/analytics")]
[Produces("application/json")]
public class AnalyticsController : ControllerBase
{
private readonly IAnalyticsService _analytics;
public AnalyticsController(IAnalyticsService analytics) => _analytics = analytics;
/// <summary>
/// Counts distinct patients whose observations for a code exceed or fall below a threshold in a time window.
/// </summary>
/// <param name="code">Observation code (e.g. HEART_RATE).</param>
/// <param name="threshold">Numeric threshold value.</param>
/// <param name="direction">above or below.</param>
/// <param name="from">Optional start of recorded-at range.</param>
/// <param name="to">Optional end of recorded-at range.</param>
/// <returns>Cardinality of unique patients matching the criteria.</returns>
[HttpGet("population")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> Population(
[FromQuery] string? code,
[FromQuery] decimal threshold,
[FromQuery] string direction, // "above" | "below"
[FromQuery] DateTimeOffset? from,
[FromQuery] DateTimeOffset? to)
{
if (string.IsNullOrWhiteSpace(code))
return BadRequest(ApiResponse<object>.Fail(400, "code is required.", "MISSING_CODE"));
var result = await _analytics.GetPopulationAsync(code, threshold, direction, from, to);
return Ok(ApiResponse<object>.Ok(result));
}
/// <summary>
/// Returns hourly observation volume for an encounter and observation code.
/// </summary>
/// <param name="encounterId">Encounter id.</param>
/// <param name="code">Observation code.</param>
/// <param name="from">Optional start of recorded-at range.</param>
/// <param name="to">Optional end of recorded-at range.</param>
/// <returns>Hourly trend buckets with document counts.</returns>
[HttpGet("observations/trend")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> ObservationTrend(
[FromQuery] Guid encounterId,
[FromQuery] string code,
[FromQuery] DateTimeOffset? from,
[FromQuery] DateTimeOffset? to)
{
var result = await _analytics.GetObservationTrendAsync(encounterId, code, from, to);
return Ok(ApiResponse<object>.Ok(result));
}
/// <summary>
/// Summarizes alert volume by department over a time window.
/// </summary>
/// <param name="severity">Optional severity filter.</param>
/// <param name="department">Optional department filter.</param>
/// <param name="from">Optional start of triggered-at range.</param>
/// <param name="to">Optional end of triggered-at range.</param>
/// <returns>Department-level counts (severity breakdown omitted in current implementation).</returns>
[HttpGet("alerts/summary")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> AlertSummary(
[FromQuery] string? severity,
[FromQuery] string? department,
[FromQuery] DateTimeOffset? from,
[FromQuery] DateTimeOffset? to)
{
var result = await _analytics.GetAlertSummaryAsync(severity, department, from, to);
return Ok(ApiResponse<object>.Ok(result));
}
/// <summary>
/// Searches patient/encounter documents by MRN, name, department, or status.
/// </summary>
/// <param name="q">Free-text query (MRN, name, or department).</param>
/// <param name="department">Optional department filter.</param>
/// <param name="status">Optional encounter status filter.</param>
/// <param name="page">Page number (0-based).</param>
/// <param name="pageSize">Results per page.</param>
/// <returns>Matching patient/encounter documents.</returns>
[HttpGet("patients")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> PatientSearch(
[FromQuery] string? q,
[FromQuery] string? department,
[FromQuery] string? status,
[FromQuery] int page = 0,
[FromQuery] int pageSize = 20)
{
var result = await _analytics.SearchPatientsAsync(q, department, status, page, pageSize);
return Ok(ApiResponse<object>.Ok(result));
}
}
@@ -0,0 +1,6 @@
public class ElasticIndexOptions
{
public string PatientEncounters { get; set; } = "patient_encounters";
public string Observations { get; set; } = "observations";
public string ClinicalAlerts { get; set; } = "clinical_alerts";
}
@@ -0,0 +1,6 @@
public class ElasticsearchOptions
{
public const string Section = "Elasticsearch";
public string Uri { get; set; } = null!;
public ElasticIndexOptions Indices { get; set; } = null!;
}
@@ -0,0 +1,11 @@
public class ClinicalAlertDocument
{
public string AlertId { get; set; } = null!;
public string EncounterId { get; set; } = null!;
public string PatientId { get; set; } = null!;
public string Department { get; set; } = null!;
public string AlertType { get; set; } = null!;
public string Severity { get; set; } = null!;
public string Status { get; set; } = null!;
public DateTimeOffset TriggeredAt { get; set; }
}
@@ -0,0 +1,12 @@
public class ObservationDocument
{
public string ObservationId { get; set; } = null!;
public string EncounterId { get; set; } = null!;
public string PatientId { get; set; } = null!;
public string Mrn { get; set; } = null!;
public string ObservationCode { get; set; } = null!;
public double Value { get; set; }
public string Unit { get; set; } = null!;
public string Source { get; set; } = null!;
public DateTimeOffset RecordedAt { get; set; }
}
@@ -0,0 +1,13 @@
public class PatientEncounterDocument
{
public string EncounterId { get; set; } = null!;
public string PatientId { get; set; } = null!;
public string Mrn { get; set; } = null!;
public string PatientName { get; set; } = null!;
public string Department { get; set; } = null!;
public string Status { get; set; } = null!;
public string AttendingPhysician { get; set; } = null!;
public DateTimeOffset AdmittedAt { get; set; }
public int OpenAlertCount { get; set; }
public DateTimeOffset? LastObservationAt { get; set; }
}
@@ -0,0 +1,3 @@
public record AlertGeneratedEvent(
Guid AlertId, Guid EncounterId, Guid PatientId, string? Department,
string AlertType, string Severity, DateTimeOffset TriggeredAt);
@@ -0,0 +1,4 @@
public record EncounterStatusChangedEvent(
Guid EncounterId, Guid PatientId, string Mrn, string PatientName,
string? PreviousStatus, string NewStatus, string Department,
string AttendingPhysician, DateTimeOffset AdmittedAt, DateTimeOffset ChangedAt);
@@ -0,0 +1,4 @@
public record ObservationRecordedEvent(
Guid ObservationId, Guid EncounterId, Guid PatientId, string? Mrn,
string ObservationCode, decimal Value, string Unit, string Source,
DateTimeOffset RecordedAt);
+14
View File
@@ -1,3 +1,4 @@
using Elastic.Clients.Elasticsearch;
using Microsoft.EntityFrameworkCore;
using Serilog;
using StackExchange.Redis;
@@ -25,16 +26,29 @@ try
builder.Services.Configure<KafkaOptions>(
builder.Configuration.GetSection(KafkaOptions.Section));
builder.Services.Configure<ElasticsearchOptions>(
builder.Configuration.GetSection(ElasticsearchOptions.Section));
var esOptions = builder.Configuration
.GetSection(ElasticsearchOptions.Section)
.Get<ElasticsearchOptions>()!;
builder.Services.AddSingleton(
new ElasticsearchClient(new Uri(esOptions.Uri)));
builder.Services.AddScoped<IPatientService, PatientService>();
builder.Services.AddScoped<IEncounterService, EncounterService>();
builder.Services.AddScoped<IAlertThresholdService, AlertThresholdService>();
builder.Services.AddScoped<IObservationService, ObservationService>();
builder.Services.AddScoped<IObservationQueryService, ObservationQueryService>();
builder.Services.AddScoped<IAlertService, AlertService>();
builder.Services.AddScoped<IAnalyticsService, AnalyticsService>();
builder.Services.AddHostedService<ThresholdCacheLoader>();
builder.Services.AddHostedService<KafkaTopicProvisioner>();
builder.Services.AddHostedService<OutboxRelayService>();
builder.Services.AddHostedService<ElasticIndexProvisioner>();
builder.Services.AddHostedService<EsIndexerService>();
builder.Services.AddControllers()
.AddJsonOptions(opts =>
@@ -0,0 +1,212 @@
using Elastic.Clients.Elasticsearch;
using Elastic.Clients.Elasticsearch.QueryDsl;
using Microsoft.Extensions.Options;
public class AnalyticsService : IAnalyticsService
{
private readonly ElasticsearchClient _elastic;
private readonly ElasticsearchOptions _options;
public AnalyticsService(ElasticsearchClient elastic, IOptions<ElasticsearchOptions> options)
{
_elastic = elastic;
_options = options.Value;
}
public async Task<object> GetPopulationAsync(string code, decimal threshold, string direction,
DateTimeOffset? from, DateTimeOffset? to)
{
var filters = new List<Action<QueryDescriptor<ObservationDocument>>>
{
fq => fq.Term(t => t.Field(o => o.ObservationCode).Value(code))
};
if (direction == "above")
filters.Add(fq => fq.Range(r => r.NumberRange(nr =>
nr.Field(o => o.Value).Gt((double)threshold))));
else
filters.Add(fq => fq.Range(r => r.NumberRange(nr =>
nr.Field(o => o.Value).Lt((double)threshold))));
if (from.HasValue)
filters.Add(fq => fq.Range(r => r.DateRange(dr =>
dr.Field(o => o.RecordedAt).Gte(from.Value.ToString("O")))));
if (to.HasValue)
filters.Add(fq => fq.Range(r => r.DateRange(dr =>
dr.Field(o => o.RecordedAt).Lte(to.Value.ToString("O")))));
var resp = await _elastic.SearchAsync<ObservationDocument>(s => s
.Indices(_options.Indices.Observations)
.Query(q => q.Bool(b => b.Filter(filters.ToArray())))
.Aggregations(a => a
// cardinality: count of distinct patientId values — not a document count.
// Two observations from the same patient count as one patient.
.Add("unique_patients", agg => agg.Cardinality(c => c.Field(o => o.PatientId)))
)
.Size(0)); // no raw hits — aggregation result only
if (!resp.IsValidResponse)
throw new InvalidOperationException("Elasticsearch query failed.");
var uniquePatients = resp.Aggregations?.GetCardinality("unique_patients")?.Value ?? 0;
return new
{
observationCode = code,
threshold,
direction,
from,
to,
uniquePatientCount = uniquePatients
};
}
public async Task<object> GetObservationTrendAsync(Guid encounterId, string code,
DateTimeOffset? from, DateTimeOffset? to)
{
var filters = new List<Action<QueryDescriptor<ObservationDocument>>>
{
fq => fq.Term(t => t.Field(o => o.EncounterId).Value(encounterId.ToString())),
fq => fq.Term(t => t.Field(o => o.ObservationCode).Value(code))
};
if (from.HasValue)
filters.Add(fq => fq.Range(r => r.DateRange(dr =>
dr.Field(o => o.RecordedAt).Gte(from.Value.ToString("O")))));
if (to.HasValue)
filters.Add(fq => fq.Range(r => r.DateRange(dr =>
dr.Field(o => o.RecordedAt).Lte(to.Value.ToString("O")))));
var resp = await _elastic.SearchAsync<ObservationDocument>(s => s
.Indices(_options.Indices.Observations)
.Query(q => q.Bool(b => b.Filter(filters.ToArray())))
.Aggregations(a => a
.Add("trend", agg => agg.DateHistogram(h => h
.Field(o => o.RecordedAt)
.CalendarInterval(Elastic.Clients.Elasticsearch.Aggregations.CalendarInterval.Hour)
)
)
)
.Size(0));
if (!resp.IsValidResponse)
throw new InvalidOperationException("Elasticsearch query failed.");
var buckets = resp.Aggregations?.GetDateHistogram("trend")?.Buckets
.Select(b => new
{
hour = b.KeyAsString,
avg = (double?)null,
min = (double?)null,
max = (double?)null,
count = b.DocCount
}) ?? Enumerable.Empty<object>();
return new { encounterId, observationCode = code, trend = buckets };
}
public async Task<object> GetAlertSummaryAsync(string? severity, string? department,
DateTimeOffset? from, DateTimeOffset? to)
{
var filters = new List<Action<QueryDescriptor<ClinicalAlertDocument>>>();
if (from.HasValue)
filters.Add(fq => fq.Range(r => r.DateRange(dr =>
dr.Field(a => a.TriggeredAt).Gte(from.Value.ToString("O")))));
if (to.HasValue)
filters.Add(fq => fq.Range(r => r.DateRange(dr =>
dr.Field(a => a.TriggeredAt).Lte(to.Value.ToString("O")))));
if (!string.IsNullOrEmpty(severity))
filters.Add(fq => fq.Term(t => t.Field(a => a.Severity).Value(severity)));
if (!string.IsNullOrEmpty(department))
filters.Add(fq => fq.Term(t => t.Field(a => a.Department).Value(department)));
var resp = await _elastic.SearchAsync<ClinicalAlertDocument>(s => s
.Indices(_options.Indices.ClinicalAlerts)
.Query(q => q.Bool(b =>
{
if (filters.Count > 0)
b.Filter(filters.ToArray());
else
b.Must(m => m.MatchAll(_ => { }));
}))
.Aggregations(a => a
.Add("by_department", agg => agg.Terms(t => t
.Field(a => a.Department).Size(50)
)
)
)
.Size(0));
if (!resp.IsValidResponse)
throw new InvalidOperationException("Elasticsearch query failed.");
var summary = resp.Aggregations?.GetStringTerms("by_department")?.Buckets
.Select(deptBucket => new
{
department = deptBucket.Key,
total = deptBucket.DocCount,
bySeverity = Enumerable.Empty<object>()
}) ?? Enumerable.Empty<object>();
return new { from, to, summary };
}
public async Task<object> SearchPatientsAsync(string? q, string? department, string? status,
int page, int pageSize)
{
var filters = new List<Action<QueryDescriptor<PatientEncounterDocument>>>();
if (!string.IsNullOrEmpty(department))
filters.Add(fq => fq.Term(t => t.Field(p => p.Department).Value(department)));
if (!string.IsNullOrEmpty(status))
filters.Add(fq => fq.Term(t => t.Field(p => p.Status).Value(status)));
var resp = await _elastic.SearchAsync<PatientEncounterDocument>(s => s
.Indices(_options.Indices.PatientEncounters)
.Query(q2 => q2
.Bool(b =>
{
if (!string.IsNullOrWhiteSpace(q))
{
b.Should(
// MRN: exact keyword match — boosted because MRN lookup is
// the most common search pattern for clinicians who know the number
s2 => s2.Term(t => t.Field(p => p.Mrn).Value(q).Boost(3)),
// Patient name: full-text across the analyzed field
s2 => s2.Match(m => m.Field(p => p.PatientName).Query(q)),
// Department: exact filter match included in should for relevance scoring
s2 => s2.Term(t => t.Field(p => p.Department).Value(q))
);
b.MinimumShouldMatch(1);
}
else
{
b.Must(m => m.MatchAll(_ => { }));
}
if (filters.Count > 0)
b.Filter(filters.ToArray());
})
)
.From(page * pageSize)
.Size(pageSize));
if (!resp.IsValidResponse)
throw new InvalidOperationException("Elasticsearch query failed.");
return new
{
total = resp.Total,
page,
pageSize,
data = resp.Documents
};
}
}
@@ -34,7 +34,10 @@ public class EncounterService : IEncounterService
public async Task<EncounterStatusTransitionResult> TransitionStatusAsync(
Guid encounterId, EncounterStatus targetStatus)
{
var encounter = await _db.Encounters.FindAsync(encounterId);
var encounter = await _db.Encounters
.Include(e => e.Patient)
.FirstOrDefaultAsync(e => e.Id == encounterId);
if (encounter is null)
throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND");
@@ -57,8 +60,13 @@ public class EncounterService : IEncounterService
{
encounterId,
patientId = encounter.PatientId,
mrn = encounter.Patient.Mrn,
patientName = $"{encounter.Patient.FirstName} {encounter.Patient.LastName}",
previousStatus = previousStatus.ToDbString(),
newStatus = targetStatus.ToDbString(),
department = encounter.Department,
attendingPhysician = encounter.AttendingPhysician,
admittedAt = encounter.AdmittedAt,
changedAt = DateTimeOffset.UtcNow
}),
PartitionKey = encounterId.ToString(),
@@ -0,0 +1,11 @@
public interface IAnalyticsService
{
Task<object> GetPopulationAsync(string code, decimal threshold, string direction,
DateTimeOffset? from, DateTimeOffset? to);
Task<object> GetObservationTrendAsync(Guid encounterId, string code,
DateTimeOffset? from, DateTimeOffset? to);
Task<object> GetAlertSummaryAsync(string? severity, string? department,
DateTimeOffset? from, DateTimeOffset? to);
Task<object> SearchPatientsAsync(string? q, string? department, string? status,
int page, int pageSize);
}
@@ -22,6 +22,7 @@ public class ObservationService : IObservationService
{
// Step 1 — encounter must be active
var encounter = await _db.Encounters
.Include(e => e.Patient)
.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == encounterId);
@@ -112,6 +113,7 @@ public class ObservationService : IObservationService
alertId = alert.Id,
encounterId,
patientId = encounter.PatientId,
department = encounter.Department,
alertType = alert.AlertType.ToDbString(),
severity = alert.Severity.ToDbString(),
triggeredAt = alert.TriggeredAt,
@@ -125,6 +127,7 @@ public class ObservationService : IObservationService
observationId = observation.Id,
encounterId,
patientId = encounter.PatientId,
mrn = encounter.Patient?.Mrn,
observationCode = req.ObservationCode,
value = req.Value,
unit = req.Unit,
@@ -1,3 +1,4 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
public class PatientService : IPatientService
@@ -87,6 +88,28 @@ public class PatientService : IPatientService
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,
attendingPhysician = encounter.AttendingPhysician,
admittedAt = encounter.AdmittedAt,
changedAt = DateTimeOffset.UtcNow
}),
PartitionKey = encounter.Id.ToString(),
CreatedAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync();
return encounter;
}
@@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="Confluent.Kafka" Version="2.14.0" />
<PackageReference Include="Elastic.Clients.Elasticsearch" Version="8.13.12" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+8
View File
@@ -46,5 +46,13 @@
"NumPartitions": 6,
"OutboxBatchSize": 100,
"OutboxPollIntervalMs": 500
},
"Elasticsearch": {
"Uri": "http://localhost:9200",
"Indices": {
"PatientEncounters": "patient_encounters",
"Observations": "observations",
"ClinicalAlerts": "clinical_alerts"
}
}
}