From 9f350be457dfc5ea95bd8377be3ba9d8858e8d35 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 11 Aug 2026 16:53:50 -0700 Subject: [PATCH 1/3] RG-T117 Fixing chat issues --- .../Repositories/IChatRepositories.cs | 3 + Core/Resgrid.Model/Services/IChatServices.cs | 18 +++ Core/Resgrid.Services/ChatChannelService.cs | 16 ++ .../ChatNotificationService.cs | 13 +- Core/Resgrid.Services/ChatPresenceService.cs | 117 ++++++++++++++ .../DepartmentGroupsService.cs | 104 +++++++------ .../ChatRepositories.cs | 32 ++++ .../PostgreSql/PostgreSqlConfiguration.cs | 2 +- .../SqlServer/SqlServerConfiguration.cs | 2 +- .../Services/ChatPresenceServiceTests.cs | 145 ++++++++++++++++++ Web/Resgrid.Web.Eventing/Hubs/ChatHub.cs | 27 ++++ .../Controllers/v4/ChatController.cs | 117 +++++++++++++- .../Models/v4/Chat/ChatApiModels.cs | 3 +- Web/Resgrid.Web.Services/Program.cs | 5 + .../Resgrid.Web.Services.xml | 2 +- .../src/components/chat/ChatPageElement.tsx | 14 +- .../src/components/chat/ChatPanelElement.tsx | 13 +- .../components/chat/NewConversationDialog.tsx | 6 +- .../User/Apps/src/components/chat/chatHub.ts | 19 +++ .../User/Apps/src/components/chat/types.ts | 1 + .../User/Controllers/DepartmentController.cs | 8 +- .../User/Views/Shared/_UserLayout.cshtml | 5 +- 22 files changed, 605 insertions(+), 67 deletions(-) create mode 100644 Tests/Resgrid.Tests/Services/ChatPresenceServiceTests.cs diff --git a/Core/Resgrid.Model/Repositories/IChatRepositories.cs b/Core/Resgrid.Model/Repositories/IChatRepositories.cs index 200445f7e..63c1fde84 100644 --- a/Core/Resgrid.Model/Repositories/IChatRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IChatRepositories.cs @@ -88,6 +88,9 @@ public interface IChatChannelMemberRepository : IRepository /// Active (not removed) explicit memberships for a user across the department. Task> GetActiveByUserIdAsync(int departmentId, string userId); + /// Active (not removed) member rows for a set of channels in one query. + Task> GetActiveByChannelIdsAsync(IEnumerable chatChannelIds); + /// /// Monotonic read/delivered pointer update: only advances when the supplied seq is higher than the /// stored one (single UPDATE ... WHERE seq < @seq). diff --git a/Core/Resgrid.Model/Services/IChatServices.cs b/Core/Resgrid.Model/Services/IChatServices.cs index 82855199b..e03a2eedd 100644 --- a/Core/Resgrid.Model/Services/IChatServices.cs +++ b/Core/Resgrid.Model/Services/IChatServices.cs @@ -56,6 +56,9 @@ public interface IChatChannelService /// A user's active (not-removed) explicit memberships across the department — used for unread/preference lookups. Task> GetActiveMembershipsForUserAsync(int departmentId, string userId); + /// Active member rows for a set of channels in one query — used to label DM channels with the counterpart's name. + Task> GetActiveMembersForChannelsAsync(List chatChannelIds); + /// A user's member row for a single channel (null if none); does not lazily create one. Task GetUserMembershipAsync(string chatChannelId, string userId); @@ -274,6 +277,21 @@ public interface IChatPresenceService /// Bulk presence lookup; returns the subset of userIds currently online. Task> GetOnlineUsersAsync(int departmentId, List userIds); + + /// + /// Records the channel the user currently has open (null/empty clears it). When the user is acting + /// as a unit, the unit's active channel is recorded too so rig-device pushes can be suppressed. + /// + Task SetActiveChannelAsync(int departmentId, string userId, string channelId, int? unitId = null); + + /// Clears the user's (and their acting unit's) active-channel marker. + Task ClearActiveChannelAsync(int departmentId, string userId); + + /// Bulk lookup: the subset of userIds actively viewing the given channel right now. + Task> GetUsersActiveInChannelAsync(int departmentId, List userIds, string channelId); + + /// True when the unit's device currently has the given channel open. + Task IsUnitActiveInChannelAsync(int departmentId, int unitId, string channelId); } /// diff --git a/Core/Resgrid.Services/ChatChannelService.cs b/Core/Resgrid.Services/ChatChannelService.cs index 6abda3ce1..d19767089 100644 --- a/Core/Resgrid.Services/ChatChannelService.cs +++ b/Core/Resgrid.Services/ChatChannelService.cs @@ -254,7 +254,12 @@ async Task> getChannels() saved = await _chatChannelRepository.GetByDmKeyAsync(departmentId, dmKey); if (saved != null && string.Equals(saved.ChatChannelId, channel.ChatChannelId, StringComparison.OrdinalIgnoreCase)) + { + // Roll the channel-list cache version so both participants see the new DM on their + // next GetChannels instead of waiting out the 45s per-user list cache. + await _chatPermissionService.InvalidateChannelCacheAsync(saved.ChatChannelId); PublishChannelEvent(saved, ChatEventKinds.ChannelProvisioned); + } return saved; } @@ -291,6 +296,7 @@ async Task> getChannels() await AddMemberRowAsync(channel, ChatParticipantType.User, memberId, null, null, creatorUserId, cancellationToken); } + await _chatPermissionService.InvalidateChannelCacheAsync(channel.ChatChannelId); PublishChannelEvent(channel, ChatEventKinds.ChannelProvisioned); return channel; @@ -327,6 +333,7 @@ async Task> getChannels() } } + await _chatPermissionService.InvalidateChannelCacheAsync(channel.ChatChannelId); PublishChannelEvent(channel, ChatEventKinds.ChannelProvisioned); return channel; @@ -384,6 +391,15 @@ public async Task> GetActiveMembershipsForUserAsync(int return members?.ToList() ?? new List(); } + public async Task> GetActiveMembersForChannelsAsync(List chatChannelIds) + { + if (chatChannelIds == null || chatChannelIds.Count == 0) + return new List(); + + var members = await _chatChannelMemberRepository.GetActiveByChannelIdsAsync(chatChannelIds); + return members?.ToList() ?? new List(); + } + public async Task GetUserMembershipAsync(string chatChannelId, string userId) { return await _chatChannelMemberRepository.GetUserMemberAsync(chatChannelId, userId); diff --git a/Core/Resgrid.Services/ChatNotificationService.cs b/Core/Resgrid.Services/ChatNotificationService.cs index 5178ce242..048a71473 100644 --- a/Core/Resgrid.Services/ChatNotificationService.cs +++ b/Core/Resgrid.Services/ChatNotificationService.cs @@ -72,9 +72,11 @@ public async Task NotifyMessageSentAsync(ChatChannel channel, ChatMessage messag mentions?.Where(m => m.MentionType == (int)ChatMentionType.User && !string.IsNullOrWhiteSpace(m.TargetUserId)).Select(m => m.TargetUserId) ?? Enumerable.Empty(), StringComparer.OrdinalIgnoreCase); - // Presence suppression: online users already get the message over SignalR. - var onlineUsers = new HashSet( - await _chatPresenceService.GetOnlineUsersAsync(channel.DepartmentId, audience), + // Active-channel suppression: only viewers with THIS conversation open are skipped — they see + // the message live over SignalR. Online-but-elsewhere users still get a push so background + // channels can alert them. + var activeUsers = new HashSet( + await _chatPresenceService.GetUsersActiveInChannelAsync(channel.DepartmentId, audience, channel.ChatChannelId), StringComparer.OrdinalIgnoreCase); var isDm = channel.ChannelType == (int)ChatChannelType.DirectMessage; @@ -100,7 +102,7 @@ await _chatPresenceService.GetOnlineUsersAsync(channel.DepartmentId, audience), if (string.Equals(userId, message.SenderUserId, StringComparison.OrdinalIgnoreCase)) continue; - if (onlineUsers.Contains(userId)) + if (activeUsers.Contains(userId)) continue; membersByUser.TryGetValue(userId, out var member); @@ -119,6 +121,9 @@ await _chatPresenceService.GetOnlineUsersAsync(channel.DepartmentId, audience), if (message.SenderUnitId.HasValue && message.SenderUnitId.Value == unitMember.UnitId.Value) continue; + if (await _chatPresenceService.IsUnitActiveInChannelAsync(channel.DepartmentId, unitMember.UnitId.Value, channel.ChatChannelId)) + continue; + if (!ShouldNotify(unitMember, isUrgent, urgentOverridesMute, mentionedEveryone)) continue; diff --git a/Core/Resgrid.Services/ChatPresenceService.cs b/Core/Resgrid.Services/ChatPresenceService.cs index 4a325854d..b9f63239d 100644 --- a/Core/Resgrid.Services/ChatPresenceService.cs +++ b/Core/Resgrid.Services/ChatPresenceService.cs @@ -35,6 +35,18 @@ public async Task SetOnlineAsync(int departmentId, string userId) public async Task TouchAsync(int departmentId, string userId) { await _cacheProvider.SetStringAsync(GetKey(departmentId, userId), "1", GetTtl()); + + // Keep the active-channel marker (and its unit mirror) alive across heartbeats so an open + // conversation stays "active" without the client re-invoking SetActiveChannel. + var active = await _cacheProvider.GetStringAsync(GetActiveKey(departmentId, userId)); + if (!string.IsNullOrWhiteSpace(active)) + { + await _cacheProvider.SetStringAsync(GetActiveKey(departmentId, userId), active, GetTtl()); + + var unitId = ParseUnitId(active); + if (unitId.HasValue) + await _cacheProvider.SetStringAsync(GetUnitActiveKey(departmentId, unitId.Value), ParseChannelId(active), GetTtl()); + } } public async Task IsOnlineAsync(int departmentId, string userId) @@ -77,11 +89,116 @@ async Task LookupAsync(string userId) return online; } + public async Task SetActiveChannelAsync(int departmentId, string userId, string channelId, int? unitId = null) + { + var activeKey = GetActiveKey(departmentId, userId); + var existingUnitId = ParseUnitId(await _cacheProvider.GetStringAsync(activeKey)); + + if (string.IsNullOrWhiteSpace(channelId)) + { + await _cacheProvider.RemoveAsync(activeKey); + if (existingUnitId.HasValue) + await _cacheProvider.RemoveAsync(GetUnitActiveKey(departmentId, existingUnitId.Value)); + return; + } + + // Acting unit changed (or dropped): clear the stale unit marker so the old rig isn't suppressed. + if (existingUnitId.HasValue && existingUnitId != unitId) + await _cacheProvider.RemoveAsync(GetUnitActiveKey(departmentId, existingUnitId.Value)); + + var value = unitId.HasValue ? $"{channelId}|{unitId.Value}" : channelId; + await _cacheProvider.SetStringAsync(activeKey, value, GetTtl()); + + if (unitId.HasValue) + await _cacheProvider.SetStringAsync(GetUnitActiveKey(departmentId, unitId.Value), channelId, GetTtl()); + } + + public async Task ClearActiveChannelAsync(int departmentId, string userId) + { + await SetActiveChannelAsync(departmentId, userId, null); + } + + public async Task> GetUsersActiveInChannelAsync(int departmentId, List userIds, string channelId) + { + var active = new List(); + + if (userIds == null || userIds.Count == 0 || string.IsNullOrWhiteSpace(channelId)) + return active; + + using (var throttler = new SemaphoreSlim(8)) + { + async Task LookupAsync(string userId) + { + await throttler.WaitAsync(); + try + { + var value = await _cacheProvider.GetStringAsync(GetActiveKey(departmentId, userId)); + if (string.Equals(ParseChannelId(value), channelId, StringComparison.OrdinalIgnoreCase)) + lock (active) + active.Add(userId); + } + finally + { + throttler.Release(); + } + } + + var lookups = new List(); + foreach (var userId in userIds) + lookups.Add(LookupAsync(userId)); + + await Task.WhenAll(lookups); + } + + return active; + } + + public async Task IsUnitActiveInChannelAsync(int departmentId, int unitId, string channelId) + { + if (unitId <= 0 || string.IsNullOrWhiteSpace(channelId)) + return false; + + var value = await _cacheProvider.GetStringAsync(GetUnitActiveKey(departmentId, unitId)); + return string.Equals(value, channelId, StringComparison.OrdinalIgnoreCase); + } + + // Active markers store "channelId" or "channelId|unitId" when the viewer is acting as a unit. + private static string ParseChannelId(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + var separator = value.IndexOf('|'); + return separator < 0 ? value : value.Substring(0, separator); + } + + private static int? ParseUnitId(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + var separator = value.IndexOf('|'); + if (separator < 0 || separator >= value.Length - 1) + return null; + + return int.TryParse(value.Substring(separator + 1), out var unitId) ? unitId : (int?)null; + } + private static string GetKey(int departmentId, string userId) { return $"chatpresence:{departmentId}:{userId?.ToLowerInvariant()}"; } + private static string GetActiveKey(int departmentId, string userId) + { + return $"chatactive:{departmentId}:{userId?.ToLowerInvariant()}"; + } + + private static string GetUnitActiveKey(int departmentId, int unitId) + { + return $"chatactiveunit:{departmentId}:{unitId}"; + } + private static TimeSpan GetTtl() { return TimeSpan.FromSeconds(Math.Max(15, ChatConfig.PresenceTtlSeconds)); diff --git a/Core/Resgrid.Services/DepartmentGroupsService.cs b/Core/Resgrid.Services/DepartmentGroupsService.cs index ced86fc6f..19bde0f3c 100644 --- a/Core/Resgrid.Services/DepartmentGroupsService.cs +++ b/Core/Resgrid.Services/DepartmentGroupsService.cs @@ -105,42 +105,8 @@ public async Task> GetAllAsync() public async Task> GetAllGroupsForDepartmentAsync(int departmentId) { - //List departmentGroups = new List(); - - //var groups = await GetAllGroupsForDepartmentUnlimitedAsync(departmentId); - - //int limit = 0; - //if (Config.SystemBehaviorConfig.RedirectHomeToLogin) - // limit = int.MaxValue; - //else - // limit = (await _subscriptionsService.GetCurrentPlanForDepartmentAsync(departmentId)).GetLimitForTypeAsInt(PlanLimitTypes.Groups); - - //int count = groups.Count < limit ? groups.Count : limit; - - //// Only return users up to the plans group limit - //for (int i = 0; i < count; i++) - //{ - // departmentGroups.Add(groups[i]); - //} - - var departmentGroups = await GetAllGroupsForDepartmentUnlimitedAsync(departmentId); - - foreach (var group in departmentGroups) - { - if (group.ParentDepartmentGroupId.HasValue) - { - group.Parent = await GetGroupByIdAsync(group.ParentDepartmentGroupId.Value, false); - } - - var childGroups = await _departmentGroupsRepository.GetAllGroupsByParentGroupIdAsync(group.DepartmentGroupId); - - if (childGroups != null && childGroups.Any()) - group.Children = childGroups.ToList(); - else - group.Children = new List(); - } - - return departmentGroups; + // GetAllGroupsForDepartmentUnlimitedAsync already resolves addresses, parents and children + return await GetAllGroupsForDepartmentUnlimitedAsync(departmentId); } public async Task InvalidateGroupInCache(int groupId) @@ -150,27 +116,73 @@ public async Task InvalidateGroupInCache(int groupId) public async Task> GetAllGroupsForDepartmentUnlimitedAsync(int departmentId) { - var groups = await _departmentGroupsRepository.GetAllGroupsByDepartmentIdAsync(departmentId); + // Repository returns null when the underlying query throws (it logs and swallows) + var groups = (await _departmentGroupsRepository.GetAllGroupsByDepartmentIdAsync(departmentId))?.ToList(); - foreach (var g in groups) + if (groups == null) + return new List(); + + foreach (var addressId in groups.Where(x => x.AddressId.HasValue && x.Address == null).Select(x => x.AddressId.Value).Distinct().ToList()) { - if (g.AddressId.HasValue && g.Address == null) - g.Address = await _addressService.GetAddressByIdAsync(g.AddressId.Value); + var address = await _addressService.GetAddressByIdAsync(addressId); + + foreach (var g in groups.Where(x => x.AddressId == addressId && x.Address == null)) + g.Address = address; + } + // The result set already contains every group in the department, so parent and + // child links resolve in memory instead of issuing per-group queries. + var groupsById = groups.ToDictionary(x => x.DepartmentGroupId); + var childrenByParentId = groups + .Where(x => x.ParentDepartmentGroupId.HasValue) + .GroupBy(x => x.ParentDepartmentGroupId.Value) + .ToDictionary(x => x.Key, x => x.ToList()); + + foreach (var g in groups) + { if (g.ParentDepartmentGroupId.HasValue) { - g.Parent = await GetGroupByIdAsync(g.ParentDepartmentGroupId.Value, false); + if (groupsById.TryGetValue(g.ParentDepartmentGroupId.Value, out var parent)) + g.Parent = CopyWithoutRelations(parent); + else + g.Parent = await GetGroupByIdAsync(g.ParentDepartmentGroupId.Value, false); } - var childGroups = await _departmentGroupsRepository.GetAllGroupsByParentGroupIdAsync(g.DepartmentGroupId); - - if (childGroups != null && childGroups.Any()) - g.Children = childGroups.ToList(); + if (childrenByParentId.TryGetValue(g.DepartmentGroupId, out var children)) + g.Children = children; else g.Children = new List(); } - return groups.ToList(); + return groups; + } + + // Parent links point at a detached copy so the group graph stays acyclic; + // Children reference the shared in-memory list instances. + private static DepartmentGroup CopyWithoutRelations(DepartmentGroup group) + { + return new DepartmentGroup + { + DepartmentGroupId = group.DepartmentGroupId, + DepartmentId = group.DepartmentId, + Type = group.Type, + AddressId = group.AddressId, + Address = group.Address, + ParentDepartmentGroupId = group.ParentDepartmentGroupId, + Members = group.Members, + Name = group.Name, + Geofence = group.Geofence, + GeofenceColor = group.GeofenceColor, + DispatchEmail = group.DispatchEmail, + MessageEmail = group.MessageEmail, + Latitude = group.Latitude, + Longitude = group.Longitude, + What3Words = group.What3Words, + DispatchToPrinter = group.DispatchToPrinter, + PrinterData = group.PrinterData, + DispatchToFax = group.DispatchToFax, + FaxNumber = group.FaxNumber + }; } public async Task> GetAllGroupsForDepartmentUnlimitedThinAsync(int departmentId) diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs index ccec4b3cd..9fa6c0d3a 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs @@ -869,6 +869,38 @@ public async Task> GetActiveByUserIdAsync(int dep } } + public async Task> GetActiveByChannelIdsAsync(IEnumerable chatChannelIds) + { + try + { + var ids = chatChannelIds?.ToList() ?? new List(); + if (ids.Count == 0) + return new List(); + + var notation = _sqlConfiguration.ParameterNotation; + var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres + ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatchannelmembers WHERE chatchannelid IN {notation}Ids AND removedon IS NULL" + : $"SELECT * FROM {_sqlConfiguration.SchemaName}.[ChatChannelMembers] WHERE [ChatChannelId] IN {notation}Ids AND [RemovedOn] IS NULL"; + + var select = new Func>>(connection => + connection.QueryAsync(sql, new { Ids = ids }, _unitOfWork.Transaction)); + + if (_unitOfWork?.Connection == null) + { + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(); + return await select(connection); + } + + return await select(_unitOfWork.CreateOrGetConnection()); + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + public async Task AdvanceReadPointerAsync(string chatChannelMemberId, long seq, DateTime readOn) { try diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs index 1d2b52acf..2350a0913 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs @@ -1326,7 +1326,7 @@ SELECT dgm.* LEFT JOIN (SELECT dgm1.* FROM %SCHEMA%.%GROUPMEMBERSSTABLE% dgm1 INNER JOIN %SCHEMA%.%DEPARTMENTMEMBERSSTABLE% dm ON dgm1.UserId = dm.UserId - WHERE dm.IsDeleted = false) AS dgm ON dgm.DepartmentGroupId = dg.DepartmentGroupId + WHERE dm.IsDeleted = false AND dm.DepartmentId = %DID%) AS dgm ON dgm.DepartmentGroupId = dg.DepartmentGroupId WHERE dg.DepartmentId = %DID%"; SelectAllGroupsByParentIdQuery = @" SELECT %SCHEMA%.%GROUPSTABLE%.*, %SCHEMA%.%GROUPMEMBERSSTABLE%.* diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs index a31717c5a..821b7d368 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs @@ -1279,7 +1279,7 @@ SELECT dgm.* LEFT JOIN (SELECT dgm1.* FROM %SCHEMA%.%GROUPMEMBERSSTABLE% dgm1 INNER JOIN %SCHEMA%.%DEPARTMENTMEMBERSSTABLE% dm ON dgm1.[UserId] = dm.[UserId] - WHERE dm.[IsDeleted] = 0) AS dgm ON dgm.[DepartmentGroupId] = dg.[DepartmentGroupId] + WHERE dm.[IsDeleted] = 0 AND dm.[DepartmentId] = %DID%) AS dgm ON dgm.[DepartmentGroupId] = dg.[DepartmentGroupId] WHERE dg.[DepartmentId] = %DID%"; SelectAllGroupsByParentIdQuery = @" SELECT %SCHEMA%.%GROUPSTABLE%.*, %SCHEMA%.%GROUPMEMBERSSTABLE%.* diff --git a/Tests/Resgrid.Tests/Services/ChatPresenceServiceTests.cs b/Tests/Resgrid.Tests/Services/ChatPresenceServiceTests.cs new file mode 100644 index 000000000..53087d8f4 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChatPresenceServiceTests.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + namespace ChatPresenceServiceTests + { + public class with_the_chat_presence_service : TestBase + { + protected IChatPresenceService _chatPresenceService; + protected Mock _cacheProviderMock; + protected Dictionary _cache; + + protected with_the_chat_presence_service() + { + BuildService(); + } + + protected override void Before_all_tests() + { + BuildService(); + } + + // Dictionary-backed cache fake: TTLs are ignored (expiry is not under test), everything else + // behaves like the real store so set/get/remove interplay is exercised end to end. + private void BuildService() + { + _cache = new Dictionary(StringComparer.Ordinal); + _cacheProviderMock = new Mock(); + + _cacheProviderMock.Setup(x => x.SetStringAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string key, string value, TimeSpan ttl) => + { + _cache[key] = value; + return true; + }); + _cacheProviderMock.Setup(x => x.GetStringAsync(It.IsAny())) + .ReturnsAsync((string key) => _cache.TryGetValue(key, out var value) ? value : null); + _cacheProviderMock.Setup(x => x.RemoveAsync(It.IsAny())) + .ReturnsAsync((string key) => _cache.Remove(key)); + + _chatPresenceService = new ChatPresenceService(_cacheProviderMock.Object); + } + } + + [TestFixture] + public class when_tracking_active_channels : with_the_chat_presence_service + { + [Test] + public async Task marks_the_user_active_in_the_channel() + { + await _chatPresenceService.SetActiveChannelAsync(1, "user-a", "chan-1"); + + var active = await _chatPresenceService.GetUsersActiveInChannelAsync(1, new List { "user-a", "user-b" }, "chan-1"); + + active.Should().ContainSingle().Which.Should().Be("user-a"); + } + + [Test] + public async Task a_user_active_in_another_channel_is_not_returned() + { + await _chatPresenceService.SetActiveChannelAsync(1, "user-a", "chan-2"); + + var active = await _chatPresenceService.GetUsersActiveInChannelAsync(1, new List { "user-a" }, "chan-1"); + + active.Should().BeEmpty(); + } + + [Test] + public async Task clearing_removes_the_marker() + { + await _chatPresenceService.SetActiveChannelAsync(1, "user-a", "chan-1"); + await _chatPresenceService.ClearActiveChannelAsync(1, "user-a"); + + var active = await _chatPresenceService.GetUsersActiveInChannelAsync(1, new List { "user-a" }, "chan-1"); + + active.Should().BeEmpty(); + } + + [Test] + public async Task setting_a_null_channel_clears_the_marker() + { + await _chatPresenceService.SetActiveChannelAsync(1, "user-a", "chan-1"); + await _chatPresenceService.SetActiveChannelAsync(1, "user-a", null); + + var active = await _chatPresenceService.GetUsersActiveInChannelAsync(1, new List { "user-a" }, "chan-1"); + + active.Should().BeEmpty(); + } + } + + [TestFixture] + public class when_tracking_unit_active_channels : with_the_chat_presence_service + { + [Test] + public async Task marks_the_acting_unit_active_in_the_channel() + { + await _chatPresenceService.SetActiveChannelAsync(1, "user-a", "chan-1", 7); + + (await _chatPresenceService.IsUnitActiveInChannelAsync(1, 7, "chan-1")).Should().BeTrue(); + (await _chatPresenceService.IsUnitActiveInChannelAsync(1, 7, "chan-2")).Should().BeFalse(); + } + + [Test] + public async Task clearing_the_user_clears_the_unit_marker_too() + { + await _chatPresenceService.SetActiveChannelAsync(1, "user-a", "chan-1", 7); + await _chatPresenceService.ClearActiveChannelAsync(1, "user-a"); + + (await _chatPresenceService.IsUnitActiveInChannelAsync(1, 7, "chan-1")).Should().BeFalse(); + } + + [Test] + public async Task switching_acting_unit_clears_the_previous_units_marker() + { + await _chatPresenceService.SetActiveChannelAsync(1, "user-a", "chan-1", 7); + await _chatPresenceService.SetActiveChannelAsync(1, "user-a", "chan-1", 9); + + (await _chatPresenceService.IsUnitActiveInChannelAsync(1, 7, "chan-1")).Should().BeFalse(); + (await _chatPresenceService.IsUnitActiveInChannelAsync(1, 9, "chan-1")).Should().BeTrue(); + } + + [Test] + public async Task heartbeat_touch_keeps_the_active_markers_refreshed() + { + await _chatPresenceService.SetActiveChannelAsync(1, "user-a", "chan-1", 7); + + await _chatPresenceService.TouchAsync(1, "user-a"); + + // Touch re-writes both markers (fresh TTL) without altering their values. + (await _chatPresenceService.IsUnitActiveInChannelAsync(1, 7, "chan-1")).Should().BeTrue(); + var active = await _chatPresenceService.GetUsersActiveInChannelAsync(1, new List { "user-a" }, "chan-1"); + active.Should().ContainSingle(); + _cacheProviderMock.Verify(x => x.SetStringAsync("chatactive:1:user-a", It.IsAny(), It.IsAny()), Times.AtLeast(2)); + } + } + } +} diff --git a/Web/Resgrid.Web.Eventing/Hubs/ChatHub.cs b/Web/Resgrid.Web.Eventing/Hubs/ChatHub.cs index 164686a80..32ec422c1 100644 --- a/Web/Resgrid.Web.Eventing/Hubs/ChatHub.cs +++ b/Web/Resgrid.Web.Eventing/Hubs/ChatHub.cs @@ -278,5 +278,32 @@ public async Task Heartbeat() if (departmentId > 0 && !string.IsNullOrWhiteSpace(userId)) await _chatPresenceService.TouchAsync(departmentId, userId); } + + /// + /// Marks the channel the caller is actively viewing (null/empty clears it). Push notifications for + /// a channel are suppressed only for viewers active in that channel — merely being online no longer + /// suppresses them. Clients call this on conversation open/close and on app foreground/background. + /// + public async Task SetActiveChannel(string channelId, int? asUnitId = null) + { + var departmentId = ClaimsAuthorizationHelper.GetDepartmentId(); + var userId = ClaimsAuthorizationHelper.GetUserId(); + + if (departmentId <= 0 || string.IsNullOrWhiteSpace(userId)) + return; + + if (string.IsNullOrWhiteSpace(channelId)) + { + await _chatPresenceService.ClearActiveChannelAsync(departmentId, userId); + return; + } + + // Access-check before recording, so a forged channelId can't suppress someone else's pushes. + var access = await ResolveAccessibleChannelAsync(channelId, asUnitId); + if (access == null) + return; + + await _chatPresenceService.SetActiveChannelAsync(departmentId, userId, channelId, asUnitId); + } } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs index 5a10dafb1..8241b842a 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs @@ -130,6 +130,8 @@ public async Task> GetChannels(int? activeUn result.Data.Add(ConvertChannelResultData(channel, member)); } + await ResolveDirectMessageNamesAsync(result.Data); + result.PageSize = result.Data.Count; result.Status = ResponseHelper.Success; } @@ -168,6 +170,7 @@ public async Task> GetChannel(string channelI var member = await _chatChannelService.GetUserMembershipAsync(channel.ChatChannelId, UserId); result.Data = ConvertChannelResultData(channel, member); + await ResolveDirectMessageNamesAsync(new List { result.Data }); result.PageSize = 1; result.Status = ResponseHelper.Success; } @@ -209,6 +212,7 @@ public async Task> CreateDirectMessage([F var member = await _chatChannelService.GetUserMembershipAsync(channel.ChatChannelId, UserId); result.Data = ConvertChannelResultData(channel, member); + await ResolveDirectMessageNamesAsync(new List { result.Data }); result.PageSize = 1; result.Status = ResponseHelper.Created; } @@ -239,11 +243,16 @@ public async Task> CreateAdHocChannel([Fr if (!ModelState.IsValid) return BadRequest(); - if (input == null || String.IsNullOrWhiteSpace(input.Name) || input.MemberUserIds == null || input.MemberUserIds.Count <= 0) + if (input == null || input.MemberUserIds == null || input.MemberUserIds.Count <= 0) return BadRequest(); + // No name supplied: name the group after its members (Slack-style), capped to the column length. + var name = input.Name?.Trim(); + if (String.IsNullOrWhiteSpace(name)) + name = await BuildGroupNameFromMembersAsync(input.MemberUserIds); + var result = new ChatChannelCreatedResult(); - var channel = await _chatChannelService.CreateAdHocGroupChannelAsync(DepartmentId, UserId, input.Name, input.MemberUserIds, cancellationToken); + var channel = await _chatChannelService.CreateAdHocGroupChannelAsync(DepartmentId, UserId, name, input.MemberUserIds, cancellationToken); if (channel != null) { @@ -1773,6 +1782,110 @@ private static ChatMessageResultData ConvertMessageResultData(ChatMessage messag return data; } + private const int MaxDerivedGroupNameLength = 100; + + // "Alice Smith, Bob Jones" from the invited members (creator excluded — matches how Slack labels + // unnamed group DMs). Falls back to "New group" only if no profile resolves. + private async Task BuildGroupNameFromMembersAsync(List memberUserIds) + { + var ids = memberUserIds? + .Where(id => !String.IsNullOrWhiteSpace(id) && !String.Equals(id, UserId, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList() ?? new List(); + + var names = new List(); + if (ids.Count > 0) + { + var profiles = await _userProfileService.GetSelectedUserProfilesAsync(ids); + foreach (var profile in profiles ?? new List()) + { + var name = profile?.FullName?.AsFirstNameLastName; + if (!String.IsNullOrWhiteSpace(name)) + names.Add(name); + } + } + + if (names.Count == 0) + return "New group"; + + names.Sort(StringComparer.OrdinalIgnoreCase); + + var joined = String.Join(", ", names); + if (joined.Length <= MaxDerivedGroupNameLength) + return joined; + + // Too long: keep whole names and count the rest ("Alice Smith, Bob Jones +3"). + var kept = new List(); + var length = 0; + foreach (var name in names) + { + var addition = (kept.Count == 0 ? 0 : 2) + name.Length; + if (length + addition + 6 > MaxDerivedGroupNameLength) + break; + + kept.Add(name); + length += addition; + } + + if (kept.Count == 0) + kept.Add(names[0].Length > MaxDerivedGroupNameLength - 6 ? names[0].Substring(0, MaxDerivedGroupNameLength - 6) : names[0]); + + var remaining = names.Count - kept.Count; + return remaining > 0 ? $"{String.Join(", ", kept)} +{remaining}" : String.Join(", ", kept); + } + + // DM channels have no stored Name; label each with the counterpart participant so multiple + // DMs stay distinguishable in every client list. Unit counterparts already carry a + // DisplayNameOverride stamped at creation; user counterparts resolve via profile lookup. + private async Task ResolveDirectMessageNamesAsync(List channels) + { + var dmChannels = channels?.Where(c => c != null && c.ChannelType == (int)ChatChannelType.DirectMessage && String.IsNullOrWhiteSpace(c.Name)).ToList(); + if (dmChannels == null || dmChannels.Count == 0) + return; + + var members = await _chatChannelService.GetActiveMembersForChannelsAsync(dmChannels.Select(c => c.ChatChannelId).ToList()); + if (members == null || members.Count == 0) + return; + + var counterparts = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var member in members) + { + if (member.UserId != null && String.Equals(member.UserId, UserId, StringComparison.OrdinalIgnoreCase)) + continue; + + if (!counterparts.ContainsKey(member.ChatChannelId)) + counterparts.Add(member.ChatChannelId, member); + } + + var userIds = counterparts.Values + .Where(m => String.IsNullOrWhiteSpace(m.DisplayNameOverride) && !String.IsNullOrWhiteSpace(m.UserId)) + .Select(m => m.UserId) + .Distinct() + .ToList(); + + var namesByUserId = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (userIds.Count > 0) + { + var profiles = await _userProfileService.GetSelectedUserProfilesAsync(userIds); + foreach (var profile in profiles ?? new List()) + { + if (profile?.UserId != null && !namesByUserId.ContainsKey(profile.UserId)) + namesByUserId.Add(profile.UserId, profile.FullName?.AsFirstNameLastName); + } + } + + foreach (var channel in dmChannels) + { + if (!counterparts.TryGetValue(channel.ChatChannelId, out var counterpart)) + continue; + + if (!String.IsNullOrWhiteSpace(counterpart.DisplayNameOverride)) + channel.Name = counterpart.DisplayNameOverride; + else if (counterpart.UserId != null && namesByUserId.TryGetValue(counterpart.UserId, out var name) && !String.IsNullOrWhiteSpace(name)) + channel.Name = name; + } + } + private static ChatChannelResultData ConvertChannelResultData(ChatChannel channel, ChatChannelMember member) { return new ChatChannelResultData diff --git a/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs index 23fbceae8..9bbd6c9dc 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs @@ -1029,9 +1029,8 @@ public class CreateDirectMessageInput public class CreateAdHocChannelInput { /// - /// Name of the channel + /// Name of the channel. Optional — when omitted the server names the group after its members. /// - [Required] [StringLength(100)] public string Name { get; set; } diff --git a/Web/Resgrid.Web.Services/Program.cs b/Web/Resgrid.Web.Services/Program.cs index e4f9b80a7..cea849e70 100644 --- a/Web/Resgrid.Web.Services/Program.cs +++ b/Web/Resgrid.Web.Services/Program.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging; using Resgrid.Config; using Resgrid.Framework; +using Sentry; using Sentry.Profiling; namespace Resgrid.Web.ServicesCore @@ -65,6 +66,10 @@ public static IHostBuilder CreateHostBuilder(string[] args) => options.ProfilesSampleRate = ExternalErrorConfig.SentryProfilingSampleRate; options.SetBeforeSendTransaction(SentryTransactionFilter.Filter); + // Client aborted/malformed requests (e.g. "Unexpected end of request content" + // when a mobile upload drops mid-body) are not actionable server errors. + options.AddExceptionFilterForType(); + // Requires NuGet package: Sentry.Profiling // Note: By default, the profiler is initialized asynchronously. This can be tuned by passing a desired initialization timeout to the constructor. options.AddIntegration(new ProfilingIntegration( diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 24fc4bbf7..ba98333d9 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -7984,7 +7984,7 @@ - Name of the channel + Name of the channel. Optional — when omitted the server names the group after its members. diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPageElement.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPageElement.tsx index a2043ce9a..31e6d2809 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPageElement.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPageElement.tsx @@ -1,9 +1,10 @@ -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import './chat.css'; import { getCurrentUserId, isDepartmentAdmin, type ChatChannelDto, type ChatMessageDto } from './types'; import { useChatBootstrap } from './useChatBootstrap'; import { useChatStore, shallowArrayEqual } from './useChatStore'; -import { setActiveChannel, setHighlightMessage } from './chatStore'; +import { setActiveChannel, setHighlightMessage, upsertChannel } from './chatStore'; +import { chatHub } from './chatHub'; import { flagChatMessage } from './chatActions'; import { searchMessages } from './chatApi'; import { channelDisplayName, formatRelativeDay } from './chatFormat'; @@ -41,6 +42,12 @@ export default function ChatPageElement(_props: ChatPageElementProps) { const canModerate = isDepartmentAdmin(); const activeChannel = channels.find((channel) => channel.ChatChannelId === activeChannelId) ?? null; + // Report the on-screen conversation so the server suppresses its pushes; cleared on unmount. + useEffect(() => { + chatHub.setActiveChannel(activeChannelId); + }, [activeChannelId]); + useEffect(() => () => chatHub.setActiveChannel(null), []); + // Stable identities: ChannelRow and MessageBubble are memo'd, so these callbacks must not be recreated // each render or those children re-render on every ChatPageElement state change (defeating their memo). const openChannel = useCallback((channelId: string, messageId?: string) => { @@ -211,6 +218,9 @@ export default function ChatPageElement(_props: ChatPageElementProps) { onClose={() => setShowNew(false)} onCreated={(channel: ChatChannelDto) => { setShowNew(false); + // Seed the store immediately: the channel-list refetch races the hub event, and the + // conversation can only open if the channel exists in state. + upsertChannel(channel); openChannel(channel.ChatChannelId); }} /> diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPanelElement.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPanelElement.tsx index eae3a7cc7..c7b1fcd0d 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPanelElement.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPanelElement.tsx @@ -3,7 +3,8 @@ import './chat.css'; import { ChatChannelType, getCurrentUserId, type ChatChannelDto, type ChatMessageDto } from './types'; import { useChatBootstrap } from './useChatBootstrap'; import { useChatStore, shallowArrayEqual } from './useChatStore'; -import { setActiveChannel } from './chatStore'; +import { setActiveChannel, upsertChannel } from './chatStore'; +import { chatHub } from './chatHub'; import { flagChatMessage } from './chatActions'; import { channelDisplayName } from './chatFormat'; import ChannelList from './ChannelList'; @@ -49,6 +50,13 @@ export default function ChatPanelElement({ hostElement, label = 'Chat' }: ChatPa } }, [hostElement, chatVisible]); + // Report the on-screen conversation so the server suppresses its pushes; a minimized panel + // counts as not viewing. Cleared on unmount (page navigation). + useEffect(() => { + chatHub.setActiveChannel(open && activeChannelId ? activeChannelId : null); + }, [open, activeChannelId]); + useEffect(() => () => chatHub.setActiveChannel(null), []); + const openPanel = () => { setOpen(true); // Lazy realtime: the hub only connects the first time the panel is opened. @@ -161,6 +169,9 @@ export default function ChatPanelElement({ hostElement, label = 'Chat' }: ChatPa onClose={() => setShowNew(false)} onCreated={(channel: ChatChannelDto) => { setShowNew(false); + // Seed the store immediately: the channel-list refetch races the hub event, and the + // conversation can only open if the channel exists in state. + upsertChannel(channel); openChannel(channel.ChatChannelId); }} /> diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/NewConversationDialog.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/NewConversationDialog.tsx index ce5280d73..101e765fc 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/NewConversationDialog.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/NewConversationDialog.tsx @@ -48,8 +48,8 @@ export default function NewConversationDialog({ currentUserId, onClose, onCreate if (selected.length === 1) { channel = await createDirectMessage(selected[0]); } else { - const name = groupName.trim().length > 0 ? groupName.trim() : 'New group'; - channel = await createAdHocChannel(name, selected); + // Empty name is fine — the server names the group after its members. + channel = await createAdHocChannel(groupName.trim(), selected); } if (channel) { onCreated(channel); @@ -89,7 +89,7 @@ export default function NewConversationDialog({ currentUserId, onClose, onCreate {selected.length > 1 && ( setGroupName(event.target.value)} /> diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatHub.ts b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatHub.ts index 9b3b9eef1..e006a0306 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatHub.ts +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatHub.ts @@ -143,6 +143,7 @@ class ChatHub { for (const [channelId, asUnitId] of this.joinedChannels.entries()) { await this.invokeJoin(channelId, asUnitId); } + this.reportActiveChannel(); this.startHeartbeat(); })(); @@ -274,6 +275,7 @@ class ChatHub { await this.invokeJoin(channelId, asUnitId); await this.deltaSync(channelId); } + this.reportActiveChannel(); this.notifyChannelsRefresh(); } catch (error) { console.error('Chat hub reconnect sync failed.', error); @@ -366,6 +368,23 @@ class ChatHub { this.connection.invoke(CHAT_HUB_METHODS.MarkRead, channelId, seq, asUnitId ?? null).catch(() => undefined); } } + + // Tells the server which conversation is on screen so pushes for it are suppressed while + // everything else still alerts. Remembered locally and re-reported after reconnects. + private activeChannelReported: string | null = null; + + public setActiveChannel(channelId: string | null): void { + this.activeChannelReported = channelId; + this.reportActiveChannel(); + } + + private reportActiveChannel(): void { + if (this.connection && this.connection.state === HubConnectionState.Connected) { + this.connection + .invoke(CHAT_HUB_METHODS.SetActiveChannel, this.activeChannelReported, null) + .catch(() => undefined); + } + } } export const chatHub = new ChatHub(); 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 d67f95322..b7780a2fd 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 @@ -70,6 +70,7 @@ export const CHAT_HUB_METHODS = { LeaveChannel: 'LeaveChannel', Typing: 'Typing', MarkRead: 'MarkRead', + SetActiveChannel: 'SetActiveChannel', } as const; // Client-side lifecycle for optimistic messages (not part of the wire DTO). diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs index 772979c56..fb0d7bcb5 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs @@ -1450,9 +1450,11 @@ public async Task GetRecipientsForGrid(int filter = 0, bool filte { var result = new List(); - var stations = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId); - var roles = await _personnelRolesService.GetRolesForDepartmentAsync(DepartmentId); - var profiles = await _userProfileService.GetAllProfilesForDepartmentAsync(DepartmentId); + // Repositories swallow DB exceptions and return null; treat as empty so a + // transient failure degrades to an empty grid instead of a 500. + var stations = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId) ?? new List(); + var roles = await _personnelRolesService.GetRolesForDepartmentAsync(DepartmentId) ?? new List(); + var profiles = await _userProfileService.GetAllProfilesForDepartmentAsync(DepartmentId) ?? new Dictionary(); if (filter == 0 || filter == 1) { diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml index 6780e198f..e70ed99c8 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml @@ -40,7 +40,10 @@ window.Sentry.init({ dsn: '@Resgrid.Config.ExternalErrorConfig.ExternalErrorServiceUrlForWebsite', integrations: [window.Sentry.breadcrumbsIntegration({ console: false })], - tracesSampleRate: @Resgrid.Config.ExternalErrorConfig.SentryPerfSampleRate + tracesSampleRate: @Resgrid.Config.ExternalErrorConfig.SentryPerfSampleRate, + // Safari masks injected extension scripts as webkit-masked-url://; those + // frames are browser-extension code, not ours. + denyUrls: [/webkit-masked-url:\/\//i] }); } From 280fb22ddee1607652321a1d8bb15f1ed4990e18 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 11 Aug 2026 17:51:39 -0700 Subject: [PATCH 2/3] RG-T117 Unit to IC/Dispatch chat fix --- .../Repositories/IChatRepositories.cs | 3 ++ Core/Resgrid.Model/Services/IChatServices.cs | 3 ++ Core/Resgrid.Services/ChatChannelService.cs | 25 +++++++++++++++ Core/Resgrid.Services/ChatMessageService.cs | 5 ++- .../ChatRepositories.cs | 31 +++++++++++++++++++ .../Services/ChatChannelServiceTests.cs | 30 ++++++++++++++++++ .../Controllers/v4/ChatController.cs | 21 +++++++++++-- 7 files changed, 115 insertions(+), 3 deletions(-) diff --git a/Core/Resgrid.Model/Repositories/IChatRepositories.cs b/Core/Resgrid.Model/Repositories/IChatRepositories.cs index 63c1fde84..da19aac37 100644 --- a/Core/Resgrid.Model/Repositories/IChatRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IChatRepositories.cs @@ -91,6 +91,9 @@ public interface IChatChannelMemberRepository : IRepository /// Active (not removed) member rows for a set of channels in one query. Task> GetActiveByChannelIdsAsync(IEnumerable chatChannelIds); + /// Active (not removed) unit-participant memberships for a unit across the department. + Task> GetActiveByUnitIdAsync(int departmentId, int unitId); + /// /// Monotonic read/delivered pointer update: only advances when the supplied seq is higher than the /// stored one (single UPDATE ... WHERE seq < @seq). diff --git a/Core/Resgrid.Model/Services/IChatServices.cs b/Core/Resgrid.Model/Services/IChatServices.cs index e03a2eedd..cfeee86dd 100644 --- a/Core/Resgrid.Model/Services/IChatServices.cs +++ b/Core/Resgrid.Model/Services/IChatServices.cs @@ -59,6 +59,9 @@ public interface IChatChannelService /// Active member rows for a set of channels in one query — used to label DM channels with the counterpart's name. Task> GetActiveMembersForChannelsAsync(List chatChannelIds); + /// A unit's active (not-removed) memberships across the department — read pointers/preferences for unit-participant channels. + Task> GetActiveMembershipsForUnitAsync(int departmentId, int unitId); + /// A user's member row for a single channel (null if none); does not lazily create one. Task GetUserMembershipAsync(string chatChannelId, string userId); diff --git a/Core/Resgrid.Services/ChatChannelService.cs b/Core/Resgrid.Services/ChatChannelService.cs index d19767089..d7cf3ad5b 100644 --- a/Core/Resgrid.Services/ChatChannelService.cs +++ b/Core/Resgrid.Services/ChatChannelService.cs @@ -156,6 +156,25 @@ async Task> getChannels() results[channel.ChatChannelId] = channel; } + // Channels where the active unit is the participant (Dispatch/IC ↔ unit DMs, units + // invited to groups) — the unit's operator must see them without a personal member row. + if (activeUnitId.HasValue) + { + var unitMemberships = await _chatChannelMemberRepository.GetActiveByUnitIdAsync(departmentId, activeUnitId.Value); + var unitChannelIds = unitMemberships? + .Select(m => m.ChatChannelId) + .Distinct() + .Where(id => !results.ContainsKey(id)) + .ToList(); + if (unitChannelIds != null && unitChannelIds.Count > 0) + { + var channels = await _chatChannelRepository.GetByIdsAsync(unitChannelIds); + if (channels != null) + foreach (var channel in channels) + results[channel.ChatChannelId] = channel; + } + } + // Implicit-audience channels (custom rule-based + active incident channels): evaluate access // per channel; evaluations are cached by the permission service. if (allChannels != null) @@ -391,6 +410,12 @@ public async Task> GetActiveMembershipsForUserAsync(int return members?.ToList() ?? new List(); } + public async Task> GetActiveMembershipsForUnitAsync(int departmentId, int unitId) + { + var members = await _chatChannelMemberRepository.GetActiveByUnitIdAsync(departmentId, unitId); + return members?.ToList() ?? new List(); + } + public async Task> GetActiveMembersForChannelsAsync(List chatChannelIds) { if (chatChannelIds == null || chatChannelIds.Count == 0) diff --git a/Core/Resgrid.Services/ChatMessageService.cs b/Core/Resgrid.Services/ChatMessageService.cs index 2dbb82c17..6c4cbd7e5 100644 --- a/Core/Resgrid.Services/ChatMessageService.cs +++ b/Core/Resgrid.Services/ChatMessageService.cs @@ -693,8 +693,11 @@ private async Task ResolveSenderDisplayNameAsync(string senderUserId, in if (asUnitId.HasValue) { + // Multiple people can be logged in as the same unit — keep the individual visible + // ("Engine 6 (Alice Smith)") so dispatchers and IC know who is typing. var unit = await _unitsService.GetUnitByIdAsync(asUnitId.Value); - return unit?.Name ?? profileName ?? "Unit"; + var unitName = unit?.Name ?? "Unit"; + return string.IsNullOrWhiteSpace(profileName) ? unitName : $"{unitName} ({profileName})"; } if (asIncidentCommander) diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs index 9fa6c0d3a..550dd61ca 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs @@ -869,6 +869,37 @@ public async Task> GetActiveByUserIdAsync(int dep } } + public async Task> GetActiveByUnitIdAsync(int departmentId, int unitId) + { + try + { + var parameters = new DynamicParametersExtension(); + parameters.Add("DepartmentId", departmentId); + parameters.Add("UnitId", unitId); + var notation = _sqlConfiguration.ParameterNotation; + var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres + ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatchannelmembers WHERE departmentid = {notation}DepartmentId AND unitid = {notation}UnitId AND participanttype = 1 AND removedon IS NULL" + : $"SELECT * FROM {_sqlConfiguration.SchemaName}.[ChatChannelMembers] WHERE [DepartmentId] = {notation}DepartmentId AND [UnitId] = {notation}UnitId AND [ParticipantType] = 1 AND [RemovedOn] IS NULL"; + + var select = new Func>>(connection => + connection.QueryAsync(sql, parameters, _unitOfWork.Transaction)); + + if (_unitOfWork?.Connection == null) + { + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(); + return await select(connection); + } + + return await select(_unitOfWork.CreateOrGetConnection()); + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + public async Task> GetActiveByChannelIdsAsync(IEnumerable chatChannelIds) { try diff --git a/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs b/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs index f09cd0914..11bc3affa 100644 --- a/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -623,6 +624,35 @@ public async Task non_admin_should_only_get_their_own_group_channel() result.Should().ContainSingle(c => c.ChannelType == (int)ChatChannelType.GroupDefault).Which.GroupId.Should().Be(9); _departmentGroupsServiceMock.Verify(x => x.GetAllGroupsForDepartmentAsync(It.IsAny()), Times.Never); } + + [Test] + public async Task active_unit_should_see_channels_where_the_unit_is_the_member() + { + SetupDepartmentChannel(); + _chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "user-a")).ReturnsAsync(false); + + var unitDm = new ChatChannel { ChatChannelId = "dm-unit-7", DepartmentId = 1, ChannelType = (int)ChatChannelType.DirectMessage, DmKey = "u:dispatcher|unit:7" }; + _chatChannelMemberRepositoryMock.Setup(x => x.GetActiveByUnitIdAsync(1, 7)).ReturnsAsync(new List + { + new ChatChannelMember { ChatChannelMemberId = "m1", ChatChannelId = "dm-unit-7", DepartmentId = 1, ParticipantType = (int)ChatParticipantType.Unit, UnitId = 7 } + }); + _chatChannelRepositoryMock.Setup(x => x.GetByIdsAsync(It.Is>(ids => ids.Contains("dm-unit-7")))).ReturnsAsync(new List { unitDm }); + + var result = await _chatChannelService.GetChannelsForUserAsync(1, "user-a", 7); + + result.Should().Contain(c => c.ChatChannelId == "dm-unit-7"); + } + + [Test] + public async Task without_an_active_unit_no_unit_membership_lookup_happens() + { + SetupDepartmentChannel(); + _chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "user-a")).ReturnsAsync(false); + + await _chatChannelService.GetChannelsForUserAsync(1, "user-a", null); + + _chatChannelMemberRepositoryMock.Verify(x => x.GetActiveByUnitIdAsync(It.IsAny(), It.IsAny()), Times.Never); + } } [TestFixture] diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs index 8241b842a..e6d3a3069 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs @@ -122,6 +122,18 @@ public async Task> GetChannels(int? activeUn } } + // Acting as a unit: the unit's member rows carry the read pointer/preferences for channels + // where the unit (not the person) is the participant. Personal rows still win when both exist. + if (activeUnitId.HasValue) + { + var unitMemberRows = await _chatChannelService.GetActiveMembershipsForUnitAsync(DepartmentId, activeUnitId.Value); + foreach (var member in unitMemberRows) + { + if (!membersByChannel.ContainsKey(member.ChatChannelId)) + membersByChannel.Add(member.ChatChannelId, member); + } + } + if (channels != null && channels.Any()) { foreach (var channel in channels) @@ -130,7 +142,7 @@ public async Task> GetChannels(int? activeUn result.Data.Add(ConvertChannelResultData(channel, member)); } - await ResolveDirectMessageNamesAsync(result.Data); + await ResolveDirectMessageNamesAsync(result.Data, activeUnitId); result.PageSize = result.Data.Count; result.Status = ResponseHelper.Success; @@ -1837,7 +1849,9 @@ private async Task BuildGroupNameFromMembersAsync(List memberUse // DM channels have no stored Name; label each with the counterpart participant so multiple // DMs stay distinguishable in every client list. Unit counterparts already carry a // DisplayNameOverride stamped at creation; user counterparts resolve via profile lookup. - private async Task ResolveDirectMessageNamesAsync(List channels) + // activeUnitId identifies the viewer when they act as a unit, so the unit's own row is + // never chosen as the counterpart (a rig viewing its DM with dispatch must see the dispatcher). + private async Task ResolveDirectMessageNamesAsync(List channels, int? activeUnitId = null) { var dmChannels = channels?.Where(c => c != null && c.ChannelType == (int)ChatChannelType.DirectMessage && String.IsNullOrWhiteSpace(c.Name)).ToList(); if (dmChannels == null || dmChannels.Count == 0) @@ -1853,6 +1867,9 @@ private async Task ResolveDirectMessageNamesAsync(List ch if (member.UserId != null && String.Equals(member.UserId, UserId, StringComparison.OrdinalIgnoreCase)) continue; + if (activeUnitId.HasValue && member.UnitId.HasValue && member.UnitId.Value == activeUnitId.Value) + continue; + if (!counterparts.ContainsKey(member.ChatChannelId)) counterparts.Add(member.ChatChannelId, member); } From 7b2ae6abea45b5727413fd73b4a40c25343c7617 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 11 Aug 2026 19:20:44 -0700 Subject: [PATCH 3/3] RG-T117 PR#460 fixes --- Core/Resgrid.Services/ChatChannelService.cs | 4 +- .../Resgrid.Services/ChatPermissionService.cs | 9 ++- Core/Resgrid.Services/ChatPresenceService.cs | 65 +++++++++++++++++-- .../Services/ChatChannelServiceTests.cs | 14 ++++ .../Services/ChatPermissionServiceTests.cs | 51 +++++++++++++++ .../Services/ChatPresenceServiceTests.cs | 41 ++++++++++++ .../Controllers/v4/ChatController.cs | 5 ++ .../User/Apps/src/components/chat/chatHub.ts | 9 ++- 8 files changed, 187 insertions(+), 11 deletions(-) diff --git a/Core/Resgrid.Services/ChatChannelService.cs b/Core/Resgrid.Services/ChatChannelService.cs index d7cf3ad5b..587f331d6 100644 --- a/Core/Resgrid.Services/ChatChannelService.cs +++ b/Core/Resgrid.Services/ChatChannelService.cs @@ -158,7 +158,9 @@ async Task> getChannels() // Channels where the active unit is the participant (Dispatch/IC ↔ unit DMs, units // invited to groups) — the unit's operator must see them without a personal member row. - if (activeUnitId.HasValue) + // The caller-supplied unit only counts when the user actually crews it; otherwise any + // department member could list another unit's private channels. + if (activeUnitId.HasValue && await _chatPermissionService.CanSendAsUnitAsync(userId, activeUnitId.Value, departmentId)) { var unitMemberships = await _chatChannelMemberRepository.GetActiveByUnitIdAsync(departmentId, activeUnitId.Value); var unitChannelIds = unitMemberships? diff --git a/Core/Resgrid.Services/ChatPermissionService.cs b/Core/Resgrid.Services/ChatPermissionService.cs index ad40ae3ad..2613b9366 100644 --- a/Core/Resgrid.Services/ChatPermissionService.cs +++ b/Core/Resgrid.Services/ChatPermissionService.cs @@ -246,7 +246,14 @@ private async Task EvaluateAccessAsync(ChatChannel channel, string userId, case ChatChannelType.DirectMessage: case ChatChannelType.AdHocGroup: - return await HasActiveMembershipAsync(channel.ChatChannelId, userId, activeUnitId); + if (await HasActiveMembershipAsync(channel.ChatChannelId, userId, null)) + return true; + + // The unit's member row only grants access when the caller actually crews the claimed + // unit — a caller-supplied activeUnitId alone must not open another unit's channels. + return activeUnitId.HasValue + && await CanSendAsUnitAsync(userId, activeUnitId.Value, channel.DepartmentId) + && await HasActiveMembershipAsync(channel.ChatChannelId, userId, activeUnitId); case ChatChannelType.DepartmentDefault: return await _departmentsService.IsUserInDepartmentAsync(channel.DepartmentId, userId); diff --git a/Core/Resgrid.Services/ChatPresenceService.cs b/Core/Resgrid.Services/ChatPresenceService.cs index b9f63239d..345f25c8f 100644 --- a/Core/Resgrid.Services/ChatPresenceService.cs +++ b/Core/Resgrid.Services/ChatPresenceService.cs @@ -45,7 +45,7 @@ public async Task TouchAsync(int departmentId, string userId) var unitId = ParseUnitId(active); if (unitId.HasValue) - await _cacheProvider.SetStringAsync(GetUnitActiveKey(departmentId, unitId.Value), ParseChannelId(active), GetTtl()); + await ClaimUnitMarkerAsync(departmentId, unitId.Value, ParseChannelId(active), userId, refreshOnly: true); } } @@ -98,19 +98,19 @@ public async Task SetActiveChannelAsync(int departmentId, string userId, string { await _cacheProvider.RemoveAsync(activeKey); if (existingUnitId.HasValue) - await _cacheProvider.RemoveAsync(GetUnitActiveKey(departmentId, existingUnitId.Value)); + await RemoveUnitMarkerIfOwnedAsync(departmentId, existingUnitId.Value, userId); return; } // Acting unit changed (or dropped): clear the stale unit marker so the old rig isn't suppressed. if (existingUnitId.HasValue && existingUnitId != unitId) - await _cacheProvider.RemoveAsync(GetUnitActiveKey(departmentId, existingUnitId.Value)); + await RemoveUnitMarkerIfOwnedAsync(departmentId, existingUnitId.Value, userId); var value = unitId.HasValue ? $"{channelId}|{unitId.Value}" : channelId; await _cacheProvider.SetStringAsync(activeKey, value, GetTtl()); if (unitId.HasValue) - await _cacheProvider.SetStringAsync(GetUnitActiveKey(departmentId, unitId.Value), channelId, GetTtl()); + await ClaimUnitMarkerAsync(departmentId, unitId.Value, channelId, userId, refreshOnly: false); } public async Task ClearActiveChannelAsync(int departmentId, string userId) @@ -158,11 +158,64 @@ public async Task IsUnitActiveInChannelAsync(int departmentId, int unitId, if (unitId <= 0 || string.IsNullOrWhiteSpace(channelId)) return false; - var value = await _cacheProvider.GetStringAsync(GetUnitActiveKey(departmentId, unitId)); - return string.Equals(value, channelId, StringComparison.OrdinalIgnoreCase); + var marker = await _cacheProvider.GetStringAsync(GetUnitActiveKey(departmentId, unitId)); + var owner = ParseUnitMarkerOwner(marker); + + if (owner == null || !string.Equals(ParseChannelId(marker), channelId, StringComparison.OrdinalIgnoreCase)) + return false; + + // The marker is only a hint naming its owner; the owner's personal marker is authoritative. + // An orphaned marker (owner moved on, or clobbered by a raced write) must not suppress pushes. + var ownerActive = await _cacheProvider.GetStringAsync(GetActiveKey(departmentId, owner)); + return ParseUnitId(ownerActive) == unitId + && string.Equals(ParseChannelId(ownerActive), channelId, StringComparison.OrdinalIgnoreCase); + } + + // Several viewers can operate the same unit, but the unit mirror is a single shared key — so it + // records WHOSE activity it reflects ("channelId|ownerUserId") and is only refreshed or removed + // by that owner. ICacheProvider has no compare-and-set, so the owner checks are best-effort + // read-then-write; the short TTL and the owner cross-check in IsUnitActiveInChannelAsync bound + // the damage of a raced write to a few extra (never missing) pushes. + private async Task ClaimUnitMarkerAsync(int departmentId, int unitId, string channelId, string userId, bool refreshOnly) + { + var key = GetUnitActiveKey(departmentId, unitId); + + if (refreshOnly) + { + var owner = ParseUnitMarkerOwner(await _cacheProvider.GetStringAsync(key)); + if (owner != null && !string.Equals(owner, userId?.ToLowerInvariant(), StringComparison.OrdinalIgnoreCase)) + return; + } + + await _cacheProvider.SetStringAsync(key, $"{channelId}|{userId?.ToLowerInvariant()}", GetTtl()); + } + + private async Task RemoveUnitMarkerIfOwnedAsync(int departmentId, int unitId, string userId) + { + var key = GetUnitActiveKey(departmentId, unitId); + var owner = ParseUnitMarkerOwner(await _cacheProvider.GetStringAsync(key)); + + // Another viewer of the same unit claimed the marker since — their activity stands. + if (owner != null && !string.Equals(owner, userId?.ToLowerInvariant(), StringComparison.OrdinalIgnoreCase)) + return; + + await _cacheProvider.RemoveAsync(key); + } + + private static string ParseUnitMarkerOwner(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + var separator = value.IndexOf('|'); + if (separator < 0 || separator >= value.Length - 1) + return null; + + return value.Substring(separator + 1); } // Active markers store "channelId" or "channelId|unitId" when the viewer is acting as a unit. + // The unit mirror key stores "channelId|ownerUserId" (see ClaimUnitMarkerAsync). private static string ParseChannelId(string value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs b/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs index 11bc3affa..4a02c2e37 100644 --- a/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs @@ -630,6 +630,7 @@ public async Task active_unit_should_see_channels_where_the_unit_is_the_member() { SetupDepartmentChannel(); _chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "user-a")).ReturnsAsync(false); + _chatPermissionServiceMock.Setup(x => x.CanSendAsUnitAsync("user-a", 7, 1)).ReturnsAsync(true); var unitDm = new ChatChannel { ChatChannelId = "dm-unit-7", DepartmentId = 1, ChannelType = (int)ChatChannelType.DirectMessage, DmKey = "u:dispatcher|unit:7" }; _chatChannelMemberRepositoryMock.Setup(x => x.GetActiveByUnitIdAsync(1, 7)).ReturnsAsync(new List @@ -643,6 +644,19 @@ public async Task active_unit_should_see_channels_where_the_unit_is_the_member() result.Should().Contain(c => c.ChatChannelId == "dm-unit-7"); } + [Test] + public async Task active_unit_the_user_does_not_crew_should_not_expose_unit_channels() + { + SetupDepartmentChannel(); + _chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "user-a")).ReturnsAsync(false); + _chatPermissionServiceMock.Setup(x => x.CanSendAsUnitAsync("user-a", 7, 1)).ReturnsAsync(false); + + var result = await _chatChannelService.GetChannelsForUserAsync(1, "user-a", 7); + + result.Should().NotContain(c => c.ChatChannelId == "dm-unit-7"); + _chatChannelMemberRepositoryMock.Verify(x => x.GetActiveByUnitIdAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Test] public async Task without_an_active_unit_no_unit_membership_lookup_happens() { diff --git a/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs b/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs index 93b57f30f..c8ac8fda0 100644 --- a/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs @@ -181,6 +181,57 @@ public async Task dm_non_member_should_not_have_access() result.Should().BeFalse(); } + [Test] + public async Task dm_unit_member_should_have_access_when_the_user_crews_the_unit() + { + var channel = CreateChannel(ChatChannelType.DirectMessage); + _chatChannelMemberRepositoryMock.Setup(x => x.GetUserMemberAsync(channel.ChatChannelId, TestData.Users.TestUser1Id)).ReturnsAsync((ChatChannelMember)null); + _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 + }); + _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 = TestData.Users.TestUser1Id } + }); + + var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, 7); + + result.Should().BeTrue(); + } + + [Test] + public async Task dm_unit_member_should_not_grant_access_when_the_user_does_not_crew_the_unit() + { + var channel = CreateChannel(ChatChannelType.DirectMessage); + _chatChannelMemberRepositoryMock.Setup(x => x.GetUserMemberAsync(channel.ChatChannelId, TestData.Users.TestUser1Id)).ReturnsAsync((ChatChannelMember)null); + _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 + }); + // User claims unit 7 as their active unit, but crews nothing. + _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 = TestData.Users.TestUser2Id } + }); + + var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, 7); + + result.Should().BeFalse(); + } + [Test] public async Task department_default_department_member_should_have_access() { diff --git a/Tests/Resgrid.Tests/Services/ChatPresenceServiceTests.cs b/Tests/Resgrid.Tests/Services/ChatPresenceServiceTests.cs index 53087d8f4..134789e41 100644 --- a/Tests/Resgrid.Tests/Services/ChatPresenceServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatPresenceServiceTests.cs @@ -140,6 +140,47 @@ public async Task heartbeat_touch_keeps_the_active_markers_refreshed() active.Should().ContainSingle(); _cacheProviderMock.Verify(x => x.SetStringAsync("chatactive:1:user-a", It.IsAny(), It.IsAny()), Times.AtLeast(2)); } + + [Test] + public async Task another_viewers_clear_does_not_remove_the_current_owners_unit_marker() + { + await _chatPresenceService.SetActiveChannelAsync(1, "user-a", "chan-1", 7); + await _chatPresenceService.SetActiveChannelAsync(1, "user-b", "chan-1", 7); + + // user-a leaves; user-b now owns the marker and is still viewing. + await _chatPresenceService.ClearActiveChannelAsync(1, "user-a"); + + (await _chatPresenceService.IsUnitActiveInChannelAsync(1, 7, "chan-1")).Should().BeTrue(); + } + + [Test] + public async Task heartbeat_does_not_reclaim_a_unit_marker_owned_by_another_viewer() + { + await _chatPresenceService.SetActiveChannelAsync(1, "user-a", "chan-1", 7); + await _chatPresenceService.SetActiveChannelAsync(1, "user-b", "chan-2", 7); + + // user-a's heartbeat must not flip the marker back to chan-1 over user-b's claim. + await _chatPresenceService.TouchAsync(1, "user-a"); + + (await _chatPresenceService.IsUnitActiveInChannelAsync(1, 7, "chan-2")).Should().BeTrue(); + (await _chatPresenceService.IsUnitActiveInChannelAsync(1, 7, "chan-1")).Should().BeFalse(); + } + + [Test] + public async Task orphaned_unit_marker_whose_owner_moved_on_does_not_suppress() + { + _cache["chatactiveunit:1:7"] = "chan-1|user-a"; + + (await _chatPresenceService.IsUnitActiveInChannelAsync(1, 7, "chan-1")).Should().BeFalse(); + } + + [Test] + public async Task legacy_unowned_unit_marker_does_not_suppress() + { + _cache["chatactiveunit:1:7"] = "chan-1"; + + (await _chatPresenceService.IsUnitActiveInChannelAsync(1, 7, "chan-1")).Should().BeFalse(); + } } } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs index e6d3a3069..9f9a6237c 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs @@ -105,6 +105,11 @@ public async Task> GetChannels(int? activeUn if (!await ChatEnabledAsync()) return NotFound(); + // The active unit is caller-supplied; only honor it when the user actually crews that unit, + // otherwise treat the request as personal so unit channels and member rows can't be probed. + if (activeUnitId.HasValue && !await _chatPermissionService.CanSendAsUnitAsync(UserId, activeUnitId.Value, DepartmentId)) + activeUnitId = null; + var result = new GetChatChannelsResult(); var channels = await _chatChannelService.GetChannelsForUserAsync(DepartmentId, UserId, activeUnitId, includeArchived); diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatHub.ts b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatHub.ts index e006a0306..39353b7cb 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatHub.ts +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatHub.ts @@ -370,18 +370,21 @@ class ChatHub { } // Tells the server which conversation is on screen so pushes for it are suppressed while - // everything else still alerts. Remembered locally and re-reported after reconnects. + // everything else still alerts. Remembered locally (with the acting unit, so unit push + // suppression tracks the viewer too) and re-reported after reconnects. private activeChannelReported: string | null = null; + private activeChannelUnitId: number | null = null; - public setActiveChannel(channelId: string | null): void { + public setActiveChannel(channelId: string | null, asUnitId?: number): void { this.activeChannelReported = channelId; + this.activeChannelUnitId = channelId ? asUnitId ?? null : null; this.reportActiveChannel(); } private reportActiveChannel(): void { if (this.connection && this.connection.state === HubConnectionState.Connected) { this.connection - .invoke(CHAT_HUB_METHODS.SetActiveChannel, this.activeChannelReported, null) + .invoke(CHAT_HUB_METHODS.SetActiveChannel, this.activeChannelReported, this.activeChannelUnitId) .catch(() => undefined); } }