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
6 changes: 6 additions & 0 deletions Core/Resgrid.Model/Repositories/IChatRepositories.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ public interface IChatChannelMemberRepository : IRepository<ChatChannelMember>
/// <summary>Active (not removed) explicit memberships for a user across the department.</summary>
Task<IEnumerable<ChatChannelMember>> GetActiveByUserIdAsync(int departmentId, string userId);

/// <summary>Active (not removed) member rows for a set of channels in one query.</summary>
Task<IEnumerable<ChatChannelMember>> GetActiveByChannelIdsAsync(IEnumerable<string> chatChannelIds);

/// <summary>Active (not removed) unit-participant memberships for a unit across the department.</summary>
Task<IEnumerable<ChatChannelMember>> GetActiveByUnitIdAsync(int departmentId, int unitId);

/// <summary>
/// Monotonic read/delivered pointer update: only advances when the supplied seq is higher than the
/// stored one (single UPDATE ... WHERE seq &lt; @seq).
Expand Down
21 changes: 21 additions & 0 deletions Core/Resgrid.Model/Services/IChatServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ public interface IChatChannelService
/// <summary>A user's active (not-removed) explicit memberships across the department — used for unread/preference lookups.</summary>
Task<List<ChatChannelMember>> GetActiveMembershipsForUserAsync(int departmentId, string userId);

/// <summary>Active member rows for a set of channels in one query — used to label DM channels with the counterpart's name.</summary>
Task<List<ChatChannelMember>> GetActiveMembersForChannelsAsync(List<string> chatChannelIds);

/// <summary>A unit's active (not-removed) memberships across the department — read pointers/preferences for unit-participant channels.</summary>
Task<List<ChatChannelMember>> GetActiveMembershipsForUnitAsync(int departmentId, int unitId);

/// <summary>A user's member row for a single channel (null if none); does not lazily create one.</summary>
Task<ChatChannelMember> GetUserMembershipAsync(string chatChannelId, string userId);

Expand Down Expand Up @@ -274,6 +280,21 @@ public interface IChatPresenceService

/// <summary>Bulk presence lookup; returns the subset of userIds currently online.</summary>
Task<List<string>> GetOnlineUsersAsync(int departmentId, List<string> userIds);

/// <summary>
/// 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.
/// </summary>
Task SetActiveChannelAsync(int departmentId, string userId, string channelId, int? unitId = null);

/// <summary>Clears the user's (and their acting unit's) active-channel marker.</summary>
Task ClearActiveChannelAsync(int departmentId, string userId);

/// <summary>Bulk lookup: the subset of userIds actively viewing the given channel right now.</summary>
Task<List<string>> GetUsersActiveInChannelAsync(int departmentId, List<string> userIds, string channelId);

/// <summary>True when the unit's device currently has the given channel open.</summary>
Task<bool> IsUnitActiveInChannelAsync(int departmentId, int unitId, string channelId);
}

/// <summary>
Expand Down
43 changes: 43 additions & 0 deletions Core/Resgrid.Services/ChatChannelService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,27 @@ async Task<List<ChatChannel>> 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.
// 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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unhandled exception in await _chatPermissionService.CanSendAsUnitAsync(...) propagates raw when the permission service throws, causing the channel list fetch to fail without context or safe fallback (also at Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs:110). Wrap the call in try/catch, log structured context (userId, activeUnitId, departmentId), and default to denying access on failure.

Kody rule violation: Handle async operations with proper error handling

Prompt for LLM

File Core/Resgrid.Services/ChatChannelService.cs:

Line 163:

Unhandled exception in `await _chatPermissionService.CanSendAsUnitAsync(...)` propagates raw when the permission service throws, causing the channel list fetch to fail without context or safe fallback (also at `Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs:110`). Wrap the call in try/catch, log structured context (`userId`, `activeUnitId`, `departmentId`), and default to denying access on failure.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

{
var unitMemberships = await _chatChannelMemberRepository.GetActiveByUnitIdAsync(departmentId, activeUnitId.Value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unhandled repository call: GetActiveByUnitIdAsync can throw transient or permanent errors that should be caught, augmented with context (departmentId, activeUnitId), and mapped to application-level errors. Wrap the call in try/catch, distinguish transient vs non-transient errors, log the operation name and identifiers, and apply retry or fallback as appropriate.

Kody rule violation: Add try-catch blocks for external calls

Prompt for LLM

File Core/Resgrid.Services/ChatChannelService.cs:

Line 163:

Unhandled repository call: GetActiveByUnitIdAsync can throw transient or permanent errors that should be caught, augmented with context (departmentId, activeUnitId), and mapped to application-level errors. Wrap the call in try/catch, distinguish transient vs non-transient errors, log the operation name and identifiers, and apply retry or fallback as appropriate.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

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;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Implicit-audience channels (custom rule-based + active incident channels): evaluate access
// per channel; evaluations are cached by the permission service.
if (allChannels != null)
Expand Down Expand Up @@ -254,7 +275,12 @@ async Task<List<ChatChannel>> 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;
}
Expand Down Expand Up @@ -291,6 +317,7 @@ async Task<List<ChatChannel>> getChannels()
await AddMemberRowAsync(channel, ChatParticipantType.User, memberId, null, null, creatorUserId, cancellationToken);
}

await _chatPermissionService.InvalidateChannelCacheAsync(channel.ChatChannelId);
PublishChannelEvent(channel, ChatEventKinds.ChannelProvisioned);

return channel;
Expand Down Expand Up @@ -327,6 +354,7 @@ async Task<List<ChatChannel>> getChannels()
}
}

await _chatPermissionService.InvalidateChannelCacheAsync(channel.ChatChannelId);
PublishChannelEvent(channel, ChatEventKinds.ChannelProvisioned);

return channel;
Expand Down Expand Up @@ -384,6 +412,21 @@ public async Task<List<ChatChannelMember>> GetActiveMembershipsForUserAsync(int
return members?.ToList() ?? new List<ChatChannelMember>();
}

public async Task<List<ChatChannelMember>> GetActiveMembershipsForUnitAsync(int departmentId, int unitId)
{
var members = await _chatChannelMemberRepository.GetActiveByUnitIdAsync(departmentId, unitId);
return members?.ToList() ?? new List<ChatChannelMember>();
}

public async Task<List<ChatChannelMember>> GetActiveMembersForChannelsAsync(List<string> chatChannelIds)
{
if (chatChannelIds == null || chatChannelIds.Count == 0)
return new List<ChatChannelMember>();

var members = await _chatChannelMemberRepository.GetActiveByChannelIdsAsync(chatChannelIds);
return members?.ToList() ?? new List<ChatChannelMember>();
}

public async Task<ChatChannelMember> GetUserMembershipAsync(string chatChannelId, string userId)
{
return await _chatChannelMemberRepository.GetUserMemberAsync(chatChannelId, userId);
Expand Down
5 changes: 4 additions & 1 deletion Core/Resgrid.Services/ChatMessageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -693,8 +693,11 @@ private async Task<string> 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)
Expand Down
13 changes: 9 additions & 4 deletions Core/Resgrid.Services/ChatNotificationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(),
StringComparer.OrdinalIgnoreCase);

// Presence suppression: online users already get the message over SignalR.
var onlineUsers = new HashSet<string>(
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<string>(
await _chatPresenceService.GetUsersActiveInChannelAsync(channel.DepartmentId, audience, channel.ChatChannelId),
StringComparer.OrdinalIgnoreCase);

var isDm = channel.ChannelType == (int)ChatChannelType.DirectMessage;
Expand All @@ -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);
Expand All @@ -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;
Comment on lines +124 to +125

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

N+1 query pattern in the notification loop: the per-unit await of IsUnitActiveInChannelAsync makes one round-trip per unit member, breaking the batch pattern established by the activeUsers HashSet (lines 78–80) and degrading latency as audience size grows. Fetch all active unit IDs for the channel once into a HashSet (via GetUnitsActiveInChannelAsync) alongside the activeUsers fetch, then replace the await with a local set lookup (activeUnits.Contains(unitMember.UnitId.Value)).

Kody rule violation: Detect N+1 style queries and suggest batching

Prompt for LLM

File Core/Resgrid.Services/ChatNotificationService.cs:

Line 124 to 125:

N+1 query pattern in the notification loop: the per-unit await of IsUnitActiveInChannelAsync makes one round-trip per unit member, breaking the batch pattern established by the activeUsers HashSet (lines 78–80) and degrading latency as audience size grows. Fetch all active unit IDs for the channel once into a HashSet (via GetUnitsActiveInChannelAsync) alongside the activeUsers fetch, then replace the await with a local set lookup (activeUnits.Contains(unitMember.UnitId.Value)).

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.


if (!ShouldNotify(unitMember, isUrgent, urgentOverridesMute, mentionedEveryone))
continue;

Expand Down
9 changes: 8 additions & 1 deletion Core/Resgrid.Services/ChatPermissionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,14 @@ private async Task<bool> 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);
Expand Down
170 changes: 170 additions & 0 deletions Core/Resgrid.Services/ChatPresenceService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ public async Task<bool> 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 ClaimUnitMarkerAsync(departmentId, unitId.Value, ParseChannelId(active), userId, refreshOnly: true);
}
}

public async Task<bool> IsOnlineAsync(int departmentId, string userId)
Expand Down Expand Up @@ -77,11 +89,169 @@ 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 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 RemoveUnitMarkerIfOwnedAsync(departmentId, existingUnitId.Value, userId);

var value = unitId.HasValue ? $"{channelId}|{unitId.Value}" : channelId;
await _cacheProvider.SetStringAsync(activeKey, value, GetTtl());

if (unitId.HasValue)
await ClaimUnitMarkerAsync(departmentId, unitId.Value, channelId, userId, refreshOnly: false);
}

public async Task ClearActiveChannelAsync(int departmentId, string userId)
{
await SetActiveChannelAsync(departmentId, userId, null);
}

public async Task<List<string>> GetUsersActiveInChannelAsync(int departmentId, List<string> userIds, string channelId)
{
var active = new List<string>();

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<Task>();
foreach (var userId in userIds)
lookups.Add(LookupAsync(userId));

await Task.WhenAll(lookups);
}

return active;
}

public async Task<bool> IsUnitActiveInChannelAsync(int departmentId, int unitId, string channelId)
{
if (unitId <= 0 || string.IsNullOrWhiteSpace(channelId))
return false;

var marker = await _cacheProvider.GetStringAsync(GetUnitActiveKey(departmentId, unitId));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unwrapped cache read in await _cacheProvider.GetStringAsync(GetUnitActiveKey(departmentId, unitId)) violates Rule [27] — a cache failure throws raw and loses operation context (also at Core/Resgrid.Services/ChatPresenceService.cs:169, 185, 190, 196, 202). Wrap the call in try/catch, log {op:'IsUnitActiveInChannelAsync', departmentId, unitId, channelId}, and return false on failure.

Kody rule violation: Add try-catch blocks for external calls

Prompt for LLM

File Core/Resgrid.Services/ChatPresenceService.cs:

Line 161:

Unwrapped cache read in `await _cacheProvider.GetStringAsync(GetUnitActiveKey(departmentId, unitId))` violates Rule [27] — a cache failure throws raw and loses operation context (also at `Core/Resgrid.Services/ChatPresenceService.cs:169, 185, 190, 196, 202`). Wrap the call in try/catch, log `{op:'IsUnitActiveInChannelAsync', departmentId, unitId, channelId}`, and return `false` on failure.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

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))
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));
Expand Down
Loading
Loading