diff --git a/Core/Resgrid.Model/Chat/ChatChannel.cs b/Core/Resgrid.Model/Chat/ChatChannel.cs
index 3d6bf695..a5d0037b 100644
--- a/Core/Resgrid.Model/Chat/ChatChannel.cs
+++ b/Core/Resgrid.Model/Chat/ChatChannel.cs
@@ -58,8 +58,9 @@ public class ChatChannel : IEntity, IChangeTracked
public string OwnerUserId { get; set; }
///
- /// Normalized participant identity key for DM dedup, unique per department when set.
- /// Sorted, e.g. "u:{idA}|u:{idB}" or "u:{userId}|unit:{unitId}".
+ /// Normalized identity key for one-channel-per-identity dedup, unique per department when set.
+ /// DMs use the sorted participant pair ("u:{idA}|u:{idB}", "u:{userId}|unit:{unitId}");
+ /// UnitDispatch channels use "unitdispatch:{unitId}".
///
[ProtoMember(13)]
public string DmKey { get; set; }
diff --git a/Core/Resgrid.Model/Chat/ChatEnums.cs b/Core/Resgrid.Model/Chat/ChatEnums.cs
index 1b246df3..9db36098 100644
--- a/Core/Resgrid.Model/Chat/ChatEnums.cs
+++ b/Core/Resgrid.Model/Chat/ChatEnums.cs
@@ -26,7 +26,16 @@ public enum ChatChannelType
/// 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
+ IncidentDispatch = 10,
+
+ ///
+ /// A unit's standing line to the dispatch desk: the unit-shared identity ("Engine 6") on one side,
+ /// every dispatch-authorized user on the other. Department-wide and permanent, unlike
+ /// which is scoped to one call — this is where a unit reaches
+ /// dispatch when there is no incident to anchor the conversation. One per unit, provisioned the
+ /// first time the unit's operator lists channels.
+ ///
+ UnitDispatch = 11
}
/// Who a chat participant is: a person, a unit-shared identity ("Engine 6"), or the chatbot.
diff --git a/Core/Resgrid.Model/Services/IChatServices.cs b/Core/Resgrid.Model/Services/IChatServices.cs
index cfeee86d..f44f703e 100644
--- a/Core/Resgrid.Model/Services/IChatServices.cs
+++ b/Core/Resgrid.Model/Services/IChatServices.cs
@@ -115,6 +115,14 @@ public interface IChatChannelService
/// Ensures the incident's line to the dispatch desk.
Task EnsureDispatchChannelAsync(int departmentId, int callId, string incidentCommandId, CancellationToken cancellationToken = default(CancellationToken));
+ ///
+ /// Ensures the unit's standing line to the dispatch desk: the unit-shared identity plus every
+ /// dispatch-authorized user. Department-wide and permanent (not call-scoped). The unit is stamped
+ /// as an explicit member row so its operators see the channel through the unit-membership pass;
+ /// the dispatch side is an implicit audience. Also refreshes the channel name if the unit was renamed.
+ ///
+ Task EnsureUnitDispatchChannelAsync(int departmentId, int unitId, 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.
diff --git a/Core/Resgrid.Services/ChatChannelService.cs b/Core/Resgrid.Services/ChatChannelService.cs
index 587f331d..0b6a7850 100644
--- a/Core/Resgrid.Services/ChatChannelService.cs
+++ b/Core/Resgrid.Services/ChatChannelService.cs
@@ -35,6 +35,7 @@ public class ChatChannelService : IChatChannelService
private readonly IDepartmentGroupsService _departmentGroupsService;
private readonly IUnitsService _unitsService;
private readonly IUserProfileService _userProfileService;
+ private readonly ICallsService _callsService;
private readonly IEventAggregator _eventAggregator;
private readonly ICacheProvider _cacheProvider;
private readonly IUnitOfWork _unitOfWork;
@@ -42,7 +43,7 @@ public class ChatChannelService : IChatChannelService
public ChatChannelService(IChatChannelRepository chatChannelRepository, IChatChannelMemberRepository chatChannelMemberRepository,
IChatChannelAccessRuleRepository chatChannelAccessRuleRepository, IChatDepartmentSettingRepository chatDepartmentSettingRepository,
IChatPermissionService chatPermissionService, IDepartmentsService departmentsService, IDepartmentGroupsService departmentGroupsService,
- IUnitsService unitsService, IUserProfileService userProfileService, IEventAggregator eventAggregator,
+ IUnitsService unitsService, IUserProfileService userProfileService, ICallsService callsService, IEventAggregator eventAggregator,
ICacheProvider cacheProvider, IUnitOfWork unitOfWork)
{
_chatChannelRepository = chatChannelRepository;
@@ -54,6 +55,7 @@ public ChatChannelService(IChatChannelRepository chatChannelRepository, IChatCha
_departmentGroupsService = departmentGroupsService;
_unitsService = unitsService;
_userProfileService = userProfileService;
+ _callsService = callsService;
_eventAggregator = eventAggregator;
_cacheProvider = cacheProvider;
_unitOfWork = unitOfWork;
@@ -162,6 +164,20 @@ async Task> getChannels()
// department member could list another unit's private channels.
if (activeUnitId.HasValue && await _chatPermissionService.CanSendAsUnitAsync(userId, activeUnitId.Value, departmentId))
{
+ // The unit's standing dispatch line is provisioned on the unit's first channel list
+ // rather than at unit creation, so pre-existing units heal themselves. Best-effort:
+ // the list must survive a provisioning failure.
+ try
+ {
+ var unitDispatchChannel = await EnsureUnitDispatchChannelAsync(departmentId, activeUnitId.Value);
+ if (unitDispatchChannel != null)
+ results[unitDispatchChannel.ChatChannelId] = unitDispatchChannel;
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex);
+ }
+
var unitMemberships = await _chatChannelMemberRepository.GetActiveByUnitIdAsync(departmentId, activeUnitId.Value);
var unitChannelIds = unitMemberships?
.Select(m => m.ChatChannelId)
@@ -177,8 +193,9 @@ async Task> getChannels()
}
}
- // Implicit-audience channels (custom rule-based + active incident channels): evaluate access
- // per channel; evaluations are cached by the permission service.
+ // Implicit-audience channels (custom rule-based, incident channels including the leads and
+ // dispatch lines, and unit dispatch lines): evaluate access per channel; evaluations are
+ // cached by the permission service.
if (allChannels != null)
{
foreach (var channel in allChannels)
@@ -188,7 +205,9 @@ async Task> getChannels()
var type = (ChatChannelType)channel.ChannelType;
if (type != ChatChannelType.CustomLocked && type != ChatChannelType.Incident &&
- type != ChatChannelType.IncidentLane && type != ChatChannelType.IncidentCommand)
+ type != ChatChannelType.IncidentLane && type != ChatChannelType.IncidentCommand &&
+ type != ChatChannelType.IncidentLeads && type != ChatChannelType.IncidentDispatch &&
+ type != ChatChannelType.UnitDispatch)
continue;
if (await _chatPermissionService.CanAccessChannelAsync(channel, userId, activeUnitId))
@@ -666,12 +685,16 @@ public async Task GetUserMembershipAsync(string chatChannelId
if (existing != null)
return existing;
+ // Callers without the call in hand (the backfill) pass no name; resolve it here so healed
+ // channels get the real call name instead of the "Call {id}" fallback.
+ var name = !string.IsNullOrWhiteSpace(callName) ? callName.Trim() : await ResolveIncidentPrefixAsync(callId, null);
+
return await InsertProvisionedChannelAsync(new ChatChannel
{
ChatChannelId = Guid.NewGuid().ToString(),
DepartmentId = departmentId,
ChannelType = (int)ChatChannelType.Incident,
- Name = string.IsNullOrWhiteSpace(callName) ? $"Call {callId}" : callName,
+ Name = name,
CallId = callId,
CreatedOn = DateTime.UtcNow
}, () => _chatChannelRepository.GetByCallIdAndTypeAsync(callId, (int)ChatChannelType.Incident), cancellationToken);
@@ -682,16 +705,36 @@ public async Task GetUserMembershipAsync(string chatChannelId
if (node == null)
return null;
+ return await EnsureLaneChannelCoreAsync(node, await ResolveLanePrefixAsync(node.CallId), cancellationToken);
+ }
+
+ ///
+ /// Incident-scoped channel names all start with the incident (or call) name; the incident channel's
+ /// own name IS that prefix, so prefer reusing it over re-deriving from the call.
+ ///
+ private async Task ResolveLanePrefixAsync(int callId)
+ {
+ var incidentChannel = await _chatChannelRepository.GetByCallIdAndTypeAsync(callId, (int)ChatChannelType.Incident);
+ if (!string.IsNullOrWhiteSpace(incidentChannel?.Name))
+ return incidentChannel.Name;
+
+ return await ResolveIncidentPrefixAsync(callId, null);
+ }
+
+ private async Task EnsureLaneChannelCoreAsync(CommandStructureNode node, string prefix, CancellationToken cancellationToken)
+ {
+ var desiredName = BuildLaneChannelName(prefix, node.Name);
+
var existing = await _chatChannelRepository.GetByCommandStructureNodeIdAsync(node.CommandStructureNodeId);
if (existing != null)
- return existing;
+ return await ApplyProvisionedNameAsync(existing, desiredName, cancellationToken);
return await InsertProvisionedChannelAsync(new ChatChannel
{
ChatChannelId = Guid.NewGuid().ToString(),
DepartmentId = node.DepartmentId,
ChannelType = (int)ChatChannelType.IncidentLane,
- Name = node.Name,
+ Name = desiredName,
CallId = node.CallId,
IncidentCommandId = node.IncidentCommandId,
CommandStructureNodeId = node.CommandStructureNodeId,
@@ -715,6 +758,11 @@ public async Task GetUserMembershipAsync(string chatChannelId
.Select(c => c.CommandStructureNodeId),
StringComparer.OrdinalIgnoreCase);
+ var prefix = (existing ?? Enumerable.Empty())
+ .FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.Incident)?.Name;
+ if (string.IsNullOrWhiteSpace(prefix))
+ prefix = await ResolveIncidentPrefixAsync(callId, null);
+
foreach (var node in nodeList)
{
if (provisionedNodeIds.Contains(node.CommandStructureNodeId))
@@ -723,7 +771,7 @@ public async Task GetUserMembershipAsync(string chatChannelId
// Provisioning inserts are serialized deliberately: they share the caller's unit-of-work
// connection (single DbConnection is not concurrency-safe). N is the template lane count
// (single digits) on a cold, once-per-incident path, so this is not a hot loop.
- await EnsureLaneChannelAsync(node, cancellationToken);
+ await EnsureLaneChannelCoreAsync(node, prefix, cancellationToken);
provisionedNodeIds.Add(node.CommandStructureNodeId);
}
}
@@ -733,20 +781,8 @@ public async Task GetUserMembershipAsync(string chatChannelId
if (command == null)
return null;
- var existing = await _chatChannelRepository.GetByCallIdAndTypeAsync(command.CallId, (int)ChatChannelType.IncidentCommand);
- 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.IncidentCommand,
- Name = "Command",
- CallId = command.CallId,
- IncidentCommandId = command.IncidentCommandId,
- CreatedOn = DateTime.UtcNow
- }, () => _chatChannelRepository.GetByCallIdAndTypeAsync(command.CallId, (int)ChatChannelType.IncidentCommand), cancellationToken);
+ return await EnsureCommandScopedChannelCoreAsync(command.DepartmentId, command.CallId, command.IncidentCommandId,
+ ChatChannelType.IncidentCommand, await ResolveIncidentPrefixAsync(command.CallId, command.Name), cancellationToken);
}
public async Task EnsureLeadsChannelAsync(IncidentCommand command, CancellationToken cancellationToken = default(CancellationToken))
@@ -754,20 +790,8 @@ public async Task GetUserMembershipAsync(string chatChannelId
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);
+ return await EnsureCommandScopedChannelCoreAsync(command.DepartmentId, command.CallId, command.IncidentCommandId,
+ ChatChannelType.IncidentLeads, await ResolveIncidentPrefixAsync(command.CallId, command.Name), cancellationToken);
}
public async Task EnsureDispatchChannelAsync(int departmentId, int callId, string incidentCommandId, CancellationToken cancellationToken = default(CancellationToken))
@@ -775,20 +799,37 @@ public async Task GetUserMembershipAsync(string chatChannelId
if (callId <= 0)
return null;
- var existing = await _chatChannelRepository.GetByCallIdAndTypeAsync(callId, (int)ChatChannelType.IncidentDispatch);
+ return await EnsureCommandScopedChannelCoreAsync(departmentId, callId, incidentCommandId,
+ ChatChannelType.IncidentDispatch, await ResolveIncidentPrefixAsync(callId, null), cancellationToken);
+ }
+
+ ///
+ /// Shared ensure for the three command-scoped singletons (Command/All Leads/Dispatch): an existing
+ /// channel is rebound to the current command and renamed if the incident prefix drifted; a missing
+ /// one is created under its "{prefix} {suffix}" name.
+ ///
+ private async Task EnsureCommandScopedChannelCoreAsync(int departmentId, int callId, string incidentCommandId,
+ ChatChannelType channelType, string prefix, CancellationToken cancellationToken)
+ {
+ var desiredName = BuildCommandScopedChannelName(channelType, prefix);
+
+ var existing = await _chatChannelRepository.GetByCallIdAndTypeAsync(callId, (int)channelType);
if (existing != null)
- return await RebindCommandScopedChannelAsync(existing, incidentCommandId, cancellationToken);
+ {
+ var rebound = await RebindCommandScopedChannelAsync(existing, incidentCommandId, cancellationToken);
+ return await ApplyProvisionedNameAsync(rebound, desiredName, cancellationToken);
+ }
return await InsertProvisionedChannelAsync(new ChatChannel
{
ChatChannelId = Guid.NewGuid().ToString(),
DepartmentId = departmentId,
- ChannelType = (int)ChatChannelType.IncidentDispatch,
- Name = "Dispatch",
+ ChannelType = (int)channelType,
+ Name = desiredName,
CallId = callId,
IncidentCommandId = incidentCommandId,
CreatedOn = DateTime.UtcNow
- }, () => _chatChannelRepository.GetByCallIdAndTypeAsync(callId, (int)ChatChannelType.IncidentDispatch), cancellationToken);
+ }, () => _chatChannelRepository.GetByCallIdAndTypeAsync(callId, (int)channelType), cancellationToken);
}
///
@@ -821,6 +862,69 @@ private async Task RebindCommandScopedChannelAsync(ChatChannel chan
return channel;
}
+ public async Task EnsureUnitDispatchChannelAsync(int departmentId, int unitId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ if (unitId <= 0)
+ return null;
+
+ var unit = await _unitsService.GetUnitByIdAsync(unitId);
+ if (unit == null || unit.DepartmentId != departmentId)
+ return null;
+
+ var dmKey = BuildUnitDispatchKey(unitId);
+ var desiredName = BuildUnitDispatchChannelName(unit.Name);
+
+ var existing = await _chatChannelRepository.GetByDmKeyAsync(departmentId, dmKey);
+ if (existing != null)
+ return await ApplyProvisionedNameAsync(existing, desiredName, cancellationToken);
+
+ var channel = new ChatChannel
+ {
+ ChatChannelId = Guid.NewGuid().ToString(),
+ DepartmentId = departmentId,
+ ChannelType = (int)ChatChannelType.UnitDispatch,
+ Name = desiredName,
+ CreatedOn = DateTime.UtcNow,
+ DmKey = dmKey
+ };
+
+ // The unit rides an explicit member row (like a unit DM) so its operators surface the channel
+ // through the unit-membership pass; the dispatch side stays an implicit audience.
+ var members = new List
+ {
+ NewMemberRow(channel, ChatParticipantType.Unit, null, unitId, unit.Name, null)
+ };
+
+ ChatChannel saved;
+ try
+ {
+ // Same atomic channel+members insert the DM path uses; the unique (DepartmentId, DmKey)
+ // index backstops concurrent provisioning.
+ saved = await _chatChannelRepository.CreateDirectMessageChannelAsync(channel, members, cancellationToken);
+ }
+ catch (Exception)
+ {
+ var winner = await _chatChannelRepository.GetByDmKeyAsync(departmentId, dmKey);
+ if (winner != null)
+ return winner;
+
+ throw;
+ }
+
+ if (saved == null)
+ saved = await _chatChannelRepository.GetByDmKeyAsync(departmentId, dmKey);
+
+ if (saved != null && string.Equals(saved.ChatChannelId, channel.ChatChannelId, StringComparison.OrdinalIgnoreCase))
+ {
+ // Roll the list caches so the dispatch desk sees the unit's new line without waiting out
+ // the 45s per-user list cache.
+ await _chatPermissionService.InvalidateChannelCacheAsync(saved.ChatChannelId);
+ PublishChannelEvent(saved, ChatEventKinds.ChannelProvisioned);
+ }
+
+ return saved;
+ }
+
public async Task EnsureIncidentChannelsAsync(IncidentCommand command, IEnumerable nodes, CancellationToken cancellationToken = default(CancellationToken))
{
if (command == null || command.CallId <= 0)
@@ -849,42 +953,51 @@ private async Task RebindCommandScopedChannelAsync(ChatChannel chan
// 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);
+ // Every incident-scoped channel is named "{incident name, or call name} {suffix}". This is
+ // the one place the command is in hand, so names set before the command existed (or before
+ // it was renamed by a re-establish) are refreshed here alongside the missing-channel fill.
+ var prefix = await ResolveIncidentPrefixAsync(command.CallId, command.Name);
- var leadsChannel = existing.FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.IncidentLeads);
- if (leadsChannel == null)
- await EnsureLeadsChannelAsync(command, cancellationToken);
+ var incidentChannel = existing.FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.Incident);
+ if (incidentChannel == null)
+ await EnsureIncidentChannelAsync(command.DepartmentId, command.CallId, prefix, cancellationToken);
else
- await RebindCommandScopedChannelAsync(leadsChannel, command.IncidentCommandId, cancellationToken);
+ await ApplyProvisionedNameAsync(incidentChannel, prefix, 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);
+ // 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.
+ foreach (var channelType in new[] { ChatChannelType.IncidentCommand, ChatChannelType.IncidentLeads, ChatChannelType.IncidentDispatch })
+ {
+ var channel = existing.FirstOrDefault(c => c.ChannelType == (int)channelType);
+ if (channel == null)
+ {
+ await EnsureCommandScopedChannelCoreAsync(command.DepartmentId, command.CallId, command.IncidentCommandId, channelType, prefix, cancellationToken);
+ }
+ else
+ {
+ var rebound = await RebindCommandScopedChannelAsync(channel, command.IncidentCommandId, cancellationToken);
+ await ApplyProvisionedNameAsync(rebound, BuildCommandScopedChannelName(channelType, prefix), cancellationToken);
+ }
+ }
- var provisionedNodeIds = new HashSet(
- existing.Where(c => c.ChannelType == (int)ChatChannelType.IncidentLane && !string.IsNullOrWhiteSpace(c.CommandStructureNodeId))
- .Select(c => c.CommandStructureNodeId),
- StringComparer.OrdinalIgnoreCase);
+ var lanesByNodeId = existing
+ .Where(c => c.ChannelType == (int)ChatChannelType.IncidentLane && !string.IsNullOrWhiteSpace(c.CommandStructureNodeId))
+ .GroupBy(c => c.CommandStructureNodeId, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
- var missingLanes = (nodes ?? Enumerable.Empty())
- .Where(n => n != null && !n.DeletedOn.HasValue && !provisionedNodeIds.Contains(n.CommandStructureNodeId))
+ var liveNodes = (nodes ?? Enumerable.Empty())
+ .Where(n => n != null && !n.DeletedOn.HasValue)
.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);
+ foreach (var node in liveNodes)
+ {
+ if (lanesByNodeId.TryGetValue(node.CommandStructureNodeId, out var laneChannel))
+ await ApplyProvisionedNameAsync(laneChannel, BuildLaneChannelName(prefix, node.Name), cancellationToken);
+ else
+ await EnsureLaneChannelCoreAsync(node, prefix, cancellationToken);
+ }
await _cacheProvider.SetStringAsync(markerKey, "1", IncidentBackfillCacheLength);
}
@@ -1096,5 +1209,82 @@ private static string BuildDmKey(string creatorUserId, string targetUserId, int?
return string.Join("|", parts);
}
+
+ ///
+ /// UnitDispatch channels ride the (DepartmentId, DmKey) unique index for dedup — not a DM, but the
+ /// same one-channel-per-identity constraint, and the prefix keeps the keyspaces disjoint.
+ ///
+ private static string BuildUnitDispatchKey(int unitId) => $"unitdispatch:{unitId}";
+
+ ///
+ /// The incident prefix every incident-scoped channel name starts with: the incident's own name when
+ /// command gave it one, otherwise the call's name, otherwise the call id.
+ ///
+ private async Task ResolveIncidentPrefixAsync(int callId, string incidentName)
+ {
+ if (!string.IsNullOrWhiteSpace(incidentName))
+ return incidentName.Trim();
+
+ try
+ {
+ var call = await _callsService.GetCallByIdAsync(callId);
+ if (!string.IsNullOrWhiteSpace(call?.Name))
+ return call.Name.Trim();
+ }
+ catch (Exception ex)
+ {
+ // Naming is cosmetic next to provisioning — never let a call lookup break channel creation.
+ Logging.LogException(ex);
+ }
+
+ return $"Call {callId}";
+ }
+
+ private static string BuildCommandScopedChannelName(ChatChannelType channelType, string prefix)
+ {
+ switch (channelType)
+ {
+ case ChatChannelType.IncidentCommand:
+ // The "(private)" marker is part of the contract with the apps: it is how the command
+ // channel reads as command-staff-only in a flat channel list.
+ return $"{prefix} Command (private)";
+
+ case ChatChannelType.IncidentLeads:
+ return $"{prefix} All Leads";
+
+ case ChatChannelType.IncidentDispatch:
+ return $"{prefix} Dispatch";
+
+ default:
+ return prefix;
+ }
+ }
+
+ private static string BuildLaneChannelName(string prefix, string laneName)
+ => string.IsNullOrWhiteSpace(laneName) ? prefix : $"{prefix} {laneName.Trim()}";
+
+ private static string BuildUnitDispatchChannelName(string unitName)
+ => string.IsNullOrWhiteSpace(unitName) ? "Dispatch" : $"{unitName.Trim()} Dispatch";
+
+ ///
+ /// Applies the computed provisioning name to an existing channel when it drifted — the incident got
+ /// its name after establish, a lane or unit was renamed. Targeted update (never a full-row write,
+ /// which would rewind the atomic LastMessageSeq allocator), and clients are told to re-read.
+ ///
+ private async Task ApplyProvisionedNameAsync(ChatChannel channel, string desiredName, CancellationToken cancellationToken)
+ {
+ if (channel == null || string.IsNullOrWhiteSpace(desiredName) || string.Equals(channel.Name, desiredName, StringComparison.Ordinal))
+ return channel;
+
+ channel.Name = desiredName;
+ channel.ModifiedOn = DateTime.UtcNow;
+
+ await _chatChannelRepository.UpdateChannelInfoAsync(channel.ChatChannelId, channel.Name, channel.Topic, channel.ModifiedOn.Value, cancellationToken);
+
+ await _chatPermissionService.InvalidateChannelCacheAsync(channel.ChatChannelId);
+ PublishChannelEvent(channel, ChatEventKinds.ChannelUpdated);
+
+ return channel;
+ }
}
}
diff --git a/Core/Resgrid.Services/ChatPermissionService.cs b/Core/Resgrid.Services/ChatPermissionService.cs
index 2613b936..30af4590 100644
--- a/Core/Resgrid.Services/ChatPermissionService.cs
+++ b/Core/Resgrid.Services/ChatPermissionService.cs
@@ -218,6 +218,13 @@ public async Task> ResolveChannelAudienceUserIdsAsync(ChatChannel c
AddIfSet(userIds, dispatcherId);
break;
+ case ChatChannelType.UnitDispatch:
+ // The unit's member row resolves to its active crew; the desk side is every dispatcher.
+ await AddExplicitMemberAudienceAsync(channel, userIds);
+ foreach (var dispatcherId in await _dispatchAccessService.GetDispatchUserIdsAsync(channel.DepartmentId))
+ AddIfSet(userIds, dispatcherId);
+ break;
+
default: // DirectMessage, AdHocGroup
await AddExplicitMemberAudienceAsync(channel, userIds);
break;
@@ -319,6 +326,21 @@ private async Task EvaluateAccessAsync(ChatChannel channel, string userId,
return await IsInIncidentAudienceAsync(channel, userId, activeUnitId);
+ case ChatChannelType.UnitDispatch:
+ {
+ // Same stance as IncidentDispatch: dispatch authorization, not admin standing, opens
+ // dispatch traffic.
+ if (await _dispatchAccessService.CanUseDispatchAsync(channel.DepartmentId, userId))
+ return true;
+
+ // The unit side is proven against the channel's OWN unit, never the caller-supplied
+ // activeUnitId or a leftover user member row (lazy read-pointer rows outlive access) —
+ // crewing some other unit must not open this unit's dispatch line.
+ var owningUnitId = await GetUnitDispatchChannelUnitIdAsync(channel);
+ return owningUnitId.HasValue
+ && await CanSendAsUnitAsync(userId, owningUnitId.Value, channel.DepartmentId);
+ }
+
default:
return false;
}
@@ -363,6 +385,22 @@ private async Task EvaluateModerateAsync(ChatChannel channel, string userI
}
}
+ ///
+ /// The unit a UnitDispatch channel belongs to: parsed from its "unitdispatch:{unitId}" DmKey,
+ /// falling back to the channel's unit member row.
+ ///
+ private async Task GetUnitDispatchChannelUnitIdAsync(ChatChannel channel)
+ {
+ const string keyPrefix = "unitdispatch:";
+ if (!string.IsNullOrWhiteSpace(channel.DmKey)
+ && channel.DmKey.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase)
+ && int.TryParse(channel.DmKey.Substring(keyPrefix.Length), out var unitId))
+ return unitId;
+
+ var members = await _chatChannelMemberRepository.GetByChannelIdAsync(channel.ChatChannelId);
+ return members?.FirstOrDefault(m => m.ParticipantType == (int)ChatParticipantType.Unit && !m.RemovedOn.HasValue && m.UnitId.HasValue)?.UnitId;
+ }
+
private async Task HasActiveMembershipAsync(string chatChannelId, string userId, int? activeUnitId)
{
var member = await _chatChannelMemberRepository.GetUserMemberAsync(chatChannelId, userId);
diff --git a/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs b/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs
index 4a02c2e3..316a9386 100644
--- a/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs
+++ b/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs
@@ -30,6 +30,7 @@ public class with_the_chat_channel_service : TestBase
protected Mock _departmentGroupsServiceMock;
protected Mock _unitsServiceMock;
protected Mock _userProfileServiceMock;
+ protected Mock _callsServiceMock;
protected Mock _eventAggregatorMock;
protected Mock _cacheProviderMock;
protected Mock _unitOfWorkMock;
@@ -57,6 +58,7 @@ private void BuildService()
_departmentGroupsServiceMock = new Mock();
_unitsServiceMock = new Mock();
_userProfileServiceMock = new Mock();
+ _callsServiceMock = new Mock();
_eventAggregatorMock = new Mock();
_cacheProviderMock = new Mock();
_unitOfWorkMock = new Mock();
@@ -93,6 +95,7 @@ private void BuildService()
_departmentGroupsServiceMock.Object,
_unitsServiceMock.Object,
_userProfileServiceMock.Object,
+ _callsServiceMock.Object,
_eventAggregatorMock.Object,
_cacheProviderMock.Object,
_unitOfWorkMock.Object);
@@ -667,6 +670,134 @@ public async Task without_an_active_unit_no_unit_membership_lookup_happens()
_chatChannelMemberRepositoryMock.Verify(x => x.GetActiveByUnitIdAsync(It.IsAny(), It.IsAny()), Times.Never);
}
+
+ [Test]
+ public async Task an_active_unit_should_get_its_dispatch_line_provisioned_into_the_list()
+ {
+ SetupDepartmentChannel();
+ _chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "user-a")).ReturnsAsync(false);
+ _chatPermissionServiceMock.Setup(x => x.CanSendAsUnitAsync("user-a", 7, 1)).ReturnsAsync(true);
+ _unitsServiceMock.Setup(x => x.GetUnitByIdAsync(7)).ReturnsAsync(new Unit { UnitId = 7, DepartmentId = 1, Name = "Engine 6" });
+ _chatChannelRepositoryMock.Setup(x => x.GetByDmKeyAsync(1, "unitdispatch:7")).ReturnsAsync((ChatChannel)null);
+
+ var result = await _chatChannelService.GetChannelsForUserAsync(1, "user-a", 7);
+
+ result.Should().Contain(c => c.ChannelType == (int)ChatChannelType.UnitDispatch && c.Name == "Engine 6 Dispatch");
+ }
+
+ [Test]
+ public async Task a_unit_dispatch_provisioning_failure_should_not_abort_the_channel_list()
+ {
+ SetupDepartmentChannel();
+ _chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "user-a")).ReturnsAsync(false);
+ _chatPermissionServiceMock.Setup(x => x.CanSendAsUnitAsync("user-a", 7, 1)).ReturnsAsync(true);
+ _unitsServiceMock.Setup(x => x.GetUnitByIdAsync(7)).ThrowsAsync(new InvalidOperationException("units down"));
+
+ var result = await _chatChannelService.GetChannelsForUserAsync(1, "user-a", 7);
+
+ result.Should().Contain(c => c.ChannelType == (int)ChatChannelType.DepartmentDefault);
+ }
+
+ [Test]
+ public async Task incident_leads_and_dispatch_channels_should_be_listed_for_users_with_access()
+ {
+ SetupDepartmentChannel();
+ _chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "user-a")).ReturnsAsync(false);
+
+ var leads = new ChatChannel { ChatChannelId = "leads-1", DepartmentId = 1, ChannelType = (int)ChatChannelType.IncidentLeads, CallId = 42, Name = "Barn Fire All Leads" };
+ var dispatch = new ChatChannel { ChatChannelId = "dispatch-1", DepartmentId = 1, ChannelType = (int)ChatChannelType.IncidentDispatch, CallId = 42, Name = "Barn Fire Dispatch" };
+ var unitDispatch = new ChatChannel { ChatChannelId = "unit-dispatch-7", DepartmentId = 1, ChannelType = (int)ChatChannelType.UnitDispatch, DmKey = "unitdispatch:7", Name = "Engine 6 Dispatch" };
+ _chatChannelRepositoryMock.Setup(x => x.GetAllByDepartmentIdAsync(1, false)).ReturnsAsync(new List { leads, dispatch, unitDispatch });
+
+ _chatPermissionServiceMock.Setup(x => x.CanAccessChannelAsync(leads, "user-a", null)).ReturnsAsync(true);
+ _chatPermissionServiceMock.Setup(x => x.CanAccessChannelAsync(dispatch, "user-a", null)).ReturnsAsync(true);
+ _chatPermissionServiceMock.Setup(x => x.CanAccessChannelAsync(unitDispatch, "user-a", null)).ReturnsAsync(true);
+
+ var result = await _chatChannelService.GetChannelsForUserAsync(1, "user-a", null);
+
+ result.Should().Contain(c => c.ChatChannelId == "leads-1");
+ result.Should().Contain(c => c.ChatChannelId == "dispatch-1");
+ result.Should().Contain(c => c.ChatChannelId == "unit-dispatch-7");
+ }
+
+ [Test]
+ public async Task incident_leads_and_dispatch_channels_should_stay_hidden_without_access()
+ {
+ SetupDepartmentChannel();
+ _chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "user-a")).ReturnsAsync(false);
+
+ var leads = new ChatChannel { ChatChannelId = "leads-1", DepartmentId = 1, ChannelType = (int)ChatChannelType.IncidentLeads, CallId = 42 };
+ var unitDispatch = new ChatChannel { ChatChannelId = "unit-dispatch-7", DepartmentId = 1, ChannelType = (int)ChatChannelType.UnitDispatch, DmKey = "unitdispatch:7" };
+ _chatChannelRepositoryMock.Setup(x => x.GetAllByDepartmentIdAsync(1, false)).ReturnsAsync(new List { leads, unitDispatch });
+
+ var result = await _chatChannelService.GetChannelsForUserAsync(1, "user-a", null);
+
+ result.Should().NotContain(c => c.ChatChannelId == "leads-1");
+ result.Should().NotContain(c => c.ChatChannelId == "unit-dispatch-7");
+ }
+ }
+
+ [TestFixture]
+ public class when_ensuring_unit_dispatch_channels : with_the_chat_channel_service
+ {
+ [Test]
+ public async Task a_missing_channel_should_be_created_with_the_unit_as_the_member()
+ {
+ _unitsServiceMock.Setup(x => x.GetUnitByIdAsync(7)).ReturnsAsync(new Unit { UnitId = 7, DepartmentId = 1, Name = "Engine 6" });
+ _chatChannelRepositoryMock.Setup(x => x.GetByDmKeyAsync(1, "unitdispatch:7")).ReturnsAsync((ChatChannel)null);
+
+ List capturedMembers = null;
+ _chatChannelRepositoryMock
+ .Setup(x => x.CreateDirectMessageChannelAsync(It.IsAny(), It.IsAny>(), It.IsAny()))
+ .Callback((ChatChannel c, IEnumerable m, CancellationToken t) => capturedMembers = m.ToList())
+ .ReturnsAsync((ChatChannel c, IEnumerable m, CancellationToken t) => c);
+
+ var result = await _chatChannelService.EnsureUnitDispatchChannelAsync(1, 7);
+
+ result.Should().NotBeNull();
+ result.ChannelType.Should().Be((int)ChatChannelType.UnitDispatch);
+ result.Name.Should().Be("Engine 6 Dispatch");
+ result.DmKey.Should().Be("unitdispatch:7");
+ capturedMembers.Should().ContainSingle(m => m.ParticipantType == (int)ChatParticipantType.Unit && m.UnitId == 7);
+ }
+
+ [Test]
+ public async Task an_existing_channel_should_be_returned_without_creating_another()
+ {
+ var existing = new ChatChannel { ChatChannelId = "ud-7", DepartmentId = 1, ChannelType = (int)ChatChannelType.UnitDispatch, DmKey = "unitdispatch:7", Name = "Engine 6 Dispatch" };
+ _unitsServiceMock.Setup(x => x.GetUnitByIdAsync(7)).ReturnsAsync(new Unit { UnitId = 7, DepartmentId = 1, Name = "Engine 6" });
+ _chatChannelRepositoryMock.Setup(x => x.GetByDmKeyAsync(1, "unitdispatch:7")).ReturnsAsync(existing);
+
+ var result = await _chatChannelService.EnsureUnitDispatchChannelAsync(1, 7);
+
+ result.Should().BeSameAs(existing);
+ _chatChannelRepositoryMock.Verify(x => x.CreateDirectMessageChannelAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never);
+ _chatChannelRepositoryMock.Verify(x => x.UpdateChannelInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public async Task a_renamed_unit_should_refresh_the_channel_name()
+ {
+ var existing = new ChatChannel { ChatChannelId = "ud-7", DepartmentId = 1, ChannelType = (int)ChatChannelType.UnitDispatch, DmKey = "unitdispatch:7", Name = "Engine 6 Dispatch" };
+ _unitsServiceMock.Setup(x => x.GetUnitByIdAsync(7)).ReturnsAsync(new Unit { UnitId = 7, DepartmentId = 1, Name = "Rescue 1" });
+ _chatChannelRepositoryMock.Setup(x => x.GetByDmKeyAsync(1, "unitdispatch:7")).ReturnsAsync(existing);
+
+ var result = await _chatChannelService.EnsureUnitDispatchChannelAsync(1, 7);
+
+ result.Name.Should().Be("Rescue 1 Dispatch");
+ _chatChannelRepositoryMock.Verify(x => x.UpdateChannelInfoAsync("ud-7", "Rescue 1 Dispatch", It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Test]
+ public async Task a_unit_from_another_department_should_not_get_a_channel()
+ {
+ _unitsServiceMock.Setup(x => x.GetUnitByIdAsync(7)).ReturnsAsync(new Unit { UnitId = 7, DepartmentId = 2, Name = "Engine 6" });
+
+ var result = await _chatChannelService.EnsureUnitDispatchChannelAsync(1, 7);
+
+ result.Should().BeNull();
+ _chatChannelRepositoryMock.Verify(x => x.CreateDirectMessageChannelAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never);
+ }
}
[TestFixture]
diff --git a/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs b/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs
index 44336b19..a4724f9e 100644
--- a/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs
+++ b/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs
@@ -29,6 +29,7 @@ public class ChatIncidentBackfillTests
private Mock _channelRepository;
private Mock _cacheProvider;
private Mock _permissionService;
+ private Mock _callsService;
private List _inserted;
[SetUp]
@@ -37,6 +38,7 @@ public void Setup()
_channelRepository = new Mock();
_cacheProvider = new Mock();
_permissionService = new Mock();
+ _callsService = new Mock();
_inserted = new List();
// No marker set: the backfill runs.
@@ -63,6 +65,7 @@ private ChatChannelService BuildService()
Mock.Of(),
Mock.Of(),
Mock.Of(),
+ _callsService.Object,
Mock.Of(),
_cacheProvider.Object,
Mock.Of());
@@ -210,25 +213,80 @@ public async Task channels_reused_from_a_prior_command_are_rebound_and_unarchive
_channelRepository.Verify(x => x.RebindToIncidentCommandAsync("d", CommandId, It.IsAny(), It.IsAny()), Times.Once);
// Archived state gates posting through cached permission verdicts — stale entries must die now.
- _permissionService.Verify(x => x.InvalidateChannelCacheAsync("b"), Times.Once);
- _permissionService.Verify(x => x.InvalidateChannelCacheAsync("c"), Times.Once);
- _permissionService.Verify(x => x.InvalidateChannelCacheAsync("d"), Times.Once);
+ // (AtLeastOnce: the rebind invalidates, and the naming refresh may invalidate again.)
+ _permissionService.Verify(x => x.InvalidateChannelCacheAsync("b"), Times.AtLeastOnce);
+ _permissionService.Verify(x => x.InvalidateChannelCacheAsync("c"), Times.AtLeastOnce);
+ _permissionService.Verify(x => x.InvalidateChannelCacheAsync("d"), Times.AtLeastOnce);
}
[Test]
public async Task channels_already_bound_to_the_active_command_are_not_rewritten()
{
- // The steady state — same command, nothing archived — must stay a pure read.
+ // The steady state — same command, nothing archived, names current — must stay a pure read.
GivenExistingChannels(
- new ChatChannel { ChatChannelId = "a", CallId = CallId, ChannelType = (int)ChatChannelType.Incident },
- new ChatChannel { ChatChannelId = "b", CallId = CallId, ChannelType = (int)ChatChannelType.IncidentCommand, IncidentCommandId = CommandId },
- new ChatChannel { ChatChannelId = "c", CallId = CallId, ChannelType = (int)ChatChannelType.IncidentLeads, IncidentCommandId = CommandId },
- new ChatChannel { ChatChannelId = "d", CallId = CallId, ChannelType = (int)ChatChannelType.IncidentDispatch, IncidentCommandId = CommandId });
+ new ChatChannel { ChatChannelId = "a", CallId = CallId, ChannelType = (int)ChatChannelType.Incident, Name = $"Call {CallId}" },
+ new ChatChannel { ChatChannelId = "b", CallId = CallId, ChannelType = (int)ChatChannelType.IncidentCommand, IncidentCommandId = CommandId, Name = $"Call {CallId} Command (private)" },
+ new ChatChannel { ChatChannelId = "c", CallId = CallId, ChannelType = (int)ChatChannelType.IncidentLeads, IncidentCommandId = CommandId, Name = $"Call {CallId} All Leads" },
+ new ChatChannel { ChatChannelId = "d", CallId = CallId, ChannelType = (int)ChatChannelType.IncidentDispatch, IncidentCommandId = CommandId, Name = $"Call {CallId} Dispatch" });
await BuildService().EnsureIncidentChannelsAsync(BuildCommand(), new CommandStructureNode[0]);
_inserted.Should().BeEmpty();
_channelRepository.Verify(x => x.RebindToIncidentCommandAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ _channelRepository.Verify(x => x.UpdateChannelInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public async Task channels_are_named_after_the_incident_when_the_command_has_a_name()
+ {
+ GivenExistingChannels();
+
+ var command = BuildCommand();
+ command.Name = "Barn Fire";
+
+ await BuildService().EnsureIncidentChannelsAsync(command, new[] { BuildNode("Staging") });
+
+ _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.Incident && c.Name == "Barn Fire");
+ _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentCommand && c.Name == "Barn Fire Command (private)");
+ _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentLeads && c.Name == "Barn Fire All Leads");
+ _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentDispatch && c.Name == "Barn Fire Dispatch");
+ _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentLane && c.Name == "Barn Fire Staging");
+ }
+
+ [Test]
+ public async Task channels_fall_back_to_the_call_name_when_the_command_is_unnamed()
+ {
+ GivenExistingChannels();
+ _callsService.Setup(x => x.GetCallByIdAsync(CallId, It.IsAny())).ReturnsAsync(new Call { CallId = CallId, DepartmentId = 1, Name = "Structure Fire" });
+
+ await BuildService().EnsureIncidentChannelsAsync(BuildCommand(), new[] { BuildNode("Staging") });
+
+ _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.Incident && c.Name == "Structure Fire");
+ _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentCommand && c.Name == "Structure Fire Command (private)");
+ _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentLane && c.Name == "Structure Fire Staging");
+ }
+
+ [Test]
+ public async Task stale_channel_names_are_refreshed_when_the_incident_is_named()
+ {
+ // Channels provisioned at call time (or by an old backfill) carry pre-incident names; naming
+ // the command must flow into them without touching anything else.
+ GivenExistingChannels(
+ new ChatChannel { ChatChannelId = "a", CallId = CallId, ChannelType = (int)ChatChannelType.Incident, Name = "Call 42" },
+ new ChatChannel { ChatChannelId = "b", CallId = CallId, ChannelType = (int)ChatChannelType.IncidentCommand, IncidentCommandId = CommandId, Name = "Command" },
+ new ChatChannel { ChatChannelId = "c", CallId = CallId, ChannelType = (int)ChatChannelType.IncidentLane, CommandStructureNodeId = "node-1", Name = "node-1" });
+
+ var command = BuildCommand();
+ command.Name = "Barn Fire";
+
+ await BuildService().EnsureIncidentChannelsAsync(command, new[] { BuildNode("node-1") });
+
+ _channelRepository.Verify(x => x.UpdateChannelInfoAsync("a", "Barn Fire", It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
+ _channelRepository.Verify(x => x.UpdateChannelInfoAsync("b", "Barn Fire Command (private)", It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
+ _channelRepository.Verify(x => x.UpdateChannelInfoAsync("c", "Barn Fire node-1", It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
+
+ // A rename changes what connected clients display — cached verdicts and lists must roll.
+ _permissionService.Verify(x => x.InvalidateChannelCacheAsync("a"), Times.Once);
}
}
}
diff --git a/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs b/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs
index c8ac8fda..5e496b51 100644
--- a/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs
+++ b/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs
@@ -1227,5 +1227,156 @@ public async Task the_audience_should_be_the_incident_plus_every_dispatcher()
audience.Should().Contain(TestData.Users.TestUser3Id);
}
}
+
+ [TestFixture]
+ public class when_evaluating_the_unit_dispatch_channel : with_the_chat_permission_service
+ {
+ private ChatChannel BuildUnitDispatchChannel()
+ {
+ var channel = CreateChannel(ChatChannelType.UnitDispatch);
+ channel.Name = "Engine 6 Dispatch";
+ channel.DmKey = "unitdispatch:7";
+ return channel;
+ }
+
+ private void GivenUnitSevenCrewedBy(string userId)
+ {
+ _unitsServiceMock.Setup(x => x.GetUnitByIdAsync(7)).ReturnsAsync(new Unit { UnitId = 7, DepartmentId = 1, Name = "Engine 6" });
+ _unitsServiceMock.Setup(x => x.GetActiveRolesForUnitAsync(7)).ReturnsAsync(new List
+ {
+ new UnitActiveRole { UnitId = 7, UserId = userId }
+ });
+ }
+
+ private void GivenTheUnitMemberRow(ChatChannel channel)
+ {
+ _chatChannelMemberRepositoryMock.Setup(x => x.GetUnitMemberAsync(channel.ChatChannelId, 7)).ReturnsAsync(new ChatChannelMember
+ {
+ ChatChannelMemberId = Guid.NewGuid().ToString(),
+ ChatChannelId = channel.ChatChannelId,
+ DepartmentId = channel.DepartmentId,
+ ParticipantType = (int)ChatParticipantType.Unit,
+ UnitId = 7,
+ JoinedOn = DateTime.UtcNow
+ });
+ }
+
+ [Test]
+ public async Task an_authorized_dispatcher_should_have_access()
+ {
+ _dispatchAccessServiceMock.Setup(x => x.CanUseDispatchAsync(1, TestData.Users.TestUser1Id)).ReturnsAsync(true);
+
+ var result = await _chatPermissionService.CanAccessChannelAsync(BuildUnitDispatchChannel(), TestData.Users.TestUser1Id, null);
+
+ result.Should().BeTrue();
+ }
+
+ [Test]
+ public async Task the_unit_crew_should_have_access()
+ {
+ var channel = BuildUnitDispatchChannel();
+ GivenUnitSevenCrewedBy(TestData.Users.TestUser1Id);
+
+ var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, 7);
+
+ result.Should().BeTrue();
+ }
+
+ [Test]
+ public async Task a_caller_who_does_not_crew_the_claimed_unit_should_be_refused()
+ {
+ var channel = BuildUnitDispatchChannel();
+ GivenUnitSevenCrewedBy(TestData.Users.TestUser2Id);
+ GivenTheUnitMemberRow(channel);
+
+ var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, 7);
+
+ result.Should().BeFalse();
+ }
+
+ [Test]
+ public async Task a_stale_user_member_row_should_not_grant_access()
+ {
+ // An ex-dispatcher's lazy read-pointer row outlives their dispatch authorization. Access
+ // is proven against the channel's own unit, so crewing some other unit plus the stale row
+ // must not reopen this unit's dispatch line.
+ var channel = BuildUnitDispatchChannel();
+ GivenUnitSevenCrewedBy(TestData.Users.TestUser2Id);
+ _chatChannelMemberRepositoryMock.Setup(x => x.GetUserMemberAsync(channel.ChatChannelId, TestData.Users.TestUser1Id))
+ .ReturnsAsync(CreateUserMember(channel, TestData.Users.TestUser1Id));
+ _unitsServiceMock.Setup(x => x.GetUnitByIdAsync(9)).ReturnsAsync(new Unit { UnitId = 9, DepartmentId = 1, Name = "Engine 9" });
+ _unitsServiceMock.Setup(x => x.GetActiveRolesForUnitAsync(9)).ReturnsAsync(new List
+ {
+ new UnitActiveRole { UnitId = 9, UserId = TestData.Users.TestUser1Id }
+ });
+
+ var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, 9);
+
+ result.Should().BeFalse();
+ }
+
+ [Test]
+ public async Task the_owning_unit_should_fall_back_to_the_member_row_when_the_dm_key_is_missing()
+ {
+ var channel = BuildUnitDispatchChannel();
+ channel.DmKey = null;
+ GivenUnitSevenCrewedBy(TestData.Users.TestUser1Id);
+ _chatChannelMemberRepositoryMock.Setup(x => x.GetByChannelIdAsync(channel.ChatChannelId)).ReturnsAsync(new List
+ {
+ new ChatChannelMember
+ {
+ ChatChannelMemberId = Guid.NewGuid().ToString(),
+ ChatChannelId = channel.ChatChannelId,
+ DepartmentId = channel.DepartmentId,
+ ParticipantType = (int)ChatParticipantType.Unit,
+ UnitId = 7,
+ JoinedOn = DateTime.UtcNow
+ }
+ });
+
+ var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, 7);
+
+ result.Should().BeTrue();
+ }
+
+ [Test]
+ public async Task a_department_admin_without_dispatch_authorization_should_be_refused()
+ {
+ // Same stance as the incident dispatch channel: admin standing alone must not open
+ // dispatch traffic.
+ _authorizationServiceMock.Setup(x => x.CanUserModifyDepartmentAsync(TestData.Users.TestUser3Id, 1)).ReturnsAsync(true);
+
+ var result = await _chatPermissionService.CanAccessChannelAsync(BuildUnitDispatchChannel(), TestData.Users.TestUser3Id, null);
+
+ result.Should().BeFalse();
+ }
+
+ [Test]
+ public async Task the_audience_should_be_the_unit_crew_plus_every_dispatcher()
+ {
+ var channel = BuildUnitDispatchChannel();
+ GivenUnitSevenCrewedBy(TestData.Users.TestUser1Id);
+ _chatChannelMemberRepositoryMock.Setup(x => x.GetByChannelIdAsync(channel.ChatChannelId)).ReturnsAsync(new List
+ {
+ new ChatChannelMember
+ {
+ ChatChannelMemberId = Guid.NewGuid().ToString(),
+ ChatChannelId = channel.ChatChannelId,
+ DepartmentId = channel.DepartmentId,
+ ParticipantType = (int)ChatParticipantType.Unit,
+ UnitId = 7,
+ JoinedOn = DateTime.UtcNow
+ }
+ });
+ _dispatchAccessServiceMock.Setup(x => x.GetDispatchUserIdsAsync(1))
+ .ReturnsAsync(new List { TestData.Users.TestUser2Id, TestData.Users.TestUser3Id });
+
+ var audience = await _chatPermissionService.ResolveChannelAudienceUserIdsAsync(channel);
+
+ audience.Should().Contain(TestData.Users.TestUser1Id);
+ audience.Should().Contain(TestData.Users.TestUser2Id);
+ audience.Should().Contain(TestData.Users.TestUser3Id);
+ }
+ }
}
}
diff --git a/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs
index 9bbd6c9d..421ea5a8 100644
--- a/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs
+++ b/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs
@@ -349,7 +349,7 @@ public class ChatChannelResultData
public string ChatChannelId { get; set; }
///
- /// Channel type (0 = DirectMessage, 1 = AdHocGroup, 2 = DepartmentDefault, 3 = GroupDefault, 4 = CustomLocked, 5 = Incident, 6 = IncidentLane, 7 = IncidentCommand, 8 = Chatbot)
+ /// Channel type (0 = DirectMessage, 1 = AdHocGroup, 2 = DepartmentDefault, 3 = GroupDefault, 4 = CustomLocked, 5 = Incident, 6 = IncidentLane, 7 = IncidentCommand, 8 = Chatbot, 9 = IncidentLeads, 10 = IncidentDispatch, 11 = UnitDispatch)
///
public int ChannelType { get; set; }
diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
index ba98333d..7da3188a 100644
--- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
+++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
@@ -4552,6 +4552,52 @@
Is the user a group admin
+
+
+ UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a
+ function that is setting status for the current user.
+
+
+
+
+ The state/staffing level of the user to set for the user.
+
+
+
+
+ Note for the staffing level
+
+
+
+
+ The result object for a state/staffing level request.
+
+
+
+
+ The UserId GUID/UUID for the user state/staffing level being return
+
+
+
+
+ The full name of the user for the state/staffing level being returned
+
+
+
+
+ The current staffing level (state) type for the user
+
+
+
+
+ The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone.
+
+
+
+
+ Staffing note for the User's staffing
+
+
Input data to add a staffing schedule in the Resgrid system
@@ -4657,52 +4703,6 @@
Note for this staffing schedule
-
-
- UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a
- function that is setting status for the current user.
-
-
-
-
- The state/staffing level of the user to set for the user.
-
-
-
-
- Note for the staffing level
-
-
-
-
- The result object for a state/staffing level request.
-
-
-
-
- The UserId GUID/UUID for the user state/staffing level being return
-
-
-
-
- The full name of the user for the state/staffing level being returned
-
-
-
-
- The current staffing level (state) type for the user
-
-
-
-
- The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone.
-
-
-
-
- Staffing note for the User's staffing
-
-
A resrouce in the system this could be a user or unit
@@ -7324,7 +7324,7 @@
- Channel type (0 = DirectMessage, 1 = AdHocGroup, 2 = DepartmentDefault, 3 = GroupDefault, 4 = CustomLocked, 5 = Incident, 6 = IncidentLane, 7 = IncidentCommand, 8 = Chatbot)
+ Channel type (0 = DirectMessage, 1 = AdHocGroup, 2 = DepartmentDefault, 3 = GroupDefault, 4 = CustomLocked, 5 = Incident, 6 = IncidentLane, 7 = IncidentCommand, 8 = Chatbot, 9 = IncidentLeads, 10 = IncidentDispatch, 11 = UnitDispatch)
@@ -10207,209 +10207,379 @@
Identifier of the new npte
-
+
- A GPS location for a point in time of a specificed person
+ The result of getting all personnel filters for the system
-
+
- PersonId of the person that the location is for
+ The Id value of the filter
-
+
- The timestamp of the location in UTC
+ The type of the filter
-
+
- GPS Latitude of the Person
+ The filters name
-
+
- GPS Longitude of the Person
+ Result containing all the data required to populate the New Call form
-
+
- GPS Latitude\Longitude Accuracy of the Person
+ Response Data
-
+
- GPS Altitude of the Person
+ Result that contains all the options available to filter personnel against compatible Resgrid APIs
-
+
- GPS Altitude Accuracy of the Person
+ Response Data
-
+
- GPS Speed of the Person
+ Result containing all the data required to populate the New Call form
-
+
- GPS Heading of the Person
+ Response Data
-
+
- A unit location in the Resgrid system
+ Information about a User
-
+
- Response Data
+ The UserId GUID/UUID for the user
-
+
- The information about a specific unit's location
+ DepartmentId of the deparment the user belongs to
-
+
- Id of the Person
+ Department specificed ID number for this user
-
+
- The Timestamp for the location in UTC
+ The Users First Name
-
+
- GPS Latitude of the Person
+ The Users Last Name
-
+
- GPS Longitude of the Person
+ The Users Email Address
-
+
- GPS Latitude\Longitude Accuracy of the Person
+ The Users Mobile Telephone Number
-
+
- GPS Altitude of the Person
+ GroupId the user is assigned to (0 for no group)
-
+
- GPS Altitude Accuracy of the Person
+ Name of the group the user is assigned to
-
+
- GPS Speed of the Person
+ Enumeration/List of roles the user currently holds
-
+
- GPS Heading of the Person
+ The current action/status type for the user
-
+
- The result of getting the current staffing for a user
+ The current action/status string for the user
-
+
- Response Data
+ The current action/status color hex string for the user
-
+
- Information about a User staffing
+ The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone.
-
+
- The UserId GUID/UUID for the user status being return
+ The current action/status destination id for the user
-
+
- DepartmentId of the deparment the user belongs to
+ The current action/status destination name for the user
-
+
- The current staffing type for the user
+ The current staffing level (state) type for the user
-
+
- The timestamp of the last staffing. This is converted UTC version of the timestamp.
+ The current staffing level (state) string for the user
-
+
- The timestamp of the last staffing. This is converted UTC to the departments, or users, TimeZone.
+ The current staffing level (state) color hex string for the user
-
+
- Note for this staffing
+ The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone.
-
+
- Saves (sets) and Personnel Staffing in the system, for a single user
+ Users last known location
-
+
- UnitId of the apparatus that the state is being set for
+ Sorting weight for the user
-
+
- The UnitStateType of the Unit
+ User Defined Field values for this personnel record
-
+
- The timestamp of the status event in UTC
+ A GPS location for a point in time of a specificed person
-
+
- The timestamp of the status event in the local time of the device
+ PersonId of the person that the location is for
-
+
- User provided note for this event
+ The timestamp of the location in UTC
-
+
- The event id used for queuing on mobile applications
+ GPS Latitude of the Person
-
+
- Depicts a result after saving a person status
+ GPS Longitude of the Person
-
+
- Response Data
+ GPS Latitude\Longitude Accuracy of the Person
-
+
- Saves (sets) and Personnel Status in the system, for a single user
+ GPS Altitude of the Person
+
+
+
+
+ GPS Altitude Accuracy of the Person
+
+
+
+
+ GPS Speed of the Person
+
+
+
+
+ GPS Heading of the Person
+
+
+
+
+ A unit location in the Resgrid system
+
+
+
+
+ Response Data
+
+
+
+
+ The information about a specific unit's location
+
+
+
+
+ Id of the Person
+
+
+
+
+ The Timestamp for the location in UTC
+
+
+
+
+ GPS Latitude of the Person
+
+
+
+
+ GPS Longitude of the Person
+
+
+
+
+ GPS Latitude\Longitude Accuracy of the Person
+
+
+
+
+ GPS Altitude of the Person
+
+
+
+
+ GPS Altitude Accuracy of the Person
+
+
+
+
+ GPS Speed of the Person
+
+
+
+
+ GPS Heading of the Person
+
+
+
+
+ The result of getting the current staffing for a user
+
+
+
+
+ Response Data
+
+
+
+
+ Information about a User staffing
+
+
+
+
+ The UserId GUID/UUID for the user status being return
+
+
+
+
+ DepartmentId of the deparment the user belongs to
+
+
+
+
+ The current staffing type for the user
+
+
+
+
+ The timestamp of the last staffing. This is converted UTC version of the timestamp.
+
+
+
+
+ The timestamp of the last staffing. This is converted UTC to the departments, or users, TimeZone.
+
+
+
+
+ Note for this staffing
+
+
+
+
+ Saves (sets) and Personnel Staffing in the system, for a single user
+
+
+
+
+ UnitId of the apparatus that the state is being set for
+
+
+
+
+ The UnitStateType of the Unit
+
+
+
+
+ The timestamp of the status event in UTC
+
+
+
+
+ The timestamp of the status event in the local time of the device
+
+
+
+
+ User provided note for this event
+
+
+
+
+ The event id used for queuing on mobile applications
+
+
+
+
+ Depicts a result after saving a person status
+
+
+
+
+ Response Data
+
+
+
+
+ Saves (sets) and Personnel Status in the system, for a single user
@@ -10710,279 +10880,109 @@
Response Data
-
+
- The result of getting all personnel filters for the system
+ Result containing all the data required to populate the New Call form
-
+
- The Id value of the filter
+ Response Data
-
+
- The type of the filter
+ Details of a protocol
-
+
- The filters name
+ Protocol id
-
+
- Result containing all the data required to populate the New Call form
+ Department id
-
+
- Response Data
+ Name of the Protocol
-
+
- Result that contains all the options available to filter personnel against compatible Resgrid APIs
+ Protocol code
-
+
- Response Data
+ This this protocol disabled
-
+
- Result containing all the data required to populate the New Call form
+ Protocol description
-
+
- Response Data
+ Text of the protocol
-
+
- Information about a User
+ UTC date and time when the Protocol was created
-
+
- The UserId GUID/UUID for the user
+ UserId of the user who created the protocol
-
+
- DepartmentId of the deparment the user belongs to
+ UTC timestamp of when the Protocol was updated
-
+
- Department specificed ID number for this user
+ Minimum triggering Weight of the Protocol
-
+
- The Users First Name
+ UserId that last updated the Protocol
-
+
- The Users Last Name
+ Triggers used to activate this Protocol
-
+
- The Users Email Address
+ Attachments for this Protocol
-
+
- The Users Mobile Telephone Number
+ Questions used to determine if this Protocol needs to be used or not
-
+
- GroupId the user is assigned to (0 for no group)
+ State type
-
+
- Name of the group the user is assigned to
+ Result containing all the data required to populate the New Call form
-
+
- Enumeration/List of roles the user currently holds
-
-
-
-
- The current action/status type for the user
-
-
-
-
- The current action/status string for the user
-
-
-
-
- The current action/status color hex string for the user
-
-
-
-
- The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone.
-
-
-
-
- The current action/status destination id for the user
-
-
-
-
- The current action/status destination name for the user
-
-
-
-
- The current staffing level (state) type for the user
-
-
-
-
- The current staffing level (state) string for the user
-
-
-
-
- The current staffing level (state) color hex string for the user
-
-
-
-
- The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone.
-
-
-
-
- Users last known location
-
-
-
-
- Sorting weight for the user
-
-
-
-
- User Defined Field values for this personnel record
-
-
-
-
- Result containing all the data required to populate the New Call form
-
-
-
-
- Response Data
-
-
-
-
- Details of a protocol
-
-
-
-
- Protocol id
-
-
-
-
- Department id
-
-
-
-
- Name of the Protocol
-
-
-
-
- Protocol code
-
-
-
-
- This this protocol disabled
-
-
-
-
- Protocol description
-
-
-
-
- Text of the protocol
-
-
-
-
- UTC date and time when the Protocol was created
-
-
-
-
- UserId of the user who created the protocol
-
-
-
-
- UTC timestamp of when the Protocol was updated
-
-
-
-
- Minimum triggering Weight of the Protocol
-
-
-
-
- UserId that last updated the Protocol
-
-
-
-
- Triggers used to activate this Protocol
-
-
-
-
- Attachments for this Protocol
-
-
-
-
- Questions used to determine if this Protocol needs to be used or not
-
-
-
-
- State type
-
-
-
-
- Result containing all the data required to populate the New Call form
-
-
-
-
- Response Data
+ Response Data
@@ -12263,545 +12263,545 @@
Default constructor
-
+
- Depicts a result after saving a unit status
+ Result that contains all the options available to filter units against compatible Resgrid APIs
-
+
Response Data
-
+
- Object inputs for setting a users Status/Action. If this object is used in an operation that sets
- a status for the current user the UserId value in this object will be ignored.
+ A unit in the Resgrid system
-
+
- UnitId of the apparatus that the state is being set for
+ Response Data
-
+
- The UnitStateType of the Unit
+ The information about a specific unit
-
+
- The Call/Station the unit is responding to
+ Id of the Unit
-
+
- Destination type for RespondingTo (Station = 1, Call = 2, POI = 3).
+ The Id of the department the unit is under
-
+
- The timestamp of the status event in UTC
+ Name of the Unit
-
+
- The timestamp of the status event in the local time of the device
+ Department assigned type for the unit
-
+
- User provided note for this event
+ Department assigned type id for the unit
-
+
- GPS Latitude of the Unit
+ Custom Statuses Set Id
-
+
- GPS Longitude of the Unit
+ Station Id of the station housing the unit (0 means no station)
-
+
- GPS Latitude\Longitude Accuracy of the Unit
+ Name of the station the unit is under
-
+
- GPS Altitude of the Unit
+ Vehicle Identification Number for the unit
-
+
- GPS Altitude Accuracy of the Unit
+ Plate Number for the Unit
-
+
- GPS Speed of the Unit
+ Is the unit 4-Wheel drive
-
+
- GPS Heading of the Unit
+ Does the unit require a special permit to drive
-
+
- The event id used for queuing on mobile applications
+ Id number of the units current destionation (0 means no destination)
-
+
- The accountability roles filed for this event
+ The current status/state of the Unit
-
+
- Role filled by a User on a Unit for an event
+ The Timestamp of the status
-
+
- Id of the locally stored event
+ The units current Latitude
-
+
- Local Event Id
+ The units current Longitude
-
+
- UserId of the user filling the role
+ Current user provide status note
-
+
- RoleId of the role being filled
+ User Defined Field values for this unit
-
+
- The name of the Role
+ Unit role information for roles on a unit
-
+
- Depicts a unit status in the Resgrid system.
+ Unit Role Id
-
+
- Response Data
+ User Id of the user in the role (could be null)
-
+
- Depicts a unit's status
+ Name of the Role
-
+
- Unit Id
+ Name of the user in the role (could be null)
-
+
- Units Name
+ Multiple Unit infos Result
-
+
- The Type of the Unit
+ Response Data
-
+
- Units current Status (State)
+ Default constructor
-
+
- CSS for status (for display)
+ The information about a specific unit
-
+
- CSS Style for status (for display)
+ Id of the Unit
-
+
- Timestamp of this Unit State
+ The Id of the department the unit is under
-
+
- Timestamp in Utc of this Unit State
+ Name of the Unit
-
+
- Destination Id (Station or Call)
+ Department assigned type for the unit
-
+
- Destination type (Station, Call, or POI).
+ Department assigned type id for the unit
-
+
- Name of the Desination (Call or Station)
+ Custom Statuses Set Id
-
+
- Destination address.
+ Station Id of the station housing the unit (0 means no station)
-
+
- Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not
- suitable for programmatic branching; use as the
- machine-readable discriminator instead.
+ Name of the station the unit is under
-
+
- Note for the State
+ Vehicle Identification Number for the unit
-
+
- Latitude
+ Plate Number for the Unit
-
+
- Longitude
+ Is the unit 4-Wheel drive
-
+
- Name of the Group the Unit is in
+ Does the unit require a special permit to drive
-
+
- Id of the Group the Unit is in
+ Id number of the units current destination (0 means no destination)
-
+
- Unit statuses (states)
+ Name of the units current destination (0 means no destination)
-
+
- Response Data
+ The current status/state of the Unit
-
+
- Default constructor
+ The current status/state of the Unit as a name
-
+
- Result that contains all the options available to filter units against compatible Resgrid APIs
+ The current status/state of the Unit color
-
+
- Response Data
+ The Timestamp of the status
-
+
- A unit in the Resgrid system
+ The Timestamp of the status in UTC/GMT
-
+
- Response Data
+ The units current Latitude
-
+
- The information about a specific unit
+ The units current Longitude
-
+
- Id of the Unit
+ Current user provide status note
-
+
- The Id of the department the unit is under
+ Units Roles
-
+
- Name of the Unit
+ Multiple Units Result
-
+
- Department assigned type for the unit
+ Response Data
-
+
- Department assigned type id for the unit
+ Default constructor
-
+
- Custom Statuses Set Id
+ Depicts a result after saving a unit status
-
+
- Station Id of the station housing the unit (0 means no station)
+ Response Data
-
+
- Name of the station the unit is under
+ Object inputs for setting a users Status/Action. If this object is used in an operation that sets
+ a status for the current user the UserId value in this object will be ignored.
-
+
- Vehicle Identification Number for the unit
+ UnitId of the apparatus that the state is being set for
-
+
- Plate Number for the Unit
+ The UnitStateType of the Unit
-
+
- Is the unit 4-Wheel drive
+ The Call/Station the unit is responding to
-
+
- Does the unit require a special permit to drive
+ Destination type for RespondingTo (Station = 1, Call = 2, POI = 3).
-
+
- Id number of the units current destionation (0 means no destination)
+ The timestamp of the status event in UTC
-
+
- The current status/state of the Unit
+ The timestamp of the status event in the local time of the device
-
+
- The Timestamp of the status
+ User provided note for this event
-
+
- The units current Latitude
+ GPS Latitude of the Unit
-
+
- The units current Longitude
+ GPS Longitude of the Unit
-
+
- Current user provide status note
+ GPS Latitude\Longitude Accuracy of the Unit
-
+
- User Defined Field values for this unit
+ GPS Altitude of the Unit
-
+
- Unit role information for roles on a unit
+ GPS Altitude Accuracy of the Unit
-
+
- Unit Role Id
+ GPS Speed of the Unit
-
+
- User Id of the user in the role (could be null)
+ GPS Heading of the Unit
-
+
- Name of the Role
+ The event id used for queuing on mobile applications
-
+
- Name of the user in the role (could be null)
+ The accountability roles filed for this event
-
+
- Multiple Unit infos Result
+ Role filled by a User on a Unit for an event
-
+
- Response Data
+ Id of the locally stored event
-
+
- Default constructor
+ Local Event Id
-
+
- The information about a specific unit
+ UserId of the user filling the role
-
+
- Id of the Unit
+ RoleId of the role being filled
-
+
- The Id of the department the unit is under
+ The name of the Role
-
+
- Name of the Unit
+ Depicts a unit status in the Resgrid system.
-
+
- Department assigned type for the unit
+ Response Data
-
+
- Department assigned type id for the unit
+ Depicts a unit's status
-
+
- Custom Statuses Set Id
+ Unit Id
-
+
- Station Id of the station housing the unit (0 means no station)
+ Units Name
-
+
- Name of the station the unit is under
+ The Type of the Unit
-
+
- Vehicle Identification Number for the unit
+ Units current Status (State)
-
+
- Plate Number for the Unit
+ CSS for status (for display)
-
+
- Is the unit 4-Wheel drive
+ CSS Style for status (for display)
-
+
- Does the unit require a special permit to drive
+ Timestamp of this Unit State
-
+
- Id number of the units current destination (0 means no destination)
+ Timestamp in Utc of this Unit State
-
+
- Name of the units current destination (0 means no destination)
+ Destination Id (Station or Call)
-
+
- The current status/state of the Unit
+ Destination type (Station, Call, or POI).
-
+
- The current status/state of the Unit as a name
+ Name of the Desination (Call or Station)
-
+
- The current status/state of the Unit color
+ Destination address.
-
+
- The Timestamp of the status
+ Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not
+ suitable for programmatic branching; use as the
+ machine-readable discriminator instead.
-
+
- The Timestamp of the status in UTC/GMT
+ Note for the State
-
+
- The units current Latitude
+ Latitude
-
+
- The units current Longitude
+ Longitude
-
+
- Current user provide status note
+ Name of the Group the Unit is in
-
+
- Units Roles
+ Id of the Group the Unit is in
-
+
- Multiple Units Result
+ Unit statuses (states)
-
+
Response Data
-
+
Default constructor
diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatFormat.ts b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatFormat.ts
index 402a9a45..3b989a5a 100644
--- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatFormat.ts
+++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatFormat.ts
@@ -237,6 +237,8 @@ export function groupChannels(channels: ChatChannelDto[]): ChannelGroup[] {
case ChatChannelType.Incident:
case ChatChannelType.IncidentLane:
case ChatChannelType.IncidentCommand:
+ case ChatChannelType.IncidentLeads:
+ case ChatChannelType.IncidentDispatch:
incidents.push(channel);
break;
default:
diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/types.ts b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/types.ts
index b7780a2f..f510e444 100644
--- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/types.ts
+++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/types.ts
@@ -11,6 +11,9 @@ export const ChatChannelType = {
IncidentLane: 6,
IncidentCommand: 7,
Chatbot: 8,
+ IncidentLeads: 9,
+ IncidentDispatch: 10,
+ UnitDispatch: 11,
} as const;
export const ChatMessageType = {