Skip to content

Commit 0221b30

Browse files
authored
Merge pull request #409 from Resgrid/develop
RE1-T117 Checkin Timer fixes, Weather Alert Fixes, MsgQ fix.
2 parents 2b8c5f9 + 8e8fba4 commit 0221b30

10 files changed

Lines changed: 588 additions & 86 deletions

File tree

Core/Resgrid.Services/CheckInTimerService.cs

Lines changed: 226 additions & 23 deletions
Large diffs are not rendered by default.

Core/Resgrid.Services/WeatherAlertService.cs

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -400,8 +400,15 @@ public async Task SendPendingNotificationsAsync(CancellationToken ct = default)
400400
continue;
401401
}
402402

403-
bool shouldSend = ShouldSendAutoMessage(alert.Severity, schedule, legacyThreshold, department);
404-
if (shouldSend)
403+
var decision = GetAutoMessageDecision(alert.Severity, schedule, legacyThreshold, department);
404+
405+
// Outside the configured delivery window — leave NotificationSent false so a
406+
// later run inside the window still delivers it. Expiration/cancellation will
407+
// drop it from the pending set if the window never opens while it's active.
408+
if (decision == AutoMessageDecision.DeferOutsideWindow)
409+
continue;
410+
411+
if (decision == AutoMessageDecision.Send)
405412
{
406413
try
407414
{
@@ -659,19 +666,27 @@ private static string Truncate(string value, int maxLength)
659666
return value.Substring(0, maxLength);
660667
}
661668

662-
private static bool ShouldSendAutoMessage(int severity, List<AutoMessageSeveritySchedule> schedule, int legacyThreshold, Department department)
669+
private enum AutoMessageDecision
670+
{
671+
Send,
672+
SkipPermanently,
673+
DeferOutsideWindow
674+
}
675+
676+
private static AutoMessageDecision GetAutoMessageDecision(int severity, List<AutoMessageSeveritySchedule> schedule, int legacyThreshold, Department department)
663677
{
664678
if (schedule != null && schedule.Count > 0)
665679
{
666680
var entry = schedule.FirstOrDefault(s => s.Severity == severity);
667681

668682
// Severity not in schedule — don't send
669683
if (entry == null || !entry.Enabled)
670-
return false;
684+
return AutoMessageDecision.SkipPermanently;
671685

672-
// Check time window (StartHour == 0 && EndHour == 0 means 24h/always)
686+
// Legacy sentinel: settings saved before EndHour 24 existed use 0/0 for 24h/always.
687+
// The canonical form is now StartHour 0 / EndHour 24, handled by the window check below.
673688
if (entry.StartHour == 0 && entry.EndHour == 0)
674-
return true;
689+
return AutoMessageDecision.Send;
675690

676691
// Get department local time
677692
var now = DateTime.UtcNow;
@@ -680,20 +695,23 @@ private static bool ShouldSendAutoMessage(int severity, List<AutoMessageSeverity
680695

681696
int currentHour = now.Hour;
682697

698+
bool inWindow;
683699
if (entry.StartHour <= entry.EndHour)
684700
{
685-
// Same-day window: e.g. 6-18
686-
return currentHour >= entry.StartHour && currentHour < entry.EndHour;
701+
// Same-day window, EndHour exclusive: e.g. 6-24 (6am through end of day)
702+
inWindow = currentHour >= entry.StartHour && currentHour < entry.EndHour;
687703
}
688704
else
689705
{
690706
// Overnight window: e.g. 18-6 (6pm to 6am)
691-
return currentHour >= entry.StartHour || currentHour < entry.EndHour;
707+
inWindow = currentHour >= entry.StartHour || currentHour < entry.EndHour;
692708
}
709+
710+
return inWindow ? AutoMessageDecision.Send : AutoMessageDecision.DeferOutsideWindow;
693711
}
694712

695-
// Legacy: simple severity threshold
696-
return severity <= legacyThreshold;
713+
// Legacy: simple severity threshold (no time window, so never defer)
714+
return severity <= legacyThreshold ? AutoMessageDecision.Send : AutoMessageDecision.SkipPermanently;
697715
}
698716

699717
private class AutoMessageSeveritySchedule

Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,13 @@ public static async Task<bool> VerifyAndCreateClients(string clientName)
6666
}
6767
}
6868
}
69-
finally
70-
{
71-
_semaphore.Release();
72-
}
7369
}
7470
}
7571
finally
7672
{
73+
// Single release: the semaphore is acquired once above, so release it exactly once here.
74+
// The outer finally covers every path (primary success, host2/host3 fallback, and rethrow);
75+
// a second release in the fallback branch previously threw SemaphoreFullException.
7776
_semaphore.Release();
7877
}
7978

Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs

Lines changed: 139 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,28 @@ public async Task ResolveAllTimersForCallAsync_ReturnsEmpty_WhenNoConfigForTarge
112112
result.Should().BeEmpty();
113113
}
114114

115+
[Test]
116+
public async Task ResolveAllTimersForCallAsync_ResolvesCallTypeNameToId_ForOverrideMatching()
117+
{
118+
// Call.Type stores the call type NAME, not the id — the resolver must look the
119+
// id up from the department's call types for override matching to work.
120+
var call = new Call { CallId = 1, DepartmentId = 10, Type = "Structure Fire", Priority = 3, CheckInTimersEnabled = true };
121+
var overrides = new List<CheckInTimerOverride>
122+
{
123+
new CheckInTimerOverride { TimerTargetType = 0, CallTypeId = 7, CallPriority = 3, DurationMinutes = 12, WarningThresholdMinutes = 2, IsEnabled = true }
124+
};
125+
_configRepo.Setup(x => x.GetByDepartmentIdAsync(10)).ReturnsAsync(new List<CheckInTimerConfig>());
126+
_callsService.Setup(x => x.GetCallTypesForDepartmentAsync(10))
127+
.ReturnsAsync(new List<CallType> { new CallType { CallTypeId = 7, Type = "Structure Fire" } });
128+
_overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, 7, 3)).ReturnsAsync(overrides);
129+
130+
var result = await _service.ResolveAllTimersForCallAsync(call);
131+
132+
result.Should().HaveCount(1);
133+
result[0].DurationMinutes.Should().Be(12);
134+
result[0].IsFromOverride.Should().BeTrue();
135+
}
136+
115137
#endregion Timer Resolution
116138

117139
#region Timer Status
@@ -135,9 +157,10 @@ public async Task GetActiveTimerStatusesForCallAsync_Green_WhenElapsedLessThanDu
135157
}
136158

137159
[Test]
138-
public async Task GetActiveTimerStatusesForCallAsync_Warning_WhenElapsedBetweenDurationAndThreshold()
160+
public async Task GetActiveTimerStatusesForCallAsync_Warning_WhenWithinWarningThresholdOfDue()
139161
{
140-
var call = new Call { CallId = 1, DepartmentId = 10, State = (int)CallStates.Active, CheckInTimersEnabled = true, LoggedOn = DateTime.UtcNow.AddMinutes(-32) };
162+
// Duration 30 / warning 5: elapsed 27 leaves 3 minutes remaining -> Warning
163+
var call = new Call { CallId = 1, DepartmentId = 10, State = (int)CallStates.Active, CheckInTimersEnabled = true, LoggedOn = DateTime.UtcNow.AddMinutes(-27) };
141164
var configs = new List<CheckInTimerConfig>
142165
{
143166
new CheckInTimerConfig { TimerTargetType = 0, DurationMinutes = 30, WarningThresholdMinutes = 5, IsEnabled = true }
@@ -153,9 +176,10 @@ public async Task GetActiveTimerStatusesForCallAsync_Warning_WhenElapsedBetweenD
153176
}
154177

155178
[Test]
156-
public async Task GetActiveTimerStatusesForCallAsync_Critical_WhenElapsedExceedsThreshold()
179+
public async Task GetActiveTimerStatusesForCallAsync_Critical_WhenCheckInIsDue()
157180
{
158-
var call = new Call { CallId = 1, DepartmentId = 10, State = (int)CallStates.Active, CheckInTimersEnabled = true, LoggedOn = DateTime.UtcNow.AddMinutes(-40) };
181+
// Duration 30 / warning 5: elapsed 32 means the check-in is overdue -> Critical
182+
var call = new Call { CallId = 1, DepartmentId = 10, State = (int)CallStates.Active, CheckInTimersEnabled = true, LoggedOn = DateTime.UtcNow.AddMinutes(-32) };
159183
var configs = new List<CheckInTimerConfig>
160184
{
161185
new CheckInTimerConfig { TimerTargetType = 0, DurationMinutes = 30, WarningThresholdMinutes = 5, IsEnabled = true }
@@ -190,6 +214,40 @@ public async Task GetActiveTimerStatusesForCallAsync_EmptyList_WhenTimersNotEnab
190214
result.Should().BeEmpty();
191215
}
192216

217+
[Test]
218+
public async Task GetActiveTimerStatusesForCallAsync_UnitTypeTimer_MatchesCheckInsByUnitsOfThatType()
219+
{
220+
var call = new Call { CallId = 1, DepartmentId = 10, State = (int)CallStates.Active, CheckInTimersEnabled = true, LoggedOn = DateTime.UtcNow.AddMinutes(-40) };
221+
var configs = new List<CheckInTimerConfig>
222+
{
223+
new CheckInTimerConfig { TimerTargetType = (int)CheckInTimerTargetType.UnitType, UnitTypeId = 2, DurationMinutes = 30, WarningThresholdMinutes = 5, IsEnabled = true }
224+
};
225+
_configRepo.Setup(x => x.GetByDepartmentIdAsync(10)).ReturnsAsync(configs);
226+
_overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, null, 0)).ReturnsAsync(new List<CheckInTimerOverride>());
227+
_unitsService.Setup(x => x.GetUnitsForDepartmentAsync(10)).ReturnsAsync(new List<Unit>
228+
{
229+
new Unit { UnitId = 5, DepartmentId = 10, Type = "Engine" },
230+
new Unit { UnitId = 6, DepartmentId = 10, Type = "Tender" }
231+
});
232+
_unitsService.Setup(x => x.GetUnitTypesForDepartmentAsync(10)).ReturnsAsync(new List<UnitType>
233+
{
234+
new UnitType { UnitTypeId = 2, DepartmentId = 10, Type = "Engine" },
235+
new UnitType { UnitTypeId = 3, DepartmentId = 10, Type = "Tender" }
236+
});
237+
// Unit 6 (wrong type) checked in most recently; unit 5 (matching type) earlier.
238+
_recordRepo.Setup(x => x.GetByCallIdAsync(1)).ReturnsAsync(new List<CheckInRecord>
239+
{
240+
new CheckInRecord { CheckInRecordId = "r1", CheckInType = (int)CheckInTimerTargetType.UnitType, UnitId = 5, Timestamp = DateTime.UtcNow.AddMinutes(-2) },
241+
new CheckInRecord { CheckInRecordId = "r2", CheckInType = (int)CheckInTimerTargetType.UnitType, UnitId = 6, Timestamp = DateTime.UtcNow.AddMinutes(-1) }
242+
});
243+
244+
var result = await _service.GetActiveTimerStatusesForCallAsync(call);
245+
246+
result.Should().HaveCount(1);
247+
result[0].UnitId.Should().Be(5);
248+
result[0].Status.Should().Be("Green");
249+
}
250+
193251
#endregion Timer Status
194252

195253
#region Check-in Operations
@@ -274,8 +332,79 @@ public async Task DeleteTimerConfigAsync_ReturnsFalse_WhenConfigNotFound()
274332
result.Should().BeFalse();
275333
}
276334

335+
[Test]
336+
public async Task SaveTimerConfigAsync_Throws_WhenDurationInvalid()
337+
{
338+
var config = new CheckInTimerConfig { DepartmentId = 10, TimerTargetType = 0, DurationMinutes = 0, WarningThresholdMinutes = 5 };
339+
340+
Func<Task> act = async () => await _service.SaveTimerConfigAsync(config);
341+
342+
await act.Should().ThrowAsync<InvalidOperationException>();
343+
}
344+
345+
[Test]
346+
public async Task SaveTimerConfigAsync_Throws_WhenWarningThresholdEqualsDuration()
347+
{
348+
var config = new CheckInTimerConfig { DepartmentId = 10, TimerTargetType = 0, DurationMinutes = 30, WarningThresholdMinutes = 30 };
349+
350+
Func<Task> act = async () => await _service.SaveTimerConfigAsync(config);
351+
352+
await act.Should().ThrowAsync<InvalidOperationException>();
353+
}
354+
355+
[Test]
356+
public async Task SaveTimerConfigAsync_Throws_WhenWarningThresholdExceedsDuration()
357+
{
358+
var config = new CheckInTimerConfig { DepartmentId = 10, TimerTargetType = 0, DurationMinutes = 15, WarningThresholdMinutes = 30 };
359+
360+
Func<Task> act = async () => await _service.SaveTimerConfigAsync(config);
361+
362+
await act.Should().ThrowAsync<InvalidOperationException>();
363+
}
364+
365+
[Test]
366+
public async Task SaveTimerConfigAsync_ClearsUnitTypeId_ForNonUnitTypeTargets()
367+
{
368+
var config = new CheckInTimerConfig { DepartmentId = 10, TimerTargetType = (int)CheckInTimerTargetType.Personnel, UnitTypeId = 5, DurationMinutes = 30, WarningThresholdMinutes = 5 };
369+
_configRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny<CheckInTimerConfig>(), It.IsAny<CancellationToken>(), It.IsAny<bool>()))
370+
.ReturnsAsync((CheckInTimerConfig c, CancellationToken ct, bool b) => c);
371+
372+
var result = await _service.SaveTimerConfigAsync(config);
373+
374+
result.UnitTypeId.Should().BeNull();
375+
}
376+
277377
#endregion CRUD
278378

379+
#region Per-User Summaries
380+
381+
[Test]
382+
public async Task GetUserActiveCallCheckInSummariesAsync_IgnoresNonPersonnelCheckIns()
383+
{
384+
var call = new Call { CallId = 1, DepartmentId = 10, Priority = 0, State = (int)CallStates.Active, CheckInTimersEnabled = true, LoggedOn = DateTime.UtcNow.AddMinutes(-40) };
385+
_callsService.Setup(x => x.GetActiveCallsWithCheckInTimersForUserAsync("user1", 10)).ReturnsAsync(new List<Call> { call });
386+
_configRepo.Setup(x => x.GetByDepartmentIdAsync(10)).ReturnsAsync(new List<CheckInTimerConfig>
387+
{
388+
new CheckInTimerConfig { TimerTargetType = (int)CheckInTimerTargetType.Personnel, DurationMinutes = 30, WarningThresholdMinutes = 5, IsEnabled = true }
389+
});
390+
_overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, null, 0)).ReturnsAsync(new List<CheckInTimerOverride>());
391+
// The user's only check-in on the call is an IC check-in — it must not reset
392+
// their personnel timer (same semantics as the per-personnel endpoint).
393+
_recordRepo.Setup(x => x.GetByCallIdAsync(1)).ReturnsAsync(new List<CheckInRecord>
394+
{
395+
new CheckInRecord { CheckInRecordId = "r1", CheckInType = (int)CheckInTimerTargetType.IC, UserId = "user1", Timestamp = DateTime.UtcNow.AddMinutes(-2) }
396+
});
397+
398+
var result = await _service.GetUserActiveCallCheckInSummariesAsync("user1", 10);
399+
400+
result.Should().HaveCount(1);
401+
result[0].LastCheckIn.Should().BeNull();
402+
result[0].NeedsCheckIn.Should().BeTrue();
403+
result[0].Status.Should().Be("Critical");
404+
}
405+
406+
#endregion Per-User Summaries
407+
279408
#region ActiveForStates Propagation
280409

281410
[Test]
@@ -356,8 +485,8 @@ public async Task GetActiveTimerStatusesForCallAsync_FiltersOut_WhenPersonnelSta
356485
_overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, null, 0)).ReturnsAsync(new List<CheckInTimerOverride>());
357486
_recordRepo.Setup(x => x.GetByCallIdAsync(1)).ReturnsAsync(new List<CheckInRecord>());
358487
// User is Responding (2), not On Scene (3)
359-
_actionLogsService.Setup(x => x.GetLastActionLogForUserAsync("user1", null))
360-
.ReturnsAsync(new ActionLog { ActionTypeId = (int)ActionTypes.Responding });
488+
_actionLogsService.Setup(x => x.GetLastActionLogsForDepartmentAsync(10, It.IsAny<bool>(), It.IsAny<bool>()))
489+
.ReturnsAsync(new List<ActionLog> { new ActionLog { UserId = "user1", ActionTypeId = (int)ActionTypes.Responding } });
361490

362491
var result = await _service.GetActiveTimerStatusesForCallAsync(call);
363492

@@ -382,8 +511,8 @@ public async Task GetActiveTimerStatusesForCallAsync_IncludesTimer_WhenPersonnel
382511
_overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, null, 0)).ReturnsAsync(new List<CheckInTimerOverride>());
383512
_recordRepo.Setup(x => x.GetByCallIdAsync(1)).ReturnsAsync(new List<CheckInRecord>());
384513
// User is On Scene (3) - matches
385-
_actionLogsService.Setup(x => x.GetLastActionLogForUserAsync("user1", null))
386-
.ReturnsAsync(new ActionLog { ActionTypeId = (int)ActionTypes.OnScene });
514+
_actionLogsService.Setup(x => x.GetLastActionLogsForDepartmentAsync(10, It.IsAny<bool>(), It.IsAny<bool>()))
515+
.ReturnsAsync(new List<ActionLog> { new ActionLog { UserId = "user1", ActionTypeId = (int)ActionTypes.OnScene } });
387516

388517
var result = await _service.GetActiveTimerStatusesForCallAsync(call);
389518

@@ -431,8 +560,8 @@ public async Task GetActiveTimerStatusesForCallAsync_FiltersOut_WhenUnitStateDoe
431560
_overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, null, 0)).ReturnsAsync(new List<CheckInTimerOverride>());
432561
_recordRepo.Setup(x => x.GetByCallIdAsync(1)).ReturnsAsync(new List<CheckInRecord>());
433562
// Unit is Responding (5), not On Scene (6)
434-
_unitsService.Setup(x => x.GetLastUnitStateByUnitIdAsync(5))
435-
.ReturnsAsync(new UnitState { State = (int)UnitStateTypes.Responding });
563+
_unitsService.Setup(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(10))
564+
.ReturnsAsync(new List<UnitState> { new UnitState { UnitId = 5, State = (int)UnitStateTypes.Responding } });
436565

437566
var result = await _service.GetActiveTimerStatusesForCallAsync(call);
438567

Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,19 @@ public async Task<ActionResult<SaveCheckInTimerConfigResult>> SaveTimerConfig([F
102102
CreatedByUserId = UserId
103103
};
104104

105-
var saved = await _checkInTimerService.SaveTimerConfigAsync(config, cancellationToken);
105+
CheckInTimerConfig saved;
106+
try
107+
{
108+
saved = await _checkInTimerService.SaveTimerConfigAsync(config, cancellationToken);
109+
}
110+
catch (System.InvalidOperationException ex)
111+
{
112+
return BadRequest(ex.Message);
113+
}
114+
catch (System.UnauthorizedAccessException)
115+
{
116+
return NotFound();
117+
}
106118

107119
result.Id = saved.CheckInTimerConfigId;
108120
result.PageSize = 1;
@@ -123,7 +135,16 @@ public async Task<ActionResult<SaveCheckInTimerConfigResult>> DeleteTimerConfig(
123135
{
124136
var result = new SaveCheckInTimerConfigResult();
125137

126-
var deleted = await _checkInTimerService.DeleteTimerConfigAsync(configId, DepartmentId, cancellationToken);
138+
bool deleted;
139+
try
140+
{
141+
deleted = await _checkInTimerService.DeleteTimerConfigAsync(configId, DepartmentId, cancellationToken);
142+
}
143+
catch (System.UnauthorizedAccessException)
144+
{
145+
return NotFound();
146+
}
147+
127148
if (!deleted)
128149
return NotFound();
129150

@@ -201,7 +222,19 @@ public async Task<ActionResult<SaveCheckInTimerOverrideResult>> SaveTimerOverrid
201222
CreatedByUserId = UserId
202223
};
203224

204-
var saved = await _checkInTimerService.SaveTimerOverrideAsync(ovr, cancellationToken);
225+
CheckInTimerOverride saved;
226+
try
227+
{
228+
saved = await _checkInTimerService.SaveTimerOverrideAsync(ovr, cancellationToken);
229+
}
230+
catch (System.InvalidOperationException ex)
231+
{
232+
return BadRequest(ex.Message);
233+
}
234+
catch (System.UnauthorizedAccessException)
235+
{
236+
return NotFound();
237+
}
205238

206239
result.Id = saved.CheckInTimerOverrideId;
207240
result.PageSize = 1;
@@ -222,7 +255,16 @@ public async Task<ActionResult<SaveCheckInTimerOverrideResult>> DeleteTimerOverr
222255
{
223256
var result = new SaveCheckInTimerOverrideResult();
224257

225-
var deleted = await _checkInTimerService.DeleteTimerOverrideAsync(overrideId, DepartmentId, cancellationToken);
258+
bool deleted;
259+
try
260+
{
261+
deleted = await _checkInTimerService.DeleteTimerOverrideAsync(overrideId, DepartmentId, cancellationToken);
262+
}
263+
catch (System.UnauthorizedAccessException)
264+
{
265+
return NotFound();
266+
}
267+
226268
if (!deleted)
227269
return NotFound();
228270

@@ -337,6 +379,9 @@ public async Task<ActionResult<PerformCheckInResult>> PerformCheckIn([FromBody]
337379
if (!call.CheckInTimersEnabled || call.State != (int)CallStates.Active)
338380
return BadRequest("Check-in timers are not enabled or call is not active.");
339381

382+
if (!System.Enum.IsDefined(typeof(CheckInTimerTargetType), input.CheckInType))
383+
return BadRequest("Invalid check-in type.");
384+
340385
var record = new CheckInRecord
341386
{
342387
DepartmentId = DepartmentId,

0 commit comments

Comments
 (0)