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..5a192dac8
--- /dev/null
+++ b/Core/Resgrid.Model/CommandBoards/CommandBoardTemplate.cs
@@ -0,0 +1,117 @@
+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,
+ 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)
+ });
+ }
+
+ 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..5992b3144
--- /dev/null
+++ b/Core/Resgrid.Model/CommandBoards/CommandBoardTemplateCatalog.cs
@@ -0,0 +1,353 @@
+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
+ {
+ /// 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)
+ {
+ 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,
+ int minUnits = 0, int maxUnits = 0, int minUnitPersonnel = 0, int maxUnitPersonnel = 0, int minTimeInRole = 0, int maxTimeInRole = 0)
+ {
+ return new CommandBoardTemplateLane
+ {
+ Name = name,
+ LaneType = laneType,
+ Description = description,
+ SuggestedUnitTypes = unitTypes ?? Array.Empty(),
+ SuggestedPersonnelRoles = personnelRoles ?? Array.Empty(),
+ 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,
+ 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" }, 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" }, 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",
+ "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" }, minUnits: 1, minUnitPersonnel: 2),
+ 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, 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.")),
+
+ // ---- 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" }, minUnits: 2),
+ 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" }, 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.")),
+
+ // ---- 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" }, 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.")),
+
+ // ---- 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, 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" }),
+ 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..c7a0d07be
--- /dev/null
+++ b/Core/Resgrid.Model/CommandBoards/CommandBoardTemplateLane.cs
@@ -0,0 +1,51 @@
+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; }
+
+ ///
+ /// 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/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/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/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/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/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/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/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..aa3c6ce7a 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,17 @@ public interface ICommandLogEntryRepository : IRepository
public interface ICommandTransferRepository : IRepository
{
}
+
+ public interface IIncidentNoteRepository : IRepository
+ {
+ }
+
+ public interface IIncidentAttachmentRepository : IRepository
+ {
+ System.Threading.Tasks.Task> GetAllMetadataByDepartmentIdAsync(int departmentId);
+ }
+
+ public interface IVoiceTransmissionLogRepository : IRepository
+ {
+ }
}
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/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.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..ce24f7799 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,293 @@ 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;
+ remainingLifetime += TokenClockSkew;
- 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..04c3a4e0c 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
};
@@ -156,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);
@@ -401,7 +422,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 +451,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 +468,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 +531,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 +603,346 @@ 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.");
+
+ // 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.");
+
+ 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)
@@ -698,6 +1069,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);
@@ -732,20 +1120,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))
@@ -774,14 +1200,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)
@@ -1074,6 +1500,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/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/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs
index 669c1bbe9..46aeaa60e 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"] = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
+ 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