diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 0bf8d36cc..cea54caec 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -24,7 +24,12 @@
"Bash(brew --prefix dotnet)",
"Bash(/opt/homebrew/opt/dotnet/bin/dotnet build:*)",
"Bash(brew info:*)",
- "mcp__graperoot-pro__graph_register_edit"
+ "mcp__graperoot-pro__graph_register_edit",
+ "mcp__graperoot-pro__graph_grep_all",
+ "Bash(/usr/local/share/dotnet/dotnet build *)",
+ "Bash(awk 'NR>=94 && /HttpGet\\\\\\(\"IncomingMessage\"\\\\\\)/{f=1} f{print NR\": \"$0} f && /^\\\\t\\\\t\\\\}$/{c++; if\\(c==1\\) exit}')",
+ "Bash(dotnet test *)",
+ "Bash(git -C /Volumes/USBSSD/dev/Resgrid/Core log --oneline -1 -- Providers/Resgrid.Providers.Migrations/Migrations/M0094_AddIncidentCommandNameAndLocations.cs)"
]
},
"enableAllProjectMcpServers": true,
@@ -53,6 +58,17 @@
}
]
}
+ ],
+ "Stop": [
+ {
+ "matcher": "",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "/Users/shawn/.graperoot-pro/venv/bin/python3 \"/Users/shawn/.graperoot-pro/stop_hook.py\""
+ }
+ ]
+ }
]
}
}
diff --git a/Core/Resgrid.Config/ApiConfig.cs b/Core/Resgrid.Config/ApiConfig.cs
index 4adc192c7..421a7bfe1 100644
--- a/Core/Resgrid.Config/ApiConfig.cs
+++ b/Core/Resgrid.Config/ApiConfig.cs
@@ -15,6 +15,16 @@ public static class ApiConfig
///
public const string CorsAllowedMethods = "GET,POST,PUT,DELETE,OPTIONS";
+ ///
+ /// Comma-separated list of additional origins allowed to make cross-origin (CORS) requests
+ /// to the API and eventing hubs, on top of the configured base urls, their subdomains and
+ /// their shared parent domain (see Resgrid.Config.CorsHelper). Entries with a scheme match
+ /// the exact origin ("http://localhost:8081"); bare hosts match that host on any scheme and
+ /// port ("dispatch.example.com"). A single "*" allows every origin — intended only for
+ /// isolated on-prem or development installs.
+ ///
+ public static string CorsAllowedOrigins = "";
+
///
/// Key used for authing with the backend internal apis
///
diff --git a/Core/Resgrid.Config/CorsHelper.cs b/Core/Resgrid.Config/CorsHelper.cs
new file mode 100644
index 000000000..5f180c980
--- /dev/null
+++ b/Core/Resgrid.Config/CorsHelper.cs
@@ -0,0 +1,175 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Config
+{
+ ///
+ /// Shared CORS origin validation used by the web front-ends (Services API and Eventing/SignalR).
+ /// An origin is allowed when it matches any of:
+ /// 1. An entry in . Entries with a scheme
+ /// ("http://localhost:8081") must match the origin's scheme, host and port exactly; bare
+ /// hosts ("dispatch.example.com") match that host on any scheme/port. A single "*" entry
+ /// allows every origin and is intended only for isolated on-prem or development installs.
+ /// 2. The host of one of the configured base urls (ResgridBaseUrl, ResgridApiBaseUrl,
+ /// ResgridEventingBaseUrl), or any subdomain of one of those hosts.
+ /// 3. The widest safe parent domain of a base-url host, or any subdomain of it. This is what
+ /// lets sibling apps call the API without being listed explicitly: with a base url of
+ /// qaapi.resgrid.dev the parent is resgrid.dev, so qadispatch.resgrid.dev is allowed.
+ /// Parent widening never crosses a public registry suffix (resgrid.co.uk will not widen
+ /// to co.uk) or a known shared-hosting suffix (myorg.github.io will not widen to
+ /// github.io) and is skipped entirely for IP addresses and single-label hosts.
+ ///
+ public static class CorsHelper
+ {
+ // Suffixes that must never be treated as a shared parent domain, because mutually
+ // untrusting parties register siblings directly under them. Two kinds live here:
+ //
+ // 1. Multi-part public registry suffixes (co.uk, com.au, ...). Widening api.resgrid.co.uk
+ // to co.uk would allow every site registered under that suffix to make credentialed
+ // calls. Single-part TLDs (com, dev, net, ...) need no listing: widening already stops
+ // at two labels, so a bare TLD can never be produced as a parent.
+ // 2. Private shared-hosting suffixes (github.io, azurewebsites.net, herokuapp.com, ...).
+ // A deployment served from myorg.github.io must not widen to github.io — every other
+ // tenant on the platform is an attacker-controlled sibling. Widening still works one
+ // level below the suffix (api.myorg.github.io widens to myorg.github.io).
+ //
+ // This is a curated snapshot of the common cases, not the full Public Suffix List. A
+ // deployment under a suffix not listed here should not rely on parent widening at all —
+ // list its sibling origins explicitly in ApiConfig.CorsAllowedOrigins instead.
+ private static readonly HashSet _unsafeParentSuffixes = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ // Public registry suffixes.
+ "co.uk", "org.uk", "me.uk", "ltd.uk", "plc.uk", "net.uk", "sch.uk", "ac.uk", "gov.uk", "nhs.uk",
+ "com.au", "net.au", "org.au", "edu.au", "gov.au", "id.au", "asn.au",
+ "co.nz", "net.nz", "org.nz", "govt.nz", "ac.nz",
+ "co.jp", "ne.jp", "or.jp", "go.jp", "ac.jp",
+ "com.br", "net.br", "org.br", "gov.br",
+ "com.mx", "org.mx", "gob.mx",
+ "co.za", "org.za", "gov.za", "web.za",
+ "co.in", "net.in", "org.in", "gen.in", "firm.in", "ind.in",
+ "com.cn", "net.cn", "org.cn", "gov.cn",
+ "com.sg", "com.hk", "com.tw", "com.my", "com.ph", "com.tr", "com.ar", "com.co",
+ "co.id", "co.kr", "co.th", "co.il",
+
+ // Private shared-hosting suffixes: code hosting pages.
+ "github.io", "gitlab.io", "bitbucket.io",
+
+ // Microsoft Azure.
+ "azurewebsites.net", "azurestaticapps.net", "azurecontainerapps.io", "cloudapp.net",
+ "cloudapp.azure.com", "trafficmanager.net", "azureedge.net", "azurefd.net",
+
+ // Amazon AWS (amazonaws.com blankets S3/ELB/execute-api regional hosts).
+ "amazonaws.com", "cloudfront.net", "elasticbeanstalk.com", "amplifyapp.com", "awsapprunner.com",
+
+ // Google Cloud / Firebase.
+ "appspot.com", "web.app", "firebaseapp.com", "run.app",
+
+ // Cloudflare.
+ "pages.dev", "workers.dev", "r2.dev", "trycloudflare.com",
+
+ // Other common PaaS / static hosting / tunnels.
+ "herokuapp.com", "netlify.app", "vercel.app", "now.sh", "surge.sh", "glitch.me",
+ "onrender.com", "fly.dev", "railway.app", "deno.dev", "koyeb.app",
+ "ondigitalocean.app", "digitaloceanspaces.com",
+ "repl.co", "replit.app",
+ "ngrok.io", "ngrok.app", "ngrok-free.app", "ngrok.dev", "loca.lt"
+ };
+
+ ///
+ /// Returns true when the supplied Origin header value is allowed to make cross-origin
+ /// requests. Suitable for use with CorsPolicyBuilder.SetIsOriginAllowed, including
+ /// policies that also call AllowCredentials (the matched origin is echoed back, never "*").
+ ///
+ public static bool IsAllowedOrigin(string origin)
+ {
+ if (String.IsNullOrWhiteSpace(origin) || !Uri.TryCreate(origin, UriKind.Absolute, out var originUri) || String.IsNullOrWhiteSpace(originUri.Host))
+ return false;
+
+ if (MatchesConfiguredOrigin(originUri))
+ return true;
+
+ foreach (var baseUrl in new[]
+ {
+ SystemBehaviorConfig.ResgridBaseUrl,
+ SystemBehaviorConfig.ResgridApiBaseUrl,
+ SystemBehaviorConfig.ResgridEventingBaseUrl
+ })
+ {
+ if (String.IsNullOrWhiteSpace(baseUrl) || !Uri.TryCreate(baseUrl, UriKind.Absolute, out var baseUri) || String.IsNullOrWhiteSpace(baseUri.Host))
+ continue;
+
+ if (HostMatchesOrIsSubdomainOf(originUri.Host, baseUri.Host))
+ return true;
+
+ var parentDomain = GetWidestSafeParentDomain(baseUri);
+ if (parentDomain != null && HostMatchesOrIsSubdomainOf(originUri.Host, parentDomain))
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool MatchesConfiguredOrigin(Uri originUri)
+ {
+ var configured = ApiConfig.CorsAllowedOrigins;
+ if (String.IsNullOrWhiteSpace(configured))
+ return false;
+
+ foreach (var rawEntry in configured.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries))
+ {
+ var entry = rawEntry.Trim();
+ if (entry.Length == 0)
+ continue;
+
+ if (entry == "*")
+ return true;
+
+ if (entry.Contains("://"))
+ {
+ if (Uri.TryCreate(entry, UriKind.Absolute, out var entryUri) &&
+ String.Equals(originUri.Scheme, entryUri.Scheme, StringComparison.OrdinalIgnoreCase) &&
+ String.Equals(originUri.Host, entryUri.Host, StringComparison.OrdinalIgnoreCase) &&
+ originUri.Port == entryUri.Port)
+ return true;
+ }
+ else if (String.Equals(originUri.Host, entry, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool HostMatchesOrIsSubdomainOf(string originHost, string allowedHost)
+ {
+ return originHost.Equals(allowedHost, StringComparison.OrdinalIgnoreCase) ||
+ originHost.EndsWith("." + allowedHost, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string GetWidestSafeParentDomain(Uri baseUri)
+ {
+ if (baseUri.HostNameType != UriHostNameType.Dns)
+ return null;
+
+ var labels = baseUri.Host.Split('.');
+
+ // Walk from the full host toward the apex (never past two labels), stopping before
+ // any public registry or shared-hosting suffix; the last safe candidate is the widest
+ // usable parent. qaapi.resgrid.dev -> resgrid.dev; api.resgrid.co.uk -> resgrid.co.uk
+ // (co.uk unsafe); api.myorg.github.io -> myorg.github.io (github.io unsafe);
+ // resgrid.com / localhost -> null (base-host matching already covers them).
+ string widest = null;
+ for (int start = 1; start <= labels.Length - 2; start++)
+ {
+ var candidate = String.Join(".", labels, start, labels.Length - start);
+ if (_unsafeParentSuffixes.Contains(candidate))
+ break;
+
+ widest = candidate;
+ }
+
+ return widest;
+ }
+ }
+}
diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx
index 0f3304ec2..91a8aaa9f 100644
--- a/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx
@@ -325,4 +325,10 @@
يجب أن تحتوي كلمة المرور على حرف صغير واحد على الأقل.
يجب أن تتكون كلمة المرور من {0} أحرف على الأقل.
لا يمكن أن يكون الحد الأدنى لطول كلمة المرور أقل من الإعداد الافتراضي للنظام وهو 8 أحرف.
+ Use Calendar Sync
+ Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications.
+ Dispatch App Login
+ Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers.
+ Command App Login
+ Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles.
diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx
index 83f24b462..bf680f32d 100644
--- a/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx
@@ -927,4 +927,10 @@
Das Passwort muss mindestens einen Kleinbuchstaben enthalten.
Das Passwort muss mindestens {0} Zeichen lang sein.
Die Mindestlänge des Passworts darf nicht kleiner sein als der Systemstandard von 8 Zeichen.
+ Use Calendar Sync
+ Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications.
+ Dispatch App Login
+ Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers.
+ Command App Login
+ Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles.
diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx
index 568a926aa..c479a1ec1 100644
--- a/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx
@@ -372,6 +372,12 @@
Minimum password length cannot be less than the system default of 8 characters.
Delete Log Entries
Who in your department is allowed to delete log entries
+ Use Calendar Sync
+ Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications.
+ Dispatch App Login
+ Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers.
+ Command App Login
+ Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles.
diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx
index dc2437fd4..b9c0f0848 100644
--- a/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx
@@ -331,6 +331,12 @@
La contraseña debe contener al menos una letra minúscula.
La contraseña debe tener al menos {0} caracteres.
La longitud mínima de la contraseña no puede ser menor que el valor predeterminado del sistema de 8 caracteres.
+ Use Calendar Sync
+ Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications.
+ Dispatch App Login
+ Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers.
+ Command App Login
+ Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles.
diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx
index 552256860..63c0a2db6 100644
--- a/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx
@@ -927,4 +927,10 @@
Le mot de passe doit contenir au moins une lettre minuscule.
Le mot de passe doit comporter au moins {0} caractères.
La longueur minimale du mot de passe ne peut pas être inférieure à la valeur par défaut du système de 8 caractères.
+ Use Calendar Sync
+ Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications.
+ Dispatch App Login
+ Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers.
+ Command App Login
+ Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles.
diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx
index 2f752ecd3..51fb0b16e 100644
--- a/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx
@@ -927,4 +927,10 @@
La password deve contenere almeno una lettera minuscola.
La password deve essere lunga almeno {0} caratteri.
La lunghezza minima della password non può essere inferiore al valore predefinito di sistema di 8 caratteri.
+ Use Calendar Sync
+ Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications.
+ Dispatch App Login
+ Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers.
+ Command App Login
+ Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles.
diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx
index cfa7b6573..3c04894a2 100644
--- a/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx
@@ -927,4 +927,10 @@
Hasło musi zawierać co najmniej jedną małą literę.
Hasło musi mieć co najmniej {0} znaków.
Minimalna długość hasła nie może być mniejsza niż systemowe minimum wynoszące 8 znaków.
+ Use Calendar Sync
+ Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications.
+ Dispatch App Login
+ Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers.
+ Command App Login
+ Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles.
diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx
index 40a31a97f..b94890373 100644
--- a/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx
@@ -927,4 +927,10 @@
Lösenordet måste innehålla minst en liten bokstav.
Lösenordet måste vara minst {0} tecken långt.
Minsta lösenordslängd kan inte vara kortare än systemets standardvärde på 8 tecken.
+ Use Calendar Sync
+ Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications.
+ Dispatch App Login
+ Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers.
+ Command App Login
+ Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles.
diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx
index 7e452c3cf..8c9225f1e 100644
--- a/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx
@@ -927,4 +927,10 @@
Пароль повинен містити щонайменше одну малу літеру.
Пароль повинен містити щонайменше {0} символів.
Мінімальна довжина пароля не може бути меншою за системний мінімум — 8 символів.
+ Use Calendar Sync
+ Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications.
+ Dispatch App Login
+ Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers.
+ Command App Login
+ Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles.
diff --git a/Core/Resgrid.Model/Chat/ChatEnums.cs b/Core/Resgrid.Model/Chat/ChatEnums.cs
index cd6436bb8..1b246df3a 100644
--- a/Core/Resgrid.Model/Chat/ChatEnums.cs
+++ b/Core/Resgrid.Model/Chat/ChatEnums.cs
@@ -11,7 +11,22 @@ public enum ChatChannelType
Incident = 5,
IncidentLane = 6,
IncidentCommand = 7,
- Chatbot = 8
+ Chatbot = 8,
+
+ ///
+ /// Incident Commander plus every lane's primary and secondary lead — command talking to the
+ /// people running the lanes, without the lane crews. Membership is derived live from the lanes,
+ /// so demoting a lead removes their access on the next check.
+ ///
+ IncidentLeads = 9,
+
+ ///
+ /// The incident's line to the dispatch desk: everyone working the incident on one side, every
+ /// dispatch-authorized user on the other. Per-call rather than department-wide so dispatchers can
+ /// tell which incident is talking to them, and audience-wide on the dispatch side so whichever
+ /// dispatcher is on shift picks it up.
+ ///
+ IncidentDispatch = 10
}
/// Who a chat participant is: a person, a unit-shared identity ("Engine 6"), or the chatbot.
diff --git a/Core/Resgrid.Model/Events/UnitStatusEvent.cs b/Core/Resgrid.Model/Events/UnitStatusEvent.cs
index bbb8a2504..97649dfc6 100644
--- a/Core/Resgrid.Model/Events/UnitStatusEvent.cs
+++ b/Core/Resgrid.Model/Events/UnitStatusEvent.cs
@@ -5,5 +5,11 @@ public class UnitStatusEvent
public int DepartmentId { get; set; }
public UnitState Status { get; set; }
public UnitState PreviousStatus { get; set; }
+
+ ///
+ /// True when the status change was made by an automated process (i.e. call dispatch auto-status)
+ /// and not directly by a user. Automated changes should not generate user notifications.
+ ///
+ public bool AutoGenerated { get; set; }
}
}
\ No newline at end of file
diff --git a/Core/Resgrid.Model/Events/UserStaffingEvent.cs b/Core/Resgrid.Model/Events/UserStaffingEvent.cs
index 528a546d5..12a262ffa 100644
--- a/Core/Resgrid.Model/Events/UserStaffingEvent.cs
+++ b/Core/Resgrid.Model/Events/UserStaffingEvent.cs
@@ -5,5 +5,11 @@ public class UserStaffingEvent
public int DepartmentId { get; set; }
public UserState Staffing { get; set; }
public UserState PreviousStaffing { get; set; }
+
+ ///
+ /// True when the staffing change was made by an automated process (i.e. scheduled department reset)
+ /// and not directly by a user. Automated changes should not generate user notifications.
+ ///
+ public bool AutoGenerated { get; set; }
}
}
\ No newline at end of file
diff --git a/Core/Resgrid.Model/Events/UserStatusEvent.cs b/Core/Resgrid.Model/Events/UserStatusEvent.cs
index 962848ef1..f8e0eb71e 100644
--- a/Core/Resgrid.Model/Events/UserStatusEvent.cs
+++ b/Core/Resgrid.Model/Events/UserStatusEvent.cs
@@ -5,5 +5,11 @@ public class UserStatusEvent
public int DepartmentId { get; set; }
public ActionLog PreviousStatus { get; set; }
public ActionLog Status { get; set; }
+
+ ///
+ /// True when the status change was made by an automated process (i.e. scheduled department reset)
+ /// and not directly by a user. Automated changes should not generate user notifications.
+ ///
+ public bool AutoGenerated { get; set; }
}
}
\ No newline at end of file
diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs b/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs
index 8f0b381e2..888731f03 100644
--- a/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs
+++ b/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs
@@ -72,6 +72,19 @@ public enum IncidentCapabilities
///
public static class IncidentRoleCapabilityMap
{
+ ///
+ /// What a command-authorized user gets on a board they hold no ICS role on — the "help work the
+ /// board" subset: see it, move resources on and off, bring ad-hoc resources in, and keep the
+ /// timers and accountability running.
+ ///
+ /// Deliberately excludes ManageCommand (closing, transferring, the action plan) and
+ /// ManageStructure (creating and deleting lanes): assisting is not commanding, and the shape of
+ /// the incident stays with whoever actually holds it.
+ ///
+ public const IncidentCapabilities CommandAssistCapabilities =
+ IncidentCapabilities.ViewBoard | IncidentCapabilities.AssignResources | IncidentCapabilities.ManageResources |
+ IncidentCapabilities.ManageTimers | IncidentCapabilities.ManageAccountability;
+
public static IncidentCapabilities GetCapabilities(IncidentRoleType role)
{
switch (role)
diff --git a/Core/Resgrid.Model/IncidentCommand/ResourceIncidentView.cs b/Core/Resgrid.Model/IncidentCommand/ResourceIncidentView.cs
index 368e0e158..8616742bd 100644
--- a/Core/Resgrid.Model/IncidentCommand/ResourceIncidentView.cs
+++ b/Core/Resgrid.Model/IncidentCommand/ResourceIncidentView.cs
@@ -45,6 +45,53 @@ public class ResourceIncidentView
/// The caller's active lane assignment, when they have one (null otherwise).
public ResourceLaneAssignmentView MyAssignment { get; set; }
+
+ ///
+ /// ICS positions filled on this incident, with contact details, so a responder can reach the right
+ /// person directly instead of going through command. Empty when nobody holds a position.
+ ///
+ public List Roles { get; set; } = new List();
+
+ ///
+ /// Chat channels the CALLER can actually reach, resolved server-side so clients never have to
+ /// guess at access. Null means "not available to you" — the caller is not command staff, holds no
+ /// lane lead slot, or the channel has not been provisioned.
+ ///
+ public IncidentChatChannels Chat { get; set; } = new IncidentChatChannels();
+ }
+
+ /// Who holds an ICS position on the incident, with the contact details to reach them.
+ public class IncidentRoleContactInfo
+ {
+ /// Maps to .
+ public int RoleType { get; set; }
+
+ public IncidentContactInfo Contact { get; set; }
+ }
+
+ /// The incident's chat channels, filtered to the ones the caller may open.
+ public class IncidentChatChannels
+ {
+ /// The call-wide incident channel (everyone on the call).
+ public string IncidentChannelId { get; set; }
+
+ /// The private command channel — only set for command staff (IC or an ICS role holder).
+ public string CommandChannelId { get; set; }
+
+ /// The "All Leads" channel — only set for the IC and lane primary/secondary leads.
+ public string LeadsChannelId { get; set; }
+
+ /// The caller's own lane channel, when they are assigned to a lane.
+ public string LaneChannelId { get; set; }
+
+ ///
+ /// The incident's line to the dispatch desk. Available to everyone on the incident — a crew
+ /// needing dispatch shouldn't have to route through command to reach them.
+ ///
+ public string DispatchChannelId { get; set; }
+
+ /// True once the incident is closed: the conversations are readable but frozen.
+ public bool IsFrozen { get; set; }
}
/// Contact card for a person relevant to a resource (commander or lane lead).
diff --git a/Core/Resgrid.Model/PermissionTypes.cs b/Core/Resgrid.Model/PermissionTypes.cs
index e2029d68f..9accd3141 100644
--- a/Core/Resgrid.Model/PermissionTypes.cs
+++ b/Core/Resgrid.Model/PermissionTypes.cs
@@ -30,7 +30,23 @@ public enum PermissionTypes
ViewUdfFields = 25,
ManageRoutes = 26,
DeleteLog = 27,
- UseCalendarSync = 28
+ UseCalendarSync = 28,
+
+ ///
+ /// Who may sign in to the Dispatch app. Defaults to everyone in the department (no permission
+ /// row = allowed, per IPermissionsService.IsUserAllowed), and can be narrowed to admins, group
+ /// admins, or selected personnel roles. Dispatch surfaces private command, unit and responder
+ /// traffic, so a department that isn't all-dispatchers should restrict this.
+ ///
+ DispatchAppLogin = 29,
+
+ ///
+ /// Who may act as a commander: sign in to the IC app, establish incident command on a call, and
+ /// read command boards. Defaults to everyone in the department (no permission row = allowed) and
+ /// can be narrowed to admins, group admins, or selected personnel roles — the same ladder as
+ /// .
+ ///
+ CommandAppLogin = 30
}
}
diff --git a/Core/Resgrid.Model/Repositories/IChatRepositories.cs b/Core/Resgrid.Model/Repositories/IChatRepositories.cs
index 2070a184b..200445f7e 100644
--- a/Core/Resgrid.Model/Repositories/IChatRepositories.cs
+++ b/Core/Resgrid.Model/Repositories/IChatRepositories.cs
@@ -35,6 +35,9 @@ public interface IChatChannelRepository : IRepository
/// Archives (or unarchives) every channel anchored to a call; returns affected channel ids.
Task> SetArchivedByCallIdAsync(int callId, bool archived, DateTime? archivedOn);
+ /// Archives/unarchives every channel anchored to one incident command (the command channel and its lane channels), returning the affected channel ids.
+ Task> SetArchivedByIncidentCommandIdAsync(string incidentCommandId, bool archived, DateTime? archivedOn);
+
/// Channels in the department carrying a per-channel retention override.
Task> GetWithRetentionOverrideAsync(int departmentId);
@@ -53,6 +56,12 @@ public interface IChatChannelRepository : IRepository
/// Targeted lock flag update (see ).
Task SetLockedAsync(string chatChannelId, bool locked, string lockedByUserId, DateTime? lockedOn, DateTime modifiedOn, CancellationToken cancellationToken);
+ ///
+ /// Rebinds a reused command-scoped channel (command/leads/dispatch) to a new incident command and
+ /// clears its archived state in one targeted update (see ).
+ ///
+ Task RebindToIncidentCommandAsync(string chatChannelId, string incidentCommandId, DateTime modifiedOn, CancellationToken cancellationToken);
+
///
/// Atomically creates a DM channel plus its member rows in one transaction. The channel insert
/// uses insert-if-absent on (DepartmentId, DmKey) so a losing racer simply reads the winner;
diff --git a/Core/Resgrid.Model/Repositories/ISystemAuditsRepository.cs b/Core/Resgrid.Model/Repositories/ISystemAuditsRepository.cs
index 127a49417..e9af9670b 100644
--- a/Core/Resgrid.Model/Repositories/ISystemAuditsRepository.cs
+++ b/Core/Resgrid.Model/Repositories/ISystemAuditsRepository.cs
@@ -32,5 +32,17 @@ public interface ISystemAuditsRepository : IRepository
/// Page size.
/// Task<IEnumerable<SystemAudit>>.
Task> GetByDepartmentIdPagedAsync(int departmentId, DateTime startDate, DateTime endDate, int page, int pageSize);
+
+ ///
+ /// Gets a date-ranged, paged set of system audits of a single type across all users and
+ /// departments (e.g. every account-deletion request platform-wide).
+ ///
+ /// The value (stored as an int).
+ /// Inclusive lower bound on LoggedOn (UTC).
+ /// Exclusive upper bound on LoggedOn (UTC).
+ /// 1-based page number.
+ /// Page size.
+ /// Task<IEnumerable<SystemAudit>>.
+ Task> GetByTypePagedAsync(int type, DateTime startDate, DateTime endDate, int page, int pageSize);
}
}
diff --git a/Core/Resgrid.Model/Services/IActionLogsService.cs b/Core/Resgrid.Model/Services/IActionLogsService.cs
index f3cfef8ee..bafd36681 100644
--- a/Core/Resgrid.Model/Services/IActionLogsService.cs
+++ b/Core/Resgrid.Model/Services/IActionLogsService.cs
@@ -84,8 +84,9 @@ public interface IActionLogsService
///
/// The action logs.
/// The cancellation token that can be used by other objects or threads to receive notice of cancellation.
+ /// True when the status changes are from an automated or bulk process (i.e. scheduled or department-wide reset) and should not generate user notifications.
/// Task<System.Boolean>.
- Task SaveAllActionLogsAsync(List actionLogs, CancellationToken cancellationToken = default(CancellationToken));
+ Task SaveAllActionLogsAsync(List actionLogs, CancellationToken cancellationToken = default(CancellationToken), bool autoGenerated = false);
///
/// Sets the user action asynchronous.
diff --git a/Core/Resgrid.Model/Services/IChatServices.cs b/Core/Resgrid.Model/Services/IChatServices.cs
index 09c2eaed1..82855199b 100644
--- a/Core/Resgrid.Model/Services/IChatServices.cs
+++ b/Core/Resgrid.Model/Services/IChatServices.cs
@@ -103,12 +103,38 @@ public interface IChatChannelService
Task EnsureCommandChannelAsync(IncidentCommand command, CancellationToken cancellationToken = default(CancellationToken));
+ /// Ensures the incident's "All Leads" channel: the IC and every lane's primary/secondary lead.
+ Task EnsureLeadsChannelAsync(IncidentCommand command, CancellationToken cancellationToken = default(CancellationToken));
+
+ /// Ensures the incident's line to the dispatch desk.
+ Task EnsureDispatchChannelAsync(int departmentId, int callId, string incidentCommandId, CancellationToken cancellationToken = default(CancellationToken));
+
+ ///
+ /// Backfills every chat channel an ACTIVE incident should have — the call's incident channel, the
+ /// command and "All Leads" channels, and one per live lane — inserting only what is missing.
+ ///
+ /// Exists for incidents that were established before those channels were a thing: rather than a
+ /// one-off migration, the read paths call this and the incident heals itself the first time someone
+ /// opens it. Idempotent, and guarded by a short-lived marker so a board that refreshes on a timer
+ /// pays one cache read instead of a channel query. Closed commands are skipped — provisioning a
+ /// channel there would create it unarchived and quietly un-freeze a point-in-time record.
+ ///
+ Task EnsureIncidentChannelsAsync(IncidentCommand command, IEnumerable nodes, CancellationToken cancellationToken = default(CancellationToken));
+
/// Provisions the per-user chatbot channel; only call when a chatbot session starts (never on the channel-list path).
Task EnsureChatbotChannelAsync(int departmentId, string userId, CancellationToken cancellationToken = default(CancellationToken));
/// Archives every channel anchored to a call (call closed); unarchive on reopen.
Task SetIncidentChannelsArchivedAsync(int callId, bool archived, CancellationToken cancellationToken = default(CancellationToken));
+ ///
+ /// Archives every channel anchored to ONE incident command — its command channel and its lane
+ /// channels — leaving the call's own incident channel alone. Used when command is closed while the
+ /// call itself keeps running: the command conversation becomes a point-in-time record while the
+ /// call channel stays live. Unarchive on reopen.
+ ///
+ Task SetCommandChannelsArchivedAsync(string incidentCommandId, bool archived, CancellationToken cancellationToken = default(CancellationToken));
+
/// Department chat settings (config defaults when no row exists); no authorization — safe for any department-scoped caller.
Task GetDepartmentSettingsAsync(int departmentId);
diff --git a/Core/Resgrid.Model/Services/ICommandAccessService.cs b/Core/Resgrid.Model/Services/ICommandAccessService.cs
new file mode 100644
index 000000000..5098824cf
--- /dev/null
+++ b/Core/Resgrid.Model/Services/ICommandAccessService.cs
@@ -0,0 +1,37 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// Who is allowed to act as a commander, per the
+ /// permission: signing in to the IC app, establishing command on a call, and reading command boards.
+ ///
+ /// The mirror of , and enforced the same way — on the server, not
+ /// just in the app. A command board is only a client of the shared API, so a client-side check alone
+ /// would keep nothing private.
+ ///
+ /// Defaults to allowing everyone in the department, so departments that never configure it are
+ /// unaffected.
+ ///
+ public interface ICommandAccessService
+ {
+ /// True when this user may act as a commander for the department.
+ Task CanUseCommandAsync(int departmentId, string userId);
+
+ /// Every user in the department who may act as a commander.
+ Task> GetCommandUserIdsAsync(int departmentId);
+
+ ///
+ /// True when this user may ASSIST on a command board they hold no ICS role on — the capability set
+ /// a dispatcher needs to help work an incident.
+ ///
+ /// Stricter than on purpose: it additionally requires the
+ /// department to have deliberately narrowed . The
+ /// permission defaults to Everyone so nothing breaks on upgrade, and inferring "therefore every
+ /// member may move resources on any board" from that open default would hand out authority no one
+ /// asked for. Once a department picks who commands, those people are trusted to assist.
+ ///
+ Task CanAssistWithCommandAsync(int departmentId, string userId);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IDispatchAccessService.cs b/Core/Resgrid.Model/Services/IDispatchAccessService.cs
new file mode 100644
index 000000000..6b2e500ef
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IDispatchAccessService.cs
@@ -0,0 +1,29 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// Who is allowed to work the dispatch desk, per the
+ /// permission.
+ ///
+ /// This is the single authority for that question. It gates signing in to the Dispatch app AND
+ /// membership of the incident dispatch chat channel — the app is only a client of the shared API, so
+ /// a client-side check alone would keep nothing private. Anyone the department hasn't authorized
+ /// simply resolves to no dispatch channel, whichever app they are running.
+ ///
+ /// Defaults to allowing everyone in the department: departments that are entirely dispatchers, or
+ /// that have never configured the permission, keep working unchanged.
+ ///
+ public interface IDispatchAccessService
+ {
+ /// True when this user may work dispatch for the department.
+ Task CanUseDispatchAsync(int departmentId, string userId);
+
+ ///
+ /// Every user in the department who may work dispatch — the audience for anything addressed to
+ /// "Dispatch", since whichever dispatcher is on shift needs to see it.
+ ///
+ Task> GetDispatchUserIdsAsync(int departmentId);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IUnitsService.cs b/Core/Resgrid.Model/Services/IUnitsService.cs
index e98d3031f..e9c7789b1 100644
--- a/Core/Resgrid.Model/Services/IUnitsService.cs
+++ b/Core/Resgrid.Model/Services/IUnitsService.cs
@@ -184,9 +184,10 @@ Task SetUnitStateAsync(int unitId, int unitStateType, int departmentI
/// The state.
/// The department identifier.
/// The cancellation token that can be used by other objects or threads to receive notice of cancellation.
+ /// True when the status change is from an automated process (i.e. call dispatch auto-status) and should not generate user notifications.
/// Task<UnitState>.
Task SetUnitStateAsync(UnitState state, int departmentId,
- CancellationToken cancellationToken = default(CancellationToken));
+ CancellationToken cancellationToken = default(CancellationToken), bool autoGenerated = false);
///
/// Gets the logs for unit asynchronous.
diff --git a/Core/Resgrid.Model/Services/IUserStateService.cs b/Core/Resgrid.Model/Services/IUserStateService.cs
index 5140a18f9..316526aab 100644
--- a/Core/Resgrid.Model/Services/IUserStateService.cs
+++ b/Core/Resgrid.Model/Services/IUserStateService.cs
@@ -51,9 +51,10 @@ Task CreateUserState(string userId, int departmentId, int userStateTy
/// Type of the user state.
/// The note.
/// The cancellation token that can be used by other objects or threads to receive notice of cancellation.
+ /// True when the staffing change is from an automated process (i.e. scheduled reset) and should not generate user notifications.
/// Task<UserState>.
Task CreateUserState(string userId, int departmentId, int userStateType, string note,
- CancellationToken cancellationToken = default(CancellationToken));
+ CancellationToken cancellationToken = default(CancellationToken), bool autoGenerated = false);
///
/// Creates the user state asynchronous.
diff --git a/Core/Resgrid.Services/ActionLogsService.cs b/Core/Resgrid.Services/ActionLogsService.cs
index b2341a772..68c5c2890 100644
--- a/Core/Resgrid.Services/ActionLogsService.cs
+++ b/Core/Resgrid.Services/ActionLogsService.cs
@@ -204,7 +204,7 @@ public async Task GetPreviousActionLogAsync(string userId, int action
return actionLog;
}
- public async Task SaveAllActionLogsAsync(List actionLogs, CancellationToken cancellationToken = default(CancellationToken))
+ public async Task SaveAllActionLogsAsync(List actionLogs, CancellationToken cancellationToken = default(CancellationToken), bool autoGenerated = false)
{
if (actionLogs != null && actionLogs.Count() > 0)
{
@@ -223,7 +223,8 @@ public async Task GetPreviousActionLogAsync(string userId, int action
{
DepartmentId = saved.DepartmentId,
Status = saved,
- PreviousStatus = previousStatus
+ PreviousStatus = previousStatus,
+ AutoGenerated = autoGenerated
});
}
@@ -344,7 +345,9 @@ public async Task SetActionForEntireDepartmentAsync(int departmentId, int
logs.Add(al);
}
- return await SaveAllActionLogsAsync(logs);
+ // Bulk status operations (scheduled resets, manual department-wide resets) never generate
+ // per-user notifications, otherwise every member change fans out to every subscriber.
+ return await SaveAllActionLogsAsync(logs, autoGenerated: true);
}
public async Task SetActionForDepartmentGroupAsync(int departmentGroupId, int actionType, string note)
@@ -366,7 +369,8 @@ public async Task SetActionForDepartmentGroupAsync(int departmentGroupId,
logs.Add(al);
}
- return await SaveAllActionLogsAsync(logs);
+ // Bulk status operations never generate per-user notifications, same as the department-wide reset.
+ return await SaveAllActionLogsAsync(logs, autoGenerated: true);
}
return false;
diff --git a/Core/Resgrid.Services/CallDispatchStatusService.cs b/Core/Resgrid.Services/CallDispatchStatusService.cs
index 22fde959c..9d6c40f3a 100644
--- a/Core/Resgrid.Services/CallDispatchStatusService.cs
+++ b/Core/Resgrid.Services/CallDispatchStatusService.cs
@@ -118,7 +118,7 @@ private async Task ApplyUnitStatusesAsync(Call call, Department department, IRea
DestinationType = (int)DestinationEntityTypes.Call
};
- await _unitsService.SetUnitStateAsync(state, call.DepartmentId, cancellationToken);
+ await _unitsService.SetUnitStateAsync(state, call.DepartmentId, cancellationToken, autoGenerated: true);
}
}
diff --git a/Core/Resgrid.Services/ChatChannelService.cs b/Core/Resgrid.Services/ChatChannelService.cs
index abb37c96c..6abda3ce1 100644
--- a/Core/Resgrid.Services/ChatChannelService.cs
+++ b/Core/Resgrid.Services/ChatChannelService.cs
@@ -23,6 +23,9 @@ public class ChatChannelService : IChatChannelService
{
private static readonly TimeSpan ChannelListCacheLength = TimeSpan.FromSeconds(45);
+ /// How long a completed incident-channel backfill suppresses the next sweep for that command.
+ private static readonly TimeSpan IncidentBackfillCacheLength = TimeSpan.FromMinutes(30);
+
private readonly IChatChannelRepository _chatChannelRepository;
private readonly IChatChannelMemberRepository _chatChannelMemberRepository;
private readonly IChatChannelAccessRuleRepository _chatChannelAccessRuleRepository;
@@ -689,7 +692,7 @@ public async Task GetUserMembershipAsync(string chatChannelId
var existing = await _chatChannelRepository.GetByCallIdAndTypeAsync(command.CallId, (int)ChatChannelType.IncidentCommand);
if (existing != null)
- return existing;
+ return await RebindCommandScopedChannelAsync(existing, command.IncidentCommandId, cancellationToken);
return await InsertProvisionedChannelAsync(new ChatChannel
{
@@ -703,6 +706,152 @@ public async Task GetUserMembershipAsync(string chatChannelId
}, () => _chatChannelRepository.GetByCallIdAndTypeAsync(command.CallId, (int)ChatChannelType.IncidentCommand), cancellationToken);
}
+ public async Task EnsureLeadsChannelAsync(IncidentCommand command, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ if (command == null)
+ return null;
+
+ var existing = await _chatChannelRepository.GetByCallIdAndTypeAsync(command.CallId, (int)ChatChannelType.IncidentLeads);
+ if (existing != null)
+ return await RebindCommandScopedChannelAsync(existing, command.IncidentCommandId, cancellationToken);
+
+ return await InsertProvisionedChannelAsync(new ChatChannel
+ {
+ ChatChannelId = Guid.NewGuid().ToString(),
+ DepartmentId = command.DepartmentId,
+ ChannelType = (int)ChatChannelType.IncidentLeads,
+ Name = "All Leads",
+ CallId = command.CallId,
+ IncidentCommandId = command.IncidentCommandId,
+ CreatedOn = DateTime.UtcNow
+ }, () => _chatChannelRepository.GetByCallIdAndTypeAsync(command.CallId, (int)ChatChannelType.IncidentLeads), cancellationToken);
+ }
+
+ public async Task EnsureDispatchChannelAsync(int departmentId, int callId, string incidentCommandId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ if (callId <= 0)
+ return null;
+
+ var existing = await _chatChannelRepository.GetByCallIdAndTypeAsync(callId, (int)ChatChannelType.IncidentDispatch);
+ if (existing != null)
+ return await RebindCommandScopedChannelAsync(existing, incidentCommandId, cancellationToken);
+
+ return await InsertProvisionedChannelAsync(new ChatChannel
+ {
+ ChatChannelId = Guid.NewGuid().ToString(),
+ DepartmentId = departmentId,
+ ChannelType = (int)ChatChannelType.IncidentDispatch,
+ Name = "Dispatch",
+ CallId = callId,
+ IncidentCommandId = incidentCommandId,
+ CreatedOn = DateTime.UtcNow
+ }, () => _chatChannelRepository.GetByCallIdAndTypeAsync(callId, (int)ChatChannelType.IncidentDispatch), cancellationToken);
+ }
+
+ ///
+ /// A call can host sequential incident commands (close command, establish a new one later), but its
+ /// command/leads/dispatch channels are singletons per call and get reused. A reused channel still
+ /// carries the closed command's id and archived state, so without this the new command's channels
+ /// stay frozen and its close/reopen archive sweeps match nothing. Targeted update — a full-row
+ /// write would rewind the atomic LastMessageSeq allocator.
+ ///
+ private async Task RebindCommandScopedChannelAsync(ChatChannel channel, string incidentCommandId, CancellationToken cancellationToken)
+ {
+ if (channel == null || string.IsNullOrWhiteSpace(incidentCommandId))
+ return channel;
+
+ var commandChanged = !string.Equals(channel.IncidentCommandId, incidentCommandId, StringComparison.OrdinalIgnoreCase);
+ if (!commandChanged && !channel.IsArchived)
+ return channel;
+
+ await _chatChannelRepository.RebindToIncidentCommandAsync(channel.ChatChannelId, incidentCommandId, DateTime.UtcNow, cancellationToken);
+
+ channel.IncidentCommandId = incidentCommandId;
+ channel.IsArchived = false;
+ channel.ArchivedOn = null;
+ channel.ModifiedOn = DateTime.UtcNow;
+
+ // The archive flag gates posting per cached permission verdicts; clients also need to re-read it.
+ await _chatPermissionService.InvalidateChannelCacheAsync(channel.ChatChannelId);
+ PublishChannelEvent(channel, ChatEventKinds.ChannelUpdated);
+
+ return channel;
+ }
+
+ public async Task EnsureIncidentChannelsAsync(IncidentCommand command, IEnumerable nodes, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ if (command == null || command.CallId <= 0)
+ return;
+
+ // A closed command's channels are a frozen record. Anything created now would be unarchived,
+ // so the freeze would silently lift for a channel nobody ever posted in.
+ if (command.Status != (int)IncidentCommandStatus.Active)
+ return;
+
+ var markerKey = $"chat:incidentbackfill:{command.IncidentCommandId}";
+
+ try
+ {
+ if (!string.IsNullOrEmpty(await _cacheProvider.GetStringAsync(markerKey)))
+ return;
+ }
+ catch (Exception ex)
+ {
+ // A cache outage must not stop the backfill — worst case it runs again on the next read.
+ Logging.LogException(ex);
+ }
+
+ try
+ {
+ // One read of the call's channels covers every check below, instead of a lookup per Ensure*.
+ var existing = (await _chatChannelRepository.GetByCallIdAsync(command.CallId))?.ToList() ?? new List();
+
+ if (!existing.Any(c => c.ChannelType == (int)ChatChannelType.Incident))
+ await EnsureIncidentChannelAsync(command.DepartmentId, command.CallId, null, cancellationToken);
+
+ // Command-scoped channels are reused across sequential commands on the same call, so a
+ // found channel still needs rebinding to this command (and unarchiving) — see the helper.
+ var commandChannel = existing.FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.IncidentCommand);
+ if (commandChannel == null)
+ await EnsureCommandChannelAsync(command, cancellationToken);
+ else
+ await RebindCommandScopedChannelAsync(commandChannel, command.IncidentCommandId, cancellationToken);
+
+ var leadsChannel = existing.FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.IncidentLeads);
+ if (leadsChannel == null)
+ await EnsureLeadsChannelAsync(command, cancellationToken);
+ else
+ await RebindCommandScopedChannelAsync(leadsChannel, command.IncidentCommandId, cancellationToken);
+
+ var dispatchChannel = existing.FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.IncidentDispatch);
+ if (dispatchChannel == null)
+ await EnsureDispatchChannelAsync(command.DepartmentId, command.CallId, command.IncidentCommandId, cancellationToken);
+ else
+ await RebindCommandScopedChannelAsync(dispatchChannel, command.IncidentCommandId, cancellationToken);
+
+ var provisionedNodeIds = new HashSet(
+ existing.Where(c => c.ChannelType == (int)ChatChannelType.IncidentLane && !string.IsNullOrWhiteSpace(c.CommandStructureNodeId))
+ .Select(c => c.CommandStructureNodeId),
+ StringComparer.OrdinalIgnoreCase);
+
+ var missingLanes = (nodes ?? Enumerable.Empty())
+ .Where(n => n != null && !n.DeletedOn.HasValue && !provisionedNodeIds.Contains(n.CommandStructureNodeId))
+ .ToList();
+
+ // Serialized deliberately: these share the caller's unit-of-work connection, which is not
+ // concurrency-safe. Bounded by the lane count on a once-per-incident path.
+ foreach (var node in missingLanes)
+ await EnsureLaneChannelAsync(node, cancellationToken);
+
+ await _cacheProvider.SetStringAsync(markerKey, "1", IncidentBackfillCacheLength);
+ }
+ catch (Exception ex)
+ {
+ // Best-effort: chat provisioning must never cost the caller their board or incident view.
+ Logging.LogException(ex);
+ }
+ }
+
public async Task EnsureChatbotChannelAsync(int departmentId, string userId, CancellationToken cancellationToken = default(CancellationToken))
{
var existing = await _chatChannelRepository.GetChatbotChannelAsync(departmentId, userId);
@@ -743,7 +892,26 @@ await _chatChannelMemberRepository.InsertAsync(new ChatChannelMember
public async Task SetIncidentChannelsArchivedAsync(int callId, bool archived, CancellationToken cancellationToken = default(CancellationToken))
{
var affected = await _chatChannelRepository.SetArchivedByCallIdAsync(callId, archived, archived ? DateTime.UtcNow : (DateTime?)null);
- var affectedList = affected?.ToList() ?? new List();
+ return await PublishArchiveChangeAsync(affected);
+ }
+
+ public async Task SetCommandChannelsArchivedAsync(string incidentCommandId, bool archived, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ if (string.IsNullOrWhiteSpace(incidentCommandId))
+ return false;
+
+ var affected = await _chatChannelRepository.SetArchivedByIncidentCommandIdAsync(incidentCommandId, archived, archived ? DateTime.UtcNow : (DateTime?)null);
+ return await PublishArchiveChangeAsync(affected);
+ }
+
+ ///
+ /// Drops the cached permission evaluations for every channel whose archived flag just moved and
+ /// tells connected clients to re-read it — a frozen channel has to stop accepting posts on every
+ /// device immediately, not whenever the cache happens to expire.
+ ///
+ private async Task PublishArchiveChangeAsync(IEnumerable affectedChannelIds)
+ {
+ var affectedList = affectedChannelIds?.ToList() ?? new List();
foreach (var channelId in affectedList)
await _chatPermissionService.InvalidateChannelCacheAsync(channelId);
diff --git a/Core/Resgrid.Services/ChatMessageService.cs b/Core/Resgrid.Services/ChatMessageService.cs
index 592d901b6..2dbb82c17 100644
--- a/Core/Resgrid.Services/ChatMessageService.cs
+++ b/Core/Resgrid.Services/ChatMessageService.cs
@@ -261,12 +261,34 @@ public async Task> GetThreadPageAsync(string threadRootMessage
return messages?.ToList() ?? new List();
}
+ ///
+ /// True when the channel is archived, i.e. frozen as a point-in-time record: a closed incident
+ /// command's channel and its lane channels, or a closed call's channel. Posting is already blocked
+ /// by IChatPermissionService.CanPostAsync; this is the matching gate for mutating what is
+ /// already there. Moderation (flagging, moderator delete) deliberately does NOT consult it.
+ /// A missing channel reads as frozen — fail closed rather than allow an unanchored edit.
+ ///
+ private async Task IsChannelFrozenAsync(string chatChannelId)
+ {
+ if (string.IsNullOrWhiteSpace(chatChannelId))
+ return true;
+
+ var channel = await _chatChannelRepository.GetByIdAsync(chatChannelId);
+ return channel == null || channel.IsArchived;
+ }
+
public async Task EditMessageAsync(string chatMessageId, string editorUserId, string newBody, CancellationToken cancellationToken = default(CancellationToken))
{
var message = await _chatMessageRepository.GetByIdAsync(chatMessageId);
if (message == null || message.DeletedOn.HasValue)
return null;
+ // An archived channel is a point-in-time record (a closed incident command/lane chat, a closed
+ // call). CanPostAsync already refuses new messages there; the history has to be just as
+ // immutable, or the record could still be rewritten after the fact.
+ if (await IsChannelFrozenAsync(message.ChatChannelId))
+ return null;
+
if (!string.Equals(message.SenderUserId, editorUserId, StringComparison.OrdinalIgnoreCase))
return null;
@@ -301,6 +323,12 @@ public async Task> GetThreadPageAsync(string threadRootMessage
return false;
var isModeratorDelete = asModerator && !isSender;
+
+ // Frozen channel: the author can no longer retract what they said, but moderation still has to
+ // work — flagged content on a closed incident must remain removable.
+ if (!isModeratorDelete && await IsChannelFrozenAsync(message.ChatChannelId))
+ return false;
+
await SaveEditHistoryAsync(message, isModeratorDelete ? ChatMessageEditType.ModeratorDelete : ChatMessageEditType.SenderDelete, byUserId, cancellationToken);
var deletedOn = DateTime.UtcNow;
@@ -336,6 +364,9 @@ public async Task> GetThreadPageAsync(string threadRootMessage
if (message == null || message.DeletedOn.HasValue)
return false;
+ if (await IsChannelFrozenAsync(message.ChatChannelId))
+ return false;
+
// Banned or currently-muted participants can't react; silently skip.
var member = unitId.HasValue
? await _chatChannelMemberRepository.GetUnitMemberAsync(message.ChatChannelId, unitId.Value)
@@ -389,6 +420,9 @@ await _chatMessageReactionRepository.InsertAsync(new ChatMessageReaction
if (message == null)
return false;
+ if (await IsChannelFrozenAsync(message.ChatChannelId))
+ return false;
+
var participantType = unitId.HasValue ? (int)ChatParticipantType.Unit : (int)ChatParticipantType.User;
var removed = await _chatMessageReactionRepository.DeleteReactionAsync(chatMessageId, participantType, unitId.HasValue ? null : userId, unitId, emoji, cancellationToken);
diff --git a/Core/Resgrid.Services/ChatPermissionService.cs b/Core/Resgrid.Services/ChatPermissionService.cs
index 4a22a1bcd..ad40ae3ad 100644
--- a/Core/Resgrid.Services/ChatPermissionService.cs
+++ b/Core/Resgrid.Services/ChatPermissionService.cs
@@ -33,12 +33,13 @@ public class ChatPermissionService : IChatPermissionService
private readonly IUnitsService _unitsService;
private readonly ICallsService _callsService;
private readonly IIncidentCommandService _incidentCommandService;
+ private readonly IDispatchAccessService _dispatchAccessService;
private readonly ICacheProvider _cacheProvider;
public ChatPermissionService(IChatChannelMemberRepository chatChannelMemberRepository, IChatChannelAccessRuleRepository chatChannelAccessRuleRepository,
IAuthorizationService authorizationService, IDepartmentsService departmentsService, IDepartmentGroupsService departmentGroupsService,
IPersonnelRolesService personnelRolesService, IUnitsService unitsService, ICallsService callsService,
- IIncidentCommandService incidentCommandService, ICacheProvider cacheProvider)
+ IIncidentCommandService incidentCommandService, IDispatchAccessService dispatchAccessService, ICacheProvider cacheProvider)
{
_chatChannelMemberRepository = chatChannelMemberRepository;
_chatChannelAccessRuleRepository = chatChannelAccessRuleRepository;
@@ -49,6 +50,7 @@ public ChatPermissionService(IChatChannelMemberRepository chatChannelMemberRepos
_unitsService = unitsService;
_callsService = callsService;
_incidentCommandService = incidentCommandService;
+ _dispatchAccessService = dispatchAccessService;
_cacheProvider = cacheProvider;
}
@@ -193,6 +195,9 @@ public async Task> ResolveChannelAudienceUserIdsAsync(ChatChannel c
case ChatChannelType.Incident:
await AddIncidentAudienceAsync(channel, userIds);
+ // The desk follows the incident's shared conversation, not just its own dispatch line.
+ foreach (var dispatcherId in await _dispatchAccessService.GetDispatchUserIdsAsync(channel.DepartmentId))
+ AddIfSet(userIds, dispatcherId);
break;
case ChatChannelType.IncidentLane:
@@ -203,6 +208,16 @@ public async Task> ResolveChannelAudienceUserIdsAsync(ChatChannel c
await AddCommandStaffAsync(channel.DepartmentId, channel.CallId.GetValueOrDefault(), userIds);
break;
+ case ChatChannelType.IncidentLeads:
+ await AddLaneLeadsAsync(channel.DepartmentId, channel.CallId.GetValueOrDefault(), userIds);
+ break;
+
+ case ChatChannelType.IncidentDispatch:
+ await AddIncidentAudienceAsync(channel, userIds);
+ foreach (var dispatcherId in await _dispatchAccessService.GetDispatchUserIdsAsync(channel.DepartmentId))
+ AddIfSet(userIds, dispatcherId);
+ break;
+
default: // DirectMessage, AdHocGroup
await AddExplicitMemberAudienceAsync(channel, userIds);
break;
@@ -259,6 +274,11 @@ private async Task EvaluateAccessAsync(ChatChannel channel, string userId,
if (await IsDepartmentAdminAsync(channel.DepartmentId, userId))
return true;
+ // Authorized dispatchers see every call's shared incident conversation — that is the
+ // desk's job. The private command channel stays closed to them.
+ if (await _dispatchAccessService.CanUseDispatchAsync(channel.DepartmentId, userId))
+ return true;
+
return await IsInIncidentAudienceAsync(channel, userId, activeUnitId);
case ChatChannelType.IncidentLane:
@@ -271,8 +291,27 @@ private async Task EvaluateAccessAsync(ChatChannel channel, string userId,
if (await IsDepartmentAdminAsync(channel.DepartmentId, userId))
return true;
+ // Command staff ONLY — deliberately not widened to dispatch. Dispatch reaches command
+ // through the incident's dispatch channel; this one stays internal to the people running
+ // the incident so command can talk candidly.
return await IsCommandStaffAsync(channel.DepartmentId, channel.CallId.GetValueOrDefault(), userId);
+ case ChatChannelType.IncidentLeads:
+ if (await IsDepartmentAdminAsync(channel.DepartmentId, userId))
+ return true;
+
+ return await IsLaneLeadOrCommanderAsync(channel.DepartmentId, channel.CallId.GetValueOrDefault(), userId);
+
+ case ChatChannelType.IncidentDispatch:
+ // Deliberately NOT widened to department admins the way the other incident channels are.
+ // The whole point of the DispatchAppLogin permission is that an admin the department has
+ // not authorized for dispatch stays out of dispatch traffic; they still get in if they
+ // are actually working the incident.
+ if (await _dispatchAccessService.CanUseDispatchAsync(channel.DepartmentId, userId))
+ return true;
+
+ return await IsInIncidentAudienceAsync(channel, userId, activeUnitId);
+
default:
return false;
}
@@ -300,6 +339,8 @@ private async Task EvaluateModerateAsync(ChatChannel channel, string userI
case ChatChannelType.Incident:
case ChatChannelType.IncidentLane:
case ChatChannelType.IncidentCommand:
+ case ChatChannelType.IncidentLeads:
+ case ChatChannelType.IncidentDispatch:
if (!channel.CallId.HasValue)
return false;
@@ -492,6 +533,54 @@ private async Task IsInLaneAudienceAsync(ChatChannel channel, string userI
return false;
}
+ ///
+ /// "All Leads" audience: the Incident Commander plus every lane's primary and secondary lead.
+ /// Deliberately derived from the lanes on each check rather than stored as membership — a lead who
+ /// is replaced on the board loses the channel without anyone having to remember to remove them.
+ ///
+ private async Task IsLaneLeadOrCommanderAsync(int departmentId, int callId, string userId)
+ {
+ if (callId <= 0)
+ return false;
+
+ var command = await _incidentCommandService.GetCommandForCallAsync(departmentId, callId);
+ if (command != null &&
+ (string.Equals(command.CurrentCommanderUserId, userId, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(command.EstablishedByUserId, userId, StringComparison.OrdinalIgnoreCase)))
+ return true;
+
+ var nodes = await _incidentCommandService.GetNodesForCallAsync(departmentId, callId);
+ if (nodes == null)
+ return false;
+
+ return nodes.Any(n => !n.DeletedOn.HasValue &&
+ (string.Equals(n.PrimaryLeadUserId, userId, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(n.SecondaryLeadUserId, userId, StringComparison.OrdinalIgnoreCase)));
+ }
+
+ private async Task AddLaneLeadsAsync(int departmentId, int callId, HashSet userIds)
+ {
+ if (callId <= 0)
+ return;
+
+ var command = await _incidentCommandService.GetCommandForCallAsync(departmentId, callId);
+ if (command != null)
+ {
+ AddIfSet(userIds, command.CurrentCommanderUserId);
+ AddIfSet(userIds, command.EstablishedByUserId);
+ }
+
+ var nodes = await _incidentCommandService.GetNodesForCallAsync(departmentId, callId);
+ if (nodes == null)
+ return;
+
+ foreach (var node in nodes.Where(n => !n.DeletedOn.HasValue))
+ {
+ AddIfSet(userIds, node.PrimaryLeadUserId);
+ AddIfSet(userIds, node.SecondaryLeadUserId);
+ }
+ }
+
private async Task IsCommandStaffAsync(int departmentId, int callId, string userId)
{
if (callId <= 0)
diff --git a/Core/Resgrid.Services/ChatProvisioningEventService.cs b/Core/Resgrid.Services/ChatProvisioningEventService.cs
index 6d9e6fe2a..a6e05c197 100644
--- a/Core/Resgrid.Services/ChatProvisioningEventService.cs
+++ b/Core/Resgrid.Services/ChatProvisioningEventService.cs
@@ -2,8 +2,10 @@
using System.Threading.Tasks;
using Autofac;
using Resgrid.Framework;
+using Resgrid.Model;
using Resgrid.Model.Events;
using Resgrid.Model.Providers;
+using Resgrid.Model.Repositories;
using Resgrid.Model.Services;
namespace Resgrid.Services
@@ -32,6 +34,8 @@ public ChatProvisioningEventService(IEventAggregator eventAggregator, ILifetimeS
_eventAggregator.AddAsyncListener(OnCallAddedAsync);
_eventAggregator.AddAsyncListener(OnCallClosedAsync);
_eventAggregator.AddAsyncListener(OnCommandEstablishedAsync);
+ _eventAggregator.AddAsyncListener(OnIncidentClosedAsync);
+ _eventAggregator.AddAsyncListener(OnLaneLeadChangedAsync);
_eventAggregator.AddAsyncListener(OnIncidentReopenedAsync);
}
@@ -67,23 +71,77 @@ private Task OnCommandEstablishedAsync(CommandEstablishedEvent message)
if (command == null)
return;
- await chatChannelService.EnsureIncidentChannelAsync(message.DepartmentId, message.CallId, null);
- await chatChannelService.EnsureCommandChannelAsync(command);
-
- // Lane channels for template-seeded nodes; later ad-hoc lanes are handled by SaveNodeAsync.
- // Batched: one existing-channel read for the call, then insert only the missing lanes.
+ // Same entry point the read-path backfill uses, so establish and heal-on-read can never
+ // drift apart. One existing-channel read, then only the missing rows are inserted.
+ // Template-seeded lanes are covered here; later ad-hoc lanes come via SaveNodeAsync.
var nodes = await incidentCommandService.GetNodesForCallAsync(message.DepartmentId, message.CallId);
- await chatChannelService.EnsureLaneChannelsAsync(nodes);
+ await chatChannelService.EnsureIncidentChannelsAsync(command, nodes);
});
}
- private Task OnIncidentReopenedAsync(IncidentReopenedEvent message)
+ ///
+ /// A lane lead changed hands, so who can see the lane and "All Leads" channels changed with it.
+ /// Both audiences are derived live from the board, but the permission service caches its verdicts —
+ /// without this the outgoing lead keeps access, and the incoming one is locked out, until the cache
+ /// expires on its own.
+ ///
+ private Task OnLaneLeadChangedAsync(LaneLeadChangedEvent message)
{
if (message == null)
return Task.CompletedTask;
+ return RunAsync(async scope =>
+ {
+ var channelRepository = scope.Resolve();
+ var permissionService = scope.Resolve();
+
+ var leadsChannel = await channelRepository.GetByCallIdAndTypeAsync(message.CallId, (int)ChatChannelType.IncidentLeads);
+ if (leadsChannel != null)
+ await permissionService.InvalidateChannelCacheAsync(leadsChannel.ChatChannelId);
+
+ if (!string.IsNullOrWhiteSpace(message.CommandStructureNodeId))
+ {
+ var laneChannel = await channelRepository.GetByCommandStructureNodeIdAsync(message.CommandStructureNodeId);
+ if (laneChannel != null)
+ await permissionService.InvalidateChannelCacheAsync(laneChannel.ChatChannelId);
+ }
+ });
+ }
+
+ ///
+ /// Command closed: freeze its command and lane channels into a point-in-time record. Scoped to the
+ /// command, NOT the call — the call may still be running, and its own incident channel has to stay
+ /// live. (The call-level freeze is CallClosedEvent's job.)
+ ///
+ private Task OnIncidentClosedAsync(IncidentClosedEvent message)
+ {
+ if (message == null || string.IsNullOrWhiteSpace(message.IncidentCommandId))
+ return Task.CompletedTask;
+
return RunAsync(scope => scope.Resolve()
- .SetIncidentChannelsArchivedAsync(message.CallId, false));
+ .SetCommandChannelsArchivedAsync(message.IncidentCommandId, true));
+ }
+
+ private Task OnIncidentReopenedAsync(IncidentReopenedEvent message)
+ {
+ if (message == null)
+ return Task.CompletedTask;
+
+ return RunAsync(async scope =>
+ {
+ var chatChannelService = scope.Resolve();
+
+ // Thaw the reopened command's own channels first — this is the part that must happen even
+ // when the underlying call is closed.
+ if (!string.IsNullOrWhiteSpace(message.IncidentCommandId))
+ await chatChannelService.SetCommandChannelsArchivedAsync(message.IncidentCommandId, false);
+
+ // The call's incident channel only comes back when the call itself is open again; reopening
+ // command on a closed call must not resurrect the call-wide conversation.
+ var call = await scope.Resolve().GetCallByIdAsync(message.CallId);
+ if (call != null && !call.ClosedOn.HasValue)
+ await chatChannelService.SetIncidentChannelsArchivedAsync(message.CallId, false);
+ });
}
///
diff --git a/Core/Resgrid.Services/CommandAccessService.cs b/Core/Resgrid.Services/CommandAccessService.cs
new file mode 100644
index 000000000..c70490b50
--- /dev/null
+++ b/Core/Resgrid.Services/CommandAccessService.cs
@@ -0,0 +1,33 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ public class CommandAccessService : PermissionGateServiceBase, ICommandAccessService
+ {
+ public CommandAccessService(
+ IPermissionsService permissionsService,
+ IDepartmentsService departmentsService,
+ IDepartmentGroupsService departmentGroupsService,
+ IPersonnelRolesService personnelRolesService,
+ ICacheProvider cacheProvider)
+ : base(permissionsService, departmentsService, departmentGroupsService, personnelRolesService, cacheProvider)
+ {
+ }
+
+ protected override PermissionTypes PermissionType => PermissionTypes.CommandAppLogin;
+
+ protected override string CacheKeyPrefix => "commandaccess";
+
+ public Task CanUseCommandAsync(int departmentId, string userId) => IsAllowedAsync(departmentId, userId);
+
+ public Task> GetCommandUserIdsAsync(int departmentId) => GetAllowedUserIdsAsync(departmentId);
+
+ public async Task CanAssistWithCommandAsync(int departmentId, string userId)
+ => await IsRestrictedAsync(departmentId) && await IsAllowedAsync(departmentId, userId);
+ }
+}
diff --git a/Core/Resgrid.Services/DispatchAccessService.cs b/Core/Resgrid.Services/DispatchAccessService.cs
new file mode 100644
index 000000000..5c6e8b04f
--- /dev/null
+++ b/Core/Resgrid.Services/DispatchAccessService.cs
@@ -0,0 +1,30 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ public class DispatchAccessService : PermissionGateServiceBase, IDispatchAccessService
+ {
+ public DispatchAccessService(
+ IPermissionsService permissionsService,
+ IDepartmentsService departmentsService,
+ IDepartmentGroupsService departmentGroupsService,
+ IPersonnelRolesService personnelRolesService,
+ ICacheProvider cacheProvider)
+ : base(permissionsService, departmentsService, departmentGroupsService, personnelRolesService, cacheProvider)
+ {
+ }
+
+ protected override PermissionTypes PermissionType => PermissionTypes.DispatchAppLogin;
+
+ protected override string CacheKeyPrefix => "dispatchaccess";
+
+ public Task CanUseDispatchAsync(int departmentId, string userId) => IsAllowedAsync(departmentId, userId);
+
+ public Task> GetDispatchUserIdsAsync(int departmentId) => GetAllowedUserIdsAsync(departmentId);
+ }
+}
diff --git a/Core/Resgrid.Services/IncidentCommandService.cs b/Core/Resgrid.Services/IncidentCommandService.cs
index 60e3a8322..69c8655f4 100644
--- a/Core/Resgrid.Services/IncidentCommandService.cs
+++ b/Core/Resgrid.Services/IncidentCommandService.cs
@@ -470,6 +470,25 @@ public async Task GetCapabilitiesForUserAsync(int departme
foreach (var role in roles.Where(r => string.Equals(r.UserId, userId)))
caps |= IncidentRoleCapabilityMap.GetCapabilities((IncidentRoleType)role.RoleType);
+ // A department that has deliberately chosen who commands can let those people assist on a board
+ // without holding an ICS role on it — that is how a dispatcher helps work an incident from the
+ // Dispatch app. CanAssistWithCommandAsync (not CanUseCommandAsync) is the right question: the
+ // permission is open by default, and granting board authority off that open default would hand
+ // every member rights nobody asked for.
+ // Resolved through the service locator (matching this file's other cross-cutting lookups) so the
+ // permission side, which has no dependency on this service, does not close a DI cycle.
+ try
+ {
+ if (await ServiceLocator.Current.GetInstance().CanAssistWithCommandAsync(departmentId, userId))
+ caps |= IncidentRoleCapabilityMap.CommandAssistCapabilities;
+ }
+ catch (Exception ex)
+ {
+ // Fail closed: an unresolvable permission grants nothing extra, leaving the caller with
+ // whatever their commander standing and ICS roles already earned them.
+ Resgrid.Framework.Logging.LogException(ex);
+ }
+
return caps;
}
@@ -506,6 +525,11 @@ public async Task GetCommandBoardAsync(int departmentId, i
Maps = await GetIncidentMapsForCallAsync(departmentId, callId)
};
+ // Heal incidents established before the chat channels existed. Reuses the nodes already read
+ // for the board rather than querying them again, and the call is cache-guarded internally so
+ // this polled read does not re-sweep on every refresh.
+ await BackfillIncidentChatChannelsAsync(command, departmentId, callId, board.Nodes);
+
return board;
}
@@ -1031,9 +1055,94 @@ public async Task GetResourceIncidentViewAsync(int departm
}
}
+ await BackfillIncidentChatChannelsAsync(command, departmentId, callId);
+ await PopulateResourceViewContactsAndChatAsync(view, command, departmentId, callId, userId);
+
return view;
}
+ ///
+ /// Heals incidents that pre-date the incident chat channels: the first time someone opens the
+ /// board or the responder view, any missing channel is created. Deliberately a lazy backfill on
+ /// the read paths rather than a migration, so nothing has to be swept over the whole estate — an
+ /// incident nobody looks at costs nothing. Best-effort and internally cache-guarded; never throws.
+ ///
+ private async Task BackfillIncidentChatChannelsAsync(IncidentCommand command, int departmentId, int callId, List knownNodes = null)
+ {
+ try
+ {
+ var nodes = knownNodes ?? await GetNodesForCallAsync(departmentId, callId);
+ await ServiceLocator.Current.GetInstance().EnsureIncidentChannelsAsync(command, nodes);
+ }
+ catch (Exception ex)
+ {
+ Resgrid.Framework.Logging.LogException(ex);
+ }
+ }
+
+ ///
+ /// Fills in who holds which ICS position and which chat channels this caller may open. Both are
+ /// resolved here rather than client-side so a responder app never has to infer access: a channel id
+ /// it does not receive is one it cannot open.
+ ///
+ private async Task PopulateResourceViewContactsAndChatAsync(ResourceIncidentView view, IncidentCommand command, int departmentId, int callId, string userId)
+ {
+ var nodes = await GetNodesForCallAsync(departmentId, callId) ?? new List();
+ var roles = await GetIncidentRolesAsync(departmentId, callId) ?? new List();
+ var activeRoles = roles.Where(r => !r.RemovedOn.HasValue).ToList();
+
+ foreach (var role in activeRoles.OrderBy(r => r.RoleType))
+ {
+ var contact = await BuildUserContactAsync(role.UserId);
+ if (contact != null)
+ view.Roles.Add(new IncidentRoleContactInfo { RoleType = role.RoleType, Contact = contact });
+ }
+
+ var isCommander = string.Equals(command.CurrentCommanderUserId, userId, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(command.EstablishedByUserId, userId, StringComparison.OrdinalIgnoreCase);
+
+ var isCommandStaff = isCommander || activeRoles.Any(r => string.Equals(r.UserId, userId, StringComparison.OrdinalIgnoreCase));
+
+ var isLaneLead = nodes.Any(n => !n.DeletedOn.HasValue
+ && (string.Equals(n.PrimaryLeadUserId, userId, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(n.SecondaryLeadUserId, userId, StringComparison.OrdinalIgnoreCase)));
+
+ view.Chat.IsFrozen = command.Status != (int)IncidentCommandStatus.Active;
+
+ try
+ {
+ // Resolved through the service locator, matching DeleteNodeAsync: the chat side depends on
+ // this service, so constructor-injecting it back would close a DI cycle.
+ var channels = (await ServiceLocator.Current.GetInstance()
+ .GetByCallIdAsync(callId))?.ToList() ?? new List();
+
+ view.Chat.IncidentChannelId = channels.FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.Incident)?.ChatChannelId;
+
+ // Anyone on the incident can raise dispatch; no command standing required.
+ view.Chat.DispatchChannelId = channels.FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.IncidentDispatch)?.ChatChannelId;
+
+ if (isCommandStaff)
+ view.Chat.CommandChannelId = channels.FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.IncidentCommand)?.ChatChannelId;
+
+ if (isCommander || isLaneLead)
+ view.Chat.LeadsChannelId = channels.FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.IncidentLeads)?.ChatChannelId;
+
+ var myNodeId = view.MyAssignment?.CommandStructureNodeId;
+ if (!string.IsNullOrWhiteSpace(myNodeId))
+ {
+ view.Chat.LaneChannelId = channels
+ .FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.IncidentLane
+ && string.Equals(c.CommandStructureNodeId, myNodeId, StringComparison.OrdinalIgnoreCase))?.ChatChannelId;
+ }
+ }
+ catch (Exception ex)
+ {
+ // Chat is supplementary to the incident view — a lookup failure must not cost the responder
+ // their objectives, needs and lane assignment.
+ Resgrid.Framework.Logging.LogException(ex);
+ }
+ }
+
/// Contact card for a Resgrid user (name from the profile; phone/email as the profile exposes them).
private async Task BuildUserContactAsync(string userId)
{
diff --git a/Core/Resgrid.Services/PermissionGateServiceBase.cs b/Core/Resgrid.Services/PermissionGateServiceBase.cs
new file mode 100644
index 000000000..39b241109
--- /dev/null
+++ b/Core/Resgrid.Services/PermissionGateServiceBase.cs
@@ -0,0 +1,182 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Resgrid.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Shared evaluation for the "who may act in this capacity" permissions — dispatch and command.
+ ///
+ /// Both answer the same question against a different value, and both
+ /// gate access to private traffic, so the rules that matter live in one place: a missing permission
+ /// row means every active department member, the department's managing user counts as an admin, and
+ /// an evaluation failure denies rather than allows.
+ ///
+ public abstract class PermissionGateServiceBase
+ {
+ private static readonly TimeSpan CacheLength = TimeSpan.FromSeconds(60);
+
+ private readonly IPermissionsService _permissionsService;
+ private readonly IDepartmentsService _departmentsService;
+ private readonly IDepartmentGroupsService _departmentGroupsService;
+ private readonly IPersonnelRolesService _personnelRolesService;
+ private readonly ICacheProvider _cacheProvider;
+
+ protected PermissionGateServiceBase(
+ IPermissionsService permissionsService,
+ IDepartmentsService departmentsService,
+ IDepartmentGroupsService departmentGroupsService,
+ IPersonnelRolesService personnelRolesService,
+ ICacheProvider cacheProvider)
+ {
+ _permissionsService = permissionsService;
+ _departmentsService = departmentsService;
+ _departmentGroupsService = departmentGroupsService;
+ _personnelRolesService = personnelRolesService;
+ _cacheProvider = cacheProvider;
+ }
+
+ /// The permission this gate evaluates.
+ protected abstract PermissionTypes PermissionType { get; }
+
+ /// Cache key prefix, unique per gate so the two never share a verdict.
+ protected abstract string CacheKeyPrefix { get; }
+
+ protected async Task IsAllowedAsync(int departmentId, string userId)
+ {
+ if (departmentId <= 0 || string.IsNullOrWhiteSpace(userId))
+ return false;
+
+ var cacheKey = $"{CacheKeyPrefix}:{departmentId}:{userId}";
+
+ try
+ {
+ var cached = await _cacheProvider.GetStringAsync(cacheKey);
+ if (cached == "1")
+ return true;
+ if (cached == "0")
+ return false;
+ }
+ catch (Exception ex)
+ {
+ // A cache outage must not lock people out — fall through and evaluate.
+ Logging.LogException(ex);
+ }
+
+ var allowed = await EvaluateAsync(departmentId, userId);
+
+ try
+ {
+ await _cacheProvider.SetStringAsync(cacheKey, allowed ? "1" : "0", CacheLength);
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex);
+ }
+
+ return allowed;
+ }
+
+ ///
+ /// True when the department has deliberately narrowed this permission — a row exists and it is not
+ /// "Everyone".
+ ///
+ /// Used where granting a capability off the back of the OPEN default would be a surprise: with no
+ /// row, or a row that admits everyone, the department has expressed no opinion about who is
+ /// trusted, so nothing extra should be inferred from it.
+ ///
+ protected async Task IsRestrictedAsync(int departmentId)
+ {
+ if (departmentId <= 0)
+ return false;
+
+ try
+ {
+ var permission = await _permissionsService.GetPermissionByDepartmentTypeAsync(departmentId, PermissionType);
+ return permission != null && permission.Action != (int)PermissionActions.Everyone;
+ }
+ catch (Exception ex)
+ {
+ // Unreadable means "no opinion expressed", which grants nothing extra.
+ Logging.LogException(ex);
+ return false;
+ }
+ }
+
+ protected async Task> GetAllowedUserIdsAsync(int departmentId)
+ {
+ if (departmentId <= 0)
+ return new List();
+
+ var members = await _departmentsService.GetAllMembersForDepartmentAsync(departmentId) ?? new List();
+ var active = members
+ .Where(m => !m.IsDisabled.GetValueOrDefault() && !m.IsDeleted && !string.IsNullOrWhiteSpace(m.UserId))
+ .ToList();
+
+ var permission = await _permissionsService.GetPermissionByDepartmentTypeAsync(departmentId, PermissionType);
+
+ // The overwhelmingly common case — no permission row, or one that allows everyone — needs no
+ // per-user evaluation at all. Only a genuinely restricted department pays for the fan-out.
+ if (permission == null || permission.Action == (int)PermissionActions.Everyone)
+ return active.Select(m => m.UserId).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+
+ var allowed = new List();
+ foreach (var member in active)
+ {
+ if (await IsAllowedAsync(departmentId, member.UserId))
+ allowed.Add(member.UserId);
+ }
+
+ return allowed.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ }
+
+ ///
+ /// Mirrors how the department rights endpoint decides every other permission: department admin,
+ /// group admin, and the user's personnel roles evaluated against the permission row. A missing row
+ /// means every active department member.
+ ///
+ private async Task EvaluateAsync(int departmentId, string userId)
+ {
+ try
+ {
+ // Membership comes first: a missing permission row means "everyone in the department",
+ // never "everyone on the platform". These gates are asked about a channel's or incident's
+ // department — not necessarily the caller's own — so the open default must not admit
+ // non-members, or members who were disabled or removed.
+ var membership = await _departmentsService.GetDepartmentMemberAsync(userId, departmentId, false);
+ if (membership == null || membership.IsDisabled.GetValueOrDefault() || membership.IsDeleted)
+ return false;
+
+ var permission = await _permissionsService.GetPermissionByDepartmentTypeAsync(departmentId, PermissionType);
+ if (permission == null)
+ return true;
+
+ var isDepartmentAdmin = membership.IsAdmin.GetValueOrDefault();
+
+ // The department's managing user is always an admin, the same carve-out the rights endpoint makes.
+ var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false);
+ if (department != null && string.Equals(department.ManagingUserId, userId, StringComparison.OrdinalIgnoreCase))
+ isDepartmentAdmin = true;
+
+ var group = await _departmentGroupsService.GetGroupForUserAsync(userId, departmentId);
+ var isGroupAdmin = group != null && group.IsUserGroupAdmin(userId);
+
+ var roles = await _personnelRolesService.GetRolesForUserAsync(userId, departmentId);
+
+ return _permissionsService.IsUserAllowed(permission, isDepartmentAdmin, isGroupAdmin, roles);
+ }
+ catch (Exception ex)
+ {
+ // Fail CLOSED. These gates exist to keep private command, unit, responder and dispatch
+ // traffic away from people the department hasn't authorized; an error must not hand it over.
+ Logging.LogException(ex);
+ return false;
+ }
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs
index ef0b45471..3be41aa8d 100644
--- a/Core/Resgrid.Services/ServicesModule.cs
+++ b/Core/Resgrid.Services/ServicesModule.cs
@@ -18,6 +18,8 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
diff --git a/Core/Resgrid.Services/UnitsService.cs b/Core/Resgrid.Services/UnitsService.cs
index 6affe621b..9a1017858 100644
--- a/Core/Resgrid.Services/UnitsService.cs
+++ b/Core/Resgrid.Services/UnitsService.cs
@@ -324,7 +324,7 @@ public async Task GetUnitTypeByNameAsync(int departmentId, string type
return saved;
}
- public async Task SetUnitStateAsync(UnitState state, int departmentId, CancellationToken cancellationToken = default(CancellationToken))
+ public async Task SetUnitStateAsync(UnitState state, int departmentId, CancellationToken cancellationToken = default(CancellationToken), bool autoGenerated = false)
{
var previousState = await GetLastUnitStateByUnitIdAsync(state.UnitId);
@@ -351,7 +351,7 @@ public async Task GetUnitTypeByNameAsync(int departmentId, string type
var saved = await _unitStatesRepository.SaveOrUpdateAsync(state, cancellationToken);
- _eventAggregator.SendMessage(new UnitStatusEvent { DepartmentId = departmentId, Status = saved, PreviousStatus = previousState });
+ _eventAggregator.SendMessage(new UnitStatusEvent { DepartmentId = departmentId, Status = saved, PreviousStatus = previousState, AutoGenerated = autoGenerated });
return state;
}
diff --git a/Core/Resgrid.Services/UserStateService.cs b/Core/Resgrid.Services/UserStateService.cs
index 9b197940c..af57a0172 100644
--- a/Core/Resgrid.Services/UserStateService.cs
+++ b/Core/Resgrid.Services/UserStateService.cs
@@ -84,7 +84,7 @@ public async Task GetPreviousUserStateAsync(string userId, int userSt
return saved;
}
- public async Task CreateUserState(string userId, int departmentId, int userStateType, string note, CancellationToken cancellationToken = default(CancellationToken))
+ public async Task CreateUserState(string userId, int departmentId, int userStateType, string note, CancellationToken cancellationToken = default(CancellationToken), bool autoGenerated = false)
{
var us = new UserState();
us.UserId = userId;
@@ -96,7 +96,7 @@ public async Task GetPreviousUserStateAsync(string userId, int userSt
var saved = await _userStateRepository.SaveOrUpdateAsync(us, cancellationToken);
var previousStaffing = await _userStateRepository.GetPreviousUserStateByUserIdAsync(userId, saved.UserStateId);
- _eventAggregator.SendMessage(new UserStaffingEvent() { DepartmentId = departmentId, Staffing = saved, PreviousStaffing = previousStaffing });
+ _eventAggregator.SendMessage(new UserStaffingEvent() { DepartmentId = departmentId, Staffing = saved, PreviousStaffing = previousStaffing, AutoGenerated = autoGenerated });
InvalidateLatestStatesForDepartmentCache(departmentId);
return saved;
diff --git a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs
index 8f241bf05..bf2af5dd2 100644
--- a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs
+++ b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs
@@ -79,7 +79,10 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro
nqi.PreviousStateId = previousState;
nqi.Value = message.Status.State.ToString();
- await _outboundQueueProvider.EnqueueNotification(nqi);
+ // Automated status changes (i.e. call dispatch auto-status) shouldn't generate user notifications,
+ // only user-initiated changes do. SignalR still fires so connected clients stay current.
+ if (!message.AutoGenerated)
+ await _outboundQueueProvider.EnqueueNotification(nqi);
try
{
@@ -109,7 +112,9 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro
nqi.Value = message.Status.State.ToString();
nqi.UnitId = message.Status.UnitId;
- await _outboundQueueProvider.EnqueueNotification(nqi);
+ // Automated status changes don't generate availability alerts, only user-initiated changes do.
+ if (!message.AutoGenerated)
+ await _outboundQueueProvider.EnqueueNotification(nqi);
};
public Action unitTypeDepartmentAvailabilityHandler = async delegate (UnitStatusEvent message)
@@ -130,7 +135,9 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro
nqi.Value = message.Status.State.ToString();
nqi.UnitId = message.Status.UnitId;
- await _outboundQueueProvider.EnqueueNotification(nqi);
+ // Automated status changes don't generate availability alerts, only user-initiated changes do.
+ if (!message.AutoGenerated)
+ await _outboundQueueProvider.EnqueueNotification(nqi);
};
public Action userStaffingHandler = async delegate (UserStaffingEvent message)
@@ -151,7 +158,10 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro
nqi.Value = message.Staffing.State.ToString();
nqi.UserId = message.Staffing.UserId;
- await _outboundQueueProvider.EnqueueNotification(nqi);
+ // Automated staffing changes (i.e. scheduled department staffing reset) shouldn't generate
+ // user notifications, only user-initiated changes do.
+ if (!message.AutoGenerated)
+ await _outboundQueueProvider.EnqueueNotification(nqi);
};
public Action userRoleGroupAvailabilityHandler = async delegate (UserStaffingEvent message)
@@ -173,7 +183,9 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro
nqi.Value = message.Staffing.UserStateId.ToString();
nqi.UserId = message.Staffing.UserId;
- await _outboundQueueProvider.EnqueueNotification(nqi);
+ // Automated staffing changes don't generate availability alerts, only user-initiated changes do.
+ if (!message.AutoGenerated)
+ await _outboundQueueProvider.EnqueueNotification(nqi);
};
public Action userRoleDepartmentAvailabilityHandler = async delegate (UserStaffingEvent message)
@@ -195,7 +207,11 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro
nqi.Value = message.Staffing.UserStateId.ToString();
nqi.UserId = message.Staffing.UserId;
- await _outboundQueueProvider.EnqueueNotification(nqi);
+ // Automated staffing changes don't generate availability alerts, only user-initiated changes do.
+ // SignalR still fires so connected clients stay current.
+ if (!message.AutoGenerated)
+ await _outboundQueueProvider.EnqueueNotification(nqi);
+
await _signalrProvider.PersonnelStaffingUpdated(message.Staffing.DepartmentId, message.Staffing);
};
@@ -216,7 +232,11 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro
nqi.PreviousStateId = previousStatus;
nqi.Value = message.Status.ActionTypeId.ToString();
- await _outboundQueueProvider.EnqueueNotification(nqi);
+ // Automated status changes (i.e. scheduled department status reset) shouldn't generate
+ // user notifications, only user-initiated changes do. SignalR still fires so connected clients stay current.
+ if (!message.AutoGenerated)
+ await _outboundQueueProvider.EnqueueNotification(nqi);
+
await _signalrProvider.PersonnelStatusUpdated(message.Status.DepartmentId, message.Status);
};
diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs
index 1dab7e26a..ccec4b3cd 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs
@@ -340,6 +340,39 @@ public async Task> SetArchivedByCallIdAsync(int callId, bool
}
}
+ public async Task> SetArchivedByIncidentCommandIdAsync(string incidentCommandId, bool archived, DateTime? archivedOn)
+ {
+ try
+ {
+ var parameters = new DynamicParametersExtension();
+ parameters.Add("IncidentCommandId", incidentCommandId);
+ parameters.Add("IsArchived", archived);
+ parameters.Add("ArchivedOn", archived ? archivedOn : (DateTime?)null, DbType.DateTime2);
+ parameters.Add("ModifiedOn", archivedOn ?? DateTime.UtcNow, DbType.DateTime2);
+ var notation = _sqlConfiguration.ParameterNotation;
+ var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres
+ ? $"UPDATE {_sqlConfiguration.SchemaName}.chatchannels SET isarchived = {notation}IsArchived, archivedon = {notation}ArchivedOn, modifiedon = {notation}ModifiedOn WHERE incidentcommandid = {notation}IncidentCommandId RETURNING chatchannelid"
+ : $"UPDATE {_sqlConfiguration.SchemaName}.[ChatChannels] SET [IsArchived] = {notation}IsArchived, [ArchivedOn] = {notation}ArchivedOn, [ModifiedOn] = {notation}ModifiedOn OUTPUT INSERTED.[ChatChannelId] WHERE [IncidentCommandId] = {notation}IncidentCommandId";
+
+ var select = new Func>>(connection =>
+ connection.QueryAsync(sql, parameters, _unitOfWork.Transaction));
+
+ if (_unitOfWork?.Connection == null)
+ {
+ using var connection = _connectionProvider.Create();
+ await connection.OpenAsync();
+ return await select(connection);
+ }
+
+ return await select(_unitOfWork.CreateOrGetConnection());
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex);
+ throw;
+ }
+ }
+
public async Task> GetWithRetentionOverrideAsync(int departmentId)
{
try
@@ -500,6 +533,38 @@ public async Task SetLockedAsync(string chatChannelId, bool locked, string
}
}
+ public async Task RebindToIncidentCommandAsync(string chatChannelId, string incidentCommandId, DateTime modifiedOn, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var parameters = new DynamicParametersExtension();
+ parameters.Add("Id", chatChannelId);
+ parameters.Add("IncidentCommandId", incidentCommandId);
+ parameters.Add("ModifiedOn", modifiedOn, DbType.DateTime2);
+ var notation = _sqlConfiguration.ParameterNotation;
+ var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres
+ ? $"UPDATE {_sqlConfiguration.SchemaName}.chatchannels SET incidentcommandid = {notation}IncidentCommandId, isarchived = FALSE, archivedon = NULL, modifiedon = {notation}ModifiedOn WHERE chatchannelid = {notation}Id"
+ : $"UPDATE {_sqlConfiguration.SchemaName}.[ChatChannels] SET [IncidentCommandId] = {notation}IncidentCommandId, [IsArchived] = 0, [ArchivedOn] = NULL, [ModifiedOn] = {notation}ModifiedOn WHERE [ChatChannelId] = {notation}Id";
+
+ var execute = new Func>(connection =>
+ connection.ExecuteAsync(sql, parameters, _unitOfWork.Transaction));
+
+ if (_unitOfWork?.Connection == null)
+ {
+ using var connection = _connectionProvider.Create();
+ await connection.OpenAsync(cancellationToken);
+ return await execute(connection) > 0;
+ }
+
+ return await execute(_unitOfWork.CreateOrGetConnection()) > 0;
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex);
+ throw;
+ }
+ }
+
public async Task CreateDirectMessageChannelAsync(ChatChannel channel, IEnumerable members, CancellationToken cancellationToken)
{
try
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByDepartmentIdPagedQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByDepartmentIdPagedQuery.cs
index 239124a19..370c820c6 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByDepartmentIdPagedQuery.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByDepartmentIdPagedQuery.cs
@@ -13,9 +13,9 @@ public class SelectSystemAuditsByDepartmentIdPagedQuery : ISelectQuery
public string GetQuery()
{
if (DataConfig.DatabaseType == DatabaseTypes.Postgres)
- return $"SELECT * FROM {_sqlConfiguration.SchemaName}.systemaudits WHERE departmentid = {_sqlConfiguration.ParameterNotation}DepartmentId AND loggedon >= {_sqlConfiguration.ParameterNotation}StartDate AND loggedon < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY loggedon DESC LIMIT {_sqlConfiguration.ParameterNotation}PageSize OFFSET {_sqlConfiguration.ParameterNotation}Offset";
+ return $"SELECT * FROM {_sqlConfiguration.SchemaName}.systemaudits WHERE departmentid = {_sqlConfiguration.ParameterNotation}DepartmentId AND loggedon >= {_sqlConfiguration.ParameterNotation}StartDate AND loggedon < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY loggedon DESC, systemauditid DESC LIMIT {_sqlConfiguration.ParameterNotation}PageSize OFFSET {_sqlConfiguration.ParameterNotation}Offset";
- return $"SELECT * FROM {_sqlConfiguration.SchemaName}.[SystemAudits] WHERE [DepartmentId] = {_sqlConfiguration.ParameterNotation}DepartmentId AND [LoggedOn] >= {_sqlConfiguration.ParameterNotation}StartDate AND [LoggedOn] < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY [LoggedOn] DESC OFFSET {_sqlConfiguration.ParameterNotation}Offset ROWS FETCH NEXT {_sqlConfiguration.ParameterNotation}PageSize ROWS ONLY";
+ return $"SELECT * FROM {_sqlConfiguration.SchemaName}.[SystemAudits] WHERE [DepartmentId] = {_sqlConfiguration.ParameterNotation}DepartmentId AND [LoggedOn] >= {_sqlConfiguration.ParameterNotation}StartDate AND [LoggedOn] < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY [LoggedOn] DESC, [SystemAuditId] DESC OFFSET {_sqlConfiguration.ParameterNotation}Offset ROWS FETCH NEXT {_sqlConfiguration.ParameterNotation}PageSize ROWS ONLY";
}
public string GetQuery() where TEntity : class, IEntity => GetQuery();
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByTypePagedQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByTypePagedQuery.cs
new file mode 100644
index 000000000..3bca1f7c7
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByTypePagedQuery.cs
@@ -0,0 +1,23 @@
+using Resgrid.Config;
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries.Contracts;
+using Resgrid.Repositories.DataRepository.Configs;
+
+namespace Resgrid.Repositories.DataRepository.Queries.SystemAudits
+{
+ public class SelectSystemAuditsByTypePagedQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public SelectSystemAuditsByTypePagedQuery(SqlConfiguration sqlConfiguration) => _sqlConfiguration = sqlConfiguration;
+
+ public string GetQuery()
+ {
+ if (DataConfig.DatabaseType == DatabaseTypes.Postgres)
+ return $"SELECT * FROM {_sqlConfiguration.SchemaName}.systemaudits WHERE type = {_sqlConfiguration.ParameterNotation}Type AND loggedon >= {_sqlConfiguration.ParameterNotation}StartDate AND loggedon < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY loggedon DESC, systemauditid DESC LIMIT {_sqlConfiguration.ParameterNotation}PageSize OFFSET {_sqlConfiguration.ParameterNotation}Offset";
+
+ return $"SELECT * FROM {_sqlConfiguration.SchemaName}.[SystemAudits] WHERE [Type] = {_sqlConfiguration.ParameterNotation}Type AND [LoggedOn] >= {_sqlConfiguration.ParameterNotation}StartDate AND [LoggedOn] < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY [LoggedOn] DESC, [SystemAuditId] DESC OFFSET {_sqlConfiguration.ParameterNotation}Offset ROWS FETCH NEXT {_sqlConfiguration.ParameterNotation}PageSize ROWS ONLY";
+ }
+
+ public string GetQuery() where TEntity : class, IEntity => GetQuery();
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByUserIdPagedQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByUserIdPagedQuery.cs
index 79d27bd90..f3e8695e1 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByUserIdPagedQuery.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByUserIdPagedQuery.cs
@@ -13,9 +13,9 @@ public class SelectSystemAuditsByUserIdPagedQuery : ISelectQuery
public string GetQuery()
{
if (DataConfig.DatabaseType == DatabaseTypes.Postgres)
- return $"SELECT * FROM {_sqlConfiguration.SchemaName}.systemaudits WHERE userid = {_sqlConfiguration.ParameterNotation}UserId AND loggedon >= {_sqlConfiguration.ParameterNotation}StartDate AND loggedon < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY loggedon DESC LIMIT {_sqlConfiguration.ParameterNotation}PageSize OFFSET {_sqlConfiguration.ParameterNotation}Offset";
+ return $"SELECT * FROM {_sqlConfiguration.SchemaName}.systemaudits WHERE userid = {_sqlConfiguration.ParameterNotation}UserId AND loggedon >= {_sqlConfiguration.ParameterNotation}StartDate AND loggedon < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY loggedon DESC, systemauditid DESC LIMIT {_sqlConfiguration.ParameterNotation}PageSize OFFSET {_sqlConfiguration.ParameterNotation}Offset";
- return $"SELECT * FROM {_sqlConfiguration.SchemaName}.[SystemAudits] WHERE [UserId] = {_sqlConfiguration.ParameterNotation}UserId AND [LoggedOn] >= {_sqlConfiguration.ParameterNotation}StartDate AND [LoggedOn] < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY [LoggedOn] DESC OFFSET {_sqlConfiguration.ParameterNotation}Offset ROWS FETCH NEXT {_sqlConfiguration.ParameterNotation}PageSize ROWS ONLY";
+ return $"SELECT * FROM {_sqlConfiguration.SchemaName}.[SystemAudits] WHERE [UserId] = {_sqlConfiguration.ParameterNotation}UserId AND [LoggedOn] >= {_sqlConfiguration.ParameterNotation}StartDate AND [LoggedOn] < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY [LoggedOn] DESC, [SystemAuditId] DESC OFFSET {_sqlConfiguration.ParameterNotation}Offset ROWS FETCH NEXT {_sqlConfiguration.ParameterNotation}PageSize ROWS ONLY";
}
public string GetQuery() where TEntity : class, IEntity => GetQuery();
diff --git a/Repositories/Resgrid.Repositories.DataRepository/SystemAuditRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/SystemAuditRepository.cs
index 9c2fd2725..01f4cf822 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/SystemAuditRepository.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/SystemAuditRepository.cs
@@ -15,6 +15,11 @@ namespace Resgrid.Repositories.DataRepository
{
public class SystemAuditsRepository : RepositoryBase, ISystemAuditsRepository
{
+ // Callers control page/pageSize; without a ceiling one request could pull the whole audit
+ // table, and (page - 1) * pageSize in int arithmetic can overflow into a negative OFFSET
+ // the database rejects.
+ private const int MaxPageSize = 1000;
+
private readonly IConnectionProvider _connectionProvider;
private readonly SqlConfiguration _sqlConfiguration;
private readonly IQueryFactory _queryFactory;
@@ -39,9 +44,8 @@ public async Task> GetByUserIdPagedAsync(string userId,
dynamicParameters.Add("UserId", userId);
dynamicParameters.Add("StartDate", startDate);
dynamicParameters.Add("EndDate", endDate);
- var safePage = page < 1 ? 1 : page;
- var safePageSize = pageSize < 1 ? 1 : pageSize;
- dynamicParameters.Add("Offset", (safePage - 1) * safePageSize);
+ var (offset, safePageSize) = NormalizePaging(page, pageSize);
+ dynamicParameters.Add("Offset", offset);
dynamicParameters.Add("PageSize", safePageSize);
var query = _queryFactory.GetQuery();
@@ -86,9 +90,8 @@ public async Task> GetByDepartmentIdPagedAsync(int depa
dynamicParameters.Add("DepartmentId", departmentId);
dynamicParameters.Add("StartDate", startDate);
dynamicParameters.Add("EndDate", endDate);
- var safePage = page < 1 ? 1 : page;
- var safePageSize = pageSize < 1 ? 1 : pageSize;
- dynamicParameters.Add("Offset", (safePage - 1) * safePageSize);
+ var (offset, safePageSize) = NormalizePaging(page, pageSize);
+ dynamicParameters.Add("Offset", offset);
dynamicParameters.Add("PageSize", safePageSize);
var query = _queryFactory.GetQuery();
@@ -122,5 +125,59 @@ public async Task> GetByDepartmentIdPagedAsync(int depa
throw;
}
}
+
+ public async Task> GetByTypePagedAsync(int type, DateTime startDate, DateTime endDate, int page, int pageSize)
+ {
+ try
+ {
+ var selectFunction = new Func>>(async x =>
+ {
+ var dynamicParameters = new DynamicParametersExtension();
+ dynamicParameters.Add("Type", type);
+ dynamicParameters.Add("StartDate", startDate);
+ dynamicParameters.Add("EndDate", endDate);
+ var (offset, safePageSize) = NormalizePaging(page, pageSize);
+ dynamicParameters.Add("Offset", offset);
+ dynamicParameters.Add("PageSize", safePageSize);
+
+ var query = _queryFactory.GetQuery();
+
+ return await x.QueryAsync(sql: query,
+ param: dynamicParameters,
+ transaction: _unitOfWork.Transaction);
+ });
+
+ DbConnection conn = null;
+ if (_unitOfWork?.Connection == null)
+ {
+ using (conn = _connectionProvider.Create())
+ {
+ await conn.OpenAsync();
+
+ return await selectFunction(conn);
+ }
+ }
+ else
+ {
+ conn = _unitOfWork.CreateOrGetConnection();
+
+ return await selectFunction(conn);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex, extraMessage: $"GetByTypePagedAsync Type: {type}");
+
+ throw;
+ }
+ }
+
+ private static (long Offset, int PageSize) NormalizePaging(int page, int pageSize)
+ {
+ var safePage = page < 1 ? 1 : page;
+ var safePageSize = pageSize < 1 ? 1 : (pageSize > MaxPageSize ? MaxPageSize : pageSize);
+
+ return ((safePage - 1L) * safePageSize, safePageSize);
+ }
}
}
diff --git a/Tests/Resgrid.Tests/Config/CorsHelperTests.cs b/Tests/Resgrid.Tests/Config/CorsHelperTests.cs
new file mode 100644
index 000000000..6a2ef61a1
--- /dev/null
+++ b/Tests/Resgrid.Tests/Config/CorsHelperTests.cs
@@ -0,0 +1,169 @@
+using FluentAssertions;
+using NUnit.Framework;
+using Resgrid.Config;
+
+namespace Resgrid.Tests.Config
+{
+ [TestFixture]
+ public class CorsHelperTests
+ {
+ private string _originalBaseUrl;
+ private string _originalApiBaseUrl;
+ private string _originalEventingBaseUrl;
+ private string _originalCorsAllowedOrigins;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _originalBaseUrl = SystemBehaviorConfig.ResgridBaseUrl;
+ _originalApiBaseUrl = SystemBehaviorConfig.ResgridApiBaseUrl;
+ _originalEventingBaseUrl = SystemBehaviorConfig.ResgridEventingBaseUrl;
+ _originalCorsAllowedOrigins = ApiConfig.CorsAllowedOrigins;
+
+ SystemBehaviorConfig.ResgridBaseUrl = "https://qaweb.resgrid.dev";
+ SystemBehaviorConfig.ResgridApiBaseUrl = "https://qaapi.resgrid.dev";
+ SystemBehaviorConfig.ResgridEventingBaseUrl = "https://qaevents.resgrid.dev";
+ ApiConfig.CorsAllowedOrigins = "";
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ SystemBehaviorConfig.ResgridBaseUrl = _originalBaseUrl;
+ SystemBehaviorConfig.ResgridApiBaseUrl = _originalApiBaseUrl;
+ SystemBehaviorConfig.ResgridEventingBaseUrl = _originalEventingBaseUrl;
+ ApiConfig.CorsAllowedOrigins = _originalCorsAllowedOrigins;
+ }
+
+ [Test]
+ public void should_allow_configured_base_hosts_and_their_subdomains()
+ {
+ CorsHelper.IsAllowedOrigin("https://qaapi.resgrid.dev").Should().BeTrue();
+ CorsHelper.IsAllowedOrigin("https://sub.qaweb.resgrid.dev").Should().BeTrue();
+ }
+
+ [Test]
+ public void should_allow_sibling_apps_under_the_shared_parent_domain()
+ {
+ // qadispatch.resgrid.dev is not a subdomain of any configured base host, but it
+ // shares the resgrid.dev parent - this is the dispatch/unit/responder web case.
+ CorsHelper.IsAllowedOrigin("https://qadispatch.resgrid.dev").Should().BeTrue();
+ CorsHelper.IsAllowedOrigin("https://resgrid.dev").Should().BeTrue();
+ }
+
+ [Test]
+ public void should_allow_subdomains_when_base_url_is_already_the_apex()
+ {
+ SystemBehaviorConfig.ResgridBaseUrl = "https://resgrid.com";
+ SystemBehaviorConfig.ResgridApiBaseUrl = "https://api.resgrid.com";
+ SystemBehaviorConfig.ResgridEventingBaseUrl = "https://events.resgrid.com";
+
+ CorsHelper.IsAllowedOrigin("https://dispatch.resgrid.com").Should().BeTrue();
+ CorsHelper.IsAllowedOrigin("https://resgrid.com").Should().BeTrue();
+ }
+
+ [Test]
+ public void should_reject_unrelated_and_lookalike_domains()
+ {
+ CorsHelper.IsAllowedOrigin("https://evil.com").Should().BeFalse();
+ CorsHelper.IsAllowedOrigin("https://evilresgrid.dev").Should().BeFalse();
+ CorsHelper.IsAllowedOrigin("https://resgrid.dev.evil.com").Should().BeFalse();
+ CorsHelper.IsAllowedOrigin("https://qaapi.resgrid.dev.evil.com").Should().BeFalse();
+ }
+
+ [Test]
+ public void should_not_widen_the_parent_domain_past_a_public_registry_suffix()
+ {
+ SystemBehaviorConfig.ResgridBaseUrl = "https://web.resgrid.co.uk";
+ SystemBehaviorConfig.ResgridApiBaseUrl = "https://api.resgrid.co.uk";
+ SystemBehaviorConfig.ResgridEventingBaseUrl = "https://events.resgrid.co.uk";
+
+ // Siblings under resgrid.co.uk are fine, but the parent must never widen to co.uk.
+ CorsHelper.IsAllowedOrigin("https://dispatch.resgrid.co.uk").Should().BeTrue();
+ CorsHelper.IsAllowedOrigin("https://someoneelse.co.uk").Should().BeFalse();
+ }
+
+ [Test]
+ public void should_not_widen_the_parent_domain_past_a_shared_hosting_suffix()
+ {
+ // A deployment on shared hosting must not treat the platform apex as its parent —
+ // every other tenant is an attacker-controlled sibling. Widening still works one
+ // level below the suffix, scoped to the deployment's own tenant name.
+ SystemBehaviorConfig.ResgridBaseUrl = "https://myorg.github.io";
+ SystemBehaviorConfig.ResgridApiBaseUrl = "https://api.myorg.github.io";
+ SystemBehaviorConfig.ResgridEventingBaseUrl = "";
+
+ CorsHelper.IsAllowedOrigin("https://myorg.github.io").Should().BeTrue();
+ CorsHelper.IsAllowedOrigin("https://dispatch.myorg.github.io").Should().BeTrue();
+ CorsHelper.IsAllowedOrigin("https://attacker.github.io").Should().BeFalse();
+ CorsHelper.IsAllowedOrigin("https://github.io").Should().BeFalse();
+ }
+
+ [Test]
+ public void should_not_widen_the_parent_domain_past_a_paas_suffix()
+ {
+ SystemBehaviorConfig.ResgridBaseUrl = "https://resgrid-web.azurewebsites.net";
+ SystemBehaviorConfig.ResgridApiBaseUrl = "https://resgrid-api.azurewebsites.net";
+ SystemBehaviorConfig.ResgridEventingBaseUrl = "https://resgrid-app.herokuapp.com";
+
+ CorsHelper.IsAllowedOrigin("https://resgrid-web.azurewebsites.net").Should().BeTrue();
+ CorsHelper.IsAllowedOrigin("https://attacker.azurewebsites.net").Should().BeFalse();
+ CorsHelper.IsAllowedOrigin("https://attacker.herokuapp.com").Should().BeFalse();
+ CorsHelper.IsAllowedOrigin("https://azurewebsites.net").Should().BeFalse();
+ }
+
+ [Test]
+ public void should_not_widen_single_label_or_ip_hosts()
+ {
+ SystemBehaviorConfig.ResgridBaseUrl = "https://localhost";
+ SystemBehaviorConfig.ResgridApiBaseUrl = "https://192.168.1.20";
+ SystemBehaviorConfig.ResgridEventingBaseUrl = "";
+
+ CorsHelper.IsAllowedOrigin("https://localhost").Should().BeTrue();
+ CorsHelper.IsAllowedOrigin("https://192.168.1.20").Should().BeTrue();
+ CorsHelper.IsAllowedOrigin("https://192.168.1.21").Should().BeFalse();
+ CorsHelper.IsAllowedOrigin("https://example.com").Should().BeFalse();
+ }
+
+ [Test]
+ public void should_match_configured_origins_with_a_scheme_exactly()
+ {
+ ApiConfig.CorsAllowedOrigins = "http://localhost:8081, https://mydispatch.example.com";
+
+ CorsHelper.IsAllowedOrigin("http://localhost:8081").Should().BeTrue();
+ CorsHelper.IsAllowedOrigin("http://localhost:9999").Should().BeFalse();
+ CorsHelper.IsAllowedOrigin("https://localhost:8081").Should().BeFalse();
+ CorsHelper.IsAllowedOrigin("https://mydispatch.example.com").Should().BeTrue();
+ CorsHelper.IsAllowedOrigin("http://mydispatch.example.com").Should().BeFalse();
+ }
+
+ [Test]
+ public void should_match_bare_host_entries_on_any_scheme_and_port()
+ {
+ ApiConfig.CorsAllowedOrigins = "mydispatch.example.com";
+
+ CorsHelper.IsAllowedOrigin("https://mydispatch.example.com").Should().BeTrue();
+ CorsHelper.IsAllowedOrigin("http://mydispatch.example.com:3000").Should().BeTrue();
+ CorsHelper.IsAllowedOrigin("https://sub.mydispatch.example.com").Should().BeFalse();
+ }
+
+ [Test]
+ public void should_allow_everything_with_a_wildcard_entry()
+ {
+ ApiConfig.CorsAllowedOrigins = "*";
+
+ CorsHelper.IsAllowedOrigin("https://anything.example.com").Should().BeTrue();
+ CorsHelper.IsAllowedOrigin("http://localhost:1234").Should().BeTrue();
+ }
+
+ [Test]
+ public void should_reject_missing_or_malformed_origins()
+ {
+ CorsHelper.IsAllowedOrigin(null).Should().BeFalse();
+ CorsHelper.IsAllowedOrigin("").Should().BeFalse();
+ CorsHelper.IsAllowedOrigin(" ").Should().BeFalse();
+ CorsHelper.IsAllowedOrigin("not-a-url").Should().BeFalse();
+ CorsHelper.IsAllowedOrigin("null").Should().BeFalse();
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Services/CallDispatchStatusServiceTests.cs b/Tests/Resgrid.Tests/Services/CallDispatchStatusServiceTests.cs
index 6fd8548eb..567cfa990 100644
--- a/Tests/Resgrid.Tests/Services/CallDispatchStatusServiceTests.cs
+++ b/Tests/Resgrid.Tests/Services/CallDispatchStatusServiceTests.cs
@@ -38,8 +38,8 @@ public void SetUp()
.Setup(x => x.SetUserActionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new ActionLog());
_unitsService
- .Setup(x => x.SetUnitStateAsync(It.IsAny(), It.IsAny(), It.IsAny()))
- .ReturnsAsync((UnitState state, int _, CancellationToken __) => state);
+ .Setup(x => x.SetUnitStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync((UnitState state, int _, CancellationToken __, bool ___) => state);
_service = new CallDispatchStatusService(
_departmentSettingsService.Object,
@@ -85,7 +85,8 @@ public async Task ApplyDispatchStatusesAsync_uses_default_shift_and_unit_dispatc
s.DestinationId == 12 &&
s.DestinationType == (int)DestinationEntityTypes.Call),
7,
- It.IsAny()), Times.Once);
+ It.IsAny(),
+ true), Times.Once);
}
[Test]
@@ -116,7 +117,8 @@ public async Task ApplyReleaseStatusesAsync_uses_configured_release_statuses()
s.DestinationId == 22 &&
s.DestinationType == (int)DestinationEntityTypes.Call),
7,
- It.IsAny()), Times.Once);
+ It.IsAny(),
+ true), Times.Once);
}
[Test]
@@ -144,7 +146,8 @@ public async Task ApplyDispatchStatusesAsync_skips_shift_personnel_when_auto_sta
s.DestinationId == 32 &&
s.DestinationType == (int)DestinationEntityTypes.Call),
7,
- It.IsAny()), Times.Once);
+ It.IsAny(),
+ true), Times.Once);
}
[Test]
@@ -181,11 +184,13 @@ public async Task ApplyDispatchStatusesAsync_uses_unit_type_override_only_for_ma
_unitsService.Verify(x => x.SetUnitStateAsync(
It.Is(s => s.UnitId == 11 && s.State == 44 && s.DestinationId == 42),
7,
- It.IsAny()), Times.Once);
+ It.IsAny(),
+ true), Times.Once);
_unitsService.Verify(x => x.SetUnitStateAsync(
It.Is(s => s.UnitId == 12 && s.State == (int)UnitStateTypes.Responding && s.DestinationId == 42),
7,
- It.IsAny()), Times.Once);
+ It.IsAny(),
+ true), Times.Once);
}
[Test]
@@ -220,7 +225,8 @@ public async Task ApplyReleaseStatusesAsync_uses_unit_type_release_override_when
_unitsService.Verify(x => x.SetUnitStateAsync(
It.Is(s => s.UnitId == 11 && s.State == 77 && s.DestinationId == 52),
7,
- It.IsAny()), Times.Once);
+ It.IsAny(),
+ true), Times.Once);
}
}
}
diff --git a/Tests/Resgrid.Tests/Services/ChatFrozenChannelTests.cs b/Tests/Resgrid.Tests/Services/ChatFrozenChannelTests.cs
new file mode 100644
index 000000000..5ddcd93f2
--- /dev/null
+++ b/Tests/Resgrid.Tests/Services/ChatFrozenChannelTests.cs
@@ -0,0 +1,156 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Moq;
+using NUnit.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Services;
+using Resgrid.Services;
+
+namespace Resgrid.Tests.Services
+{
+ ///
+ /// A closed incident's command and lane chat becomes a point-in-time record: nobody posts, nobody
+ /// rewrites what is already there, and moderation still works. Posting is enforced by
+ /// ; this fixture covers the matching gates on the
+ /// mutation paths, which previously let an author keep editing history in an archived channel.
+ ///
+ [TestFixture]
+ public class ChatFrozenChannelTests
+ {
+ private const string ChannelId = "channel-1";
+ private const string MessageId = "message-1";
+ private const string SenderId = "sender";
+
+ private Mock _channelRepository;
+ private Mock _messageRepository;
+ private Mock _reactionRepository;
+ private Mock _editRepository;
+ private ChatMessage _message;
+
+ [SetUp]
+ public void Setup()
+ {
+ _message = new ChatMessage
+ {
+ ChatMessageId = MessageId,
+ ChatChannelId = ChannelId,
+ DepartmentId = 1,
+ SenderUserId = SenderId,
+ Body = "original body"
+ };
+
+ _channelRepository = new Mock();
+ _messageRepository = new Mock();
+ _reactionRepository = new Mock();
+ _editRepository = new Mock();
+
+ _messageRepository.Setup(x => x.GetByIdAsync(MessageId)).ReturnsAsync(_message);
+ _messageRepository
+ .Setup(x => x.UpdateBodyAsync(MessageId, It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(true);
+ _messageRepository
+ .Setup(x => x.TombstoneAsync(MessageId, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(true);
+ }
+
+ private void GivenChannelArchived(bool archived)
+ {
+ _channelRepository
+ .Setup(x => x.GetByIdAsync(ChannelId))
+ .ReturnsAsync(new ChatChannel { ChatChannelId = ChannelId, DepartmentId = 1, IsArchived = archived });
+ }
+
+ private ChatMessageService BuildService()
+ => new ChatMessageService(
+ _channelRepository.Object,
+ _messageRepository.Object,
+ _editRepository.Object,
+ Mock.Of(),
+ _reactionRepository.Object,
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of());
+
+ [Test]
+ public async Task EditMessageAsync_is_refused_once_the_channel_is_frozen()
+ {
+ GivenChannelArchived(true);
+
+ var result = await BuildService().EditMessageAsync(MessageId, SenderId, "rewritten after the fact");
+
+ result.Should().BeNull();
+ _message.Body.Should().Be("original body");
+ _messageRepository.Verify(x => x.UpdateBodyAsync(MessageId, It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public async Task EditMessageAsync_still_works_while_the_incident_is_active()
+ {
+ GivenChannelArchived(false);
+
+ var result = await BuildService().EditMessageAsync(MessageId, SenderId, "corrected");
+
+ result.Should().NotBeNull();
+ result.Body.Should().Be("corrected");
+ }
+
+ [Test]
+ public async Task DeleteMessageAsync_refuses_the_author_once_the_channel_is_frozen()
+ {
+ GivenChannelArchived(true);
+
+ var result = await BuildService().DeleteMessageAsync(MessageId, SenderId, asModerator: false, reason: null);
+
+ result.Should().BeFalse();
+ _messageRepository.Verify(x => x.TombstoneAsync(MessageId, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public async Task DeleteMessageAsync_still_lets_a_moderator_remove_flagged_content_when_frozen()
+ {
+ GivenChannelArchived(true);
+
+ // Moderation has to keep working on a closed incident — that is the whole point of leaving
+ // flagging available on a frozen record.
+ var result = await BuildService().DeleteMessageAsync(MessageId, "moderator", asModerator: true, reason: "policy");
+
+ result.Should().BeTrue();
+ _message.IsModerated.Should().BeTrue();
+ _messageRepository.Verify(x => x.TombstoneAsync(MessageId, It.IsAny(), "moderator", true, It.IsAny()), Times.Once);
+ }
+
+ [Test]
+ public async Task Reactions_are_refused_both_ways_once_the_channel_is_frozen()
+ {
+ GivenChannelArchived(true);
+ var service = BuildService();
+
+ (await service.AddReactionAsync(MessageId, SenderId, null, "👍")).Should().BeFalse();
+ (await service.RemoveReactionAsync(MessageId, SenderId, null, "👍")).Should().BeFalse();
+
+ _reactionRepository.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ _reactionRepository.Verify(
+ x => x.DeleteReactionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Test]
+ public async Task A_missing_channel_reads_as_frozen_so_an_unanchored_edit_cannot_slip_through()
+ {
+ _channelRepository.Setup(x => x.GetByIdAsync(ChannelId)).ReturnsAsync((ChatChannel)null);
+
+ var result = await BuildService().EditMessageAsync(MessageId, SenderId, "rewritten");
+
+ result.Should().BeNull();
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs b/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs
new file mode 100644
index 000000000..44336b199
--- /dev/null
+++ b/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs
@@ -0,0 +1,234 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Moq;
+using NUnit.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Repositories.Queries;
+using Resgrid.Model.Services;
+using Resgrid.Services;
+
+namespace Resgrid.Tests.Services
+{
+ ///
+ /// Incidents established before the incident chat channels existed heal themselves the first time
+ /// someone opens the board or the responder view — no migration sweep. This fixture pins the parts
+ /// that matter: only what is missing gets created, closed commands stay frozen, and a board that
+ /// refreshes on a timer does not re-sweep.
+ ///
+ [TestFixture]
+ public class ChatIncidentBackfillTests
+ {
+ private const int CallId = 42;
+ private const string CommandId = "command-1";
+
+ private Mock _channelRepository;
+ private Mock _cacheProvider;
+ private Mock _permissionService;
+ private List _inserted;
+
+ [SetUp]
+ public void Setup()
+ {
+ _channelRepository = new Mock();
+ _cacheProvider = new Mock();
+ _permissionService = new Mock();
+ _inserted = new List();
+
+ // No marker set: the backfill runs.
+ _cacheProvider.Setup(x => x.GetStringAsync(It.IsAny())).ReturnsAsync((string)null);
+ _cacheProvider.Setup(x => x.SetStringAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true);
+
+ _channelRepository
+ .Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync((ChatChannel channel, CancellationToken _, bool __) =>
+ {
+ _inserted.Add(channel);
+ return channel;
+ });
+ }
+
+ private ChatChannelService BuildService()
+ => new ChatChannelService(
+ _channelRepository.Object,
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of(),
+ _permissionService.Object,
+ Mock.Of(),
+ Mock.Of