diff --git a/Core/Resgrid.Services/CheckInTimerService.cs b/Core/Resgrid.Services/CheckInTimerService.cs index 28507f3d8..df75fd5ef 100644 --- a/Core/Resgrid.Services/CheckInTimerService.cs +++ b/Core/Resgrid.Services/CheckInTimerService.cs @@ -43,9 +43,33 @@ public async Task> GetTimerConfigsForDepartmentAsync(in public async Task SaveTimerConfigAsync(CheckInTimerConfig config, CancellationToken cancellationToken = default) { + ValidateTimerValues(config.TimerTargetType, config.DurationMinutes, config.WarningThresholdMinutes); + + // Unit type only applies to unit-type timers; clear it for other targets so it + // can't create per-unit-type rows the resolver and unique-target lookup would mismatch + if (config.TimerTargetType != (int)CheckInTimerTargetType.UnitType) + config.UnitTypeId = null; + + // Only one config may exist per (department, target type, unit type) — + // enforced by the UQ_CheckInTimerConfigs_Dept_Target_Unit unique index + var existingForTarget = await _configRepository.GetByDepartmentAndTargetAsync( + config.DepartmentId, config.TimerTargetType, config.UnitTypeId); + if (string.IsNullOrWhiteSpace(config.CheckInTimerConfigId)) { - config.CreatedOn = DateTime.UtcNow; + if (existingForTarget != null) + { + // Saving a "new" config for a target that already has one — update the + // existing row instead of inserting a duplicate + config.CheckInTimerConfigId = existingForTarget.CheckInTimerConfigId; + config.CreatedOn = existingForTarget.CreatedOn; + config.CreatedByUserId = existingForTarget.CreatedByUserId; + config.UpdatedOn = DateTime.UtcNow; + } + else + { + config.CreatedOn = DateTime.UtcNow; + } } else { @@ -53,10 +77,36 @@ public async Task SaveTimerConfigAsync(CheckInTimerConfig co if (existing != null && existing.DepartmentId != config.DepartmentId) throw new UnauthorizedAccessException("Cannot modify a timer config belonging to another department."); + if (existingForTarget != null && existingForTarget.CheckInTimerConfigId != config.CheckInTimerConfigId) + throw new InvalidOperationException("A check-in timer configuration already exists for this target type and unit type."); + + if (existing != null) + config.CreatedOn = existing.CreatedOn; + config.UpdatedOn = DateTime.UtcNow; } - return await _configRepository.SaveOrUpdateAsync(config, cancellationToken); + var isInsert = string.IsNullOrWhiteSpace(config.CheckInTimerConfigId); + + try + { + return await _configRepository.SaveOrUpdateAsync(config, cancellationToken); + } + catch (Exception) when (isInsert) + { + // Two concurrent saves can both pass the lookup above and race the unique + // target index; the loser lands here — adopt the winner's row and update it + var winner = await _configRepository.GetByDepartmentAndTargetAsync( + config.DepartmentId, config.TimerTargetType, config.UnitTypeId); + if (winner == null) + throw; + + config.CheckInTimerConfigId = winner.CheckInTimerConfigId; + config.CreatedOn = winner.CreatedOn; + config.CreatedByUserId = winner.CreatedByUserId; + config.UpdatedOn = DateTime.UtcNow; + return await _configRepository.SaveOrUpdateAsync(config, cancellationToken); + } } public async Task DeleteTimerConfigAsync(string configId, int departmentId, CancellationToken cancellationToken = default) @@ -83,9 +133,37 @@ public async Task> GetTimerOverridesForDepartmentAsyn public async Task SaveTimerOverrideAsync(CheckInTimerOverride ovr, CancellationToken cancellationToken = default) { + ValidateTimerValues(ovr.TimerTargetType, ovr.DurationMinutes, ovr.WarningThresholdMinutes); + + // Unit type only applies to unit-type timers; clear it for other targets so it + // can't create per-unit-type rows the resolver and unique-target lookup would mismatch + if (ovr.TimerTargetType != (int)CheckInTimerTargetType.UnitType) + ovr.UnitTypeId = null; + + // Only one override may exist per (department, call type, call priority, target type, + // unit type) — enforced by the UQ_CheckInTimerOverrides_Dept_Call_Target_Unit unique index + var departmentOverrides = await _overrideRepository.GetByDepartmentIdAsync(ovr.DepartmentId); + var existingForTarget = departmentOverrides?.FirstOrDefault(o => + o.CallTypeId == ovr.CallTypeId && + o.CallPriority == ovr.CallPriority && + o.TimerTargetType == ovr.TimerTargetType && + o.UnitTypeId == ovr.UnitTypeId); + if (string.IsNullOrWhiteSpace(ovr.CheckInTimerOverrideId)) { - ovr.CreatedOn = DateTime.UtcNow; + if (existingForTarget != null) + { + // Saving a "new" override for a target that already has one — update the + // existing row instead of inserting a duplicate + ovr.CheckInTimerOverrideId = existingForTarget.CheckInTimerOverrideId; + ovr.CreatedOn = existingForTarget.CreatedOn; + ovr.CreatedByUserId = existingForTarget.CreatedByUserId; + ovr.UpdatedOn = DateTime.UtcNow; + } + else + { + ovr.CreatedOn = DateTime.UtcNow; + } } else { @@ -93,10 +171,40 @@ public async Task SaveTimerOverrideAsync(CheckInTimerOverr if (existing != null && existing.DepartmentId != ovr.DepartmentId) throw new UnauthorizedAccessException("Cannot modify a timer override belonging to another department."); + if (existingForTarget != null && existingForTarget.CheckInTimerOverrideId != ovr.CheckInTimerOverrideId) + throw new InvalidOperationException("A check-in timer override already exists for this call type, priority, target type and unit type."); + + if (existing != null) + ovr.CreatedOn = existing.CreatedOn; + ovr.UpdatedOn = DateTime.UtcNow; } - return await _overrideRepository.SaveOrUpdateAsync(ovr, cancellationToken); + var isInsert = string.IsNullOrWhiteSpace(ovr.CheckInTimerOverrideId); + + try + { + return await _overrideRepository.SaveOrUpdateAsync(ovr, cancellationToken); + } + catch (Exception) when (isInsert) + { + // Two concurrent saves can both pass the lookup above and race the unique + // target index; the loser lands here — adopt the winner's row and update it + var currentOverrides = await _overrideRepository.GetByDepartmentIdAsync(ovr.DepartmentId); + var winner = currentOverrides?.FirstOrDefault(o => + o.CallTypeId == ovr.CallTypeId && + o.CallPriority == ovr.CallPriority && + o.TimerTargetType == ovr.TimerTargetType && + o.UnitTypeId == ovr.UnitTypeId); + if (winner == null) + throw; + + ovr.CheckInTimerOverrideId = winner.CheckInTimerOverrideId; + ovr.CreatedOn = winner.CreatedOn; + ovr.CreatedByUserId = winner.CreatedByUserId; + ovr.UpdatedOn = DateTime.UtcNow; + return await _overrideRepository.SaveOrUpdateAsync(ovr, cancellationToken); + } } public async Task DeleteTimerOverrideAsync(string overrideId, int departmentId, CancellationToken cancellationToken = default) @@ -123,10 +231,22 @@ public async Task> ResolveAllTimersForCallAsync(Call var defaults = await _configRepository.GetByDepartmentIdAsync(call.DepartmentId); var defaultList = defaults?.Where(c => c.IsEnabled).ToList() ?? new List(); - // Parse call type as int for override matching + // Resolve the call's type to a CallTypeId for override matching. Call.Type stores + // the type NAME (see call creation in CallsController), so a numeric parse only + // covers legacy data — otherwise look the id up from the department's call types. int? callTypeId = null; - if (!string.IsNullOrWhiteSpace(call.Type) && int.TryParse(call.Type, out int parsedType)) - callTypeId = parsedType; + if (!string.IsNullOrWhiteSpace(call.Type)) + { + if (int.TryParse(call.Type, out int parsedType)) + { + callTypeId = parsedType; + } + else + { + var callTypes = await _callsService.GetCallTypesForDepartmentAsync(call.DepartmentId); + callTypeId = callTypes?.FirstOrDefault(t => string.Equals(t.Type, call.Type, StringComparison.OrdinalIgnoreCase))?.CallTypeId; + } + } var overrides = await _overrideRepository.GetMatchingOverridesAsync(call.DepartmentId, callTypeId, call.Priority); var overrideList = overrides?.ToList() ?? new List(); @@ -228,8 +348,15 @@ public async Task> GetActiveTimerStatusesForCallAsync(C if (!resolvedTimers.Any()) return new List(); + // Unit-type-scoped timers match check-ins and unit states by the unit's TYPE, but + // check-ins and dispatches carry UnitIds. Unit.Type stores the type name, so build + // a UnitId -> UnitTypeId map once when any timer is unit-type-scoped. + Dictionary unitTypeIdByUnitId = null; + if (resolvedTimers.Any(t => t.UnitTypeId.HasValue)) + unitTypeIdByUnitId = await GetUnitTypeIdsByUnitIdAsync(call.DepartmentId); + // Filter timers by ActiveForStates against current dispatched entity states - resolvedTimers = await FilterTimersByActiveStatesAsync(resolvedTimers, call); + resolvedTimers = await FilterTimersByActiveStatesAsync(resolvedTimers, call, unitTypeIdByUnitId); if (!resolvedTimers.Any()) return new List(); @@ -246,7 +373,11 @@ public async Task> GetActiveTimerStatusesForCallAsync(C .Where(c => c.CheckInType == timer.TargetType); if (timer.UnitTypeId.HasValue) - matchingCheckIns = matchingCheckIns.Where(c => c.UnitId == timer.UnitTypeId); + matchingCheckIns = matchingCheckIns.Where(c => + c.UnitId.HasValue && + unitTypeIdByUnitId != null && + unitTypeIdByUnitId.TryGetValue(c.UnitId.Value, out var checkInUnitTypeId) && + checkInUnitTypeId == timer.UnitTypeId); var latestCheckIn = matchingCheckIns .OrderByDescending(c => c.Timestamp) @@ -254,11 +385,14 @@ public async Task> GetActiveTimerStatusesForCallAsync(C var baseTime = latestCheckIn?.Timestamp ?? call.LoggedOn; var elapsed = (now - baseTime).TotalMinutes; + var minutesRemaining = timer.DurationMinutes - elapsed; + // Same semantics as the per-user and per-personnel endpoints: warn when within + // the threshold of the deadline, critical once the check-in is due string status; - if (elapsed < timer.DurationMinutes) + if (minutesRemaining > timer.WarningThresholdMinutes) status = "Green"; - else if (elapsed < timer.DurationMinutes + timer.WarningThresholdMinutes) + else if (minutesRemaining > 0) status = "Warning"; else status = "Critical"; @@ -284,7 +418,7 @@ public async Task> GetActiveTimerStatusesForCallAsync(C #region State Filtering - private async Task> FilterTimersByActiveStatesAsync(List timers, Call call) + private async Task> FilterTimersByActiveStatesAsync(List timers, Call call, Dictionary unitTypeIdByUnitId) { var timersWithStateFilter = timers.Where(t => !string.IsNullOrWhiteSpace(t.ActiveForStates)).ToList(); if (!timersWithStateFilter.Any()) @@ -294,24 +428,36 @@ private async Task> FilterTimersByActiveStatesAsync(L if (call.Dispatches == null || call.UnitDispatches == null) call = await _callsService.PopulateCallData(call, true, false, false, false, true, false, false, false, false); - // Build a set of current personnel action type IDs + // Build a set of current personnel action type IDs (one batch query instead of + // one last-action query per dispatched user) var personnelStates = new Dictionary(); - if (call.Dispatches != null) + if (call.Dispatches != null && call.Dispatches.Any()) { + var lastActionLogs = await _actionLogsService.GetLastActionLogsForDepartmentAsync(call.DepartmentId); + var lastActionByUser = (lastActionLogs ?? new List()) + .GroupBy(a => a.UserId) + .ToDictionary(g => g.Key, g => g.First()); + foreach (var dispatch in call.Dispatches) { - var lastAction = await _actionLogsService.GetLastActionLogForUserAsync(dispatch.UserId); + lastActionByUser.TryGetValue(dispatch.UserId, out var lastAction); personnelStates[dispatch.UserId] = lastAction?.ActionTypeId ?? (int)ActionTypes.StandingBy; } } - // Build a set of current unit state IDs (keyed by UnitId) + // Build a set of current unit state IDs keyed by UnitId (one batch query instead + // of one last-state query per dispatched unit) var unitStates = new Dictionary(); - if (call.UnitDispatches != null) + if (call.UnitDispatches != null && call.UnitDispatches.Any()) { + var latestUnitStates = await _unitsService.GetAllLatestStatusForUnitsByDepartmentIdAsync(call.DepartmentId); + var lastStateByUnit = (latestUnitStates ?? new List()) + .GroupBy(s => s.UnitId) + .ToDictionary(g => g.Key, g => g.First()); + foreach (var unitDispatch in call.UnitDispatches) { - var lastState = await _unitsService.GetLastUnitStateByUnitIdAsync(unitDispatch.UnitId); + lastStateByUnit.TryGetValue(unitDispatch.UnitId, out var lastState); unitStates[unitDispatch.UnitId] = lastState?.State ?? (int)UnitStateTypes.Available; } } @@ -331,11 +477,16 @@ private async Task> FilterTimersByActiveStatesAsync(L if (timer.TargetType == (int)CheckInTimerTargetType.UnitType) { - // Check unit states + // Check unit states; a timer scoped to a specific unit type only considers + // dispatched units of that type foreach (var kvp in unitStates) { - // If timer is for a specific UnitType, we'd need to check the unit's type - // For now, check all dispatched units + if (timer.UnitTypeId.HasValue && + (unitTypeIdByUnitId == null || + !unitTypeIdByUnitId.TryGetValue(kvp.Key, out var dispatchedUnitTypeId) || + dispatchedUnitTypeId != timer.UnitTypeId)) + continue; + if (allowedStates.Contains(kvp.Value)) { anyEntityMatches = true; @@ -363,6 +514,51 @@ private async Task> FilterTimersByActiveStatesAsync(L return result; } + /// + /// Maps every unit in the department to its UnitTypeId. Unit.Type stores the unit + /// type NAME, so the id is resolved against the department's unit types; units with + /// no (or an unknown) type map to null. + /// + private async Task> GetUnitTypeIdsByUnitIdAsync(int departmentId) + { + var units = await _unitsService.GetUnitsForDepartmentAsync(departmentId) ?? new List(); + var unitTypes = await _unitsService.GetUnitTypesForDepartmentAsync(departmentId) ?? new List(); + + var typeIdByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var unitType in unitTypes) + { + if (!string.IsNullOrWhiteSpace(unitType.Type) && !typeIdByName.ContainsKey(unitType.Type)) + typeIdByName.Add(unitType.Type, unitType.UnitTypeId); + } + + var map = new Dictionary(); + foreach (var unit in units) + { + int? unitTypeId = null; + if (!string.IsNullOrWhiteSpace(unit.Type) && typeIdByName.TryGetValue(unit.Type, out var resolved)) + unitTypeId = resolved; + + map[unit.UnitId] = unitTypeId; + } + + return map; + } + + private static void ValidateTimerValues(int timerTargetType, int durationMinutes, int warningThresholdMinutes) + { + if (!Enum.IsDefined(typeof(CheckInTimerTargetType), timerTargetType)) + throw new InvalidOperationException("Invalid check-in timer target type."); + + if (durationMinutes < 1) + throw new InvalidOperationException("Check-in timer duration must be at least 1 minute."); + + if (warningThresholdMinutes < 1) + throw new InvalidOperationException("Check-in timer warning threshold must be at least 1 minute."); + + if (warningThresholdMinutes >= durationMinutes) + throw new InvalidOperationException("Check-in timer warning threshold must be less than the duration."); + } + private static HashSet ParseActiveForStates(string activeForStates) { var states = new HashSet(); @@ -422,8 +618,15 @@ public async Task> GetUserActiveCallCheckInSummarie continue; } - // Get the user's last check-in on this call (single targeted query). - var lastCheckIn = await _recordRepository.GetLastCheckInForUserOnCallAsync(call.CallId, userId); + // Only personnel-type check-ins reset the personnel timer — same semantics as + // GetCallPersonnelCheckInStatusesAsync. The raw last-check-in query is + // type-agnostic, so filter the call's records here. + var callCheckIns = (await _recordRepository.GetByCallIdAsync(call.CallId))?.ToList() + ?? new List(); + var lastCheckIn = callCheckIns + .Where(r => r.CheckInType == (int)CheckInTimerTargetType.Personnel && r.UserId == userId) + .OrderByDescending(r => r.Timestamp) + .FirstOrDefault(); // Baseline is the last check-in timestamp OR the call start time if // the user has never checked in. diff --git a/Core/Resgrid.Services/WeatherAlertService.cs b/Core/Resgrid.Services/WeatherAlertService.cs index b980ed766..56d0e8b7e 100644 --- a/Core/Resgrid.Services/WeatherAlertService.cs +++ b/Core/Resgrid.Services/WeatherAlertService.cs @@ -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 { @@ -659,7 +666,14 @@ private static string Truncate(string value, int maxLength) return value.Substring(0, maxLength); } - private static bool ShouldSendAutoMessage(int severity, List schedule, int legacyThreshold, Department department) + private enum AutoMessageDecision + { + Send, + SkipPermanently, + DeferOutsideWindow + } + + private static AutoMessageDecision GetAutoMessageDecision(int severity, List schedule, int legacyThreshold, Department department) { if (schedule != null && schedule.Count > 0) { @@ -667,11 +681,12 @@ private static bool ShouldSendAutoMessage(int severity, List= 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 diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs index ca842995e..031802bf0 100644 --- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs +++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs @@ -66,14 +66,13 @@ public static async Task 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(); } diff --git a/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs b/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs index 6f8d641ec..a64e707fe 100644 --- a/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs @@ -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 + { + new CheckInTimerOverride { TimerTargetType = 0, CallTypeId = 7, CallPriority = 3, DurationMinutes = 12, WarningThresholdMinutes = 2, IsEnabled = true } + }; + _configRepo.Setup(x => x.GetByDepartmentIdAsync(10)).ReturnsAsync(new List()); + _callsService.Setup(x => x.GetCallTypesForDepartmentAsync(10)) + .ReturnsAsync(new List { 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 @@ -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 { new CheckInTimerConfig { TimerTargetType = 0, DurationMinutes = 30, WarningThresholdMinutes = 5, IsEnabled = true } @@ -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 { new CheckInTimerConfig { TimerTargetType = 0, DurationMinutes = 30, WarningThresholdMinutes = 5, IsEnabled = true } @@ -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 + { + 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()); + _unitsService.Setup(x => x.GetUnitsForDepartmentAsync(10)).ReturnsAsync(new List + { + 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 + { + 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 + { + 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 @@ -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 act = async () => await _service.SaveTimerConfigAsync(config); + + await act.Should().ThrowAsync(); + } + + [Test] + public async Task SaveTimerConfigAsync_Throws_WhenWarningThresholdEqualsDuration() + { + var config = new CheckInTimerConfig { DepartmentId = 10, TimerTargetType = 0, DurationMinutes = 30, WarningThresholdMinutes = 30 }; + + Func act = async () => await _service.SaveTimerConfigAsync(config); + + await act.Should().ThrowAsync(); + } + + [Test] + public async Task SaveTimerConfigAsync_Throws_WhenWarningThresholdExceedsDuration() + { + var config = new CheckInTimerConfig { DepartmentId = 10, TimerTargetType = 0, DurationMinutes = 15, WarningThresholdMinutes = 30 }; + + Func act = async () => await _service.SaveTimerConfigAsync(config); + + await act.Should().ThrowAsync(); + } + + [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(), It.IsAny(), It.IsAny())) + .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 }); + _configRepo.Setup(x => x.GetByDepartmentIdAsync(10)).ReturnsAsync(new List + { + new CheckInTimerConfig { TimerTargetType = (int)CheckInTimerTargetType.Personnel, DurationMinutes = 30, WarningThresholdMinutes = 5, IsEnabled = true } + }); + _overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, null, 0)).ReturnsAsync(new List()); + // 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 + { + 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] @@ -356,8 +485,8 @@ public async Task GetActiveTimerStatusesForCallAsync_FiltersOut_WhenPersonnelSta _overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, null, 0)).ReturnsAsync(new List()); _recordRepo.Setup(x => x.GetByCallIdAsync(1)).ReturnsAsync(new List()); // 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(), It.IsAny())) + .ReturnsAsync(new List { new ActionLog { UserId = "user1", ActionTypeId = (int)ActionTypes.Responding } }); var result = await _service.GetActiveTimerStatusesForCallAsync(call); @@ -382,8 +511,8 @@ public async Task GetActiveTimerStatusesForCallAsync_IncludesTimer_WhenPersonnel _overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, null, 0)).ReturnsAsync(new List()); _recordRepo.Setup(x => x.GetByCallIdAsync(1)).ReturnsAsync(new List()); // 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(), It.IsAny())) + .ReturnsAsync(new List { new ActionLog { UserId = "user1", ActionTypeId = (int)ActionTypes.OnScene } }); var result = await _service.GetActiveTimerStatusesForCallAsync(call); @@ -431,8 +560,8 @@ public async Task GetActiveTimerStatusesForCallAsync_FiltersOut_WhenUnitStateDoe _overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, null, 0)).ReturnsAsync(new List()); _recordRepo.Setup(x => x.GetByCallIdAsync(1)).ReturnsAsync(new List()); // 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 { new UnitState { UnitId = 5, State = (int)UnitStateTypes.Responding } }); var result = await _service.GetActiveTimerStatusesForCallAsync(call); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs index 297f1adf5..8b9fded21 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs @@ -102,7 +102,19 @@ public async Task> 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; @@ -123,7 +135,16 @@ public async Task> 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(); @@ -201,7 +222,19 @@ public async Task> 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; @@ -222,7 +255,16 @@ public async Task> 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(); @@ -337,6 +379,9 @@ public async Task> 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, diff --git a/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs b/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs index e91be9d9d..7b647dcee 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs @@ -197,7 +197,13 @@ public async Task> GetUnitStatus(string unitId) private async Task> ProcessSetUnitState(UnitStatusInput stateInput, CancellationToken cancellationToken = default(CancellationToken)) { var result = new SaveUnitStatusResult(); - var unit = await _unitsService.GetUnitByIdAsync(int.Parse(stateInput.Id)); + + // Validate the unit id up front: this runs before the try/catch and ModelState check below, so a + // non-numeric id would otherwise throw an unhandled FormatException (HTTP 500) instead of a 400. + if (!int.TryParse(stateInput.Id, out var unitId)) + return BadRequest(); + + var unit = await _unitsService.GetUnitByIdAsync(unitId); var setPersonnelStatus = await _departmentSettingsService.GetPersonnelOnUnitSetUnitStatusAsync(DepartmentId); if (unit == null) @@ -215,31 +221,37 @@ public async Task> GetUnitStatus(string unitId) { var state = new UnitState(); - state.UnitId = int.Parse(stateInput.Id); + state.UnitId = unitId; state.LocalTimestamp = stateInput.Timestamp; - if (!String.IsNullOrWhiteSpace(stateInput.Latitude)) - state.Latitude = decimal.Parse(stateInput.Latitude); + // Parse device-supplied telemetry defensively: a malformed value (e.g. a "lat,lon" pair sent + // in a single field) must be skipped, not throw FormatException and reject the whole status + // update. Invalid values are simply omitted from the saved state. + if (!String.IsNullOrWhiteSpace(stateInput.Latitude) && decimal.TryParse(stateInput.Latitude, out var latitude)) + state.Latitude = latitude; + + if (!String.IsNullOrWhiteSpace(stateInput.Longitude) && decimal.TryParse(stateInput.Longitude, out var longitude)) + state.Longitude = longitude; - if (!String.IsNullOrWhiteSpace(stateInput.Longitude)) - state.Longitude = decimal.Parse(stateInput.Longitude); + if (!String.IsNullOrWhiteSpace(stateInput.Accuracy) && decimal.TryParse(stateInput.Accuracy, out var accuracy)) + state.Accuracy = accuracy; - if (!String.IsNullOrWhiteSpace(stateInput.Accuracy)) - state.Accuracy = decimal.Parse(stateInput.Accuracy); + if (!String.IsNullOrWhiteSpace(stateInput.Altitude) && decimal.TryParse(stateInput.Altitude, out var altitude)) + state.Altitude = altitude; - if (!String.IsNullOrWhiteSpace(stateInput.Altitude)) - state.Altitude = decimal.Parse(stateInput.Altitude); + if (!String.IsNullOrWhiteSpace(stateInput.AltitudeAccuracy) && decimal.TryParse(stateInput.AltitudeAccuracy, out var altitudeAccuracy)) + state.AltitudeAccuracy = altitudeAccuracy; - if (!String.IsNullOrWhiteSpace(stateInput.AltitudeAccuracy)) - state.AltitudeAccuracy = decimal.Parse(stateInput.AltitudeAccuracy); + if (!String.IsNullOrWhiteSpace(stateInput.Speed) && decimal.TryParse(stateInput.Speed, out var speed)) + state.Speed = speed; - if (!String.IsNullOrWhiteSpace(stateInput.Speed)) - state.Speed = decimal.Parse(stateInput.Speed); + if (!String.IsNullOrWhiteSpace(stateInput.Heading) && decimal.TryParse(stateInput.Heading, out var heading)) + state.Heading = heading; - if (!String.IsNullOrWhiteSpace(stateInput.Heading)) - state.Heading = decimal.Parse(stateInput.Heading); + if (!int.TryParse(stateInput.Type, out var stateType)) + return BadRequest(); - state.State = int.Parse(stateInput.Type); + state.State = stateType; if (stateInput.Timestamp.HasValue) state.Timestamp = stateInput.Timestamp.Value; @@ -253,10 +265,10 @@ public async Task> GetUnitStatus(string unitId) state.GeoLocationData = string.Format("{0},{1}", state.Latitude.Value, state.Longitude.Value); } - if (!string.IsNullOrWhiteSpace(stateInput.RespondingTo) && int.Parse(stateInput.RespondingTo) > 0) + if (!string.IsNullOrWhiteSpace(stateInput.RespondingTo) && int.TryParse(stateInput.RespondingTo, out var respondingToId) && respondingToId > 0) { var destinationType = stateInput.RespondingToType ?? (int)DestinationEntityTypes.Call; - var destinationId = int.Parse(stateInput.RespondingTo); + var destinationId = respondingToId; if (!await IsValidDestinationAsync(destinationId, destinationType)) return BadRequest(); @@ -273,12 +285,14 @@ public async Task> GetUnitStatus(string unitId) var roles = new List(); foreach (var role in stateInput.Roles) { - if (!string.IsNullOrWhiteSpace(role.UserId) && !string.IsNullOrWhiteSpace(role.RoleId)) + // Skip roles with a non-numeric RoleId rather than throwing and rejecting the whole + // status update (TryParse also covers the null/empty check). + if (!string.IsNullOrWhiteSpace(role.UserId) && int.TryParse(role.RoleId, out var unitStateRoleId)) { var unitRole = new UnitStateRole(); unitRole.UnitStateId = savedState.UnitStateId; unitRole.UserId = role.UserId; - unitRole.UnitStateRoleId = int.Parse(role.RoleId); + unitRole.UnitStateRoleId = unitStateRoleId; if (String.IsNullOrWhiteSpace(role.Name)) { diff --git a/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs index 31bb6e0cc..82923c131 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs @@ -368,8 +368,24 @@ public async Task> GetSettings() [HttpPost("SaveSettings")] [Authorize(Policy = ResgridResources.WeatherAlert_Update)] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task> SaveSettings([FromBody] SaveWeatherAlertSettingsInput input) { + if (input.AutoMessageSchedule != null) + { + foreach (var entry in input.AutoMessageSchedule) + { + if (entry.StartHour < 0 || entry.StartHour > 23 || entry.EndHour < 0 || entry.EndHour > 24) + return BadRequest("Auto-message schedule hours are invalid: start hour must be 0-23 and end hour must be 0-24."); + + // Hours only apply to enabled rows; the window must be non-empty. An end hour + // less than the start hour is a valid overnight window (e.g. 22 to 6); only an + // equal start and end is empty (use 0 and 24 for 24-hour delivery) + if (entry.Enabled && entry.EndHour == entry.StartHour) + return BadRequest("Auto-message schedule window cannot be empty: start hour and end hour must differ. Use start 0 and end 24 for 24-hour delivery."); + } + } + await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, input.WeatherAlertsEnabled.ToString(), DepartmentSettingTypes.WeatherAlertsEnabled); await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, input.MinimumSeverity.ToString(), DepartmentSettingTypes.WeatherAlertMinimumSeverity); await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, input.AutoMessageSeverity.ToString(), DepartmentSettingTypes.WeatherAlertAutoMessageSeverity); @@ -427,6 +443,17 @@ private async Task GetWeatherAlertSettingsDataAsync() try { settings.AutoMessageSchedule = Newtonsoft.Json.JsonConvert.DeserializeObject>(scheduleSetting.Setting); + + // Normalize the legacy 0/0 (24-hour) sentinel to the canonical 0/24 form so + // settings saved before EndHour 24 existed round-trip through validation + if (settings.AutoMessageSchedule != null) + { + foreach (var entry in settings.AutoMessageSchedule) + { + if (entry.StartHour == 0 && entry.EndHour == 0) + entry.EndHour = 24; + } + } } catch { } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs index 15ddb1433..fae2037d4 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs @@ -2013,7 +2013,18 @@ public async Task SaveCheckInTimerConfig(string configId, int tim CreatedByUserId = UserId }; - await _checkInTimerService.SaveTimerConfigAsync(config, cancellationToken); + try + { + await _checkInTimerService.SaveTimerConfigAsync(config, cancellationToken); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + catch (UnauthorizedAccessException) + { + return NotFound(); + } return RedirectToAction("DispatchSettings"); } @@ -2026,7 +2037,14 @@ public async Task DeleteCheckInTimerConfig(string configId, Cance if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) return Unauthorized(); - await _checkInTimerService.DeleteTimerConfigAsync(configId, DepartmentId, cancellationToken); + try + { + await _checkInTimerService.DeleteTimerConfigAsync(configId, DepartmentId, cancellationToken); + } + catch (UnauthorizedAccessException) + { + return NotFound(); + } return RedirectToAction("DispatchSettings"); } @@ -2055,7 +2073,14 @@ public async Task SaveCheckInTimerOverride(string overrideId, int CreatedByUserId = UserId }; - await _checkInTimerService.SaveTimerOverrideAsync(ovr, cancellationToken); + try + { + await _checkInTimerService.SaveTimerOverrideAsync(ovr, cancellationToken); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } return RedirectToAction("DispatchSettings"); } @@ -2068,7 +2093,14 @@ public async Task DeleteCheckInTimerOverride(string overrideId, C if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) return Unauthorized(); - await _checkInTimerService.DeleteTimerOverrideAsync(overrideId, DepartmentId, cancellationToken); + try + { + await _checkInTimerService.DeleteTimerOverrideAsync(overrideId, DepartmentId, cancellationToken); + } + catch (UnauthorizedAccessException) + { + return NotFound(); + } return RedirectToAction("DispatchSettings"); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index f191451f4..22dc49675 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -202,6 +202,11 @@ public async Task NewCall() var model = new NewCallView(); model.Call = new Resgrid.Model.Call(); + + // Default the check-in timers checkbox from the department auto-enable setting, + // mirroring API call creation when no explicit value is supplied + model.Call.CheckInTimersEnabled = await _departmentSettingsService.GetCheckInTimersAutoEnableForNewCallsAsync(DepartmentId); + model = await FillNewCallView(model); return View(model); diff --git a/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Settings.cshtml b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Settings.cshtml index b53aa66fe..ff92731a4 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Settings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Settings.cshtml @@ -66,7 +66,7 @@
-

Configure which severity levels generate auto-messages and optionally restrict them to specific hours (department local time). Set both hours to 0 for 24-hour delivery.

+

Configure which severity levels generate auto-messages and optionally restrict them to specific hours (department local time). Delivery runs from the start hour up to (but not including) the end hour. If the start hour is later than the end hour the window wraps overnight — for example, 18 and 6 delivers from 6pm to 6am. Use start 0 and end 24 for 24-hour delivery.

@@ -77,11 +77,11 @@ - - - - - + + + + +
@localizer["SeverityExtreme"]
@localizer["SeveritySevere"]
@localizer["SeverityModerate"]
@localizer["SeverityMinor"]
@localizer["SeverityUnknown"]
@localizer["SeverityExtreme"]
@localizer["SeveritySevere"]
@localizer["SeverityModerate"]
@localizer["SeverityMinor"]
@localizer["SeverityUnknown"]
@@ -347,17 +347,47 @@ } function saveSettings() { + var severityNames = { + 0: '@localizer["SeverityExtreme"]', + 1: '@localizer["SeveritySevere"]', + 2: '@localizer["SeverityModerate"]', + 3: '@localizer["SeverityMinor"]', + 4: '@localizer["SeverityUnknown"]' + }; + var schedule = []; + var validationError = null; $('.sched-enabled').each(function () { var sev = parseInt($(this).data('severity')); + var enabled = $(this).is(':checked'); + var start = parseInt($('.sched-start[data-severity="' + sev + '"]').val()); + var end = parseInt($('.sched-end[data-severity="' + sev + '"]').val()); + if (isNaN(start)) start = 0; + if (isNaN(end)) end = 0; + + if (enabled && !validationError) { + if (start < 0 || end < 0 || start > 23 || end > 24) { + validationError = severityNames[sev] + ': start hour must be 0-23 and end hour must be 0-24.'; + } else if (end === start) { + validationError = severityNames[sev] + ': start and end hour cannot be the same. Use start 0 and end 24 for 24-hour delivery.'; + } else if (end < start) { + validationError = severityNames[sev] + ': end hour cannot be before the start hour.'; + } + } + schedule.push({ Severity: sev, - Enabled: $(this).is(':checked'), - StartHour: parseInt($('.sched-start[data-severity="' + sev + '"]').val()) || 0, - EndHour: parseInt($('.sched-end[data-severity="' + sev + '"]').val()) || 0 + Enabled: enabled, + StartHour: start, + EndHour: end }); }); + if (validationError) { + alert(validationError); + return; + } + var data = { WeatherAlertsEnabled: $('#settingEnabled').is(':checked'), MinimumSeverity: parseInt($('#settingMinSeverity').val()),