From 08eb3d639a47909e349aa790509dbf14a4ab0d8c Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 19 Jul 2026 22:22:57 -0700 Subject: [PATCH 1/4] RG-T126 IC fixes, templates for IC and Unit Roles, SSO fix --- Core/Resgrid.Config/IncidentCommandConfig.cs | 15 + .../CommandBoards/CommandBoardTemplate.cs | 110 +++++ .../CommandBoardTemplateCatalog.cs | 314 +++++++++++++ .../CommandBoards/CommandBoardTemplateLane.cs | 27 ++ .../Events/IncidentCommandEvents.cs | 78 ++++ .../IncidentCommand/IncidentCommand.cs | 6 + .../IncidentCommand/IncidentCommandBoard.cs | 6 + .../IncidentCommand/IncidentCommandChanges.cs | 5 + .../IncidentCommand/IncidentCommandEnums.cs | 10 +- .../IncidentCommand/IncidentContent.cs | 158 +++++++ .../IncidentCommand/IncidentRole.cs | 20 +- .../IncidentCommand/IncidentWeather.cs | 64 +++ .../Providers/IIncidentWeatherProvider.cs | 10 + .../IIncidentCommandRepositories.cs | 10 + .../Services/IIncidentCommandService.cs | 21 + .../UnitRoles/UnitRoleTemplateCatalog.cs | 131 +++++- .../WorkflowTemplateVariableCatalog.cs | 26 ++ .../Resgrid.Model/WorkflowTriggerEventType.cs | 12 +- Core/Resgrid.Services/DepartmentSsoService.cs | 424 ++++++++++++++---- .../IncidentCommandService.cs | 409 ++++++++++++++++- .../WorkflowSampleDataGenerator.cs | 45 ++ .../WorkflowTemplateContextBuilder.cs | 81 ++++ .../WorkflowEventProvider.cs | 10 + ...0090_AddIncidentContentAndPublicSharing.cs | 97 ++++ .../M0091_AddUniqueDepartmentSsoProvider.cs | 30 ++ ...90_AddIncidentContentAndPublicSharingPg.cs | 95 ++++ .../M0091_AddUniqueDepartmentSsoProviderPg.cs | 30 ++ .../NwsIncidentWeatherProvider.cs | 280 ++++++++++++ .../WeatherProviderModule.cs | 1 + .../IncidentCommandRepositories.cs | 93 ++++ .../Modules/DataModule.cs | 2 + .../NwsIncidentWeatherProviderTests.cs | 128 ++++++ .../Services/DepartmentSsoServiceTests.cs | 270 +++++++++++ .../IncidentCommandContentServiceTests.cs | 225 ++++++++++ .../IncidentCommandServiceParTests.cs | 8 +- .../Services/TemplateCatalogTests.cs | 172 +++++++ .../WorkflowTemplateContextBuilderTests.cs | 31 ++ .../Web/Services/ConnectControllerSsoTests.cs | 116 +++++ .../Controllers/v4/ConnectController.cs | 104 ++++- .../v4/IncidentCommandController.cs | 230 ++++++++++ .../v4/PublicIncidentController.cs | 62 +++ .../Controllers/v4/SsoAdminController.cs | 48 +- .../IncidentCommand/IncidentCommandModels.cs | 56 +++ .../User/Controllers/CommandController.cs | 37 +- .../User/Controllers/SecurityController.cs | 94 +++- .../Areas/User/Views/Command/Index.cshtml | 2 +- .../Areas/User/Views/Command/New.cshtml | 7 + .../Areas/User/Views/Command/Templates.cshtml | 151 +++++++ .../User/Views/Command/_CommandForm.cshtml | 53 +-- .../Units/_UnitRoleTemplatesModal.cshtml | 31 +- .../command/resgrid.commands.newcommand.js | 51 ++- 51 files changed, 4277 insertions(+), 219 deletions(-) create mode 100644 Core/Resgrid.Config/IncidentCommandConfig.cs create mode 100644 Core/Resgrid.Model/CommandBoards/CommandBoardTemplate.cs create mode 100644 Core/Resgrid.Model/CommandBoards/CommandBoardTemplateCatalog.cs create mode 100644 Core/Resgrid.Model/CommandBoards/CommandBoardTemplateLane.cs create mode 100644 Core/Resgrid.Model/IncidentCommand/IncidentContent.cs create mode 100644 Core/Resgrid.Model/IncidentCommand/IncidentWeather.cs create mode 100644 Core/Resgrid.Model/Providers/IIncidentWeatherProvider.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0090_AddIncidentContentAndPublicSharing.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0091_AddUniqueDepartmentSsoProvider.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0090_AddIncidentContentAndPublicSharingPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0091_AddUniqueDepartmentSsoProviderPg.cs create mode 100644 Providers/Resgrid.Providers.Weather/NwsIncidentWeatherProvider.cs create mode 100644 Tests/Resgrid.Tests/Providers/NwsIncidentWeatherProviderTests.cs create mode 100644 Tests/Resgrid.Tests/Services/DepartmentSsoServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Services/IncidentCommandContentServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Services/TemplateCatalogTests.cs create mode 100644 Tests/Resgrid.Tests/Web/Services/ConnectControllerSsoTests.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/PublicIncidentController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/Command/Templates.cshtml diff --git a/Core/Resgrid.Config/IncidentCommandConfig.cs b/Core/Resgrid.Config/IncidentCommandConfig.cs new file mode 100644 index 000000000..77a64c851 --- /dev/null +++ b/Core/Resgrid.Config/IncidentCommandConfig.cs @@ -0,0 +1,15 @@ +namespace Resgrid.Config +{ + /// Operational and security limits for the live Incident Command system. + public static class IncidentCommandConfig + { + /// Maximum size of an incident attachment in bytes (25 MiB by default). + public static int MaxAttachmentBytes = 25 * 1024 * 1024; + + /// Maximum status-note body length. + public static int MaxNoteLength = 16000; + + /// How many hourly forecast periods the commander app receives by default. + public static int ForecastHours = 24; + } +} diff --git a/Core/Resgrid.Model/CommandBoards/CommandBoardTemplate.cs b/Core/Resgrid.Model/CommandBoards/CommandBoardTemplate.cs new file mode 100644 index 000000000..460000d96 --- /dev/null +++ b/Core/Resgrid.Model/CommandBoards/CommandBoardTemplate.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model.CommandBoards +{ + /// + /// A code-defined command-board example that can be projected into an unsaved, editable + /// . Templates never contain department or database identifiers. + /// + public class CommandBoardTemplate + { + public string Id { get; set; } + + public string Name { get; set; } + + public string Category { get; set; } + + public string Description { get; set; } + + public string[] Keywords { get; set; } = Array.Empty(); + + public bool Timer { get; set; } + + public int TimerMinutes { get; set; } + + public List Lanes { get; set; } = new List(); + + public int LaneCount => Lanes?.Count ?? 0; + + /// Searchable text used by both the catalog and the browser UI. + public string SearchText + { + get + { + var parts = new List { Name, Category, Description }; + + if (Keywords != null) + parts.AddRange(Keywords); + + if (Lanes != null) + { + parts.AddRange(Lanes.Select(l => l.Name)); + parts.AddRange(Lanes.Select(l => l.Description)); + parts.AddRange(Lanes.SelectMany(l => l.SuggestedUnitTypes ?? Array.Empty())); + parts.AddRange(Lanes.SelectMany(l => l.SuggestedPersonnelRoles ?? Array.Empty())); + } + + return string.Join(" ", parts.Where(p => !string.IsNullOrWhiteSpace(p))).ToLowerInvariant(); + } + } + + /// + /// Creates an unsaved definition and matches this template's name-based suggestions to the + /// department's configured unit types and personnel roles. Unmatched suggestions are ignored so + /// every example remains usable by departments with different terminology. + /// + public CommandDefinition CreateDefinition(IEnumerable unitTypes, IEnumerable personnelRoles) + { + var availableUnitTypes = unitTypes?.ToList() ?? new List(); + var availablePersonnelRoles = personnelRoles?.ToList() ?? new List(); + var assignments = new List(); + + for (var index = 0; index < (Lanes?.Count ?? 0); index++) + { + var lane = Lanes[index]; + var matchedUnitTypes = availableUnitTypes + .Where(unitType => MatchesAny(unitType.Type, lane.SuggestedUnitTypes)) + .GroupBy(unitType => unitType.UnitTypeId) + .Select(group => new CommandDefinitionRoleUnitType { UnitTypeId = group.Key }) + .ToList(); + var matchedPersonnelRoles = availablePersonnelRoles + .Where(role => MatchesAny(role.Name, lane.SuggestedPersonnelRoles)) + .GroupBy(role => role.PersonnelRoleId) + .Select(group => new CommandDefinitionRolePersonnelRole { PersonnelRoleId = group.Key }) + .ToList(); + + assignments.Add(new CommandDefinitionRole + { + Name = lane.Name, + Description = lane.Description, + LaneType = (int)lane.LaneType, + SortOrder = index, + RequiredUnitTypes = matchedUnitTypes, + RequiredRoles = matchedPersonnelRoles, + ForceRequirements = lane.ForceRequirements && (matchedUnitTypes.Count > 0 || matchedPersonnelRoles.Count > 0) + }); + } + + return new CommandDefinition + { + Name = Name, + Description = Description, + Timer = Timer, + TimerMinutes = TimerMinutes, + Assignments = assignments + }; + } + + private static bool MatchesAny(string value, IEnumerable suggestions) + { + if (string.IsNullOrWhiteSpace(value) || suggestions == null) + return false; + + return suggestions.Any(suggestion => + !string.IsNullOrWhiteSpace(suggestion) && + string.Equals(value.Trim(), suggestion.Trim(), StringComparison.OrdinalIgnoreCase)); + } + } +} diff --git a/Core/Resgrid.Model/CommandBoards/CommandBoardTemplateCatalog.cs b/Core/Resgrid.Model/CommandBoards/CommandBoardTemplateCatalog.cs new file mode 100644 index 000000000..b041ca929 --- /dev/null +++ b/Core/Resgrid.Model/CommandBoards/CommandBoardTemplateCatalog.cs @@ -0,0 +1,314 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model.CommandBoards +{ + /// + /// Searchable, code-only starter command boards. Selecting one creates an unsaved definition that a + /// department can rename, expand, trim, and change requirements on before saving. + /// + public static class CommandBoardTemplateCatalog + { + public static IReadOnlyList All { get; } = BuildAll(); + + public static CommandBoardTemplate GetById(string id) + { + if (string.IsNullOrWhiteSpace(id)) + return null; + + return All.FirstOrDefault(template => string.Equals(template.Id, id, StringComparison.OrdinalIgnoreCase)); + } + + public static IReadOnlyList GetByCategory(string category) + { + return All.Where(template => string.Equals(template.Category, category, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + public static IReadOnlyList Search(string query) + { + if (string.IsNullOrWhiteSpace(query)) + return All; + + var terms = query.ToLowerInvariant().Split(new[] { ' ', ',' }, StringSplitOptions.RemoveEmptyEntries); + return All.Where(template => terms.All(term => template.SearchText.Contains(term))).ToList(); + } + + #region Builders + + private static CommandBoardTemplateLane L(string name, CommandNodeType laneType, string description, + string[] unitTypes = null, string[] personnelRoles = null, bool forceRequirements = false) + { + return new CommandBoardTemplateLane + { + Name = name, + LaneType = laneType, + Description = description, + SuggestedUnitTypes = unitTypes ?? Array.Empty(), + SuggestedPersonnelRoles = personnelRoles ?? Array.Empty(), + ForceRequirements = forceRequirements + }; + } + + private static CommandBoardTemplate T(string id, string name, string category, string description, + string[] keywords, bool timer, int timerMinutes, params CommandBoardTemplateLane[] lanes) + { + return new CommandBoardTemplate + { + Id = id, + Name = name, + Category = category, + Description = description, + Keywords = keywords, + Timer = timer, + TimerMinutes = timerMinutes, + Lanes = lanes.ToList() + }; + } + + private static IReadOnlyList BuildAll() + { + return new List + { + // ---- Fire Departments ---- + T("fire-residential-structure", "Residential Structure Fire", "Fire Departments", + "A first-alarm residential fire board with tactical groups, water supply, rapid intervention and staging.", + new[] { "house", "dwelling", "first alarm", "rit", "residential" }, true, 15, + L("Fire Attack", CommandNodeType.Group, "Interior fire control and confinement.", new[] { "Engine", "Pumper" }), + L("Primary Search", CommandNodeType.Group, "Primary life-safety search.", new[] { "Truck", "Ladder", "Rescue" }), + L("Ventilation", CommandNodeType.Group, "Coordinate horizontal and vertical ventilation.", new[] { "Truck", "Ladder" }), + L("Water Supply", CommandNodeType.Group, "Establish and maintain a sustained water source.", new[] { "Engine", "Tanker", "Tender" }), + L("Rapid Intervention", CommandNodeType.Group, "Dedicated firefighter rescue team.", new[] { "Rescue", "Squad", "Engine" }, new[] { "Firefighter" }), + L("Staging", CommandNodeType.Staging, "Track unassigned responding resources.")), + + T("fire-commercial-structure", "Commercial Structure Fire", "Fire Departments", + "A scalable commercial-building board organized by geographic divisions and functional groups.", + new[] { "commercial", "warehouse", "high rise", "second alarm", "divisions" }, true, 15, + L("Division A", CommandNodeType.Division, "Street/address-side operations."), + L("Division B", CommandNodeType.Division, "Left-side exposure and operations."), + L("Division C", CommandNodeType.Division, "Rear-side exposure and operations."), + L("Division D", CommandNodeType.Division, "Right-side exposure and operations."), + L("Roof Division", CommandNodeType.Division, "Roof access, ventilation and conditions.", new[] { "Truck", "Ladder" }), + L("Search Group", CommandNodeType.Group, "Primary and secondary searches.", new[] { "Truck", "Ladder", "Rescue" }), + L("Rapid Intervention", CommandNodeType.Group, "Dedicated firefighter rescue team.", new[] { "Rescue", "Squad", "Engine" }), + L("Staging", CommandNodeType.Staging, "Manage additional alarms and relief companies.")), + + T("fire-wildland-wui", "Wildland / WUI Fire", "Fire Departments", + "A wildland-urban interface board for geographic divisions, structure protection and water support.", + new[] { "brush", "wildfire", "wildland", "wui", "vegetation", "strike team" }, true, 30, + L("Division Alpha", CommandNodeType.Division, "Anchor, flank and contain Division Alpha."), + L("Division Bravo", CommandNodeType.Division, "Anchor, flank and contain Division Bravo."), + L("Structure Protection", CommandNodeType.Group, "Assess and protect threatened structures.", new[] { "Engine", "Brush" }), + L("Water Supply", CommandNodeType.Group, "Tender shuttle, fill sites and portable tanks.", new[] { "Tanker", "Tender" }), + L("Dozer / Heavy Equipment", CommandNodeType.TaskForce, "Coordinate line construction and heavy equipment."), + L("Staging", CommandNodeType.Staging, "Check in and deploy incoming resources.")), + + T("fire-hazmat", "Hazardous Materials Incident", "Fire Departments", + "A hazmat branch layout separating entry, backup, decontamination, medical monitoring and support.", + new[] { "hazmat", "chemical", "spill", "cbrne", "decon", "entry" }, true, 20, + L("Hazmat Branch", CommandNodeType.Branch, "Coordinate all hazardous-materials operations."), + L("Entry Group", CommandNodeType.Group, "Hot-zone reconnaissance, control and product identification.", new[] { "Hazmat" }, new[] { "Hazmat Technician" }, true), + L("Backup Group", CommandNodeType.Group, "Dedicated backup for the entry team.", new[] { "Hazmat" }, new[] { "Hazmat Technician" }, true), + L("Decontamination", CommandNodeType.Group, "Technical and emergency decontamination corridor."), + L("Medical Monitoring", CommandNodeType.Group, "Pre-entry and post-entry medical monitoring.", new[] { "Ambulance", "Medic" }, new[] { "Paramedic", "EMT" }), + L("Staging", CommandNodeType.Staging, "Cold-zone resource accountability.")), + + // ---- EMS ---- + T("ems-mass-casualty", "Mass-Casualty Incident", "EMS", + "A standard MCI board for triage, categorized treatment, transport coordination and resource staging.", + new[] { "mci", "multiple patients", "triage", "treatment", "transport", "disaster medical" }, true, 15, + L("Triage Group", CommandNodeType.Group, "Initial patient sorting, tagging and patient count.", null, new[] { "Paramedic", "EMT" }), + L("Immediate Treatment", CommandNodeType.Group, "Treatment area for immediate/red patients."), + L("Delayed Treatment", CommandNodeType.Group, "Treatment area for delayed/yellow patients."), + L("Minor Treatment", CommandNodeType.Group, "Treatment area for minor/green patients."), + L("Transport Group", CommandNodeType.Group, "Ambulance loading, destination coordination and tracking.", new[] { "Ambulance", "Medic" }), + L("Ambulance Staging", CommandNodeType.Staging, "Check in and sequence transport units.", new[] { "Ambulance", "Medic" })), + + T("ems-event-medical", "Planned Event Medical", "EMS", + "A medical operations board for a festival, sporting event, fair or other planned gathering.", + new[] { "festival", "concert", "sporting event", "aid station", "roving team", "special event" }, false, 0, + L("Medical Command", CommandNodeType.Branch, "Coordinate event medical operations and venue command."), + L("Main Aid Station", CommandNodeType.Group, "Fixed treatment and documentation location."), + L("Roving Team Alpha", CommandNodeType.TaskForce, "Mobile first-response team.", null, new[] { "Paramedic", "EMT" }), + L("Roving Team Bravo", CommandNodeType.TaskForce, "Mobile first-response team.", null, new[] { "Paramedic", "EMT" }), + L("Transport Group", CommandNodeType.Group, "Coordinate ambulance access, loading and hospitals.", new[] { "Ambulance", "Medic" }), + L("Medical Logistics", CommandNodeType.Group, "Restock supplies, oxygen, AEDs and responder rehab.")), + + // ---- Law Enforcement ---- + T("law-enforcement-tactical", "Law Enforcement Tactical Incident", "Law Enforcement", + "A high-risk incident board for containment, tactical operations, arrest, negotiation and staging.", + new[] { "police", "sheriff", "swat", "barricade", "hostage", "high risk" }, true, 20, + L("Inner Perimeter", CommandNodeType.Group, "Immediate containment and direct observation."), + L("Outer Perimeter", CommandNodeType.Group, "Traffic, public and media control outside the hot zone."), + L("Tactical Group", CommandNodeType.Group, "Coordinate entry and tactical teams.", new[] { "SWAT", "Tactical" }, new[] { "SWAT", "Tactical Officer" }), + L("Negotiations", CommandNodeType.Group, "Crisis communication and intelligence support."), + L("Arrest Team", CommandNodeType.TaskForce, "Custody, search and prisoner movement."), + L("Staging", CommandNodeType.Staging, "Check in responding personnel and specialty assets.")), + + T("law-enforcement-search", "Manhunt / Law Enforcement Search", "Law Enforcement", + "A containment and search board for a fleeing subject, evidence search or area canvass.", + new[] { "manhunt", "suspect", "containment", "canvass", "k9", "evidence" }, false, 0, + L("Search Branch", CommandNodeType.Branch, "Coordinate containment and search strategy."), + L("North Division", CommandNodeType.Division, "Northern search and containment area."), + L("South Division", CommandNodeType.Division, "Southern search and containment area."), + L("K9 Group", CommandNodeType.Group, "Canine search teams and flankers.", new[] { "K9" }, new[] { "K9 Handler" }), + L("Aviation Group", CommandNodeType.Group, "Air observation and sensor coordination.", new[] { "Helicopter", "Drone", "UAS" }), + L("Staging", CommandNodeType.Staging, "Personnel check-in, briefing and deployment.")), + + // ---- Search and Rescue ---- + T("sar-missing-person", "Wilderness Missing Person Search", "Search and Rescue", + "A field search board for planning, hasty teams, ground teams, canine resources and subject care.", + new[] { "sar", "lost person", "wilderness", "ground search", "hasty", "clue" }, false, 0, + L("Search Management", CommandNodeType.Branch, "Strategy, maps, clues, team tasks and debriefs."), + L("Hasty Team", CommandNodeType.TaskForce, "Rapid search of high-probability routes and locations."), + L("Ground Team Alpha", CommandNodeType.TaskForce, "Assigned search segment."), + L("Ground Team Bravo", CommandNodeType.TaskForce, "Assigned search segment."), + L("K9 Group", CommandNodeType.Group, "Canine teams and flankers.", new[] { "K9" }, new[] { "K9 Handler" }), + L("Medical / Extraction", CommandNodeType.Group, "Subject stabilization and evacuation planning.", new[] { "Ambulance", "Rescue" }), + L("Staging", CommandNodeType.Staging, "Team check-in, briefing and equipment cache.")), + + T("sar-technical-rescue", "Technical / Rope Rescue", "Search and Rescue", + "A technical rescue board separating rescue, rigging, edge safety, medical and support functions.", + new[] { "rope", "high angle", "low angle", "cliff", "cave", "technical rescue" }, true, 20, + L("Rescue Group", CommandNodeType.Group, "Coordinate the rescue plan and rescue team."), + L("Rigging Team", CommandNodeType.TaskForce, "Build, inspect and operate rope systems.", null, new[] { "Rope Rescue Technician" }), + L("Edge Team", CommandNodeType.TaskForce, "Edge protection, litter transition and communications."), + L("Medical Group", CommandNodeType.Group, "Patient access, stabilization and packaging.", new[] { "Ambulance", "Medic" }, new[] { "Paramedic", "EMT" }), + L("Safety", CommandNodeType.Group, "Independent hazard and system monitoring."), + L("Staging", CommandNodeType.Staging, "Personnel and equipment accountability.")), + + T("sar-swiftwater", "Swiftwater / Flood Rescue", "Search and Rescue", + "A water-rescue board for upstream safety, downstream containment, boat teams and shore support.", + new[] { "swiftwater", "flood", "river", "boat", "water rescue" }, true, 15, + L("Rescue Group", CommandNodeType.Group, "Coordinate rescue swimmers, boats and shore teams.", new[] { "Boat", "Rescue" }), + L("Upstream Safety", CommandNodeType.Group, "Watch for and communicate upstream hazards."), + L("Downstream Safety", CommandNodeType.Group, "Contain rescuers or subjects swept downstream."), + L("Boat Team", CommandNodeType.TaskForce, "Boat launch, operations and recovery.", new[] { "Boat" }), + L("Medical Group", CommandNodeType.Group, "Patient warming, treatment and transport.", new[] { "Ambulance", "Medic" }), + L("Staging", CommandNodeType.Staging, "Water-rescue resources and PPE accountability.")), + + // ---- Emergency Management ---- + T("em-eoc-activation", "Emergency Operations Center Activation", "Emergency Management", + "A full EOC starter board using command and general staff functions for multi-agency coordination.", + new[] { "eoc", "ics", "coordination", "nims", "general staff", "policy group" }, false, 0, + L("EOC Director / Unified Command", CommandNodeType.UnifiedCommand, "Executive direction, priorities and policy coordination."), + L("Operations Section", CommandNodeType.Branch, "Coordinate field operations and agency representatives."), + L("Planning Section", CommandNodeType.Branch, "Situation status, resource status and action planning."), + L("Logistics Section", CommandNodeType.Branch, "Facilities, supplies, communications and responder support."), + L("Finance / Administration", CommandNodeType.Branch, "Time, procurement, compensation and cost tracking."), + L("Public Information", CommandNodeType.Group, "Joint information, warnings and media coordination.", null, new[] { "Public Information Officer", "PIO" })), + + T("em-severe-weather", "Severe Weather Coordination", "Emergency Management", + "A coordination board for tornado, hurricane, winter storm, extreme heat or other regional weather impacts.", + new[] { "storm", "hurricane", "tornado", "winter", "weather", "damage assessment" }, false, 0, + L("Warning and Intelligence", CommandNodeType.Group, "Forecasts, alerts, situational awareness and GIS."), + L("Damage Assessment", CommandNodeType.Group, "Initial and detailed impact assessment."), + L("Public Works", CommandNodeType.Branch, "Roads, utilities, debris and infrastructure priorities."), + L("Mass Care", CommandNodeType.Branch, "Shelter, feeding and access/functional needs."), + L("Logistics", CommandNodeType.Branch, "Resource requests, distribution and mutual aid."), + L("Public Information", CommandNodeType.Group, "Protective actions, rumor control and media updates.")), + + // ---- Disaster Response ---- + T("disaster-field-operations", "Disaster Field Operations", "Disaster Response", + "A field operations board for urban search and rescue, medical care, assessment, logistics and communications.", + new[] { "disaster", "usar", "earthquake", "collapse", "task force", "deployment" }, true, 30, + L("USAR Branch", CommandNodeType.Branch, "Coordinate search, rescue and structural specialists.", new[] { "Rescue", "USAR" }), + L("Medical Group", CommandNodeType.Group, "Responder and survivor medical operations.", new[] { "Ambulance", "Medic" }), + L("Damage Assessment", CommandNodeType.Group, "Rapid structural and community impact assessment."), + L("Logistics Base", CommandNodeType.Group, "Cache, food, water, fuel and team sustainment."), + L("Communications", CommandNodeType.Group, "Radio plan, interoperability and communications repair."), + L("Staging", CommandNodeType.Staging, "Incoming teams, equipment and mission assignments.")), + + T("disaster-shelter-relief", "Shelter and Relief Distribution", "Disaster Response", + "A humanitarian services board for sheltering, feeding, relief supplies, registration and site security.", + new[] { "relief", "humanitarian", "shelter", "feeding", "distribution", "donations" }, false, 0, + L("Shelter Operations", CommandNodeType.Branch, "Dormitory, sanitation and resident services."), + L("Registration", CommandNodeType.Group, "Resident intake, records and reunification information."), + L("Feeding", CommandNodeType.Group, "Meal production, dietary needs and distribution."), + L("Medical Support", CommandNodeType.Group, "First aid, medication support and referral."), + L("Relief Distribution", CommandNodeType.Group, "Receive, stage and distribute relief commodities."), + L("Logistics", CommandNodeType.Group, "Supplies, facilities, staffing and transport."), + L("Site Security", CommandNodeType.Group, "Access control, safety and traffic flow.")), + + // ---- Security Companies ---- + T("security-facility-incident", "Facility Security Incident", "Security Companies", + "A private-security board for facility response, access control, perimeter operations and agency liaison.", + new[] { "security", "facility", "campus", "alarm", "intrusion", "guard" }, false, 0, + L("Security Command", CommandNodeType.UnifiedCommand, "Coordinate site leadership and public-safety agencies."), + L("Interior Response", CommandNodeType.Group, "Assess and respond inside the facility."), + L("Access Control", CommandNodeType.Group, "Lockdown, credentials and controlled entry."), + L("Perimeter Group", CommandNodeType.Group, "Secure exterior zones and control pedestrians."), + L("Agency Liaison", CommandNodeType.Group, "Meet and brief fire, EMS and law enforcement."), + L("Staging", CommandNodeType.Staging, "Security teams and responding resources.")), + + // ---- Event Medical / Security ---- + T("event-unified-operations", "Large Event Medical and Security", "Event Medical / Security", + "A unified event board combining medical, security, crowd, access and transport operations.", + new[] { "concert", "festival", "fair", "stadium", "venue", "special event", "unified" }, false, 0, + L("Event Unified Command", CommandNodeType.UnifiedCommand, "Venue, medical, security and public-safety coordination."), + L("Medical Branch", CommandNodeType.Branch, "Aid stations, roving teams and patient tracking."), + L("Main Aid Station", CommandNodeType.Group, "Fixed medical treatment area."), + L("Security Branch", CommandNodeType.Branch, "Security teams and incident response."), + L("Access Control", CommandNodeType.Group, "Credentials, gates and prohibited items."), + L("Crowd Management", CommandNodeType.Group, "Density, queues, barriers and audience movement."), + L("Transport / Public Safety Staging", CommandNodeType.Staging, "Ambulances and public-safety resources.")), + + T("event-rapid-response", "Event Rapid Response", "Event Medical / Security", + "A compact board for small and medium events with roving medical, security response and venue liaison teams.", + new[] { "event team", "roving", "first aid", "security response", "venue" }, false, 0, + L("Event Lead", CommandNodeType.UnifiedCommand, "Single contact for venue and response partners."), + L("Medical Team", CommandNodeType.TaskForce, "Roving first aid and patient assessment."), + L("Security Team", CommandNodeType.TaskForce, "Roving security and crowd support."), + L("Venue Liaison", CommandNodeType.Group, "Operations, facilities and organizer coordination."), + L("Response Staging", CommandNodeType.Staging, "Reserve staff and transport resources.")), + + // ---- Industrial Response ---- + T("industrial-emergency", "Industrial Site Emergency", "Industrial Response", + "An industrial response board for process isolation, fire, rescue, hazmat, medical and accountability.", + new[] { "plant", "refinery", "factory", "industrial", "process", "brigade" }, true, 15, + L("Industrial Command", CommandNodeType.UnifiedCommand, "Site management, emergency responders and agency coordination."), + L("Process Isolation", CommandNodeType.Group, "Shutdown, lockout and energy/product isolation."), + L("Fire Suppression", CommandNodeType.Group, "Industrial brigade fire control.", new[] { "Engine", "Fire Brigade" }), + L("Rescue Group", CommandNodeType.Group, "Access, extrication and victim removal.", new[] { "Rescue" }), + L("Hazmat Group", CommandNodeType.Group, "Product control, monitoring and decontamination.", new[] { "Hazmat" }), + L("Medical Group", CommandNodeType.Group, "On-site treatment and transport.", new[] { "Ambulance", "Medic" }), + L("Accountability / Staging", CommandNodeType.Staging, "Personnel accountability and resource check-in.")), + + T("industrial-confined-space", "Confined Space Rescue", "Industrial Response", + "A confined-space board for entry, backup, rigging, atmospheric monitoring, medical and safety.", + new[] { "confined space", "permit space", "trench", "technical rescue", "entry team" }, true, 15, + L("Rescue Group", CommandNodeType.Group, "Coordinate the entry rescue plan."), + L("Entry Team", CommandNodeType.TaskForce, "Enter, assess and package the victim.", null, new[] { "Confined Space Technician" }, true), + L("Backup Team", CommandNodeType.TaskForce, "Ready team for entry-team rescue.", null, new[] { "Confined Space Technician" }, true), + L("Rigging", CommandNodeType.Group, "Mechanical advantage, haul and lowering systems."), + L("Atmospheric Monitoring", CommandNodeType.Group, "Continuous air monitoring and ventilation."), + L("Medical", CommandNodeType.Group, "Victim and entrant medical support.", new[] { "Ambulance", "Medic" }), + L("Safety", CommandNodeType.Group, "Permit, hazards, PPE and rescue-system oversight.")), + + // ---- Delivery Companies ---- + T("delivery-distribution-disruption", "Distribution Center Disruption", "Delivery Companies", + "A business-continuity board for a hub shutdown, safety incident or major sorting and dispatch interruption.", + new[] { "warehouse", "distribution", "parcel", "sort center", "business continuity", "logistics" }, false, 0, + L("Site Command", CommandNodeType.UnifiedCommand, "Site leadership, safety and emergency-service coordination."), + L("Life Safety", CommandNodeType.Group, "Employee accountability, first aid and hazard control."), + L("Dock Operations", CommandNodeType.Group, "Trailer, dock and material-flow priorities."), + L("Route Recovery", CommandNodeType.Branch, "Reassign routes, drivers, vehicles and service areas."), + L("Customer Coordination", CommandNodeType.Group, "Service alerts and priority-customer exceptions."), + L("Security", CommandNodeType.Group, "Access, traffic and cargo security."), + L("Fleet / Maintenance", CommandNodeType.Group, "Vehicle availability, repairs and replacement assets.")), + + T("delivery-route-emergency", "Fleet / Route Emergency", "Delivery Companies", + "A compact board for a vehicle crash, disabled vehicle, cargo incident or route-wide disruption.", + new[] { "delivery", "courier", "fleet", "vehicle", "cargo", "route", "last mile" }, false, 0, + L("Incident Lead", CommandNodeType.UnifiedCommand, "Coordinate driver, public safety and business response."), + L("Driver Welfare", CommandNodeType.Group, "Driver contact, medical needs and family notification."), + L("Cargo Recovery", CommandNodeType.TaskForce, "Secure, inventory and transfer cargo."), + L("Towing / Maintenance", CommandNodeType.TaskForce, "Recover or replace the vehicle."), + L("Route Reassignment", CommandNodeType.Group, "Transfer stops and rebalance neighboring routes."), + L("Customer Notifications", CommandNodeType.Group, "Communicate delays and delivery exceptions.")) + }; + } + + #endregion Builders + } +} diff --git a/Core/Resgrid.Model/CommandBoards/CommandBoardTemplateLane.cs b/Core/Resgrid.Model/CommandBoards/CommandBoardTemplateLane.cs new file mode 100644 index 000000000..83a21466e --- /dev/null +++ b/Core/Resgrid.Model/CommandBoards/CommandBoardTemplateLane.cs @@ -0,0 +1,27 @@ +using System; + +namespace Resgrid.Model.CommandBoards +{ + /// + /// A code-defined lane in a . Suggested unit types and personnel + /// roles are names rather than database identifiers because both are configured per department. + /// + public class CommandBoardTemplateLane + { + public string Name { get; set; } + + public string Description { get; set; } + + public CommandNodeType LaneType { get; set; } + + public string[] SuggestedUnitTypes { get; set; } = Array.Empty(); + + public string[] SuggestedPersonnelRoles { get; set; } = Array.Empty(); + + /// + /// Whether matched requirements should block incompatible assignments. This is only applied when + /// at least one suggested unit type or personnel role exists in the department. + /// + public bool ForceRequirements { get; set; } + } +} diff --git a/Core/Resgrid.Model/Events/IncidentCommandEvents.cs b/Core/Resgrid.Model/Events/IncidentCommandEvents.cs index db2a72005..2ff6cfd80 100644 --- a/Core/Resgrid.Model/Events/IncidentCommandEvents.cs +++ b/Core/Resgrid.Model/Events/IncidentCommandEvents.cs @@ -104,4 +104,82 @@ public class IncidentCommandUpdatedEvent public int DepartmentId { get; set; } public int CallId { get; set; } } + + public class IncidentNoteAddedEvent + { + public int DepartmentId { get; set; } + public int CallId { get; set; } + public string IncidentCommandId { get; set; } + public string IncidentNoteId { get; set; } + public int Visibility { get; set; } + public int NoteType { get; set; } + public string Title { get; set; } + public string Body { get; set; } + public decimal? ContainmentPercent { get; set; } + public string CreatedByUserId { get; set; } + } + + public class IncidentNoteRemovedEvent + { + public int DepartmentId { get; set; } + public int CallId { get; set; } + public string IncidentCommandId { get; set; } + public string IncidentNoteId { get; set; } + public int Visibility { get; set; } + public string RemovedByUserId { get; set; } + } + + public class IncidentAttachmentAddedEvent + { + public int DepartmentId { get; set; } + public int CallId { get; set; } + public string IncidentCommandId { get; set; } + public string IncidentAttachmentId { get; set; } + public int Visibility { get; set; } + public string FileName { get; set; } + public string ContentType { get; set; } + public long ContentLength { get; set; } + public string Sha256Hash { get; set; } + public string Description { get; set; } + public string UploadedByUserId { get; set; } + } + + public class IncidentAttachmentRemovedEvent + { + public int DepartmentId { get; set; } + public int CallId { get; set; } + public string IncidentCommandId { get; set; } + public string IncidentAttachmentId { get; set; } + public int Visibility { get; set; } + public string FileName { get; set; } + public string RemovedByUserId { get; set; } + } + + public class IncidentActionPlanUpdatedEvent + { + public int DepartmentId { get; set; } + public int CallId { get; set; } + public string IncidentCommandId { get; set; } + public string ActionPlan { get; set; } + public string UpdatedByUserId { get; set; } + } + + public class IncidentCommandPostUpdatedEvent + { + public int DepartmentId { get; set; } + public int CallId { get; set; } + public string IncidentCommandId { get; set; } + public string Latitude { get; set; } + public string Longitude { get; set; } + public string UpdatedByUserId { get; set; } + } + + public class IncidentPublicSharingChangedEvent + { + public int DepartmentId { get; set; } + public int CallId { get; set; } + public string IncidentCommandId { get; set; } + public bool Enabled { get; set; } + public string UpdatedByUserId { get; set; } + } } diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs b/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs index c9fa8c0df..d0d858ae9 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs @@ -40,6 +40,12 @@ public class IncidentCommand : IEntity, IChangeTracked public DateTime? ClosedOn { get; set; } + /// Whether the token-scoped public status feed is currently available. + public bool PublicShareEnabled { get; set; } + + /// Random, rotatable token used by the anonymous public incident feed; never a numeric CallId. + public string PublicShareToken { get; set; } + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. public DateTime? ModifiedOn { get; set; } diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentCommandBoard.cs b/Core/Resgrid.Model/IncidentCommand/IncidentCommandBoard.cs index 076bc90f5..e02acdfe0 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentCommandBoard.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentCommandBoard.cs @@ -24,5 +24,11 @@ public class IncidentCommandBoard /// Active functional command-role assignments for the incident (ยง3.11). public List Roles { get; set; } = new List(); + + /// Active internal and public operational status notes. + public List Notes { get; set; } = new List(); + + /// Active attachment metadata; binary content is downloaded separately. + public List Attachments { get; set; } = new List(); } } diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs b/Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs index fbc33ef8b..e080b9eae 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs @@ -33,6 +33,11 @@ public class IncidentCommandChanges public List AdHocPersonnel { get; set; } = new List(); + public List Notes { get; set; } = new List(); + + /// Attachment metadata only; is JSON-ignored. + public List Attachments { get; set; } = new List(); + public List TimelineEntries { get; set; } = new List(); } } diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentCommandEnums.cs b/Core/Resgrid.Model/IncidentCommand/IncidentCommandEnums.cs index ac359f2cf..51e816df9 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentCommandEnums.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentCommandEnums.cs @@ -113,6 +113,14 @@ public enum CommandLogEntryType /// PAR sweep (IncidentCommandService.EvaluateCriticalParAsync) keyed on the subject user, and /// doubles as the dedup marker so the alert only re-fires after the member checks in and lapses again. /// - ParCritical = 22 + ParCritical = 22, + IncidentNoteAdded = 23, + IncidentNoteRemoved = 24, + IncidentAttachmentAdded = 25, + IncidentAttachmentRemoved = 26, + ActionPlanUpdated = 27, + CommandPostUpdated = 28, + PublicSharingEnabled = 29, + PublicSharingDisabled = 30 } } diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentContent.cs b/Core/Resgrid.Model/IncidentCommand/IncidentContent.cs new file mode 100644 index 000000000..781088079 --- /dev/null +++ b/Core/Resgrid.Model/IncidentCommand/IncidentContent.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// Audience for incident notes and attachments. + public enum IncidentContentVisibility + { + Internal = 0, + Public = 1 + } + + /// Operational classification for an incident status note. + public enum IncidentNoteType + { + General = 0, + SituationUpdate = 1, + Containment = 2, + ForwardProgress = 3, + Safety = 4, + Evacuation = 5, + Shelter = 6, + DamageAssessment = 7, + PublicInformation = 8, + ResourceNeed = 9, + Weather = 10 + } + + /// + /// A status update on an incident. Notes are retained for the operational record and may be marked public for + /// inclusion in the incident's opt-in public information feed. + /// + public class IncidentNote : IEntity, IChangeTracked + { + public string IncidentNoteId { get; set; } + public string IncidentCommandId { get; set; } + public int DepartmentId { get; set; } + public int CallId { get; set; } + public int NoteType { get; set; } + public int Visibility { get; set; } + public string Title { get; set; } + public string Body { get; set; } + + /// Optional structured containment value (0-100) for fire/disaster status reporting. + public decimal? ContainmentPercent { get; set; } + + public string CreatedByUserId { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime? DeletedOn { get; set; } + public string DeletedByUserId { get; set; } + public DateTime? ModifiedOn { get; set; } + + [NotMapped] + public string TableName => "IncidentNotes"; + + [NotMapped] + public string IdName => "IncidentNoteId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return IncidentNoteId; } + set { IncidentNoteId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// + /// Incident-level file metadata and content. Binary data is intentionally excluded from JSON board/sync responses + /// and is available only from the authenticated or public-token download endpoints. + /// + public class IncidentAttachment : IEntity, IChangeTracked + { + public string IncidentAttachmentId { get; set; } + public string IncidentCommandId { get; set; } + public int DepartmentId { get; set; } + public int CallId { get; set; } + public int Visibility { get; set; } + public string FileName { get; set; } + public string ContentType { get; set; } + public long ContentLength { get; set; } + public string Sha256Hash { get; set; } + public string Description { get; set; } + + [JsonIgnore] + [System.Text.Json.Serialization.JsonIgnore] + public byte[] Data { get; set; } + + public string UploadedByUserId { get; set; } + public DateTime UploadedOn { get; set; } + public DateTime? DeletedOn { get; set; } + public string DeletedByUserId { get; set; } + public DateTime? ModifiedOn { get; set; } + + [NotMapped] + public string TableName => "IncidentAttachments"; + + [NotMapped] + public string IdName => "IncidentAttachmentId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return IncidentAttachmentId; } + set { IncidentAttachmentId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// Safe public projection resolved from an enabled incident share token. + public class IncidentPublicInformation + { + public string IncidentCommandId { get; set; } + public DateTime EstablishedOn { get; set; } + public int Status { get; set; } + public DateTime? ClosedOn { get; set; } + public DateTime? LastUpdatedOn { get; set; } + public List Notes { get; set; } = new List(); + public List Attachments { get; set; } = new List(); + } + + /// Public-note projection that omits department, call, and user identifiers. + public class PublicIncidentNote + { + public string IncidentNoteId { get; set; } + public int NoteType { get; set; } + public string Title { get; set; } + public string Body { get; set; } + public decimal? ContainmentPercent { get; set; } + public DateTime CreatedOn { get; set; } + } + + /// Public attachment projection; the opaque id is used with the token-scoped download route. + public class PublicIncidentAttachment + { + public string IncidentAttachmentId { get; set; } + public string FileName { get; set; } + public string ContentType { get; set; } + public long ContentLength { get; set; } + public string Sha256Hash { get; set; } + public string Description { get; set; } + public DateTime UploadedOn { get; set; } + } +} diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs b/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs index 37c6d83c1..8f0b381e2 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs @@ -60,7 +60,10 @@ public enum IncidentCapabilities ManageChannels = 256, // on-demand voice channels ManageResources = 512, // create ad-hoc resources / staging intake ViewReports = 1024, - All = ViewBoard | ManageCommand | ManageStructure | AssignResources | ManageObjectives | ManageTimers | ManageAnnotations | ManageAccountability | ManageChannels | ManageResources | ViewReports + ManageNotes = 2048, // operational/situation/safety notes + ManageDocuments = 4096, // incident-level files and records + ManagePublicInformation = 8192,// public notes, documents, and share-link lifecycle + All = ViewBoard | ManageCommand | ManageStructure | AssignResources | ManageObjectives | ManageTimers | ManageAnnotations | ManageAccountability | ManageChannels | ManageResources | ViewReports | ManageNotes | ManageDocuments | ManagePublicInformation } /// @@ -84,10 +87,11 @@ public static IncidentCapabilities GetCapabilities(IncidentRoleType role) case IncidentRoleType.PlanningSectionChief: case IncidentRoleType.SituationUnitLeader: - return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageObjectives | IncidentCapabilities.ManageAnnotations | IncidentCapabilities.ViewReports; + return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageObjectives | IncidentCapabilities.ManageAnnotations | + IncidentCapabilities.ManageNotes | IncidentCapabilities.ManageDocuments | IncidentCapabilities.ViewReports; case IncidentRoleType.DocumentationUnitLeader: - return IncidentCapabilities.ViewBoard | IncidentCapabilities.ViewReports; + return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageNotes | IncidentCapabilities.ManageDocuments | IncidentCapabilities.ViewReports; case IncidentRoleType.LogisticsSectionChief: return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageChannels | IncidentCapabilities.ManageResources; @@ -96,11 +100,14 @@ public static IncidentCapabilities GetCapabilities(IncidentRoleType role) return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageChannels; case IncidentRoleType.FinanceAdminSectionChief: - case IncidentRoleType.PublicInformationOfficer: return IncidentCapabilities.ViewBoard | IncidentCapabilities.ViewReports; + case IncidentRoleType.PublicInformationOfficer: + return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageNotes | IncidentCapabilities.ManageDocuments | + IncidentCapabilities.ManagePublicInformation | IncidentCapabilities.ViewReports; + case IncidentRoleType.SafetyOfficer: - return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageAnnotations | IncidentCapabilities.ManageObjectives; + return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageAnnotations | IncidentCapabilities.ManageObjectives | IncidentCapabilities.ManageNotes; case IncidentRoleType.LiaisonOfficer: return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageResources; @@ -137,7 +144,8 @@ public static IncidentCapabilities GetCapabilities(IncidentRoleType role) case IncidentRoleType.ShelterMassCareCoordinator: case IncidentRoleType.DamageAssessmentLead: - return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageObjectives | IncidentCapabilities.ViewReports; + return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageObjectives | IncidentCapabilities.ManageNotes | + IncidentCapabilities.ManageDocuments | IncidentCapabilities.ViewReports; default: return IncidentCapabilities.ViewBoard; diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentWeather.cs b/Core/Resgrid.Model/IncidentCommand/IncidentWeather.cs new file mode 100644 index 000000000..3d5ab6b8c --- /dev/null +++ b/Core/Resgrid.Model/IncidentCommand/IncidentWeather.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// Commander-ready current conditions, hourly forecast, and map overlays for an incident location. + public class IncidentWeather + { + public decimal Latitude { get; set; } + public decimal Longitude { get; set; } + public string Source { get; set; } + public string Attribution { get; set; } + public DateTime GeneratedAtUtc { get; set; } + public DateTime? UpdatedAtUtc { get; set; } + public DateTime ExpiresAtUtc { get; set; } + public IncidentWeatherObservation Current { get; set; } + public List HourlyForecast { get; set; } = new List(); + public List Overlays { get; set; } = new List(); + } + + public class IncidentWeatherObservation + { + public string StationId { get; set; } + public string Description { get; set; } + public DateTime? ObservedAtUtc { get; set; } + public decimal? TemperatureCelsius { get; set; } + public decimal? RelativeHumidityPercent { get; set; } + public decimal? WindSpeedKph { get; set; } + public decimal? WindGustKph { get; set; } + public decimal? WindDirectionDegrees { get; set; } + public decimal? BarometricPressureHpa { get; set; } + public decimal? VisibilityMeters { get; set; } + } + + public class IncidentWeatherForecastPeriod + { + public int Number { get; set; } + public string Name { get; set; } + public DateTime StartsAtUtc { get; set; } + public DateTime EndsAtUtc { get; set; } + public bool IsDaytime { get; set; } + public decimal? TemperatureCelsius { get; set; } + public int? PrecipitationProbabilityPercent { get; set; } + public string WindSpeed { get; set; } + public string WindDirection { get; set; } + public string ShortForecast { get; set; } + public string DetailedForecast { get; set; } + public string IconUrl { get; set; } + } + + /// Map-client overlay manifest. ArcGIS export templates use an EPSG:3857 bounding box. + public class IncidentWeatherOverlay + { + public string Id { get; set; } + public string Name { get; set; } + public string OverlayType { get; set; } + public string ServiceUrl { get; set; } + public string ExportUrlTemplate { get; set; } + public string LayerIds { get; set; } + public decimal DefaultOpacity { get; set; } + public int RefreshSeconds { get; set; } + public string Attribution { get; set; } + } +} diff --git a/Core/Resgrid.Model/Providers/IIncidentWeatherProvider.cs b/Core/Resgrid.Model/Providers/IIncidentWeatherProvider.cs new file mode 100644 index 000000000..588ad6789 --- /dev/null +++ b/Core/Resgrid.Model/Providers/IIncidentWeatherProvider.cs @@ -0,0 +1,10 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Providers +{ + public interface IIncidentWeatherProvider + { + Task GetWeatherAsync(decimal latitude, decimal longitude, int forecastHours = 24, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Repositories/IIncidentCommandRepositories.cs b/Core/Resgrid.Model/Repositories/IIncidentCommandRepositories.cs index 03d2707c1..f18d67813 100644 --- a/Core/Resgrid.Model/Repositories/IIncidentCommandRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IIncidentCommandRepositories.cs @@ -2,6 +2,7 @@ namespace Resgrid.Model.Repositories { public interface IIncidentCommandRepository : IRepository { + System.Threading.Tasks.Task GetByPublicShareTokenAsync(string publicShareToken); } public interface ICommandStructureNodeRepository : IRepository @@ -31,4 +32,13 @@ public interface ICommandLogEntryRepository : IRepository public interface ICommandTransferRepository : IRepository { } + + public interface IIncidentNoteRepository : IRepository + { + } + + public interface IIncidentAttachmentRepository : IRepository + { + System.Threading.Tasks.Task> GetAllMetadataByDepartmentIdAsync(int departmentId); + } } diff --git a/Core/Resgrid.Model/Services/IIncidentCommandService.cs b/Core/Resgrid.Model/Services/IIncidentCommandService.cs index 532674639..ecf2aca79 100644 --- a/Core/Resgrid.Model/Services/IIncidentCommandService.cs +++ b/Core/Resgrid.Model/Services/IIncidentCommandService.cs @@ -52,6 +52,27 @@ public interface IIncidentCommandService Task CloseCommandAsync(int departmentId, string incidentCommandId, string userId, CancellationToken cancellationToken = default(CancellationToken)); Task TransferCommandAsync(int departmentId, string incidentCommandId, string fromUserId, string toUserId, string notes, CancellationToken cancellationToken = default(CancellationToken)); Task UpdateActionPlanAsync(int departmentId, string incidentCommandId, string actionPlan, string userId, CancellationToken cancellationToken = default(CancellationToken)); + Task UpdateCommandPostAsync(int departmentId, string incidentCommandId, string latitude, string longitude, string userId, CancellationToken cancellationToken = default(CancellationToken)); + + // Operational status notes + Task AddNoteAsync(IncidentNote note, string userId, CancellationToken cancellationToken = default(CancellationToken)); + Task> GetNotesForCallAsync(int departmentId, int callId, bool publicOnly = false); + Task RemoveNoteAsync(int departmentId, string incidentNoteId, string userId, CancellationToken cancellationToken = default(CancellationToken)); + + // Incident-level documents/files + Task AddAttachmentAsync(IncidentAttachment attachment, string userId, CancellationToken cancellationToken = default(CancellationToken)); + Task> GetAttachmentsForCallAsync(int departmentId, int callId, bool publicOnly = false); + Task GetAttachmentAsync(int departmentId, string incidentAttachmentId); + Task RemoveAttachmentAsync(int departmentId, string incidentAttachmentId, string userId, CancellationToken cancellationToken = default(CancellationToken)); + + // Public incident information feed + Task EnablePublicSharingAsync(int departmentId, string incidentCommandId, string userId, CancellationToken cancellationToken = default(CancellationToken)); + Task DisablePublicSharingAsync(int departmentId, string incidentCommandId, string userId, CancellationToken cancellationToken = default(CancellationToken)); + Task GetPublicInformationAsync(string publicShareToken); + Task GetPublicAttachmentAsync(string publicShareToken, string incidentAttachmentId); + + // Real-time commander weather (command-post coordinates first, then call coordinates) + Task GetWeatherForIncidentAsync(int departmentId, int callId, CancellationToken cancellationToken = default(CancellationToken)); // Structure (lanes) Task SaveNodeAsync(CommandStructureNode node, string userId, CancellationToken cancellationToken = default(CancellationToken)); diff --git a/Core/Resgrid.Model/UnitRoles/UnitRoleTemplateCatalog.cs b/Core/Resgrid.Model/UnitRoles/UnitRoleTemplateCatalog.cs index 422e8133d..e0e6c3ab6 100644 --- a/Core/Resgrid.Model/UnitRoles/UnitRoleTemplateCatalog.cs +++ b/Core/Resgrid.Model/UnitRoles/UnitRoleTemplateCatalog.cs @@ -59,32 +59,37 @@ private static IReadOnlyList BuildAll() { return new List { - // ---- Fire ---- - T("fire-engine-company", "Fire Engine Company", "Fire", + // ---- Fire Departments ---- + T("fire-engine-company", "Fire Engine Company", "Fire Departments", "A standard engine (pumper) company: company officer, apparatus driver/engineer and firefighters.", new[] { "engine", "pumper", "company", "suppression", "crew" }, R("Company Officer", "Officer"), R("Driver/Engineer", "Driver/Operator"), R("Firefighter"), R("Firefighter")), - T("fire-truck-company", "Fire Truck / Ladder Company", "Fire", + T("fire-truck-company", "Fire Truck / Ladder Company", "Fire Departments", "A truck (ladder/aerial) company focused on search, ventilation and rescue.", new[] { "ladder", "truck", "aerial", "tiller", "rescue", "ventilation" }, R("Company Officer", "Officer"), R("Driver/Operator", "Driver/Operator"), R("Firefighter"), R("Firefighter"), R("Firefighter")), - T("fire-rescue-squad", "Rescue / Squad Company", "Fire", + T("fire-rescue-squad", "Rescue / Squad Company", "Fire Departments", "A heavy rescue or squad company for technical rescue and extrication work.", new[] { "rescue", "squad", "extrication", "technical", "heavy" }, R("Rescue Officer", "Officer"), R("Driver/Operator", "Driver/Operator"), R("Rescue Technician", "Rescue Technician"), R("Rescue Technician", "Rescue Technician")), - T("fire-brush-unit", "Brush / Wildland Unit", "Fire", + T("fire-brush-unit", "Brush / Wildland Unit", "Fire Departments", "A wildland/brush unit for vegetation and wildland-urban interface fires.", new[] { "brush", "wildland", "grass", "wui", "interface" }, R("Crew Boss", "Wildland"), R("Operator", "Driver/Operator"), R("Firefighter", "Wildland"), R("Firefighter", "Wildland")), - T("fire-command", "Fire Command Staff", "Fire", + T("fire-command", "Fire Command Staff", "Fire Departments", "An incident command team for structure fires and larger incidents.", new[] { "command", "ics", "incident", "chief", "accountability" }, R("Incident Commander", "Command Officer"), R("Safety Officer", "Safety Officer"), R("Operations"), R("Accountability")), + T("fire-tanker-tender", "Tanker / Tender Crew", "Fire Departments", + "A water-supply apparatus crew for rural and non-hydrant operations.", + new[] { "tanker", "tender", "water shuttle", "rural", "portable tank" }, + R("Driver/Operator", "Driver/Operator"), R("Dump Site Operator"), R("Fill Site Operator")), + // ---- EMS ---- T("ems-ambulance-als", "Ambulance (ALS)", "EMS", "An advanced life support ambulance staffed with a paramedic and a driver.", @@ -101,6 +106,16 @@ private static IReadOnlyList BuildAll() new[] { "supervisor", "chase", "fly car", "qrv", "field" }, R("Supervisor", "Paramedic")), + T("ems-critical-care", "Critical Care Transport", "EMS", + "An interfacility or specialty transport team with advanced clinical roles.", + new[] { "critical care", "cct", "interfacility", "specialty", "transport", "nurse" }, + R("Critical Care Paramedic", "Critical Care Paramedic", required: true), R("Transport Nurse", "Nurse"), R("Driver / EMT", "EMT")), + + T("ems-mci-team", "Mass-Casualty Medical Team", "EMS", + "A deployable medical team for triage, treatment and transport coordination.", + new[] { "mci", "mass casualty", "triage", "treatment", "transport group" }, + R("Medical Group Supervisor", "Paramedic"), R("Triage Lead", "Paramedic"), R("Treatment Lead", "Paramedic"), R("Transport Coordinator", "EMT")), + // ---- Law Enforcement ---- T("le-patrol-unit", "Patrol Unit", "Law Enforcement", "A patrol vehicle staffed by one or two officers.", @@ -117,6 +132,16 @@ private static IReadOnlyList BuildAll() new[] { "swat", "tactical", "sert", "entry", "sniper" }, R("Team Leader", "Officer"), R("Entry"), R("Entry"), R("Sniper/Observer", "Marksman"), R("Tactical Medic", "Paramedic", required: true)), + T("le-traffic-unit", "Traffic Enforcement Unit", "Law Enforcement", + "A traffic or highway unit with enforcement, collision and traffic-control seats.", + new[] { "traffic", "highway", "motor", "collision", "road closure" }, + R("Primary Officer", "Officer"), R("Collision Investigator"), R("Traffic Control")), + + T("le-investigations-team", "Investigations Team", "Law Enforcement", + "An investigative unit for case leadership, evidence, interviews and scene documentation.", + new[] { "detective", "investigation", "evidence", "crime scene", "interview" }, + R("Lead Investigator", "Detective"), R("Evidence Technician"), R("Interviewer"), R("Scene Security", "Officer")), + // ---- Search and Rescue ---- T("sar-ground-team", "SAR Ground Team", "Search and Rescue", "A ground search-and-rescue team with a leader, navigator and medic.", @@ -133,60 +158,122 @@ private static IReadOnlyList BuildAll() new[] { "swiftwater", "water rescue", "flood", "boat", "river" }, R("Team Leader", "Swiftwater Technician"), R("Rescue Swimmer", "Swiftwater Technician", required: true), R("Rope Tender"), R("Spotter")), - // ---- Emergency / Disaster Response ---- - T("er-incident-command", "Incident Command Team", "Emergency Response", + T("sar-rope-team", "Technical / Rope Rescue Team", "Search and Rescue", + "A high- or low-angle rescue team with rigging, edge and medical responsibilities.", + new[] { "rope", "high angle", "low angle", "cliff", "rigging", "technical rescue" }, + R("Rescue Team Leader", "Rope Rescue Technician"), R("Lead Rigger", "Rope Rescue Technician", required: true), R("Edge Attendant"), R("Rescuer", "Rope Rescue Technician"), R("Medic", "Paramedic")), + + // ---- Emergency Management ---- + T("er-incident-command", "Incident Command Team", "Emergency Management", "A general ICS command and general staff structure for any incident type.", new[] { "ics", "command", "general staff", "nims", "unified command" }, R("Incident Commander"), R("Operations Section Chief"), R("Planning Section Chief"), R("Logistics Section Chief"), R("Finance/Admin Section Chief")), + T("dr-eoc-team", "Emergency Operations Center", "Emergency Management", + "An EOC staffing template for coordinating a large-scale response.", + new[] { "eoc", "operations center", "coordination", "activation" }, + R("EOC Director"), R("Operations"), R("Planning"), R("Logistics"), R("Public Information Officer", "PIO")), + + T("em-damage-assessment", "Damage Assessment Team", "Emergency Management", + "A field team for rapid impact surveys, documentation and situation reporting.", + new[] { "damage assessment", "survey", "impact", "gis", "situation report" }, + R("Team Lead"), R("Building Assessor"), R("GIS / Mapping"), R("Documentation"), R("Driver / Safety")), + + // ---- Disaster Response ---- T("dr-usar-squad", "USAR Squad", "Disaster Response", "An urban search-and-rescue squad for structural collapse operations.", new[] { "usar", "collapse", "task force", "disaster", "shoring" }, R("Squad Leader", "Rescue Technician"), R("Rescue Specialist", "Rescue Technician", required: true), R("Medical Specialist", "Paramedic", required: true), R("Hazmat Specialist", "Hazmat Technician")), - T("dr-eoc-team", "Emergency Operations Center", "Disaster Response", - "An EOC staffing template for coordinating a large-scale response.", - new[] { "eoc", "operations center", "coordination", "activation" }, - R("EOC Director"), R("Operations"), R("Planning"), R("Logistics"), R("Public Information Officer", "PIO")), + T("dr-shelter-team", "Disaster Shelter Team", "Disaster Response", + "A shelter operations team for registration, dormitory, feeding, medical and logistics support.", + new[] { "shelter", "mass care", "evacuee", "feeding", "registration", "relief" }, + R("Shelter Manager"), R("Registration"), R("Dormitory Lead"), R("Feeding Lead"), R("Medical / First Aid"), R("Logistics")), + + T("dr-relief-distribution", "Relief Distribution Team", "Disaster Response", + "A commodity distribution crew for receiving, inventory, traffic flow and public handoff.", + new[] { "relief", "distribution", "commodities", "supplies", "pod", "humanitarian" }, + R("Site Lead"), R("Receiving"), R("Inventory"), R("Loader"), R("Traffic Control"), R("Public Handoff")), - // ---- Security ---- - T("sec-patrol", "Security Patrol", "Security", + // ---- Security Companies ---- + T("sec-patrol", "Security Patrol", "Security Companies", "A mobile security patrol with a supervisor and officers.", new[] { "security", "guard", "patrol", "site", "campus" }, R("Shift Supervisor", "Supervisor"), R("Patrol Officer", "Security Officer"), R("Patrol Officer", "Security Officer")), - T("sec-event-team", "Event Security Team", "Security", + T("sec-alarm-response", "Alarm Response Team", "Security Companies", + "A security response unit for intrusion, fire, duress and facility alarms.", + new[] { "alarm", "intrusion", "duress", "facility", "response" }, + R("Response Lead", "Supervisor"), R("Primary Officer", "Security Officer"), R("Cover Officer", "Security Officer"), R("Control Room Liaison")), + + T("sec-protective-detail", "Protective Services Detail", "Security Companies", + "An executive or dignitary protection team with close protection, advance and driving roles.", + new[] { "executive protection", "vip", "dignitary", "protective detail", "advance" }, + R("Detail Leader"), R("Close Protection"), R("Advance Agent"), R("Protective Driver"), R("Medical Support")), + + // ---- Event Medical / Security ---- + T("sec-event-team", "Event Security Team", "Event Medical / Security", "A security detail for events and venues.", new[] { "event", "venue", "crowd", "detail", "guard" }, R("Team Lead", "Supervisor"), R("Officer", "Security Officer"), R("Officer", "Security Officer"), R("Officer", "Security Officer")), - // ---- Industrial Management ---- - T("ind-fire-brigade", "Industrial Fire Brigade", "Industrial Management", + T("event-medical-roving", "Event Roving Medical Team", "Event Medical / Security", + "A mobile first-aid team for festivals, sporting events and large venues.", + new[] { "event medical", "roving", "festival", "concert", "stadium", "first aid" }, + R("Team Lead", "Paramedic"), R("Medic", "Paramedic"), R("EMT", "EMT"), R("Event Liaison")), + + T("event-aid-station", "Event Medical Aid Station", "Event Medical / Security", + "A fixed medical post for triage, treatment, documentation and transport coordination.", + new[] { "aid station", "first aid", "treatment", "patient", "event", "transport" }, + R("Aid Station Lead", "Paramedic"), R("Triage"), R("Treatment"), R("Patient Tracking"), R("Transport Coordinator")), + + T("event-unified-team", "Event Unified Response Team", "Event Medical / Security", + "A joint venue team combining operations, medical, security and communications seats.", + new[] { "unified", "venue", "operations", "medical", "security", "communications" }, + R("Event Commander"), R("Venue Operations"), R("Medical Lead", "Paramedic"), R("Security Lead", "Supervisor"), R("Communications")), + + // ---- Industrial Response ---- + T("ind-fire-brigade", "Industrial Fire Brigade", "Industrial Response", "A plant/industrial fire brigade crew for on-site emergency response.", new[] { "industrial", "brigade", "plant", "refinery", "on-site" }, R("Brigade Leader", "Fire Brigade Leader"), R("Nozzle", "Fire Brigade Member"), R("Backup", "Fire Brigade Member"), R("Pump Operator", "Driver/Operator")), - T("ind-hazmat-team", "Hazmat Team", "Industrial Management", + T("ind-hazmat-team", "Hazmat Team", "Industrial Response", "A hazardous materials response team with certified technicians.", new[] { "hazmat", "hazardous materials", "decon", "spill", "cbrne" }, R("Team Leader", "Hazmat Technician"), R("Entry Team", "Hazmat Technician", required: true), R("Entry Team", "Hazmat Technician", required: true), R("Decon"), R("Safety Officer", "Safety Officer")), - T("ind-work-crew", "Utility / Work Crew", "Industrial Management", + T("ind-work-crew", "Utility / Work Crew", "Industrial Response", "A general industrial or utility work crew.", new[] { "utility", "work crew", "maintenance", "operator", "labor" }, R("Crew Lead", "Supervisor"), R("Operator", "Equipment Operator"), R("Laborer"), R("Laborer")), - // ---- Commodity Delivery / Logistics ---- - T("log-delivery-vehicle", "Delivery Vehicle", "Commodity Delivery", + T("ind-confined-space", "Confined Space Rescue Team", "Industrial Response", + "An industrial confined-space team with entry, backup, rigging, monitoring and medical seats.", + new[] { "confined space", "permit space", "entry", "rescue", "monitoring", "rigging" }, + R("Rescue Team Leader"), R("Entry Rescuer", "Confined Space Technician", required: true), R("Backup Rescuer", "Confined Space Technician", required: true), R("Rigging"), R("Atmospheric Monitor"), R("Medic", "Paramedic")), + + // ---- Delivery Companies ---- + T("log-delivery-vehicle", "Delivery Vehicle", "Delivery Companies", "A delivery vehicle crew with a driver and helper.", new[] { "delivery", "courier", "driver", "logistics", "route" }, R("Driver", "CDL", required: true), R("Helper")), - T("log-logistics-truck", "Logistics Truck Crew", "Commodity Delivery", + T("log-logistics-truck", "Logistics Truck Crew", "Delivery Companies", "A freight/logistics truck crew for loading and hauling.", new[] { "logistics", "freight", "cargo", "haul", "supply" }, R("Driver", "CDL", required: true), R("Loader"), R("Loader")), + T("log-last-mile", "Last-Mile Delivery Van", "Delivery Companies", + "A parcel or courier vehicle with route, delivery and loading responsibilities.", + new[] { "last mile", "parcel", "courier", "van", "route", "package" }, + R("Route Driver"), R("Delivery Associate"), R("Loader")), + + T("log-route-support", "Route Recovery / Support Team", "Delivery Companies", + "A support unit for disabled vehicles, overflow routes and cargo transfers.", + new[] { "route recovery", "fleet support", "overflow", "cargo transfer", "breakdown" }, + R("Support Lead"), R("Relief Driver"), R("Cargo Transfer"), R("Fleet Technician")), + // ---- General ---- T("gen-two-person", "Two-Person Crew", "General", "A simple two-person crew for any small unit.", diff --git a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs index 8e5ec3d96..8a3c843c4 100644 --- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs +++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs @@ -102,6 +102,16 @@ public static IReadOnlyList GetVariableCatalog(Workf case WorkflowTriggerEventType.IncidentRoleAssigned: case WorkflowTriggerEventType.AdHocResourceCreated: case WorkflowTriggerEventType.IncidentChannelOpened: + case WorkflowTriggerEventType.PublicIncidentNoteAdded: + case WorkflowTriggerEventType.InternalIncidentNoteAdded: + case WorkflowTriggerEventType.PublicIncidentDocumentAdded: + case WorkflowTriggerEventType.InternalIncidentDocumentAdded: + case WorkflowTriggerEventType.IncidentNoteRemoved: + case WorkflowTriggerEventType.IncidentDocumentRemoved: + case WorkflowTriggerEventType.IncidentActionPlanUpdated: + case WorkflowTriggerEventType.IncidentCommandPostUpdated: + case WorkflowTriggerEventType.IncidentPublicSharingEnabled: + case WorkflowTriggerEventType.IncidentPublicSharingDisabled: list.AddRange(new[] { new TemplateVariableDescriptor("incident.command_id", "Incident command identifier", "string", false), @@ -109,6 +119,22 @@ public static IReadOnlyList GetVariableCatalog(Workf new TemplateVariableDescriptor("incident.department_id", "Department identifier", "int", false), new TemplateVariableDescriptor("incident.user_id", "User associated with the event (when applicable)", "string", false), new TemplateVariableDescriptor("incident.name", "Name associated with the event (objective/resource/channel)", "string", false), + new TemplateVariableDescriptor("incident.visibility", "Content visibility (0=Internal, 1=Public)", "int", false), + new TemplateVariableDescriptor("incident.note_id", "Incident note identifier", "string", false), + new TemplateVariableDescriptor("incident.note_type", "Operational note type", "int", false), + new TemplateVariableDescriptor("incident.title", "Status-note title", "string", false), + new TemplateVariableDescriptor("incident.body", "Status-note body", "string", false), + new TemplateVariableDescriptor("incident.containment_percent", "Optional containment percentage", "decimal", false), + new TemplateVariableDescriptor("incident.attachment_id", "Incident attachment identifier", "string", false), + new TemplateVariableDescriptor("incident.file_name", "Attachment file name", "string", false), + new TemplateVariableDescriptor("incident.content_type", "Attachment MIME type", "string", false), + new TemplateVariableDescriptor("incident.content_length", "Attachment size in bytes", "long", false), + new TemplateVariableDescriptor("incident.sha256_hash", "Attachment SHA-256 integrity hash", "string", false), + new TemplateVariableDescriptor("incident.description", "Attachment/event description", "string", false), + new TemplateVariableDescriptor("incident.action_plan", "Current incident action plan", "string", false), + new TemplateVariableDescriptor("incident.latitude", "Command-post latitude", "string", false), + new TemplateVariableDescriptor("incident.longitude", "Command-post longitude", "string", false), + new TemplateVariableDescriptor("incident.enabled", "Whether public sharing is enabled", "bool", false), }); break; diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs index 666abf3fb..386adde49 100644 --- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs +++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs @@ -41,7 +41,17 @@ public enum WorkflowTriggerEventType IncidentRoleAssigned = 34, AdHocResourceCreated = 35, IncidentChannelOpened = 36, - IncidentClosed = 37 + IncidentClosed = 37, + PublicIncidentNoteAdded = 38, + InternalIncidentNoteAdded = 39, + PublicIncidentDocumentAdded = 40, + InternalIncidentDocumentAdded = 41, + IncidentNoteRemoved = 42, + IncidentDocumentRemoved = 43, + IncidentActionPlanUpdated = 44, + IncidentCommandPostUpdated = 45, + IncidentPublicSharingEnabled = 46, + IncidentPublicSharingDisabled = 47 } } diff --git a/Core/Resgrid.Services/DepartmentSsoService.cs b/Core/Resgrid.Services/DepartmentSsoService.cs index 80b890e05..e0af2af7c 100644 --- a/Core/Resgrid.Services/DepartmentSsoService.cs +++ b/Core/Resgrid.Services/DepartmentSsoService.cs @@ -1,17 +1,26 @@ ๏ปฟusing System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; +using System.IO; using System.Linq; using System.Net; using System.Security.Claims; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Security.Cryptography.Xml; using System.Text; using System.Threading; using System.Threading.Tasks; +using System.Xml; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; using Newtonsoft.Json; using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Identity; +using Resgrid.Model.Providers; using Resgrid.Model.Repositories; using Resgrid.Model.Services; @@ -23,12 +32,15 @@ namespace Resgrid.Services /// public class DepartmentSsoService : IDepartmentSsoService { + private static readonly TimeSpan TokenClockSkew = TimeSpan.FromMinutes(2); + private static readonly ConcurrentDictionary> OidcConfigurationManagers = new(StringComparer.OrdinalIgnoreCase); private readonly IDepartmentSsoConfigRepository _ssoConfigRepository; private readonly IDepartmentSecurityPolicyRepository _securityPolicyRepository; private readonly IDepartmentMembersRepository _departmentMembersRepository; private readonly IDepartmentsService _departmentsService; private readonly IUserProfileService _userProfileService; private readonly IEncryptionService _encryptionService; + private readonly ICacheProvider _cacheProvider; public DepartmentSsoService( IDepartmentSsoConfigRepository ssoConfigRepository, @@ -36,7 +48,8 @@ public DepartmentSsoService( IDepartmentMembersRepository departmentMembersRepository, IDepartmentsService departmentsService, IUserProfileService userProfileService, - IEncryptionService encryptionService) + IEncryptionService encryptionService, + ICacheProvider cacheProvider) { _ssoConfigRepository = ssoConfigRepository; _securityPolicyRepository = securityPolicyRepository; @@ -44,6 +57,7 @@ public DepartmentSsoService( _departmentsService = departmentsService; _userProfileService = userProfileService; _encryptionService = encryptionService; + _cacheProvider = cacheProvider; } // โ”€โ”€ SSO Config CRUD โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -65,22 +79,40 @@ public async Task GetSsoConfigByEntityIdAsync(string entity public async Task SaveSsoConfigAsync(DepartmentSsoConfig config, string departmentCode, CancellationToken cancellationToken = default) { - // Encrypt sensitive fields before persisting - if (!string.IsNullOrWhiteSpace(config.EncryptedClientSecret)) - config.EncryptedClientSecret = _encryptionService.EncryptForDepartment(config.EncryptedClientSecret, config.DepartmentId, departmentCode); + if (config == null) + throw new ArgumentNullException(nameof(config)); + + var providerType = (SsoProviderType)config.SsoProviderType; + var existing = await _ssoConfigRepository.GetByDepartmentIdAndTypeAsync(config.DepartmentId, providerType); + + if (existing == null) + { + if (string.IsNullOrWhiteSpace(config.DepartmentSsoConfigId)) + config.DepartmentSsoConfigId = Guid.NewGuid().ToString(); - if (!string.IsNullOrWhiteSpace(config.EncryptedIdpCertificate)) - config.EncryptedIdpCertificate = _encryptionService.EncryptForDepartment(config.EncryptedIdpCertificate, config.DepartmentId, departmentCode); + if (config.CreatedOn == default) + config.CreatedOn = DateTime.UtcNow; - if (!string.IsNullOrWhiteSpace(config.EncryptedSigningCertificate)) - config.EncryptedSigningCertificate = _encryptionService.EncryptForDepartment(config.EncryptedSigningCertificate, config.DepartmentId, departmentCode); + config.EncryptedClientSecret = EncryptNewSecret(config.EncryptedClientSecret, config.DepartmentId, departmentCode); + config.EncryptedIdpCertificate = EncryptNewSecret(config.EncryptedIdpCertificate, config.DepartmentId, departmentCode); + config.EncryptedSigningCertificate = EncryptNewSecret(config.EncryptedSigningCertificate, config.DepartmentId, departmentCode); + config.EncryptedScimBearerToken = EncryptNewSecret(config.EncryptedScimBearerToken, config.DepartmentId, departmentCode); - if (!string.IsNullOrWhiteSpace(config.EncryptedScimBearerToken)) - config.EncryptedScimBearerToken = _encryptionService.EncryptForDepartment(config.EncryptedScimBearerToken, config.DepartmentId, departmentCode); + return await _ssoConfigRepository.InsertAsync(config, cancellationToken); + } + // Blank secret fields mean "keep the stored value". The generic repository updates + // every column, so this preservation must happen before issuing the UPDATE. + config.DepartmentSsoConfigId = existing.DepartmentSsoConfigId; + config.CreatedByUserId = existing.CreatedByUserId; + config.CreatedOn = existing.CreatedOn; + config.EncryptedClientSecret = EncryptUpdatedSecret(config.EncryptedClientSecret, existing.EncryptedClientSecret, config.DepartmentId, departmentCode); + config.EncryptedIdpCertificate = EncryptUpdatedSecret(config.EncryptedIdpCertificate, existing.EncryptedIdpCertificate, config.DepartmentId, departmentCode); + config.EncryptedSigningCertificate = EncryptUpdatedSecret(config.EncryptedSigningCertificate, existing.EncryptedSigningCertificate, config.DepartmentId, departmentCode); + config.EncryptedScimBearerToken = EncryptUpdatedSecret(config.EncryptedScimBearerToken, existing.EncryptedScimBearerToken, config.DepartmentId, departmentCode); config.UpdatedOn = DateTime.UtcNow; - return await _ssoConfigRepository.SaveOrUpdateAsync(config, cancellationToken); + return await _ssoConfigRepository.UpdateAsync(config, cancellationToken); } public async Task DeleteSsoConfigAsync(int departmentId, SsoProviderType providerType, CancellationToken cancellationToken = default) @@ -117,10 +149,10 @@ public async Task ValidateExternalTokenAsync(int departmentId, return null; if (providerType == SsoProviderType.Oidc) - return ValidateOidcToken(externalToken, config, departmentCode); + return await ValidateOidcTokenAsync(externalToken, config, cancellationToken); if (providerType == SsoProviderType.Saml2) - return ValidateSamlResponse(externalToken, config, departmentCode); + return await ValidateSamlResponseAsync(externalToken, config, departmentCode); return null; } @@ -414,26 +446,50 @@ public async Task RecordPasswordChangedAsync(int departmentId, string userId, Ca // โ”€โ”€ Private helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - private ClaimsPrincipal ValidateOidcToken(string idToken, DepartmentSsoConfig config, string departmentCode) + private string EncryptNewSecret(string plaintext, int departmentId, string departmentCode) + { + return string.IsNullOrWhiteSpace(plaintext) + ? null + : _encryptionService.EncryptForDepartment(plaintext, departmentId, departmentCode); + } + + private string EncryptUpdatedSecret(string submittedValue, string storedCiphertext, int departmentId, string departmentCode) + { + if (string.IsNullOrWhiteSpace(submittedValue) || string.Equals(submittedValue, storedCiphertext, StringComparison.Ordinal)) + return storedCiphertext; + + return _encryptionService.EncryptForDepartment(submittedValue, departmentId, departmentCode); + } + + private async Task ValidateOidcTokenAsync(string idToken, DepartmentSsoConfig config, CancellationToken cancellationToken) { try { + if (string.IsNullOrWhiteSpace(idToken) || string.IsNullOrWhiteSpace(config.Authority) || string.IsNullOrWhiteSpace(config.ClientId)) + return null; + + if (!Uri.TryCreate(config.Authority, UriKind.Absolute, out var authorityUri) || authorityUri.Scheme != Uri.UriSchemeHttps) + return null; + + var authority = config.Authority.TrimEnd('/'); + var manager = OidcConfigurationManagers.GetOrAdd(authority, static value => + new ConfigurationManager( + $"{value}/.well-known/openid-configuration", + new OpenIdConnectConfigurationRetriever(), + new HttpDocumentRetriever { RequireHttps = true })); + + var oidcConfiguration = await manager.GetConfigurationAsync(cancellationToken); var handler = new JwtSecurityTokenHandler(); - var validationParameters = new TokenValidationParameters + try + { + return handler.ValidateToken(idToken, BuildOidcValidationParameters(config, oidcConfiguration), out _); + } + catch (SecurityTokenSignatureKeyNotFoundException) { - ValidateIssuer = !string.IsNullOrWhiteSpace(config.Authority), - ValidIssuer = config.Authority, - ValidateAudience = !string.IsNullOrWhiteSpace(config.ClientId), - ValidAudience = config.ClientId, - ValidateLifetime = true, - ValidateIssuerSigningKey = false, - // Signature validation is intentionally skipped here โ€” the token was - // already validated by the OIDC provider's userinfo endpoint in production. - // For strict validation, configure a JWKS endpoint via IConfigurationManager. - SignatureValidator = (token, _) => handler.ReadJwtToken(token) - }; - - return handler.ValidateToken(idToken, validationParameters, out _); + manager.RequestRefresh(); + oidcConfiguration = await manager.GetConfigurationAsync(cancellationToken); + return handler.ValidateToken(idToken, BuildOidcValidationParameters(config, oidcConfiguration), out _); + } } catch (Exception ex) { @@ -442,78 +498,292 @@ private ClaimsPrincipal ValidateOidcToken(string idToken, DepartmentSsoConfig co } } - private ClaimsPrincipal ValidateSamlResponse(string base64SamlResponse, DepartmentSsoConfig config, string departmentCode) + private static TokenValidationParameters BuildOidcValidationParameters(DepartmentSsoConfig config, OpenIdConnectConfiguration oidcConfiguration) + { + return new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = oidcConfiguration.Issuer, + ValidateAudience = true, + ValidAudience = config.ClientId, + ValidateLifetime = true, + RequireExpirationTime = true, + ValidateIssuerSigningKey = true, + RequireSignedTokens = true, + IssuerSigningKeys = oidcConfiguration.SigningKeys, + ClockSkew = TokenClockSkew + }; + } + + private async Task ValidateSamlResponseAsync(string base64SamlResponse, DepartmentSsoConfig config, string departmentCode) { - // Decode the base64 SAMLResponse XML - string samlXml; try { - samlXml = Encoding.UTF8.GetString(Convert.FromBase64String(base64SamlResponse)); + if (string.IsNullOrWhiteSpace(base64SamlResponse) || base64SamlResponse.Length > 2_800_000 || + string.IsNullOrWhiteSpace(config.EncryptedIdpCertificate) || string.IsNullOrWhiteSpace(config.EntityId) || + string.IsNullOrWhiteSpace(config.AssertionConsumerServiceUrl)) + return null; + + var samlBytes = Convert.FromBase64String(base64SamlResponse); + if (samlBytes.Length > 2_000_000) + return null; + + var document = LoadSamlDocument(samlBytes); + var response = document.DocumentElement; + if (response == null || response.LocalName != "Response" || response.NamespaceURI != "urn:oasis:names:tc:SAML:2.0:protocol") + return null; + + var namespaces = new XmlNamespaceManager(document.NameTable); + namespaces.AddNamespace("samlp", "urn:oasis:names:tc:SAML:2.0:protocol"); + namespaces.AddNamespace("saml", "urn:oasis:names:tc:SAML:2.0:assertion"); + namespaces.AddNamespace("ds", SignedXml.XmlDsigNamespaceUrl); + + var statusCode = response.SelectSingleNode("./samlp:Status/samlp:StatusCode", namespaces) as XmlElement; + if (statusCode?.GetAttribute("Value") != "urn:oasis:names:tc:SAML:2.0:status:Success") + return null; + + var assertionNodes = response.SelectNodes("./saml:Assertion", namespaces); + if (assertionNodes?.Count != 1 || assertionNodes[0] is not XmlElement assertion || !HasUniqueSamlIds(document)) + return null; + + var certificatePem = _encryptionService.DecryptForDepartment( + config.EncryptedIdpCertificate, config.DepartmentId, departmentCode); + using var certificate = X509Certificate2.CreateFromPem(certificatePem); + + var now = DateTime.UtcNow; + if (now + TokenClockSkew < certificate.NotBefore.ToUniversalTime() || now - TokenClockSkew >= certificate.NotAfter.ToUniversalTime()) + return null; + + if (!ValidateSamlSignature(document, response, assertion, namespaces, certificate) || + !ValidateSamlDestinationAndConditions(response, assertion, namespaces, config, now, out var assertionExpiresOn)) + return null; + + var assertionId = assertion.GetAttribute("ID"); + if (string.IsNullOrWhiteSpace(assertionId) || + !await MarkSamlAssertionConsumedAsync(config.DepartmentSsoConfigId, assertionId, assertionExpiresOn, now)) + return null; + + var claims = ExtractSamlClaims(assertion, namespaces); + return claims.Count == 0 ? null : new ClaimsPrincipal(new ClaimsIdentity(claims, "SAML2")); } - catch + catch (Exception ex) { + Logging.LogException(ex); return null; } + } - // Parse the NameID (subject) and attributes from the SAML assertion XML. - // A full implementation would use Sustainsys.Saml2 to validate the XML signature - // against the stored IdP certificate. This parser extracts core claims for - // the provisioning pipeline without requiring a full SP registration at startup. - var claims = new List(); + private static XmlDocument LoadSamlDocument(byte[] samlBytes) + { + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + MaxCharactersInDocument = 2_000_000 + }; + + var document = new XmlDocument { PreserveWhitespace = true, XmlResolver = null }; + using var stream = new MemoryStream(samlBytes, writable: false); + using var reader = XmlReader.Create(stream, settings); + document.Load(reader); + return document; + } - var nameIdStart = samlXml.IndexOf("= 0) + private static bool HasUniqueSamlIds(XmlDocument document) + { + var ids = new HashSet(StringComparer.Ordinal); + var nodes = document.SelectNodes("//*[@ID]"); + if (nodes == null) + return false; + + foreach (XmlElement node in nodes) { - var nameIdEnd = samlXml.IndexOf("", nameIdStart, StringComparison.OrdinalIgnoreCase); - if (nameIdEnd > nameIdStart) + var id = node.GetAttribute("ID"); + if (string.IsNullOrWhiteSpace(id) || !ids.Add(id)) + return false; + } + + return ids.Count > 0; + } + + private static bool ValidateSamlSignature(XmlDocument document, XmlElement response, XmlElement assertion, + XmlNamespaceManager namespaces, X509Certificate2 certificate) + { + var signature = assertion.SelectSingleNode("./ds:Signature", namespaces) as XmlElement; + var signedElement = assertion; + if (signature == null) + { + signature = response.SelectSingleNode("./ds:Signature", namespaces) as XmlElement; + signedElement = response; + } + + if (signature == null) + return false; + + var signedXml = new SignedXml(document); + signedXml.LoadXml(signature); + if (signedXml.SignedInfo.CanonicalizationMethod != SignedXml.XmlDsigExcC14NTransformUrl || + !IsAllowedSamlSignatureAlgorithm(signedXml.SignedInfo.SignatureMethod) || signedXml.SignedInfo.References.Count != 1) + return false; + + if (signedXml.SignedInfo.References[0] is not Reference reference || + !IsAllowedSamlDigestAlgorithm(reference.DigestMethod) || !HasOnlyAllowedSamlTransforms(reference)) + return false; + + var id = signedElement.GetAttribute("ID"); + if (string.IsNullOrWhiteSpace(id) || reference.Uri != $"#{id}" || !ReferenceEquals(signedXml.GetIdElement(document, id), signedElement)) + return false; + + return signedXml.CheckSignature(certificate, verifySignatureOnly: true); + } + + private static bool HasOnlyAllowedSamlTransforms(Reference reference) + { + if (reference.TransformChain.Count is < 1 or > 2) + return false; + + var hasEnvelopedSignatureTransform = false; + var hasExclusiveCanonicalizationTransform = false; + foreach (Transform transform in reference.TransformChain) + { + if (transform.Algorithm == SignedXml.XmlDsigEnvelopedSignatureTransformUrl && !hasEnvelopedSignatureTransform) + { + hasEnvelopedSignatureTransform = true; + continue; + } + + if (transform.Algorithm == SignedXml.XmlDsigExcC14NTransformUrl && !hasExclusiveCanonicalizationTransform) { - var tagEnd = samlXml.IndexOf('>', nameIdStart); - if (tagEnd >= 0 && tagEnd < nameIdEnd) - { - var nameId = samlXml.Substring(tagEnd + 1, nameIdEnd - tagEnd - 1).Trim(); - claims.Add(new Claim(ClaimTypes.NameIdentifier, nameId)); - } + hasExclusiveCanonicalizationTransform = true; + continue; } + + return false; } - // Extract common SAML attribute values - ExtractSamlAttribute(samlXml, "email", ClaimTypes.Email, claims); - ExtractSamlAttribute(samlXml, "EmailAddress", ClaimTypes.Email, claims); - ExtractSamlAttribute(samlXml, "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", ClaimTypes.Email, claims); - ExtractSamlAttribute(samlXml, "givenname", ClaimTypes.GivenName, claims); - ExtractSamlAttribute(samlXml, "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", ClaimTypes.GivenName, claims); - ExtractSamlAttribute(samlXml, "surname", ClaimTypes.Surname, claims); - ExtractSamlAttribute(samlXml, "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname", ClaimTypes.Surname, claims); + return hasEnvelopedSignatureTransform; + } - if (!claims.Any()) - return null; + private static bool IsAllowedSamlSignatureAlgorithm(string algorithm) + { + return algorithm == SignedXml.XmlDsigRSASHA256Url || + algorithm == "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384" || + algorithm == "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512"; + } + + private static bool IsAllowedSamlDigestAlgorithm(string algorithm) + { + return algorithm == SignedXml.XmlDsigSHA256Url || + algorithm == "http://www.w3.org/2001/04/xmldsig-more#sha384" || + algorithm == "http://www.w3.org/2001/04/xmlenc#sha512"; + } + + private static bool ValidateSamlDestinationAndConditions(XmlElement response, XmlElement assertion, + XmlNamespaceManager namespaces, DepartmentSsoConfig config, DateTime now, out DateTime assertionExpiresOn) + { + assertionExpiresOn = default; + var destination = response.GetAttribute("Destination"); + if (!string.IsNullOrWhiteSpace(destination) && !string.Equals(destination, config.AssertionConsumerServiceUrl, StringComparison.Ordinal)) + return false; + + if (assertion.SelectSingleNode("./saml:Conditions", namespaces) is not XmlElement conditions || + !TryReadSamlInstant(conditions.GetAttribute("NotOnOrAfter"), out assertionExpiresOn) || + now - TokenClockSkew >= assertionExpiresOn) + return false; + + if (TryReadSamlInstant(conditions.GetAttribute("NotBefore"), out var notBefore) && now + TokenClockSkew < notBefore) + return false; + + var audienceNodes = conditions.SelectNodes("./saml:AudienceRestriction/saml:Audience", namespaces); + if (audienceNodes == null || !audienceNodes.Cast().Any(node => + string.Equals(node.InnerText.Trim(), config.EntityId, StringComparison.Ordinal))) + return false; - var identity = new ClaimsIdentity(claims, "SAML2"); - return new ClaimsPrincipal(identity); + var confirmationNodes = assertion.SelectNodes("./saml:Subject/saml:SubjectConfirmation[@Method='urn:oasis:names:tc:SAML:2.0:cm:bearer']/saml:SubjectConfirmationData", namespaces); + return confirmationNodes != null && confirmationNodes.Cast().Any(data => + string.Equals(data.GetAttribute("Recipient"), config.AssertionConsumerServiceUrl, StringComparison.Ordinal) && + TryReadSamlInstant(data.GetAttribute("NotOnOrAfter"), out var subjectExpiresOn) && + now - TokenClockSkew < subjectExpiresOn); } - private static void ExtractSamlAttribute(string samlXml, string attributeName, string claimType, List claims) + private static bool TryReadSamlInstant(string value, out DateTime instant) { - var marker = $"Name=\"{attributeName}\""; - var pos = samlXml.IndexOf(marker, StringComparison.OrdinalIgnoreCase); - if (pos < 0) - return; + instant = default; + if (string.IsNullOrWhiteSpace(value)) + return false; + + try + { + instant = XmlConvert.ToDateTime(value, XmlDateTimeSerializationMode.Utc); + return true; + } + catch (FormatException) + { + return false; + } + } - var valueStart = samlXml.IndexOf(" MarkSamlAssertionConsumedAsync(string configId, string assertionId, DateTime expiresOn, DateTime now) + { + var remainingLifetime = expiresOn - now; + if (remainingLifetime <= TimeSpan.Zero) + return false; - var tagEnd = samlXml.IndexOf('>', valueStart); - if (tagEnd < 0) - return; + var replayIdentifier = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes($"{configId}:{assertionId}"))); + return await _cacheProvider.IncrementAsync( + $"Sso:SamlAssertion:{replayIdentifier}", remainingLifetime) == 1; + } - var valueEnd = samlXml.IndexOf("", tagEnd, StringComparison.OrdinalIgnoreCase); - if (valueEnd <= tagEnd) - return; + private static List ExtractSamlClaims(XmlElement assertion, XmlNamespaceManager namespaces) + { + var claims = new List(); + var nameId = assertion.SelectSingleNode("./saml:Subject/saml:NameID", namespaces)?.InnerText?.Trim(); + if (!string.IsNullOrWhiteSpace(nameId)) + claims.Add(new Claim(ClaimTypes.NameIdentifier, nameId)); - var value = samlXml.Substring(tagEnd + 1, valueEnd - tagEnd - 1).Trim(); - if (!string.IsNullOrWhiteSpace(value)) - claims.Add(new Claim(claimType, value)); + var attributes = assertion.SelectNodes("./saml:AttributeStatement/saml:Attribute", namespaces); + if (attributes == null) + return claims; + + foreach (XmlElement attribute in attributes) + { + var name = attribute.GetAttribute("Name"); + if (string.IsNullOrWhiteSpace(name)) + continue; + + var values = attribute.SelectNodes("./saml:AttributeValue", namespaces); + if (values == null) + continue; + + foreach (XmlNode valueNode in values) + { + var value = valueNode.InnerText?.Trim(); + if (string.IsNullOrWhiteSpace(value)) + continue; + + claims.Add(new Claim(name, value)); + var standardClaimType = GetStandardSamlClaimType(name); + if (standardClaimType != null && !string.Equals(standardClaimType, name, StringComparison.Ordinal)) + claims.Add(new Claim(standardClaimType, value)); + } + } + + return claims; + } + + private static string GetStandardSamlClaimType(string attributeName) + { + if (attributeName.Equals("email", StringComparison.OrdinalIgnoreCase) || attributeName.Equals("EmailAddress", StringComparison.OrdinalIgnoreCase) || attributeName.Equals(ClaimTypes.Email, StringComparison.OrdinalIgnoreCase)) + return ClaimTypes.Email; + + if (attributeName.Equals("givenname", StringComparison.OrdinalIgnoreCase) || attributeName.Equals("given_name", StringComparison.OrdinalIgnoreCase) || attributeName.Equals(ClaimTypes.GivenName, StringComparison.OrdinalIgnoreCase)) + return ClaimTypes.GivenName; + + if (attributeName.Equals("surname", StringComparison.OrdinalIgnoreCase) || attributeName.Equals("family_name", StringComparison.OrdinalIgnoreCase) || attributeName.Equals(ClaimTypes.Surname, StringComparison.OrdinalIgnoreCase)) + return ClaimTypes.Surname; + + return null; } private static Dictionary ResolveAttributeMapping(string attributeMappingJson) diff --git a/Core/Resgrid.Services/IncidentCommandService.cs b/Core/Resgrid.Services/IncidentCommandService.cs index 665242fd4..e6db89cb5 100644 --- a/Core/Resgrid.Services/IncidentCommandService.cs +++ b/Core/Resgrid.Services/IncidentCommandService.cs @@ -1,6 +1,9 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.IO; using System.Linq; +using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using Resgrid.Model; @@ -36,6 +39,9 @@ public class IncidentCommandService : IIncidentCommandService private readonly ICoreEventService _coreEventService; private readonly IUnitsService _unitsService; private readonly IPersonnelRolesService _personnelRolesService; + private readonly IIncidentNoteRepository _incidentNoteRepository; + private readonly IIncidentAttachmentRepository _incidentAttachmentRepository; + private readonly IIncidentWeatherProvider _incidentWeatherProvider; public IncidentCommandService( IIncidentCommandRepository incidentCommandRepository, @@ -54,7 +60,10 @@ public IncidentCommandService( IEventAggregator eventAggregator, ICoreEventService coreEventService, IUnitsService unitsService, - IPersonnelRolesService personnelRolesService) + IPersonnelRolesService personnelRolesService, + IIncidentNoteRepository incidentNoteRepository, + IIncidentAttachmentRepository incidentAttachmentRepository, + IIncidentWeatherProvider incidentWeatherProvider) { _incidentCommandRepository = incidentCommandRepository; _commandStructureNodeRepository = commandStructureNodeRepository; @@ -73,6 +82,9 @@ public IncidentCommandService( _coreEventService = coreEventService; _unitsService = unitsService; _personnelRolesService = personnelRolesService; + _incidentNoteRepository = incidentNoteRepository; + _incidentAttachmentRepository = incidentAttachmentRepository; + _incidentWeatherProvider = incidentWeatherProvider; } #region Command lifecycle @@ -115,6 +127,7 @@ public IncidentCommandService( EstablishedByUserId = userId, EstablishedOn = DateTime.UtcNow, CurrentCommanderUserId = userId, + PublicShareEnabled = false, Status = (int)IncidentCommandStatus.Active }; @@ -401,7 +414,9 @@ public async Task GetCommandBoardAsync(int departmentId, i Timers = await GetActiveTimersForCallAsync(departmentId, callId), Annotations = await GetAnnotationsForCallAsync(departmentId, callId), Accountability = await GetAccountabilityForCallAsync(departmentId, callId), - Roles = await GetIncidentRolesAsync(departmentId, callId) + Roles = await GetIncidentRolesAsync(departmentId, callId), + Notes = await GetNotesForCallAsync(departmentId, callId), + Attachments = await GetAttachmentsForCallAsync(departmentId, callId) }; return board; @@ -428,6 +443,8 @@ public async Task GetBundleForDepartmentAsync(int departm var timers = ToCallLookup(await _incidentTimerRepository.GetAllByDepartmentIdAsync(departmentId), x => x.CallId); var annotations = ToCallLookup(await _incidentMapAnnotationRepository.GetAllByDepartmentIdAsync(departmentId), x => x.CallId); var roles = ToCallLookup(await _incidentRoleAssignmentRepository.GetAllByDepartmentIdAsync(departmentId), x => x.CallId); + var notes = ToCallLookup(await _incidentNoteRepository.GetAllByDepartmentIdAsync(departmentId), x => x.CallId); + var attachments = ToCallLookup(await _incidentAttachmentRepository.GetAllMetadataByDepartmentIdAsync(departmentId), x => x.CallId); foreach (var command in active) { @@ -443,7 +460,9 @@ public async Task GetBundleForDepartmentAsync(int departm Objectives = objectives[callId].OrderBy(x => x.SortOrder).ToList(), Timers = timers[callId].Where(x => x.Status != (int)IncidentTimerStatus.Stopped).ToList(), Annotations = annotations[callId].Where(x => x.DeletedOn == null).ToList(), - Roles = roles[callId].Where(x => x.RemovedOn == null).ToList() + Roles = roles[callId].Where(x => x.RemovedOn == null).ToList(), + Notes = notes[callId].Where(x => x.DeletedOn == null).OrderBy(x => x.CreatedOn).ToList(), + Attachments = attachments[callId].Where(x => x.DeletedOn == null).OrderBy(x => x.UploadedOn).ToList() }; // Accountability/PAR is the one per-incident read here, and it is READ-ONLY (no marker writes / SignalR @@ -504,6 +523,14 @@ public async Task GetChangesSinceAsync(int departmentId, if (roles != null) changes.Roles = roles.Where(Changed).ToList(); + var notes = await _incidentNoteRepository.GetAllByDepartmentIdAsync(departmentId); + if (notes != null) + changes.Notes = notes.Where(Changed).ToList(); + + var attachments = await _incidentAttachmentRepository.GetAllMetadataByDepartmentIdAsync(departmentId); + if (attachments != null) + changes.Attachments = attachments.Where(Changed).ToList(); + // The timeline is append-only (no ModifiedOn); its natural cursor is OccurredOn. var timeline = await _commandLogEntryRepository.GetAllByDepartmentIdAsync(departmentId); if (timeline != null) @@ -568,10 +595,345 @@ public async Task GetChangesSinceAsync(int departmentId, command.IncidentActionPlan = actionPlan; command = await _incidentCommandRepository.SaveOrUpdateAsync(Touch(command), cancellationToken); - await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.Note, "Incident action plan updated", userId, cancellationToken); + await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.ActionPlanUpdated, "Incident action plan updated", userId, cancellationToken); + _eventAggregator.SendMessage(new IncidentActionPlanUpdatedEvent + { + DepartmentId = command.DepartmentId, + CallId = command.CallId, + IncidentCommandId = command.IncidentCommandId, + ActionPlan = actionPlan, + UpdatedByUserId = userId + }); + return command; + } + + public async Task UpdateCommandPostAsync(int departmentId, string incidentCommandId, string latitude, string longitude, string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + if (!TryParseCoordinates(latitude, longitude, out var latitudeValue, out var longitudeValue)) + throw new ArgumentException("A valid latitude and longitude are required."); + + var command = await GetOwnedCommandAsync(incidentCommandId, departmentId); + if (command == null) + return null; + + command.CommandPostLatitude = latitudeValue.ToString("0.######", CultureInfo.InvariantCulture); + command.CommandPostLongitude = longitudeValue.ToString("0.######", CultureInfo.InvariantCulture); + command = await _incidentCommandRepository.SaveOrUpdateAsync(Touch(command), cancellationToken); + + await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.CommandPostUpdated, "Command post location updated", userId, cancellationToken); + _eventAggregator.SendMessage(new IncidentCommandPostUpdatedEvent + { + DepartmentId = command.DepartmentId, + CallId = command.CallId, + IncidentCommandId = command.IncidentCommandId, + Latitude = command.CommandPostLatitude, + Longitude = command.CommandPostLongitude, + UpdatedByUserId = userId + }); + return command; + } + + public async Task AddNoteAsync(IncidentNote note, string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + if (note == null || string.IsNullOrWhiteSpace(note.IncidentCommandId) || string.IsNullOrWhiteSpace(note.Body)) + throw new ArgumentException("An incident command and note body are required."); + if (note.Body.Length > Resgrid.Config.IncidentCommandConfig.MaxNoteLength) + throw new ArgumentException($"Incident notes cannot exceed {Resgrid.Config.IncidentCommandConfig.MaxNoteLength} characters."); + if (!Enum.IsDefined(typeof(IncidentNoteType), note.NoteType) || !Enum.IsDefined(typeof(IncidentContentVisibility), note.Visibility)) + throw new ArgumentException("The incident note type or visibility is invalid."); + if (note.ContainmentPercent.HasValue && (note.ContainmentPercent.Value < 0 || note.ContainmentPercent.Value > 100)) + throw new ArgumentOutOfRangeException(nameof(note.ContainmentPercent), "Containment must be between 0 and 100 percent."); + + var command = await GetOwnedCommandAsync(note.IncidentCommandId, note.DepartmentId); + if (command == null) + return null; + + note.IncidentNoteId = Guid.NewGuid().ToString(); + note.CallId = command.CallId; + note.Title = TrimToLength(note.Title, 250); + note.Body = note.Body.Trim(); + note.CreatedByUserId = userId; + note.CreatedOn = DateTime.UtcNow; + note.DeletedOn = null; + note.DeletedByUserId = null; + + var saved = await _incidentNoteRepository.InsertAsync(Touch(note), cancellationToken); + await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.IncidentNoteAdded, $"Incident note added: {saved.Title ?? ((IncidentNoteType)saved.NoteType).ToString()}", userId, cancellationToken); + _eventAggregator.SendMessage(new IncidentNoteAddedEvent + { + DepartmentId = saved.DepartmentId, + CallId = saved.CallId, + IncidentCommandId = saved.IncidentCommandId, + IncidentNoteId = saved.IncidentNoteId, + Visibility = saved.Visibility, + NoteType = saved.NoteType, + Title = saved.Title, + Body = saved.Body, + ContainmentPercent = saved.ContainmentPercent, + CreatedByUserId = userId + }); + return saved; + } + + public async Task> GetNotesForCallAsync(int departmentId, int callId, bool publicOnly = false) + { + var notes = await _incidentNoteRepository.GetAllByDepartmentIdAsync(departmentId); + if (notes == null) + return new List(); + + return notes.Where(x => x.CallId == callId && x.DeletedOn == null && + (!publicOnly || x.Visibility == (int)IncidentContentVisibility.Public)) + .OrderBy(x => x.CreatedOn) + .ToList(); + } + + public async Task RemoveNoteAsync(int departmentId, string incidentNoteId, string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + var note = await _incidentNoteRepository.GetByIdAsync(incidentNoteId); + if (note == null || note.DepartmentId != departmentId || note.DeletedOn.HasValue) + return false; + var capabilities = await GetCapabilitiesForUserAsync(departmentId, note.CallId, userId); + var required = IncidentCapabilities.ManageNotes; + if (note.Visibility == (int)IncidentContentVisibility.Public) + required |= IncidentCapabilities.ManagePublicInformation; + if ((capabilities & required) != required) + return false; + + note.DeletedOn = DateTime.UtcNow; + note.DeletedByUserId = userId; + await _incidentNoteRepository.SaveOrUpdateAsync(Touch(note), cancellationToken); + await WriteLogAsync(note.IncidentCommandId, note.DepartmentId, note.CallId, CommandLogEntryType.IncidentNoteRemoved, "Incident note removed", userId, cancellationToken); + _eventAggregator.SendMessage(new IncidentNoteRemovedEvent + { + DepartmentId = note.DepartmentId, + CallId = note.CallId, + IncidentCommandId = note.IncidentCommandId, + IncidentNoteId = note.IncidentNoteId, + Visibility = note.Visibility, + RemovedByUserId = userId + }); + return true; + } + + public async Task AddAttachmentAsync(IncidentAttachment attachment, string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + if (attachment == null || string.IsNullOrWhiteSpace(attachment.IncidentCommandId) || attachment.Data == null || attachment.Data.Length == 0) + throw new ArgumentException("An incident command and file data are required."); + if (!Enum.IsDefined(typeof(IncidentContentVisibility), attachment.Visibility)) + throw new ArgumentException("The incident attachment visibility is invalid."); + if (attachment.Data.Length > Resgrid.Config.IncidentCommandConfig.MaxAttachmentBytes) + throw new ArgumentException($"Incident files cannot exceed {Resgrid.Config.IncidentCommandConfig.MaxAttachmentBytes} bytes."); + + var safeFileName = Path.GetFileName(attachment.FileName ?? string.Empty); + if (string.IsNullOrWhiteSpace(safeFileName) || IsBlockedAttachment(safeFileName, attachment.ContentType)) + throw new ArgumentException("The incident file name or type is not allowed."); + + var command = await GetOwnedCommandAsync(attachment.IncidentCommandId, attachment.DepartmentId); + if (command == null) + return null; + + attachment.IncidentAttachmentId = Guid.NewGuid().ToString(); + attachment.CallId = command.CallId; + attachment.FileName = TrimToLength(safeFileName, 512); + attachment.ContentType = TrimToLength(string.IsNullOrWhiteSpace(attachment.ContentType) ? "application/octet-stream" : attachment.ContentType, 200); + attachment.ContentLength = attachment.Data.LongLength; + attachment.Sha256Hash = Convert.ToHexString(SHA256.HashData(attachment.Data)).ToLowerInvariant(); + attachment.Description = TrimToLength(attachment.Description, 1000); + attachment.UploadedByUserId = userId; + attachment.UploadedOn = DateTime.UtcNow; + attachment.DeletedOn = null; + attachment.DeletedByUserId = null; + + var saved = await _incidentAttachmentRepository.InsertAsync(Touch(attachment), cancellationToken); + await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.IncidentAttachmentAdded, $"Incident file added: {saved.FileName}", userId, cancellationToken); + _eventAggregator.SendMessage(new IncidentAttachmentAddedEvent + { + DepartmentId = saved.DepartmentId, + CallId = saved.CallId, + IncidentCommandId = saved.IncidentCommandId, + IncidentAttachmentId = saved.IncidentAttachmentId, + Visibility = saved.Visibility, + FileName = saved.FileName, + ContentType = saved.ContentType, + ContentLength = saved.ContentLength, + Sha256Hash = saved.Sha256Hash, + Description = saved.Description, + UploadedByUserId = userId + }); + return saved; + } + + public async Task> GetAttachmentsForCallAsync(int departmentId, int callId, bool publicOnly = false) + { + var attachments = await _incidentAttachmentRepository.GetAllMetadataByDepartmentIdAsync(departmentId); + if (attachments == null) + return new List(); + + return attachments.Where(x => x.CallId == callId && x.DeletedOn == null && + (!publicOnly || x.Visibility == (int)IncidentContentVisibility.Public)) + .OrderBy(x => x.UploadedOn) + .ToList(); + } + + public async Task GetAttachmentAsync(int departmentId, string incidentAttachmentId) + { + var attachment = await _incidentAttachmentRepository.GetByIdAsync(incidentAttachmentId); + return attachment != null && attachment.DepartmentId == departmentId && !attachment.DeletedOn.HasValue ? attachment : null; + } + + public async Task RemoveAttachmentAsync(int departmentId, string incidentAttachmentId, string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + var attachment = await _incidentAttachmentRepository.GetByIdAsync(incidentAttachmentId); + if (attachment == null || attachment.DepartmentId != departmentId || attachment.DeletedOn.HasValue) + return false; + var capabilities = await GetCapabilitiesForUserAsync(departmentId, attachment.CallId, userId); + var required = IncidentCapabilities.ManageDocuments; + if (attachment.Visibility == (int)IncidentContentVisibility.Public) + required |= IncidentCapabilities.ManagePublicInformation; + if ((capabilities & required) != required) + return false; + + attachment.DeletedOn = DateTime.UtcNow; + attachment.DeletedByUserId = userId; + await _incidentAttachmentRepository.SaveOrUpdateAsync(Touch(attachment), cancellationToken); + await WriteLogAsync(attachment.IncidentCommandId, attachment.DepartmentId, attachment.CallId, CommandLogEntryType.IncidentAttachmentRemoved, $"Incident file removed: {attachment.FileName}", userId, cancellationToken); + _eventAggregator.SendMessage(new IncidentAttachmentRemovedEvent + { + DepartmentId = attachment.DepartmentId, + CallId = attachment.CallId, + IncidentCommandId = attachment.IncidentCommandId, + IncidentAttachmentId = attachment.IncidentAttachmentId, + Visibility = attachment.Visibility, + FileName = attachment.FileName, + RemovedByUserId = userId + }); + return true; + } + + public async Task EnablePublicSharingAsync(int departmentId, string incidentCommandId, string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + var command = await GetOwnedCommandAsync(incidentCommandId, departmentId); + if (command == null) + return null; + + if (!command.PublicShareEnabled || string.IsNullOrWhiteSpace(command.PublicShareToken)) + command.PublicShareToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); + command.PublicShareEnabled = true; + command = await _incidentCommandRepository.SaveOrUpdateAsync(Touch(command), cancellationToken); + await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.PublicSharingEnabled, "Public incident sharing enabled", userId, cancellationToken); + _eventAggregator.SendMessage(new IncidentPublicSharingChangedEvent + { + DepartmentId = command.DepartmentId, + CallId = command.CallId, + IncidentCommandId = command.IncidentCommandId, + Enabled = true, + UpdatedByUserId = userId + }); + return command; + } + + public async Task DisablePublicSharingAsync(int departmentId, string incidentCommandId, string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + var command = await GetOwnedCommandAsync(incidentCommandId, departmentId); + if (command == null) + return null; + + command.PublicShareEnabled = false; + command.PublicShareToken = null; + command = await _incidentCommandRepository.SaveOrUpdateAsync(Touch(command), cancellationToken); + await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.PublicSharingDisabled, "Public incident sharing disabled", userId, cancellationToken); + _eventAggregator.SendMessage(new IncidentPublicSharingChangedEvent + { + DepartmentId = command.DepartmentId, + CallId = command.CallId, + IncidentCommandId = command.IncidentCommandId, + Enabled = false, + UpdatedByUserId = userId + }); return command; } + public async Task GetPublicInformationAsync(string publicShareToken) + { + if (!IsValidPublicShareToken(publicShareToken)) + return null; + var command = await _incidentCommandRepository.GetByPublicShareTokenAsync(publicShareToken); + if (command == null) + return null; + + var notes = await GetNotesForCallAsync(command.DepartmentId, command.CallId, true); + var attachments = await GetAttachmentsForCallAsync(command.DepartmentId, command.CallId, true); + var lastUpdated = new[] + { + command.ModifiedOn, + notes.Select(x => x.ModifiedOn).Where(x => x.HasValue).OrderByDescending(x => x).FirstOrDefault(), + attachments.Select(x => x.ModifiedOn).Where(x => x.HasValue).OrderByDescending(x => x).FirstOrDefault() + }.Where(x => x.HasValue).OrderByDescending(x => x).FirstOrDefault(); + + return new IncidentPublicInformation + { + IncidentCommandId = command.IncidentCommandId, + EstablishedOn = command.EstablishedOn, + Status = command.Status, + ClosedOn = command.ClosedOn, + LastUpdatedOn = lastUpdated, + Notes = notes.Select(x => new PublicIncidentNote + { + IncidentNoteId = x.IncidentNoteId, + NoteType = x.NoteType, + Title = x.Title, + Body = x.Body, + ContainmentPercent = x.ContainmentPercent, + CreatedOn = x.CreatedOn + }).ToList(), + Attachments = attachments.Select(x => new PublicIncidentAttachment + { + IncidentAttachmentId = x.IncidentAttachmentId, + FileName = x.FileName, + ContentType = x.ContentType, + ContentLength = x.ContentLength, + Sha256Hash = x.Sha256Hash, + Description = x.Description, + UploadedOn = x.UploadedOn + }).ToList() + }; + } + + public async Task GetPublicAttachmentAsync(string publicShareToken, string incidentAttachmentId) + { + if (!IsValidPublicShareToken(publicShareToken) || string.IsNullOrWhiteSpace(incidentAttachmentId)) + return null; + var command = await _incidentCommandRepository.GetByPublicShareTokenAsync(publicShareToken); + if (command == null) + return null; + + var attachment = await _incidentAttachmentRepository.GetByIdAsync(incidentAttachmentId); + return attachment != null && attachment.IncidentCommandId == command.IncidentCommandId && + attachment.Visibility == (int)IncidentContentVisibility.Public && !attachment.DeletedOn.HasValue + ? attachment + : null; + } + + public async Task GetWeatherForIncidentAsync(int departmentId, int callId, CancellationToken cancellationToken = default(CancellationToken)) + { + var command = await GetCommandForCallAsync(departmentId, callId); + if (command == null) + return null; + + decimal latitude; + decimal longitude; + if (!TryParseCoordinates(command.CommandPostLatitude, command.CommandPostLongitude, out latitude, out longitude)) + { + var call = await _callsService.GetCallByIdAsync(callId); + var coordinates = call?.GeoLocationData?.Split(','); + if (call == null || call.DepartmentId != departmentId || coordinates == null || coordinates.Length < 2 || + !TryParseCoordinates(coordinates[0], coordinates[1], out latitude, out longitude)) + throw new InvalidOperationException("The incident does not have a valid command-post or call location."); + } + + return await _incidentWeatherProvider.GetWeatherAsync(latitude, longitude, Resgrid.Config.IncidentCommandConfig.ForecastHours, cancellationToken); + } + #endregion Command lifecycle #region Structure (lanes) @@ -1074,6 +1436,45 @@ private async Task WriteLogAsync(string incidentCommandId, int return saved; } + private static bool TryParseCoordinates(string latitude, string longitude, out decimal latitudeValue, out decimal longitudeValue) + { + latitudeValue = 0; + longitudeValue = 0; + var validLatitude = decimal.TryParse(latitude?.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out latitudeValue); + var validLongitude = decimal.TryParse(longitude?.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out longitudeValue); + var valid = validLatitude && validLongitude; + return valid && latitudeValue >= -90 && latitudeValue <= 90 && longitudeValue >= -180 && longitudeValue <= 180; + } + + private static string TrimToLength(string value, int maximumLength) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + value = value.Trim(); + return value.Length <= maximumLength ? value : value.Substring(0, maximumLength); + } + + private static bool IsBlockedAttachment(string fileName, string contentType) + { + var extension = Path.GetExtension(fileName)?.ToLowerInvariant(); + var blockedExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".exe", ".dll", ".com", ".scr", ".msi", ".bat", ".cmd", ".ps1", ".vbs", ".js", ".jar" + }; + if (!string.IsNullOrWhiteSpace(extension) && blockedExtensions.Contains(extension)) + return true; + + var normalizedContentType = contentType?.Split(';')[0].Trim().ToLowerInvariant(); + return normalizedContentType == "application/x-msdownload" || + normalizedContentType == "application/x-msdos-program" || + normalizedContentType == "application/x-sh"; + } + + private static bool IsValidPublicShareToken(string token) + { + return !string.IsNullOrWhiteSpace(token) && token.Length == 64 && token.All(Uri.IsHexDigit); + } + #endregion Private helpers } } diff --git a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs index 669c1bbe9..bb5ef8ecb 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -416,6 +416,51 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve stt["department_number"] = "SFD1"; obj["shift_trade"] = stt; break; + + case WorkflowTriggerEventType.CommandEstablished: + case WorkflowTriggerEventType.CommandTransferred: + case WorkflowTriggerEventType.IncidentClosed: + case WorkflowTriggerEventType.ResourceAssigned: + case WorkflowTriggerEventType.ResourceReleased: + case WorkflowTriggerEventType.ObjectiveCompleted: + case WorkflowTriggerEventType.CriticalParDetected: + case WorkflowTriggerEventType.IncidentRoleAssigned: + case WorkflowTriggerEventType.AdHocResourceCreated: + case WorkflowTriggerEventType.IncidentChannelOpened: + case WorkflowTriggerEventType.PublicIncidentNoteAdded: + case WorkflowTriggerEventType.InternalIncidentNoteAdded: + case WorkflowTriggerEventType.PublicIncidentDocumentAdded: + case WorkflowTriggerEventType.InternalIncidentDocumentAdded: + case WorkflowTriggerEventType.IncidentNoteRemoved: + case WorkflowTriggerEventType.IncidentDocumentRemoved: + case WorkflowTriggerEventType.IncidentActionPlanUpdated: + case WorkflowTriggerEventType.IncidentCommandPostUpdated: + case WorkflowTriggerEventType.IncidentPublicSharingEnabled: + case WorkflowTriggerEventType.IncidentPublicSharingDisabled: + var incident = new ScriptObject(); + incident["command_id"] = "f0d7c9bd-c692-4c63-b714-111111111111"; + incident["call_id"] = 1001; + incident["department_id"] = 1; + incident["user_id"] = "00000000-0000-0000-0000-000000000001"; + incident["name"] = "Oak Creek Wildfire"; + incident["visibility"] = eventType == WorkflowTriggerEventType.PublicIncidentNoteAdded || eventType == WorkflowTriggerEventType.PublicIncidentDocumentAdded ? 1 : 0; + incident["note_id"] = "98e7cbdd-b3fa-4725-8332-222222222222"; + incident["note_type"] = (int)IncidentNoteType.Containment; + incident["title"] = "Containment update"; + incident["body"] = "Fire is 40% contained; forward progress has stopped on the east flank."; + incident["containment_percent"] = 40m; + incident["attachment_id"] = "35990439-23a1-43b5-a2dd-333333333333"; + incident["file_name"] = "public-situation-map.pdf"; + incident["content_type"] = "application/pdf"; + incident["content_length"] = 284123L; + incident["sha256_hash"] = "9f86d081884c7d659a2feaa0c55ad015"; + incident["description"] = "Current public situation map"; + incident["action_plan"] = "Protect life, hold the east flank, and maintain evacuation routes."; + incident["latitude"] = "39.7817"; + incident["longitude"] = "-89.6501"; + incident["enabled"] = eventType == WorkflowTriggerEventType.IncidentPublicSharingEnabled; + obj["incident"] = incident; + break; } } } diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index ff554fad6..1bedc3c69 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Events; @@ -303,6 +304,30 @@ public async Task BuildContextAsync( if (grp != null) MapGroupVariables(scriptObject, grp, "group"); break; } + case WorkflowTriggerEventType.CommandEstablished: + case WorkflowTriggerEventType.CommandTransferred: + case WorkflowTriggerEventType.IncidentClosed: + case WorkflowTriggerEventType.ResourceAssigned: + case WorkflowTriggerEventType.ResourceReleased: + case WorkflowTriggerEventType.ObjectiveCompleted: + case WorkflowTriggerEventType.CriticalParDetected: + case WorkflowTriggerEventType.IncidentRoleAssigned: + case WorkflowTriggerEventType.AdHocResourceCreated: + case WorkflowTriggerEventType.IncidentChannelOpened: + case WorkflowTriggerEventType.PublicIncidentNoteAdded: + case WorkflowTriggerEventType.InternalIncidentNoteAdded: + case WorkflowTriggerEventType.PublicIncidentDocumentAdded: + case WorkflowTriggerEventType.InternalIncidentDocumentAdded: + case WorkflowTriggerEventType.IncidentNoteRemoved: + case WorkflowTriggerEventType.IncidentDocumentRemoved: + case WorkflowTriggerEventType.IncidentActionPlanUpdated: + case WorkflowTriggerEventType.IncidentCommandPostUpdated: + case WorkflowTriggerEventType.IncidentPublicSharingEnabled: + case WorkflowTriggerEventType.IncidentPublicSharingDisabled: + { + triggeringUserId = MapIncidentVariables(scriptObject, eventPayloadJson); + break; + } } await AddCommonUserVariablesAsync(scriptObject, triggeringUserId); @@ -379,6 +404,62 @@ private static void AddCommonTimestampVariables(ScriptObject obj, string timeZon obj["timestamp"] = ts; } + private static string MapIncidentVariables(ScriptObject obj, string eventPayloadJson) + { + JObject payload; + try + { + payload = JObject.Parse(eventPayloadJson ?? "{}"); + } + catch (JsonException) + { + payload = new JObject(); + } + + JToken Find(params string[] names) + { + foreach (var name in names) + { + if (payload.TryGetValue(name, StringComparison.OrdinalIgnoreCase, out var value)) + return value; + } + return null; + } + + string Text(params string[] names) => Find(names)?.Type == JTokenType.Null ? null : Find(names)?.ToString(); + object Scalar(params string[] names) + { + var token = Find(names); + return token == null || token.Type == JTokenType.Null ? null : ((JValue)token).Value; + } + + var incident = new ScriptObject(); + incident["command_id"] = Text("IncidentCommandId"); + incident["call_id"] = Scalar("CallId") ?? 0; + incident["department_id"] = Scalar("DepartmentId") ?? 0; + incident["user_id"] = Text("EstablishedByUserId", "ToUserId", "UserId", "CreatedByUserId", "UploadedByUserId", "UpdatedByUserId", "RemovedByUserId"); + incident["name"] = Text("Name"); + incident["visibility"] = Scalar("Visibility") ?? 0; + incident["note_id"] = Text("IncidentNoteId"); + incident["note_type"] = Scalar("NoteType") ?? 0; + incident["title"] = Text("Title"); + incident["body"] = Text("Body"); + incident["containment_percent"] = Scalar("ContainmentPercent"); + incident["attachment_id"] = Text("IncidentAttachmentId"); + incident["file_name"] = Text("FileName"); + incident["content_type"] = Text("ContentType"); + incident["content_length"] = Scalar("ContentLength") ?? 0L; + incident["sha256_hash"] = Text("Sha256Hash"); + incident["description"] = Text("Description"); + incident["action_plan"] = Text("ActionPlan"); + incident["latitude"] = Text("Latitude"); + incident["longitude"] = Text("Longitude"); + incident["enabled"] = Scalar("Enabled") ?? false; + obj["incident"] = incident; + + return incident["user_id"]?.ToString(); + } + private async Task AddCommonUserVariablesAsync(ScriptObject obj, string userId) { var u = new ScriptObject(); diff --git a/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs b/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs index a94bcd99c..3ea854e01 100644 --- a/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs +++ b/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs @@ -106,6 +106,16 @@ private void RegisterListeners() _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.AdHocResourceCreated, e)); _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.IncidentChannelOpened, e)); _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.CriticalParDetected, e)); + _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, + e.Visibility == (int)IncidentContentVisibility.Public ? WorkflowTriggerEventType.PublicIncidentNoteAdded : WorkflowTriggerEventType.InternalIncidentNoteAdded, e)); + _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, + e.Visibility == (int)IncidentContentVisibility.Public ? WorkflowTriggerEventType.PublicIncidentDocumentAdded : WorkflowTriggerEventType.InternalIncidentDocumentAdded, e)); + _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.IncidentNoteRemoved, e)); + _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.IncidentDocumentRemoved, e)); + _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.IncidentActionPlanUpdated, e)); + _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.IncidentCommandPostUpdated, e)); + _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, + e.Enabled ? WorkflowTriggerEventType.IncidentPublicSharingEnabled : WorkflowTriggerEventType.IncidentPublicSharingDisabled, e)); } private static async void HandleEvent(int departmentId, WorkflowTriggerEventType eventType, object eventObj) diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0090_AddIncidentContentAndPublicSharing.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0090_AddIncidentContentAndPublicSharing.cs new file mode 100644 index 000000000..7ddafdcb5 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0090_AddIncidentContentAndPublicSharing.cs @@ -0,0 +1,97 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// Incident status notes, files, and token-scoped public information sharing. + [Migration(90)] + public class M0090_AddIncidentContentAndPublicSharing : Migration + { + public override void Up() + { + if (Schema.Table("IncidentCommands").Exists()) + { + if (!Schema.Table("IncidentCommands").Column("PublicShareEnabled").Exists()) + Alter.Table("IncidentCommands").AddColumn("PublicShareEnabled").AsBoolean().NotNullable().WithDefaultValue(false); + if (!Schema.Table("IncidentCommands").Column("PublicShareToken").Exists()) + Alter.Table("IncidentCommands").AddColumn("PublicShareToken").AsString(64).Nullable(); + // SQL Server permits only one NULL in a plain unique index. Keep this lookup indexed; the 256-bit random + // token plus rotation makes collisions impractical without blocking multiple legacy NULL rows. + if (!Schema.Table("IncidentCommands").Index("IX_IncidentCommands_PublicShareToken").Exists()) + Create.Index("IX_IncidentCommands_PublicShareToken").OnTable("IncidentCommands").OnColumn("PublicShareToken").Ascending(); + } + + if (!Schema.Table("IncidentNotes").Exists()) + { + Create.Table("IncidentNotes") + .WithColumn("IncidentNoteId").AsString(128).NotNullable().PrimaryKey() + .WithColumn("IncidentCommandId").AsString(128).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("CallId").AsInt32().NotNullable() + .WithColumn("NoteType").AsInt32().NotNullable() + .WithColumn("Visibility").AsInt32().NotNullable() + .WithColumn("Title").AsString(250).Nullable() + .WithColumn("Body").AsString(int.MaxValue).NotNullable() + .WithColumn("ContainmentPercent").AsDecimal(5, 2).Nullable() + .WithColumn("CreatedByUserId").AsString(128).NotNullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("DeletedOn").AsDateTime2().Nullable() + .WithColumn("DeletedByUserId").AsString(128).Nullable() + .WithColumn("ModifiedOn").AsDateTime2().Nullable(); + + Create.ForeignKey("FK_IncidentNotes_IncidentCommands") + .FromTable("IncidentNotes").ForeignColumn("IncidentCommandId") + .ToTable("IncidentCommands").PrimaryColumn("IncidentCommandId"); + Create.Index("IX_IncidentNotes_Department_Call_Modified") + .OnTable("IncidentNotes") + .OnColumn("DepartmentId").Ascending() + .OnColumn("CallId").Ascending() + .OnColumn("ModifiedOn").Ascending(); + } + + if (!Schema.Table("IncidentAttachments").Exists()) + { + Create.Table("IncidentAttachments") + .WithColumn("IncidentAttachmentId").AsString(128).NotNullable().PrimaryKey() + .WithColumn("IncidentCommandId").AsString(128).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("CallId").AsInt32().NotNullable() + .WithColumn("Visibility").AsInt32().NotNullable() + .WithColumn("FileName").AsString(512).NotNullable() + .WithColumn("ContentType").AsString(200).NotNullable() + .WithColumn("ContentLength").AsInt64().NotNullable() + .WithColumn("Sha256Hash").AsString(64).NotNullable() + .WithColumn("Description").AsString(1000).Nullable() + .WithColumn("Data").AsBinary(int.MaxValue).NotNullable() + .WithColumn("UploadedByUserId").AsString(128).NotNullable() + .WithColumn("UploadedOn").AsDateTime2().NotNullable() + .WithColumn("DeletedOn").AsDateTime2().Nullable() + .WithColumn("DeletedByUserId").AsString(128).Nullable() + .WithColumn("ModifiedOn").AsDateTime2().Nullable(); + + Create.ForeignKey("FK_IncidentAttachments_IncidentCommands") + .FromTable("IncidentAttachments").ForeignColumn("IncidentCommandId") + .ToTable("IncidentCommands").PrimaryColumn("IncidentCommandId"); + Create.Index("IX_IncidentAttachments_Department_Call_Modified") + .OnTable("IncidentAttachments") + .OnColumn("DepartmentId").Ascending() + .OnColumn("CallId").Ascending() + .OnColumn("ModifiedOn").Ascending(); + } + } + + public override void Down() + { + if (Schema.Table("IncidentAttachments").Exists()) Delete.Table("IncidentAttachments"); + if (Schema.Table("IncidentNotes").Exists()) Delete.Table("IncidentNotes"); + if (Schema.Table("IncidentCommands").Exists()) + { + if (Schema.Table("IncidentCommands").Index("IX_IncidentCommands_PublicShareToken").Exists()) + Delete.Index("IX_IncidentCommands_PublicShareToken").OnTable("IncidentCommands"); + if (Schema.Table("IncidentCommands").Column("PublicShareToken").Exists()) + Delete.Column("PublicShareToken").FromTable("IncidentCommands"); + if (Schema.Table("IncidentCommands").Column("PublicShareEnabled").Exists()) + Delete.Column("PublicShareEnabled").FromTable("IncidentCommands"); + } + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0091_AddUniqueDepartmentSsoProvider.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0091_AddUniqueDepartmentSsoProvider.cs new file mode 100644 index 000000000..b695681e9 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0091_AddUniqueDepartmentSsoProvider.cs @@ -0,0 +1,30 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// Enforces one SSO configuration per provider type for each department. + [Migration(91)] + public class M0091_AddUniqueDepartmentSsoProvider : Migration + { + private const string TableName = "DepartmentSsoConfigs"; + private const string IndexName = "UX_DepartmentSsoConfigs_DepartmentId_SsoProviderType"; + + public override void Up() + { + if (Schema.Table(TableName).Exists() && !Schema.Table(TableName).Index(IndexName).Exists()) + { + Create.Index(IndexName) + .OnTable(TableName) + .OnColumn("DepartmentId").Ascending() + .OnColumn("SsoProviderType").Ascending() + .WithOptions().Unique(); + } + } + + public override void Down() + { + if (Schema.Table(TableName).Exists() && Schema.Table(TableName).Index(IndexName).Exists()) + Delete.Index(IndexName).OnTable(TableName); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0090_AddIncidentContentAndPublicSharingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0090_AddIncidentContentAndPublicSharingPg.cs new file mode 100644 index 000000000..1969bf86e --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0090_AddIncidentContentAndPublicSharingPg.cs @@ -0,0 +1,95 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// PostgreSQL incident status notes, files, and token-scoped public information sharing. + [Migration(90)] + public class M0090_AddIncidentContentAndPublicSharingPg : Migration + { + public override void Up() + { + if (Schema.Table("incidentcommands").Exists()) + { + if (!Schema.Table("incidentcommands").Column("publicshareenabled").Exists()) + Alter.Table("incidentcommands").AddColumn("publicshareenabled").AsBoolean().NotNullable().WithDefaultValue(false); + if (!Schema.Table("incidentcommands").Column("publicsharetoken").Exists()) + Alter.Table("incidentcommands").AddColumn("publicsharetoken").AsCustom("citext").Nullable(); + if (!Schema.Table("incidentcommands").Index("ux_incidentcommands_publicsharetoken").Exists()) + Create.Index("ux_incidentcommands_publicsharetoken").OnTable("incidentcommands").OnColumn("publicsharetoken").Ascending().WithOptions().Unique(); + } + + if (!Schema.Table("incidentnotes").Exists()) + { + Create.Table("incidentnotes") + .WithColumn("incidentnoteid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("incidentcommandid").AsCustom("citext").NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("callid").AsInt32().NotNullable() + .WithColumn("notetype").AsInt32().NotNullable() + .WithColumn("visibility").AsInt32().NotNullable() + .WithColumn("title").AsCustom("citext").Nullable() + .WithColumn("body").AsCustom("citext").NotNullable() + .WithColumn("containmentpercent").AsDecimal(5, 2).Nullable() + .WithColumn("createdbyuserid").AsCustom("citext").NotNullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("deletedon").AsDateTime2().Nullable() + .WithColumn("deletedbyuserid").AsCustom("citext").Nullable() + .WithColumn("modifiedon").AsDateTime2().Nullable(); + + Create.ForeignKey("fk_incidentnotes_incidentcommands") + .FromTable("incidentnotes").ForeignColumn("incidentcommandid") + .ToTable("incidentcommands").PrimaryColumn("incidentcommandid"); + Create.Index("ix_incidentnotes_department_call_modified") + .OnTable("incidentnotes") + .OnColumn("departmentid").Ascending() + .OnColumn("callid").Ascending() + .OnColumn("modifiedon").Ascending(); + } + + if (!Schema.Table("incidentattachments").Exists()) + { + Create.Table("incidentattachments") + .WithColumn("incidentattachmentid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("incidentcommandid").AsCustom("citext").NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("callid").AsInt32().NotNullable() + .WithColumn("visibility").AsInt32().NotNullable() + .WithColumn("filename").AsCustom("citext").NotNullable() + .WithColumn("contenttype").AsCustom("citext").NotNullable() + .WithColumn("contentlength").AsInt64().NotNullable() + .WithColumn("sha256hash").AsCustom("citext").NotNullable() + .WithColumn("description").AsCustom("citext").Nullable() + .WithColumn("data").AsBinary(int.MaxValue).NotNullable() + .WithColumn("uploadedbyuserid").AsCustom("citext").NotNullable() + .WithColumn("uploadedon").AsDateTime2().NotNullable() + .WithColumn("deletedon").AsDateTime2().Nullable() + .WithColumn("deletedbyuserid").AsCustom("citext").Nullable() + .WithColumn("modifiedon").AsDateTime2().Nullable(); + + Create.ForeignKey("fk_incidentattachments_incidentcommands") + .FromTable("incidentattachments").ForeignColumn("incidentcommandid") + .ToTable("incidentcommands").PrimaryColumn("incidentcommandid"); + Create.Index("ix_incidentattachments_department_call_modified") + .OnTable("incidentattachments") + .OnColumn("departmentid").Ascending() + .OnColumn("callid").Ascending() + .OnColumn("modifiedon").Ascending(); + } + } + + public override void Down() + { + if (Schema.Table("incidentattachments").Exists()) Delete.Table("incidentattachments"); + if (Schema.Table("incidentnotes").Exists()) Delete.Table("incidentnotes"); + if (Schema.Table("incidentcommands").Exists()) + { + if (Schema.Table("incidentcommands").Index("ux_incidentcommands_publicsharetoken").Exists()) + Delete.Index("ux_incidentcommands_publicsharetoken").OnTable("incidentcommands"); + if (Schema.Table("incidentcommands").Column("publicsharetoken").Exists()) + Delete.Column("publicsharetoken").FromTable("incidentcommands"); + if (Schema.Table("incidentcommands").Column("publicshareenabled").Exists()) + Delete.Column("publicshareenabled").FromTable("incidentcommands"); + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0091_AddUniqueDepartmentSsoProviderPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0091_AddUniqueDepartmentSsoProviderPg.cs new file mode 100644 index 000000000..3da371c26 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0091_AddUniqueDepartmentSsoProviderPg.cs @@ -0,0 +1,30 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// Enforces one SSO configuration per provider type for each department. + [Migration(91)] + public class M0091_AddUniqueDepartmentSsoProviderPg : Migration + { + private const string TableName = "departmentssoconfigs"; + private const string IndexName = "ux_departmentssoconfigs_departmentid_ssoprovidertype"; + + public override void Up() + { + if (Schema.Table(TableName).Exists() && !Schema.Table(TableName).Index(IndexName).Exists()) + { + Create.Index(IndexName) + .OnTable(TableName) + .OnColumn("departmentid").Ascending() + .OnColumn("ssoprovidertype").Ascending() + .WithOptions().Unique(); + } + } + + public override void Down() + { + if (Schema.Table(TableName).Exists() && Schema.Table(TableName).Index(IndexName).Exists()) + Delete.Index(IndexName).OnTable(TableName); + } + } +} diff --git a/Providers/Resgrid.Providers.Weather/NwsIncidentWeatherProvider.cs b/Providers/Resgrid.Providers.Weather/NwsIncidentWeatherProvider.cs new file mode 100644 index 000000000..0953b3ac1 --- /dev/null +++ b/Providers/Resgrid.Providers.Weather/NwsIncidentWeatherProvider.cs @@ -0,0 +1,280 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Providers; + +namespace Resgrid.Providers.Weather +{ + /// + /// Real-time incident weather from the NWS point/forecast/observation APIs, plus the official NOAA/NWS MRMS + /// radar map-service manifest used by the commander map. Upstream responses are cached briefly to respect NWS + /// rate limits while multiple command clients watch the same incident. + /// + public class NwsIncidentWeatherProvider : IIncidentWeatherProvider + { + private const string NwsBaseUrl = "https://api.weather.gov"; + private const string RadarServiceUrl = "https://mapservices.weather.noaa.gov/eventdriven/rest/services/radar/radar_base_reflectivity/MapServer"; + private static readonly HttpClient SharedHttpClient = CreateHttpClient(); + private static readonly ConcurrentDictionary Cache = new ConcurrentDictionary(); + private readonly HttpClient _httpClient; + + public NwsIncidentWeatherProvider() : this(SharedHttpClient) + { + } + + /// Constructor exposed for deterministic provider tests with a stubbed HTTP handler. + public NwsIncidentWeatherProvider(HttpClient httpClient) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + public async Task GetWeatherAsync(decimal latitude, decimal longitude, int forecastHours = 24, CancellationToken cancellationToken = default) + { + ValidateCoordinates(latitude, longitude); + forecastHours = Math.Clamp(forecastHours, 1, 168); + + var cacheKey = $"{Math.Round(latitude, 4)}:{Math.Round(longitude, 4)}:{forecastHours}"; + if (Cache.TryGetValue(cacheKey, out var cached) && cached.ExpiresAtUtc > DateTime.UtcNow) + return cached.Weather; + + var pointUrl = $"{NwsBaseUrl}/points/{latitude.ToString("0.####", CultureInfo.InvariantCulture)},{longitude.ToString("0.####", CultureInfo.InvariantCulture)}"; + using var pointDoc = await GetJsonAsync(pointUrl, cancellationToken); + var pointProperties = pointDoc.RootElement.GetProperty("properties"); + var forecastUrl = GetString(pointProperties, "forecastHourly"); + var stationsUrl = GetString(pointProperties, "observationStations"); + + if (string.IsNullOrWhiteSpace(forecastUrl)) + throw new InvalidOperationException("NWS did not return an hourly forecast endpoint for the incident location."); + + var weather = new IncidentWeather + { + Latitude = latitude, + Longitude = longitude, + Source = "National Weather Service", + Attribution = "NOAA / National Weather Service", + GeneratedAtUtc = DateTime.UtcNow, + ExpiresAtUtc = DateTime.UtcNow.AddMinutes(5) + }; + + using (var forecastDoc = await GetJsonAsync(forecastUrl, cancellationToken)) + { + var properties = forecastDoc.RootElement.GetProperty("properties"); + weather.UpdatedAtUtc = GetDate(properties, "updated"); + if (properties.TryGetProperty("periods", out var periods)) + { + weather.HourlyForecast = periods.EnumerateArray() + .Take(forecastHours) + .Select(ParseForecastPeriod) + .ToList(); + } + } + + if (!string.IsNullOrWhiteSpace(stationsUrl)) + { + try + { + weather.Current = await GetLatestObservationAsync(stationsUrl, cancellationToken); + } + catch (Exception ex) when (ex is HttpRequestException || ex is JsonException || ex is InvalidOperationException) + { + // A station observation can be delayed or temporarily unavailable. Forecast/radar data remains useful, + // so degrade only the current-conditions portion instead of failing the commander weather panel. + weather.Current = null; + } + } + + weather.Overlays.Add(CreateRadarOverlay()); + Cache[cacheKey] = new CacheEntry(weather, weather.ExpiresAtUtc); + return weather; + } + + private async Task GetLatestObservationAsync(string stationsUrl, CancellationToken cancellationToken) + { + using var stationsDoc = await GetJsonAsync(stationsUrl, cancellationToken); + if (!stationsDoc.RootElement.TryGetProperty("features", out var features) || features.GetArrayLength() == 0) + return null; + + var stationFeature = features[0]; + var stationProperties = stationFeature.GetProperty("properties"); + var stationId = GetString(stationProperties, "stationIdentifier"); + if (string.IsNullOrWhiteSpace(stationId)) + { + var stationUrl = GetString(stationFeature, "id"); + stationId = stationUrl?.TrimEnd('/').Split('/').LastOrDefault(); + } + + if (string.IsNullOrWhiteSpace(stationId)) + return null; + + using var observationDoc = await GetJsonAsync($"{NwsBaseUrl}/stations/{Uri.EscapeDataString(stationId)}/observations/latest", cancellationToken); + var properties = observationDoc.RootElement.GetProperty("properties"); + + return new IncidentWeatherObservation + { + StationId = stationId, + Description = GetString(properties, "textDescription"), + ObservedAtUtc = GetDate(properties, "timestamp"), + TemperatureCelsius = ReadTemperatureCelsius(properties, "temperature"), + RelativeHumidityPercent = ReadValue(properties, "relativeHumidity"), + WindSpeedKph = ReadSpeedKph(properties, "windSpeed"), + WindGustKph = ReadSpeedKph(properties, "windGust"), + WindDirectionDegrees = ReadValue(properties, "windDirection"), + BarometricPressureHpa = ReadPressureHpa(properties, "barometricPressure"), + VisibilityMeters = ReadValue(properties, "visibility") + }; + } + + private async Task GetJsonAsync(string url, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/geo+json")); + request.Headers.UserAgent.ParseAdd("Resgrid/1.0 (team@resgrid.com)"); + + using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync(cancellationToken); + return JsonDocument.Parse(json); + } + + private static IncidentWeatherForecastPeriod ParseForecastPeriod(JsonElement period) + { + var temperature = GetDecimal(period, "temperature"); + var unit = GetString(period, "temperatureUnit"); + if (temperature.HasValue && string.Equals(unit, "F", StringComparison.OrdinalIgnoreCase)) + temperature = Math.Round((temperature.Value - 32m) * 5m / 9m, 1); + + int? precipitation = null; + if (period.TryGetProperty("probabilityOfPrecipitation", out var pop)) + precipitation = GetInt(pop, "value"); + + return new IncidentWeatherForecastPeriod + { + Number = GetInt(period, "number") ?? 0, + Name = GetString(period, "name"), + StartsAtUtc = GetDate(period, "startTime") ?? DateTime.MinValue, + EndsAtUtc = GetDate(period, "endTime") ?? DateTime.MinValue, + IsDaytime = GetBool(period, "isDaytime"), + TemperatureCelsius = temperature, + PrecipitationProbabilityPercent = precipitation, + WindSpeed = GetString(period, "windSpeed"), + WindDirection = GetString(period, "windDirection"), + ShortForecast = GetString(period, "shortForecast"), + DetailedForecast = GetString(period, "detailedForecast"), + IconUrl = GetString(period, "icon") + }; + } + + private static IncidentWeatherOverlay CreateRadarOverlay() + { + return new IncidentWeatherOverlay + { + Id = "noaa-mrms-base-reflectivity", + Name = "Weather Radar", + OverlayType = "ArcGisMapServerExport", + ServiceUrl = RadarServiceUrl, + LayerIds = "3", + ExportUrlTemplate = RadarServiceUrl + "/export?bbox={west},{south},{east},{north}&bboxSR=3857&imageSR=3857&size={width},{height}&format=png32&transparent=true&layers=show:3&f=image", + DefaultOpacity = 0.70m, + RefreshSeconds = 300, + Attribution = "NOAA / National Weather Service MRMS" + }; + } + + private static decimal? ReadTemperatureCelsius(JsonElement properties, string propertyName) + { + if (!TryGetQuantitativeValue(properties, propertyName, out var value, out var unit)) + return null; + if (unit?.IndexOf("degF", StringComparison.OrdinalIgnoreCase) >= 0) + return Math.Round((value - 32m) * 5m / 9m, 1); + return value; + } + + private static decimal? ReadSpeedKph(JsonElement properties, string propertyName) + { + if (!TryGetQuantitativeValue(properties, propertyName, out var value, out var unit)) + return null; + if (unit?.IndexOf("m_s-1", StringComparison.OrdinalIgnoreCase) >= 0) + return Math.Round(value * 3.6m, 1); + return value; + } + + private static decimal? ReadPressureHpa(JsonElement properties, string propertyName) + { + if (!TryGetQuantitativeValue(properties, propertyName, out var value, out var unit)) + return null; + if (unit?.EndsWith(":Pa", StringComparison.OrdinalIgnoreCase) == true) + return Math.Round(value / 100m, 1, MidpointRounding.AwayFromZero); + return value; + } + + private static decimal? ReadValue(JsonElement properties, string propertyName) + => TryGetQuantitativeValue(properties, propertyName, out var value, out _) ? value : null; + + private static bool TryGetQuantitativeValue(JsonElement properties, string propertyName, out decimal value, out string unit) + { + value = 0; + unit = null; + if (!properties.TryGetProperty(propertyName, out var quantity) || quantity.ValueKind != JsonValueKind.Object) + return false; + unit = GetString(quantity, "unitCode"); + var parsed = GetDecimal(quantity, "value"); + if (!parsed.HasValue) + return false; + value = parsed.Value; + return true; + } + + private static string GetString(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + + private static decimal? GetDecimal(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetDecimal(out var result) ? result : null; + + private static int? GetInt(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var result) ? result : null; + + private static bool GetBool(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.True; + + private static DateTime? GetDate(JsonElement element, string propertyName) + { + var value = GetString(element, propertyName); + return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed) + ? parsed.UtcDateTime + : (DateTime?)null; + } + + private static void ValidateCoordinates(decimal latitude, decimal longitude) + { + if (latitude < -90 || latitude > 90) + throw new ArgumentOutOfRangeException(nameof(latitude)); + if (longitude < -180 || longitude > 180) + throw new ArgumentOutOfRangeException(nameof(longitude)); + } + + private static HttpClient CreateHttpClient() + { + return new HttpClient { Timeout = TimeSpan.FromSeconds(12) }; + } + + private sealed class CacheEntry + { + public CacheEntry(IncidentWeather weather, DateTime expiresAtUtc) + { + Weather = weather; + ExpiresAtUtc = expiresAtUtc; + } + + public IncidentWeather Weather { get; } + public DateTime ExpiresAtUtc { get; } + } + } +} diff --git a/Providers/Resgrid.Providers.Weather/WeatherProviderModule.cs b/Providers/Resgrid.Providers.Weather/WeatherProviderModule.cs index a82b4e21e..e3a5a227d 100644 --- a/Providers/Resgrid.Providers.Weather/WeatherProviderModule.cs +++ b/Providers/Resgrid.Providers.Weather/WeatherProviderModule.cs @@ -7,6 +7,7 @@ public class WeatherProviderModule : Module { protected override void Load(ContainerBuilder builder) { + builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/IncidentCommandRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/IncidentCommandRepositories.cs index 5f58c5ff9..2cda5e246 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/IncidentCommandRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/IncidentCommandRepositories.cs @@ -1,3 +1,11 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Connection; @@ -8,9 +16,49 @@ namespace Resgrid.Repositories.DataRepository { public class IncidentCommandRepository : RepositoryBase, IIncidentCommandRepository { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IUnitOfWork _unitOfWork; + public IncidentCommandRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _unitOfWork = unitOfWork; + } + + public async Task GetByPublicShareTokenAsync(string publicShareToken) + { + if (string.IsNullOrWhiteSpace(publicShareToken)) + return null; + + try + { + var parameters = new DynamicParametersExtension(); + parameters.Add("PublicShareToken", publicShareToken); + var notation = _sqlConfiguration.ParameterNotation; + var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres + ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.incidentcommands WHERE publicsharetoken = {notation}PublicShareToken AND publicshareenabled = true" + : $"SELECT * FROM {_sqlConfiguration.SchemaName}.[IncidentCommands] WHERE [PublicShareToken] = {notation}PublicShareToken AND [PublicShareEnabled] = 1"; + + var select = new Func>>(connection => + connection.QueryAsync(sql, parameters, _unitOfWork.Transaction)); + + if (_unitOfWork?.Connection == null) + { + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(); + return (await select(connection)).FirstOrDefault(); + } + + return (await select(_unitOfWork.CreateOrGetConnection())).FirstOrDefault(); + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } } } @@ -69,4 +117,49 @@ public CommandTransferRepository(IConnectionProvider connectionProvider, SqlConf { } } + + public class IncidentNoteRepository : RepositoryBase, IIncidentNoteRepository + { + public IncidentNoteRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + } + } + + public class IncidentAttachmentRepository : RepositoryBase, IIncidentAttachmentRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IUnitOfWork _unitOfWork; + + public IncidentAttachmentRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _unitOfWork = unitOfWork; + } + + public async Task> GetAllMetadataByDepartmentIdAsync(int departmentId) + { + var parameters = new DynamicParametersExtension(); + parameters.Add("DepartmentId", departmentId); + var notation = _sqlConfiguration.ParameterNotation; + var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres + ? $"SELECT incidentattachmentid, incidentcommandid, departmentid, callid, visibility, filename, contenttype, contentlength, sha256hash, description, uploadedbyuserid, uploadedon, deletedon, deletedbyuserid, modifiedon FROM {_sqlConfiguration.SchemaName}.incidentattachments WHERE departmentid = {notation}DepartmentId" + : $"SELECT [IncidentAttachmentId], [IncidentCommandId], [DepartmentId], [CallId], [Visibility], [FileName], [ContentType], [ContentLength], [Sha256Hash], [Description], [UploadedByUserId], [UploadedOn], [DeletedOn], [DeletedByUserId], [ModifiedOn] FROM {_sqlConfiguration.SchemaName}.[IncidentAttachments] WHERE [DepartmentId] = {notation}DepartmentId"; + + var select = new Func>>(connection => + connection.QueryAsync(sql, parameters, _unitOfWork.Transaction)); + + if (_unitOfWork?.Connection == null) + { + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(); + return await select(connection); + } + + return await select(_unitOfWork.CreateOrGetConnection()); + } + } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index 81f1f4360..d0711ade7 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -117,6 +117,8 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Tests/Resgrid.Tests/Providers/NwsIncidentWeatherProviderTests.cs b/Tests/Resgrid.Tests/Providers/NwsIncidentWeatherProviderTests.cs new file mode 100644 index 000000000..ba85c2c49 --- /dev/null +++ b/Tests/Resgrid.Tests/Providers/NwsIncidentWeatherProviderTests.cs @@ -0,0 +1,128 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Providers.Weather; + +namespace Resgrid.Tests.Providers +{ + [TestFixture] + public class NwsIncidentWeatherProviderTests + { + [Test] + public async Task GetWeather_WithNwsResponses_MapsForecastObservationAndRadarOverlay() + { + // Arrange + using var httpClient = new HttpClient(new NwsWeatherHandler()); + var provider = new NwsIncidentWeatherProvider(httpClient); + + // Act + var weather = await provider.GetWeatherAsync(41.1234m, -122.5678m, 1); + + // Assert + weather.Source.Should().Be("National Weather Service"); + weather.HourlyForecast.Should().ContainSingle(); + weather.HourlyForecast[0].TemperatureCelsius.Should().Be(20m); + weather.HourlyForecast[0].PrecipitationProbabilityPercent.Should().Be(30); + weather.Current.StationId.Should().Be("KXYZ"); + weather.Current.TemperatureCelsius.Should().Be(12.5m); + weather.Current.BarometricPressureHpa.Should().Be(1013.3m); + weather.Overlays.Should().ContainSingle(x => + x.Id == "noaa-mrms-base-reflectivity" && x.LayerIds == "3" && x.RefreshSeconds == 300); + weather.Overlays[0].ExportUrlTemplate.Should().Contain("mapservices.weather.noaa.gov"); + } + + [TestCase(-91, 0)] + [TestCase(91, 0)] + [TestCase(0, -181)] + [TestCase(0, 181)] + public async Task GetWeather_WithInvalidCoordinates_RejectsRequest(decimal latitude, decimal longitude) + { + // Arrange + var provider = new NwsIncidentWeatherProvider(new HttpClient(new NwsWeatherHandler())); + + // Act + Func act = () => provider.GetWeatherAsync(latitude, longitude); + + // Assert + await act.Should().ThrowAsync(); + } + + private sealed class NwsWeatherHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var url = request.RequestUri.ToString(); + string json; + if (url.Contains("/points/", StringComparison.OrdinalIgnoreCase)) + { + json = """ + { + "properties": { + "forecastHourly": "https://test.local/forecast", + "observationStations": "https://test.local/stations" + } + } + """; + } + else if (url.EndsWith("/forecast", StringComparison.OrdinalIgnoreCase)) + { + json = """ + { + "properties": { + "updated": "2026-07-19T12:00:00+00:00", + "periods": [{ + "number": 1, + "name": "This Hour", + "startTime": "2026-07-19T12:00:00+00:00", + "endTime": "2026-07-19T13:00:00+00:00", + "isDaytime": true, + "temperature": 68, + "temperatureUnit": "F", + "probabilityOfPrecipitation": { "value": 30 }, + "windSpeed": "10 mph", + "windDirection": "SW", + "shortForecast": "Showers", + "detailedForecast": "Showers likely.", + "icon": "https://api.weather.gov/icons/test" + }] + } + } + """; + } + else if (url.EndsWith("/stations", StringComparison.OrdinalIgnoreCase)) + { + json = """ + { "features": [{ "id": "https://api.weather.gov/stations/KXYZ", "properties": { "stationIdentifier": "KXYZ" } }] } + """; + } + else + { + json = """ + { + "properties": { + "textDescription": "Light rain", + "timestamp": "2026-07-19T12:05:00+00:00", + "temperature": { "value": 12.5, "unitCode": "wmoUnit:degC" }, + "relativeHumidity": { "value": 82, "unitCode": "wmoUnit:percent" }, + "windSpeed": { "value": 18, "unitCode": "wmoUnit:km_h-1" }, + "windGust": { "value": 25, "unitCode": "wmoUnit:km_h-1" }, + "windDirection": { "value": 225, "unitCode": "wmoUnit:degree_(angle)" }, + "barometricPressure": { "value": 101325, "unitCode": "wmoUnit:Pa" }, + "visibility": { "value": 16000, "unitCode": "wmoUnit:m" } + } + } + """; + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json) + }); + } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/DepartmentSsoServiceTests.cs b/Tests/Resgrid.Tests/Services/DepartmentSsoServiceTests.cs new file mode 100644 index 000000000..ab9c2399a --- /dev/null +++ b/Tests/Resgrid.Tests/Services/DepartmentSsoServiceTests.cs @@ -0,0 +1,270 @@ +using System; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Security.Cryptography.Xml; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class DepartmentSsoServiceTests + { + private Mock _ssoConfigRepository; + private Mock _encryptionService; + private Mock _cacheProvider; + private int _samlReplayUseCount; + private DepartmentSsoService _service; + + [SetUp] + public void SetUp() + { + _ssoConfigRepository = new Mock(); + _encryptionService = new Mock(); + _cacheProvider = new Mock(); + _samlReplayUseCount = 0; + _encryptionService + .Setup(x => x.EncryptForDepartment(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string value, int _, string _) => $"encrypted:{value}"); + _cacheProvider + .Setup(x => x.IncrementAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => Interlocked.Increment(ref _samlReplayUseCount)); + + _service = new DepartmentSsoService( + _ssoConfigRepository.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + _encryptionService.Object, + _cacheProvider.Object); + } + + [Test] + public async Task SaveSsoConfigAsync_NewConfigWithPreallocatedId_UsesInsertInsteadOfUpdate() + { + // Arrange + var config = CreateSamlConfig(); + _ssoConfigRepository + .Setup(x => x.GetByDepartmentIdAndTypeAsync(config.DepartmentId, SsoProviderType.Saml2)) + .ReturnsAsync((DepartmentSsoConfig)null); + _ssoConfigRepository + .Setup(x => x.InsertAsync(config, It.IsAny(), It.IsAny())) + .ReturnsAsync(config); + + // Act + var saved = await _service.SaveSsoConfigAsync(config, "DEPT"); + + // Assert + saved.DepartmentSsoConfigId.Should().Be(config.DepartmentSsoConfigId); + saved.EncryptedIdpCertificate.Should().Be("encrypted:certificate"); + _ssoConfigRepository.Verify(x => x.InsertAsync(config, It.IsAny(), It.IsAny()), Times.Once); + _ssoConfigRepository.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _ssoConfigRepository.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task SaveSsoConfigAsync_BlankSecretsOnUpdate_PreservesStoredCiphertexts() + { + // Arrange + var stored = CreateSamlConfig(); + stored.EncryptedClientSecret = "stored-client-secret"; + stored.EncryptedIdpCertificate = "stored-idp-certificate"; + stored.EncryptedSigningCertificate = "stored-signing-certificate"; + stored.EncryptedScimBearerToken = "stored-scim-token"; + + var update = CreateSamlConfig(); + update.EncryptedClientSecret = null; + update.EncryptedIdpCertificate = null; + update.EncryptedSigningCertificate = null; + update.EncryptedScimBearerToken = null; + + _ssoConfigRepository + .Setup(x => x.GetByDepartmentIdAndTypeAsync(update.DepartmentId, SsoProviderType.Saml2)) + .ReturnsAsync(stored); + _ssoConfigRepository + .Setup(x => x.UpdateAsync(update, It.IsAny(), It.IsAny())) + .ReturnsAsync(update); + + // Act + var saved = await _service.SaveSsoConfigAsync(update, "DEPT"); + + // Assert + saved.EncryptedClientSecret.Should().Be("stored-client-secret"); + saved.EncryptedIdpCertificate.Should().Be("stored-idp-certificate"); + saved.EncryptedSigningCertificate.Should().Be("stored-signing-certificate"); + saved.EncryptedScimBearerToken.Should().Be("stored-scim-token"); + _ssoConfigRepository.Verify(x => x.UpdateAsync(update, It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task ValidateExternalTokenAsync_UnsignedSamlAssertion_IsRejected() + { + // Arrange + using var rsa = RSA.Create(2048); + using var certificate = CreateCertificate(rsa); + var config = CreateSamlConfig(); + config.IsEnabled = true; + config.EncryptedIdpCertificate = "stored-certificate"; + _ssoConfigRepository + .Setup(x => x.GetByDepartmentIdAndTypeAsync(config.DepartmentId, SsoProviderType.Saml2)) + .ReturnsAsync(config); + _encryptionService + .Setup(x => x.DecryptForDepartment("stored-certificate", config.DepartmentId, "DEPT")) + .Returns(certificate.ExportCertificatePem()); + var samlResponse = BuildSamlResponse(config, rsa, signAssertion: false); + + // Act + var principal = await _service.ValidateExternalTokenAsync( + config.DepartmentId, SsoProviderType.Saml2, samlResponse, "DEPT"); + + // Assert + principal.Should().BeNull(); + } + + [Test] + public async Task ValidateExternalTokenAsync_SignedSamlAssertion_ValidatesOnceAndRejectsReplay() + { + // Arrange + using var rsa = RSA.Create(2048); + using var certificate = CreateCertificate(rsa); + var config = CreateSamlConfig(); + config.IsEnabled = true; + config.EncryptedIdpCertificate = "stored-certificate"; + _ssoConfigRepository + .Setup(x => x.GetByDepartmentIdAndTypeAsync(config.DepartmentId, SsoProviderType.Saml2)) + .ReturnsAsync(config); + _encryptionService + .Setup(x => x.DecryptForDepartment("stored-certificate", config.DepartmentId, "DEPT")) + .Returns(certificate.ExportCertificatePem()); + var samlResponse = BuildSamlResponse(config, rsa, signAssertion: true); + + // Act + var firstAttempt = await _service.ValidateExternalTokenAsync( + config.DepartmentId, SsoProviderType.Saml2, samlResponse, "DEPT"); + var replayAttempt = await _service.ValidateExternalTokenAsync( + config.DepartmentId, SsoProviderType.Saml2, samlResponse, "DEPT"); + + // Assert + firstAttempt.Should().NotBeNull(); + firstAttempt.FindFirst(ClaimTypes.NameIdentifier)?.Value.Should().Be("external-user"); + firstAttempt.FindFirst(ClaimTypes.Email)?.Value.Should().Be("user@example.com"); + replayAttempt.Should().BeNull(); + } + + [Test] + public async Task ValidateExternalTokenAsync_SignedSamlAssertionForWrongAudience_IsRejected() + { + // Arrange + using var rsa = RSA.Create(2048); + using var certificate = CreateCertificate(rsa); + var config = CreateSamlConfig(); + config.IsEnabled = true; + config.EncryptedIdpCertificate = "stored-certificate"; + _ssoConfigRepository + .Setup(x => x.GetByDepartmentIdAndTypeAsync(config.DepartmentId, SsoProviderType.Saml2)) + .ReturnsAsync(config); + _encryptionService + .Setup(x => x.DecryptForDepartment("stored-certificate", config.DepartmentId, "DEPT")) + .Returns(certificate.ExportCertificatePem()); + var samlResponse = BuildSamlResponse(config, rsa, signAssertion: true, audience: "https://another-service.example.test"); + + // Act + var principal = await _service.ValidateExternalTokenAsync( + config.DepartmentId, SsoProviderType.Saml2, samlResponse, "DEPT"); + + // Assert + principal.Should().BeNull(); + } + + private static DepartmentSsoConfig CreateSamlConfig() + { + return new DepartmentSsoConfig + { + DepartmentSsoConfigId = Guid.NewGuid().ToString(), + DepartmentId = 42, + SsoProviderType = (int)SsoProviderType.Saml2, + EntityId = "https://api.resgrid.test/saml/test", + AssertionConsumerServiceUrl = "https://api.resgrid.test/api/v4/connect/saml-mobile-callback", + EncryptedIdpCertificate = "certificate", + CreatedByUserId = "admin", + CreatedOn = DateTime.UtcNow + }; + } + + private static X509Certificate2 CreateCertificate(RSA rsa) + { + var request = new CertificateRequest("CN=Test SAML IdP", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + } + + private static string BuildSamlResponse(DepartmentSsoConfig config, RSA signingKey, bool signAssertion, string audience = null) + { + var now = DateTime.UtcNow; + var assertionId = $"_{Guid.NewGuid():N}"; + var responseId = $"_{Guid.NewGuid():N}"; + var notBefore = XmlConvert.ToString(now.AddMinutes(-1), XmlDateTimeSerializationMode.Utc); + var notOnOrAfter = XmlConvert.ToString(now.AddMinutes(5), XmlDateTimeSerializationMode.Utc); + var issueInstant = XmlConvert.ToString(now, XmlDateTimeSerializationMode.Utc); + var xml = $""" + + https://idp.example.test + + + https://idp.example.test + + external-user + + + + + + {audience ?? config.EntityId} + + + user@example.com + + + + """; + + var document = new XmlDocument { PreserveWhitespace = true }; + document.LoadXml(xml); + if (signAssertion) + { + var assertion = (XmlElement)document.SelectSingleNode("/samlp:Response/saml:Assertion", CreateSamlNamespaces(document)); + var signedXml = new SignedXml(assertion) { SigningKey = signingKey }; + signedXml.SignedInfo.CanonicalizationMethod = SignedXml.XmlDsigExcC14NTransformUrl; + signedXml.SignedInfo.SignatureMethod = SignedXml.XmlDsigRSASHA256Url; + var reference = new Reference($"#{assertionId}") { DigestMethod = SignedXml.XmlDsigSHA256Url }; + reference.AddTransform(new XmlDsigEnvelopedSignatureTransform()); + reference.AddTransform(new XmlDsigExcC14NTransform()); + signedXml.AddReference(reference); + signedXml.ComputeSignature(); + var signature = document.ImportNode(signedXml.GetXml(), deep: true); + assertion.InsertAfter(signature, assertion.FirstChild); + } + + return Convert.ToBase64String(Encoding.UTF8.GetBytes(document.OuterXml)); + } + + private static XmlNamespaceManager CreateSamlNamespaces(XmlDocument document) + { + var namespaces = new XmlNamespaceManager(document.NameTable); + namespaces.AddNamespace("samlp", "urn:oasis:names:tc:SAML:2.0:protocol"); + namespaces.AddNamespace("saml", "urn:oasis:names:tc:SAML:2.0:assertion"); + return namespaces; + } + } +} diff --git a/Tests/Resgrid.Tests/Services/IncidentCommandContentServiceTests.cs b/Tests/Resgrid.Tests/Services/IncidentCommandContentServiceTests.cs new file mode 100644 index 000000000..5d80398d2 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/IncidentCommandContentServiceTests.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class IncidentCommandContentServiceTests + { + private const int DepartmentId = 10; + private const int CallId = 22; + private Mock _commandRepository; + private Mock _noteRepository; + private Mock _attachmentRepository; + private Mock _logRepository; + private Mock _callsService; + private Mock _weatherProvider; + private Mock _events; + private IncidentCommandService _service; + + [SetUp] + public void SetUp() + { + _commandRepository = new Mock(); + _noteRepository = new Mock(); + _attachmentRepository = new Mock(); + _logRepository = new Mock(); + _callsService = new Mock(); + _weatherProvider = new Mock(); + _events = new Mock(); + + _logRepository.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CommandLogEntry value, CancellationToken _, bool _) => value); + _noteRepository.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IncidentNote value, CancellationToken _, bool _) => value); + _attachmentRepository.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IncidentAttachment value, CancellationToken _, bool _) => value); + + _service = new IncidentCommandService( + _commandRepository.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + _logRepository.Object, + new Mock().Object, + new Mock().Object, + _callsService.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + _events.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + _noteRepository.Object, + _attachmentRepository.Object, + _weatherProvider.Object); + } + + [Test] + public async Task AddNote_WithPublicContainmentUpdate_StampsOwnershipAndRaisesEvent() + { + // Arrange + ArrangeOwnedCommand(); + var note = new IncidentNote + { + IncidentCommandId = "ic-1", + DepartmentId = DepartmentId, + CallId = 999, + NoteType = (int)IncidentNoteType.Containment, + Visibility = (int)IncidentContentVisibility.Public, + Title = " Containment update ", + Body = " Fire is 40% contained. ", + ContainmentPercent = 40 + }; + + // Act + var saved = await _service.AddNoteAsync(note, "pio-1"); + + // Assert + saved.CallId.Should().Be(CallId); + saved.Title.Should().Be("Containment update"); + saved.Body.Should().Be("Fire is 40% contained."); + saved.CreatedByUserId.Should().Be("pio-1"); + saved.ModifiedOn.Should().NotBeNull(); + _events.Verify(x => x.SendMessage(It.Is(e => + e.CallId == CallId && e.Visibility == (int)IncidentContentVisibility.Public && e.ContainmentPercent == 40)), Times.Once); + } + + [Test] + public async Task AddAttachment_WithAllowedDocument_ComputesIntegrityMetadata() + { + // Arrange + ArrangeOwnedCommand(); + var data = Encoding.UTF8.GetBytes("incident action plan"); + var attachment = new IncidentAttachment + { + IncidentCommandId = "ic-1", + DepartmentId = DepartmentId, + Visibility = (int)IncidentContentVisibility.Internal, + FileName = "..\\plans\\iap.pdf", + ContentType = "application/pdf", + Data = data + }; + + // Act + var saved = await _service.AddAttachmentAsync(attachment, "docs-1"); + + // Assert + saved.FileName.Should().Be("iap.pdf"); + saved.ContentLength.Should().Be(data.LongLength); + saved.Sha256Hash.Should().HaveLength(64); + saved.Sha256Hash.Should().Be("3620f1049dbe154a423fd33fdb92ceb6d591a77d4af0e6341451c6f4d05659f6"); + _events.Verify(x => x.SendMessage(It.Is(e => + e.FileName == "iap.pdf" && e.ContentLength == data.LongLength)), Times.Once); + } + + [Test] + public async Task AddAttachment_WithExecutableExtension_IsRejected() + { + // Arrange + var attachment = new IncidentAttachment + { + IncidentCommandId = "ic-1", + DepartmentId = DepartmentId, + Visibility = (int)IncidentContentVisibility.Internal, + FileName = "payload.exe", + ContentType = "application/octet-stream", + Data = new byte[] { 1, 2, 3 } + }; + + // Act + Func act = () => _service.AddAttachmentAsync(attachment, "docs-1"); + + // Assert + await act.Should().ThrowAsync(); + } + + [Test] + public async Task GetPublicInformation_WithMixedContent_ReturnsOnlyExplicitlyPublicRecords() + { + // Arrange + var shareToken = new string('a', 64); + _commandRepository.Setup(x => x.GetByPublicShareTokenAsync(shareToken)).ReturnsAsync(new IncidentCommand + { + IncidentCommandId = "ic-1", + DepartmentId = DepartmentId, + CallId = CallId, + EstablishedOn = DateTime.UtcNow.AddHours(-2), + Status = (int)IncidentCommandStatus.Active, + PublicShareEnabled = true + }); + _noteRepository.Setup(x => x.GetAllByDepartmentIdAsync(DepartmentId)).ReturnsAsync(new List + { + new IncidentNote { IncidentNoteId = "public-note", CallId = CallId, Visibility = (int)IncidentContentVisibility.Public, CreatedOn = DateTime.UtcNow }, + new IncidentNote { IncidentNoteId = "internal-note", CallId = CallId, Visibility = (int)IncidentContentVisibility.Internal, CreatedOn = DateTime.UtcNow } + }); + _attachmentRepository.Setup(x => x.GetAllMetadataByDepartmentIdAsync(DepartmentId)).ReturnsAsync(new List + { + new IncidentAttachment { IncidentAttachmentId = "public-file", CallId = CallId, Visibility = (int)IncidentContentVisibility.Public, UploadedOn = DateTime.UtcNow }, + new IncidentAttachment { IncidentAttachmentId = "internal-file", CallId = CallId, Visibility = (int)IncidentContentVisibility.Internal, UploadedOn = DateTime.UtcNow } + }); + + // Act + var result = await _service.GetPublicInformationAsync(shareToken); + + // Assert + result.Notes.Select(x => x.IncidentNoteId).Should().Equal("public-note"); + result.Attachments.Select(x => x.IncidentAttachmentId).Should().Equal("public-file"); + } + + [Test] + public async Task GetWeatherForIncident_WithCommandPost_UsesCommandPostInsteadOfCallLocation() + { + // Arrange + _commandRepository.Setup(x => x.GetAllByDepartmentIdAsync(DepartmentId)).ReturnsAsync(new List + { + new IncidentCommand + { + IncidentCommandId = "ic-1", + DepartmentId = DepartmentId, + CallId = CallId, + CommandPostLatitude = "39.7817", + CommandPostLongitude = "-89.6501", + EstablishedOn = DateTime.UtcNow + } + }); + _weatherProvider.Setup(x => x.GetWeatherAsync(39.7817m, -89.6501m, It.IsAny(), It.IsAny())) + .ReturnsAsync(new IncidentWeather { Latitude = 39.7817m, Longitude = -89.6501m }); + + // Act + var result = await _service.GetWeatherForIncidentAsync(DepartmentId, CallId); + + // Assert + result.Latitude.Should().Be(39.7817m); + _weatherProvider.Verify(x => x.GetWeatherAsync(39.7817m, -89.6501m, It.IsAny(), It.IsAny()), Times.Once); + _callsService.Verify(x => x.GetCallByIdAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + private void ArrangeOwnedCommand() + { + _commandRepository.Setup(x => x.GetByIdAsync("ic-1")).ReturnsAsync(new IncidentCommand + { + IncidentCommandId = "ic-1", + DepartmentId = DepartmentId, + CallId = CallId, + Status = (int)IncidentCommandStatus.Active + }); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs b/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs index 1bfcdb857..253711112 100644 --- a/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs +++ b/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs @@ -43,6 +43,9 @@ public class IncidentCommandServiceParTests private Mock _coreEventService; private Mock _unitsService; private Mock _personnelRolesService; + private Mock _noteRepo; + private Mock _attachmentRepo; + private Mock _weatherProvider; private IncidentCommandService _service; [SetUp] @@ -65,6 +68,9 @@ public void SetUp() _coreEventService = new Mock(); _unitsService = new Mock(); _personnelRolesService = new Mock(); + _noteRepo = new Mock(); + _attachmentRepo = new Mock(); + _weatherProvider = new Mock(); // Timeline entries are append-only inserts; echo back the entry so WriteLogAsync resolves a non-null result. _logRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) @@ -74,7 +80,7 @@ public void SetUp() _objectiveRepo.Object, _timerRepo.Object, _annotationRepo.Object, _logRepo.Object, _transferRepo.Object, _commandsService.Object, _callsService.Object, _checkInTimerService.Object, _voiceService.Object, _roleRepo.Object, _eventAggregator.Object, _coreEventService.Object, - _unitsService.Object, _personnelRolesService.Object); + _unitsService.Object, _personnelRolesService.Object, _noteRepo.Object, _attachmentRepo.Object, _weatherProvider.Object); } private void ArrangeCall(bool checkInTimersEnabled = true, int departmentId = Dept) diff --git a/Tests/Resgrid.Tests/Services/TemplateCatalogTests.cs b/Tests/Resgrid.Tests/Services/TemplateCatalogTests.cs new file mode 100644 index 000000000..282ff209c --- /dev/null +++ b/Tests/Resgrid.Tests/Services/TemplateCatalogTests.cs @@ -0,0 +1,172 @@ +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.CommandBoards; +using Resgrid.Model.UnitRoles; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class TemplateCatalogTests + { + private static readonly string[] ExpectedOperationalCategories = + { + "Fire Departments", + "EMS", + "Law Enforcement", + "Search and Rescue", + "Emergency Management", + "Disaster Response", + "Security Companies", + "Event Medical / Security", + "Industrial Response", + "Delivery Companies" + }; + + [Test] + public void UnitRoleCatalog_AllTemplates_HasBroadUniqueCodeDefinedCoverage() + { + // Arrange + var templates = UnitRoleTemplateCatalog.All; + + // Act + var categories = templates.Select(template => template.Category).Distinct().ToList(); + var ids = templates.Select(template => template.Id).ToList(); + + // Assert + templates.Should().HaveCountGreaterThan(30); + categories.Should().Contain(ExpectedOperationalCategories); + ids.Should().OnlyHaveUniqueItems(); + templates.Should().OnlyContain(template => + !string.IsNullOrWhiteSpace(template.Id) && + !string.IsNullOrWhiteSpace(template.Name) && + template.Roles.Count > 0 && + template.Roles.All(role => !string.IsNullOrWhiteSpace(role.Name))); + } + + [Test] + public void UnitRoleCatalog_Search_MatchesCategoryKeywordsAndSeatNames() + { + // Arrange + const string query = "event medic"; + + // Act + var results = UnitRoleTemplateCatalog.Search(query); + + // Assert + results.Should().Contain(template => template.Id == "event-medical-roving"); + results.Should().OnlyContain(template => + template.SearchText.Contains("event") && template.SearchText.Contains("medic")); + } + + [Test] + public void CommandBoardCatalog_AllTemplates_HasBroadUniqueCodeDefinedCoverage() + { + // Arrange + var templates = CommandBoardTemplateCatalog.All; + + // Act + var categories = templates.Select(template => template.Category).Distinct().ToList(); + var ids = templates.Select(template => template.Id).ToList(); + + // Assert + templates.Should().HaveCountGreaterThan(20); + categories.Should().Contain(ExpectedOperationalCategories); + ids.Should().OnlyHaveUniqueItems(); + templates.Should().OnlyContain(template => + !string.IsNullOrWhiteSpace(template.Id) && + !string.IsNullOrWhiteSpace(template.Name) && + template.Lanes.Count > 0 && + template.Lanes.All(lane => !string.IsNullOrWhiteSpace(lane.Name))); + } + + [Test] + public void CommandBoardCatalog_Search_MatchesAcrossKeywordsAndLaneNames() + { + // Arrange + const string query = "ambulance staging"; + + // Act + var results = CommandBoardTemplateCatalog.Search(query); + + // Assert + results.Should().Contain(template => template.Id == "ems-mass-casualty"); + results.Should().OnlyContain(template => + template.SearchText.Contains("ambulance") && template.SearchText.Contains("staging")); + } + + [Test] + public void CreateDefinition_MatchingDepartmentReferenceData_SeedsEditableRequirements() + { + // Arrange + var template = new CommandBoardTemplate + { + Name = "Example Board", + Description = "Example description", + Timer = true, + TimerMinutes = 15, + Lanes = new List + { + new CommandBoardTemplateLane + { + Name = "Fire Attack", + Description = "Interior operations", + LaneType = CommandNodeType.Group, + SuggestedUnitTypes = new[] { " engine " }, + SuggestedPersonnelRoles = new[] { "firefighter" }, + ForceRequirements = true + } + } + }; + var unitTypes = new[] { new UnitType { UnitTypeId = 10, Type = "Engine" } }; + var personnelRoles = new[] { new PersonnelRole { PersonnelRoleId = 20, Name = "Firefighter" } }; + + // Act + var definition = template.CreateDefinition(unitTypes, personnelRoles); + var assignment = definition.Assignments.Single(); + + // Assert + definition.CommandDefinitionId.Should().Be(0); + definition.DepartmentId.Should().Be(0); + definition.Name.Should().Be("Example Board"); + definition.Timer.Should().BeTrue(); + definition.TimerMinutes.Should().Be(15); + assignment.Name.Should().Be("Fire Attack"); + assignment.LaneType.Should().Be((int)CommandNodeType.Group); + assignment.SortOrder.Should().Be(0); + assignment.RequiredUnitTypes.Should().ContainSingle(item => item.UnitTypeId == 10); + assignment.RequiredRoles.Should().ContainSingle(item => item.PersonnelRoleId == 20); + assignment.ForceRequirements.Should().BeTrue(); + } + + [Test] + public void CreateDefinition_UnmatchedSuggestions_LeavesLaneUsableAndAdvisory() + { + // Arrange + var template = new CommandBoardTemplate + { + Name = "Example Board", + Lanes = new List + { + new CommandBoardTemplateLane + { + Name = "Entry Team", + SuggestedPersonnelRoles = new[] { "Hazmat Technician" }, + ForceRequirements = true + } + } + }; + + // Act + var definition = template.CreateDefinition(null, null); + var assignment = definition.Assignments.Single(); + + // Assert + assignment.RequiredUnitTypes.Should().BeEmpty(); + assignment.RequiredRoles.Should().BeEmpty(); + assignment.ForceRequirements.Should().BeFalse(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/WorkflowTemplateContextBuilderTests.cs b/Tests/Resgrid.Tests/Services/WorkflowTemplateContextBuilderTests.cs index 2a25f3284..51a58fc8c 100644 --- a/Tests/Resgrid.Tests/Services/WorkflowTemplateContextBuilderTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkflowTemplateContextBuilderTests.cs @@ -206,6 +206,37 @@ public async Task DocumentAdded_ShouldIncludeDocumentObject() } } + [TestFixture] + public class when_building_incident_command_variables : with_the_context_builder + { + [Test] + public async Task PublicIncidentNoteAdded_ShouldExposeShareableStatusFields() + { + // Arrange / Act + var ctx = await BuildContext(WorkflowTriggerEventType.PublicIncidentNoteAdded, new IncidentNoteAddedEvent + { + DepartmentId = 1, + CallId = 1001, + IncidentCommandId = "ic-1", + IncidentNoteId = "note-1", + Visibility = (int)IncidentContentVisibility.Public, + NoteType = (int)IncidentNoteType.Containment, + Title = "Containment update", + Body = "Forward progress stopped.", + ContainmentPercent = 40, + CreatedByUserId = "user-1" + }); + + // Assert + var incident = (ScriptObject)ctx["incident"]; + incident["command_id"].Should().Be("ic-1"); + incident["note_id"].Should().Be("note-1"); + incident["body"].Should().Be("Forward progress stopped."); + incident["containment_percent"].Should().Be(40m); + incident["visibility"].Should().Be((int)IncidentContentVisibility.Public); + } + } + [TestFixture] public class when_building_group_variables : with_the_context_builder { diff --git a/Tests/Resgrid.Tests/Web/Services/ConnectControllerSsoTests.cs b/Tests/Resgrid.Tests/Web/Services/ConnectControllerSsoTests.cs new file mode 100644 index 000000000..1acfb5655 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/ConnectControllerSsoTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Controllers.v4; + +namespace Resgrid.Tests.Web.Services +{ + [TestFixture] + public class ConnectControllerSsoTests + { + private Mock _departmentsService; + private Mock _systemAuditsService; + private Mock _ssoService; + private Mock _encryptionService; + private Mock _cacheProvider; + private ConnectController _controller; + + [SetUp] + public void SetUp() + { + var department = new Department { DepartmentId = 42, Code = "DEPT" }; + _departmentsService = new Mock(); + _departmentsService.Setup(x => x.GetDepartmentByNameAsync("DEPT")).ReturnsAsync(department); + _systemAuditsService = new Mock(); + _systemAuditsService + .Setup(x => x.SaveSystemAuditAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((SystemAudit audit, CancellationToken _) => audit); + _ssoService = new Mock(); + _encryptionService = new Mock(); + _cacheProvider = new Mock(); + + var httpContext = new DefaultHttpContext(); + httpContext.Connection.RemoteIpAddress = IPAddress.Loopback; + + _controller = new ConnectController( + Mock.Of(), + Mock.Of(), + _departmentsService.Object, + null, + null, + _systemAuditsService.Object, + _ssoService.Object, + _encryptionService.Object, + _cacheProvider.Object) + { + ControllerContext = new ControllerContext { HttpContext = httpContext } + }; + } + + [Test] + public async Task SamlMobileCallback_StoresAssertionAndRedirectsWithRelayInsteadOfAssertion() + { + // Arrange + _encryptionService.Setup(x => x.Encrypt("raw-saml-response")).Returns("encrypted-saml-response"); + _encryptionService.Setup(x => x.Encrypt("42:DEPT")).Returns("encrypted-department-token"); + _cacheProvider + .Setup(x => x.SetStringAsync( + It.Is(key => key.StartsWith("Sso:SamlRelay:", StringComparison.Ordinal)), + "encrypted-saml-response", + It.Is(expiration => expiration == TimeSpan.FromMinutes(5)))) + .ReturnsAsync(true); + + // Act + var result = await _controller.SamlMobileCallback( + departmentToken: null, departmentCode: "DEPT", SAMLResponse: "raw-saml-response", CancellationToken.None); + + // Assert + var redirect = result.Should().BeOfType().Subject; + var decodedLocation = Uri.UnescapeDataString(redirect.Url); + decodedLocation.Should().Contain("saml_response=saml-relay:"); + decodedLocation.Should().NotContain("raw-saml-response"); + decodedLocation.Should().Contain("department_token=encrypted-department-token"); + } + + [Test] + public async Task ExternalToken_ConsumesRelayOnceAndValidatesStoredAssertion() + { + // Arrange + var relayId = new string('A', 64); + var relayToken = $"saml-relay:{relayId}"; + _cacheProvider + .Setup(x => x.IncrementAsync($"Sso:SamlRelayUse:{relayId}", It.IsAny())) + .ReturnsAsync(1); + _cacheProvider + .Setup(x => x.GetStringAsync($"Sso:SamlRelay:{relayId}")) + .ReturnsAsync("encrypted-saml-response"); + _cacheProvider + .Setup(x => x.RemoveAsync($"Sso:SamlRelay:{relayId}")) + .ReturnsAsync(true); + _encryptionService.Setup(x => x.Decrypt("encrypted-saml-response")).Returns("raw-saml-response"); + _ssoService + .Setup(x => x.ValidateExternalTokenAsync( + 42, SsoProviderType.Saml2, "raw-saml-response", "DEPT", It.IsAny())) + .ReturnsAsync((System.Security.Claims.ClaimsPrincipal)null); + + // Act + var result = await _controller.ExternalToken( + "saml2", relayToken, "DEPT", null, null, null, CancellationToken.None); + + // Assert + result.Should().BeOfType(); + _ssoService.Verify(x => x.ValidateExternalTokenAsync( + 42, SsoProviderType.Saml2, "raw-saml-response", "DEPT", It.IsAny()), Times.Once); + _cacheProvider.Verify(x => x.RemoveAsync($"Sso:SamlRelay:{relayId}"), Times.Once); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs index 2735d6b56..cd2dfa210 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs @@ -7,7 +7,9 @@ using OpenIddict.Abstractions; using OpenIddict.Server.AspNetCore; using Resgrid.Config; +using Resgrid.Framework; using Resgrid.Model; +using Resgrid.Model.Providers; using Resgrid.Model.Services; using Resgrid.Web.Services.Helpers; using Resgrid.Web.Services.Models.v4.Sso; @@ -36,6 +38,9 @@ namespace Resgrid.Web.Services.Controllers.v4 [ApiExplorerSettings(GroupName = "v4")] public class ConnectController : ControllerBase { + private const string SamlRelayTokenPrefix = "saml-relay:"; + private static readonly TimeSpan SamlRelayLifetime = TimeSpan.FromMinutes(5); + private readonly SignInManager _signInManager; private readonly UserManager _userManager; private readonly IUsersService _usersService; @@ -44,6 +49,7 @@ public class ConnectController : ControllerBase private readonly ISystemAuditsService _systemAuditsService; private readonly IDepartmentSsoService _departmentSsoService; private readonly IEncryptionService _encryptionService; + private readonly ICacheProvider _cacheProvider; public ConnectController( IUsersService usersService, @@ -53,7 +59,8 @@ public ConnectController( UserManager userManager, ISystemAuditsService systemAuditsService, IDepartmentSsoService departmentSsoService, - IEncryptionService encryptionService + IEncryptionService encryptionService, + ICacheProvider cacheProvider ) { _usersService = usersService; @@ -64,6 +71,7 @@ IEncryptionService encryptionService _systemAuditsService = systemAuditsService; _departmentSsoService = departmentSsoService; _encryptionService = encryptionService; + _cacheProvider = cacheProvider; } /// @@ -575,7 +583,7 @@ public async Task> GetSsoConfigForUse /// /// Exchanges an external SSO token (OIDC id_token or base64-encoded SAMLResponse) for a /// Resgrid access token. Supports grant_type=external_token with fields: - /// provider (saml2|oidc), external_token, department_code, scope (optional), + /// provider (saml2|oidc), external_token, department_code or department_token, scope (optional), /// totp_code (required when the user has Resgrid 2FA enrolled). /// SSO authentication does NOT bypass Resgrid's own Two-Factor Authentication. /// When the user has 2FA enabled in Resgrid, a valid totp_code must be supplied @@ -591,6 +599,7 @@ public async Task ExternalToken( [FromForm] string provider, [FromForm] string external_token, [FromForm] string department_code, + [FromForm] string department_token, [FromForm] string scope, [FromForm] string totp_code, CancellationToken cancellationToken) @@ -599,34 +608,45 @@ public async Task ExternalToken( { System = (int)SystemAuditSystems.Api, Type = (int)SystemAuditTypes.SsoLogin, - Username = department_code, + Username = department_code ?? "encrypted-department-token", Successful = false, IpAddress = IpAddressHelper.GetRequestIP(Request, true), ServerName = Environment.MachineName, Data = $"ExternalToken provider={provider}, {Request.Headers["User-Agent"]}" }; - if (string.IsNullOrWhiteSpace(provider) || string.IsNullOrWhiteSpace(external_token) || string.IsNullOrWhiteSpace(department_code)) + if (string.IsNullOrWhiteSpace(provider) || string.IsNullOrWhiteSpace(external_token) || + (string.IsNullOrWhiteSpace(department_code) && string.IsNullOrWhiteSpace(department_token))) { await _systemAuditsService.SaveSystemAuditAsync(audit); - return BadRequest(new { error = "invalid_request", error_description = "provider, external_token, and department_code are required." }); + return BadRequest(new { error = "invalid_request", error_description = "provider, external_token, and either department_code or department_token are required." }); } - // Resolve department by department code - var department = await _departmentsService.GetDepartmentByNameAsync(department_code); + var department = await ResolveDepartmentAsync(department_token, department_code); if (department == null) { await _systemAuditsService.SaveSystemAuditAsync(audit); - return Unauthorized(new { error = "invalid_grant", error_description = "Invalid department_code." }); + return Unauthorized(new { error = "invalid_grant", error_description = "Invalid department identifier." }); } // Parse provider type - if (!Enum.TryParse(provider, ignoreCase: true, out var providerType)) + if (!Enum.TryParse(provider, ignoreCase: true, out var providerType) || !Enum.IsDefined(providerType)) { await _systemAuditsService.SaveSystemAuditAsync(audit); return BadRequest(new { error = "invalid_request", error_description = "provider must be 'saml2' or 'oidc'." }); } + if (providerType == SsoProviderType.Saml2 && external_token.StartsWith(SamlRelayTokenPrefix, StringComparison.Ordinal)) + { + external_token = await ConsumeSamlRelayAsync(external_token); + if (string.IsNullOrWhiteSpace(external_token)) + { + audit.Type = (int)SystemAuditTypes.SsoLoginFailed; + await _systemAuditsService.SaveSystemAuditAsync(audit); + return Unauthorized(new { error = "invalid_grant", error_description = "The SAML relay token is invalid, expired, or has already been used." }); + } + } + // Validate the external token against the department's SSO config var externalPrincipal = await _departmentSsoService.ValidateExternalTokenAsync( department.DepartmentId, providerType, external_token, department.Code, cancellationToken); @@ -756,18 +776,18 @@ public async Task ExternalToken( /// POST /api/v4/connect/saml-mobile-callback?departmentCode=DEPT /// [HttpPost("saml-mobile-callback")] - [HttpGet("saml-mobile-callback")] [AllowAnonymous] [ProducesResponseType(StatusCodes.Status302Found)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] public async Task SamlMobileCallback( [FromQuery] string departmentToken, [FromQuery] string departmentCode, [FromForm] string SAMLResponse, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(SAMLResponse)) - return BadRequest(new { error = "invalid_request", error_description = "SAMLResponse is required." }); + if (string.IsNullOrWhiteSpace(SAMLResponse) || SAMLResponse.Length > 2_800_000) + return BadRequest(new { error = "invalid_request", error_description = "SAMLResponse is required and must be within the supported size limit." }); if (string.IsNullOrWhiteSpace(departmentToken) && string.IsNullOrWhiteSpace(departmentCode)) return BadRequest(new { error = "invalid_request", error_description = "departmentToken or departmentCode query parameter is required." }); @@ -776,9 +796,18 @@ public async Task SamlMobileCallback( if (department == null) return BadRequest(new { error = "invalid_request", error_description = "Unknown or invalid department token." }); - var encodedResponse = Uri.EscapeDataString(SAMLResponse); - // Pass the encrypted token to the deep link so the mobile app can use it with external-token - var encodedToken = Uri.EscapeDataString(departmentToken ?? Uri.EscapeDataString(department.Code)); + // Keep the assertion out of the custom-scheme URL. Store it encrypted for five minutes + // and hand the app a single-use, cryptographically random relay value instead. + var relayId = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + var stored = await _cacheProvider.SetStringAsync( + GetSamlRelayCacheKey(relayId), _encryptionService.Encrypt(SAMLResponse), SamlRelayLifetime); + if (!stored) + return StatusCode(StatusCodes.Status503ServiceUnavailable, + new { error = "temporarily_unavailable", error_description = "SAML login relay is temporarily unavailable." }); + + var encodedResponse = Uri.EscapeDataString($"{SamlRelayTokenPrefix}{relayId}"); + var callbackToken = _encryptionService.Encrypt($"{department.DepartmentId}:{department.Code}"); + var encodedToken = Uri.EscapeDataString(callbackToken); var deepLink = $"resgrid://auth/callback?saml_response={encodedResponse}&department_token={encodedToken}"; return Redirect(deepLink); @@ -795,18 +824,14 @@ public async Task SamlMobileCallback( { try { - // First pass: decrypt using just the system key to obtain the plain payload var plain = _encryptionService.Decrypt(departmentToken); var parts = plain.Split(':'); if (parts.Length >= 2 && int.TryParse(parts[0], out var deptId)) { var deptCode = string.Join(":", parts.Skip(1)); - // Second pass: verify with the department-specific key - var verify = _encryptionService.DecryptForDepartment( - _encryptionService.EncryptForDepartment(plain, deptId, deptCode), - deptId, deptCode); - if (verify == plain) - return await _departmentsService.GetDepartmentByIdAsync(deptId); + var department = await _departmentsService.GetDepartmentByIdAsync(deptId); + if (department != null && string.Equals(department.Code, deptCode, StringComparison.Ordinal)) + return department; } } catch @@ -821,6 +846,41 @@ public async Task SamlMobileCallback( return null; } + private async Task ConsumeSamlRelayAsync(string relayToken) + { + if (relayToken.Length != SamlRelayTokenPrefix.Length + 64) + return null; + + var relayId = relayToken[SamlRelayTokenPrefix.Length..]; + if (relayId.Any(character => !Uri.IsHexDigit(character))) + return null; + + // Increment is atomic in Redis. Only the first exchange is allowed to read the assertion, + // including when callback and token requests land on different API instances. + var useCount = await _cacheProvider.IncrementAsync(GetSamlRelayUseCacheKey(relayId), SamlRelayLifetime); + if (useCount != 1) + return null; + + var encryptedResponse = await _cacheProvider.GetStringAsync(GetSamlRelayCacheKey(relayId)); + await _cacheProvider.RemoveAsync(GetSamlRelayCacheKey(relayId)); + if (string.IsNullOrWhiteSpace(encryptedResponse)) + return null; + + try + { + return _encryptionService.Decrypt(encryptedResponse); + } + catch (Exception ex) + { + Logging.LogException(ex); + return null; + } + } + + private static string GetSamlRelayCacheKey(string relayId) => $"Sso:SamlRelay:{relayId}"; + + private static string GetSamlRelayUseCacheKey(string relayId) => $"Sso:SamlRelayUse:{relayId}"; + private IEnumerable GetDestinations(Claim claim, ClaimsPrincipal principal) { // Note: by default, claims are NOT automatically included in the access and identity tokens. // To allow OpenIddict to serialize them, you must attach them a destination, that specifies diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs index 8682dcd40..69c558eb2 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs @@ -6,6 +6,9 @@ using Resgrid.Providers.Claims; using Resgrid.Web.Services.Filters; using Resgrid.Web.Services.Helpers; +using System; +using System.IO; +using System.Net.Mime; using System.Threading; using System.Threading.Tasks; using ICModels = Resgrid.Web.Services.Models.v4.IncidentCommand; @@ -164,6 +167,223 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService) return result; } + /// Updates the command-post location used by maps and incident weather. + [HttpPut("UpdateCommandPost")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageCommand)] + public async Task> UpdateCommandPost([FromBody] ICModels.UpdateCommandPostInput input) + { + if (input == null || string.IsNullOrWhiteSpace(input.IncidentCommandId)) + return BadRequest(); + + try + { + var command = await _incidentCommandService.UpdateCommandPostAsync(DepartmentId, input.IncidentCommandId, input.Latitude, input.Longitude, UserId, CancellationToken.None); + var result = new ICModels.IncidentCommandResult + { + Data = command, + PageSize = command == null ? 0 : 1, + Status = command == null ? ResponseHelper.NotFound : ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + + #region Notes and documents + + /// Adds an internal or public operational status note to the incident. + [HttpPost("AddNote")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageNotes)] + public async Task> AddNote([FromBody] ICModels.AddIncidentNoteInput input) + { + if (input == null || string.IsNullOrWhiteSpace(input.IncidentCommandId) || string.IsNullOrWhiteSpace(input.Body)) + return BadRequest(); + + if (input.Visibility == (int)IncidentContentVisibility.Public && !await HasCapabilityAsync(input.IncidentCommandId, IncidentCapabilities.ManagePublicInformation)) + return Forbid(); + + try + { + var note = await _incidentCommandService.AddNoteAsync(new IncidentNote + { + IncidentCommandId = input.IncidentCommandId, + DepartmentId = DepartmentId, + NoteType = input.NoteType, + Visibility = input.Visibility, + Title = input.Title, + Body = input.Body, + ContainmentPercent = input.ContainmentPercent + }, UserId, CancellationToken.None); + + var result = new ICModels.IncidentNoteResult + { + Data = note, + PageSize = note == null ? 0 : 1, + Status = note == null ? ResponseHelper.NotFound : ResponseHelper.Created + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpGet("GetNotes/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetNotes(int callId) + { + var notes = await _incidentCommandService.GetNotesForCallAsync(DepartmentId, callId); + var result = new ICModels.IncidentNotesResult { Data = notes, PageSize = notes.Count, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpDelete("RemoveNote/{incidentNoteId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> RemoveNote(string incidentNoteId) + { + var removed = await _incidentCommandService.RemoveNoteAsync(DepartmentId, incidentNoteId, UserId, CancellationToken.None); + var result = new ICModels.IncidentCommandActionResult { Data = removed, Status = removed ? ResponseHelper.Success : ResponseHelper.NotFound }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Uploads an incident-level internal or public file using multipart/form-data. + [HttpPost("AddAttachment")] + [Consumes("multipart/form-data")] + [RequestSizeLimit(26_214_400)] + [RequestFormLimits(MultipartBodyLengthLimit = 26_214_400)] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageDocuments)] + public async Task> AddAttachment([FromForm] ICModels.AddIncidentAttachmentInput input, CancellationToken cancellationToken) + { + if (input == null || string.IsNullOrWhiteSpace(input.IncidentCommandId) || input.File == null || input.File.Length == 0) + return BadRequest(); + + if (input.Visibility == (int)IncidentContentVisibility.Public && !await HasCapabilityAsync(input.IncidentCommandId, IncidentCapabilities.ManagePublicInformation)) + return Forbid(); + + try + { + await using var stream = new MemoryStream(); + await input.File.CopyToAsync(stream, cancellationToken); + var attachment = await _incidentCommandService.AddAttachmentAsync(new IncidentAttachment + { + IncidentCommandId = input.IncidentCommandId, + DepartmentId = DepartmentId, + Visibility = input.Visibility, + FileName = input.File.FileName, + ContentType = input.File.ContentType, + Description = input.Description, + Data = stream.ToArray() + }, UserId, cancellationToken); + + var result = new ICModels.IncidentAttachmentResult + { + Data = attachment, + PageSize = attachment == null ? 0 : 1, + Status = attachment == null ? ResponseHelper.NotFound : ResponseHelper.Created + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpGet("GetAttachments/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetAttachments(int callId) + { + var attachments = await _incidentCommandService.GetAttachmentsForCallAsync(DepartmentId, callId); + var result = new ICModels.IncidentAttachmentsResult { Data = attachments, PageSize = attachments.Count, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpGet("DownloadAttachment/{incidentAttachmentId}")] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task DownloadAttachment(string incidentAttachmentId) + { + var attachment = await _incidentCommandService.GetAttachmentAsync(DepartmentId, incidentAttachmentId); + if (attachment?.Data == null) + return NotFound(); + + return File(attachment.Data, attachment.ContentType ?? MediaTypeNames.Application.Octet, attachment.FileName); + } + + [HttpDelete("RemoveAttachment/{incidentAttachmentId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> RemoveAttachment(string incidentAttachmentId) + { + var removed = await _incidentCommandService.RemoveAttachmentAsync(DepartmentId, incidentAttachmentId, UserId, CancellationToken.None); + var result = new ICModels.IncidentCommandActionResult { Data = removed, Status = removed ? ResponseHelper.Success : ResponseHelper.NotFound }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + #endregion Notes and documents + + #region Public sharing and weather + + [HttpPost("EnablePublicSharing/{incidentCommandId}")] + [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManagePublicInformation)] + public async Task> EnablePublicSharing(string incidentCommandId) + { + var command = await _incidentCommandService.EnablePublicSharingAsync(DepartmentId, incidentCommandId, UserId, CancellationToken.None); + var result = new ICModels.IncidentCommandResult { Data = command, PageSize = command == null ? 0 : 1, Status = command == null ? ResponseHelper.NotFound : ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpPost("DisablePublicSharing/{incidentCommandId}")] + [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManagePublicInformation)] + public async Task> DisablePublicSharing(string incidentCommandId) + { + var command = await _incidentCommandService.DisablePublicSharingAsync(DepartmentId, incidentCommandId, UserId, CancellationToken.None); + var result = new ICModels.IncidentCommandResult { Data = command, PageSize = command == null ? 0 : 1, Status = command == null ? ResponseHelper.NotFound : ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpGet("GetWeather/{callId}")] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetWeather(int callId, CancellationToken cancellationToken) + { + try + { + var weather = await _incidentCommandService.GetWeatherForIncidentAsync(DepartmentId, callId, cancellationToken); + var result = new ICModels.IncidentWeatherResult { Data = weather, PageSize = weather == null ? 0 : 1, Status = weather == null ? ResponseHelper.NotFound : ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + #endregion Public sharing and weather + /// Gets the personnel accountability / PAR status (Green/Warning/Critical) for the incident. [HttpGet("GetAccountability/{callId}")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -523,5 +743,15 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService) } #endregion Timeline + + private async Task HasCapabilityAsync(string incidentCommandId, IncidentCapabilities required) + { + var command = await _incidentCommandService.GetCommandByIdAsync(incidentCommandId); + if (command == null || command.DepartmentId != DepartmentId) + return false; + + var capabilities = await _incidentCommandService.GetCapabilitiesForUserAsync(DepartmentId, command.CallId, UserId); + return (capabilities & required) == required; + } } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/PublicIncidentController.cs b/Web/Resgrid.Web.Services/Controllers/v4/PublicIncidentController.cs new file mode 100644 index 000000000..46adfaffe --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/PublicIncidentController.cs @@ -0,0 +1,62 @@ +using System.Net.Mime; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.IncidentCommand; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Anonymous, token-scoped public incident status feed. It exposes only records explicitly marked Public and only + /// while the Incident Commander/PIO has public sharing enabled. Disabling sharing revokes the token immediately. + /// + [AllowAnonymous] + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class PublicIncidentController : ControllerBase + { + private readonly IIncidentCommandService _incidentCommandService; + + public PublicIncidentController(IIncidentCommandService incidentCommandService) + { + _incidentCommandService = incidentCommandService; + } + + [HttpGet("Get/{publicShareToken}")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Get(string publicShareToken) + { + var information = await _incidentCommandService.GetPublicInformationAsync(publicShareToken); + if (information == null) + return NotFound(); + + var result = new PublicIncidentInformationResult + { + Data = information, + PageSize = 1, + Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpGet("Download/{publicShareToken}/{incidentAttachmentId}")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Download(string publicShareToken, string incidentAttachmentId) + { + var attachment = await _incidentCommandService.GetPublicAttachmentAsync(publicShareToken, incidentAttachmentId); + if (attachment?.Data == null) + return NotFound(); + + return File(attachment.Data, attachment.ContentType ?? MediaTypeNames.Application.Octet, attachment.FileName); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/SsoAdminController.cs b/Web/Resgrid.Web.Services/Controllers/v4/SsoAdminController.cs index c82a4085b..d7ff1c98d 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/SsoAdminController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/SsoAdminController.cs @@ -126,9 +126,13 @@ public async Task> CreateSsoConfig( if (!ModelState.IsValid) return BadRequest(ModelState); if (!await IsAdminAsync()) return Forbid(); - if (!Enum.TryParse(input.ProviderType, ignoreCase: true, out var providerType)) + if (!Enum.TryParse(input.ProviderType, ignoreCase: true, out var providerType) || !Enum.IsDefined(providerType)) return BadRequest(new { error = "Invalid providerType. Must be 'saml2' or 'oidc'." }); + var validationError = ValidateSsoConfiguration(input, providerType, existing: null); + if (validationError != null) + return BadRequest(new { error = validationError }); + // Enforce one config per provider type per department var existing = await _ssoService.GetSsoConfigForDepartmentAsync(DepartmentId, providerType, cancellationToken); if (existing != null) @@ -175,6 +179,14 @@ public async Task> UpdateSsoConfig( return NotFound(notFound); } + if (!Enum.TryParse(input.ProviderType, ignoreCase: true, out var providerType) || + !Enum.IsDefined(providerType) || providerType != (SsoProviderType)config.SsoProviderType) + return BadRequest(new { error = "providerType must match the existing SSO configuration." }); + + var validationError = ValidateSsoConfiguration(input, providerType, config); + if (validationError != null) + return BadRequest(new { error = validationError }); + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); // Update non-secret fields @@ -233,7 +245,7 @@ public async Task> DeleteSsoConfig( { if (!await IsAdminAsync()) return Forbid(); - if (!Enum.TryParse(providerType, ignoreCase: true, out var provider)) + if (!Enum.TryParse(providerType, ignoreCase: true, out var provider) || !Enum.IsDefined(provider)) return BadRequest(new { error = "Invalid providerType. Must be 'saml2' or 'oidc'." }); var success = await _ssoService.DeleteSsoConfigAsync(DepartmentId, provider, cancellationToken); @@ -272,7 +284,7 @@ public async Task> RotateScimToken( { if (!await IsAdminAsync()) return Forbid(); - if (!Enum.TryParse(providerType, ignoreCase: true, out var provider)) + if (!Enum.TryParse(providerType, ignoreCase: true, out var provider) || !Enum.IsDefined(provider)) return BadRequest(new { error = "Invalid providerType. Must be 'saml2' or 'oidc'." }); var config = await _ssoService.GetSsoConfigForDepartmentAsync(DepartmentId, provider, cancellationToken); @@ -411,7 +423,7 @@ public async Task> TestScimConnection( { if (!await IsAdminAsync()) return Forbid(); - if (!Enum.TryParse(providerType, ignoreCase: true, out var provider)) + if (!Enum.TryParse(providerType, ignoreCase: true, out var provider) || !Enum.IsDefined(provider)) return BadRequest(new { error = "Invalid providerType." }); var config = await _ssoService.GetSsoConfigForDepartmentAsync(DepartmentId, provider, cancellationToken); @@ -440,6 +452,34 @@ private async Task IsAdminAsync() return department != null && department.IsUserAnAdmin(UserId); } + private static string ValidateSsoConfiguration(SaveSsoConfigInput input, SsoProviderType providerType, DepartmentSsoConfig existing) + { + if (providerType == SsoProviderType.Oidc) + { + var clientId = input.ClientId ?? existing?.ClientId; + var authorityValue = input.Authority ?? existing?.Authority; + if (string.IsNullOrWhiteSpace(clientId)) + return "OIDC clientId is required."; + + if (!Uri.TryCreate(authorityValue, UriKind.Absolute, out var authority) || authority.Scheme != Uri.UriSchemeHttps) + return "OIDC authority must be a valid HTTPS URL."; + + return null; + } + + if (string.IsNullOrWhiteSpace(input.EntityId ?? existing?.EntityId)) + return "SAML entityId is required."; + + var assertionConsumerServiceUrl = input.AssertionConsumerServiceUrl ?? existing?.AssertionConsumerServiceUrl; + if (!Uri.TryCreate(assertionConsumerServiceUrl, UriKind.Absolute, out var acsUri) || acsUri.Scheme != Uri.UriSchemeHttps) + return "SAML assertionConsumerServiceUrl must be a valid HTTPS URL."; + + if (string.IsNullOrWhiteSpace(input.IdpCertificate) && string.IsNullOrWhiteSpace(existing?.EncryptedIdpCertificate)) + return "An IdP signing certificate is required to validate SAML assertions."; + + return null; + } + private static DepartmentSsoConfig BuildConfigFromInput( SaveSsoConfigInput input, SsoProviderType providerType, diff --git a/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs b/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs index f26b9a9bb..b5f7571a5 100644 --- a/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using Microsoft.AspNetCore.Http; namespace Resgrid.Web.Services.Models.v4.IncidentCommand { @@ -24,6 +25,31 @@ public class UpdateActionPlanInput public string ActionPlan { get; set; } } + public class UpdateCommandPostInput + { + public string IncidentCommandId { get; set; } + public string Latitude { get; set; } + public string Longitude { get; set; } + } + + public class AddIncidentNoteInput + { + public string IncidentCommandId { get; set; } + public int NoteType { get; set; } + public int Visibility { get; set; } + public string Title { get; set; } + public string Body { get; set; } + public decimal? ContainmentPercent { get; set; } + } + + public class AddIncidentAttachmentInput + { + public string IncidentCommandId { get; set; } + public int Visibility { get; set; } + public string Description { get; set; } + public IFormFile File { get; set; } + } + /// Input to move a resource assignment to a different node. public class MoveResourceInput { @@ -78,6 +104,36 @@ public class IncidentMapAnnotationResult : StandardApiResponseV4Base public Resgrid.Model.IncidentMapAnnotation Data { get; set; } } + public class IncidentNoteResult : StandardApiResponseV4Base + { + public Resgrid.Model.IncidentNote Data { get; set; } + } + + public class IncidentNotesResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class IncidentAttachmentResult : StandardApiResponseV4Base + { + public Resgrid.Model.IncidentAttachment Data { get; set; } + } + + public class IncidentAttachmentsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class IncidentWeatherResult : StandardApiResponseV4Base + { + public Resgrid.Model.IncidentWeather Data { get; set; } + } + + public class PublicIncidentInformationResult : StandardApiResponseV4Base + { + public Resgrid.Model.IncidentPublicInformation Data { get; set; } + } + public class CommandTimelineResult : StandardApiResponseV4Base { public List Data { get; set; } = new List(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/CommandController.cs b/Web/Resgrid.Web/Areas/User/Controllers/CommandController.cs index fece184ae..4068b3a28 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/CommandController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/CommandController.cs @@ -11,6 +11,7 @@ using Resgrid.Web.Areas.User.Models.Command; using Microsoft.AspNetCore.Authorization; using Resgrid.Providers.Claims; +using Resgrid.Model.CommandBoards; namespace Resgrid.Web.Areas.User.Controllers { @@ -53,13 +54,27 @@ public async Task Index() [HttpGet] [Authorize(Policy = ResgridResources.Command_Create)] - public async Task New() + public IActionResult Templates() + { + return View(CommandBoardTemplateCatalog.All); + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Command_Create)] + public async Task New(string templateId = null) { var model = new NewCommandView(); model.Command = new CommandDefinition(); await PopulateSupportingDataAsync(model); + if (!string.IsNullOrWhiteSpace(templateId)) + { + var template = CommandBoardTemplateCatalog.GetById(templateId); + if (template != null) + model.Command = template.CreateDefinition(model.UnitTypes, model.PersonnelRoles); + } + return View(model); } @@ -68,6 +83,11 @@ public async Task New() [Authorize(Policy = ResgridResources.Command_Create)] public async Task New(NewCommandView model, IFormCollection form, CancellationToken cancellationToken) { + if (model.Command == null) + model.Command = new CommandDefinition(); + + model.Command.Assignments = ParseAssignmentsFromForm(form); + if (string.IsNullOrWhiteSpace(model.Command?.Name)) ModelState.AddModelError("Command.Name", "Command name is required."); @@ -75,7 +95,6 @@ public async Task New(NewCommandView model, IFormCollection form, { model.Command.DepartmentId = DepartmentId; model.Command.CallTypeId = await ResolveCallTypeIdAsync(model.SelectedType); - model.Command.Assignments = ParseAssignmentsFromForm(form); await _commandsService.Save(model.Command, cancellationToken); @@ -109,11 +128,16 @@ public async Task Edit(int commandId) [Authorize(Policy = ResgridResources.Command_Update)] public async Task Edit(NewCommandView model, IFormCollection form, CancellationToken cancellationToken) { + if (model.Command == null) + model.Command = new CommandDefinition(); + var command = await _commandsService.GetCommandByIdAsync(model.Command.CommandDefinitionId); if (command == null || command.DepartmentId != DepartmentId) return RedirectToAction("Index"); + var postedAssignments = ParseAssignmentsFromForm(form); + if (string.IsNullOrWhiteSpace(model.Command?.Name)) ModelState.AddModelError("Command.Name", "Command name is required."); @@ -124,14 +148,15 @@ public async Task Edit(NewCommandView model, IFormCollection form command.CallTypeId = await ResolveCallTypeIdAsync(model.SelectedType); command.Timer = model.Command.Timer; command.TimerMinutes = model.Command.TimerMinutes; - command.Assignments = ParseAssignmentsFromForm(form); + command.Assignments = postedAssignments; await _commandsService.Save(command, cancellationToken); return RedirectToAction("Index"); } - model.Command = command; + model.Command.CommandDefinitionId = command.CommandDefinitionId; + model.Command.Assignments = postedAssignments; await PopulateSupportingDataAsync(model); return View(model); } @@ -235,8 +260,8 @@ private static List ParseAssignmentsFromForm(IFormCollect if (int.TryParse(form[$"assignmentLaneType_{i}"], out var laneType) && Enum.IsDefined(typeof(CommandNodeType), laneType)) assignment.LaneType = laneType; - if (bool.TryParse(form[$"assignmentLock_{i}"], out var forceRequirements)) - assignment.ForceRequirements = forceRequirements; + assignment.ForceRequirements = form[$"assignmentLock_{i}"] + .Any(value => bool.TryParse(value, out var forceRequirements) && forceRequirements); // The form is a full document: absent/empty pickers clear the lane's requirements. assignment.RequiredUnitTypes = ParseIdCsv(form[$"assignmentUnitTypes_{i}"]) diff --git a/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs b/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs index f5e334d1f..b07361c53 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs @@ -650,7 +650,7 @@ public async Task Sso(CancellationToken cancellationToken) // Build an encrypted token carrying departmentId:departmentCode so it can // be passed safely over the public internet without exposing either value. var plainToken = $"{department.DepartmentId}:{department.Code}"; - var encryptedToken = _encryptionService.EncryptForDepartment(plainToken, department.DepartmentId, department.Code); + var encryptedToken = _encryptionService.Encrypt(plainToken); var apiBase = Config.SystemBehaviorConfig.ResgridApiBaseUrl; @@ -692,16 +692,22 @@ public async Task SsoNew(string providerType, CancellationToken c var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); var apiBase = Config.SystemBehaviorConfig.ResgridApiBaseUrl; + var configId = Guid.NewGuid().ToString(); + var plainToken = $"{department.DepartmentId}:{department.Code}"; var model = new SsoConfigEditView { IsNew = true, + DepartmentSsoConfigId = configId, ProviderType = providerType ?? "oidc", + EntityId = string.Equals(providerType, "saml2", StringComparison.OrdinalIgnoreCase) + ? $"{apiBase}{Config.SsoConfig.SamlEntityIdBasePath}{configId}" + : null, IsEnabled = true, AllowLocalLogin = true, ProviderTypes = BuildProviderTypeList(providerType ?? "oidc"), RankList = await BuildRankListAsync(null), - AcsUrl = $"{apiBase}{Config.SsoConfig.SamlAcsPath}?departmentToken={Uri.EscapeDataString(_encryptionService.EncryptForDepartment($"{department.DepartmentId}:{department.Code}", department.DepartmentId, department.Code))}", + AcsUrl = $"{apiBase}{Config.SsoConfig.SamlAcsPath}?departmentToken={Uri.EscapeDataString(_encryptionService.Encrypt(plainToken))}", ApiBaseUrl = apiBase }; @@ -717,16 +723,21 @@ public async Task SsoNew(SsoConfigEditView model, CancellationTok if (!ModelState.IsValid) { - model.ProviderTypes = BuildProviderTypeList(model.ProviderType); - model.RankList = await BuildRankListAsync(model.DefaultRankId); + await PopulateSsoEditViewContextAsync(model); return View("SsoEdit", model); } - if (!System.Enum.TryParse(model.ProviderType, ignoreCase: true, out var providerType)) + if (!System.Enum.TryParse(model.ProviderType, ignoreCase: true, out var providerType) || !System.Enum.IsDefined(providerType)) { ModelState.AddModelError("ProviderType", "Invalid provider type."); - model.ProviderTypes = BuildProviderTypeList(model.ProviderType); - model.RankList = await BuildRankListAsync(model.DefaultRankId); + await PopulateSsoEditViewContextAsync(model); + return View("SsoEdit", model); + } + + ValidateSsoProviderConfiguration(model, providerType, hasStoredIdpCertificate: false); + if (!ModelState.IsValid) + { + await PopulateSsoEditViewContextAsync(model); return View("SsoEdit", model); } @@ -734,15 +745,19 @@ public async Task SsoNew(SsoConfigEditView model, CancellationTok if (existing != null) { ModelState.AddModelError("", $"An SSO configuration for {model.ProviderType.ToUpperInvariant()} already exists. Use Edit to modify it."); - model.ProviderTypes = BuildProviderTypeList(model.ProviderType); - model.RankList = await BuildRankListAsync(model.DefaultRankId); + await PopulateSsoEditViewContextAsync(model); return View("SsoEdit", model); } var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + var configId = Guid.TryParse(model.DepartmentSsoConfigId, out var parsedConfigId) + ? parsedConfigId.ToString() + : Guid.NewGuid().ToString(); + var apiBase = Config.SystemBehaviorConfig.ResgridApiBaseUrl; + var encryptedDepartmentToken = _encryptionService.Encrypt($"{department.DepartmentId}:{department.Code}"); var config = new DepartmentSsoConfig { - DepartmentSsoConfigId = Guid.NewGuid().ToString(), + DepartmentSsoConfigId = configId, DepartmentId = DepartmentId, SsoProviderType = (int)providerType, IsEnabled = model.IsEnabled, @@ -750,8 +765,12 @@ public async Task SsoNew(SsoConfigEditView model, CancellationTok EncryptedClientSecret = model.ClientSecret, Authority = model.Authority, MetadataUrl = model.MetadataUrl, - EntityId = model.EntityId, - AssertionConsumerServiceUrl = model.AssertionConsumerServiceUrl, + EntityId = providerType == SsoProviderType.Saml2 && string.IsNullOrWhiteSpace(model.EntityId) + ? $"{apiBase}{Config.SsoConfig.SamlEntityIdBasePath}{configId}" + : model.EntityId, + AssertionConsumerServiceUrl = providerType == SsoProviderType.Saml2 && string.IsNullOrWhiteSpace(model.AssertionConsumerServiceUrl) + ? $"{apiBase}{Config.SsoConfig.SamlAcsPath}?departmentToken={Uri.EscapeDataString(encryptedDepartmentToken)}" + : model.AssertionConsumerServiceUrl, EncryptedIdpCertificate = model.IdpCertificate, EncryptedSigningCertificate = model.SigningCertificate, AttributeMappingJson = model.AttributeMappingJson, @@ -805,7 +824,7 @@ public async Task SsoEdit(string id, CancellationToken cancellati HasSigningCertificate = !string.IsNullOrWhiteSpace(config.EncryptedSigningCertificate), ProviderTypes = BuildProviderTypeList(((SsoProviderType)config.SsoProviderType).ToString().ToLowerInvariant()), RankList = await BuildRankListAsync(config.DefaultRankId), - AcsUrl = $"{apiBase}{Config.SsoConfig.SamlAcsPath}?departmentToken={Uri.EscapeDataString(_encryptionService.EncryptForDepartment($"{department.DepartmentId}:{department.Code}", department.DepartmentId, department.Code))}", + AcsUrl = $"{apiBase}{Config.SsoConfig.SamlAcsPath}?departmentToken={Uri.EscapeDataString(_encryptionService.Encrypt($"{department.DepartmentId}:{department.Code}"))}", ApiBaseUrl = apiBase }; @@ -821,8 +840,7 @@ public async Task SsoEdit(SsoConfigEditView model, CancellationTo if (!ModelState.IsValid) { - model.ProviderTypes = BuildProviderTypeList(model.ProviderType); - model.RankList = await BuildRankListAsync(model.DefaultRankId); + await PopulateSsoEditViewContextAsync(model); return View("SsoEdit", model); } @@ -831,6 +849,17 @@ public async Task SsoEdit(SsoConfigEditView model, CancellationTo if (config == null) return NotFound(); + var providerType = (SsoProviderType)config.SsoProviderType; + ValidateSsoProviderConfiguration(model, providerType, !string.IsNullOrWhiteSpace(config.EncryptedIdpCertificate)); + if (!ModelState.IsValid) + { + model.HasClientSecret = !string.IsNullOrWhiteSpace(config.EncryptedClientSecret); + model.HasIdpCertificate = !string.IsNullOrWhiteSpace(config.EncryptedIdpCertificate); + model.HasSigningCertificate = !string.IsNullOrWhiteSpace(config.EncryptedSigningCertificate); + await PopulateSsoEditViewContextAsync(model); + return View("SsoEdit", model); + } + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); config.IsEnabled = model.IsEnabled; @@ -931,7 +960,7 @@ public async Task GenerateScimTokenFromSso(string id, Cancellatio var configList = configs?.ToList() ?? new System.Collections.Generic.List(); var plainToken = $"{department.DepartmentId}:{department.Code}"; - var encryptedToken = _encryptionService.EncryptForDepartment(plainToken, department.DepartmentId, department.Code); + var encryptedToken = _encryptionService.Encrypt(plainToken); var apiBase = Config.SystemBehaviorConfig.ResgridApiBaseUrl; var model = new SsoIndexView @@ -978,8 +1007,7 @@ public async Task ScimSetup(string id, CancellationToken cancella return NotFound(); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); - var encryptedToken = _encryptionService.EncryptForDepartment( - $"{department.DepartmentId}:{department.Code}", department.DepartmentId, department.Code); + var encryptedToken = _encryptionService.Encrypt($"{department.DepartmentId}:{department.Code}"); var model = new ScimSetupView { @@ -1024,8 +1052,7 @@ public async Task RotateScimToken(string id, CancellationToken ca await _ssoService.SaveSsoConfigAsync(config, department.Code, cancellationToken); - var encryptedToken = _encryptionService.EncryptForDepartment( - $"{department.DepartmentId}:{department.Code}", department.DepartmentId, department.Code); + var encryptedToken = _encryptionService.Encrypt($"{department.DepartmentId}:{department.Code}"); var model = new ScimSetupView { @@ -1151,6 +1178,33 @@ private static SelectList BuildProviderTypeList(string selected) => new { Id = "saml2", Name = "SAML 2.0 ๏ฟฝ Most enterprise / government IdPs" } }, "Id", "Name", selected); + private void ValidateSsoProviderConfiguration(SsoConfigEditView model, SsoProviderType providerType, bool hasStoredIdpCertificate) + { + if (providerType == SsoProviderType.Oidc) + { + if (string.IsNullOrWhiteSpace(model.ClientId)) + ModelState.AddModelError("ClientId", "OIDC client ID is required."); + + if (!Uri.TryCreate(model.Authority, UriKind.Absolute, out var authority) || authority.Scheme != Uri.UriSchemeHttps) + ModelState.AddModelError("Authority", "OIDC authority must be a valid HTTPS URL."); + + return; + } + + if (!hasStoredIdpCertificate && string.IsNullOrWhiteSpace(model.IdpCertificate)) + ModelState.AddModelError("IdpCertificate", "An IdP signing certificate is required to validate SAML assertions."); + } + + private async Task PopulateSsoEditViewContextAsync(SsoConfigEditView model) + { + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + var apiBase = Config.SystemBehaviorConfig.ResgridApiBaseUrl; + model.ProviderTypes = BuildProviderTypeList(model.ProviderType); + model.RankList = await BuildRankListAsync(model.DefaultRankId); + model.ApiBaseUrl = apiBase; + model.AcsUrl = $"{apiBase}{Config.SsoConfig.SamlAcsPath}?departmentToken={Uri.EscapeDataString(_encryptionService.Encrypt($"{department.DepartmentId}:{department.Code}"))}"; + } + private async Task BuildRankListAsync(int? selectedRankId) { // Ranks are not currently implemented as a standalone service ๏ฟฝ return empty with placeholder diff --git a/Web/Resgrid.Web/Areas/User/Views/Command/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Command/Index.cshtml index d663578e4..bff03d216 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Command/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Command/Index.cshtml @@ -22,7 +22,7 @@ { } diff --git a/Web/Resgrid.Web/Areas/User/Views/Command/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/Command/New.cshtml index aa3b319e1..30264f2b9 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Command/New.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Command/New.cshtml @@ -19,6 +19,13 @@ +
diff --git a/Web/Resgrid.Web/Areas/User/Views/Command/Templates.cshtml b/Web/Resgrid.Web/Areas/User/Views/Command/Templates.cshtml new file mode 100644 index 000000000..641b20751 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Command/Templates.cshtml @@ -0,0 +1,151 @@ +@using Resgrid.Model.CommandBoards +@using System.Linq +@model IReadOnlyList +@{ + ViewBag.Title = "Resgrid | Command Board Examples"; + var categories = Model.Select(template => template.Category).Distinct().ToList(); +} + +
+
+

Command Board Examples

+ +
+
+ +
+
+
+
+
+
Choose a starting layout
+ +
+
+

+ These examples pre-fill the board name, description, timer and lanes. You can rename or remove + lanes, add your own, and adjust requirements before saving. Suggested unit types and personnel + roles are matched to your department by name; nothing is saved until you create the board. +

+ +
+
+
+ + +
+
+
+ +
+
+ +
+ @foreach (var template in Model) + { +
+
+
+
+ @template.Name + @template.Category +
+

@template.Description

+ +
+ @foreach (var lane in template.Lanes) + { + + @lane.Name + + } +
+ +
+ + @template.LaneCount lanes + @if (template.Timer) + { + · @template.TimerMinutes-minute timer + } + + + Use this example + +
+
+
+
+ } +
+ + +
+
+
+
+
+ +@section Scripts +{ + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Command/_CommandForm.cshtml b/Web/Resgrid.Web/Areas/User/Views/Command/_CommandForm.cshtml index ee9e3edd3..0ae71a07c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Command/_CommandForm.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Command/_CommandForm.cshtml @@ -75,43 +75,44 @@ @for (int i = 0; i < assignments.Count; i++) { var assignment = assignments[i]; - var laneTypeName = Enum.IsDefined(typeof(CommandNodeType), assignment.LaneType) - ? ((CommandNodeType)assignment.LaneType).ToString() - : CommandNodeType.Division.ToString(); var requiredUnitTypeIds = assignment.RequiredUnitTypes?.Select(x => x.UnitTypeId).ToList() ?? new System.Collections.Generic.List(); var requiredRoleIds = assignment.RequiredRoles?.Select(x => x.PersonnelRoleId).ToList() ?? new System.Collections.Generic.List(); - var requiredUnitTypeNames = Model.UnitTypes.Where(x => requiredUnitTypeIds.Contains(x.UnitTypeId)).Select(x => x.Type).ToList(); - var requiredRoleNames = Model.PersonnelRoles.Where(x => requiredRoleIds.Contains(x.PersonnelRoleId)).Select(x => x.Name).ToList(); - @assignment.Name - + - @laneTypeName - + - @assignment.Description - + - @if (requiredUnitTypeNames.Count > 0) - { -
Units: @string.Join(", ", requiredUnitTypeNames)
- } - @if (requiredRoleNames.Count > 0) - { -
Roles: @string.Join(", ", requiredRoleNames)
- } - @if (assignment.ForceRequirements) - { -
Enforced
- } - - - + + +
+ + +
diff --git a/Web/Resgrid.Web/Areas/User/Views/Units/_UnitRoleTemplatesModal.cshtml b/Web/Resgrid.Web/Areas/User/Views/Units/_UnitRoleTemplatesModal.cshtml index a8f0cf70a..5f3d53133 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Units/_UnitRoleTemplatesModal.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Units/_UnitRoleTemplatesModal.cshtml @@ -3,6 +3,7 @@ @model List @{ var templates = UnitRoleTemplateCatalog.All; + var categories = templates.Select(t => t.Category).Distinct().ToList(); var templatesJs = templates.Select(t => new { id = t.Id, @@ -25,15 +26,28 @@ before saving. Suggested qualifications are matched to your department's personnel roles by name.

-
- - +
+
+
+ + +
+
+
+ +
@foreach (var t in templates) { -
+
@@ -144,14 +158,17 @@ $('#unitRoleTemplatesModal').modal('hide'); }; - rt.filter = function (query) { - var q = (query || '').toLowerCase().trim(); + rt.filter = function () { + var q = ($('#unitRoleTemplateSearch').val() || '').toLowerCase().trim(); + var category = ($('#unitRoleTemplateCategory').val() || '').toLowerCase(); var terms = q.length ? q.split(/[\s,]+/) : []; var visible = 0; $('#unitRoleTemplateGrid .role-template-card').each(function () { var hay = $(this).attr('data-search') || ''; - var match = terms.every(function (t) { return hay.indexOf(t) !== -1; }); + var cardCategory = $(this).attr('data-category') || ''; + var match = (!category || cardCategory === category) && + terms.every(function (t) { return hay.indexOf(t) !== -1; }); $(this).toggle(match); if (match) { visible++; } }); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/command/resgrid.commands.newcommand.js b/Web/Resgrid.Web/wwwroot/js/app/internal/command/resgrid.commands.newcommand.js index 7baed7650..4d76cc77c 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/command/resgrid.commands.newcommand.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/command/resgrid.commands.newcommand.js @@ -11,6 +11,8 @@ var resgrid; $('#SelectedType').select2(); $('#assignment-unittypes').select2({ placeholder: 'Any unit type', allowClear: true }); $('#assignment-personnelroles').select2({ placeholder: 'Any personnel', allowClear: true }); + $('.assignment-unittypes-inline').select2({ placeholder: 'Any unit type', allowClear: true, width: '100%' }); + $('.assignment-personnelroles-inline').select2({ placeholder: 'Any personnel', allowClear: true, width: '100%' }); // Continue numbering after any server-rendered lane rows (Edit page). newcommand.assignmentCount = parseInt($('#assignments').data('next-index'), 10) || 0; @@ -35,39 +37,44 @@ var resgrid; var index = newcommand.assignmentCount++; var description = $('#description-text').val(); var laneType = $('#assignment-lanetype').val() || '0'; - var laneTypeName = $('#assignment-lanetype option:selected').text(); const forceRequirements = $('#forceRequirements').is(':checked') ? 'true' : 'false'; const unitTypeIds = $('#assignment-unittypes').val() || []; - const unitTypeNames = $('#assignment-unittypes option:selected').map(function () { return $(this).text(); }).get(); const roleIds = $('#assignment-personnelroles').val() || []; - const roleNames = $('#assignment-personnelroles option:selected').map(function () { return $(this).text(); }).get(); var row = $(''); - var nameCell = $('').text(name); + var nameCell = $(''); nameCell.append($('').attr('name', 'assignmentId_' + index).val('0')); - nameCell.append($('').attr('name', 'assignmentName_' + index).val(name)); + nameCell.append($('').attr('name', 'assignmentName_' + index).val(name)); - var laneCell = $('').text(laneTypeName); - laneCell.append($('').attr('name', 'assignmentLaneType_' + index).val(laneType)); + var laneCell = $(''); + var laneSelect = $('#assignment-lanetype').clone().removeAttr('id').attr({ + name: 'assignmentLaneType_' + index, + 'aria-label': 'Lane type' + }).val(laneType); + laneCell.append(laneSelect); - var descriptionCell = $('').text(description); - descriptionCell.append($('').attr('name', 'assignmentDescription_' + index).val(description)); + var descriptionCell = $(''); + descriptionCell.append($('') + .attr('name', 'assignmentDescription_' + index).val(description)); const requirementsCell = $(''); - if (unitTypeNames.length > 0) { - requirementsCell.append($('
').append($('').text('Units: ')).append(document.createTextNode(unitTypeNames.join(', ')))); - } - if (roleNames.length > 0) { - requirementsCell.append($('
').append($('').text('Roles: ')).append(document.createTextNode(roleNames.join(', ')))); - } - if (forceRequirements === 'true') { - requirementsCell.append($('
').append($('Enforced'))); - } - requirementsCell.append($('').attr('name', 'assignmentUnitTypes_' + index).val(unitTypeIds.join(','))); - requirementsCell.append($('').attr('name', 'assignmentRoles_' + index).val(roleIds.join(','))); - requirementsCell.append($('').attr('name', 'assignmentLock_' + index).val(forceRequirements)); + var unitTypeSelect = $('').attr({ + name: 'assignmentUnitTypes_' + index, + 'aria-label': 'Required unit types' + }).append($('#assignment-unittypes option').clone().removeAttr('data-select2-id')).val(unitTypeIds); + var roleSelect = $('').attr({ + name: 'assignmentRoles_' + index, + 'aria-label': 'Required personnel roles' + }).append($('#assignment-personnelroles option').clone().removeAttr('data-select2-id')).css('margin-top', '5px').val(roleIds); + var enforceContainer = $('
'); + enforceContainer.append($('').attr('name', 'assignmentLock_' + index).val('false')); + var enforceLabel = $(''); + enforceLabel.append($('').attr('name', 'assignmentLock_' + index).prop('checked', forceRequirements === 'true')); + enforceLabel.append(document.createTextNode(' Enforce requirements')); + enforceContainer.append(enforceLabel); + requirementsCell.append(unitTypeSelect).append(roleSelect).append(enforceContainer); var actionCell = $(''); actionCell.append($('').on('click', function () { @@ -76,6 +83,8 @@ var resgrid; row.append(nameCell).append(laneCell).append(descriptionCell).append(requirementsCell).append(actionCell); $('#assignments tbody').first().append(row); + unitTypeSelect.select2({ placeholder: 'Any unit type', allowClear: true, width: '100%' }); + roleSelect.select2({ placeholder: 'Any personnel', allowClear: true, width: '100%' }); } newcommand.addAssignment = addAssignment; })(newcommand = commands.newcommand || (commands.newcommand = {})); From a2c12f6eb5f9e6737d3310bef0643cc62548c97c Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 20 Jul 2026 09:35:50 -0700 Subject: [PATCH 2/4] RG-T126 PR#433 fix --- Core/Resgrid.Services/DepartmentSsoService.cs | 1 + .../IncidentCommandService.cs | 3 +- .../WorkflowSampleDataGenerator.cs | 2 +- .../NwsIncidentWeatherProvider.cs | 109 ++++++++---------- .../NwsIncidentWeatherProviderTests.cs | 53 ++++++++- .../Services/DepartmentSsoServiceTests.cs | 40 ++++++- .../IncidentCommandContentServiceTests.cs | 7 +- .../WorkflowSampleDataGeneratorTests.cs | 16 +++ 8 files changed, 162 insertions(+), 69 deletions(-) diff --git a/Core/Resgrid.Services/DepartmentSsoService.cs b/Core/Resgrid.Services/DepartmentSsoService.cs index e0af2af7c..ce24f7799 100644 --- a/Core/Resgrid.Services/DepartmentSsoService.cs +++ b/Core/Resgrid.Services/DepartmentSsoService.cs @@ -728,6 +728,7 @@ private async Task MarkSamlAssertionConsumedAsync(string configId, string var remainingLifetime = expiresOn - now; if (remainingLifetime <= TimeSpan.Zero) return false; + remainingLifetime += TokenClockSkew; var replayIdentifier = Convert.ToHexString( SHA256.HashData(Encoding.UTF8.GetBytes($"{configId}:{assertionId}"))); diff --git a/Core/Resgrid.Services/IncidentCommandService.cs b/Core/Resgrid.Services/IncidentCommandService.cs index e6db89cb5..46fe7159f 100644 --- a/Core/Resgrid.Services/IncidentCommandService.cs +++ b/Core/Resgrid.Services/IncidentCommandService.cs @@ -724,7 +724,8 @@ public async Task> GetNotesForCallAsync(int departmentId, int if (attachment.Data.Length > Resgrid.Config.IncidentCommandConfig.MaxAttachmentBytes) throw new ArgumentException($"Incident files cannot exceed {Resgrid.Config.IncidentCommandConfig.MaxAttachmentBytes} bytes."); - var safeFileName = Path.GetFileName(attachment.FileName ?? string.Empty); + // Browsers and API clients can submit either path separator regardless of the host OS. + var safeFileName = Path.GetFileName((attachment.FileName ?? string.Empty).Replace('\\', '/')); if (string.IsNullOrWhiteSpace(safeFileName) || IsBlockedAttachment(safeFileName, attachment.ContentType)) throw new ArgumentException("The incident file name or type is not allowed."); diff --git a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs index bb5ef8ecb..46aeaa60e 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -453,7 +453,7 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve incident["file_name"] = "public-situation-map.pdf"; incident["content_type"] = "application/pdf"; incident["content_length"] = 284123L; - incident["sha256_hash"] = "9f86d081884c7d659a2feaa0c55ad015"; + incident["sha256_hash"] = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"; incident["description"] = "Current public situation map"; incident["action_plan"] = "Protect life, hold the east flank, and maintain evacuation routes."; incident["latitude"] = "39.7817"; diff --git a/Providers/Resgrid.Providers.Weather/NwsIncidentWeatherProvider.cs b/Providers/Resgrid.Providers.Weather/NwsIncidentWeatherProvider.cs index 0953b3ac1..d71b762db 100644 --- a/Providers/Resgrid.Providers.Weather/NwsIncidentWeatherProvider.cs +++ b/Providers/Resgrid.Providers.Weather/NwsIncidentWeatherProvider.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -22,18 +21,20 @@ public class NwsIncidentWeatherProvider : IIncidentWeatherProvider { private const string NwsBaseUrl = "https://api.weather.gov"; private const string RadarServiceUrl = "https://mapservices.weather.noaa.gov/eventdriven/rest/services/radar/radar_base_reflectivity/MapServer"; + private static readonly TimeSpan WeatherCacheExpiration = TimeSpan.FromMinutes(5); private static readonly HttpClient SharedHttpClient = CreateHttpClient(); - private static readonly ConcurrentDictionary Cache = new ConcurrentDictionary(); private readonly HttpClient _httpClient; + private readonly ICacheProvider _cacheProvider; - public NwsIncidentWeatherProvider() : this(SharedHttpClient) + public NwsIncidentWeatherProvider(ICacheProvider cacheProvider) : this(SharedHttpClient, cacheProvider) { } /// Constructor exposed for deterministic provider tests with a stubbed HTTP handler. - public NwsIncidentWeatherProvider(HttpClient httpClient) + public NwsIncidentWeatherProvider(HttpClient httpClient, ICacheProvider cacheProvider) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _cacheProvider = cacheProvider ?? throw new ArgumentNullException(nameof(cacheProvider)); } public async Task GetWeatherAsync(decimal latitude, decimal longitude, int forecastHours = 24, CancellationToken cancellationToken = default) @@ -41,59 +42,61 @@ public async Task GetWeatherAsync(decimal latitude, decimal lon ValidateCoordinates(latitude, longitude); forecastHours = Math.Clamp(forecastHours, 1, 168); - var cacheKey = $"{Math.Round(latitude, 4)}:{Math.Round(longitude, 4)}:{forecastHours}"; - if (Cache.TryGetValue(cacheKey, out var cached) && cached.ExpiresAtUtc > DateTime.UtcNow) - return cached.Weather; + var cacheKey = $"NwsIncidentWeather:{Math.Round(latitude, 4)}:{Math.Round(longitude, 4)}:{forecastHours}"; - var pointUrl = $"{NwsBaseUrl}/points/{latitude.ToString("0.####", CultureInfo.InvariantCulture)},{longitude.ToString("0.####", CultureInfo.InvariantCulture)}"; - using var pointDoc = await GetJsonAsync(pointUrl, cancellationToken); - var pointProperties = pointDoc.RootElement.GetProperty("properties"); - var forecastUrl = GetString(pointProperties, "forecastHourly"); - var stationsUrl = GetString(pointProperties, "observationStations"); - - if (string.IsNullOrWhiteSpace(forecastUrl)) - throw new InvalidOperationException("NWS did not return an hourly forecast endpoint for the incident location."); - - var weather = new IncidentWeather + async Task getWeather() { - Latitude = latitude, - Longitude = longitude, - Source = "National Weather Service", - Attribution = "NOAA / National Weather Service", - GeneratedAtUtc = DateTime.UtcNow, - ExpiresAtUtc = DateTime.UtcNow.AddMinutes(5) - }; + var pointUrl = $"{NwsBaseUrl}/points/{latitude.ToString("0.####", CultureInfo.InvariantCulture)},{longitude.ToString("0.####", CultureInfo.InvariantCulture)}"; + using var pointDoc = await GetJsonAsync(pointUrl, cancellationToken); + var pointProperties = pointDoc.RootElement.GetProperty("properties"); + var forecastUrl = GetString(pointProperties, "forecastHourly"); + var stationsUrl = GetString(pointProperties, "observationStations"); - using (var forecastDoc = await GetJsonAsync(forecastUrl, cancellationToken)) - { - var properties = forecastDoc.RootElement.GetProperty("properties"); - weather.UpdatedAtUtc = GetDate(properties, "updated"); - if (properties.TryGetProperty("periods", out var periods)) - { - weather.HourlyForecast = periods.EnumerateArray() - .Take(forecastHours) - .Select(ParseForecastPeriod) - .ToList(); - } - } + if (string.IsNullOrWhiteSpace(forecastUrl)) + throw new InvalidOperationException("NWS did not return an hourly forecast endpoint for the incident location."); - if (!string.IsNullOrWhiteSpace(stationsUrl)) - { - try + var weather = new IncidentWeather + { + Latitude = latitude, + Longitude = longitude, + Source = "National Weather Service", + Attribution = "NOAA / National Weather Service", + GeneratedAtUtc = DateTime.UtcNow, + ExpiresAtUtc = DateTime.UtcNow.Add(WeatherCacheExpiration) + }; + + using (var forecastDoc = await GetJsonAsync(forecastUrl, cancellationToken)) { - weather.Current = await GetLatestObservationAsync(stationsUrl, cancellationToken); + var properties = forecastDoc.RootElement.GetProperty("properties"); + weather.UpdatedAtUtc = GetDate(properties, "updated"); + if (properties.TryGetProperty("periods", out var periods)) + { + weather.HourlyForecast = periods.EnumerateArray() + .Take(forecastHours) + .Select(ParseForecastPeriod) + .ToList(); + } } - catch (Exception ex) when (ex is HttpRequestException || ex is JsonException || ex is InvalidOperationException) + + if (!string.IsNullOrWhiteSpace(stationsUrl)) { - // A station observation can be delayed or temporarily unavailable. Forecast/radar data remains useful, - // so degrade only the current-conditions portion instead of failing the commander weather panel. - weather.Current = null; + try + { + weather.Current = await GetLatestObservationAsync(stationsUrl, cancellationToken); + } + catch (Exception ex) when (ex is HttpRequestException || ex is JsonException || ex is InvalidOperationException) + { + // A station observation can be delayed or temporarily unavailable. Forecast/radar data remains useful, + // so degrade only the current-conditions portion instead of failing the commander weather panel. + weather.Current = null; + } } + + weather.Overlays.Add(CreateRadarOverlay()); + return weather; } - weather.Overlays.Add(CreateRadarOverlay()); - Cache[cacheKey] = new CacheEntry(weather, weather.ExpiresAtUtc); - return weather; + return await _cacheProvider.RetrieveAsync(cacheKey, getWeather, WeatherCacheExpiration); } private async Task GetLatestObservationAsync(string stationsUrl, CancellationToken cancellationToken) @@ -264,17 +267,5 @@ private static HttpClient CreateHttpClient() { return new HttpClient { Timeout = TimeSpan.FromSeconds(12) }; } - - private sealed class CacheEntry - { - public CacheEntry(IncidentWeather weather, DateTime expiresAtUtc) - { - Weather = weather; - ExpiresAtUtc = expiresAtUtc; - } - - public IncidentWeather Weather { get; } - public DateTime ExpiresAtUtc { get; } - } } } diff --git a/Tests/Resgrid.Tests/Providers/NwsIncidentWeatherProviderTests.cs b/Tests/Resgrid.Tests/Providers/NwsIncidentWeatherProviderTests.cs index ba85c2c49..57c50388d 100644 --- a/Tests/Resgrid.Tests/Providers/NwsIncidentWeatherProviderTests.cs +++ b/Tests/Resgrid.Tests/Providers/NwsIncidentWeatherProviderTests.cs @@ -4,7 +4,10 @@ using System.Threading; using System.Threading.Tasks; using FluentAssertions; +using Moq; using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; using Resgrid.Providers.Weather; namespace Resgrid.Tests.Providers @@ -16,8 +19,10 @@ public class NwsIncidentWeatherProviderTests public async Task GetWeather_WithNwsResponses_MapsForecastObservationAndRadarOverlay() { // Arrange - using var httpClient = new HttpClient(new NwsWeatherHandler()); - var provider = new NwsIncidentWeatherProvider(httpClient); + var handler = new NwsWeatherHandler(); + using var httpClient = new HttpClient(handler); + var cacheProvider = CreatePassthroughCacheProvider(); + var provider = new NwsIncidentWeatherProvider(httpClient, cacheProvider.Object); // Act var weather = await provider.GetWeatherAsync(41.1234m, -122.5678m, 1); @@ -33,6 +38,31 @@ public async Task GetWeather_WithNwsResponses_MapsForecastObservationAndRadarOve weather.Overlays.Should().ContainSingle(x => x.Id == "noaa-mrms-base-reflectivity" && x.LayerIds == "3" && x.RefreshSeconds == 300); weather.Overlays[0].ExportUrlTemplate.Should().Contain("mapservices.weather.noaa.gov"); + handler.RequestCount.Should().Be(4); + cacheProvider.Verify(x => x.RetrieveAsync( + It.IsAny(), It.IsAny>>(), TimeSpan.FromMinutes(5)), Times.Once); + } + + [Test] + public async Task GetWeather_WithCachedWeather_DoesNotCallNws() + { + // Arrange + var cachedWeather = new IncidentWeather { Source = "Cached weather" }; + var cacheProvider = new Mock(); + cacheProvider + .Setup(x => x.RetrieveAsync( + It.IsAny(), It.IsAny>>(), It.IsAny())) + .ReturnsAsync(cachedWeather); + var handler = new NwsWeatherHandler(); + using var httpClient = new HttpClient(handler); + var provider = new NwsIncidentWeatherProvider(httpClient, cacheProvider.Object); + + // Act + var weather = await provider.GetWeatherAsync(41.1234m, -122.5678m, 1); + + // Assert + weather.Should().BeSameAs(cachedWeather); + handler.RequestCount.Should().Be(0); } [TestCase(-91, 0)] @@ -42,19 +72,36 @@ public async Task GetWeather_WithNwsResponses_MapsForecastObservationAndRadarOve public async Task GetWeather_WithInvalidCoordinates_RejectsRequest(decimal latitude, decimal longitude) { // Arrange - var provider = new NwsIncidentWeatherProvider(new HttpClient(new NwsWeatherHandler())); + var cacheProvider = CreatePassthroughCacheProvider(); + using var httpClient = new HttpClient(new NwsWeatherHandler()); + var provider = new NwsIncidentWeatherProvider(httpClient, cacheProvider.Object); // Act Func act = () => provider.GetWeatherAsync(latitude, longitude); // Assert await act.Should().ThrowAsync(); + cacheProvider.Verify(x => x.RetrieveAsync( + It.IsAny(), It.IsAny>>(), It.IsAny()), Times.Never); + } + + private static Mock CreatePassthroughCacheProvider() + { + var cacheProvider = new Mock(); + cacheProvider + .Setup(x => x.RetrieveAsync( + It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns((string _, Func> fallback, TimeSpan _) => fallback()); + return cacheProvider; } private sealed class NwsWeatherHandler : HttpMessageHandler { + public int RequestCount { get; private set; } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + RequestCount++; var url = request.RequestUri.ToString(); string json; if (url.Contains("/points/", StringComparison.OrdinalIgnoreCase)) diff --git a/Tests/Resgrid.Tests/Services/DepartmentSsoServiceTests.cs b/Tests/Resgrid.Tests/Services/DepartmentSsoServiceTests.cs index ab9c2399a..0fd2f5341 100644 --- a/Tests/Resgrid.Tests/Services/DepartmentSsoServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/DepartmentSsoServiceTests.cs @@ -137,6 +137,11 @@ public async Task ValidateExternalTokenAsync_UnsignedSamlAssertion_IsRejected() public async Task ValidateExternalTokenAsync_SignedSamlAssertion_ValidatesOnceAndRejectsReplay() { // Arrange + var replayMarkerLifetime = TimeSpan.Zero; + _cacheProvider + .Setup(x => x.IncrementAsync(It.IsAny(), It.IsAny())) + .Callback((_, lifetime) => replayMarkerLifetime = lifetime) + .ReturnsAsync(() => Interlocked.Increment(ref _samlReplayUseCount)); using var rsa = RSA.Create(2048); using var certificate = CreateCertificate(rsa); var config = CreateSamlConfig(); @@ -161,6 +166,36 @@ public async Task ValidateExternalTokenAsync_SignedSamlAssertion_ValidatesOnceAn firstAttempt.FindFirst(ClaimTypes.NameIdentifier)?.Value.Should().Be("external-user"); firstAttempt.FindFirst(ClaimTypes.Email)?.Value.Should().Be("user@example.com"); replayAttempt.Should().BeNull(); + replayMarkerLifetime.Should().BeGreaterThan(TimeSpan.FromMinutes(6)); + replayMarkerLifetime.Should().BeLessThanOrEqualTo(TimeSpan.FromMinutes(7)); + } + + [Test] + public async Task ValidateExternalTokenAsync_SignedSamlAssertionPastRawExpiry_IsRejectedWithoutReplayMarker() + { + // Arrange + using var rsa = RSA.Create(2048); + using var certificate = CreateCertificate(rsa); + var config = CreateSamlConfig(); + config.IsEnabled = true; + config.EncryptedIdpCertificate = "stored-certificate"; + _ssoConfigRepository + .Setup(x => x.GetByDepartmentIdAndTypeAsync(config.DepartmentId, SsoProviderType.Saml2)) + .ReturnsAsync(config); + _encryptionService + .Setup(x => x.DecryptForDepartment("stored-certificate", config.DepartmentId, "DEPT")) + .Returns(certificate.ExportCertificatePem()); + var samlResponse = BuildSamlResponse( + config, rsa, signAssertion: true, expiresOn: DateTime.UtcNow.AddMinutes(-1)); + + // Act + var principal = await _service.ValidateExternalTokenAsync( + config.DepartmentId, SsoProviderType.Saml2, samlResponse, "DEPT"); + + // Assert + principal.Should().BeNull(); + _cacheProvider.Verify( + x => x.IncrementAsync(It.IsAny(), It.IsAny()), Times.Never); } [Test] @@ -209,13 +244,14 @@ private static X509Certificate2 CreateCertificate(RSA rsa) return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); } - private static string BuildSamlResponse(DepartmentSsoConfig config, RSA signingKey, bool signAssertion, string audience = null) + private static string BuildSamlResponse(DepartmentSsoConfig config, RSA signingKey, bool signAssertion, + string audience = null, DateTime? expiresOn = null) { var now = DateTime.UtcNow; var assertionId = $"_{Guid.NewGuid():N}"; var responseId = $"_{Guid.NewGuid():N}"; var notBefore = XmlConvert.ToString(now.AddMinutes(-1), XmlDateTimeSerializationMode.Utc); - var notOnOrAfter = XmlConvert.ToString(now.AddMinutes(5), XmlDateTimeSerializationMode.Utc); + var notOnOrAfter = XmlConvert.ToString(expiresOn ?? now.AddMinutes(5), XmlDateTimeSerializationMode.Utc); var issueInstant = XmlConvert.ToString(now, XmlDateTimeSerializationMode.Utc); var xml = $""" diff --git a/Tests/Resgrid.Tests/Services/IncidentCommandContentServiceTests.cs b/Tests/Resgrid.Tests/Services/IncidentCommandContentServiceTests.cs index 5d80398d2..7344a3065 100644 --- a/Tests/Resgrid.Tests/Services/IncidentCommandContentServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/IncidentCommandContentServiceTests.cs @@ -101,8 +101,9 @@ public async Task AddNote_WithPublicContainmentUpdate_StampsOwnershipAndRaisesEv e.CallId == CallId && e.Visibility == (int)IncidentContentVisibility.Public && e.ContainmentPercent == 40)), Times.Once); } - [Test] - public async Task AddAttachment_WithAllowedDocument_ComputesIntegrityMetadata() + [TestCase("..\\plans\\iap.pdf")] + [TestCase("../plans/iap.pdf")] + public async Task AddAttachment_WithAllowedDocument_ComputesIntegrityMetadata(string fileName) { // Arrange ArrangeOwnedCommand(); @@ -112,7 +113,7 @@ public async Task AddAttachment_WithAllowedDocument_ComputesIntegrityMetadata() IncidentCommandId = "ic-1", DepartmentId = DepartmentId, Visibility = (int)IncidentContentVisibility.Internal, - FileName = "..\\plans\\iap.pdf", + FileName = fileName, ContentType = "application/pdf", Data = data }; diff --git a/Tests/Resgrid.Tests/Services/WorkflowSampleDataGeneratorTests.cs b/Tests/Resgrid.Tests/Services/WorkflowSampleDataGeneratorTests.cs index 4c90055e8..c827f3425 100644 --- a/Tests/Resgrid.Tests/Services/WorkflowSampleDataGeneratorTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkflowSampleDataGeneratorTests.cs @@ -83,6 +83,22 @@ public void GenerateSampleData_DepartmentName_IsRealisticValue() name.Should().NotBe("test"); name.Should().NotBe("value"); } + + [Test] + public void GenerateSampleData_PublicIncidentDocumentAdded_IncludesValidSha256Hash() + { + // Arrange + var eventType = WorkflowTriggerEventType.PublicIncidentDocumentAdded; + + // Act + var data = (ScriptObject)WorkflowSampleDataGenerator.GenerateSampleData( + eventType); + var incident = data["incident"] as ScriptObject; + + // Assert + incident.Should().NotBeNull(); + incident?["sha256_hash"]?.ToString().Should().MatchRegex("^[0-9a-fA-F]{64}$"); + } } } From c31bbcbcf773d0dec25ff26f79340fd504447a21 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 20 Jul 2026 10:27:36 -0700 Subject: [PATCH 3/4] RIC-T39 IC Board Setup fixes --- .../CommandBoards/CommandBoardTemplate.cs | 7 + .../CommandBoardTemplateCatalog.cs | 69 +++++++-- .../CommandBoards/CommandBoardTemplateLane.cs | 24 +++ Core/Resgrid.Model/CommandDefinitionRole.cs | 8 + .../IncidentCommand/CommandStructureNode.cs | 24 +++ .../IncidentCommand/IncidentReport.cs | 54 +++++++ .../IncidentCommand/VoiceTransmissionLog.cs | 50 +++++++ .../IIncidentCommandRepositories.cs | 4 + .../Services/IIncidentReportingService.cs | 9 ++ .../Services/IIncidentVoiceService.cs | 6 + .../IncidentCommandService.cs | 77 +++++++++- .../IncidentReportingService.cs | 138 +++++++++++++++++- Core/Resgrid.Services/IncidentVoiceService.cs | 32 ++++ ...092_AddLaneColorAndVoiceTransmissionLog.cs | 94 ++++++++++++ ...2_AddLaneColorAndVoiceTransmissionLogPg.cs | 94 ++++++++++++ .../IncidentCommandRepositories.cs | 8 + .../Modules/ApiDataModule.cs | 1 + .../Modules/DataModule.cs | 1 + .../IncidentCommandServiceParTests.cs | 107 ++++++++++++++ .../Services/IncidentExportTests.cs | 2 +- .../Services/IncidentVoiceServiceTests.cs | 2 +- .../Controllers/v4/CommandsController.cs | 4 + .../v4/IncidentReportingController.cs | 57 ++++++++ .../Controllers/v4/IncidentVoiceController.cs | 54 +++++++ .../Models/v4/Commands/CommandModels.cs | 10 ++ .../IncidentCommand/IncidentCommandModels.cs | 29 ++++ .../Resgrid.Web.Services.xml | 68 ++++++++- .../User/Controllers/CommandController.cs | 20 +++ .../User/Models/Command/NewCommandView.cs | 13 ++ .../Areas/User/Views/Command/Templates.cshtml | 2 +- .../Areas/User/Views/Command/View.cshtml | 22 ++- .../Views/Command/_AssignmentModal.cshtml | 45 ++++++ .../User/Views/Command/_CommandForm.cshtml | 21 +++ .../command/resgrid.commands.newcommand.js | 35 ++++- 34 files changed, 1160 insertions(+), 31 deletions(-) create mode 100644 Core/Resgrid.Model/IncidentCommand/VoiceTransmissionLog.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0092_AddLaneColorAndVoiceTransmissionLog.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0092_AddLaneColorAndVoiceTransmissionLogPg.cs diff --git a/Core/Resgrid.Model/CommandBoards/CommandBoardTemplate.cs b/Core/Resgrid.Model/CommandBoards/CommandBoardTemplate.cs index 460000d96..5a192dac8 100644 --- a/Core/Resgrid.Model/CommandBoards/CommandBoardTemplate.cs +++ b/Core/Resgrid.Model/CommandBoards/CommandBoardTemplate.cs @@ -81,6 +81,13 @@ public CommandDefinition CreateDefinition(IEnumerable unitTypes, IEnum Description = lane.Description, LaneType = (int)lane.LaneType, SortOrder = index, + Color = lane.Color, + MinUnits = lane.MinUnits, + MaxUnits = lane.MaxUnits, + MinUnitPersonnel = lane.MinUnitPersonnel, + MaxUnitPersonnel = lane.MaxUnitPersonnel, + MinTimeInRole = lane.MinTimeInRole, + MaxTimeInRole = lane.MaxTimeInRole, RequiredUnitTypes = matchedUnitTypes, RequiredRoles = matchedPersonnelRoles, ForceRequirements = lane.ForceRequirements && (matchedUnitTypes.Count > 0 || matchedPersonnelRoles.Count > 0) diff --git a/Core/Resgrid.Model/CommandBoards/CommandBoardTemplateCatalog.cs b/Core/Resgrid.Model/CommandBoards/CommandBoardTemplateCatalog.cs index b041ca929..5992b3144 100644 --- a/Core/Resgrid.Model/CommandBoards/CommandBoardTemplateCatalog.cs +++ b/Core/Resgrid.Model/CommandBoards/CommandBoardTemplateCatalog.cs @@ -10,6 +10,9 @@ namespace Resgrid.Model.CommandBoards ///
public static class CommandBoardTemplateCatalog { + /// Lane identification palette โ€” matches the app's LANE_COLORS swatches. + private static readonly string[] LanePalette = { "#e74c3c", "#e67e22", "#f1c40f", "#2ecc71", "#1abc9c", "#3498db", "#9b59b6", "#7f8c8d" }; + public static IReadOnlyList All { get; } = BuildAll(); public static CommandBoardTemplate GetById(string id) @@ -37,7 +40,8 @@ public static IReadOnlyList Search(string query) #region Builders private static CommandBoardTemplateLane L(string name, CommandNodeType laneType, string description, - string[] unitTypes = null, string[] personnelRoles = null, bool forceRequirements = false) + string[] unitTypes = null, string[] personnelRoles = null, bool forceRequirements = false, + int minUnits = 0, int maxUnits = 0, int minUnitPersonnel = 0, int maxUnitPersonnel = 0, int minTimeInRole = 0, int maxTimeInRole = 0) { return new CommandBoardTemplateLane { @@ -46,13 +50,48 @@ private static CommandBoardTemplateLane L(string name, CommandNodeType laneType, Description = description, SuggestedUnitTypes = unitTypes ?? Array.Empty(), SuggestedPersonnelRoles = personnelRoles ?? Array.Empty(), - ForceRequirements = forceRequirements + ForceRequirements = forceRequirements, + MinUnits = minUnits, + MaxUnits = maxUnits, + MinUnitPersonnel = minUnitPersonnel, + MaxUnitPersonnel = maxUnitPersonnel, + MinTimeInRole = minTimeInRole, + MaxTimeInRole = maxTimeInRole }; } + /// + /// Deterministic lane color: recognizable functions get a conventional color (medical red, + /// water blue, safety yellow, staging gray); everything else cycles the palette. + /// + private static string AutoColor(CommandBoardTemplateLane lane, int index) + { + var name = lane.Name?.ToLowerInvariant() ?? string.Empty; + + if (lane.LaneType == CommandNodeType.Staging || name.Contains("staging") || name.Contains("accountability")) + return "#7f8c8d"; + if (name.Contains("medical") || name.Contains("treatment") || name.Contains("triage") || name.Contains("aid station")) + return "#e74c3c"; + if (name.Contains("safety")) + return "#f1c40f"; + if (name.Contains("water") || name.Contains("boat") || name.Contains("swiftwater")) + return "#3498db"; + if (name.Contains("rapid intervention") || name.Contains("backup")) + return "#e67e22"; + + return LanePalette[index % LanePalette.Length]; + } + private static CommandBoardTemplate T(string id, string name, string category, string description, string[] keywords, bool timer, int timerMinutes, params CommandBoardTemplateLane[] lanes) { + // Every example ships with lane colors: explicit colors win, the rest are auto-assigned. + for (var i = 0; i < lanes.Length; i++) + { + if (string.IsNullOrWhiteSpace(lanes[i].Color)) + lanes[i].Color = AutoColor(lanes[i], i); + } + return new CommandBoardTemplate { Id = id, @@ -74,11 +113,11 @@ private static IReadOnlyList BuildAll() T("fire-residential-structure", "Residential Structure Fire", "Fire Departments", "A first-alarm residential fire board with tactical groups, water supply, rapid intervention and staging.", new[] { "house", "dwelling", "first alarm", "rit", "residential" }, true, 15, - L("Fire Attack", CommandNodeType.Group, "Interior fire control and confinement.", new[] { "Engine", "Pumper" }), - L("Primary Search", CommandNodeType.Group, "Primary life-safety search.", new[] { "Truck", "Ladder", "Rescue" }), + L("Fire Attack", CommandNodeType.Group, "Interior fire control and confinement.", new[] { "Engine", "Pumper" }, minUnitPersonnel: 2, maxTimeInRole: 30), + L("Primary Search", CommandNodeType.Group, "Primary life-safety search.", new[] { "Truck", "Ladder", "Rescue" }, minUnitPersonnel: 2, maxTimeInRole: 30), L("Ventilation", CommandNodeType.Group, "Coordinate horizontal and vertical ventilation.", new[] { "Truck", "Ladder" }), - L("Water Supply", CommandNodeType.Group, "Establish and maintain a sustained water source.", new[] { "Engine", "Tanker", "Tender" }), - L("Rapid Intervention", CommandNodeType.Group, "Dedicated firefighter rescue team.", new[] { "Rescue", "Squad", "Engine" }, new[] { "Firefighter" }), + L("Water Supply", CommandNodeType.Group, "Establish and maintain a sustained water source.", new[] { "Engine", "Tanker", "Tender" }, minUnits: 1), + L("Rapid Intervention", CommandNodeType.Group, "Dedicated firefighter rescue team.", new[] { "Rescue", "Squad", "Engine" }, new[] { "Firefighter" }, minUnits: 1, minUnitPersonnel: 2), L("Staging", CommandNodeType.Staging, "Track unassigned responding resources.")), T("fire-commercial-structure", "Commercial Structure Fire", "Fire Departments", @@ -90,7 +129,7 @@ private static IReadOnlyList BuildAll() L("Division D", CommandNodeType.Division, "Right-side exposure and operations."), L("Roof Division", CommandNodeType.Division, "Roof access, ventilation and conditions.", new[] { "Truck", "Ladder" }), L("Search Group", CommandNodeType.Group, "Primary and secondary searches.", new[] { "Truck", "Ladder", "Rescue" }), - L("Rapid Intervention", CommandNodeType.Group, "Dedicated firefighter rescue team.", new[] { "Rescue", "Squad", "Engine" }), + L("Rapid Intervention", CommandNodeType.Group, "Dedicated firefighter rescue team.", new[] { "Rescue", "Squad", "Engine" }, minUnits: 1, minUnitPersonnel: 2), L("Staging", CommandNodeType.Staging, "Manage additional alarms and relief companies.")), T("fire-wildland-wui", "Wildland / WUI Fire", "Fire Departments", @@ -107,8 +146,8 @@ private static IReadOnlyList BuildAll() "A hazmat branch layout separating entry, backup, decontamination, medical monitoring and support.", new[] { "hazmat", "chemical", "spill", "cbrne", "decon", "entry" }, true, 20, L("Hazmat Branch", CommandNodeType.Branch, "Coordinate all hazardous-materials operations."), - L("Entry Group", CommandNodeType.Group, "Hot-zone reconnaissance, control and product identification.", new[] { "Hazmat" }, new[] { "Hazmat Technician" }, true), - L("Backup Group", CommandNodeType.Group, "Dedicated backup for the entry team.", new[] { "Hazmat" }, new[] { "Hazmat Technician" }, true), + L("Entry Group", CommandNodeType.Group, "Hot-zone reconnaissance, control and product identification.", new[] { "Hazmat" }, new[] { "Hazmat Technician" }, true, minUnitPersonnel: 2, maxTimeInRole: 30), + L("Backup Group", CommandNodeType.Group, "Dedicated backup for the entry team.", new[] { "Hazmat" }, new[] { "Hazmat Technician" }, true, minUnitPersonnel: 2), L("Decontamination", CommandNodeType.Group, "Technical and emergency decontamination corridor."), L("Medical Monitoring", CommandNodeType.Group, "Pre-entry and post-entry medical monitoring.", new[] { "Ambulance", "Medic" }, new[] { "Paramedic", "EMT" }), L("Staging", CommandNodeType.Staging, "Cold-zone resource accountability.")), @@ -121,7 +160,7 @@ private static IReadOnlyList BuildAll() L("Immediate Treatment", CommandNodeType.Group, "Treatment area for immediate/red patients."), L("Delayed Treatment", CommandNodeType.Group, "Treatment area for delayed/yellow patients."), L("Minor Treatment", CommandNodeType.Group, "Treatment area for minor/green patients."), - L("Transport Group", CommandNodeType.Group, "Ambulance loading, destination coordination and tracking.", new[] { "Ambulance", "Medic" }), + L("Transport Group", CommandNodeType.Group, "Ambulance loading, destination coordination and tracking.", new[] { "Ambulance", "Medic" }, minUnits: 2), L("Ambulance Staging", CommandNodeType.Staging, "Check in and sequence transport units.", new[] { "Ambulance", "Medic" })), T("ems-event-medical", "Planned Event Medical", "EMS", @@ -129,8 +168,8 @@ private static IReadOnlyList BuildAll() new[] { "festival", "concert", "sporting event", "aid station", "roving team", "special event" }, false, 0, L("Medical Command", CommandNodeType.Branch, "Coordinate event medical operations and venue command."), L("Main Aid Station", CommandNodeType.Group, "Fixed treatment and documentation location."), - L("Roving Team Alpha", CommandNodeType.TaskForce, "Mobile first-response team.", null, new[] { "Paramedic", "EMT" }), - L("Roving Team Bravo", CommandNodeType.TaskForce, "Mobile first-response team.", null, new[] { "Paramedic", "EMT" }), + L("Roving Team Alpha", CommandNodeType.TaskForce, "Mobile first-response team.", null, new[] { "Paramedic", "EMT" }, maxTimeInRole: 120), + L("Roving Team Bravo", CommandNodeType.TaskForce, "Mobile first-response team.", null, new[] { "Paramedic", "EMT" }, maxTimeInRole: 120), L("Transport Group", CommandNodeType.Group, "Coordinate ambulance access, loading and hospitals.", new[] { "Ambulance", "Medic" }), L("Medical Logistics", CommandNodeType.Group, "Restock supplies, oxygen, AEDs and responder rehab.")), @@ -183,7 +222,7 @@ private static IReadOnlyList BuildAll() L("Rescue Group", CommandNodeType.Group, "Coordinate rescue swimmers, boats and shore teams.", new[] { "Boat", "Rescue" }), L("Upstream Safety", CommandNodeType.Group, "Watch for and communicate upstream hazards."), L("Downstream Safety", CommandNodeType.Group, "Contain rescuers or subjects swept downstream."), - L("Boat Team", CommandNodeType.TaskForce, "Boat launch, operations and recovery.", new[] { "Boat" }), + L("Boat Team", CommandNodeType.TaskForce, "Boat launch, operations and recovery.", new[] { "Boat" }, minUnitPersonnel: 2), L("Medical Group", CommandNodeType.Group, "Patient warming, treatment and transport.", new[] { "Ambulance", "Medic" }), L("Staging", CommandNodeType.Staging, "Water-rescue resources and PPE accountability.")), @@ -278,8 +317,8 @@ private static IReadOnlyList BuildAll() "A confined-space board for entry, backup, rigging, atmospheric monitoring, medical and safety.", new[] { "confined space", "permit space", "trench", "technical rescue", "entry team" }, true, 15, L("Rescue Group", CommandNodeType.Group, "Coordinate the entry rescue plan."), - L("Entry Team", CommandNodeType.TaskForce, "Enter, assess and package the victim.", null, new[] { "Confined Space Technician" }, true), - L("Backup Team", CommandNodeType.TaskForce, "Ready team for entry-team rescue.", null, new[] { "Confined Space Technician" }, true), + L("Entry Team", CommandNodeType.TaskForce, "Enter, assess and package the victim.", null, new[] { "Confined Space Technician" }, true, minUnitPersonnel: 2, maxTimeInRole: 30), + L("Backup Team", CommandNodeType.TaskForce, "Ready team for entry-team rescue.", null, new[] { "Confined Space Technician" }, true, minUnitPersonnel: 2), L("Rigging", CommandNodeType.Group, "Mechanical advantage, haul and lowering systems."), L("Atmospheric Monitoring", CommandNodeType.Group, "Continuous air monitoring and ventilation."), L("Medical", CommandNodeType.Group, "Victim and entrant medical support.", new[] { "Ambulance", "Medic" }), diff --git a/Core/Resgrid.Model/CommandBoards/CommandBoardTemplateLane.cs b/Core/Resgrid.Model/CommandBoards/CommandBoardTemplateLane.cs index 83a21466e..c7a0d07be 100644 --- a/Core/Resgrid.Model/CommandBoards/CommandBoardTemplateLane.cs +++ b/Core/Resgrid.Model/CommandBoards/CommandBoardTemplateLane.cs @@ -23,5 +23,29 @@ public class CommandBoardTemplateLane /// at least one suggested unit type or personnel role exists in the department. /// public bool ForceRequirements { get; set; } + + /// + /// Lane identification color (hex). Carried onto the created definition role and, from there, + /// onto runtime board lanes and the map markers of assigned resources. + /// + public string Color { get; set; } + + /// Minimum units this lane wants filled (0 = none; advisory under-filled indicator). + public int MinUnits { get; set; } + + /// Maximum units in this lane at once (0 = unlimited). + public int MaxUnits { get; set; } + + /// Minimum personnel riding a unit for it to fit this lane (0 = none). + public int MinUnitPersonnel { get; set; } + + /// Maximum personnel riding a unit for it to fit this lane (0 = none). + public int MaxUnitPersonnel { get; set; } + + /// Minimum minutes a resource should stay before rotating out (0 = none; advisory). + public int MinTimeInRole { get; set; } + + /// Minutes before an assigned resource shows rotation-due (0 = none). + public int MaxTimeInRole { get; set; } } } diff --git a/Core/Resgrid.Model/CommandDefinitionRole.cs b/Core/Resgrid.Model/CommandDefinitionRole.cs index ccc1f5dfb..c460dd12e 100644 --- a/Core/Resgrid.Model/CommandDefinitionRole.cs +++ b/Core/Resgrid.Model/CommandDefinitionRole.cs @@ -27,6 +27,8 @@ public class CommandDefinitionRole : IEntity public int MaxUnitPersonnel { get; set; } + public int MinUnits { get; set; } + public int MaxUnits { get; set; } public int MinTimeInRole { get; set; } @@ -46,6 +48,12 @@ public class CommandDefinitionRole : IEntity /// public int SortOrder { get; set; } + /// + /// Display color for this lane (hex, e.g. "#e74c3c"). Seeded onto the runtime + /// CommandStructureNode so board lanes and map markers inherit it. + /// + public string Color { get; set; } + public virtual ICollection RequiredUnitTypes { get; set; } public virtual ICollection RequiredRoles { get; set; } diff --git a/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs b/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs index abcd72b53..b09ebbf52 100644 --- a/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs +++ b/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs @@ -24,6 +24,9 @@ public class CommandStructureNode : IEntity, IChangeTracked public string Name { get; set; } + /// Display color for this lane (hex, e.g. "#e74c3c"); resources assigned to the lane inherit it on maps. Null = default. + public string Color { get; set; } + /// Parent node for branch/division/group hierarchies; null for top-level nodes. public string ParentNodeId { get; set; } @@ -36,6 +39,27 @@ public class CommandStructureNode : IEntity, IChangeTracked /// The CommandDefinitionRole this node was seeded from, if any. public int? SourceRoleId { get; set; } + /// Minimum personnel riding a unit for it to fill this lane (0 = no minimum). Seeded from the template role. + public int MinUnitPersonnel { get; set; } + + /// Maximum personnel riding a unit for it to fill this lane (0 = no maximum). Seeded from the template role. + public int MaxUnitPersonnel { get; set; } + + /// Minimum units this lane wants filled (0 = none; advisory readiness indicator, never blocks). + public int MinUnits { get; set; } + + /// Maximum units in this lane at once (0 = unlimited). Seeded from the template role. + public int MaxUnits { get; set; } + + /// Minimum minutes a resource should stay before rotating out (0 = none; advisory only). + public int MinTimeInRole { get; set; } + + /// Maximum minutes a resource should work this lane before rotation (0 = none; surfaced as rotation-due). + public int MaxTimeInRole { get; set; } + + /// When true, unmet lane requirements block assignment instead of warning. Seeded from the template role. + public bool ForceRequirements { get; set; } + /// Soft-delete tombstone so a lane removed offline propagates on delta sync (null = live). public DateTime? DeletedOn { get; set; } diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentReport.cs b/Core/Resgrid.Model/IncidentCommand/IncidentReport.cs index e9e087a1f..7af5a2344 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentReport.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentReport.cs @@ -33,4 +33,58 @@ public class IncidentAfterActionReport public List Timeline { get; set; } = new List(); public List Roles { get; set; } = new List(); } + + /// + /// NFIRS/NERIS-oriented key-times report: the timestamps and counts a department needs when + /// filling federal incident reporting (NFIRS Basic Module / NERIS incident times). + /// + public class IncidentTimesReport + { + public int CallId { get; set; } + /// Alarm time โ€” when the call was logged (NFIRS "Alarm Date/Time"). + public DateTime? AlarmOn { get; set; } + public DateTime? CommandEstablishedOn { get; set; } + /// First resource assignment on the board (proxy for first tactical commitment). + public DateTime? FirstResourceAssignedOn { get; set; } + /// First completed benchmark objective (e.g. primary search complete). + public DateTime? FirstBenchmarkCompletedOn { get; set; } + /// Last completed objective (NFIRS "Controlled" proxy when benchmarks model control). + public DateTime? LastBenchmarkCompletedOn { get; set; } + public DateTime? CommandClosedOn { get; set; } + public double DurationMinutes { get; set; } + /// Distinct department + linked units committed to the incident. + public int UnitResourceCount { get; set; } + /// Distinct department + linked personnel committed to the incident. + public int PersonnelResourceCount { get; set; } + /// Ad-hoc resources from an outside agency (NFIRS "Mutual aid received" indicator). + public int MutualAidResourceCount { get; set; } + /// Every completed benchmark with its elapsed minutes from alarm. + public List Benchmarks { get; set; } = new List(); + } + + /// A completed benchmark objective and when it was reached relative to the alarm. + public class BenchmarkTime + { + public string Name { get; set; } + public DateTime? CompletedOn { get; set; } + public double? MinutesFromAlarm { get; set; } + } + + /// Per-resource utilization across the incident: which lanes it worked and for how long. + public class ResourceUtilizationReport + { + public int CallId { get; set; } + public List Rows { get; set; } = new List(); + } + + /// One assignment stint: a resource's time in one lane. + public class ResourceUtilizationRow + { + public int ResourceKind { get; set; } + public string ResourceId { get; set; } + public string LaneName { get; set; } + public DateTime AssignedOn { get; set; } + public DateTime? ReleasedOn { get; set; } + public double Minutes { get; set; } + } } diff --git a/Core/Resgrid.Model/IncidentCommand/VoiceTransmissionLog.cs b/Core/Resgrid.Model/IncidentCommand/VoiceTransmissionLog.cs new file mode 100644 index 000000000..fbe7a295d --- /dev/null +++ b/Core/Resgrid.Model/IncidentCommand/VoiceTransmissionLog.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// One push-to-talk transmission on an incident voice channel: who keyed up, on which channel, and for how long. + /// Written by clients when a PTT transmission ends; read back as the channel's transmission log (ยง3.4). + /// + public class VoiceTransmissionLog : IEntity + { + public string VoiceTransmissionLogId { get; set; } + + public int DepartmentId { get; set; } + + public int CallId { get; set; } + + /// The on-demand incident channel this transmission occurred on. + public string DepartmentVoiceChannelId { get; set; } + + public string UserId { get; set; } + + public DateTime StartedOn { get; set; } + + public DateTime? EndedOn { get; set; } + + [NotMapped] + public string TableName => "VoiceTransmissionLogs"; + + [NotMapped] + public string IdName => "VoiceTransmissionLogId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return VoiceTransmissionLogId; } + set { VoiceTransmissionLogId = (string)value; } + } + + [NotMapped] + [JsonIgnore] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Repositories/IIncidentCommandRepositories.cs b/Core/Resgrid.Model/Repositories/IIncidentCommandRepositories.cs index f18d67813..aa3c6ce7a 100644 --- a/Core/Resgrid.Model/Repositories/IIncidentCommandRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IIncidentCommandRepositories.cs @@ -41,4 +41,8 @@ public interface IIncidentAttachmentRepository : IRepository { System.Threading.Tasks.Task> GetAllMetadataByDepartmentIdAsync(int departmentId); } + + public interface IVoiceTransmissionLogRepository : IRepository + { + } } diff --git a/Core/Resgrid.Model/Services/IIncidentReportingService.cs b/Core/Resgrid.Model/Services/IIncidentReportingService.cs index fe5059c5e..33b20466e 100644 --- a/Core/Resgrid.Model/Services/IIncidentReportingService.cs +++ b/Core/Resgrid.Model/Services/IIncidentReportingService.cs @@ -11,5 +11,14 @@ public interface IIncidentReportingService Task GetIncidentSummaryAsync(int departmentId, int callId); Task GetAfterActionReportAsync(int departmentId, int callId); Task ExportTimelineCsvAsync(int departmentId, int callId); + + /// NFIRS/NERIS-oriented key times and resource counts for federal/NFPA reporting. + Task GetIncidentTimesReportAsync(int departmentId, int callId); + + /// Per-resource lane utilization (which lanes, how long) across the incident. + Task GetResourceUtilizationReportAsync(int departmentId, int callId); + + /// Full after-action export as a multi-section CSV (summary, times, roles, lanes, utilization, timeline). + Task ExportAfterActionCsvAsync(int departmentId, int callId); } } diff --git a/Core/Resgrid.Model/Services/IIncidentVoiceService.cs b/Core/Resgrid.Model/Services/IIncidentVoiceService.cs index f7c11bcee..3b7a2bab8 100644 --- a/Core/Resgrid.Model/Services/IIncidentVoiceService.cs +++ b/Core/Resgrid.Model/Services/IIncidentVoiceService.cs @@ -22,5 +22,11 @@ public interface IIncidentVoiceService /// Closes (soft-close) all open on-demand tactical channels for a call. Task CloseIncidentChannelsForCallAsync(int departmentId, int callId, string userId, CancellationToken cancellationToken = default(CancellationToken)); + + /// Records one completed PTT transmission (who keyed up, on which channel, start/end). + Task LogTransmissionAsync(VoiceTransmissionLog log, CancellationToken cancellationToken = default(CancellationToken)); + + /// Gets the transmission log for a call's incident channels, newest first. + Task> GetTransmissionLogForCallAsync(int departmentId, int callId); } } diff --git a/Core/Resgrid.Services/IncidentCommandService.cs b/Core/Resgrid.Services/IncidentCommandService.cs index e6db89cb5..b907092b9 100644 --- a/Core/Resgrid.Services/IncidentCommandService.cs +++ b/Core/Resgrid.Services/IncidentCommandService.cs @@ -169,8 +169,16 @@ public IncidentCommandService( CallId = callId, NodeType = role.LaneType, Name = role.Name, + Color = role.Color, SortOrder = role.SortOrder, - SourceRoleId = role.CommandDefinitionRoleId + SourceRoleId = role.CommandDefinitionRoleId, + MinUnitPersonnel = role.MinUnitPersonnel, + MaxUnitPersonnel = role.MaxUnitPersonnel, + MinUnits = role.MinUnits, + MaxUnits = role.MaxUnits, + MinTimeInRole = role.MinTimeInRole, + MaxTimeInRole = role.MaxTimeInRole, + ForceRequirements = role.ForceRequirements }; await _commandStructureNodeRepository.InsertAsync(Touch(node), cancellationToken); @@ -1060,6 +1068,23 @@ public async Task> GetNodesForCallAsync(int departmen assignment.RequirementsWarning = violation != null; assignment.RequirementsWarningMessage = violation; + // Early-rotation advisory (MinTimeInRole): rotating a resource out before the source lane's + // minimum stint never blocks โ€” the IC may have good reason โ€” but the move gets flagged. + if (!string.IsNullOrWhiteSpace(assignment.CommandStructureNodeId) && assignment.CommandStructureNodeId != targetNodeId) + { + var sourceNode = await _commandStructureNodeRepository.GetByIdAsync(assignment.CommandStructureNodeId); + if (sourceNode != null && sourceNode.MinTimeInRole > 0) + { + var minutesInLane = (DateTime.UtcNow - assignment.AssignedOn).TotalMinutes; + if (minutesInLane < sourceNode.MinTimeInRole) + { + var earlyWarning = $"Rotated out of lane '{sourceNode.Name}' after {Math.Round(minutesInLane)} of its minimum {sourceNode.MinTimeInRole} minutes."; + assignment.RequirementsWarning = true; + assignment.RequirementsWarningMessage = string.IsNullOrWhiteSpace(assignment.RequirementsWarningMessage) ? earlyWarning : $"{assignment.RequirementsWarningMessage} {earlyWarning}"; + } + } + } + assignment.CommandStructureNodeId = targetNodeId; assignment = await _resourceAssignmentRepository.SaveOrUpdateAsync(Touch(assignment), cancellationToken); @@ -1094,20 +1119,58 @@ public async Task> GetNodesForCallAsync(int departmen /// private async Task<(string violation, bool enforced)> EvaluateNodeRequirementsAsync(CommandStructureNode node, int departmentId, int resourceKind, string resourceId) { - if (node == null || !node.SourceRoleId.HasValue) + if (node == null) return (null, false); - var role = await _commandsService.GetRoleWithRequirementsAsync(node.SourceRoleId.Value); - if (role == null) + // Ad-hoc (external mutual-aid style) units and personnel created at incident time always bypass + // lane requirements โ€” even when the lane forces them โ€” and never get an advisory warning. The IC + // vouches for outside resources; the department's unit types/roles don't apply to them. + if (resourceKind == (int)ResourceAssignmentKind.AdHocUnit || resourceKind == (int)ResourceAssignmentKind.AdHocPersonnel) return (null, false); + var role = node.SourceRoleId.HasValue ? await _commandsService.GetRoleWithRequirementsAsync(node.SourceRoleId.Value) : null; + var enforced = node.ForceRequirements || (role?.ForceRequirements ?? false); + var isUnitKind = resourceKind == (int)ResourceAssignmentKind.RealUnit || resourceKind == (int)ResourceAssignmentKind.LinkedDeptUnit; + + // Lane capacity (MaxUnits): every active unit-kind assignment occupies a slot โ€” ad-hoc units + // included (they bypass qualification checks but still take up space in the lane). + if (isUnitKind && node.MaxUnits > 0) + { + var laneAssignments = await GetAssignmentsForCallAsync(departmentId, node.CallId); + var unitCount = laneAssignments.Count(a => a.ReleasedOn == null + && a.CommandStructureNodeId == node.CommandStructureNodeId + && (a.ResourceKind == (int)ResourceAssignmentKind.RealUnit || a.ResourceKind == (int)ResourceAssignmentKind.LinkedDeptUnit || a.ResourceKind == (int)ResourceAssignmentKind.AdHocUnit) + && !(a.ResourceKind == resourceKind && a.ResourceId == resourceId)); + + if (unitCount >= node.MaxUnits) + return ($"Lane '{node.Name}' is at its maximum of {node.MaxUnits} unit(s).", enforced); + } + + // Unit staffing (MinUnitPersonnel/MaxUnitPersonnel): riders currently on the unit's active + // role seats. Own-department units only โ€” riders can't be resolved for linked-department units. + if (resourceKind == (int)ResourceAssignmentKind.RealUnit && (node.MinUnitPersonnel > 0 || node.MaxUnitPersonnel > 0) + && int.TryParse(resourceId, out var staffedUnitId)) + { + var riders = await _unitsService.GetActiveRolesForUnitAsync(staffedUnitId); + var riderCount = riders?.Count(r => !string.IsNullOrWhiteSpace(r.UserId)) ?? 0; + + if (node.MinUnitPersonnel > 0 && riderCount < node.MinUnitPersonnel) + return ($"Lane '{node.Name}' requires at least {node.MinUnitPersonnel} personnel riding the unit (currently {riderCount}).", enforced); + + if (node.MaxUnitPersonnel > 0 && riderCount > node.MaxUnitPersonnel) + return ($"Lane '{node.Name}' allows at most {node.MaxUnitPersonnel} personnel riding the unit (currently {riderCount}).", enforced); + } + + if (role == null) + return (null, enforced); + string violation = null; if (resourceKind == (int)ResourceAssignmentKind.RealUnit) { var requiredUnitTypes = role.RequiredUnitTypes?.Select(x => x.UnitTypeId).ToList(); if (requiredUnitTypes == null || requiredUnitTypes.Count == 0) - return (null, role.ForceRequirements); + return (null, enforced); Unit unit = null; if (int.TryParse(resourceId, out var unitId)) @@ -1136,14 +1199,14 @@ public async Task> GetNodesForCallAsync(int departmen { var requiredRoles = role.RequiredRoles?.Select(x => x.PersonnelRoleId).ToList(); if (requiredRoles == null || requiredRoles.Count == 0) - return (null, role.ForceRequirements); + return (null, enforced); var userRoles = await _personnelRolesService.GetRolesForUserAsync(resourceId, departmentId); if (userRoles == null || !userRoles.Any(x => requiredRoles.Contains(x.PersonnelRoleId))) violation = $"This member does not hold a personnel role required by lane '{node.Name}'."; } - return (violation, role.ForceRequirements); + return (violation, enforced); } public async Task> GetAssignmentsForCallAsync(int departmentId, int callId) diff --git a/Core/Resgrid.Services/IncidentReportingService.cs b/Core/Resgrid.Services/IncidentReportingService.cs index b74e6beb7..c99dfd5cd 100644 --- a/Core/Resgrid.Services/IncidentReportingService.cs +++ b/Core/Resgrid.Services/IncidentReportingService.cs @@ -15,10 +15,14 @@ namespace Resgrid.Services public class IncidentReportingService : IIncidentReportingService { private readonly IIncidentCommandService _incidentCommandService; + private readonly ICallsService _callsService; + private readonly IIncidentResourcesService _incidentResourcesService; - public IncidentReportingService(IIncidentCommandService incidentCommandService) + public IncidentReportingService(IIncidentCommandService incidentCommandService, ICallsService callsService, IIncidentResourcesService incidentResourcesService) { _incidentCommandService = incidentCommandService; + _callsService = callsService; + _incidentResourcesService = incidentResourcesService; } public async Task GetIncidentSummaryAsync(int departmentId, int callId) @@ -88,6 +92,138 @@ public async Task ExportTimelineCsvAsync(int departmentId, int callId) return sb.ToString(); } + public async Task GetIncidentTimesReportAsync(int departmentId, int callId) + { + var command = await _incidentCommandService.GetCommandForCallAsync(departmentId, callId); + if (command == null) + return null; + + var call = await _callsService.GetCallByIdAsync(callId); + // CallId is guessable โ€” never report on another department's call. + if (call != null && call.DepartmentId != departmentId) + return null; + + var assignments = await _incidentCommandService.GetAssignmentsForCallAsync(departmentId, callId); + var objectives = await _incidentCommandService.GetObjectivesForCallAsync(departmentId, callId); + var adHocUnits = await _incidentResourcesService.GetAdHocUnitsForCallAsync(departmentId, callId); + var adHocPersonnel = await _incidentResourcesService.GetAdHocPersonnelForCallAsync(departmentId, callId); + + var alarm = call?.LoggedOn; + var completedBenchmarks = objectives + .Where(o => o.Status == (int)TacticalObjectiveStatus.Complete && o.CompletedOn.HasValue) + .OrderBy(o => o.CompletedOn) + .ToList(); + + var end = command.ClosedOn ?? DateTime.UtcNow; + + return new IncidentTimesReport + { + CallId = callId, + AlarmOn = alarm, + CommandEstablishedOn = command.EstablishedOn, + FirstResourceAssignedOn = assignments.OrderBy(a => a.AssignedOn).FirstOrDefault()?.AssignedOn, + FirstBenchmarkCompletedOn = completedBenchmarks.FirstOrDefault()?.CompletedOn, + LastBenchmarkCompletedOn = completedBenchmarks.LastOrDefault()?.CompletedOn, + CommandClosedOn = command.ClosedOn, + DurationMinutes = System.Math.Round((end - (alarm ?? command.EstablishedOn)).TotalMinutes, 1), + UnitResourceCount = assignments + .Where(a => a.ResourceKind == (int)ResourceAssignmentKind.RealUnit || a.ResourceKind == (int)ResourceAssignmentKind.LinkedDeptUnit) + .Select(a => a.ResourceId).Distinct().Count(), + PersonnelResourceCount = assignments + .Where(a => a.ResourceKind == (int)ResourceAssignmentKind.RealPersonnel || a.ResourceKind == (int)ResourceAssignmentKind.LinkedDeptPersonnel) + .Select(a => a.ResourceId).Distinct().Count(), + MutualAidResourceCount = adHocUnits.Count(u => !string.IsNullOrWhiteSpace(u.ExternalAgencyName)) + + adHocPersonnel.Count(person => !string.IsNullOrWhiteSpace(person.ExternalAgencyName)), + Benchmarks = completedBenchmarks.Select(o => new BenchmarkTime + { + Name = o.Name, + CompletedOn = o.CompletedOn, + MinutesFromAlarm = alarm.HasValue && o.CompletedOn.HasValue ? System.Math.Round((o.CompletedOn.Value - alarm.Value).TotalMinutes, 1) : (double?)null + }).ToList() + }; + } + + public async Task GetResourceUtilizationReportAsync(int departmentId, int callId) + { + var command = await _incidentCommandService.GetCommandForCallAsync(departmentId, callId); + if (command == null) + return null; + + var nodes = await _incidentCommandService.GetNodesForCallAsync(departmentId, callId); + var assignments = await _incidentCommandService.GetAssignmentsForCallAsync(departmentId, callId); + var laneNames = nodes.ToDictionary(n => n.CommandStructureNodeId, n => n.Name); + + var rows = assignments + .OrderBy(a => a.AssignedOn) + .Select(a => new ResourceUtilizationRow + { + ResourceKind = a.ResourceKind, + ResourceId = a.ResourceId, + LaneName = !string.IsNullOrWhiteSpace(a.CommandStructureNodeId) && laneNames.ContainsKey(a.CommandStructureNodeId) ? laneNames[a.CommandStructureNodeId] : string.Empty, + AssignedOn = a.AssignedOn, + ReleasedOn = a.ReleasedOn, + Minutes = System.Math.Round(((a.ReleasedOn ?? command.ClosedOn ?? DateTime.UtcNow) - a.AssignedOn).TotalMinutes, 1) + }) + .ToList(); + + return new ResourceUtilizationReport { CallId = callId, Rows = rows }; + } + + public async Task ExportAfterActionCsvAsync(int departmentId, int callId) + { + var report = await GetAfterActionReportAsync(departmentId, callId); + if (report == null) + return string.Empty; + + var times = await GetIncidentTimesReportAsync(departmentId, callId); + var utilization = await GetResourceUtilizationReportAsync(departmentId, callId); + + var sb = new StringBuilder(); + + sb.AppendLine("Section,Field,Value"); + sb.AppendLine($"Summary,CallId,{report.Summary.CallId}"); + sb.AppendLine($"Summary,EstablishedOn,{report.Summary.EstablishedOn:o}"); + sb.AppendLine($"Summary,ClosedOn,{report.Summary.ClosedOn:o}"); + sb.AppendLine($"Summary,DurationMinutes,{report.Summary.DurationMinutes}"); + sb.AppendLine($"Summary,Lanes,{report.Summary.LaneCount}"); + sb.AppendLine($"Summary,ActiveAssignments,{report.Summary.ActiveAssignmentCount}"); + sb.AppendLine($"Summary,Objectives,{report.Summary.CompletedObjectiveCount}/{report.Summary.ObjectiveCount}"); + + if (times != null) + { + sb.AppendLine($"Times,AlarmOn,{times.AlarmOn:o}"); + sb.AppendLine($"Times,CommandEstablishedOn,{times.CommandEstablishedOn:o}"); + sb.AppendLine($"Times,FirstResourceAssignedOn,{times.FirstResourceAssignedOn:o}"); + sb.AppendLine($"Times,FirstBenchmarkCompletedOn,{times.FirstBenchmarkCompletedOn:o}"); + sb.AppendLine($"Times,LastBenchmarkCompletedOn,{times.LastBenchmarkCompletedOn:o}"); + sb.AppendLine($"Times,UnitResources,{times.UnitResourceCount}"); + sb.AppendLine($"Times,PersonnelResources,{times.PersonnelResourceCount}"); + sb.AppendLine($"Times,MutualAidResources,{times.MutualAidResourceCount}"); + foreach (var benchmark in times.Benchmarks) + sb.AppendLine($"Benchmark,{Escape(benchmark.Name)},{benchmark.CompletedOn:o}"); + } + + sb.AppendLine(); + sb.AppendLine("Lane,NodeType,Name"); + foreach (var node in report.Nodes) + sb.AppendLine($"Lane,{(CommandNodeType)node.NodeType},{Escape(node.Name)}"); + + if (utilization != null) + { + sb.AppendLine(); + sb.AppendLine("ResourceKind,ResourceId,Lane,AssignedOn,ReleasedOn,Minutes"); + foreach (var row in utilization.Rows) + sb.AppendLine($"{(ResourceAssignmentKind)row.ResourceKind},{Escape(row.ResourceId)},{Escape(row.LaneName)},{row.AssignedOn:o},{row.ReleasedOn:o},{row.Minutes}"); + } + + sb.AppendLine(); + sb.AppendLine("OccurredOn,EntryType,Description,UserId"); + foreach (var entry in report.Timeline) + sb.AppendLine($"{entry.OccurredOn:o},{(CommandLogEntryType)entry.EntryType},{Escape(entry.Description)},{Escape(entry.UserId)}"); + + return sb.ToString(); + } + private static string Escape(string value) { if (string.IsNullOrEmpty(value)) diff --git a/Core/Resgrid.Services/IncidentVoiceService.cs b/Core/Resgrid.Services/IncidentVoiceService.cs index 85d501c5a..401027c7b 100644 --- a/Core/Resgrid.Services/IncidentVoiceService.cs +++ b/Core/Resgrid.Services/IncidentVoiceService.cs @@ -21,6 +21,7 @@ public class IncidentVoiceService : IIncidentVoiceService private readonly IVoiceService _voiceService; private readonly IDepartmentsService _departmentsService; private readonly ICommandLogEntryRepository _commandLogEntryRepository; + private readonly IVoiceTransmissionLogRepository _voiceTransmissionLogRepository; private readonly IIncidentCommandRepository _incidentCommandRepository; private readonly IEventAggregator _eventAggregator; private readonly ICoreEventService _coreEventService; @@ -29,6 +30,7 @@ public IncidentVoiceService( IVoiceService voiceService, IDepartmentsService departmentsService, ICommandLogEntryRepository commandLogEntryRepository, + IVoiceTransmissionLogRepository voiceTransmissionLogRepository, IIncidentCommandRepository incidentCommandRepository, IEventAggregator eventAggregator, ICoreEventService coreEventService) @@ -36,6 +38,7 @@ public IncidentVoiceService( _voiceService = voiceService; _departmentsService = departmentsService; _commandLogEntryRepository = commandLogEntryRepository; + _voiceTransmissionLogRepository = voiceTransmissionLogRepository; _incidentCommandRepository = incidentCommandRepository; _eventAggregator = eventAggregator; _coreEventService = coreEventService; @@ -104,6 +107,35 @@ public async Task> GetChannelsForCallAsync(int depa return true; } + public async Task LogTransmissionAsync(VoiceTransmissionLog log, CancellationToken cancellationToken = default(CancellationToken)) + { + if (log == null || string.IsNullOrWhiteSpace(log.DepartmentVoiceChannelId) || string.IsNullOrWhiteSpace(log.UserId)) + return null; + + // The channel must be one of this call's open incident channels for the caller's department โ€” + // prevents writing transmissions against another department's channel ids. + var channels = await GetChannelsForCallAsync(log.DepartmentId, log.CallId); + if (channels == null || channels.All(c => c.DepartmentVoiceChannelId != log.DepartmentVoiceChannelId)) + return null; + + if (string.IsNullOrWhiteSpace(log.VoiceTransmissionLogId)) + log.VoiceTransmissionLogId = Guid.NewGuid().ToString(); + if (log.StartedOn == default(DateTime)) + log.StartedOn = DateTime.UtcNow; + + // Append-only insert (see WriteLogAsync note about pre-set GUIDs and SaveOrUpdateAsync). + return await _voiceTransmissionLogRepository.InsertAsync(log, cancellationToken); + } + + public async Task> GetTransmissionLogForCallAsync(int departmentId, int callId) + { + var logs = await _voiceTransmissionLogRepository.GetAllByDepartmentIdAsync(departmentId); + if (logs == null) + return new List(); + + return logs.Where(l => l.CallId == callId).OrderByDescending(l => l.StartedOn).ToList(); + } + private async Task WriteLogAsync(int departmentId, int callId, CommandLogEntryType type, string description, string userId, CancellationToken cancellationToken) { var commands = await _incidentCommandRepository.GetAllByDepartmentIdAsync(departmentId); diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0092_AddLaneColorAndVoiceTransmissionLog.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0092_AddLaneColorAndVoiceTransmissionLog.cs new file mode 100644 index 000000000..ffb65c467 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0092_AddLaneColorAndVoiceTransmissionLog.cs @@ -0,0 +1,94 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Lane display colors (map marker tinting per lane) and the PTT transmission log for + /// on-demand incident voice channels (who keyed up, on which channel, start/end). + /// + [Migration(92)] + public class M0092_AddLaneColorAndVoiceTransmissionLog : Migration + { + public override void Up() + { + if (Schema.Table("CommandStructureNodes").Exists() && !Schema.Table("CommandStructureNodes").Column("Color").Exists()) + { + Alter.Table("CommandStructureNodes") + .AddColumn("Color").AsString(32).Nullable(); + } + + if (Schema.Table("CommandDefinitionRoles").Exists() && !Schema.Table("CommandDefinitionRoles").Column("Color").Exists()) + { + Alter.Table("CommandDefinitionRoles") + .AddColumn("Color").AsString(32).Nullable(); + } + + if (Schema.Table("CommandDefinitionRoles").Exists() && !Schema.Table("CommandDefinitionRoles").Column("MinUnits").Exists()) + { + Alter.Table("CommandDefinitionRoles") + .AddColumn("MinUnits").AsInt32().NotNullable().WithDefaultValue(0); + } + + // Per-lane runtime constraints, denormalized from the source template role at seeding so + // assignment-time enforcement never needs the (mutable) definition. + if (Schema.Table("CommandStructureNodes").Exists()) + { + foreach (var column in new[] { "MinUnitPersonnel", "MaxUnitPersonnel", "MinUnits", "MaxUnits", "MinTimeInRole", "MaxTimeInRole" }) + { + if (!Schema.Table("CommandStructureNodes").Column(column).Exists()) + { + Alter.Table("CommandStructureNodes") + .AddColumn(column).AsInt32().NotNullable().WithDefaultValue(0); + } + } + + if (!Schema.Table("CommandStructureNodes").Column("ForceRequirements").Exists()) + { + Alter.Table("CommandStructureNodes") + .AddColumn("ForceRequirements").AsBoolean().NotNullable().WithDefaultValue(false); + } + } + + if (!Schema.Table("VoiceTransmissionLogs").Exists()) + { + Create.Table("VoiceTransmissionLogs") + .WithColumn("VoiceTransmissionLogId").AsString(128).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("CallId").AsInt32().NotNullable() + .WithColumn("DepartmentVoiceChannelId").AsString(128).Nullable() + .WithColumn("UserId").AsString(450).Nullable() + .WithColumn("StartedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime) + .WithColumn("EndedOn").AsDateTime2().Nullable(); + + Create.Index("IX_VoiceTransmissionLogs_Department_Call") + .OnTable("VoiceTransmissionLogs") + .OnColumn("DepartmentId").Ascending() + .OnColumn("CallId").Ascending(); + } + } + + public override void Down() + { + if (Schema.Table("VoiceTransmissionLogs").Exists()) + Delete.Table("VoiceTransmissionLogs"); + + if (Schema.Table("CommandStructureNodes").Exists() && Schema.Table("CommandStructureNodes").Column("Color").Exists()) + Delete.Column("Color").FromTable("CommandStructureNodes"); + + if (Schema.Table("CommandDefinitionRoles").Exists() && Schema.Table("CommandDefinitionRoles").Column("Color").Exists()) + Delete.Column("Color").FromTable("CommandDefinitionRoles"); + + if (Schema.Table("CommandDefinitionRoles").Exists() && Schema.Table("CommandDefinitionRoles").Column("MinUnits").Exists()) + Delete.Column("MinUnits").FromTable("CommandDefinitionRoles"); + + if (Schema.Table("CommandStructureNodes").Exists()) + { + foreach (var column in new[] { "MinUnitPersonnel", "MaxUnitPersonnel", "MinUnits", "MaxUnits", "MinTimeInRole", "MaxTimeInRole", "ForceRequirements" }) + { + if (Schema.Table("CommandStructureNodes").Column(column).Exists()) + Delete.Column(column).FromTable("CommandStructureNodes"); + } + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0092_AddLaneColorAndVoiceTransmissionLogPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0092_AddLaneColorAndVoiceTransmissionLogPg.cs new file mode 100644 index 000000000..5e9e20d67 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0092_AddLaneColorAndVoiceTransmissionLogPg.cs @@ -0,0 +1,94 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Lane display colors (map marker tinting per lane) and the PTT transmission log for + /// on-demand incident voice channels (who keyed up, on which channel, start/end). + /// + [Migration(92)] + public class M0092_AddLaneColorAndVoiceTransmissionLogPg : Migration + { + public override void Up() + { + if (Schema.Table("commandstructurenodes").Exists() && !Schema.Table("commandstructurenodes").Column("color").Exists()) + { + Alter.Table("commandstructurenodes") + .AddColumn("color").AsString(32).Nullable(); + } + + if (Schema.Table("commanddefinitionroles").Exists() && !Schema.Table("commanddefinitionroles").Column("color").Exists()) + { + Alter.Table("commanddefinitionroles") + .AddColumn("color").AsString(32).Nullable(); + } + + if (Schema.Table("commanddefinitionroles").Exists() && !Schema.Table("commanddefinitionroles").Column("minunits").Exists()) + { + Alter.Table("commanddefinitionroles") + .AddColumn("minunits").AsInt32().NotNullable().WithDefaultValue(0); + } + + // Per-lane runtime constraints, denormalized from the source template role at seeding so + // assignment-time enforcement never needs the (mutable) definition. + if (Schema.Table("commandstructurenodes").Exists()) + { + foreach (var column in new[] { "minunitpersonnel", "maxunitpersonnel", "minunits", "maxunits", "mintimeinrole", "maxtimeinrole" }) + { + if (!Schema.Table("commandstructurenodes").Column(column).Exists()) + { + Alter.Table("commandstructurenodes") + .AddColumn(column).AsInt32().NotNullable().WithDefaultValue(0); + } + } + + if (!Schema.Table("commandstructurenodes").Column("forcerequirements").Exists()) + { + Alter.Table("commandstructurenodes") + .AddColumn("forcerequirements").AsBoolean().NotNullable().WithDefaultValue(false); + } + } + + if (!Schema.Table("VoiceTransmissionLogs".ToLower()).Exists()) + { + Create.Table("VoiceTransmissionLogs".ToLower()) + .WithColumn("VoiceTransmissionLogId".ToLower()).AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("DepartmentId".ToLower()).AsInt32().NotNullable() + .WithColumn("CallId".ToLower()).AsInt32().NotNullable() + .WithColumn("DepartmentVoiceChannelId".ToLower()).AsCustom("citext").Nullable() + .WithColumn("UserId".ToLower()).AsCustom("citext").Nullable() + .WithColumn("StartedOn".ToLower()).AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime) + .WithColumn("EndedOn".ToLower()).AsDateTime2().Nullable(); + + Create.Index("IX_VoiceTransmissionLogs_Department_Call".ToLower()) + .OnTable("VoiceTransmissionLogs".ToLower()) + .OnColumn("DepartmentId".ToLower()).Ascending() + .OnColumn("CallId".ToLower()).Ascending(); + } + } + + public override void Down() + { + if (Schema.Table("VoiceTransmissionLogs".ToLower()).Exists()) + Delete.Table("VoiceTransmissionLogs".ToLower()); + + if (Schema.Table("commandstructurenodes").Exists() && Schema.Table("commandstructurenodes").Column("color").Exists()) + Delete.Column("color").FromTable("commandstructurenodes"); + + if (Schema.Table("commanddefinitionroles").Exists() && Schema.Table("commanddefinitionroles").Column("color").Exists()) + Delete.Column("color").FromTable("commanddefinitionroles"); + + if (Schema.Table("commanddefinitionroles").Exists() && Schema.Table("commanddefinitionroles").Column("minunits").Exists()) + Delete.Column("minunits").FromTable("commanddefinitionroles"); + + if (Schema.Table("commandstructurenodes").Exists()) + { + foreach (var column in new[] { "minunitpersonnel", "maxunitpersonnel", "minunits", "maxunits", "mintimeinrole", "maxtimeinrole", "forcerequirements" }) + { + if (Schema.Table("commandstructurenodes").Column(column).Exists()) + Delete.Column(column).FromTable("commandstructurenodes"); + } + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/IncidentCommandRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/IncidentCommandRepositories.cs index 2cda5e246..827d07782 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/IncidentCommandRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/IncidentCommandRepositories.cs @@ -162,4 +162,12 @@ public async Task> GetAllMetadataByDepartmentIdA return await select(_unitOfWork.CreateOrGetConnection()); } } + + public class VoiceTransmissionLogRepository : RepositoryBase, IVoiceTransmissionLogRepository + { + public VoiceTransmissionLogRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + } + } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs index 9684c720d..f7a8a6ed0 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs @@ -113,6 +113,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index d0711ade7..53172b8a6 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -117,6 +117,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs b/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs index 253711112..f0df8f124 100644 --- a/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs +++ b/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs @@ -800,5 +800,112 @@ public async Task GetChangesSinceAsync_FullSync_ReturnsAllRowsIncludingNullModif changes.Commands.Should().HaveCount(2); changes.Commands.Should().Contain(c => c.IncidentCommandId == "c-legacy"); } + // Lane capacity / staffing / rotation constraints (MaxUnits, MinUnitPersonnel, MinTimeInRole) โ€” + // denormalized onto the node at seeding and enforced with the same Force semantics. + + private void ArrangeConstrainedLane(string nodeId = "node-1", int maxUnits = 0, int minUnitPersonnel = 0, int minTimeInRole = 0, bool force = true) + { + _commandRepo.Setup(x => x.GetByIdAsync("ic1")).ReturnsAsync(new IncidentCommand + { + IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Status = (int)IncidentCommandStatus.Active + }); + + _nodeRepo.Setup(x => x.GetByIdAsync(nodeId)).ReturnsAsync(new CommandStructureNode + { + CommandStructureNodeId = nodeId, DepartmentId = Dept, CallId = CallId, Name = "Fire Attack", + MaxUnits = maxUnits, MinUnitPersonnel = minUnitPersonnel, MinTimeInRole = minTimeInRole, ForceRequirements = force + }); + + _assignmentRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ResourceAssignment a, CancellationToken ct, bool b) => a); + _assignmentRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ResourceAssignment a, CancellationToken ct, bool b) => a); + } + + [Test] + public async Task AssignResourceAsync_Rejects_UnitOverForcedLaneCapacity() + { + ArrangeConstrainedLane(maxUnits: 1); + // One active unit already fills the lane's single slot. + _assignmentRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new ResourceAssignment { ResourceAssignmentId = "existing", DepartmentId = Dept, CallId = CallId, CommandStructureNodeId = "node-1", ResourceKind = (int)ResourceAssignmentKind.RealUnit, ResourceId = "77" } + }); + + Func act = () => _service.AssignResourceAsync(NewAssignment((int)ResourceAssignmentKind.RealUnit, "5"), "user1"); + + await act.Should().ThrowAsync(); + _assignmentRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task AssignResourceAsync_AllowsAdHocUnit_EvenWhenForcedLaneIsFull() + { + // Ad-hoc (mutual aid) resources bypass every lane check โ€” the IC vouches for them. + ArrangeConstrainedLane(maxUnits: 1); + _assignmentRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new ResourceAssignment { ResourceAssignmentId = "existing", DepartmentId = Dept, CallId = CallId, CommandStructureNodeId = "node-1", ResourceKind = (int)ResourceAssignmentKind.RealUnit, ResourceId = "77" } + }); + + var saved = await _service.AssignResourceAsync(NewAssignment((int)ResourceAssignmentKind.AdHocUnit, "adhoc-1"), "user1"); + + saved.Should().NotBeNull(); + saved.RequirementsWarning.Should().BeFalse(); + } + + [Test] + public async Task AssignResourceAsync_Rejects_UnderstaffedUnit_WhenLaneForcesMinPersonnel() + { + ArrangeConstrainedLane(minUnitPersonnel: 2); + _assignmentRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List()); + _unitsService.Setup(x => x.GetActiveRolesForUnitAsync(5)).ReturnsAsync(new List + { + new UnitActiveRole { UnitId = 5, Role = "Driver", UserId = "u1" } // 1 rider, lane needs 2 + }); + + Func act = () => _service.AssignResourceAsync(NewAssignment((int)ResourceAssignmentKind.RealUnit, "5"), "user1"); + + await act.Should().ThrowAsync(); + } + + [Test] + public async Task AssignResourceAsync_Warns_UnderstaffedUnit_WhenAdvisory() + { + ArrangeConstrainedLane(minUnitPersonnel: 2, force: false); + _assignmentRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List()); + _unitsService.Setup(x => x.GetActiveRolesForUnitAsync(5)).ReturnsAsync(new List()); + + var saved = await _service.AssignResourceAsync(NewAssignment((int)ResourceAssignmentKind.RealUnit, "5"), "user1"); + + saved.Should().NotBeNull(); + saved.RequirementsWarning.Should().BeTrue(); + saved.RequirementsWarningMessage.Should().Contain("at least 2 personnel"); + } + + [Test] + public async Task MoveResourceAsync_FlagsEarlyRotation_AsAdvisoryOnly() + { + // Source lane wants a 30-minute minimum stint; moving out after 5 minutes succeeds but is flagged. + ArrangeConstrainedLane(nodeId: "node-target", force: true); + _nodeRepo.Setup(x => x.GetByIdAsync("node-source")).ReturnsAsync(new CommandStructureNode + { + CommandStructureNodeId = "node-source", DepartmentId = Dept, CallId = CallId, Name = "Fire Attack", MinTimeInRole = 30 + }); + _assignmentRepo.Setup(x => x.GetByIdAsync("ra1")).ReturnsAsync(new ResourceAssignment + { + ResourceAssignmentId = "ra1", DepartmentId = Dept, CallId = CallId, IncidentCommandId = "ic1", + CommandStructureNodeId = "node-source", ResourceKind = (int)ResourceAssignmentKind.RealUnit, ResourceId = "5", + AssignedOn = DateTime.UtcNow.AddMinutes(-5) + }); + + var moved = await _service.MoveResourceAsync(Dept, "ra1", "node-target", "user1"); + + moved.Should().NotBeNull(); + moved.CommandStructureNodeId.Should().Be("node-target"); + moved.RequirementsWarning.Should().BeTrue(); + moved.RequirementsWarningMessage.Should().Contain("minimum 30 minutes"); + } + } } diff --git a/Tests/Resgrid.Tests/Services/IncidentExportTests.cs b/Tests/Resgrid.Tests/Services/IncidentExportTests.cs index 9d94dfa38..c3164f0db 100644 --- a/Tests/Resgrid.Tests/Services/IncidentExportTests.cs +++ b/Tests/Resgrid.Tests/Services/IncidentExportTests.cs @@ -111,7 +111,7 @@ private static string TimelineCsvWithDescription(string description) UserId = "user1" } }); - var service = new IncidentReportingService(commandService.Object); + var service = new IncidentReportingService(commandService.Object, new Mock().Object, new Mock().Object); return service.ExportTimelineCsvAsync(10, 7).GetAwaiter().GetResult(); } diff --git a/Tests/Resgrid.Tests/Services/IncidentVoiceServiceTests.cs b/Tests/Resgrid.Tests/Services/IncidentVoiceServiceTests.cs index e5a45c691..89d03edff 100644 --- a/Tests/Resgrid.Tests/Services/IncidentVoiceServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/IncidentVoiceServiceTests.cs @@ -50,7 +50,7 @@ public async Task CloseIncidentChannelsForCallAsync_WritesChannelClosedLog_EvenW .ReturnsAsync((CommandLogEntry e, CancellationToken ct, bool b) => e); var service = new IncidentVoiceService(voiceService.Object, departmentsService.Object, logRepo.Object, - commandRepo.Object, eventAggregator.Object, coreEventService.Object); + new Mock().Object, commandRepo.Object, eventAggregator.Object, coreEventService.Object); var result = await service.CloseIncidentChannelsForCallAsync(10, 7, "user1"); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CommandsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CommandsController.cs index ec8553d90..ce9837abb 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CommandsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CommandsController.cs @@ -165,9 +165,11 @@ public async Task> SaveCommand([FromBody] SaveComman Name = lane.Name, Description = lane.Description, LaneType = lane.LaneType, + Color = lane.Color, SortOrder = lane.SortOrder, MinUnitPersonnel = lane.MinUnitPersonnel, MaxUnitPersonnel = lane.MaxUnitPersonnel, + MinUnits = lane.MinUnits, MaxUnits = lane.MaxUnits, MinTimeInRole = lane.MinTimeInRole, MaxTimeInRole = lane.MaxTimeInRole, @@ -243,9 +245,11 @@ private static CommandResultData ConvertCommandData(CommandDefinition command) Name = lane.Name, Description = lane.Description, LaneType = lane.LaneType, + Color = lane.Color, SortOrder = lane.SortOrder, MinUnitPersonnel = lane.MinUnitPersonnel, MaxUnitPersonnel = lane.MaxUnitPersonnel, + MinUnits = lane.MinUnits, MaxUnits = lane.MaxUnits, MinTimeInRole = lane.MinTimeInRole, MaxTimeInRole = lane.MaxTimeInRole, diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportingController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportingController.cs index 84d0316e5..ca6344c8a 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportingController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportingController.cs @@ -84,5 +84,62 @@ public async Task ExportIncident(int callId) var bytes = Encoding.UTF8.GetBytes(csv); return File(bytes, "text/csv", $"incident-{callId}-timeline.csv"); } + + /// Gets the NFIRS/NERIS-oriented key-times report (alarm, command, benchmarks, resource counts). + [HttpGet("GetIncidentTimes/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetIncidentTimes(int callId) + { + var result = new ICModels.IncidentTimesReportResult(); + var report = await _incidentReportingService.GetIncidentTimesReportAsync(DepartmentId, callId); + + if (report == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = report; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Gets per-resource lane utilization (which lanes each resource worked and for how long). + [HttpGet("GetResourceUtilization/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetResourceUtilization(int callId) + { + var result = new ICModels.ResourceUtilizationReportResult(); + var report = await _incidentReportingService.GetResourceUtilizationReportAsync(DepartmentId, callId); + + if (report == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = report; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Exports the full after-action report (summary, times, lanes, utilization, timeline) as CSV. + [HttpGet("ExportAfterAction/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task ExportAfterAction(int callId) + { + var csv = await _incidentReportingService.ExportAfterActionCsvAsync(DepartmentId, callId); + var bytes = Encoding.UTF8.GetBytes(csv); + return File(bytes, "text/csv", $"incident-{callId}-after-action.csv"); + } } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentVoiceController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentVoiceController.cs index 9767ab933..0946bf226 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/IncidentVoiceController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentVoiceController.cs @@ -71,6 +71,60 @@ public IncidentVoiceController(IIncidentVoiceService incidentVoiceService) return result; } + /// Logs one completed PTT transmission on an incident channel (who keyed up, start/end). + [HttpPost("LogTransmission")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> LogTransmission([FromBody] ICModels.LogTransmissionInput input) + { + if (input == null || input.CallId <= 0 || string.IsNullOrWhiteSpace(input.DepartmentVoiceChannelId)) + return BadRequest(); + + var result = new ICModels.VoiceTransmissionLogResult(); + + System.DateTime.TryParse(input.StartedOn, null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var startedOn); + System.DateTime? endedOn = null; + if (System.DateTime.TryParse(input.EndedOn, null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var parsedEnd)) + endedOn = parsedEnd; + + var saved = await _incidentVoiceService.LogTransmissionAsync(new VoiceTransmissionLog + { + DepartmentId = DepartmentId, + CallId = input.CallId, + DepartmentVoiceChannelId = input.DepartmentVoiceChannelId, + UserId = UserId, + StartedOn = startedOn, + EndedOn = endedOn + }, CancellationToken.None); + + if (saved == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = saved; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Gets the PTT transmission log for a call's incident channels, newest first. + [HttpGet("GetTransmissionLog/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetTransmissionLog(int callId) + { + var result = new ICModels.VoiceTransmissionLogsResult(); + result.Data = await _incidentVoiceService.GetTransmissionLogForCallAsync(DepartmentId, callId); + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + /// Closes all open on-demand tactical channels for a call. [HttpPost("CloseIncidentChannels/{callId}")] [ProducesResponseType(StatusCodes.Status200OK)] diff --git a/Web/Resgrid.Web.Services/Models/v4/Commands/CommandModels.cs b/Web/Resgrid.Web.Services/Models/v4/Commands/CommandModels.cs index c1ff5af0c..a4c2967a9 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Commands/CommandModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Commands/CommandModels.cs @@ -41,10 +41,15 @@ public class CommandRoleResultData public int SortOrder { get; set; } public int MinUnitPersonnel { get; set; } public int MaxUnitPersonnel { get; set; } + public int MinUnits { get; set; } + public int MaxUnits { get; set; } public int MinTimeInRole { get; set; } public int MaxTimeInRole { get; set; } + /// Lane identification color (hex, e.g. "#e74c3c"); null = default styling. + public string Color { get; set; } + /// /// When true the API rejects assigning/moving own-department resources into this lane unless /// they match the requirement lists below; when false the requirements are advisory (UI hints). @@ -100,9 +105,14 @@ public class SaveCommandLaneInput public int SortOrder { get; set; } public int MinUnitPersonnel { get; set; } public int MaxUnitPersonnel { get; set; } + public int MinUnits { get; set; } + public int MaxUnits { get; set; } public int MinTimeInRole { get; set; } public int MaxTimeInRole { get; set; } + + /// Lane identification color (hex, e.g. "#e74c3c"); null = default styling. + public string Color { get; set; } public bool ForceRequirements { get; set; } /// UnitTypeIds required to assign a unit to this lane (empty = unrestricted). diff --git a/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs b/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs index b5f7571a5..4494cd99f 100644 --- a/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs @@ -104,6 +104,35 @@ public class IncidentMapAnnotationResult : StandardApiResponseV4Base public Resgrid.Model.IncidentMapAnnotation Data { get; set; } } + public class IncidentTimesReportResult : StandardApiResponseV4Base + { + public Resgrid.Model.IncidentTimesReport Data { get; set; } + } + + public class ResourceUtilizationReportResult : StandardApiResponseV4Base + { + public Resgrid.Model.ResourceUtilizationReport Data { get; set; } + } + + public class VoiceTransmissionLogResult : StandardApiResponseV4Base + { + public Resgrid.Model.VoiceTransmissionLog Data { get; set; } + } + + public class VoiceTransmissionLogsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + /// Input logging one completed PTT transmission on an incident channel. + public class LogTransmissionInput + { + public int CallId { get; set; } + public string DepartmentVoiceChannelId { get; set; } + public string StartedOn { get; set; } + public string EndedOn { get; set; } + } + public class IncidentNoteResult : StandardApiResponseV4Base { public Resgrid.Model.IncidentNote Data { get; set; } diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index d74ef291c..4cb13278b 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -665,11 +665,11 @@ - + Exchanges an external SSO token (OIDC id_token or base64-encoded SAMLResponse) for a Resgrid access token. Supports grant_type=external_token with fields: - provider (saml2|oidc), external_token, department_code, scope (optional), + provider (saml2|oidc), external_token, department_code or department_token, scope (optional), totp_code (required when the user has Resgrid 2FA enrolled). SSO authentication does NOT bypass Resgrid's own Two-Factor Authentication. When the user has 2FA enabled in Resgrid, a valid totp_code must be supplied @@ -1028,6 +1028,15 @@ Updates the incident action plan. + + Updates the command-post location used by maps and incident weather. + + + Adds an internal or public operational status note to the incident. + + + Uploads an incident-level internal or public file using multipart/form-data. + Gets the personnel accountability / PAR status (Green/Warning/Critical) for the incident. @@ -1084,6 +1093,15 @@ Exports the incident command timeline as a CSV file. + + Gets the NFIRS/NERIS-oriented key-times report (alarm, command, benchmarks, resource counts). + + + Gets per-resource lane utilization (which lanes each resource worked and for how long). + + + Exports the full after-action report (summary, times, lanes, utilization, timeline) as CSV. + Incident-scoped ad-hoc resources: create units/personnel on the fly for non-Resgrid resources, build @@ -1143,6 +1161,12 @@ Gets the open on-demand tactical channels for a call (visible to assigned units/users). + + Logs one completed PTT transmission on an incident channel (who keyed up, start/end). + + + Gets the PTT transmission log for a call's incident channels, newest first. + Closes all open on-demand tactical channels for a call. @@ -1516,6 +1540,12 @@ ID of the protocol attachment + + + Anonymous, token-scoped public incident status feed. It exposes only records explicitly marked Public and only + while the Incident Commander/PIO has public sharing enabled. Disabling sharing revokes the token immediately. + + Reporting and analytics for the caller's department: a composite dashboard, realtime personnel/ @@ -6488,6 +6518,21 @@ A predefined lane (role) within a command definition. + + Lane identification color (hex, e.g. "#e74c3c"); null = default styling. + + + + When true the API rejects assigning/moving own-department resources into this lane unless + they match the requirement lists below; when false the requirements are advisory (UI hints). + + + + UnitTypeIds a unit must match to satisfy this lane (empty = unrestricted). + + + PersonnelRoleIds a member must hold (any one) to satisfy this lane (empty = unrestricted). + Result wrapper for a collection of command definitions. @@ -6508,6 +6553,15 @@ Input payload for a single lane within a command definition. + + Lane identification color (hex, e.g. "#e74c3c"); null = default styling. + + + UnitTypeIds required to assign a unit to this lane (empty = unrestricted). + + + PersonnelRoleIds required (any one) to assign a member to this lane (empty = unrestricted). + Result of getting all communication tests for a department @@ -7477,6 +7531,16 @@ Input to move a resource assignment to a different node. + + + Human-readable requirements notice. On Status=failure: why the assignment was rejected (forced + lane requirements not met). On Status=success: an advisory warning when the resource doesn't + meet the lane's non-forced requirements (also stamped on Data.RequirementsWarning/-Message). + + + + Input logging one completed PTT transmission on an incident channel. + Simple boolean action result (delete/release operations). diff --git a/Web/Resgrid.Web/Areas/User/Controllers/CommandController.cs b/Web/Resgrid.Web/Areas/User/Controllers/CommandController.cs index 4068b3a28..f0a84d765 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/CommandController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/CommandController.cs @@ -263,6 +263,20 @@ private static List ParseAssignmentsFromForm(IFormCollect assignment.ForceRequirements = form[$"assignmentLock_{i}"] .Any(value => bool.TryParse(value, out var forceRequirements) && forceRequirements); + // Optional lane limits โ€” blank/invalid values mean "no limit" (0). + assignment.MinUnits = ParseLimitField(form[$"assignmentMinUnits_{i}"]); + assignment.MaxUnits = ParseLimitField(form[$"assignmentMaxUnits_{i}"]); + assignment.MinUnitPersonnel = ParseLimitField(form[$"assignmentMinUnitPersonnel_{i}"]); + assignment.MaxUnitPersonnel = ParseLimitField(form[$"assignmentMaxUnitPersonnel_{i}"]); + assignment.MinTimeInRole = ParseLimitField(form[$"assignmentMinTimeInRole_{i}"]); + assignment.MaxTimeInRole = ParseLimitField(form[$"assignmentMaxTimeInRole_{i}"]); + + // Lane identification color โ€” only well-formed hex values are persisted. + string color = form[$"assignmentColor_{i}"]; + assignment.Color = !string.IsNullOrWhiteSpace(color) && System.Text.RegularExpressions.Regex.IsMatch(color.Trim(), "^#[0-9a-fA-F]{3,8}$") + ? color.Trim() + : null; + // The form is a full document: absent/empty pickers clear the lane's requirements. assignment.RequiredUnitTypes = ParseIdCsv(form[$"assignmentUnitTypes_{i}"]) .Select(id => new CommandDefinitionRoleUnitType { UnitTypeId = id }).ToList(); @@ -275,6 +289,12 @@ private static List ParseAssignmentsFromForm(IFormCollect return assignments; } + /// Parses an optional non-negative lane-limit field; blank or invalid input means "no limit" (0). + private static int ParseLimitField(string value) + { + return int.TryParse(value, out var parsed) && parsed > 0 ? parsed : 0; + } + private static List ParseIdCsv(string value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/Web/Resgrid.Web/Areas/User/Models/Command/NewCommandView.cs b/Web/Resgrid.Web/Areas/User/Models/Command/NewCommandView.cs index c0c599848..859f7a1f5 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Command/NewCommandView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Command/NewCommandView.cs @@ -6,6 +6,19 @@ namespace Resgrid.Web.Areas.User.Models.Command { public class NewCommandView { + /// Lane identification palette โ€” keep in sync with the IC app's LANE_COLORS swatches. + public static readonly IReadOnlyList<(string Name, string Value)> LaneColors = new List<(string, string)> + { + ("Red", "#e74c3c"), + ("Orange", "#e67e22"), + ("Yellow", "#f1c40f"), + ("Green", "#2ecc71"), + ("Teal", "#1abc9c"), + ("Blue", "#3498db"), + ("Purple", "#9b59b6"), + ("Gray", "#7f8c8d"), + }; + public string Message { get; set; } public CommandDefinition Command { get; set; } public List CallTypes { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Views/Command/Templates.cshtml b/Web/Resgrid.Web/Areas/User/Views/Command/Templates.cshtml index 641b20751..d4490d91c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Command/Templates.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Command/Templates.cshtml @@ -80,7 +80,7 @@ { + style="display:inline-block; margin: 0 4px 5px 0; font-weight: normal;@(string.IsNullOrWhiteSpace(lane.Color) ? null : $" background-color:{lane.Color};")"> @lane.Name } diff --git a/Web/Resgrid.Web/Areas/User/Views/Command/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Command/View.cshtml index 49d930985..9ae9ddb94 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Command/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Command/View.cshtml @@ -88,11 +88,29 @@ var requiredRoleIds = assignment.RequiredRoles?.Select(x => x.PersonnelRoleId).ToList() ?? new System.Collections.Generic.List(); var requiredUnitTypeNames = Model.UnitTypes.Where(x => requiredUnitTypeIds.Contains(x.UnitTypeId)).Select(x => x.Type).ToList(); var requiredRoleNames = Model.PersonnelRoles.Where(x => requiredRoleIds.Contains(x.PersonnelRoleId)).Select(x => x.Name).ToList(); + var limitParts = new System.Collections.Generic.List(); + if (assignment.MinUnits > 0) { limitParts.Add($"min {assignment.MinUnits} unit(s)"); } + if (assignment.MaxUnits > 0) { limitParts.Add($"max {assignment.MaxUnits} unit(s)"); } + if (assignment.MinUnitPersonnel > 0) { limitParts.Add($"min {assignment.MinUnitPersonnel} riding"); } + if (assignment.MaxUnitPersonnel > 0) { limitParts.Add($"max {assignment.MaxUnitPersonnel} riding"); } + if (assignment.MinTimeInRole > 0) { limitParts.Add($"min {assignment.MinTimeInRole} min in lane"); } + if (assignment.MaxTimeInRole > 0) { limitParts.Add($"rotate after {assignment.MaxTimeInRole} min"); } @(i + 1) - @assignment.Name + + @if (!string.IsNullOrWhiteSpace(assignment.Color)) + { + + }@assignment.Name + @laneTypeName - @assignment.Description + + @assignment.Description + @if (limitParts.Count > 0) + { +
Limits: @string.Join(", ", limitParts) + } + @if (requiredUnitTypeNames.Count == 0 && requiredRoleNames.Count == 0) { diff --git a/Web/Resgrid.Web/Areas/User/Views/Command/_AssignmentModal.cshtml b/Web/Resgrid.Web/Areas/User/Views/Command/_AssignmentModal.cshtml index 0e56474de..b5b4d007d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Command/_AssignmentModal.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Command/_AssignmentModal.cshtml @@ -47,6 +47,51 @@ Personnel must hold one of these roles to fit this lane. Leave empty for anyone. +
+ + + Identification color for this lane โ€” board lanes and the map markers of assigned units/personnel are tinted with it. +
+
+ +
+
+ +
+
+ +
+
+ How many units this lane should have. Below Min Units the lane shows as under-filled (never blocks). At Max Units further unit assignments are blocked (Force on) or warned (Force off). +
+
+ +
+
+ +
+
+ Personnel riding a unit for it to fit this lane (e.g. an interior attack lane may require at least 2 on the truck). Checked when a unit is assigned; blocked or warned per Force Requirements. +
+
+ +
+
+ +
+
+ Rotation guidance in minutes. Rotating a resource out before Min Time flags the move (advisory only). Past Max Time the resource shows as rotation-due on the board โ€” assignments are never auto-removed. +
diff --git a/Web/Resgrid.Web/Areas/User/Views/Command/_CommandForm.cshtml b/Web/Resgrid.Web/Areas/User/Views/Command/_CommandForm.cshtml index 0ae71a07c..717cbd84b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Command/_CommandForm.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Command/_CommandForm.cshtml @@ -67,6 +67,8 @@ Name Lane Type Description + Color + Limits Requirements Add Lane @@ -93,6 +95,25 @@ + + + + +
+ + + + + + +
+ ') .attr('name', 'assignmentDescription_' + index).val(description)); + var colorCell = $(''); + var colorSelect = $('#assignment-color').clone().removeAttr('id').attr({ + name: 'assignmentColor_' + index, + 'aria-label': 'Lane color', + 'class': 'form-control assignment-color-inline' + }).val(laneColor); + colorCell.append(colorSelect); + + var limitsCell = $(''); + var limitsWrap = $('
'); + function limitInput(name, value, placeholder, title) { + return $('') + .attr({ name: name + '_' + index, placeholder: placeholder, title: title, 'aria-label': title }).val(value); + } + limitsWrap.append(limitInput('assignmentMinUnits', limits.minUnits, 'Min U', 'Minimum units this lane should have (advisory under-filled indicator)')); + limitsWrap.append(limitInput('assignmentMaxUnits', limits.maxUnits, 'Max U', 'Maximum units in this lane at once (blocks or warns per Force Requirements)')); + limitsWrap.append(limitInput('assignmentMinUnitPersonnel', limits.minUnitPersonnel, 'Min P', 'Minimum personnel riding a unit for it to fit this lane')); + limitsWrap.append(limitInput('assignmentMaxUnitPersonnel', limits.maxUnitPersonnel, 'Max P', 'Maximum personnel riding a unit for it to fit this lane')); + limitsWrap.append(limitInput('assignmentMinTimeInRole', limits.minTimeInRole, 'Min T', 'Minimum minutes before rotating out (early rotation is flagged, never blocked)')); + limitsWrap.append(limitInput('assignmentMaxTimeInRole', limits.maxTimeInRole, 'Max T', 'Minutes before a resource shows rotation-due on the board')); + limitsCell.append(limitsWrap); + const requirementsCell = $(''); var unitTypeSelect = $('').attr({ name: 'assignmentUnitTypes_' + index, @@ -81,7 +114,7 @@ var resgrid; row.remove(); })); - row.append(nameCell).append(laneCell).append(descriptionCell).append(requirementsCell).append(actionCell); + row.append(nameCell).append(laneCell).append(descriptionCell).append(colorCell).append(limitsCell).append(requirementsCell).append(actionCell); $('#assignments tbody').first().append(row); unitTypeSelect.select2({ placeholder: 'Any unit type', allowClear: true, width: '100%' }); roleSelect.select2({ placeholder: 'Any personnel', allowClear: true, width: '100%' }); From cb521f8095ee63c885197e4a1eb9cfe46d0925a7 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 20 Jul 2026 17:22:11 -0700 Subject: [PATCH 4/4] RIC-T39 PR#433 fixes --- .../v4/IncidentReportingController.cs | 3 + .../Resgrid.Web.Services.xml | 1116 ++++++++--------- 2 files changed, 561 insertions(+), 558 deletions(-) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportingController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportingController.cs index ca6344c8a..fa29317b0 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportingController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportingController.cs @@ -138,6 +138,9 @@ public async Task ExportIncident(int callId) public async Task ExportAfterAction(int callId) { var csv = await _incidentReportingService.ExportAfterActionCsvAsync(DepartmentId, callId); + if (string.IsNullOrEmpty(csv)) + return NotFound(); + var bytes = Encoding.UTF8.GetBytes(csv); return File(bytes, "text/csv", $"incident-{callId}-after-action.csv"); } diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 4cb13278b..dcfc18092 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -3951,52 +3951,6 @@ Is the user a group admin
- - - UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a - function that is setting status for the current user. - - - - - The state/staffing level of the user to set for the user. - - - - - Note for the staffing level - - - - - The result object for a state/staffing level request. - - - - - The UserId GUID/UUID for the user state/staffing level being return - - - - - The full name of the user for the state/staffing level being returned - - - - - The current staffing level (state) type for the user - - - - - The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. - - - - - Staffing note for the User's staffing - - Input data to add a staffing schedule in the Resgrid system @@ -4102,6 +4056,52 @@ Note for this staffing schedule + + + UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a + function that is setting status for the current user. + + + + + The state/staffing level of the user to set for the user. + + + + + Note for the staffing level + + + + + The result object for a state/staffing level request. + + + + + The UserId GUID/UUID for the user state/staffing level being return + + + + + The full name of the user for the state/staffing level being returned + + + + + The current staffing level (state) type for the user + + + + + The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + + + + + Staffing note for the User's staffing + + A resrouce in the system this could be a user or unit @@ -8087,379 +8087,209 @@ Identifier of the new npte - + - The result of getting all personnel filters for the system + A GPS location for a point in time of a specificed person - + - The Id value of the filter + PersonId of the person that the location is for - + - The type of the filter + The timestamp of the location in UTC - + - The filters name + GPS Latitude of the Person - + - Result containing all the data required to populate the New Call form + GPS Longitude of the Person - + - Response Data + GPS Latitude\Longitude Accuracy of the Person - + - Result that contains all the options available to filter personnel against compatible Resgrid APIs + GPS Altitude of the Person - + - Response Data + GPS Altitude Accuracy of the Person - + - Result containing all the data required to populate the New Call form + GPS Speed of the Person - + - Response Data + GPS Heading of the Person - + - Information about a User + A unit location in the Resgrid system - + - The UserId GUID/UUID for the user + Response Data - + - DepartmentId of the deparment the user belongs to + The information about a specific unit's location - + - Department specificed ID number for this user + Id of the Person - + - The Users First Name + The Timestamp for the location in UTC - + - The Users Last Name + GPS Latitude of the Person - + - The Users Email Address + GPS Longitude of the Person - + - The Users Mobile Telephone Number + GPS Latitude\Longitude Accuracy of the Person - + - GroupId the user is assigned to (0 for no group) + GPS Altitude of the Person - + - Name of the group the user is assigned to + GPS Altitude Accuracy of the Person - + - Enumeration/List of roles the user currently holds + GPS Speed of the Person - + - The current action/status type for the user + GPS Heading of the Person - + - The current action/status string for the user + The result of getting the current staffing for a user - + - The current action/status color hex string for the user + Response Data - + - The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. + Information about a User staffing - + - The current action/status destination id for the user + The UserId GUID/UUID for the user status being return - + - The current action/status destination name for the user + DepartmentId of the deparment the user belongs to - + - The current staffing level (state) type for the user + The current staffing type for the user - + - The current staffing level (state) string for the user + The timestamp of the last staffing. This is converted UTC version of the timestamp. - + - The current staffing level (state) color hex string for the user + The timestamp of the last staffing. This is converted UTC to the departments, or users, TimeZone. - + - The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + Note for this staffing - + - Users last known location + Saves (sets) and Personnel Staffing in the system, for a single user - + - Sorting weight for the user + UnitId of the apparatus that the state is being set for - + - User Defined Field values for this personnel record + The UnitStateType of the Unit - + - A GPS location for a point in time of a specificed person + The timestamp of the status event in UTC - + - PersonId of the person that the location is for + The timestamp of the status event in the local time of the device - + - The timestamp of the location in UTC + User provided note for this event - + - GPS Latitude of the Person + The event id used for queuing on mobile applications - + - GPS Longitude of the Person + Depicts a result after saving a person status - + - GPS Latitude\Longitude Accuracy of the Person + Response Data - + - GPS Altitude of the Person - - - - - GPS Altitude Accuracy of the Person - - - - - GPS Speed of the Person - - - - - GPS Heading of the Person - - - - - A unit location in the Resgrid system - - - - - Response Data - - - - - The information about a specific unit's location - - - - - Id of the Person - - - - - The Timestamp for the location in UTC - - - - - GPS Latitude of the Person - - - - - GPS Longitude of the Person - - - - - GPS Latitude\Longitude Accuracy of the Person - - - - - GPS Altitude of the Person - - - - - GPS Altitude Accuracy of the Person - - - - - GPS Speed of the Person - - - - - GPS Heading of the Person - - - - - The result of getting the current staffing for a user - - - - - Response Data - - - - - Information about a User staffing - - - - - The UserId GUID/UUID for the user status being return - - - - - DepartmentId of the deparment the user belongs to - - - - - The current staffing type for the user - - - - - The timestamp of the last staffing. This is converted UTC version of the timestamp. - - - - - The timestamp of the last staffing. This is converted UTC to the departments, or users, TimeZone. - - - - - Note for this staffing - - - - - Saves (sets) and Personnel Staffing in the system, for a single user - - - - - UnitId of the apparatus that the state is being set for - - - - - The UnitStateType of the Unit - - - - - The timestamp of the status event in UTC - - - - - The timestamp of the status event in the local time of the device - - - - - User provided note for this event - - - - - The event id used for queuing on mobile applications - - - - - Depicts a result after saving a person status - - - - - Response Data - - - - - Saves (sets) and Personnel Status in the system, for a single user + Saves (sets) and Personnel Status in the system, for a single user @@ -8760,113 +8590,283 @@ Response Data
- + - Result containing all the data required to populate the New Call form + The result of getting all personnel filters for the system - + - Response Data + The Id value of the filter - + - Details of a protocol + The type of the filter - + - Protocol id + The filters name - + - Department id + Result containing all the data required to populate the New Call form - + - Name of the Protocol + Response Data - + - Protocol code + Result that contains all the options available to filter personnel against compatible Resgrid APIs - + - This this protocol disabled + Response Data - + - Protocol description + Result containing all the data required to populate the New Call form - + - Text of the protocol + Response Data - + - UTC date and time when the Protocol was created + Information about a User - + - UserId of the user who created the protocol + The UserId GUID/UUID for the user - + - UTC timestamp of when the Protocol was updated + DepartmentId of the deparment the user belongs to - + - Minimum triggering Weight of the Protocol + Department specificed ID number for this user - + - UserId that last updated the Protocol + The Users First Name - + - Triggers used to activate this Protocol + The Users Last Name - + - Attachments for this Protocol + The Users Email Address - + - Questions used to determine if this Protocol needs to be used or not + The Users Mobile Telephone Number - + - State type + GroupId the user is assigned to (0 for no group) - + - Result containing all the data required to populate the New Call form + Name of the group the user is assigned to - + - Response Data + Enumeration/List of roles the user currently holds - - Composite dashboard report (scalar totals, dense series, breakdowns). + + + The current action/status type for the user + + + + + The current action/status string for the user + + + + + The current action/status color hex string for the user + + + + + The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. + + + + + The current action/status destination id for the user + + + + + The current action/status destination name for the user + + + + + The current staffing level (state) type for the user + + + + + The current staffing level (state) string for the user + + + + + The current staffing level (state) color hex string for the user + + + + + The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + + + + + Users last known location + + + + + Sorting weight for the user + + + + + User Defined Field values for this personnel record + + + + + Result containing all the data required to populate the New Call form + + + + + Response Data + + + + + Details of a protocol + + + + + Protocol id + + + + + Department id + + + + + Name of the Protocol + + + + + Protocol code + + + + + This this protocol disabled + + + + + Protocol description + + + + + Text of the protocol + + + + + UTC date and time when the Protocol was created + + + + + UserId of the user who created the protocol + + + + + UTC timestamp of when the Protocol was updated + + + + + Minimum triggering Weight of the Protocol + + + + + UserId that last updated the Protocol + + + + + Triggers used to activate this Protocol + + + + + Attachments for this Protocol + + + + + Questions used to determine if this Protocol needs to be used or not + + + + + State type + + + + + Result containing all the data required to populate the New Call form + + + + + Response Data + + + + Composite dashboard report (scalar totals, dense series, breakdowns). Response Data @@ -10131,545 +10131,545 @@ Default constructor
- + - Result that contains all the options available to filter units against compatible Resgrid APIs + Depicts a result after saving a unit status - + Response Data - + - A unit in the Resgrid system + Object inputs for setting a users Status/Action. If this object is used in an operation that sets + a status for the current user the UserId value in this object will be ignored. - + - Response Data + UnitId of the apparatus that the state is being set for - + - The information about a specific unit + The UnitStateType of the Unit - + - Id of the Unit + The Call/Station the unit is responding to - + - The Id of the department the unit is under + Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). - + - Name of the Unit + The timestamp of the status event in UTC - + - Department assigned type for the unit + The timestamp of the status event in the local time of the device - + - Department assigned type id for the unit + User provided note for this event - + - Custom Statuses Set Id + GPS Latitude of the Unit - + - Station Id of the station housing the unit (0 means no station) + GPS Longitude of the Unit - + - Name of the station the unit is under + GPS Latitude\Longitude Accuracy of the Unit - + - Vehicle Identification Number for the unit + GPS Altitude of the Unit - + - Plate Number for the Unit + GPS Altitude Accuracy of the Unit - + - Is the unit 4-Wheel drive + GPS Speed of the Unit - + - Does the unit require a special permit to drive + GPS Heading of the Unit - + - Id number of the units current destionation (0 means no destination) + The event id used for queuing on mobile applications - + - The current status/state of the Unit + The accountability roles filed for this event - + - The Timestamp of the status + Role filled by a User on a Unit for an event - + - The units current Latitude + Id of the locally stored event - + - The units current Longitude + Local Event Id - + - Current user provide status note + UserId of the user filling the role - + - User Defined Field values for this unit + RoleId of the role being filled - + - Unit role information for roles on a unit + The name of the Role - + - Unit Role Id + Depicts a unit status in the Resgrid system. - + - User Id of the user in the role (could be null) + Response Data - + - Name of the Role + Depicts a unit's status - + - Name of the user in the role (could be null) + Unit Id - + - Multiple Unit infos Result + Units Name - + - Response Data + The Type of the Unit - + - Default constructor + Units current Status (State) - + - The information about a specific unit + CSS for status (for display) - + - Id of the Unit + CSS Style for status (for display) - + - The Id of the department the unit is under + Timestamp of this Unit State - + - Name of the Unit + Timestamp in Utc of this Unit State - + - Department assigned type for the unit + Destination Id (Station or Call) - + - Department assigned type id for the unit + Destination type (Station, Call, or POI). - + - Custom Statuses Set Id + Name of the Desination (Call or Station) - + - Station Id of the station housing the unit (0 means no station) + Destination address. - + - Name of the station the unit is under + Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not + suitable for programmatic branching; use as the + machine-readable discriminator instead. - + - Vehicle Identification Number for the unit + Note for the State - + - Plate Number for the Unit + Latitude - + - Is the unit 4-Wheel drive + Longitude - + - Does the unit require a special permit to drive + Name of the Group the Unit is in - + - Id number of the units current destination (0 means no destination) + Id of the Group the Unit is in - + - Name of the units current destination (0 means no destination) + Unit statuses (states) - + - The current status/state of the Unit + Response Data - + - The current status/state of the Unit as a name + Default constructor - + - The current status/state of the Unit color + Result that contains all the options available to filter units against compatible Resgrid APIs - + - The Timestamp of the status + Response Data - + - The Timestamp of the status in UTC/GMT + A unit in the Resgrid system - + - The units current Latitude + Response Data - + - The units current Longitude + The information about a specific unit - + - Current user provide status note + Id of the Unit - + - Units Roles + The Id of the department the unit is under - + - Multiple Units Result + Name of the Unit - + - Response Data + Department assigned type for the unit - + - Default constructor + Department assigned type id for the unit - + - Depicts a result after saving a unit status + Custom Statuses Set Id - + - Response Data + Station Id of the station housing the unit (0 means no station) - + - Object inputs for setting a users Status/Action. If this object is used in an operation that sets - a status for the current user the UserId value in this object will be ignored. + Name of the station the unit is under - + - UnitId of the apparatus that the state is being set for + Vehicle Identification Number for the unit - + - The UnitStateType of the Unit + Plate Number for the Unit - + - The Call/Station the unit is responding to + Is the unit 4-Wheel drive - + - Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). + Does the unit require a special permit to drive - + - The timestamp of the status event in UTC + Id number of the units current destionation (0 means no destination) - + - The timestamp of the status event in the local time of the device + The current status/state of the Unit - + - User provided note for this event + The Timestamp of the status - + - GPS Latitude of the Unit + The units current Latitude - + - GPS Longitude of the Unit + The units current Longitude - + - GPS Latitude\Longitude Accuracy of the Unit + Current user provide status note - + - GPS Altitude of the Unit + User Defined Field values for this unit - + - GPS Altitude Accuracy of the Unit + Unit role information for roles on a unit - + - GPS Speed of the Unit + Unit Role Id - + - GPS Heading of the Unit + User Id of the user in the role (could be null) - + - The event id used for queuing on mobile applications + Name of the Role - + - The accountability roles filed for this event + Name of the user in the role (could be null) - + - Role filled by a User on a Unit for an event + Multiple Unit infos Result - + - Id of the locally stored event + Response Data - + - Local Event Id + Default constructor - + - UserId of the user filling the role + The information about a specific unit - + - RoleId of the role being filled + Id of the Unit - + - The name of the Role + The Id of the department the unit is under - + - Depicts a unit status in the Resgrid system. + Name of the Unit - + - Response Data + Department assigned type for the unit - + - Depicts a unit's status + Department assigned type id for the unit - + - Unit Id + Custom Statuses Set Id - + - Units Name + Station Id of the station housing the unit (0 means no station) - + - The Type of the Unit + Name of the station the unit is under - + - Units current Status (State) + Vehicle Identification Number for the unit - + - CSS for status (for display) + Plate Number for the Unit - + - CSS Style for status (for display) + Is the unit 4-Wheel drive - + - Timestamp of this Unit State + Does the unit require a special permit to drive - + - Timestamp in Utc of this Unit State + Id number of the units current destination (0 means no destination) - + - Destination Id (Station or Call) + Name of the units current destination (0 means no destination) - + - Destination type (Station, Call, or POI). + The current status/state of the Unit - + - Name of the Desination (Call or Station) + The current status/state of the Unit as a name - + - Destination address. + The current status/state of the Unit color - + - Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not - suitable for programmatic branching; use as the - machine-readable discriminator instead. + The Timestamp of the status - + - Note for the State + The Timestamp of the status in UTC/GMT - + - Latitude + The units current Latitude - + - Longitude + The units current Longitude - + - Name of the Group the Unit is in + Current user provide status note - + - Id of the Group the Unit is in + Units Roles - + - Unit statuses (states) + Multiple Units Result - + Response Data - + Default constructor