-
Notifications
You must be signed in to change notification settings - Fork 1
LEGLINK-828: Tenant service integration with the DMRP module #1848
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 8 commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
f81043c
LEGLINK-828: Derive facility scheduled reports from DMRP reporting plans
MikeAtPinnacle 0aa11f8
LEGLINK-828: Point the scheduled reports refusal at a remedy that works
MikeAtPinnacle 030381c
TECH_DEBT: Document the DMRP controller endpoints
MikeAtPinnacle 5b6fc3a
LEGLINK-828: Let the Admin UI create a facility when DMRP is enabled
MikeAtPinnacle 7572df2
LEGLINK-828: Refuse a referenced measure mapping with a conflict, notβ¦
MikeAtPinnacle 255a051
Merge branch 'dev' into users/mtherien/leglink-709
MikeAtPinnacle 26db3dc
LEGLINK-828: Address review findings and unblock the Backend E2E suite
MikeAtPinnacle e817701
LEGLINK-828: Delete a facility and its reporting plans in one transacβ¦
MikeAtPinnacle 503bab0
LEGLINK-828: Cover the measure mapping delete backstop and refuse a mβ¦
MikeAtPinnacle 8c4b241
LEGLINK-828: Set up automation facilities correctly whether or not DMβ¦
MikeAtPinnacle eaaec0c
LEGLINK-828: Cover the DMRP endpoints in API Health
MikeAtPinnacle 8e9789c
Merge branch 'dev' into users/mtherien/leglink-709
MikeAtPinnacle 2c191f6
Merge remote-tracking branch 'origin/dev' into users/mtherien/leglinkβ¦
MikeAtPinnacle 465336c
LEGLINK-828: Stop the facility form requiring at least one scheduled β¦
MikeAtPinnacle 98a28a5
Merge branch 'dev' into users/mtherien/leglink-709
MikeAtPinnacle File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| using LantanaGroup.Link.DMRP.Data.Entities; | ||
| using LantanaGroup.Link.Shared.Application.Services.Security; | ||
| using LantanaGroup.Link.Shared.Domain.Repositories.Interfaces; | ||
|
|
||
| namespace LantanaGroup.Link.DMRP.Business | ||
| { | ||
| /// <summary> | ||
| /// Reads a facility's reporting plan out of the module's own tables, resolving each row through | ||
| /// its measure mapping. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// This is the source of reporting plans until the DMRP API client exists. It reports what Link | ||
| /// last recorded rather than what the API says right now, so a plan is only as current as the last | ||
| /// write to the reporting plans table. | ||
| /// </remarks> | ||
| public sealed class DbBackedReportingPlanSource : IReportingPlanSource | ||
| { | ||
| private readonly ILogger<DbBackedReportingPlanSource> _logger; | ||
| private readonly IEntityRepository<FacilityReportingPlan> _plans; | ||
| private readonly IEntityRepository<MeasureMapping> _measureMappings; | ||
|
|
||
| public DbBackedReportingPlanSource(ILogger<DbBackedReportingPlanSource> logger, | ||
| IEntityRepository<FacilityReportingPlan> plans, | ||
| IEntityRepository<MeasureMapping> measureMappings) | ||
| { | ||
| _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||
| _plans = plans ?? throw new ArgumentNullException(nameof(plans)); | ||
| _measureMappings = measureMappings ?? throw new ArgumentNullException(nameof(measureMappings)); | ||
| } | ||
|
|
||
| public async Task<IReadOnlyList<ReportingPlanEntry>> GetForPeriodAsync(string facilityId, int month, int year, | ||
| CancellationToken cancellationToken = default) | ||
| { | ||
| ArgumentException.ThrowIfNullOrWhiteSpace(facilityId); | ||
|
|
||
| var plans = await _plans.FindAsync(p => p.FacilityId == facilityId | ||
| && p.ReportingMonth == month | ||
| && p.ReportingYear == year | ||
| && p.IsReporting, cancellationToken); | ||
|
|
||
| if (plans.Count == 0) | ||
| { | ||
| return Array.Empty<ReportingPlanEntry>(); | ||
| } | ||
|
|
||
| var mappingIds = plans.Select(p => p.MeasureMappingId).Distinct().ToList(); | ||
|
|
||
| var mappings = await _measureMappings.FindAsync(m => mappingIds.Contains(m.Id), cancellationToken); | ||
|
|
||
| var mappingsById = mappings.ToDictionary(m => m.Id); | ||
|
|
||
| var entries = new List<ReportingPlanEntry>(plans.Count); | ||
|
|
||
| foreach (var plan in plans) | ||
| { | ||
| if (!mappingsById.TryGetValue(plan.MeasureMappingId, out var mapping)) | ||
| { | ||
| // The reporting plan's foreign key guarantees the mapping row exists, so this is a | ||
| // read that raced a delete rather than an ordinary miss. | ||
| _logger.LogWarning( | ||
| "Reporting plan {PlanId} for facility {FacilityId} references measure mapping {MeasureMappingId}, which was not found. The measure is excluded from the facility's schedule.", | ||
| plan.Id.SanitizeForLog(), facilityId.SanitizeForLog(), plan.MeasureMappingId.SanitizeForLog()); | ||
|
|
||
| continue; | ||
| } | ||
|
|
||
| entries.Add(new ReportingPlanEntry(mapping.Measure, mapping.DQM, mapping.Frequency)); | ||
| } | ||
|
|
||
| return entries; | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,268 @@ | ||
| using LantanaGroup.Link.DMRP.Business.Managers; | ||
| using LantanaGroup.Link.DMRP.Models.Exceptions; | ||
| using LantanaGroup.Link.Shared.Application.Models; | ||
| using LantanaGroup.Link.Shared.Application.Models.Tenant; | ||
| using LantanaGroup.Link.Shared.Application.Services.Security; | ||
| using LantanaGroup.Link.DMRP.Data.Entities; | ||
| using LantanaGroup.Link.Shared.Domain.Repositories.Interfaces; | ||
|
|
||
| namespace LantanaGroup.Link.DMRP.Business | ||
| { | ||
| /// <summary> | ||
| /// Adds NHSN measure enrollment to the host's facility operations. A facility's scheduled reports | ||
| /// stop being something a caller supplies and become something derived from the facility's DMRP | ||
| /// reporting plans, and a facility that is removed outright takes its reporting plans with it. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Every operation still runs the host's (tenant service) implementation. This type owns only the difference | ||
| /// DMRP makes, so the host remains the single place facilities are validated, persisted and | ||
| /// scheduled. | ||
| /// </remarks> | ||
| public sealed class DmrpFacilityOperations : IFacilityOperations | ||
| { | ||
| private readonly ILogger<DmrpFacilityOperations> _logger; | ||
| private readonly IFacilityOperations _hostImplementation; | ||
| private readonly IReportingPlanSource _reportingPlans; | ||
| private readonly IFacilityReportingPlanManager _reportingPlanManager; | ||
|
|
||
| /// <summary> | ||
| /// Held for its transaction control only. Facilities and reporting plans persist through the | ||
| /// same context, so a transaction opened here covers the host's writes as well as this | ||
| /// module's. | ||
| /// </summary> | ||
| private readonly IEntityRepository<FacilityReportingPlan> _reportingPlanRepository; | ||
|
|
||
| private readonly TimeProvider _timeProvider; | ||
|
|
||
| public DmrpFacilityOperations(ILogger<DmrpFacilityOperations> logger, | ||
| IFacilityOperations hostImplementation, | ||
| IReportingPlanSource reportingPlans, | ||
| IFacilityReportingPlanManager reportingPlanManager, | ||
| IEntityRepository<FacilityReportingPlan> reportingPlanRepository, | ||
| TimeProvider timeProvider) | ||
| { | ||
| _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||
| _hostImplementation = hostImplementation ?? throw new ArgumentNullException(nameof(hostImplementation)); | ||
| _reportingPlans = reportingPlans ?? throw new ArgumentNullException(nameof(reportingPlans)); | ||
| _reportingPlanManager = reportingPlanManager ?? throw new ArgumentNullException(nameof(reportingPlanManager)); | ||
| _reportingPlanRepository = reportingPlanRepository ?? throw new ArgumentNullException(nameof(reportingPlanRepository)); | ||
| _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); | ||
| } | ||
|
|
||
| public async Task CreateAsync(FacilityModel facility, CancellationToken cancellationToken = default) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(facility); | ||
|
|
||
| RejectCallerSuppliedSchedule(facility); | ||
|
|
||
| facility.ScheduledReports = await BuildScheduleAsync(facility, cancellationToken); | ||
|
|
||
| await _hostImplementation.CreateAsync(facility, cancellationToken); | ||
| } | ||
|
|
||
| public async Task UpdateAsync(FacilityModel existingFacility, FacilityModel updatedFacility, | ||
| CancellationToken cancellationToken = default) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(existingFacility); | ||
| ArgumentNullException.ThrowIfNull(updatedFacility); | ||
|
|
||
| RejectCallerSuppliedSchedule(updatedFacility); | ||
|
|
||
| // The period is read from the timezone being saved, not the one on record, so a facility | ||
| // that moves timezone in the same request is scheduled against the period it moved to. | ||
| updatedFacility.ScheduledReports = await BuildScheduleAsync(updatedFacility, cancellationToken); | ||
|
|
||
| await _hostImplementation.UpdateAsync(existingFacility, updatedFacility, cancellationToken); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Removes the facility and its reporting plans as one unit. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// The order still matters - the host's delete can refuse, and plans removed ahead of a refused | ||
| /// delete would leave a facility that reports nothing - but order alone is not enough. If the | ||
| /// plan cleanup failed after the facility row was gone, the plans were stranded: nothing ever | ||
| /// collected them, they blocked measure mapping deletes, and a facility later created with the | ||
| /// same id silently inherited a previous incarnation's schedule. | ||
| /// <para> | ||
| /// Both live in the host's database context, so one transaction covers them. Quartz keeps its | ||
| /// own store and cannot enlist, so a rollback leaves the restored facility without its jobs | ||
| /// until the delete is retried or the service restarts - <c>ScheduleService.StartAsync</c> | ||
| /// rebuilds jobs for every facility that is not deleted, and removing them is idempotent. That | ||
| /// heals; a stranded reporting plan does not. | ||
| /// </para> | ||
| /// </remarks> | ||
| public async Task DeleteAsync(string facilityId, CancellationToken cancellationToken = default) | ||
| { | ||
| await _reportingPlanRepository.StartTransactionAsync(cancellationToken); | ||
|
|
||
| int removed; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| try | ||
| { | ||
| await _hostImplementation.DeleteAsync(facilityId, cancellationToken); | ||
|
|
||
| removed = await _reportingPlanManager.DeleteForFacilityAsync(facilityId, cancellationToken); | ||
|
|
||
| await _reportingPlanRepository.CommitTransactionAsync(cancellationToken); | ||
| } | ||
| catch | ||
| { | ||
| await RollbackQuietlyAsync(facilityId, cancellationToken); | ||
| throw; | ||
| } | ||
|
|
||
| _logger.LogInformation("Deleted {Count} reporting plan(s) belonging to removed facility {FacilityId}", | ||
| removed, facilityId.SanitizeForLog()); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// A rollback that fails must not replace the error that caused it, or the caller is told about | ||
| /// the cleanup instead of the thing that actually went wrong. | ||
| /// </summary> | ||
| private async Task RollbackQuietlyAsync(string facilityId, CancellationToken cancellationToken) | ||
| { | ||
| try | ||
| { | ||
| await _reportingPlanRepository.RollbackTransactionAsync(cancellationToken); | ||
| } | ||
| catch (Exception rollbackFailure) | ||
| { | ||
| _logger.LogError(rollbackFailure, | ||
| "Rolling back the deletion of facility {FacilityId} failed. Its reporting plans may be left behind; clear them with DELETE api/dmrp/reporting-plans/facilities/{FacilityId}.", | ||
| facilityId.SanitizeForLog(), facilityId.SanitizeForLog()); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Soft delete keeps the facility's reporting plans. The facility can be restored, and the | ||
| /// plans are the record of what DMRP said it was enrolled to report while it was active. | ||
| /// </summary> | ||
| public Task SoftDeleteAsync(string facilityId, CancellationToken cancellationToken = default) => | ||
| _hostImplementation.SoftDeleteAsync(facilityId, cancellationToken); | ||
|
|
||
| public Task RestoreAsync(FacilityModel facility, CancellationToken cancellationToken = default) => | ||
| _hostImplementation.RestoreAsync(facility, cancellationToken); | ||
|
|
||
| /// <summary> | ||
| /// Turns the facility's enrolled measures into the schedule the host stores, grouping the dQMs | ||
| /// by the frequency their measure mapping carries. | ||
| /// </summary> | ||
| private async Task<TenantScheduledReportConfig> BuildScheduleAsync(FacilityModel facility, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| var facilityId = facility.FacilityId; | ||
|
|
||
| if (string.IsNullOrWhiteSpace(facilityId)) | ||
| { | ||
| // The host rejects this on its own with a message naming the field. Returning an empty | ||
| // schedule lets that happen instead of failing here on a lookup that cannot succeed. | ||
| return EmptySchedule(); | ||
| } | ||
|
|
||
| var (month, year) = CurrentPeriod(facility); | ||
|
|
||
| var entries = await _reportingPlans.GetForPeriodAsync(facilityId, month, year, cancellationToken); | ||
|
|
||
| if (entries.Count == 0) | ||
| { | ||
| _logger.LogInformation( | ||
| "Facility {FacilityId} has no reporting plans for {Month}/{Year}; it is scheduled for no reports.", | ||
| facilityId.SanitizeForLog(), month, year); | ||
|
|
||
| return EmptySchedule(); | ||
| } | ||
|
|
||
| var unmapped = entries.Where(e => string.IsNullOrWhiteSpace(e.DQM)).ToList(); | ||
|
|
||
| foreach (var entry in unmapped) | ||
| { | ||
| // The scheduling workflow records a measure DMRP returned that Link has no mapping for | ||
| // with a null dQM, precisely so it shows up here rather than being lost. | ||
| _logger.LogWarning( | ||
| "Facility {FacilityId} is enrolled in measure {Measure} for {Month}/{Year}, which has no dQM mapped. It is excluded from the facility's schedule.", | ||
| facilityId.SanitizeForLog(), entry.Measure.SanitizeForLog(), month, year); | ||
| } | ||
|
|
||
| return new TenantScheduledReportConfig | ||
| { | ||
| Daily = DqmsFor(entries, Frequency.Daily), | ||
| Weekly = DqmsFor(entries, Frequency.Weekly), | ||
| Monthly = DqmsFor(entries, Frequency.Monthly) | ||
| }; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// The distinct dQMs reported at one frequency. Two NHSN measures can map to the same dQM β the | ||
| /// ADR's example is a patient safety measure and a medication safety measure both under ACH | ||
| /// Monthly β and the host refuses a schedule that names one twice. | ||
| /// </summary> | ||
| private static string[] DqmsFor(IReadOnlyList<ReportingPlanEntry> entries, Frequency frequency) => | ||
| entries.Where(e => e.Frequency == frequency && !string.IsNullOrWhiteSpace(e.DQM)) | ||
| .Select(e => e.DQM) | ||
| .Distinct(StringComparer.Ordinal) | ||
| .ToArray(); | ||
|
|
||
| /// <summary> | ||
| /// The reporting period the facility is currently in, read in its own timezone so a facility | ||
| /// near a month boundary is scheduled against the month it is actually in. | ||
| /// </summary> | ||
| private (int Month, int Year) CurrentPeriod(FacilityModel facility) | ||
| { | ||
| var utcNow = _timeProvider.GetUtcNow(); | ||
|
|
||
| if (string.IsNullOrWhiteSpace(facility.TimeZone)) | ||
| { | ||
| return (utcNow.Month, utcNow.Year); | ||
| } | ||
|
|
||
| try | ||
| { | ||
| var timeZone = TimeZoneInfo.FindSystemTimeZoneById(facility.TimeZone); | ||
| var localNow = TimeZoneInfo.ConvertTime(utcNow, timeZone); | ||
|
|
||
| return (localNow.Month, localNow.Year); | ||
| } | ||
| catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException) | ||
| { | ||
| // The host validates the timezone and answers with a message naming it. Falling back | ||
| // to UTC here lets the request reach that validation rather than failing first with an | ||
| // error about reporting periods. | ||
| _logger.LogWarning(ex, | ||
| "Facility {FacilityId} has an unusable timezone; the reporting period was read in UTC instead.", | ||
| facility.FacilityId?.SanitizeForLog()); | ||
|
|
||
| return (utcNow.Month, utcNow.Year); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// The remedy the message names has to be one the caller can actually carry out. Leaving | ||
| /// scheduledReports out of the request body is not: its three arrays are non-nullable, so model | ||
| /// binding rejects an absent block before this ever runs. An empty block is what gets through. | ||
| /// </summary> | ||
| private static void RejectCallerSuppliedSchedule(FacilityModel facility) | ||
| { | ||
| if (!HasScheduledReports(facility.ScheduledReports)) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| throw new ScheduledReportsNotAcceptedException( | ||
| "Scheduled reports cannot be set on a facility while DMRP is enabled. They are derived from the facility's DMRP reporting plans. Resubmit with empty daily, weekly and monthly arrays in scheduledReports."); | ||
| } | ||
|
|
||
| private static bool HasScheduledReports(TenantScheduledReportConfig? schedule) => | ||
| schedule is not null | ||
| && ((schedule.Daily?.Length ?? 0) > 0 | ||
| || (schedule.Weekly?.Length ?? 0) > 0 | ||
| || (schedule.Monthly?.Length ?? 0) > 0); | ||
|
|
||
| private static TenantScheduledReportConfig EmptySchedule() => new() | ||
| { | ||
| Daily = Array.Empty<string>(), | ||
| Weekly = Array.Empty<string>(), | ||
| Monthly = Array.Empty<string>() | ||
| }; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| using LantanaGroup.Link.Shared.Application.Models.Tenant; | ||
|
|
||
| namespace LantanaGroup.Link.DMRP.Business | ||
| { | ||
| /// <summary> | ||
| /// The facility operations that change state. The host registers an implementation performing its | ||
| /// own persistence and job scheduling; when the DMRP module is enabled it decorates that | ||
| /// implementation rather than replacing it, so the host's behavior still runs underneath. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Reads, ad hoc reports and report regeneration are deliberately absent: DMRP attaches no | ||
| /// behavior to them, so routing them through this seam would add indirection with nothing behind | ||
| /// it. | ||
| /// </remarks> | ||
| public interface IFacilityOperations | ||
| { | ||
| Task CreateAsync(FacilityModel facility, CancellationToken cancellationToken = default); | ||
|
|
||
| /// <summary> | ||
| /// Applies <paramref name="updatedFacility"/> over the facility described by | ||
| /// <paramref name="existingFacility"/>. Both are needed because the scheduled jobs are | ||
| /// reconciled from the difference between them. | ||
| /// </summary> | ||
| Task UpdateAsync(FacilityModel existingFacility, FacilityModel updatedFacility, | ||
| CancellationToken cancellationToken = default); | ||
|
|
||
| Task DeleteAsync(string facilityId, CancellationToken cancellationToken = default); | ||
|
|
||
| Task SoftDeleteAsync(string facilityId, CancellationToken cancellationToken = default); | ||
|
|
||
| Task RestoreAsync(FacilityModel facility, CancellationToken cancellationToken = default); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.