feature: Elasticsearch CQRS Projection and Analytics Endpoints
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user