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 = {