Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 226 additions & 23 deletions Core/Resgrid.Services/CheckInTimerService.cs

Large diffs are not rendered by default.

40 changes: 29 additions & 11 deletions Core/Resgrid.Services/WeatherAlertService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -400,8 +400,15 @@ public async Task SendPendingNotificationsAsync(CancellationToken ct = default)
continue;
}

bool shouldSend = ShouldSendAutoMessage(alert.Severity, schedule, legacyThreshold, department);
if (shouldSend)
var decision = GetAutoMessageDecision(alert.Severity, schedule, legacyThreshold, department);

// Outside the configured delivery window — leave NotificationSent false so a
// later run inside the window still delivers it. Expiration/cancellation will
// drop it from the pending set if the window never opens while it's active.
if (decision == AutoMessageDecision.DeferOutsideWindow)
continue;

if (decision == AutoMessageDecision.Send)
{
try
{
Expand Down Expand Up @@ -659,19 +666,27 @@ private static string Truncate(string value, int maxLength)
return value.Substring(0, maxLength);
}

private static bool ShouldSendAutoMessage(int severity, List<AutoMessageSeveritySchedule> schedule, int legacyThreshold, Department department)
private enum AutoMessageDecision
{
Send,
SkipPermanently,
DeferOutsideWindow
}

private static AutoMessageDecision GetAutoMessageDecision(int severity, List<AutoMessageSeveritySchedule> schedule, int legacyThreshold, Department department)
{
if (schedule != null && schedule.Count > 0)
{
var entry = schedule.FirstOrDefault(s => s.Severity == severity);

// Severity not in schedule — don't send
if (entry == null || !entry.Enabled)
return false;
return AutoMessageDecision.SkipPermanently;

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

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

int currentHour = now.Hour;

bool inWindow;
if (entry.StartHour <= entry.EndHour)
{
// Same-day window: e.g. 6-18
return currentHour >= entry.StartHour && currentHour < entry.EndHour;
// Same-day window, EndHour exclusive: e.g. 6-24 (6am through end of day)
inWindow = currentHour >= entry.StartHour && currentHour < entry.EndHour;
}
else
{
// Overnight window: e.g. 18-6 (6pm to 6am)
return currentHour >= entry.StartHour || currentHour < entry.EndHour;
inWindow = currentHour >= entry.StartHour || currentHour < entry.EndHour;
}

return inWindow ? AutoMessageDecision.Send : AutoMessageDecision.DeferOutsideWindow;
}

// Legacy: simple severity threshold
return severity <= legacyThreshold;
// Legacy: simple severity threshold (no time window, so never defer)
return severity <= legacyThreshold ? AutoMessageDecision.Send : AutoMessageDecision.SkipPermanently;
}

private class AutoMessageSeveritySchedule
Expand Down
7 changes: 3 additions & 4 deletions Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,13 @@ public static async Task<bool> VerifyAndCreateClients(string clientName)
}
}
}
finally
{
_semaphore.Release();
}
}
}
finally
{
// Single release: the semaphore is acquired once above, so release it exactly once here.
// The outer finally covers every path (primary success, host2/host3 fallback, and rethrow);
// a second release in the fallback branch previously threw SemaphoreFullException.
_semaphore.Release();
}

Expand Down
149 changes: 139 additions & 10 deletions Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,28 @@ public async Task ResolveAllTimersForCallAsync_ReturnsEmpty_WhenNoConfigForTarge
result.Should().BeEmpty();
}

[Test]
public async Task ResolveAllTimersForCallAsync_ResolvesCallTypeNameToId_ForOverrideMatching()
{
// Call.Type stores the call type NAME, not the id — the resolver must look the
// id up from the department's call types for override matching to work.
var call = new Call { CallId = 1, DepartmentId = 10, Type = "Structure Fire", Priority = 3, CheckInTimersEnabled = true };
var overrides = new List<CheckInTimerOverride>
{
new CheckInTimerOverride { TimerTargetType = 0, CallTypeId = 7, CallPriority = 3, DurationMinutes = 12, WarningThresholdMinutes = 2, IsEnabled = true }
};
_configRepo.Setup(x => x.GetByDepartmentIdAsync(10)).ReturnsAsync(new List<CheckInTimerConfig>());
_callsService.Setup(x => x.GetCallTypesForDepartmentAsync(10))
.ReturnsAsync(new List<CallType> { new CallType { CallTypeId = 7, Type = "Structure Fire" } });
_overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, 7, 3)).ReturnsAsync(overrides);

var result = await _service.ResolveAllTimersForCallAsync(call);

result.Should().HaveCount(1);
result[0].DurationMinutes.Should().Be(12);
result[0].IsFromOverride.Should().BeTrue();
}

#endregion Timer Resolution

#region Timer Status
Expand All @@ -135,9 +157,10 @@ public async Task GetActiveTimerStatusesForCallAsync_Green_WhenElapsedLessThanDu
}

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

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

[Test]
public async Task GetActiveTimerStatusesForCallAsync_UnitTypeTimer_MatchesCheckInsByUnitsOfThatType()
{
var call = new Call { CallId = 1, DepartmentId = 10, State = (int)CallStates.Active, CheckInTimersEnabled = true, LoggedOn = DateTime.UtcNow.AddMinutes(-40) };
var configs = new List<CheckInTimerConfig>
{
new CheckInTimerConfig { TimerTargetType = (int)CheckInTimerTargetType.UnitType, UnitTypeId = 2, DurationMinutes = 30, WarningThresholdMinutes = 5, IsEnabled = true }
};
_configRepo.Setup(x => x.GetByDepartmentIdAsync(10)).ReturnsAsync(configs);
_overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, null, 0)).ReturnsAsync(new List<CheckInTimerOverride>());
_unitsService.Setup(x => x.GetUnitsForDepartmentAsync(10)).ReturnsAsync(new List<Unit>
{
new Unit { UnitId = 5, DepartmentId = 10, Type = "Engine" },
new Unit { UnitId = 6, DepartmentId = 10, Type = "Tender" }
});
_unitsService.Setup(x => x.GetUnitTypesForDepartmentAsync(10)).ReturnsAsync(new List<UnitType>
{
new UnitType { UnitTypeId = 2, DepartmentId = 10, Type = "Engine" },
new UnitType { UnitTypeId = 3, DepartmentId = 10, Type = "Tender" }
});
// Unit 6 (wrong type) checked in most recently; unit 5 (matching type) earlier.
_recordRepo.Setup(x => x.GetByCallIdAsync(1)).ReturnsAsync(new List<CheckInRecord>
{
new CheckInRecord { CheckInRecordId = "r1", CheckInType = (int)CheckInTimerTargetType.UnitType, UnitId = 5, Timestamp = DateTime.UtcNow.AddMinutes(-2) },
new CheckInRecord { CheckInRecordId = "r2", CheckInType = (int)CheckInTimerTargetType.UnitType, UnitId = 6, Timestamp = DateTime.UtcNow.AddMinutes(-1) }
});

var result = await _service.GetActiveTimerStatusesForCallAsync(call);

result.Should().HaveCount(1);
result[0].UnitId.Should().Be(5);
result[0].Status.Should().Be("Green");
}

#endregion Timer Status

#region Check-in Operations
Expand Down Expand Up @@ -274,8 +332,79 @@ public async Task DeleteTimerConfigAsync_ReturnsFalse_WhenConfigNotFound()
result.Should().BeFalse();
}

[Test]
public async Task SaveTimerConfigAsync_Throws_WhenDurationInvalid()
{
var config = new CheckInTimerConfig { DepartmentId = 10, TimerTargetType = 0, DurationMinutes = 0, WarningThresholdMinutes = 5 };

Func<Task> act = async () => await _service.SaveTimerConfigAsync(config);

await act.Should().ThrowAsync<InvalidOperationException>();
}

[Test]
public async Task SaveTimerConfigAsync_Throws_WhenWarningThresholdEqualsDuration()
{
var config = new CheckInTimerConfig { DepartmentId = 10, TimerTargetType = 0, DurationMinutes = 30, WarningThresholdMinutes = 30 };

Func<Task> act = async () => await _service.SaveTimerConfigAsync(config);

await act.Should().ThrowAsync<InvalidOperationException>();
}

[Test]
public async Task SaveTimerConfigAsync_Throws_WhenWarningThresholdExceedsDuration()
{
var config = new CheckInTimerConfig { DepartmentId = 10, TimerTargetType = 0, DurationMinutes = 15, WarningThresholdMinutes = 30 };

Func<Task> act = async () => await _service.SaveTimerConfigAsync(config);

await act.Should().ThrowAsync<InvalidOperationException>();
}

[Test]
public async Task SaveTimerConfigAsync_ClearsUnitTypeId_ForNonUnitTypeTargets()
{
var config = new CheckInTimerConfig { DepartmentId = 10, TimerTargetType = (int)CheckInTimerTargetType.Personnel, UnitTypeId = 5, DurationMinutes = 30, WarningThresholdMinutes = 5 };
_configRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny<CheckInTimerConfig>(), It.IsAny<CancellationToken>(), It.IsAny<bool>()))
.ReturnsAsync((CheckInTimerConfig c, CancellationToken ct, bool b) => c);

var result = await _service.SaveTimerConfigAsync(config);

result.UnitTypeId.Should().BeNull();
}

#endregion CRUD

#region Per-User Summaries

[Test]
public async Task GetUserActiveCallCheckInSummariesAsync_IgnoresNonPersonnelCheckIns()
{
var call = new Call { CallId = 1, DepartmentId = 10, Priority = 0, State = (int)CallStates.Active, CheckInTimersEnabled = true, LoggedOn = DateTime.UtcNow.AddMinutes(-40) };
_callsService.Setup(x => x.GetActiveCallsWithCheckInTimersForUserAsync("user1", 10)).ReturnsAsync(new List<Call> { call });
_configRepo.Setup(x => x.GetByDepartmentIdAsync(10)).ReturnsAsync(new List<CheckInTimerConfig>
{
new CheckInTimerConfig { TimerTargetType = (int)CheckInTimerTargetType.Personnel, DurationMinutes = 30, WarningThresholdMinutes = 5, IsEnabled = true }
});
_overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, null, 0)).ReturnsAsync(new List<CheckInTimerOverride>());
// The user's only check-in on the call is an IC check-in — it must not reset
// their personnel timer (same semantics as the per-personnel endpoint).
_recordRepo.Setup(x => x.GetByCallIdAsync(1)).ReturnsAsync(new List<CheckInRecord>
{
new CheckInRecord { CheckInRecordId = "r1", CheckInType = (int)CheckInTimerTargetType.IC, UserId = "user1", Timestamp = DateTime.UtcNow.AddMinutes(-2) }
});

var result = await _service.GetUserActiveCallCheckInSummariesAsync("user1", 10);

result.Should().HaveCount(1);
result[0].LastCheckIn.Should().BeNull();
result[0].NeedsCheckIn.Should().BeTrue();
result[0].Status.Should().Be("Critical");
}

#endregion Per-User Summaries

#region ActiveForStates Propagation

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

var result = await _service.GetActiveTimerStatusesForCallAsync(call);

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

var result = await _service.GetActiveTimerStatusesForCallAsync(call);

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

var result = await _service.GetActiveTimerStatusesForCallAsync(call);

Expand Down
53 changes: 49 additions & 4 deletions Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,19 @@ public async Task<ActionResult<SaveCheckInTimerConfigResult>> SaveTimerConfig([F
CreatedByUserId = UserId
};

var saved = await _checkInTimerService.SaveTimerConfigAsync(config, cancellationToken);
CheckInTimerConfig saved;
try
{
saved = await _checkInTimerService.SaveTimerConfigAsync(config, cancellationToken);
}
catch (System.InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
catch (System.UnauthorizedAccessException)
{
return NotFound();
}

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

var deleted = await _checkInTimerService.DeleteTimerConfigAsync(configId, DepartmentId, cancellationToken);
bool deleted;
try
{
deleted = await _checkInTimerService.DeleteTimerConfigAsync(configId, DepartmentId, cancellationToken);
}
catch (System.UnauthorizedAccessException)
{
return NotFound();
}

if (!deleted)
return NotFound();

Expand Down Expand Up @@ -201,7 +222,19 @@ public async Task<ActionResult<SaveCheckInTimerOverrideResult>> SaveTimerOverrid
CreatedByUserId = UserId
};

var saved = await _checkInTimerService.SaveTimerOverrideAsync(ovr, cancellationToken);
CheckInTimerOverride saved;
try
{
saved = await _checkInTimerService.SaveTimerOverrideAsync(ovr, cancellationToken);
}
catch (System.InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
catch (System.UnauthorizedAccessException)
{
return NotFound();
}

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

var deleted = await _checkInTimerService.DeleteTimerOverrideAsync(overrideId, DepartmentId, cancellationToken);
bool deleted;
try
{
deleted = await _checkInTimerService.DeleteTimerOverrideAsync(overrideId, DepartmentId, cancellationToken);
}
catch (System.UnauthorizedAccessException)
{
return NotFound();
}

if (!deleted)
return NotFound();

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

if (!System.Enum.IsDefined(typeof(CheckInTimerTargetType), input.CheckInType))
return BadRequest("Invalid check-in type.");

var record = new CheckInRecord
{
DepartmentId = DepartmentId,
Expand Down
Loading
Loading