11using DataAcquisition . Domain . Application . Models ;
2- using DataAcquisition . Domain . Application . Models . Exceptions ;
32using LantanaGroup . Link . DataAcquisition . Domain . Application . Models ;
43using LantanaGroup . Link . DataAcquisition . Domain . Application . Models . Exceptions ;
4+ using LantanaGroup . Link . DataAcquisition . Domain . Application . Validators ;
55using LantanaGroup . Link . DataAcquisition . Domain . Infrastructure ;
66using LantanaGroup . Link . DataAcquisition . Domain . Infrastructure . Entities ;
7- using LantanaGroup . Link . DataAcquisition . Domain . Infrastructure . Interfaces ;
8- using LantanaGroup . Link . DataAcquisition . Domain . Infrastructure . Models . QueryConfig ;
97using LantanaGroup . Link . Shared . Application . Models ;
108using Microsoft . Extensions . Logging ;
119
@@ -23,11 +21,60 @@ public class QueryPlanManager : IQueryPlanManager
2321{
2422 private readonly IDatabase _database ;
2523 private readonly ILogger < QueryPlanManager > _logger ;
24+ private readonly IQueryPlanValidator _validator ;
2625
27- public QueryPlanManager ( IDatabase database , ILogger < QueryPlanManager > logger )
26+ public QueryPlanManager (
27+ IDatabase database ,
28+ ILogger < QueryPlanManager > logger ,
29+ IQueryPlanValidator validator )
2830 {
2931 _database = database ?? throw new ArgumentNullException ( nameof ( database ) ) ;
3032 _logger = logger ?? throw new ArgumentNullException ( nameof ( logger ) ) ;
33+ _validator = validator ?? throw new ArgumentNullException ( nameof ( validator ) ) ;
34+ }
35+
36+ /// <summary>
37+ /// Returns a sanitized version of a value that is safe to include in log messages.
38+ /// Removes newline characters to help prevent log forging.
39+ /// </summary>
40+ /// <param name="value">The original value.</param>
41+ /// <returns>A logging-safe value.</returns>
42+ private static string ? SanitizeForLog ( string ? value )
43+ {
44+ if ( value == null )
45+ {
46+ return null ;
47+ }
48+
49+ // Remove carriage return and line feed characters that can break log structure.
50+ return value . Replace ( "\r " , string . Empty )
51+ . Replace ( "\n " , string . Empty ) ;
52+ }
53+
54+ /// <summary>
55+ /// Sanitizes log messages derived from user input to prevent log forging by removing line breaks.
56+ /// </summary>
57+ /// <param name="messages">The collection of messages to sanitize.</param>
58+ /// <returns>An enumerable of sanitized messages.</returns>
59+ private static IEnumerable < string > SanitizeLogMessages ( IEnumerable < string > messages )
60+ {
61+ if ( messages == null )
62+ {
63+ yield break ;
64+ }
65+
66+ foreach ( var message in messages )
67+ {
68+ if ( message == null )
69+ {
70+ continue ;
71+ }
72+
73+ // Replace carriage returns and newlines with spaces to keep each log entry on a single line.
74+ yield return message
75+ . Replace ( "\r " , " " )
76+ . Replace ( "\n " , " " ) ;
77+ }
3178 }
3279
3380 public async Task < QueryPlanModel > AddAsync ( CreateQueryPlanModel model , CancellationToken cancellationToken = default )
@@ -37,9 +84,27 @@ public async Task<QueryPlanModel> AddAsync(CreateQueryPlanModel model, Cancellat
3784 throw new ArgumentNullException ( nameof ( model ) , "CreateQueryPlanModel cannot be null." ) ;
3885 }
3986
40- //// Validate query order
41- ValidateQueryOrder ( model . InitialQueries , "InitialQueries" ) ;
42- ValidateQueryOrder ( model . SupplementalQueries , "SupplementalQueries" ) ;
87+ // Perform comprehensive validation
88+ var validationResult = _validator . ValidateQueryPlan ( model . InitialQueries , model . SupplementalQueries ) ;
89+
90+ var safeFacilityId = SanitizeForLog ( model . FacilityId ) ;
91+
92+ if ( ! validationResult . IsValid )
93+ {
94+ _logger . LogError ( "Query Plan validation failed for facility {FacilityId}: {Errors}" ,
95+ safeFacilityId ,
96+ string . Join ( "; " , SanitizeLogMessages ( validationResult . Errors ) ) ) ;
97+
98+ throw new BadRequestException ( $ "Query Plan validation failed: { validationResult . GetErrorMessage ( ) } ") ;
99+ }
100+
101+ // Log warnings if any exist
102+ if ( validationResult . Warnings . Any ( ) )
103+ {
104+ _logger . LogWarning ( "Query Plan validation warnings for facility {FacilityId}: {Warnings}" ,
105+ safeFacilityId ,
106+ string . Join ( "; " , SanitizeLogMessages ( validationResult . Warnings ) ) ) ;
107+ }
43108
44109 var date = DateTime . UtcNow ;
45110
@@ -59,6 +124,10 @@ public async Task<QueryPlanModel> AddAsync(CreateQueryPlanModel model, Cancellat
59124 entity = await _database . QueryPlanRepository . AddAsync ( entity ) ;
60125 await _database . QueryPlanRepository . SaveChangesAsync ( ) ;
61126
127+ _logger . LogInformation ( "Successfully created Query Plan for facility {FacilityId} with type {Type}" ,
128+ SanitizeForLog ( model . FacilityId ) ,
129+ model . Type ) ;
130+
62131 return QueryPlanModel . FromDomain ( entity ) ;
63132 }
64133
@@ -69,82 +138,88 @@ public async Task<QueryPlanModel> UpdateAsync(UpdateQueryPlanModel model, Cancel
69138 throw new ArgumentNullException ( nameof ( model ) , "UpdateQueryPlanModel cannot be null." ) ;
70139 }
71140
72- // Validate query order
73- ValidateQueryOrder ( model . InitialQueries , "InitialQueries" ) ;
74- ValidateQueryOrder ( model . SupplementalQueries , "SupplementalQueries" ) ;
141+ // Perform comprehensive validation
142+ var validationResult = _validator . ValidateQueryPlan ( model . InitialQueries , model . SupplementalQueries ) ;
75143
76- var existingQueryPlan = await _database . QueryPlanRepository . FirstOrDefaultAsync ( q => q . FacilityId == model . FacilityId && q . Type == model . Type ) ;
144+ if ( ! validationResult . IsValid )
145+ {
146+ _logger . LogError ( "Query Plan validation failed for facility {FacilityId}: {Errors}" ,
147+ SanitizeForLog ( model . FacilityId ) ,
148+ string . Join ( "; " , SanitizeLogMessages ( validationResult . Errors ) ) ) ;
149+
150+ throw new BadRequestException ( $ "Query Plan validation failed: { validationResult . GetErrorMessage ( ) } ") ;
151+ }
77152
78- if ( existingQueryPlan != null )
153+ // Log warnings if any exist
154+ if ( validationResult . Warnings . Any ( ) )
79155 {
80- existingQueryPlan . InitialQueries = model . InitialQueries ;
81- existingQueryPlan . SupplementalQueries = model . SupplementalQueries ;
82- existingQueryPlan . PlanName = model . PlanName ;
83- existingQueryPlan . EHRDescription = model . EHRDescription ;
84- existingQueryPlan . LookBack = model . LookBack ;
85- existingQueryPlan . ModifyDate = DateTime . UtcNow ;
156+ _logger . LogWarning ( "Query Plan validation warnings for facility {FacilityId}: {Warnings}" ,
157+ SanitizeForLog ( model . FacilityId ) ,
158+ string . Join ( "; " , SanitizeLogMessages ( validationResult . Warnings ) ) ) ;
159+ }
86160
87- await _database . QueryPlanRepository . SaveChangesAsync ( ) ;
161+ var existingQueryPlan = await _database . QueryPlanRepository . FirstOrDefaultAsync (
162+ q => q . FacilityId == model . FacilityId && q . Type == model . Type ) ;
88163
89- return QueryPlanModel . FromDomain ( existingQueryPlan ) ;
164+ if ( existingQueryPlan == null )
165+ {
166+ throw new NotFoundException ( $ "No Query Plan for FacilityId { model . FacilityId } and Type { model . Type } was found.") ;
90167 }
91168
92- throw new NotFoundException ( $ "No Query Plan for FacilityId { model . FacilityId } and Type { model . Type } was found.") ;
169+ existingQueryPlan . InitialQueries = model . InitialQueries ;
170+ existingQueryPlan . SupplementalQueries = model . SupplementalQueries ;
171+ existingQueryPlan . PlanName = model . PlanName ;
172+ existingQueryPlan . EHRDescription = model . EHRDescription ;
173+ existingQueryPlan . LookBack = model . LookBack ;
174+ existingQueryPlan . ModifyDate = DateTime . UtcNow ;
175+
176+ await _database . QueryPlanRepository . SaveChangesAsync ( ) ;
177+
178+ _logger . LogInformation ( "Successfully updated Query Plan for facility {FacilityId} with type {Type}" ,
179+ SanitizeForLog ( model . FacilityId ) ,
180+ model . Type ) ;
181+
182+ return QueryPlanModel . FromDomain ( existingQueryPlan ) ;
93183 }
94184
95185 public async Task DeleteAsync ( string facilityId , Frequency type , CancellationToken cancellationToken = default )
96186 {
97- var entity = await _database . QueryPlanRepository . SingleOrDefaultAsync ( q => q . FacilityId == facilityId && q . Type == type ) ;
187+ var entity = await _database . QueryPlanRepository . SingleOrDefaultAsync (
188+ q => q . FacilityId == facilityId && q . Type == type ) ;
98189
99- if ( entity != null )
100- {
101- _database . QueryPlanRepository . Remove ( entity ) ;
102- await _database . QueryPlanRepository . SaveChangesAsync ( ) ;
103- }
104- else
190+ if ( entity == null )
105191 {
106192 throw new NotFoundException ( $ "No Query Plan for FacilityId { facilityId } and Type { type } was found.") ;
107193 }
194+
195+ _database . QueryPlanRepository . Remove ( entity ) ;
196+ await _database . QueryPlanRepository . SaveChangesAsync ( ) ;
197+
198+ _logger . LogInformation ( "Successfully deleted Query Plan for facility {FacilityId} with type {Type}" ,
199+ SanitizeForLog ( facilityId ) ,
200+ type ) ;
108201 }
109202
110203 public async Task DeleteAllQueryPlansAsync ( string facilityId , CancellationToken cancellationToken = default )
111204 {
112- // Get all query plans
113205 var allPlans = await _database . QueryPlanRepository . GetAllAsync ( cancellationToken ) ;
114-
115- // Filter by facilityId
116206 var facilityPlans = allPlans . Where ( q => q . FacilityId == facilityId ) . ToList ( ) ;
117207
118- // Remove each plan individually
119208 foreach ( var plan in facilityPlans )
120209 {
121210 _database . QueryPlanRepository . Remove ( plan ) ;
122211 }
123212
124- // Save changes once after all removals
125213 if ( facilityPlans . Any ( ) )
126214 {
127215 await _database . QueryPlanRepository . SaveChangesAsync ( cancellationToken ) ;
216+ _logger . LogInformation ( "Successfully deleted {Count} Query Plans for facility {FacilityId}" ,
217+ facilityPlans . Count ,
218+ SanitizeForLog ( facilityId ) ) ;
128219 }
129- }
130-
131- private void ValidateQueryOrder ( Dictionary < string , IQueryConfig > queries , string querySetName )
132- {
133- if ( queries == null ) return ;
134-
135- bool seenReference = false ;
136- foreach ( var kvp in queries . OrderBy ( q => int . TryParse ( q . Key , out var i ) ? i : int . MaxValue ) )
220+ else
137221 {
138- var config = kvp . Value ;
139- if ( config is ReferenceQueryConfig )
140- {
141- seenReference = true ;
142- }
143- else if ( config is ParameterQueryConfig && seenReference )
144- {
145- throw new IncorrectQueryPlanOrderException (
146- $ "All ReferenceQueryConfig entries must appear after all ParameterQueryConfig entries in { querySetName } .") ;
147- }
222+ _logger . LogInformation ( "No Query Plans found to delete for facility {FacilityId}" , SanitizeForLog ( facilityId ) ) ;
148223 }
149224 }
150225}
0 commit comments