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
Original file line number Diff line number Diff line change
Expand Up @@ -1655,6 +1655,7 @@
<data name="NotRecordedShort" xml:space="preserve"><value>غير منطبق</value></data>
<data name="NoteBody" xml:space="preserve"><value>نص الملاحظة</value></data>
<data name="NoteSaved" xml:space="preserve"><value>تم حفظ الملاحظة.</value></data>
<data name="InvalidDate" xml:space="preserve"><value>التاريخ غير صالح. تحقق من السنة.</value></data>
<data name="NoteSubject" xml:space="preserve"><value>الموضوع</value></data>
<data name="NoticeIssued" xml:space="preserve"><value>تم إصدار الإشعار.</value></data>
<data name="NoticeReference" xml:space="preserve"><value>مرجع الإشعار</value></data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1655,6 +1655,7 @@
<data name="NotRecordedShort" xml:space="preserve"><value>k. A.</value></data>
<data name="NoteBody" xml:space="preserve"><value>Notiztext</value></data>
<data name="NoteSaved" xml:space="preserve"><value>Notiz gespeichert.</value></data>
<data name="InvalidDate" xml:space="preserve"><value>Das Datum ist ungültig. Prüfen Sie das Jahr.</value></data>
<data name="NoteSubject" xml:space="preserve"><value>Betreff</value></data>
<data name="NoticeIssued" xml:space="preserve"><value>Bescheid ausgestellt.</value></data>
<data name="NoticeReference" xml:space="preserve"><value>Bescheid-Referenz</value></data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1655,6 +1655,7 @@
<data name="NotRecordedShort" xml:space="preserve"><value>Δ/Ε</value></data>
<data name="NoteBody" xml:space="preserve"><value>Κείμενο σημείωσης</value></data>
<data name="NoteSaved" xml:space="preserve"><value>Η σημείωση αποθηκεύτηκε.</value></data>
<data name="InvalidDate" xml:space="preserve"><value>Η ημερομηνία δεν είναι έγκυρη. Ελέγξτε το έτος.</value></data>
<data name="NoteSubject" xml:space="preserve"><value>Θέμα</value></data>
<data name="NoticeIssued" xml:space="preserve"><value>Η ειδοποίηση εκδόθηκε.</value></data>
<data name="NoticeReference" xml:space="preserve"><value>Αναφορά ειδοποίησης</value></data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1655,6 +1655,7 @@
<data name="NotRecordedShort" xml:space="preserve"><value>N/A</value></data>
<data name="NoteBody" xml:space="preserve"><value>Note text</value></data>
<data name="NoteSaved" xml:space="preserve"><value>Note saved.</value></data>
<data name="InvalidDate" xml:space="preserve"><value>The date is not valid. Check the year.</value></data>
<data name="NoteSubject" xml:space="preserve"><value>Subject</value></data>
<data name="NoticeIssued" xml:space="preserve"><value>Notice issued.</value></data>
<data name="NoticeReference" xml:space="preserve"><value>Notice reference</value></data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1655,6 +1655,7 @@
<data name="NotRecordedShort" xml:space="preserve"><value>N/D</value></data>
<data name="NoteBody" xml:space="preserve"><value>Texto de la nota</value></data>
<data name="NoteSaved" xml:space="preserve"><value>Nota guardada.</value></data>
<data name="InvalidDate" xml:space="preserve"><value>La fecha no es válida. Revise el año.</value></data>
<data name="NoteSubject" xml:space="preserve"><value>Asunto</value></data>
<data name="NoticeIssued" xml:space="preserve"><value>Notificación emitida.</value></data>
<data name="NoticeReference" xml:space="preserve"><value>Referencia de la notificación</value></data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1655,6 +1655,7 @@
<data name="NotRecordedShort" xml:space="preserve"><value>S. O.</value></data>
<data name="NoteBody" xml:space="preserve"><value>Texte de la note</value></data>
<data name="NoteSaved" xml:space="preserve"><value>Note enregistrée.</value></data>
<data name="InvalidDate" xml:space="preserve"><value>La date n'est pas valide. Vérifiez l'année.</value></data>
<data name="NoteSubject" xml:space="preserve"><value>Objet</value></data>
<data name="NoticeIssued" xml:space="preserve"><value>Avis émis.</value></data>
<data name="NoticeReference" xml:space="preserve"><value>Référence de l'avis</value></data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1655,6 +1655,7 @@
<data name="NotRecordedShort" xml:space="preserve"><value>N/D</value></data>
<data name="NoteBody" xml:space="preserve"><value>Testo della nota</value></data>
<data name="NoteSaved" xml:space="preserve"><value>Nota salvata.</value></data>
<data name="InvalidDate" xml:space="preserve"><value>La data non è valida. Verifica l'anno.</value></data>
<data name="NoteSubject" xml:space="preserve"><value>Oggetto</value></data>
<data name="NoticeIssued" xml:space="preserve"><value>Avviso emesso.</value></data>
<data name="NoticeReference" xml:space="preserve"><value>Riferimento avviso</value></data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1655,6 +1655,7 @@
<data name="NotRecordedShort" xml:space="preserve"><value>Nie dot.</value></data>
<data name="NoteBody" xml:space="preserve"><value>Treść notatki</value></data>
<data name="NoteSaved" xml:space="preserve"><value>Notatka zapisana.</value></data>
<data name="InvalidDate" xml:space="preserve"><value>Data jest nieprawidłowa. Sprawdź rok.</value></data>
<data name="NoteSubject" xml:space="preserve"><value>Temat</value></data>
<data name="NoticeIssued" xml:space="preserve"><value>Wezwanie wystawione.</value></data>
<data name="NoticeReference" xml:space="preserve"><value>Numer wezwania</value></data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1655,6 +1655,7 @@
<data name="NotRecordedShort" xml:space="preserve"><value>Ej tillämpl.</value></data>
<data name="NoteBody" xml:space="preserve"><value>Anteckningstext</value></data>
<data name="NoteSaved" xml:space="preserve"><value>Anteckning sparad.</value></data>
<data name="InvalidDate" xml:space="preserve"><value>Datumet är ogiltigt. Kontrollera året.</value></data>
<data name="NoteSubject" xml:space="preserve"><value>Ämne</value></data>
<data name="NoticeIssued" xml:space="preserve"><value>Föreläggande utfärdat.</value></data>
<data name="NoticeReference" xml:space="preserve"><value>Föreläggandereferens</value></data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1655,6 +1655,7 @@
<data name="NotRecordedShort" xml:space="preserve"><value>Н/З</value></data>
<data name="NoteBody" xml:space="preserve"><value>Текст нотатки</value></data>
<data name="NoteSaved" xml:space="preserve"><value>Нотатку збережено.</value></data>
<data name="InvalidDate" xml:space="preserve"><value>Дата недійсна. Перевірте рік.</value></data>
<data name="NoteSubject" xml:space="preserve"><value>Тема</value></data>
<data name="NoticeIssued" xml:space="preserve"><value>Припис видано.</value></data>
<data name="NoticeReference" xml:space="preserve"><value>Номер припису</value></data>
Expand Down
8 changes: 8 additions & 0 deletions Core/Resgrid.Model/Providers/ICacheProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ public interface ICacheProvider
Task<string> GetStringAsync(string cacheKey);
Task<T> GetAsync<T>(string cacheKey) where T : class;

/// <summary>
/// Atomically returns the string stored at the key, or stores <paramref name="valueIfAbsent"/> and
/// returns it when the key is missing (concurrent callers all get the first value stored). Either
/// way the key's expiration is reset, so a key in regular use never ages out. Returns null only
/// when the cache is unavailable — unlike <see cref="GetStringAsync"/>, null never means "absent".
/// </summary>
Task<string> GetOrAddStringAsync(string cacheKey, string valueIfAbsent, TimeSpan slidingExpiration);

/// <summary>
/// Atomically increments a counter and returns the new value. Sets the expiration on first
/// increment (when the value becomes 1). Returns 0 when the cache is unavailable.
Expand Down
7 changes: 7 additions & 0 deletions Core/Resgrid.Model/Repositories/ISearchRepositories.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ public interface ISearchIndexStatesRepository : IRepository<SearchIndexState>
Task<SearchIndexState> GetAsync(string indexName, int departmentId);

Task<IEnumerable<SearchIndexState>> GetAllForIndexAsync(string indexName);

/// <summary>
/// Inserts <paramref name="state"/> unless a row for its (IndexName, DepartmentId) already exists, in one statement,
/// so concurrent callers never trip the unique index. Returns false, and leaves the existing row untouched, when
/// another writer got there first.
/// </summary>
Task<bool> InsertIfMissingAsync(SearchIndexState state, CancellationToken cancellationToken = default);
}

public interface ISearchIndexLeasesRepository : IRepository<SearchIndexLease>
Expand Down
4 changes: 2 additions & 2 deletions Core/Resgrid.Model/Search/UnifiedSearchContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,8 @@ public class SystemActionDefinition
/// <summary>Feature flag key that must evaluate true for the department (FeatureFlagKeys).</summary>
public string FeatureFlag { get; set; }

/// <summary>Hide when the Records module is on: the legacy Logs pages are replaced after cutover.</summary>
public bool HiddenWhenRecordsEnabled { get; set; }
/// <summary>Hide once the department's Records cutover is active: a legacy Logs write that the Logs pages now refuse. Reads stay listed.</summary>
public bool HiddenAfterRecordsCutover { get; set; }
}

public class SystemActionHit
Expand Down
5 changes: 4 additions & 1 deletion Core/Resgrid.Model/Services/IChatServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,10 @@ public interface IChatPermissionService
/// <summary>Drops cached permission evaluations for a channel (membership/roles changed) and bumps the channel-list cache version.</summary>
Task InvalidateChannelCacheAsync(string chatChannelId);

/// <summary>Current distributed authorization epoch used to isolate realtime channel groups after access changes.</summary>
/// <summary>
/// Current distributed authorization epoch used to isolate realtime channel groups after access changes.
/// Minted on first use; null only when the cache is unavailable, and callers must then fail closed.
/// </summary>
Task<string> GetChannelAccessVersionAsync(string chatChannelId);
}

Expand Down
2 changes: 1 addition & 1 deletion Core/Resgrid.Services/ChatChannelService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public class ChatChannelService : IChatChannelService
private readonly ICacheProvider _cacheProvider;
private readonly IUnitOfWork _unitOfWork;

// IncidentCommandService reaches back for channel provisioning through ServiceLocator, so this
// IncidentCommandService reaches back for channel provisioning through a Lazy<IChatChannelService>, so this
// constructor edge does not close a resolution cycle.
private readonly IIncidentCommandService _incidentCommandService;

Expand Down
20 changes: 15 additions & 5 deletions Core/Resgrid.Services/ChatMessageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommonServiceLocator;
using Autofac;
using Autofac.Core;
using Microsoft.Data.SqlClient;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
Expand Down Expand Up @@ -33,6 +34,7 @@ public class ChatMessageService : IChatMessageService
private readonly IChatMessageMentionRepository _chatMessageMentionRepository;
private readonly IChatMessageAckRepository _chatMessageAckRepository;
private readonly IChatChannelMemberRepository _chatChannelMemberRepository;
private readonly ILifetimeScope _lifetimeScope;
private readonly IChatChannelService _chatChannelService;
private readonly IChatPermissionService _chatPermissionService;
private readonly IUserProfileService _userProfileService;
Expand All @@ -44,7 +46,7 @@ public ChatMessageService(IChatChannelRepository chatChannelRepository, IChatMes
IChatMessageReactionRepository chatMessageReactionRepository, IChatMessageMentionRepository chatMessageMentionRepository,
IChatMessageAckRepository chatMessageAckRepository, IChatChannelMemberRepository chatChannelMemberRepository,
IChatChannelService chatChannelService, IChatPermissionService chatPermissionService, IUserProfileService userProfileService,
IUnitsService unitsService, IEventAggregator eventAggregator)
IUnitsService unitsService, IEventAggregator eventAggregator, ILifetimeScope lifetimeScope = null)
{
_chatChannelRepository = chatChannelRepository;
_chatMessageRepository = chatMessageRepository;
Expand All @@ -59,6 +61,7 @@ public ChatMessageService(IChatChannelRepository chatChannelRepository, IChatMes
_userProfileService = userProfileService;
_unitsService = unitsService;
_eventAggregator = eventAggregator;
_lifetimeScope = lifetimeScope;
}

public async Task<ChatMessage> SendMessageAsync(int departmentId, string senderUserId, ChatMessageSendRequest request, CancellationToken cancellationToken = default(CancellationToken))
Expand Down Expand Up @@ -807,16 +810,23 @@ await _chatMessageEditRepository.InsertAsync(new ChatMessageEdit

/// <summary>
/// Push fan-out off the request path: per-recipient Novu calls can be slow for large channels,
/// and a push failure must never fail the send. Fresh resolution inside the task keeps us off
/// the request's disposed lifetime scope (ChatProvisioningEventService pattern).
/// and a push failure must never fail the send. The task outlives the request, and Autofac will not
/// resolve from a scope whose parent is disposed, so it runs in its own child of the ROOT scope: never
/// the request's scope, and never a root-resolved notifier sharing the root unit of work
/// (ChatProvisioningEventService pattern). Skipped when constructed outside the container.
/// </summary>
private void FireAndForgetNotify(ChatChannel channel, ChatMessage message, List<ChatMessageMention> mentions)
{
var root = (_lifetimeScope as ISharingLifetimeScope)?.RootLifetimeScope;
if (root == null)
return;

_ = Task.Run(async () =>
{
try
{
var notifier = ServiceLocator.Current.GetInstance<IChatNotificationService>();
using var scope = root.BeginLifetimeScope();
var notifier = scope.Resolve<IChatNotificationService>();
await notifier.NotifyMessageSentAsync(channel, message, mentions);
}
catch (Exception ex)
Expand Down
24 changes: 20 additions & 4 deletions Core/Resgrid.Services/ChatPermissionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ public class ChatPermissionService : IChatPermissionService
private static readonly TimeSpan CacheLength = TimeSpan.FromSeconds(60);
private static readonly TimeSpan VersionCacheLength = TimeSpan.FromDays(1);

/// <summary>
/// Sliding lifetime of a channel's access epoch. Every read refreshes it, so an epoch only lapses
/// on a channel with no joins or fan-out for this long; the lapse mints a new epoch, which strands
/// any still-joined connection until it rejoins, so keep this well beyond a connection's lifetime.
/// </summary>
private static readonly TimeSpan AccessVersionSlidingExpiration = TimeSpan.FromDays(30);

/// <summary>Shared version key rolled into every per-user channel-list cache key; bumped by InvalidateChannelCacheAsync.</summary>
internal const string ChannelListVersionCacheKey = "chatchannellistver";

Expand Down Expand Up @@ -299,7 +306,9 @@ public async Task InvalidateChannelCacheAsync(string chatChannelId)
if (string.IsNullOrWhiteSpace(chatChannelId))
return;

await _cacheProvider.IncrementAsync(GetVersionKey(chatChannelId), VersionCacheLength);
// A fresh random epoch rather than an increment: once a key lapses, a counter restarts at a
// value an obsolete group (still holding a revoked connection) may already carry.
await _cacheProvider.SetStringAsync(GetVersionKey(chatChannelId), NewAccessVersion(), AccessVersionSlidingExpiration);

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

The external cache call can throw without recording the operation or chatChannelId, obscuring failures in ChatPermissionService and the listed callers. Wrap SetStringAsync in try/catch, log the exception with operation and channel context, and rethrow or map it to an appropriate application-level error.

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

try
{
    await _cacheProvider.SetStringAsync(GetVersionKey(chatChannelId), NewAccessVersion(), AccessVersionSlidingExpiration);
}
catch (Exception ex)
{
    Resgrid.Framework.Logging.LogException(ex);
    throw;
}
Prompt for LLM

File Core/Resgrid.Services/ChatPermissionService.cs:

Line 311:

The external cache call can throw without recording the operation or chatChannelId, obscuring failures in ChatPermissionService and the listed callers. Wrap SetStringAsync in try/catch, log the exception with operation and channel context, and rethrow or map it to an appropriate application-level error.

Suggested Code:

try
{
    await _cacheProvider.SetStringAsync(GetVersionKey(chatChannelId), NewAccessVersion(), AccessVersionSlidingExpiration);
}
catch (Exception ex)
{
    Resgrid.Framework.Logging.LogException(ex);
    throw;
}

Talk to Kody by mentioning @kody

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

​

​


// Roll every per-user channel-list cache key forward too (channel set/visibility changed).
await _cacheProvider.IncrementAsync(ChannelListVersionCacheKey, VersionCacheLength);
Expand All @@ -312,17 +321,24 @@ public async Task<string> GetChannelAccessVersionAsync(string chatChannelId)

try
{
return await _cacheProvider.GetStringAsync(GetVersionKey(chatChannelId));
// A channel that has never been invalidated (or sat idle past the sliding window) has no
// epoch yet — mint one rather than treating the absence as an outage.
return await _cacheProvider.GetOrAddStringAsync(GetVersionKey(chatChannelId), NewAccessVersion(), AccessVersionSlidingExpiration);
}
catch (Exception ex)
{
// A missing authorization epoch must stop realtime fan-out. Falling back to the old
// group during a cache outage could reconnect a user whose access was just revoked.
// Null (cache unavailable) must stop realtime fan-out. Falling back to a fixed group during
// a cache outage could reconnect a user whose access was just revoked.
Resgrid.Framework.Logging.LogException(ex);
return null;
}
}

private static string NewAccessVersion()
{
return Guid.NewGuid().ToString("N");
}

private async Task<bool> EvaluateAccessAsync(ChatChannel channel, string userId, int? activeUnitId)
{
if (!await HasValidDepartmentScopeAsync(channel))
Expand Down
32 changes: 25 additions & 7 deletions Core/Resgrid.Services/CoreEventService.cs
Original file line number Diff line number Diff line change
@@ -1,29 +1,47 @@
using System;
using System.Threading.Tasks;
using CommonServiceLocator;
using Autofac;
using Resgrid.Framework;
using Resgrid.Model;
using Resgrid.Model.Events;
using Resgrid.Model.Services;
using Resgrid.Model.Providers;

namespace Resgrid.Services
{
/// <summary>
/// Registered as a singleton, so the scoped department settings service is NOT resolved once and kept: from
/// the root scope it would share one unit of work, and one DB connection, with everything else resolved there,
/// and the timestamp save runs inside an audited configuration transaction. Each event instead runs in its own
/// child scope, as <see cref="ChatProvisioningEventService"/> does.
/// </summary>
public class CoreEventService : ICoreEventService
{
private readonly IEventAggregator _eventAggregator;
private readonly ILifetimeScope _lifetimeScope;

public CoreEventService(IEventAggregator eventAggregator)
public CoreEventService(IEventAggregator eventAggregator, ILifetimeScope lifetimeScope)
{
_eventAggregator = eventAggregator;
_lifetimeScope = lifetimeScope;

_eventAggregator.AddListener(departmentSettingsUpdateHandler);
// Fire-and-forget as before: the publisher (a unit, department or custom state save) is not held up.
_eventAggregator.AddListener<DepartmentSettingsUpdateEvent>(message => _ = UpdateDepartmentTimestampAsync(message));

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

The AddListener registration does not provide an explicit error handler, so failures from UpdateDepartmentTimestampAsync lack deterministic error handling and lifecycle cleanup. Pass HandleEventError through onError when registering the listener.

Kody rule violation: Provide error handlers to subscription/listener APIs

_eventAggregator.AddListener<DepartmentSettingsUpdateEvent>(message => _ = UpdateDepartmentTimestampAsync(message), onError: HandleEventError);
Prompt for LLM

File Core/Resgrid.Services/CoreEventService.cs:

Line 29:

The AddListener registration does not provide an explicit error handler, so failures from UpdateDepartmentTimestampAsync lack deterministic error handling and lifecycle cleanup. Pass HandleEventError through onError when registering the listener.

Suggested Code:

_eventAggregator.AddListener<DepartmentSettingsUpdateEvent>(message => _ = UpdateDepartmentTimestampAsync(message), onError: HandleEventError);

Talk to Kody by mentioning @kody

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

​

​

}

private Action<DepartmentSettingsUpdateEvent> departmentSettingsUpdateHandler = async delegate(DepartmentSettingsUpdateEvent message)
private async Task UpdateDepartmentTimestampAsync(DepartmentSettingsUpdateEvent message)
{
var departmentSettingsService = ServiceLocator.Current.GetInstance<IDepartmentSettingsService>();
var result = await departmentSettingsService.SaveOrUpdateSettingAsync(message.DepartmentId, DateTime.UtcNow.ToString("G"), DepartmentSettingTypes.UpdateTimestamp);
};
try
{
using var scope = _lifetimeScope.BeginLifetimeScope();
await scope.Resolve<IDepartmentSettingsService>().SaveOrUpdateSettingAsync(message.DepartmentId, DateTime.UtcNow.ToString("G"), DepartmentSettingTypes.UpdateTimestamp);
}
catch (Exception ex)
{
// Nothing awaits this, so an escaping exception would go unobserved.
Logging.LogException(ex, $"Department update timestamp could not be saved for department {message?.DepartmentId}.");
}
}

public Task IncidentCommandUpdatedAsync(int departmentId, int callId)
{
Expand Down
Loading
Loading