Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Core/Resgrid.Model/Chat/ChatChannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ public class ChatChannel : IEntity, IChangeTracked
public string OwnerUserId { get; set; }

/// <summary>
/// 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}".
/// </summary>
[ProtoMember(13)]
public string DmKey { get; set; }
Expand Down
11 changes: 10 additions & 1 deletion Core/Resgrid.Model/Chat/ChatEnums.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
IncidentDispatch = 10
IncidentDispatch = 10,

/// <summary>
/// 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
/// <see cref="IncidentDispatch"/> 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.
/// </summary>
UnitDispatch = 11
}

/// <summary>Who a chat participant is: a person, a unit-shared identity ("Engine 6"), or the chatbot.</summary>
Expand Down
8 changes: 8 additions & 0 deletions Core/Resgrid.Model/Services/IChatServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ public interface IChatChannelService
/// <summary>Ensures the incident's line to the dispatch desk.</summary>
Task<ChatChannel> EnsureDispatchChannelAsync(int departmentId, int callId, string incidentCommandId, CancellationToken cancellationToken = default(CancellationToken));

/// <summary>
/// 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.
/// </summary>
Task<ChatChannel> EnsureUnitDispatchChannelAsync(int departmentId, int unitId, CancellationToken cancellationToken = default(CancellationToken));

/// <summary>
/// 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.
Expand Down
326 changes: 258 additions & 68 deletions Core/Resgrid.Services/ChatChannelService.cs

Large diffs are not rendered by default.

38 changes: 38 additions & 0 deletions Core/Resgrid.Services/ChatPermissionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,13 @@ public async Task<List<string>> 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;
Expand Down Expand Up @@ -319,6 +326,21 @@ private async Task<bool> 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);
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
default:
return false;
}
Expand Down Expand Up @@ -363,6 +385,22 @@ private async Task<bool> EvaluateModerateAsync(ChatChannel channel, string userI
}
}

/// <summary>
/// The unit a UnitDispatch channel belongs to: parsed from its "unitdispatch:{unitId}" DmKey,
/// falling back to the channel's unit member row.
/// </summary>
private async Task<int?> 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<bool> HasActiveMembershipAsync(string chatChannelId, string userId, int? activeUnitId)
{
var member = await _chatChannelMemberRepository.GetUserMemberAsync(chatChannelId, userId);
Expand Down
131 changes: 131 additions & 0 deletions Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public class with_the_chat_channel_service : TestBase
protected Mock<IDepartmentGroupsService> _departmentGroupsServiceMock;
protected Mock<IUnitsService> _unitsServiceMock;
protected Mock<IUserProfileService> _userProfileServiceMock;
protected Mock<ICallsService> _callsServiceMock;
protected Mock<IEventAggregator> _eventAggregatorMock;
protected Mock<ICacheProvider> _cacheProviderMock;
protected Mock<IUnitOfWork> _unitOfWorkMock;
Expand Down Expand Up @@ -57,6 +58,7 @@ private void BuildService()
_departmentGroupsServiceMock = new Mock<IDepartmentGroupsService>();
_unitsServiceMock = new Mock<IUnitsService>();
_userProfileServiceMock = new Mock<IUserProfileService>();
_callsServiceMock = new Mock<ICallsService>();
_eventAggregatorMock = new Mock<IEventAggregator>();
_cacheProviderMock = new Mock<ICacheProvider>();
_unitOfWorkMock = new Mock<IUnitOfWork>();
Expand Down Expand Up @@ -93,6 +95,7 @@ private void BuildService()
_departmentGroupsServiceMock.Object,
_unitsServiceMock.Object,
_userProfileServiceMock.Object,
_callsServiceMock.Object,
_eventAggregatorMock.Object,
_cacheProviderMock.Object,
_unitOfWorkMock.Object);
Expand Down Expand Up @@ -667,6 +670,134 @@ public async Task without_an_active_unit_no_unit_membership_lookup_happens()

_chatChannelMemberRepositoryMock.Verify(x => x.GetActiveByUnitIdAsync(It.IsAny<int>(), It.IsAny<int>()), 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<ChatChannel> { 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<ChatChannel> { 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<ChatChannelMember> capturedMembers = null;
_chatChannelRepositoryMock
.Setup(x => x.CreateDirectMessageChannelAsync(It.IsAny<ChatChannel>(), It.IsAny<IEnumerable<ChatChannelMember>>(), It.IsAny<CancellationToken>()))
.Callback((ChatChannel c, IEnumerable<ChatChannelMember> m, CancellationToken t) => capturedMembers = m.ToList())
.ReturnsAsync((ChatChannel c, IEnumerable<ChatChannelMember> 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<ChatChannel>(), It.IsAny<IEnumerable<ChatChannelMember>>(), It.IsAny<CancellationToken>()), Times.Never);
_chatChannelRepositoryMock.Verify(x => x.UpdateChannelInfoAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), 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<string>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), 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<ChatChannel>(), It.IsAny<IEnumerable<ChatChannelMember>>(), It.IsAny<CancellationToken>()), Times.Never);
}
}

[TestFixture]
Expand Down
Loading
Loading