Skip to content

Commit 87f3559

Browse files
authored
Merge pull request #461 from Resgrid/develop
RC-T40 Chat Fixes
2 parents 5c8fd36 + 6ee2cef commit 87f3559

12 files changed

Lines changed: 1228 additions & 637 deletions

File tree

Core/Resgrid.Model/Chat/ChatChannel.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,9 @@ public class ChatChannel : IEntity, IChangeTracked
5858
public string OwnerUserId { get; set; }
5959

6060
/// <summary>
61-
/// Normalized participant identity key for DM dedup, unique per department when set.
62-
/// Sorted, e.g. "u:{idA}|u:{idB}" or "u:{userId}|unit:{unitId}".
61+
/// Normalized identity key for one-channel-per-identity dedup, unique per department when set.
62+
/// DMs use the sorted participant pair ("u:{idA}|u:{idB}", "u:{userId}|unit:{unitId}");
63+
/// UnitDispatch channels use "unitdispatch:{unitId}".
6364
/// </summary>
6465
[ProtoMember(13)]
6566
public string DmKey { get; set; }

Core/Resgrid.Model/Chat/ChatEnums.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,16 @@ public enum ChatChannelType
2626
/// tell which incident is talking to them, and audience-wide on the dispatch side so whichever
2727
/// dispatcher is on shift picks it up.
2828
/// </summary>
29-
IncidentDispatch = 10
29+
IncidentDispatch = 10,
30+
31+
/// <summary>
32+
/// A unit's standing line to the dispatch desk: the unit-shared identity ("Engine 6") on one side,
33+
/// every dispatch-authorized user on the other. Department-wide and permanent, unlike
34+
/// <see cref="IncidentDispatch"/> which is scoped to one call — this is where a unit reaches
35+
/// dispatch when there is no incident to anchor the conversation. One per unit, provisioned the
36+
/// first time the unit's operator lists channels.
37+
/// </summary>
38+
UnitDispatch = 11
3039
}
3140

3241
/// <summary>Who a chat participant is: a person, a unit-shared identity ("Engine 6"), or the chatbot.</summary>

Core/Resgrid.Model/Services/IChatServices.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,14 @@ public interface IChatChannelService
115115
/// <summary>Ensures the incident's line to the dispatch desk.</summary>
116116
Task<ChatChannel> EnsureDispatchChannelAsync(int departmentId, int callId, string incidentCommandId, CancellationToken cancellationToken = default(CancellationToken));
117117

118+
/// <summary>
119+
/// Ensures the unit's standing line to the dispatch desk: the unit-shared identity plus every
120+
/// dispatch-authorized user. Department-wide and permanent (not call-scoped). The unit is stamped
121+
/// as an explicit member row so its operators see the channel through the unit-membership pass;
122+
/// the dispatch side is an implicit audience. Also refreshes the channel name if the unit was renamed.
123+
/// </summary>
124+
Task<ChatChannel> EnsureUnitDispatchChannelAsync(int departmentId, int unitId, CancellationToken cancellationToken = default(CancellationToken));
125+
118126
/// <summary>
119127
/// Backfills every chat channel an ACTIVE incident should have — the call's incident channel, the
120128
/// command and "All Leads" channels, and one per live lane — inserting only what is missing.

Core/Resgrid.Services/ChatChannelService.cs

Lines changed: 258 additions & 68 deletions
Large diffs are not rendered by default.

Core/Resgrid.Services/ChatPermissionService.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,13 @@ public async Task<List<string>> ResolveChannelAudienceUserIdsAsync(ChatChannel c
218218
AddIfSet(userIds, dispatcherId);
219219
break;
220220

221+
case ChatChannelType.UnitDispatch:
222+
// The unit's member row resolves to its active crew; the desk side is every dispatcher.
223+
await AddExplicitMemberAudienceAsync(channel, userIds);
224+
foreach (var dispatcherId in await _dispatchAccessService.GetDispatchUserIdsAsync(channel.DepartmentId))
225+
AddIfSet(userIds, dispatcherId);
226+
break;
227+
221228
default: // DirectMessage, AdHocGroup
222229
await AddExplicitMemberAudienceAsync(channel, userIds);
223230
break;
@@ -319,6 +326,21 @@ private async Task<bool> EvaluateAccessAsync(ChatChannel channel, string userId,
319326

320327
return await IsInIncidentAudienceAsync(channel, userId, activeUnitId);
321328

329+
case ChatChannelType.UnitDispatch:
330+
{
331+
// Same stance as IncidentDispatch: dispatch authorization, not admin standing, opens
332+
// dispatch traffic.
333+
if (await _dispatchAccessService.CanUseDispatchAsync(channel.DepartmentId, userId))
334+
return true;
335+
336+
// The unit side is proven against the channel's OWN unit, never the caller-supplied
337+
// activeUnitId or a leftover user member row (lazy read-pointer rows outlive access) —
338+
// crewing some other unit must not open this unit's dispatch line.
339+
var owningUnitId = await GetUnitDispatchChannelUnitIdAsync(channel);
340+
return owningUnitId.HasValue
341+
&& await CanSendAsUnitAsync(userId, owningUnitId.Value, channel.DepartmentId);
342+
}
343+
322344
default:
323345
return false;
324346
}
@@ -363,6 +385,22 @@ private async Task<bool> EvaluateModerateAsync(ChatChannel channel, string userI
363385
}
364386
}
365387

388+
/// <summary>
389+
/// The unit a UnitDispatch channel belongs to: parsed from its "unitdispatch:{unitId}" DmKey,
390+
/// falling back to the channel's unit member row.
391+
/// </summary>
392+
private async Task<int?> GetUnitDispatchChannelUnitIdAsync(ChatChannel channel)
393+
{
394+
const string keyPrefix = "unitdispatch:";
395+
if (!string.IsNullOrWhiteSpace(channel.DmKey)
396+
&& channel.DmKey.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase)
397+
&& int.TryParse(channel.DmKey.Substring(keyPrefix.Length), out var unitId))
398+
return unitId;
399+
400+
var members = await _chatChannelMemberRepository.GetByChannelIdAsync(channel.ChatChannelId);
401+
return members?.FirstOrDefault(m => m.ParticipantType == (int)ChatParticipantType.Unit && !m.RemovedOn.HasValue && m.UnitId.HasValue)?.UnitId;
402+
}
403+
366404
private async Task<bool> HasActiveMembershipAsync(string chatChannelId, string userId, int? activeUnitId)
367405
{
368406
var member = await _chatChannelMemberRepository.GetUserMemberAsync(chatChannelId, userId);

Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public class with_the_chat_channel_service : TestBase
3030
protected Mock<IDepartmentGroupsService> _departmentGroupsServiceMock;
3131
protected Mock<IUnitsService> _unitsServiceMock;
3232
protected Mock<IUserProfileService> _userProfileServiceMock;
33+
protected Mock<ICallsService> _callsServiceMock;
3334
protected Mock<IEventAggregator> _eventAggregatorMock;
3435
protected Mock<ICacheProvider> _cacheProviderMock;
3536
protected Mock<IUnitOfWork> _unitOfWorkMock;
@@ -57,6 +58,7 @@ private void BuildService()
5758
_departmentGroupsServiceMock = new Mock<IDepartmentGroupsService>();
5859
_unitsServiceMock = new Mock<IUnitsService>();
5960
_userProfileServiceMock = new Mock<IUserProfileService>();
61+
_callsServiceMock = new Mock<ICallsService>();
6062
_eventAggregatorMock = new Mock<IEventAggregator>();
6163
_cacheProviderMock = new Mock<ICacheProvider>();
6264
_unitOfWorkMock = new Mock<IUnitOfWork>();
@@ -93,6 +95,7 @@ private void BuildService()
9395
_departmentGroupsServiceMock.Object,
9496
_unitsServiceMock.Object,
9597
_userProfileServiceMock.Object,
98+
_callsServiceMock.Object,
9699
_eventAggregatorMock.Object,
97100
_cacheProviderMock.Object,
98101
_unitOfWorkMock.Object);
@@ -667,6 +670,134 @@ public async Task without_an_active_unit_no_unit_membership_lookup_happens()
667670

668671
_chatChannelMemberRepositoryMock.Verify(x => x.GetActiveByUnitIdAsync(It.IsAny<int>(), It.IsAny<int>()), Times.Never);
669672
}
673+
674+
[Test]
675+
public async Task an_active_unit_should_get_its_dispatch_line_provisioned_into_the_list()
676+
{
677+
SetupDepartmentChannel();
678+
_chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "user-a")).ReturnsAsync(false);
679+
_chatPermissionServiceMock.Setup(x => x.CanSendAsUnitAsync("user-a", 7, 1)).ReturnsAsync(true);
680+
_unitsServiceMock.Setup(x => x.GetUnitByIdAsync(7)).ReturnsAsync(new Unit { UnitId = 7, DepartmentId = 1, Name = "Engine 6" });
681+
_chatChannelRepositoryMock.Setup(x => x.GetByDmKeyAsync(1, "unitdispatch:7")).ReturnsAsync((ChatChannel)null);
682+
683+
var result = await _chatChannelService.GetChannelsForUserAsync(1, "user-a", 7);
684+
685+
result.Should().Contain(c => c.ChannelType == (int)ChatChannelType.UnitDispatch && c.Name == "Engine 6 Dispatch");
686+
}
687+
688+
[Test]
689+
public async Task a_unit_dispatch_provisioning_failure_should_not_abort_the_channel_list()
690+
{
691+
SetupDepartmentChannel();
692+
_chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "user-a")).ReturnsAsync(false);
693+
_chatPermissionServiceMock.Setup(x => x.CanSendAsUnitAsync("user-a", 7, 1)).ReturnsAsync(true);
694+
_unitsServiceMock.Setup(x => x.GetUnitByIdAsync(7)).ThrowsAsync(new InvalidOperationException("units down"));
695+
696+
var result = await _chatChannelService.GetChannelsForUserAsync(1, "user-a", 7);
697+
698+
result.Should().Contain(c => c.ChannelType == (int)ChatChannelType.DepartmentDefault);
699+
}
700+
701+
[Test]
702+
public async Task incident_leads_and_dispatch_channels_should_be_listed_for_users_with_access()
703+
{
704+
SetupDepartmentChannel();
705+
_chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "user-a")).ReturnsAsync(false);
706+
707+
var leads = new ChatChannel { ChatChannelId = "leads-1", DepartmentId = 1, ChannelType = (int)ChatChannelType.IncidentLeads, CallId = 42, Name = "Barn Fire All Leads" };
708+
var dispatch = new ChatChannel { ChatChannelId = "dispatch-1", DepartmentId = 1, ChannelType = (int)ChatChannelType.IncidentDispatch, CallId = 42, Name = "Barn Fire Dispatch" };
709+
var unitDispatch = new ChatChannel { ChatChannelId = "unit-dispatch-7", DepartmentId = 1, ChannelType = (int)ChatChannelType.UnitDispatch, DmKey = "unitdispatch:7", Name = "Engine 6 Dispatch" };
710+
_chatChannelRepositoryMock.Setup(x => x.GetAllByDepartmentIdAsync(1, false)).ReturnsAsync(new List<ChatChannel> { leads, dispatch, unitDispatch });
711+
712+
_chatPermissionServiceMock.Setup(x => x.CanAccessChannelAsync(leads, "user-a", null)).ReturnsAsync(true);
713+
_chatPermissionServiceMock.Setup(x => x.CanAccessChannelAsync(dispatch, "user-a", null)).ReturnsAsync(true);
714+
_chatPermissionServiceMock.Setup(x => x.CanAccessChannelAsync(unitDispatch, "user-a", null)).ReturnsAsync(true);
715+
716+
var result = await _chatChannelService.GetChannelsForUserAsync(1, "user-a", null);
717+
718+
result.Should().Contain(c => c.ChatChannelId == "leads-1");
719+
result.Should().Contain(c => c.ChatChannelId == "dispatch-1");
720+
result.Should().Contain(c => c.ChatChannelId == "unit-dispatch-7");
721+
}
722+
723+
[Test]
724+
public async Task incident_leads_and_dispatch_channels_should_stay_hidden_without_access()
725+
{
726+
SetupDepartmentChannel();
727+
_chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "user-a")).ReturnsAsync(false);
728+
729+
var leads = new ChatChannel { ChatChannelId = "leads-1", DepartmentId = 1, ChannelType = (int)ChatChannelType.IncidentLeads, CallId = 42 };
730+
var unitDispatch = new ChatChannel { ChatChannelId = "unit-dispatch-7", DepartmentId = 1, ChannelType = (int)ChatChannelType.UnitDispatch, DmKey = "unitdispatch:7" };
731+
_chatChannelRepositoryMock.Setup(x => x.GetAllByDepartmentIdAsync(1, false)).ReturnsAsync(new List<ChatChannel> { leads, unitDispatch });
732+
733+
var result = await _chatChannelService.GetChannelsForUserAsync(1, "user-a", null);
734+
735+
result.Should().NotContain(c => c.ChatChannelId == "leads-1");
736+
result.Should().NotContain(c => c.ChatChannelId == "unit-dispatch-7");
737+
}
738+
}
739+
740+
[TestFixture]
741+
public class when_ensuring_unit_dispatch_channels : with_the_chat_channel_service
742+
{
743+
[Test]
744+
public async Task a_missing_channel_should_be_created_with_the_unit_as_the_member()
745+
{
746+
_unitsServiceMock.Setup(x => x.GetUnitByIdAsync(7)).ReturnsAsync(new Unit { UnitId = 7, DepartmentId = 1, Name = "Engine 6" });
747+
_chatChannelRepositoryMock.Setup(x => x.GetByDmKeyAsync(1, "unitdispatch:7")).ReturnsAsync((ChatChannel)null);
748+
749+
List<ChatChannelMember> capturedMembers = null;
750+
_chatChannelRepositoryMock
751+
.Setup(x => x.CreateDirectMessageChannelAsync(It.IsAny<ChatChannel>(), It.IsAny<IEnumerable<ChatChannelMember>>(), It.IsAny<CancellationToken>()))
752+
.Callback((ChatChannel c, IEnumerable<ChatChannelMember> m, CancellationToken t) => capturedMembers = m.ToList())
753+
.ReturnsAsync((ChatChannel c, IEnumerable<ChatChannelMember> m, CancellationToken t) => c);
754+
755+
var result = await _chatChannelService.EnsureUnitDispatchChannelAsync(1, 7);
756+
757+
result.Should().NotBeNull();
758+
result.ChannelType.Should().Be((int)ChatChannelType.UnitDispatch);
759+
result.Name.Should().Be("Engine 6 Dispatch");
760+
result.DmKey.Should().Be("unitdispatch:7");
761+
capturedMembers.Should().ContainSingle(m => m.ParticipantType == (int)ChatParticipantType.Unit && m.UnitId == 7);
762+
}
763+
764+
[Test]
765+
public async Task an_existing_channel_should_be_returned_without_creating_another()
766+
{
767+
var existing = new ChatChannel { ChatChannelId = "ud-7", DepartmentId = 1, ChannelType = (int)ChatChannelType.UnitDispatch, DmKey = "unitdispatch:7", Name = "Engine 6 Dispatch" };
768+
_unitsServiceMock.Setup(x => x.GetUnitByIdAsync(7)).ReturnsAsync(new Unit { UnitId = 7, DepartmentId = 1, Name = "Engine 6" });
769+
_chatChannelRepositoryMock.Setup(x => x.GetByDmKeyAsync(1, "unitdispatch:7")).ReturnsAsync(existing);
770+
771+
var result = await _chatChannelService.EnsureUnitDispatchChannelAsync(1, 7);
772+
773+
result.Should().BeSameAs(existing);
774+
_chatChannelRepositoryMock.Verify(x => x.CreateDirectMessageChannelAsync(It.IsAny<ChatChannel>(), It.IsAny<IEnumerable<ChatChannelMember>>(), It.IsAny<CancellationToken>()), Times.Never);
775+
_chatChannelRepositoryMock.Verify(x => x.UpdateChannelInfoAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), Times.Never);
776+
}
777+
778+
[Test]
779+
public async Task a_renamed_unit_should_refresh_the_channel_name()
780+
{
781+
var existing = new ChatChannel { ChatChannelId = "ud-7", DepartmentId = 1, ChannelType = (int)ChatChannelType.UnitDispatch, DmKey = "unitdispatch:7", Name = "Engine 6 Dispatch" };
782+
_unitsServiceMock.Setup(x => x.GetUnitByIdAsync(7)).ReturnsAsync(new Unit { UnitId = 7, DepartmentId = 1, Name = "Rescue 1" });
783+
_chatChannelRepositoryMock.Setup(x => x.GetByDmKeyAsync(1, "unitdispatch:7")).ReturnsAsync(existing);
784+
785+
var result = await _chatChannelService.EnsureUnitDispatchChannelAsync(1, 7);
786+
787+
result.Name.Should().Be("Rescue 1 Dispatch");
788+
_chatChannelRepositoryMock.Verify(x => x.UpdateChannelInfoAsync("ud-7", "Rescue 1 Dispatch", It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), Times.Once);
789+
}
790+
791+
[Test]
792+
public async Task a_unit_from_another_department_should_not_get_a_channel()
793+
{
794+
_unitsServiceMock.Setup(x => x.GetUnitByIdAsync(7)).ReturnsAsync(new Unit { UnitId = 7, DepartmentId = 2, Name = "Engine 6" });
795+
796+
var result = await _chatChannelService.EnsureUnitDispatchChannelAsync(1, 7);
797+
798+
result.Should().BeNull();
799+
_chatChannelRepositoryMock.Verify(x => x.CreateDirectMessageChannelAsync(It.IsAny<ChatChannel>(), It.IsAny<IEnumerable<ChatChannelMember>>(), It.IsAny<CancellationToken>()), Times.Never);
800+
}
670801
}
671802

672803
[TestFixture]

0 commit comments

Comments
 (0)