Skip to content

RG-T129 Notification System bug fixes - #446

Merged
ucswift merged 5 commits into
masterfrom
develop
Aug 5, 2026
Merged

RG-T129 Notification System bug fixes#446
ucswift merged 5 commits into
masterfrom
develop

Conversation

@ucswift

@ucswift ucswift commented Aug 4, 2026

Copy link
Copy Markdown
Member

PR Description: RG-T129 Notification System Bug Fixes

Summary

This PR fixes several bugs in the notification system that prevented notifications from firing correctly and caused runtime errors under certain conditions.

Changes

Bug Fix: Incorrect Event Type in Group Lookup

In GetGroupForEventAsync, the code block that looks up personnel staffing data was incorrectly matched against PersonnelStatusChanged instead of PersonnelStaffingChanged. This meant group-based notifications for staffing changes would never resolve the correct department group.

Bug Fix: Empty BeforeData/CurrentData Causing Notifications to Never Fire

The notification validation logic previously returned false whenever BeforeData or CurrentData was null or empty. Since the UI's "Any" option was posting an empty string, notifications saved with default "Any" settings would never trigger. The validation now treats empty/null values as "-1" (the system's "Any" sentinel), allowing these notifications to process as intended.

Bug Fix: NullReferenceException When No Previous State Exists

For UnitStatusChanged, PersonnelStaffingChanged, and PersonnelStatusChanged events, when a "before" state was required but no prior state existed (e.g., the very first state change), the code would throw a null reference exception. Null checks were added so the notification is safely skipped (returns false) instead of crashing. A missing null check on currentState was also added for PersonnelStatusChanged.

Bug Fix: UI Dropdown Posting Incorrect "Any" Value

The client-side dropdown initialization was changed to post "-1" for the "Any" option instead of an empty string, aligning with the notification engine's expected value format. The API calls for populating these dropdowns were updated to stop requesting an "Any" entry from the server (includeAny=False), since it is now provided client-side.

Test Coverage

New unit tests were added covering:

  • Notifications processing when BeforeData/CurrentData are empty (should succeed)
  • Graceful handling when no previous state exists for each event type (should return false without throwing)
  • Group resolution for PersonnelStaffingChanged and PersonnelStatusChanged events

Summary by CodeRabbit

  • New Features

    • Added moderation tools for reporting, reviewing, completing, and auditing flagged chat, dispatch, and system content.
    • Administrators can search requests, review reports and retained evidence, track actions, and notify reporters.
    • Added localized moderation messaging, status indicators, and administrator-only controls.
    • Moderated message deletions are now clearly identified.
  • Bug Fixes

    • Improved notification matching for blank values and personnel or unit status changes.
    • Deleted or unavailable attachments now return appropriate not-found responses.
    • Improved call-edit validation, message length handling, subscription expiry checks, and template editor initialization.

@request-info

request-info Bot commented Aug 4, 2026

Copy link
Copy Markdown

Thanks for opening this, but we'd appreciate a little more information. Could you update it with more details?

@Resgrid-Bot

This comment has been minimized.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds moderation workflows across storage, services, APIs, controllers, and chat interfaces. It also adds localization, moderation state, legacy-data migrations, notification handling, validation changes, and supporting database updates.

Changes

Moderation platform

Layer / File(s) Summary
Moderation contracts and persistence
Core/Resgrid.Model/..., Providers/..., Repositories/...
Adds moderation entities, repository contracts and implementations, moderation tables, legacy-data imports, audit types, and moderator-applied chat tombstone state.
Moderation service workflow
Core/Resgrid.Services/ModerationService.cs
Implements report submission, authorization, evidence capture, content removal, request completion, evidence access, notifications, and audit persistence.
Moderation API contracts and endpoints
Web/Resgrid.Web.Services/Controllers/v4/ModerationController.cs, Web/Resgrid.Web.Services/Models/v4/Moderation/*
Adds v4 endpoints and models for reporting, status lookup, request search, completion, and evidence downloads.
User-area moderation flows
Web/Resgrid.Web/Areas/User/Controllers/*, Web/Resgrid.Web/Areas/User/Views/*
Routes chat, message, call-note, and call-image reports through moderation requests and displays request status and administrator notes.
Chat moderation interface
Web/Resgrid.Web/Areas/User/Apps/src/components/chat/...
Adds request and report tabs, filters, evidence downloads, completion actions, audit trails, localized text, and existing-request status handling.

Supporting behavior updates

Layer / File(s) Summary
Notification matching and form values
Core/Resgrid.Services/NotificationService.cs, Web/Resgrid.Web/wwwroot/js/app/internal/notifications/resgrid.notifications.addNotification.js, Workers/...
Personnel staffing events now resolve groups through user-state data. Blank status values map to “Any.” Moderation completion notifications use a dedicated worker path.
Localization and model metadata
Core/Resgrid.Localization/..., Core/Resgrid.Model/..., Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml
Adds moderation localization lookup and client exposure. Adds protobuf metadata, message length constants, moderation state, and related model metadata.
Validation and isolated fixes
Web/Resgrid.Web.Services/Controllers/v4/*, Web/Resgrid.Web/Areas/User/Controllers/SubscriptionController.cs, Web/Resgrid.Web/wwwroot/js/app/internal/templates/resgrid.templates.newtemplate.js
Uses invariant identifier parsing, validates required input, normalizes expiry checks, and avoids editor initialization when the target element is absent.
Communication test delete migrations
Providers/Resgrid.Providers.Migrations/Migrations/M0111_CascadeCommunicationTestDeletes.cs, Providers/Resgrid.Providers.MigrationsPg/Migrations/M0111_CascadeCommunicationTestDeletesPg.cs
Adds cascading-delete foreign keys with reversible rollback logic.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Reporter
  participant ModerationController
  participant ModerationService
  participant ModerationRepository
  participant Moderator
  Reporter->>ModerationController: submit moderation report
  ModerationController->>ModerationService: flag content
  ModerationService->>ModerationRepository: store request and report
  ModerationRepository-->>ModerationService: return moderation data
  ModerationService-->>Reporter: return report status
  Moderator->>ModerationController: search or complete request
  ModerationController->>ModerationService: authorize and complete
  ModerationService->>ModerationRepository: store action and status
  ModerationService-->>Moderator: return completed request
Loading

Possibly related PRs

  • Resgrid/Core#300: Both update notification handling for blank notification values.

Suggested reviewers: github-actions

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.28% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the notification-processing fixes that match the stated pull request objectives, although the changeset also contains substantial moderation work.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch develop

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Core/Resgrid.Services/NotificationService.cs`:
- Around line 507-511: Update the single-select comparisons in the notification
matching logic around the existing before/current data branches to test
normalized values for exact equality with "-1", not substring containment. Apply
this consistently to the early wildcard match and both beforeAny/currentAny
assignments, including the corresponding branches around the additional affected
locations, so values such as "-10" are not treated as the wildcard.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9c388c78-29fd-42a9-9a14-01652d4b0775

📥 Commits

Reviewing files that changed from the base of the PR and between 6b0f0e3 and ea6eef3.

⛔ Files ignored due to path filters (1)
  • Tests/Resgrid.Tests/Services/NotificationServiceTests.cs is excluded by !**/Tests/**
📒 Files selected for processing (2)
  • Core/Resgrid.Services/NotificationService.cs
  • Web/Resgrid.Web/wwwroot/js/app/internal/notifications/resgrid.notifications.addNotification.js

Comment thread Core/Resgrid.Services/NotificationService.cs Outdated

if ((currentAny || currentState.State == int.Parse(setting.CurrentData)) &&
(beforeAny || beforeState.State == int.Parse(setting.BeforeData)))
if ((currentAny || currentState.State == int.Parse(currentData)) &&

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

Unsafe string conversion: int.Parse(currentData) is used without TryParse validation for user/IO input. Prefer int.TryParse and validate culture/format where applicable.

Kody rule violation: Use TryParse for string conversions

Prompt for LLM

File Core/Resgrid.Services/NotificationService.cs:

Line 521:

Unsafe string conversion: `int.Parse(currentData)` is used without `TryParse` validation for user/IO input. Prefer `int.TryParse` and validate culture/format where applicable.

Talk to Kody by mentioning @kody

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

if (setting.BeforeData.Contains("-1") && setting.CurrentData.Contains("-1"))
// Empty Before/Current data means "Any": the post-Telerik UI posts "" for the
// default Any option, so settings saved that way must still match every change.
var beforeData = String.IsNullOrWhiteSpace(setting.BeforeData) ? "-1" : setting.BeforeData;

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 low

Magic string literal "-1" is scattered across NotificationService.cs (lines 505–565) and resgrid.notifications.addNotification.js (lines 132–146) as a sentinel meaning "Any" without a named constant. Define a class-level constant private const string AnySelection = "-1"; and replace all inline occurrences.

Kody rule violation: Centralize string constants

Prompt for LLM

File Core/Resgrid.Services/NotificationService.cs:

Line 504:

Magic string literal `"-1"` is scattered across `NotificationService.cs` (lines 505–565) and `resgrid.notifications.addNotification.js` (lines 132–146) as a sentinel meaning "Any" without a named constant. Define a class-level constant `private const string AnySelection = "-1";` and replace all inline occurrences.

Talk to Kody by mentioning @kody

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

if (setting.BeforeData.Contains("-1") && setting.CurrentData.Contains("-1"))
// Empty Before/Current data means "Any": the post-Telerik UI posts "" for the
// default Any option, so settings saved that way must still match every change.
var beforeData = String.IsNullOrWhiteSpace(setting.BeforeData) ? "-1" : setting.BeforeData;

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 low

Triplicated logic: the normalization-plus-state-comparison sequence is duplicated verbatim across the UnitStatusChanged, PersonnelStaffingChanged, and PersonnelStatusChanged cases. Extract a single generic helper such as ValidateStateChangeAsync<TState>(setting, Func<int, Task<TState>> getCurrent, Func<TState, Task<TState>> getBefore, Func<TState,int> stateSelector) and call it from each case.

Kody rule violation: Extract duplicated logic into functions

Prompt for LLM

File Core/Resgrid.Services/NotificationService.cs:

Line 504:

Triplicated logic: the normalization-plus-state-comparison sequence is duplicated verbatim across the `UnitStatusChanged`, `PersonnelStaffingChanged`, and `PersonnelStatusChanged` cases. Extract a single generic helper such as `ValidateStateChangeAsync<TState>(setting, Func<int, Task<TState>> getCurrent, Func<TState, Task<TState>> getBefore, Func<TState,int> stateSelector)` and call it from each case.

Talk to Kody by mentioning @kody

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

{
DepartmentId = 1,
MessageId = "123456",
Data = new NotificationItem() { StateId = 3, DepartmentId = 1, PreviousStateId = 2 }.SerializeProto(),

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 low

Magic numbers StateId=3 and PreviousStateId=2 lack self-documenting domain meaning. Use enum casts such as StateId=(int)UnitStateTypes.Responding, consistent with existing BeforeData casts in the same test.

Kody rule violation: Replace magic numbers with named constants

Prompt for LLM

File Tests/Resgrid.Tests/Services/NotificationServiceTests.cs:

Line 771:

Magic numbers `StateId=3` and `PreviousStateId=2` lack self-documenting domain meaning. Use enum casts such as `StateId=(int)UnitStateTypes.Responding`, consistent with existing `BeforeData` casts in the same test.

Talk to Kody by mentioning @kody

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

$('#beforeStateControl').empty().append('<select id="Notification_BeforeData" name="Notification.BeforeData" style="width:100%"></select>');
$('#currentStateControl').empty().append('<select id="Notification_CurrentData" name="Notification.CurrentData" style="width:100%"></select>');
var url = resgrid.absoluteBaseUrl + '/User/CustomStatuses/GetUnitStatusesLevelsForDepartmentCombined?includeAny=True';
var url = resgrid.absoluteBaseUrl + '/User/CustomStatuses/GetUnitStatusesLevelsForDepartmentCombined?includeAny=False';

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 low

String concatenation using + violates the team template literals rule. Replace with a template literal to improve readability and reduce error-proneness.

Kody rule violation: Use Template Literals Instead of String Concatenation

Prompt for LLM

File Web/Resgrid.Web/wwwroot/js/app/internal/notifications/resgrid.notifications.addNotification.js:

Line 132:

String concatenation using `+` violates the team template literals rule. Replace with a template literal to improve readability and reduce error-proneness.

Talk to Kody by mentioning @kody

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

var $sel = $(selector).empty().append('<option value="">-- Any --</option>');
// "Any" must post "-1", not "" — the notification engine treats the value as a
// state id and an empty string used to make the setting never match.
var $sel = $(selector).empty().append('<option value="-1">-- Any --</option>');

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 low

var declaration of $sel violates Rule [37] and risks function-scoping pitfalls. Use const since $sel is never reassigned.

Kody rule violation: Always use const and let

Prompt for LLM

File Web/Resgrid.Web/wwwroot/js/app/internal/notifications/resgrid.notifications.addNotification.js:

Line 119:

`var` declaration of `$sel` violates Rule [37] and risks function-scoping pitfalls. Use `const` since `$sel` is never reassigned.

Talk to Kody by mentioning @kody

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

@Resgrid-Bot

This comment has been minimized.

public async Task<ActionResult<GetCallResult>> GetCall(string callId, [FromQuery] string departmentId = null)
{
if (String.IsNullOrWhiteSpace(callId))
if (!int.TryParse(callId, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedCallId))
var result = new EditCallResult();

var canDoOperation = await _authorizationService.CanUserEditCallAsync(UserId, int.Parse(editCallInput.Id));
if (editCallInput == null || !ModelState.IsValid ||

var canDoOperation = await _authorizationService.CanUserEditCallAsync(UserId, int.Parse(editCallInput.Id));
if (editCallInput == null || !ModelState.IsValid ||
!int.TryParse(editCallInput.Id, NumberStyles.Integer, CultureInfo.InvariantCulture, out int callId))
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<ModerationActionResult>> Flag([FromBody] FlagModerationInput input,
public async Task<ActionResult<ModerationActionResult>> Flag([FromBody] FlagModerationInput input,
CancellationToken cancellationToken)
{
if (!ModelState.IsValid || input == null)

/// <summary>Completes a scoped request with no action or by removing the live content.</summary>
[HttpPost("Complete")]
public async Task<ActionResult<ModerationActionResult>> Complete(string requestId,
public async Task<ActionResult<ModerationActionResult>> Complete(string requestId,
[FromBody] CompleteModerationInput input, CancellationToken cancellationToken)
{
if (!ModelState.IsValid || input == null || string.IsNullOrWhiteSpace(requestId))

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 17

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs (1)

1461-1486: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

FlagMessage does not handle the exceptions FlagAsync throws.

ModerationService.FlagAsync throws in cases this endpoint can reach:

  • InvalidOperationException when the chat message is deleted. CheckMessageChannelAccessAsync does not inspect DeletedOn, so flagging a tombstoned message reaches LoadEvidenceAsync and throws.
  • ArgumentOutOfRangeException when input.Reason is outside the ModerationReason range. FlagMessageInput.Reason is a plain int.
  • UnauthorizedAccessException from LoadEvidenceAsync.

None are caught, so each returns 500. ModerationController.Flag catches all three and maps them to 400 or 401. Mirror that handling here.

The cast (ModerationReason)input.Reason also couples two independently declared enums. The values align today. Map them explicitly so a future change to either enum fails at compile time instead of silently mislabelling a report.

🛠️ Proposed fix
 			var result = new ChatActionResult();
-			var flag = await _moderationService.FlagAsync(DepartmentId, UserId,
-				ModerationItemType.ChatMessage, messageId, (ModerationReason)input.Reason, input.Note,
-				BuildModerationContext("Reporter"), cancellationToken);
+			ModerationReport flag;
+
+			try
+			{
+				flag = await _moderationService.FlagAsync(DepartmentId, UserId,
+					ModerationItemType.ChatMessage, messageId, (ModerationReason)input.Reason, input.Note,
+					BuildModerationContext("Reporter"), cancellationToken);
+			}
+			catch (UnauthorizedAccessException)
+			{
+				return Unauthorized();
+			}
+			catch (ArgumentException ex)
+			{
+				return BadRequest(ex.Message);
+			}
+			catch (InvalidOperationException ex)
+			{
+				return BadRequest(ex.Message);
+			}
 
 			result.Success = flag != null;

ArgumentOutOfRangeException derives from ArgumentException, so the ArgumentException catch covers the invalid-reason case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs` around lines 1461
- 1486, Update FlagMessage to explicitly map input.Reason to the corresponding
ModerationReason value instead of directly casting between enums, and wrap
FlagAsync in exception handling matching ModerationController.Flag: map
ArgumentException and InvalidOperationException to BadRequest,
UnauthorizedAccessException to Unauthorized, and preserve the existing success
response for successful flags.
Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatStore.ts (1)

341-345: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Copy IsModerated for deleted thread replies.

The channel-message branch copies moderation state from HubDeletedPayload, but the thread-reply branch does not. A moderator-deleted reply remains IsModerated: false in local state. Apply the same payload.IsModerated ?? payload.DeletedByModerator value in this branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatStore.ts` around
lines 341 - 345, Update the thread-reply handling in the loop over
state.threadMessagesByRoot to include IsModerated from payload.IsModerated ??
payload.DeletedByModerator when calling upsertThreadMessage, alongside DeletedOn
and Body. Preserve the existing reply lookup and early return behavior.
Web/Resgrid.Web/Areas/User/Controllers/MessagesController.cs (1)

37-56: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the required dependency-resolution pattern.

These changes add constructor injection for new dependencies. Resolve the dependencies with Bootstrapper.GetKernel().Resolve<T>() in each constructor instead.

  • Web/Resgrid.Web/Areas/User/Controllers/MessagesController.cs#L37-L56: Resolve IModerationService and the moderation localizer through the required service locator.
  • Web/Resgrid.Web/Areas/User/Controllers/ModerationController.cs#L13-L18: Resolve IDepartmentGroupsService through the required service locator.

As per coding guidelines, use Bootstrapper.GetKernel().Resolve<T>() to resolve dependencies explicitly in constructors rather than constructor injection.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Web/Resgrid.Web/Areas/User/Controllers/MessagesController.cs` around lines 37
- 56, Replace constructor injection with explicit service-locator resolution in
MessagesController: remove the IModerationService and moderation localizer
parameters and initialize both fields via Bootstrapper.GetKernel().Resolve<T>().
In Web/Resgrid.Web/Areas/User/Controllers/ModerationController.cs lines 13-18,
resolve IDepartmentGroupsService through Bootstrapper.GetKernel().Resolve<T>()
instead of injecting it; update each constructor accordingly.

Source: Coding guidelines

🧹 Nitpick comments (8)
Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs (1)

45-46: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the unused IChatModerationService dependency from ChatController.

_chatModerationService and its constructor parameter are no longer referenced by ChatController; removing them reduces unnecessary injected dependencies and the unused registration can be cleaned up separately if this is its only remaining direct dependency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs` around lines 45 -
46, Remove the unused IChatModerationService field and its constructor parameter
from ChatController, and update constructor assignments and calls accordingly
while preserving the existing IModerationService dependency.

Source: Coding guidelines

Web/Resgrid.Web.Services/Models/v4/Moderation/ModerationApiModels.cs (1)

78-101: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive moderation ranges from the enums.

FlagAsync and CompleteRequestAsync use Enum.IsDefined/explicit checks for ModerationItemType, ModerationReason, and the accepted ModerationDisposition values, while the API input uses literal Range constraints. If a future enum value is added, validation can block it at the controller before the service rejects it. Bind these properties to their enum types or validate with Enum.IsDefined so the enum remains the source of truth.

Note limits are not an issue here: both migration definitions use text / int.MaxValue.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Web/Resgrid.Web.Services/Models/v4/Moderation/ModerationApiModels.cs` around
lines 78 - 101, Replace the literal Range constraints on
FlagModerationInput.ItemType, FlagModerationInput.Reason, and
CompleteModerationInput.Disposition with enum-based validation using their
corresponding moderation enums, preferably by changing the properties to those
enum types or applying Enum.IsDefined validation. Preserve the existing note and
ItemId validation.
Core/Resgrid.Services/ChatMessageService.cs (1)

316-324: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Remove the duplicate moderator flag from the deletion event payload.

DeletedByModerator now carries the same raw asModerator value as IsModerated, so moderators deleting their own messages publish both fields as true. The chat client derives the display state from IsModerated, so keep that field and remove DeletedByModerator from the event and type.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Core/Resgrid.Services/ChatMessageService.cs` around lines 316 - 324, Update
the deletion event payload in the message deletion flow around PublishEvent to
remove DeletedByModerator = asModerator while retaining message.IsModerated.
Remove the corresponding DeletedByModerator property from the event payload type
and update any affected consumers to use IsModerated only.
Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderation/ModerationRequestsTable.tsx (2)

246-246: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Confirm the destructive action before it runs.

The RemoveContent button calls complete(request, 2) on the first click. The moderation service removes the live content for that disposition, and the table offers no undo. Add a confirmation step, so a mis-click does not delete a message, a call note, or a call image.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderation/ModerationRequestsTable.tsx`
at line 246, Update the RemoveContent button in ModerationRequestsTable so it
asks the user for confirmation before invoking complete(request, 2). Only call
complete after confirmation is accepted; preserve the existing isBusy disabled
state and behavior for other moderation actions.

71-75: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Index the personnel list once.

personName runs a linear people.find for every content author, every report, and every audit action. With a page of 100 requests and a large personnel roster, the render performs thousands of scans. Build a Map once and read from it.

♻️ Proposed refactor
+  const peopleById = useMemo(
+    () => new Map(people.map((item) => [item.userId, item.name])),
+    [people],
+  );
+
   const personName = useCallback((userId?: string | null) => {
     if (!userId) return moderationText('SystemOrUnknown');
-    const person = people.find((item) => item.userId === userId);
-    return person ? `${person.name} (${userId})` : userId;
-  }, [people]);
+    const name = peopleById.get(userId);
+    return name ? `${name} (${userId})` : userId;
+  }, [peopleById]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderation/ModerationRequestsTable.tsx`
around lines 71 - 75, Update the personnel lookup used by personName to build a
Map keyed by userId once per people change, then read entries from that Map
instead of calling people.find for each author, report, or audit action.
Preserve the existing SystemOrUnknown fallback and display formatting for found
and missing users.
Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs (2)

69-69: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the no-op ternary.

Both branches return "r", and every generated statement already hard-codes the r alias. Replace the variable with the literal at line 153.

♻️ Proposed cleanup
-				var requestAlias = postgres ? "r" : "r";
 				var filters = new List<string>();

Then use the literal alias in the PostgreSQL statement:

-FROM {_sqlConfiguration.SchemaName}.moderationrequests {requestAlias}
+FROM {_sqlConfiguration.SchemaName}.moderationrequests r
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs`
at line 69, Remove the no-op requestAlias assignment in the moderation
repository and replace its usage in the PostgreSQL statement around the
generated query with the literal “r” alias. Preserve the existing SQL behavior
and remove the now-unused variable.

180-193: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the _unitOfWork null handling consistent.

Line 185 tests _unitOfWork?.Connection, which states that _unitOfWork can be null. Line 183 then reads _unitOfWork.Transaction inside the delegate, and line 192 calls _unitOfWork.CreateOrGetConnection(). If _unitOfWork were ever null, the delegate throws a NullReferenceException when it runs, not at line 185. The same mix exists at lines 244/246 and 298/300.

The constructors require the dependency, so remove the null-conditional operator, or guard the whole method.

Also applies to: 243-253, 297-307

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs`
around lines 180 - 193, Make null handling consistent in QueryAsync and the
corresponding methods around the later query blocks: since constructors require
_unitOfWork, remove the null-conditional checks and use _unitOfWork.Connection
directly, or consistently guard the entire method before accessing
_unitOfWork.Transaction and CreateOrGetConnection(). Apply the same correction
to all three affected query methods.
Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatFormat.ts (1)

192-192: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Hoist the formatter out of the function.

formatRelativeDay runs once per rendered message and once per moderation or export table row. Each call with a one-day-old date constructs a new Intl.RelativeTimeFormat. Create the formatter once at module scope and reuse it.

Note that the output is now lowercase in English, for example "yesterday" instead of the previous "Yesterday". Confirm that reads correctly in the cells that show it.

♻️ Proposed change
+const relativeDayFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
+
 export function formatRelativeDay(iso: string | null | undefined): string {
   if (diffDays === 1) {
-    return new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }).format(-1, 'day');
+    return relativeDayFormatter.format(-1, 'day');
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatFormat.ts` at line
192, Hoist the Intl.RelativeTimeFormat instance used by formatRelativeDay to
module scope and reuse it for each one-day-old date instead of constructing it
per call. Preserve the relative-day output, and verify the resulting lowercase
English text such as “yesterday” reads correctly in message, moderation, and
export table cells.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Core/Resgrid.Model/Repositories/IChatRepositories.cs`:
- Around line 147-148: Update the delete flow that calls TombstoneAsync so it
derives one effective moderator/sender flag and reuses it for the TombstoneAsync
isModerated argument, message.IsModerated, and delete-event audit type;
alternatively block moderator self-deletion consistently. Ensure persisted
tombstone state and audit history reflect the same actor classification.

In `@Core/Resgrid.Services/ChatMessageService.cs`:
- Around line 303-313: Make the edit-history type and IsModerated assignment in
DeleteMessageAsync use the same moderator-deletion condition, including the
isSender case consistently. Derive both from one shared condition so
moderator-authored deletions cannot produce SenderDelete while setting
IsModerated to true.

In `@Core/Resgrid.Services/ModerationService.cs`:
- Around line 519-542: The HydrateAsync loop performs per-request report and
action queries, causing excessive sequential database round trips. Add batch
repository lookup methods accepting the collected ModerationRequestId values,
call each once, group the returned reports and actions by request ID in memory,
and use those groups while preserving ApplyGroupScope and null-scope behavior.
- Around line 338-343: Update LoadEvidenceAsync to retain and load every
attachment returned by GetMetadataByMessageIdsAsync instead of selecting only
FirstOrDefault, and pass the complete attachment collection through the evidence
model so RemoveLiveContentAsync preserves all attachments in the audit trail. If
the surrounding API cannot support multiple attachments, document the enforced
single-attachment limitation in LoadEvidenceAsync instead.
- Around line 272-282: Make CompleteRequestAsync persist content removal and the
moderation request status transition atomically, so RemoveLiveContentAsync
cannot leave destructive changes committed when
_moderationRequestRepository.UpdateAsync fails. Use the existing
transaction/unit-of-work mechanism around both operations; preserve the current
failure behavior and only finalize the transaction after the removal and request
update succeed.
- Around line 110-124: Update both insert exception handlers in the moderation
request flow, including the blocks around InsertAsync and the reporter lookup,
to catch OperationCanceledException separately and rethrow it so cancellation
propagates. Restrict the general handler to non-cancellation exceptions, log
each swallowed insert failure with Resgrid.Framework.Logging.LogException, then
retain the existing concurrent-row recovery and rethrow behavior. Add the
required Resgrid.Framework import.
- Around line 654-689: Update NotifyReportersAsync to verify the result returned
by SaveMessageAsync before passing it to SendMessageAsync; skip sending when the
saved message is null so notification failure cannot throw. Move the
per-recipient profile lookup, message persistence, and send/enqueue work out of
CompleteRequestAsync’s synchronous completion path by dispatching the
notification fan-out through the existing background queue mechanism, while
preserving recipient filtering and cancellation behavior.

In `@Providers/Resgrid.Providers.Migrations/Migrations/M0112_AddModeration.cs`:
- Around line 177-179: Replace manual metadata JSON string concatenation in the
migration’s notification metadata construction, including the logic around
n.Source, n.Latitude, n.Longitude, and the lines handling names, with SQL
Server’s JSON API such as FOR JSON or JSON_OBJECT. Ensure all values are
serialized with valid JSON escaping and numeric formatting, while preserving the
existing metadata fields and fallback values.
- Around line 118-127: Update M0112_AddModeration.cs at
Providers/Resgrid.Providers.Migrations/Migrations/M0112_AddModeration.cs:118-127
and M0112_AddModerationPg.cs at
Providers/Resgrid.Providers.MigrationsPg/Migrations/M0112_AddModerationPg.cs:118-127
by guarding each foreign-key creation with a constraint-existence check, and
invoke ImportLegacyFlags() only when ModerationRequests/moderationrequests
contains no rows. Apply the equivalent checks and condition in both migration
implementations.

In `@Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs`:
- Around line 41-45: Update the SQL built by GetByItemAsync to replace SELECT *
with the same explicit non-blob moderation request column list used by
SearchAsync and ModerationActionRepository.GetByRequestAsync. Exclude
OriginalContent and any other evidence blob columns while preserving the
existing PostgreSQL and non-PostgreSQL table and filter syntax.

In `@Web/Resgrid.Web.Services/Controllers/v4/ModerationController.cs`:
- Around line 190-206: Update DownloadEvidence to accept a CancellationToken and
pass it through to the moderation service calls, matching Flag and Complete.
Handle UnauthorizedAccessException from RecordEvidenceAccessAsync by returning
Unauthorized instead of allowing a 500, while preserving the existing
no-disclosure behavior. Also revise the method summary to describe general
evidence or retained content rather than only image evidence.

In `@Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPageElement.tsx`:
- Around line 59-65: Update openFlag to ignore stale getMyModerationRequest
responses when the selected ChatMessageId changes, using a request token or
matching the response to the current target before calling setFlagStatus. Apply
the same guard to success and failure handlers so an earlier lookup cannot alter
the second message’s dialog state.

In
`@Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderation/ModerationRequestsTable.tsx`:
- Line 208: Update the conditional rendering for request.CallId and
report.ReporterGroupId to explicitly check that each value is neither null nor
undefined, preventing a numeric zero from rendering as text while preserving
rendering for valid zero and nonzero identifiers.
- Around line 77-86: Update ModerationRequestsTable’s pagination flow so
moderators can access results beyond the hard-coded first 100 records: add page
state and previous/next controls that update search.page, while keeping pageSize
within the repository’s 200-row cap. Reuse the existing ActionsTab pagination
behavior and ensure controls reflect whether another page is available based on
the returned result count.
- Line 270: Replace the dynamic moderationText key construction for
action.ActorRole in ModerationRequestsTable with an explicit
role-to-localization-key map and a defined fallback for unmapped roles,
following the existing ITEM_LABEL_KEYS and ACTION_LABEL_KEYS pattern. Preserve
the UnknownRole behavior when ActorRole is absent and ensure persisted values
such as LegacyImport do not render as raw localization keys.

In `@Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs`:
- Line 74: Remove the IModerationService parameter from the DispatchController
constructor and initialize the existing _moderationService field by resolving
IModerationService through
Bootstrapper.GetKernel().Resolve<IModerationService>() inside the constructor.
Update all affected constructor call sites while preserving the controller’s
existing behavior.
- Around line 1792-1793: Update the call-note handling in DispatchController to
load the current reporter’s moderation requests once before the note loop,
instead of awaiting GetReporterRequestAsync for each note. Build a set of
flagged CallNote item IDs from that result, then assign note.IsFlagged by
checking callNote.CallNoteId against the set while preserving the existing
department, user, and moderation item-type filters.

---

Outside diff comments:
In `@Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs`:
- Around line 1461-1486: Update FlagMessage to explicitly map input.Reason to
the corresponding ModerationReason value instead of directly casting between
enums, and wrap FlagAsync in exception handling matching
ModerationController.Flag: map ArgumentException and InvalidOperationException
to BadRequest, UnauthorizedAccessException to Unauthorized, and preserve the
existing success response for successful flags.

In `@Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatStore.ts`:
- Around line 341-345: Update the thread-reply handling in the loop over
state.threadMessagesByRoot to include IsModerated from payload.IsModerated ??
payload.DeletedByModerator when calling upsertThreadMessage, alongside DeletedOn
and Body. Preserve the existing reply lookup and early return behavior.

In `@Web/Resgrid.Web/Areas/User/Controllers/MessagesController.cs`:
- Around line 37-56: Replace constructor injection with explicit service-locator
resolution in MessagesController: remove the IModerationService and moderation
localizer parameters and initialize both fields via
Bootstrapper.GetKernel().Resolve<T>(). In
Web/Resgrid.Web/Areas/User/Controllers/ModerationController.cs lines 13-18,
resolve IDepartmentGroupsService through Bootstrapper.GetKernel().Resolve<T>()
instead of injecting it; update each constructor accordingly.

---

Nitpick comments:
In `@Core/Resgrid.Services/ChatMessageService.cs`:
- Around line 316-324: Update the deletion event payload in the message deletion
flow around PublishEvent to remove DeletedByModerator = asModerator while
retaining message.IsModerated. Remove the corresponding DeletedByModerator
property from the event payload type and update any affected consumers to use
IsModerated only.

In `@Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs`:
- Line 69: Remove the no-op requestAlias assignment in the moderation repository
and replace its usage in the PostgreSQL statement around the generated query
with the literal “r” alias. Preserve the existing SQL behavior and remove the
now-unused variable.
- Around line 180-193: Make null handling consistent in QueryAsync and the
corresponding methods around the later query blocks: since constructors require
_unitOfWork, remove the null-conditional checks and use _unitOfWork.Connection
directly, or consistently guard the entire method before accessing
_unitOfWork.Transaction and CreateOrGetConnection(). Apply the same correction
to all three affected query methods.

In `@Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs`:
- Around line 45-46: Remove the unused IChatModerationService field and its
constructor parameter from ChatController, and update constructor assignments
and calls accordingly while preserving the existing IModerationService
dependency.

In `@Web/Resgrid.Web.Services/Models/v4/Moderation/ModerationApiModels.cs`:
- Around line 78-101: Replace the literal Range constraints on
FlagModerationInput.ItemType, FlagModerationInput.Reason, and
CompleteModerationInput.Disposition with enum-based validation using their
corresponding moderation enums, preferably by changing the properties to those
enum types or applying Enum.IsDefined validation. Preserve the existing note and
ItemId validation.

In `@Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatFormat.ts`:
- Line 192: Hoist the Intl.RelativeTimeFormat instance used by formatRelativeDay
to module scope and reuse it for each one-day-old date instead of constructing
it per call. Preserve the relative-day output, and verify the resulting
lowercase English text such as “yesterday” reads correctly in message,
moderation, and export table cells.

In
`@Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderation/ModerationRequestsTable.tsx`:
- Line 246: Update the RemoveContent button in ModerationRequestsTable so it
asks the user for confirmation before invoking complete(request, 2). Only call
complete after confirmation is accepted; preserve the existing isBusy disabled
state and behavior for other moderation actions.
- Around line 71-75: Update the personnel lookup used by personName to build a
Map keyed by userId once per people change, then read entries from that Map
instead of calling people.find for each author, report, or audit action.
Preserve the existing SystemOrUnknown fallback and display formatting for found
and missing users.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5f4c2e9b-e2eb-408b-8004-393cdc24a872

📥 Commits

Reviewing files that changed from the base of the PR and between ea6eef3 and 4bfbfd3.

⛔ Files ignored due to path filters (36)
  • Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Areas/User/Moderation/Moderation.ar.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Areas/User/Moderation/Moderation.de.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Areas/User/Moderation/Moderation.en.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Areas/User/Moderation/Moderation.es.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Areas/User/Moderation/Moderation.fr.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Areas/User/Moderation/Moderation.it.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Areas/User/Moderation/Moderation.pl.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Areas/User/Moderation/Moderation.sv.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Areas/User/Moderation/Moderation.uk.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Common.ar.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Common.de.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Common.en.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Common.es.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Common.fr.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Common.it.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Common.pl.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Common.sv.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Common.uk.resx is excluded by !**/*.resx
  • Tests/Resgrid.Tests/Chatbot/ChatbotDeptConfigAndSessionTests.cs is excluded by !**/Tests/**
  • Tests/Resgrid.Tests/Localization/ModerationLocalizationTests.cs is excluded by !**/Tests/**
  • Tests/Resgrid.Tests/Models/FormAutomationTests.cs is excluded by !**/Tests/**
  • Tests/Resgrid.Tests/Services/MessageServiceInboxTests.cs is excluded by !**/Tests/**
  • Tests/Resgrid.Tests/Services/ModerationServiceTests.cs is excluded by !**/Tests/**
  • Tests/Resgrid.Tests/Services/NotificationServiceTests.cs is excluded by !**/Tests/**
  • Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs is excluded by !**/Tests/**
  • Tests/Resgrid.Tests/Web/Services/MessagesControllerTests.cs is excluded by !**/Tests/**
  • Tests/Resgrid.Tests/Web/User/SubscriptionControllerTests.cs is excluded by !**/Tests/**
📒 Files selected for processing (69)
  • Core/Resgrid.Localization/Areas/User/Moderation/Moderation.cs
  • Core/Resgrid.Model/AuditLogTypes.cs
  • Core/Resgrid.Model/Chat/ChatMessage.cs
  • Core/Resgrid.Model/ChatbotDepartmentConfig.cs
  • Core/Resgrid.Model/FormAutomation.cs
  • Core/Resgrid.Model/Message.cs
  • Core/Resgrid.Model/Moderation/Moderation.cs
  • Core/Resgrid.Model/Repositories/IChatRepositories.cs
  • Core/Resgrid.Model/Repositories/IModerationRepositories.cs
  • Core/Resgrid.Model/Services/IModerationService.cs
  • Core/Resgrid.Services/AuditService.cs
  • Core/Resgrid.Services/ChatMessageService.cs
  • Core/Resgrid.Services/MessageService.cs
  • Core/Resgrid.Services/ModerationService.cs
  • Core/Resgrid.Services/NotificationService.cs
  • Core/Resgrid.Services/Resgrid.Services.csproj
  • Core/Resgrid.Services/ServicesModule.cs
  • Providers/Resgrid.Providers.Migrations/Migrations/M0111_CascadeCommunicationTestDeletes.cs
  • Providers/Resgrid.Providers.Migrations/Migrations/M0112_AddModeration.cs
  • Providers/Resgrid.Providers.MigrationsPg/Migrations/M0111_CascadeCommunicationTestDeletesPg.cs
  • Providers/Resgrid.Providers.MigrationsPg/Migrations/M0112_AddModerationPg.cs
  • Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs
  • Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs
  • Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs
  • Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs
  • Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs
  • Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs
  • Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs
  • Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs
  • Web/Resgrid.Web.Services/Controllers/v4/MessagesController.cs
  • Web/Resgrid.Web.Services/Controllers/v4/ModerationController.cs
  • Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs
  • Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs
  • Web/Resgrid.Web.Services/Models/v4/Moderation/ModerationApiModels.cs
  • Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
  • Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatModerationElement.tsx
  • Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPageElement.tsx
  • Web/Resgrid.Web/Areas/User/Apps/src/components/chat/FlagDialog.tsx
  • Web/Resgrid.Web/Areas/User/Apps/src/components/chat/atoms/MessageBubble.tsx
  • Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chat.css
  • Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatFormat.ts
  • Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatStore.ts
  • Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderation/ActionsTab.tsx
  • Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderation/ExportsTab.tsx
  • Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderation/FlagsTab.tsx
  • Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderation/ModerationRequestsTable.tsx
  • Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderation/ReportsTab.tsx
  • Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderation/SettingsTab.tsx
  • Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderationApi.ts
  • Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderationI18n.ts
  • Web/Resgrid.Web/Areas/User/Apps/src/components/chat/types.ts
  • Web/Resgrid.Web/Areas/User/Apps/src/elements.ts
  • Web/Resgrid.Web/Areas/User/Controllers/ChatController.cs
  • Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs
  • Web/Resgrid.Web/Areas/User/Controllers/MessagesController.cs
  • Web/Resgrid.Web/Areas/User/Controllers/ModerationController.cs
  • Web/Resgrid.Web/Areas/User/Controllers/SubscriptionController.cs
  • Web/Resgrid.Web/Areas/User/Models/Dispatch/FlagCallImageView.cs
  • Web/Resgrid.Web/Areas/User/Models/Dispatch/FlagCallNoteView.cs
  • Web/Resgrid.Web/Areas/User/Models/Messages/ViewMessageView.cs
  • Web/Resgrid.Web/Areas/User/Views/Chat/Moderation.cshtml
  • Web/Resgrid.Web/Areas/User/Views/Dispatch/FlagCallImage.cshtml
  • Web/Resgrid.Web/Areas/User/Views/Dispatch/FlagCallNote.cshtml
  • Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml
  • Web/Resgrid.Web/Areas/User/Views/Messages/ViewMessage.cshtml
  • Web/Resgrid.Web/Areas/User/Views/Moderation/Index.cshtml
  • Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml
  • Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml
  • Web/Resgrid.Web/wwwroot/js/app/internal/templates/resgrid.templates.newtemplate.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • Core/Resgrid.Services/NotificationService.cs

Comment thread Core/Resgrid.Model/Repositories/IChatRepositories.cs
Comment thread Core/Resgrid.Services/ChatMessageService.cs Outdated
Comment thread Core/Resgrid.Services/ModerationService.cs
Comment thread Core/Resgrid.Services/ModerationService.cs Outdated
Comment on lines +338 to +343
ChatAttachment attachment = null;
var attachmentMetadata = await _chatAttachmentRepository.GetMetadataByMessageIdsAsync(new[] { itemId });
var firstAttachment = attachmentMetadata?.FirstOrDefault();
if (firstAttachment != null)
attachment = await _chatAttachmentRepository.GetByIdAsync(firstAttachment.ChatAttachmentId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Only the first attachment is captured as evidence.

LoadEvidenceAsync reads the attachment metadata list for the chat message and keeps FirstOrDefault(). If the reported message carries more than one attachment, the remaining attachments are never captured. RemoveLiveContentAsync still tombstones the whole message, so the uncaptured attachments are lost from the audit trail.

Capture every attachment, or document the single-attachment limit in the method comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Core/Resgrid.Services/ModerationService.cs` around lines 338 - 343, Update
LoadEvidenceAsync to retain and load every attachment returned by
GetMetadataByMessageIdsAsync instead of selecting only FirstOrDefault, and pass
the complete attachment collection through the evidence model so
RemoveLiveContentAsync preserves all attachments in the audit trail. If the
surrounding API cannot support multiple attachments, document the enforced
single-attachment limitation in LoadEvidenceAsync instead.

<strong>{moderationText(ACTION_LABEL_KEYS[action.ActionType] ?? 'Action')}</strong> {moderationText('By')} {personName(action.PerformedByUserId)}
<div>{action.Note || moderationText('NoNote')}</div>
<div className="rgchat-convo__sub">
{formatTimestamp(action.PerformedOn)} · {action.ActorRole ? moderationText(`Actor${action.ActorRole}`) : moderationText('UnknownRole')} · {action.IpAddress || moderationText('NoIp')} · {moderationText('TraceFormat', action.TraceId || moderationText('NotAvailable'))}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Do not build a localization key from stored server data.

moderationText(Actor${action.ActorRole}) derives the key from the persisted ActorRole string. moderationText returns the key itself when the entry is missing, so an unmapped role renders a raw identifier. The SQL Server and PostgreSQL migrations both write ActorRole = 'LegacyImport' for every imported action, which produces the key ActorLegacyImport.

Use an explicit map with a fallback, like ITEM_LABEL_KEYS and ACTION_LABEL_KEYS do for the numeric enums.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderation/ModerationRequestsTable.tsx`
at line 270, Replace the dynamic moderationText key construction for
action.ActorRole in ModerationRequestsTable with an explicit
role-to-localization-key map and a defined fallback for unmapped roles,
following the existing ITEM_LABEL_KEYS and ACTION_LABEL_KEYS pattern. Preserve
the UnknownRole behavior when ActorRole is absent and ensure persisted values
such as LegacyImport do not render as raw localization keys.

private readonly ICheckInTimerService _checkInTimerService;
private readonly IWeatherAlertService _weatherAlertService;
private readonly ICallDispatchStatusService _callDispatchStatusService;
private readonly IModerationService _moderationService;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Resolve IModerationService through the required service locator.

Line 87 adds constructor injection for IModerationService. Remove this parameter. Resolve the service in the constructor with Bootstrapper.GetKernel().Resolve<IModerationService>().

As per coding guidelines, use Service Locator pattern via Bootstrapper.GetKernel().Resolve<T>() to resolve dependencies explicitly in constructors, rather than constructor injection.

Also applies to: 87-87, 118-118

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs` at line 74,
Remove the IModerationService parameter from the DispatchController constructor
and initialize the existing _moderationService field by resolving
IModerationService through
Bootstrapper.GetKernel().Resolve<IModerationService>() inside the constructor.
Update all affected constructor call sites while preserving the controller’s
existing behavior.

Source: Coding guidelines

Comment thread Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs Outdated

return resourceSet.Cast<DictionaryEntry>()
.Where(x => x.Key is string && x.Value is string)
.ToDictionary(x => (string)x.Key, x => (string)x.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 low

Unsafe type casting violates team rule. Use the as operator or pattern matching for safe casts and guard null results before usage.

Kody rule violation: Use safe type casting with as operator

Prompt for LLM

File Core/Resgrid.Localization/Areas/User/Moderation/Moderation.cs:

Line 49:

Unsafe type casting violates team rule. Use the `as` operator or pattern matching for safe casts and guard null results before usage.

Talk to Kody by mentioning @kody

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

{
var cultureInfo = GetSupportedCulture(culture);
var value = ResourceManager.GetString(key, cultureInfo)
?? ResourceManager.GetString(key, CultureInfo.GetCultureInfo("en"))

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 low

Magic string "en" for the default culture is repeated across multiple locations, risking inconsistency during changes. Define a private constant like private const string DefaultCulture = "en"; in ModerationResources and reference it.

Kody rule violation: Centralize string constants

Prompt for LLM

File Core/Resgrid.Localization/Areas/User/Moderation/Moderation.cs:

Line 28:

Magic string `"en"` for the default culture is repeated across multiple locations, risking inconsistency during changes. Define a private constant like `private const string DefaultCulture = "en";` in `ModerationResources` and reference it.

Talk to Kody by mentioning @kody

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

}
}

private async Task<List<ModerationRequest>> HydrateAsync(IEnumerable<ModerationRequest> requests,

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 Performance high

Query amplification in HydrateAsync issues two sequential database round-trips (GetByRequestAsync for reports and actions) per request, causing up to 400 queries on a 200-item page. Batch-load reports and actions using WHERE ModerationRequestId IN (...) and group them in memory.

var requestIds = result.Select(x => x.ModerationRequestId).ToList();
var allReports = await _moderationReportRepository.GetByRequestIdsAsync(requestIds);
var allActions = await _moderationActionRepository.GetByRequestIdsAsync(requestIds);
var reportsByRequest = allReports.ToLookup(x => x.ModerationRequestId);
var actionsByRequest = allActions.ToLookup(x => x.ModerationRequestId);

foreach (var request in result)
{
	var reports = reportsByRequest[request.ModerationRequestId].ToList();
	var actions = actionsByRequest[request.ModerationRequestId].ToList();

	if (visibleGroupIds == null)
	{
		request.Reports = reports;
		request.Actions = actions;
	}
	else
	{
		ApplyGroupScope(request, reports, actions, visibleGroupIds, viewerUserId);
	}
}
Prompt for LLM

File Core/Resgrid.Services/ModerationService.cs:

Line 519:

Query amplification in `HydrateAsync` issues two sequential database round-trips (`GetByRequestAsync` for reports and actions) per request, causing up to 400 queries on a 200-item page. Batch-load reports and actions using `WHERE ModerationRequestId IN (...)` and group them in memory.

Suggested Code:

var requestIds = result.Select(x => x.ModerationRequestId).ToList();
var allReports = await _moderationReportRepository.GetByRequestIdsAsync(requestIds);
var allActions = await _moderationActionRepository.GetByRequestIdsAsync(requestIds);
var reportsByRequest = allReports.ToLookup(x => x.ModerationRequestId);
var actionsByRequest = allActions.ToLookup(x => x.ModerationRequestId);

foreach (var request in result)
{
	var reports = reportsByRequest[request.ModerationRequestId].ToList();
	var actions = actionsByRequest[request.ModerationRequestId].ToList();

	if (visibleGroupIds == null)
	{
		request.Reports = reports;
		request.Actions = actions;
	}
	else
	{
		ApplyGroupScope(request, reports, actions, visibleGroupIds, viewerUserId);
	}
}

Talk to Kody by mentioning @kody

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

}
}

private async Task<List<ModerationRequest>> HydrateAsync(IEnumerable<ModerationRequest> requests,

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 Performance high

O(N) query amplification in HydrateAsync issues two database round-trips (GetByRequestAsync for reports and actions) per ModerationRequest, causing up to 400 sequential queries on a 200-item page. Replace the loop with two batch queries keyed on request IDs (WHERE ModerationRequestId IN @ids) and group the results client-side.

// Batch-load all reports and actions for the page in two queries, then group client-side:
// var ids = result.Select(r => r.ModerationRequestId).ToList();
// var allReports = (await _moderationReportRepository.GetByRequestIdsAsync(ids))
//     .GroupBy(r => r.ModerationRequestId).ToDictionary(g => g.Key, g => g.ToList());
// var allActions = (await _moderationActionRepository.GetByRequestIdsAsync(ids))
//     .GroupBy(a => a.ModerationRequestId).ToDictionary(g => g.Key, g => g.ToList());
// then iterate result and pull reports/actions from the dictionaries (falling back to empty lists).
Prompt for LLM

File Core/Resgrid.Services/ModerationService.cs:

Line 519:

O(N) query amplification in `HydrateAsync` issues two database round-trips (`GetByRequestAsync` for reports and actions) per `ModerationRequest`, causing up to 400 sequential queries on a 200-item page. Replace the loop with two batch queries keyed on request IDs (`WHERE ModerationRequestId IN @ids`) and group the results client-side.

Suggested Code:

// Batch-load all reports and actions for the page in two queries, then group client-side:
// var ids = result.Select(r => r.ModerationRequestId).ToList();
// var allReports = (await _moderationReportRepository.GetByRequestIdsAsync(ids))
//     .GroupBy(r => r.ModerationRequestId).ToDictionary(g => g.Key, g => g.ToList());
// var allActions = (await _moderationActionRepository.GetByRequestIdsAsync(ids))
//     .GroupBy(a => a.ModerationRequestId).ToDictionary(g => g.Key, g => g.ToList());
// then iterate result and pull reports/actions from the dictionaries (falling back to empty lists).

Talk to Kody by mentioning @kody

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

request.CompletedOn = null;
request.AdminNote = null;
request.ModifiedOn = DateTime.UtcNow;
await _moderationRequestRepository.UpdateAsync(request, cancellationToken);

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

Database inconsistency risk arises when the request-reopen block performs three separate writes (UpdateAsync, InsertAsync, SaveAuditLogAsync) without a wrapping transaction. Wrap the entire sequence in a transaction or unit-of-work to ensure atomic commits.

Kody rule violation: Handle transaction rollbacks properly

Prompt for LLM

File Core/Resgrid.Services/ModerationService.cs:

Line 141:

Database inconsistency risk arises when the request-reopen block performs three separate writes (`UpdateAsync`, `InsertAsync`, `SaveAuditLogAsync`) without a wrapping transaction. Wrap the entire sequence in a transaction or unit-of-work to ensure atomic commits.

Talk to Kody by mentioning @kody

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

.WithColumn("ContentAuthorUserId").AsString(450).Nullable()
.WithColumn("ContentAuthorUnitId").AsInt32().Nullable()
.WithColumn("ContentCreatedOn").AsDateTime2().Nullable()
.WithColumn("OriginalSubject").AsString(int.MaxValue).Nullable()

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 low

Inefficient row storage occurs because OriginalSubject uses NVARCHAR(MAX), preventing SQL Server optimization for bounded subjects. Use a bounded length like AsString(512).

Kody rule violation: Optimize string column types

Prompt for LLM

File Providers/Resgrid.Providers.Migrations/Migrations/M0112_AddModeration.cs:

Line 28:

Inefficient row storage occurs because `OriginalSubject` uses `NVARCHAR(MAX)`, preventing SQL Server optimization for bounded subjects. Use a bounded length like `AsString(512)`.

Talk to Kody by mentioning @kody

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

Comment on lines +16 to +19
Create.ForeignKey("fk_communicationtestruns_communicationtests")
.FromTable("communicationtestruns").ForeignColumn("communicationtestid")
.ToTable("communicationtests").PrimaryColumn("communicationtestid")
.OnDelete(Rule.Cascade);

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 critical

Locking and downtime risk occurs when adding a new FK constraint, as it takes ACCESS EXCLUSIVE locks while validating existing rows. Use the PostgreSQL online pattern by adding the constraint NOT VALID first, then executing VALIDATE CONSTRAINT in a later transaction.

Kody rule violation: Block risky database migrations (locking ops, downtime risk)

Prompt for LLM

File Providers/Resgrid.Providers.MigrationsPg/Migrations/M0111_CascadeCommunicationTestDeletesPg.cs:

Line 16 to 19:

Locking and downtime risk occurs when adding a new FK constraint, as it takes `ACCESS EXCLUSIVE` locks while validating existing rows. Use the PostgreSQL online pattern by adding the constraint `NOT VALID` first, then executing `VALIDATE CONSTRAINT` in a later transaction.

Talk to Kody by mentioning @kody

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

MIN(m.senderuserid::text), MIN(m.senderunitid), MIN(m.senton),
COALESCE(MIN(m.body), (SELECT e.priorbody FROM chatmessageedits e
WHERE e.chatmessageid = f.chatmessageid ORDER BY e.editedon DESC LIMIT 1)),
(SELECT ca.filename FROM chatattachments ca

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

Tripled per-row I/O occurs due to three separate correlated subqueries to chatattachments for the same chatmessageid. Replace them with a single LEFT JOIN LATERAL to select the required fields in one pass.

Kody rule violation: Optimize database queries with JOINs

Prompt for LLM

File Providers/Resgrid.Providers.MigrationsPg/Migrations/M0112_AddModerationPg.cs:

Line 141:

Tripled per-row I/O occurs due to three separate correlated subqueries to `chatattachments` for the same `chatmessageid`. Replace them with a single `LEFT JOIN LATERAL` to select the required fields in one pass.

Talk to Kody by mentioning @kody

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

}
}

var extra = filters.Count > 0 ? " AND " + string.Join(" AND ", filters) : string.Empty;

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 low

Error-prone string concatenation using + violates team rule. Use template literals to improve readability.

Kody rule violation: Use Template Literals Instead of String Concatenation

Prompt for LLM

File Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs:

Line 144:

Error-prone string concatenation using `+` violates team rule. Use template literals to improve readability.

Talk to Kody by mentioning @kody

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

{
foreach (var pair in english)
{
var expected = Regex.Matches(pair.Value, @"\{\d+\}").Select(x => x.Value).OrderBy(x => x);

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

Regular expression denial of service (ReDoS) vulnerability violates team rule. Define a timeout when using regex on untrusted input to prevent DoS attacks.

Kody rule violation: Specify Timeout for Regular Expressions

Prompt for LLM

File Tests/Resgrid.Tests/Localization/ModerationLocalizationTests.cs:

Line 118:

Regular expression denial of service (ReDoS) vulnerability violates team rule. Define a timeout when using regex on untrusted input to prevent DoS attacks.

Talk to Kody by mentioning @kody

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

{
foreach (var pair in english)
{
var expected = Regex.Matches(pair.Value, @"\{\d+\}").Select(x => x.Value).OrderBy(x => x);

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 low

Wasted CPU cycles occur because Regex.Matches is called with a constant pattern inside a foreach loop, forcing recompilation on every iteration. Declare a private static readonly Regex with RegexOptions.Compiled at the class level and reuse it.

Kody rule violation: Cache expensive operations outside loops

Prompt for LLM

File Tests/Resgrid.Tests/Localization/ModerationLocalizationTests.cs:

Line 118:

Wasted CPU cycles occur because `Regex.Matches` is called with a constant pattern inside a `foreach` loop, forcing recompilation on every iteration. Declare a `private static readonly Regex` with `RegexOptions.Compiled` at the class level and reuse it.

Talk to Kody by mentioning @kody

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

.GetColumns(new SqlServerConfiguration(), ignoreProperties: automation.IgnoredProperties)
.ToList();

automation.IdType.Should().Be(1);

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 low

Opaque magic number 1 represents an IdType value without a named constant, making the test brittle and hard to read. Reference the named enum or constant, such as automation.IdType.Should().Be((int)IdType.String), for self-documenting assertions.

Kody rule violation: Replace magic numbers with named constants

Prompt for LLM

File Tests/Resgrid.Tests/Models/FormAutomationTests.cs:

Line 28:

Opaque magic number `1` represents an `IdType` value without a named constant, making the test brittle and hard to read. Reference the named enum or constant, such as `automation.IdType.Should().Be((int)IdType.String)`, for self-documenting assertions.

Talk to Kody by mentioning @kody

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

{
var response = await _controller.GetCall(callId);

response.Result.Should().BeOfType<BadRequestResult>();

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 critical

Blocking async methods with .Result or .Wait() can cause deadlocks and violates team rule. Use await instead for proper asynchronous execution.

Kody rule violation: Avoid Blocking Calls to Async Methods

Prompt for LLM

File Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs:

Line 63:

Blocking async methods with `.Result` or `.Wait()` can cause deadlocks and violates team rule. Use `await` instead for proper asynchronous execution.

Talk to Kody by mentioning @kody

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

{
var response = await _controller.GetCall(callId);

response.Result.Should().BeOfType<BadRequestResult>();

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

Blocking async operation violates team rule. Await Tasks instead of blocking with .Result or .Wait(), and prefer async/await end-to-end.

Kody rule violation: Await async operations properly

Prompt for LLM

File Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs:

Line 63:

Blocking async operation violates team rule. Await Tasks instead of blocking with `.Result` or `.Wait()`, and prefer `async/await` end-to-end.

Talk to Kody by mentioning @kody

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

Comment on lines +1282 to +1284
var message = await _chatMessageService.GetMessageByIdAsync(attachment.ChatMessageId);
if (message == null || message.DeletedOn.HasValue)
return NotFound();

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 low

Duplicated domain logic for the deleted-message check violates the DRY principle across GetAttachment and GetAttachmentThumbnail. Extract a helper method like EnsureMessageNotDeletedAsync(ChatMessageId) to handle the rule in one location.

Kody rule violation: Extract duplicated business logic

Prompt for LLM

File Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs:

Line 1282 to 1284:

Duplicated domain logic for the deleted-message check violates the DRY principle across `GetAttachment` and `GetAttachmentThumbnail`. Extract a helper method like `EnsureMessageNotDeletedAsync(ChatMessageId)` to handle the rule in one location.

Talk to Kody by mentioning @kody

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

Comment on lines +191 to +198
ContentCreatedOn, OriginalText, OriginalMetadataJson, Status, Disposition, CreatedOn, ModifiedOn)
SELECT CONCAT('callnote-', n.CallNoteId), c.DepartmentId, 2, CONVERT(varchar(32), n.CallNoteId),
n.CallId, n.UserId, n.Timestamp, n.Note,
(SELECT n.Source AS [source], n.Latitude AS [latitude], n.Longitude AS [longitude]
FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER),
0, 0, COALESCE(n.FlaggedOn, n.Timestamp), COALESCE(n.FlaggedOn, n.Timestamp)
FROM CallNotes n
INNER JOIN Calls c ON c.CallId = n.CallId

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 critical

Unbatched INSERT...SELECT operations in ImportLegacyFlags cause extended lock contention and transaction log bloat on production-scale data. Implement keyset batching using ranges like TOP @batch WHERE CallNoteId > @last, commit per batch, and ensure migration idempotency.

Kody rule violation: Block risky database migrations (locking ops, downtime risk)

Prompt for LLM

File Providers/Resgrid.Providers.Migrations/Migrations/M0112_AddModeration.cs:

Line 191 to 198:

Unbatched `INSERT...SELECT` operations in `ImportLegacyFlags` cause extended lock contention and transaction log bloat on production-scale data. Implement keyset batching using ranges like `TOP @batch WHERE CallNoteId > @last`, commit per batch, and ensure migration idempotency.

Talk to Kody by mentioning @kody

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

Comment on lines +42 to +53
? $@"SELECT moderationrequestid, departmentid, itemtype, itemid, callid,
chatchannelid, contentauthoruserid, contentauthorunitid, contentcreatedon,
originalsubject, originaltext, originalfilename, originalcontenttype,
originalmetadatajson, status, disposition, createdon, modifiedon,
completedbyuserid, completedon, adminnote
FROM {_sqlConfiguration.SchemaName}.moderationrequests WHERE departmentid = {notation}DepartmentId AND itemtype = {notation}ItemType AND itemid = {notation}ItemId"
: $@"SELECT [ModerationRequestId], [DepartmentId], [ItemType], [ItemId], [CallId],
[ChatChannelId], [ContentAuthorUserId], [ContentAuthorUnitId], [ContentCreatedOn],
[OriginalSubject], [OriginalText], [OriginalFileName], [OriginalContentType],
[OriginalMetadataJson], [Status], [Disposition], [CreatedOn], [ModifiedOn],
[CompletedByUserId], [CompletedOn], [AdminNote]
FROM {_sqlConfiguration.SchemaName}.[ModerationRequests] WHERE [DepartmentId] = {notation}DepartmentId AND [ItemType] = {notation}ItemType AND [ItemId] = {notation}ItemId";

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 Bug critical

Omitting the originalcontent column in GetByItemAsync permanently nulls out retained binary evidence during updates, as Dapper repositories write all mapped columns without change tracking. Include originalcontent, evidencetext, evidencecontent, and evidencemetadatajson in the GetByItemAsync query to prevent overwriting unloaded fields.

var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres
	? $@"SELECT moderationrequestid, departmentid, itemtype, itemid, callid,
chatchannelid, contentauthoruserid, contentauthorunitid, contentcreatedon,
originalsubject, originaltext, originalfilename, originalcontenttype,
originalmetadatajson, originalcontent, status, disposition, createdon, modifiedon,
completedbyuserid, completedon, adminnote
FROM {_sqlConfiguration.SchemaName}.moderationrequests WHERE departmentid = {notation}DepartmentId AND itemtype = {notation}ItemType AND itemid = {notation}ItemId"
Prompt for LLM

File Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs:

Line 42 to 53:

Omitting the `originalcontent` column in `GetByItemAsync` permanently nulls out retained binary evidence during updates, as Dapper repositories write all mapped columns without change tracking. Include `originalcontent`, `evidencetext`, `evidencecontent`, and `evidencemetadatajson` in the `GetByItemAsync` query to prevent overwriting unloaded fields.

Suggested Code:

var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres
	? $@"SELECT moderationrequestid, departmentid, itemtype, itemid, callid,
chatchannelid, contentauthoruserid, contentauthorunitid, contentcreatedon,
originalsubject, originaltext, originalfilename, originalcontenttype,
originalmetadatajson, originalcontent, status, disposition, createdon, modifiedon,
completedbyuserid, completedon, adminnote
FROM {_sqlConfiguration.SchemaName}.moderationrequests WHERE departmentid = {notation}DepartmentId AND itemtype = {notation}ItemType AND itemid = {notation}ItemId"

Talk to Kody by mentioning @kody

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

}
catch (Exception ex)
{
Logging.LogException(ex);

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

Insufficient logging context occurs when Logging.LogException(ex) omits operation names and relevant identifiers. Pass structured context, such as Operation = nameof(GetByItemsAndReporterAsync), DepartmentId, ItemType, and ReporterUserId, alongside the exception to improve traceability.

Kody rule violation: Include error context in structured logs

Prompt for LLM

File Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs:

Line 105:

Insufficient logging context occurs when `Logging.LogException(ex)` omits operation names and relevant identifiers. Pass structured context, such as `Operation = nameof(GetByItemsAndReporterAsync)`, `DepartmentId`, `ItemType`, and `ReporterUserId`, alongside the exception to improve traceability.

Talk to Kody by mentioning @kody

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

: $"SELECT * FROM {_sqlConfiguration.SchemaName}.[ModerationReports] WHERE [ModerationRequestId] IN {notation}Ids ORDER BY [ModerationRequestId], [ReportedOn]";

var select = new Func<DbConnection, Task<IEnumerable<ModerationReport>>>(connection =>
connection.QueryAsync<ModerationReport>(sql, new { Ids = ids }, _unitOfWork.Transaction));

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

Null pointer dereference identified when _unitOfWork.Transaction is accessed without a null guard, despite treating _unitOfWork as potentially null elsewhere. Use the null-conditional operator (_unitOfWork?.Transaction) inside the lambda or add an explicit null check before delegate invocation to prevent a runtime NullReferenceException.

Kody rule violation: Add null checks to prevent NullReferenceException

Prompt for LLM

File Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs:

Line 292:

Null pointer dereference identified when `_unitOfWork.Transaction` is accessed without a null guard, despite treating `_unitOfWork` as potentially null elsewhere. Use the null-conditional operator (`_unitOfWork?.Transaction`) inside the lambda or add an explicit null check before delegate invocation to prevent a runtime `NullReferenceException`.

Talk to Kody by mentioning @kody

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

Comment on lines +425 to +435
var select = new Func<DbConnection, Task<IEnumerable<ModerationAction>>>(connection =>
connection.QueryAsync<ModerationAction>(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());

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 low

Code duplication identified in the connection-creation and query-execution block (lines 425-435), mirroring logic in GetByRequestIdsAsync (lines 291-301). Extract a generic helper like ExecuteWithConnectionsAsync<T>(string sql, object param) to encapsulate connection creation and the _unitOfWork fallback logic.

Kody rule violation: Extract duplicated logic into functions

Prompt for LLM

File Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs:

Line 425 to 435:

Code duplication identified in the connection-creation and query-execution block (lines 425-435), mirroring logic in `GetByRequestIdsAsync` (lines 291-301). Extract a generic helper like `ExecuteWithConnectionsAsync<T>(string sql, object param)` to encapsulate connection creation and the `_unitOfWork` fallback logic.

Talk to Kody by mentioning @kody

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

Comment on lines +70 to +74
.catch(() => {
if (requestToken === flagRequestToken.current) {
setFlagStatus(null);
}
});

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

Silent exception swallowing identified where the catch handler fails to capture or log rejection contexts, obscuring debugging failures. Capture the error parameter, log structured context such as console.error('getMyModerationRequest failed', { chatMessageId: message.ChatMessageId, err }), or rethrow the exception.

Kody rule violation: Avoid empty catch blocks

Prompt for LLM

File Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPageElement.tsx:

Line 70 to 74:

Silent exception swallowing identified where the `catch` handler fails to capture or log rejection contexts, obscuring debugging failures. Capture the error parameter, log structured context such as `console.error('getMyModerationRequest failed', { chatMessageId: message.ChatMessageId, err })`, or rethrow the exception.

Talk to Kody by mentioning @kody

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

<div className="rgchat-mod__filters">
<label>
<span>{moderationText('Status')}</span>
<select className="rgchat-input" value={status} onChange={(event) => { setStatus(Number(event.target.value)); setPage(1); }}>

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

Performance degradation identified due to inline arrow functions in JSX props creating new function instances on every render cycle. Extract these function definitions outside the render method to prevent unnecessary memory allocations.

Kody rule violation: Avoid using .bind() or arrow functions in JSX props

Prompt for LLM

File Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderation/ModerationRequestsTable.tsx:

Line 151:

Performance degradation identified due to inline arrow functions in JSX props creating new function instances on every render cycle. Extract these function definitions outside the render method to prevent unnecessary memory allocations.

Talk to Kody by mentioning @kody

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

if (ni?.Type == (int)EventTypes.ModerationRequestCompleted)
{
var moderationService = Bootstrapper.GetKernel().Resolve<IModerationService>();
await moderationService.NotifyReportersAsync(ni.Value, cancellationToken);

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

Unguarded async exception propagation risks system instability when awaiting NotifyReportersAsync without error handling. Wrap the awaited call in a try/catch block and log the exception context to handle moderation service rejections safely.

Kody rule violation: Handle async operations with proper error handling

Prompt for LLM

File Workers/Resgrid.Workers.Framework/Logic/NotificationBroadcastLogic.cs:

Line 23:

Unguarded async exception propagation risks system instability when awaiting `NotifyReportersAsync` without error handling. Wrap the awaited call in a `try/catch` block and log the exception context to handle moderation service rejections safely.

Talk to Kody by mentioning @kody

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

@Resgrid-Bot

This comment has been minimized.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Providers/Resgrid.Providers.Migrations/Migrations/M0112_AddModeration.cs`:
- Around line 190-195: Update the moderation request aggregation around the
computed Status and the CompletedByUserId/CompletedOn expressions so both
completion columns are populated only when the aggregated status is not Open
(Status = 0); otherwise return NULL. Preserve the existing MAX-based values for
non-open requests and keep the surrounding aggregation unchanged.
- Around line 144-159: Fix the import guard in the migration’s Execute callback
so it checks for the presence of an existing LegacyImport action, rather than
any moderation request lacking one, before calling ImportLegacyFlags. Preserve
rerun safety through ImportLegacyFlags’ existing NOT EXISTS guards; if
partial-import resumption is required, remove the outer guard and always invoke
ImportLegacyFlags.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 059bec34-1855-473b-9b6d-1fd44f4109a0

📥 Commits

Reviewing files that changed from the base of the PR and between 46680c9 and f7eb8f8.

⛔ Files ignored due to path filters (2)
  • Tests/Resgrid.Tests/Services/ModerationServiceTests.cs is excluded by !**/Tests/**
  • Tests/Resgrid.Tests/Web/Services/ModerationControllerTests.cs is excluded by !**/Tests/**
📒 Files selected for processing (5)
  • Core/Resgrid.Model/Moderation/Moderation.cs
  • Core/Resgrid.Services/ModerationService.cs
  • Providers/Resgrid.Providers.Migrations/Migrations/M0112_AddModeration.cs
  • Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs
  • Web/Resgrid.Web.Services/Controllers/v4/ModerationController.cs
🚧 Files skipped from review as they are similar to previous changes (4)
  • Core/Resgrid.Services/ModerationService.cs
  • Core/Resgrid.Model/Moderation/Moderation.cs
  • Web/Resgrid.Web.Services/Controllers/v4/ModerationController.cs
  • Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs

Comment on lines +144 to +159
Execute.WithConnection((connection, _) =>
{
using var command = connection.CreateCommand();
command.CommandText = @"SELECT TOP (1) 1
FROM ModerationRequests r
WHERE NOT EXISTS
(
SELECT 1
FROM ModerationActions a
WHERE a.ModerationRequestId = r.ModerationRequestId
AND a.ActorRole = 'LegacyImport'
);";

if (command.ExecuteScalar() == null)
ImportLegacyFlags(connection);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

The import guard is inverted and can skip the legacy import permanently.

The probe returns a row when a ModerationRequests row exists that has no LegacyImport action. ImportLegacyFlags runs only when the probe returns nothing. Two consequences follow:

  • If any moderation request was created by the application (no LegacyImport action), the probe returns 1 and the whole legacy import is skipped.
  • If a previous run committed some batches and then failed, the remaining legacy rows are never imported, because the imported requests now coexist with rows that satisfy the probe.

The intent is "no legacy import has run yet". Test for the presence of a LegacyImport action instead. Because every insert in ImportLegacyFlags is guarded by a NOT EXISTS check on the target key, a rerun is safe.

🐛 Proposed fix for the import guard
 			Execute.WithConnection((connection, _) =>
 			{
 				using var command = connection.CreateCommand();
 				command.CommandText = @"SELECT TOP (1) 1
-FROM ModerationRequests r
-WHERE NOT EXISTS
-(
-    SELECT 1
-    FROM ModerationActions a
-    WHERE a.ModerationRequestId = r.ModerationRequestId
-      AND a.ActorRole = 'LegacyImport'
-);";
+FROM ModerationActions a
+WHERE a.ActorRole = 'LegacyImport';";
 
 				if (command.ExecuteScalar() == null)
 					ImportLegacyFlags(connection);
 			});

Note: this still skips a resumed import after a partial run. If resumption matters, drop the guard and rely on the NOT EXISTS checks inside ImportLegacyFlags.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Providers/Resgrid.Providers.Migrations/Migrations/M0112_AddModeration.cs`
around lines 144 - 159, Fix the import guard in the migration’s Execute callback
so it checks for the presence of an existing LegacyImport action, rather than
any moderation request lacking one, before calling ImportLegacyFlags. Preserve
rerun safety through ImportLegacyFlags’ existing NOT EXISTS guards; if
partial-import resumption is required, remove the outer guard and always invoke
ImportLegacyFlags.

Comment on lines +190 to +195
CASE WHEN SUM(CASE WHEN f.Status = 0 THEN 1 ELSE 0 END) > 0 THEN 0 ELSE 1 END AS Status,
CASE WHEN SUM(CASE WHEN f.Status = 0 THEN 1 ELSE 0 END) > 0 THEN 0
WHEN SUM(CASE WHEN f.Status = 3 THEN 1 ELSE 0 END) > 0 THEN 2 ELSE 1 END AS Disposition,
MIN(f.FlaggedOn) AS CreatedOn, MAX(COALESCE(f.ReviewedOn, f.FlaggedOn)) AS ModifiedOn,
MAX(f.ReviewedByUserId) AS CompletedByUserId, MAX(f.ReviewedOn) AS CompletedOn,
MAX(f.ResolutionNote) AS AdminNote

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Open imported requests can carry completion values.

Status becomes 0 when any flag has Status = 0, but CompletedByUserId and CompletedOn are still taken with MAX across all flags of the message. A message with one open flag and one reviewed flag produces an Open request that has a completion user and a completion timestamp. ModerationRequestRepository reads both columns, so consumers see a completed-looking open request.

Set the completion columns only when the computed status is not Open.

🐛 Proposed fix for the completion columns
-           MAX(f.ReviewedByUserId) AS CompletedByUserId, MAX(f.ReviewedOn) AS CompletedOn,
+           CASE WHEN SUM(CASE WHEN f.Status = 0 THEN 1 ELSE 0 END) > 0 THEN NULL
+                ELSE MAX(f.ReviewedByUserId) END AS CompletedByUserId,
+           CASE WHEN SUM(CASE WHEN f.Status = 0 THEN 1 ELSE 0 END) > 0 THEN NULL
+                ELSE MAX(f.ReviewedOn) END AS CompletedOn,
            MAX(f.ResolutionNote) AS AdminNote
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
CASE WHEN SUM(CASE WHEN f.Status = 0 THEN 1 ELSE 0 END) > 0 THEN 0 ELSE 1 END AS Status,
CASE WHEN SUM(CASE WHEN f.Status = 0 THEN 1 ELSE 0 END) > 0 THEN 0
WHEN SUM(CASE WHEN f.Status = 3 THEN 1 ELSE 0 END) > 0 THEN 2 ELSE 1 END AS Disposition,
MIN(f.FlaggedOn) AS CreatedOn, MAX(COALESCE(f.ReviewedOn, f.FlaggedOn)) AS ModifiedOn,
MAX(f.ReviewedByUserId) AS CompletedByUserId, MAX(f.ReviewedOn) AS CompletedOn,
MAX(f.ResolutionNote) AS AdminNote
CASE WHEN SUM(CASE WHEN f.Status = 0 THEN 1 ELSE 0 END) > 0 THEN 0 ELSE 1 END AS Status,
CASE WHEN SUM(CASE WHEN f.Status = 0 THEN 1 ELSE 0 END) > 0 THEN 0
WHEN SUM(CASE WHEN f.Status = 3 THEN 1 ELSE 0 END) > 0 THEN 2 ELSE 1 END AS Disposition,
MIN(f.FlaggedOn) AS CreatedOn, MAX(COALESCE(f.ReviewedOn, f.FlaggedOn)) AS ModifiedOn,
CASE WHEN SUM(CASE WHEN f.Status = 0 THEN 1 ELSE 0 END) > 0 THEN NULL
ELSE MAX(f.ReviewedByUserId) END AS CompletedByUserId,
CASE WHEN SUM(CASE WHEN f.Status = 0 THEN 1 ELSE 0 END) > 0 THEN NULL
ELSE MAX(f.ReviewedOn) END AS CompletedOn,
MAX(f.ResolutionNote) AS AdminNote
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Providers/Resgrid.Providers.Migrations/Migrations/M0112_AddModeration.cs`
around lines 190 - 195, Update the moderation request aggregation around the
computed Status and the CompletedByUserId/CompletedOn expressions so both
completion columns are populated only when the aggregated status is not Open
(Status = 0); otherwise return NULL. Preserve the existing MAX-based values for
non-open requests and keep the surrounding aggregation unchanged.

Comment on lines +384 to +387
await RecordActionAsync(request, ModerationActionType.ReportersNotified, null, null,
request.Status, request.Status, null, null, cancellationToken);
await NotifyReportersAsync(request, reports, (ModerationDisposition)request.Disposition,
request.AdminNote, cancellationToken);

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 Bug high

At-most-once delivery semantics occur because RecordActionAsync (line 700) auto-commits the ReportersNotified guard via RepositoryBase.InsertAsync (lines 192-199) before the inner send loop (lines 761-787) executes, permanently short-circuiting future retry attempts at lines 378-380 upon partial failure. Move the RecordActionAsync call below line 386 so the guard only suppresses genuinely completed deliveries after NotifyReportersAsync succeeds.

var reports = (await _moderationReportRepository.GetByRequestAsync(moderationRequestId))?.ToList()
    ?? new List<ModerationReport>();
await NotifyReportersAsync(request, reports, (ModerationDisposition)request.Disposition,
    request.AdminNote, cancellationToken);
await RecordActionAsync(request, ModerationActionType.ReportersNotified, null, null,
    request.Status, request.Status, null, null, cancellationToken);
Prompt for LLM

File Core/Resgrid.Services/ModerationService.cs:

Line 384 to 387:

At-most-once delivery semantics occur because RecordActionAsync (line 700) auto-commits the ReportersNotified guard via RepositoryBase.InsertAsync (lines 192-199) before the inner send loop (lines 761-787) executes, permanently short-circuiting future retry attempts at lines 378-380 upon partial failure. Move the RecordActionAsync call below line 386 so the guard only suppresses genuinely completed deliveries after NotifyReportersAsync succeeds.

Suggested Code:

var reports = (await _moderationReportRepository.GetByRequestAsync(moderationRequestId))?.ToList()
    ?? new List<ModerationReport>();
await NotifyReportersAsync(request, reports, (ModerationDisposition)request.Disposition,
    request.AdminNote, cancellationToken);
await RecordActionAsync(request, ModerationActionType.ReportersNotified, null, null,
    request.Status, request.Status, null, null, cancellationToken);

Talk to Kody by mentioning @kody

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

Comment on lines +201 to +205
catch (Exception ex)
{
Logging.LogException(ex);
throw;
}

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 Bug high

Race condition recovery loss in FlagAsync causes unhandled 500 exceptions during concurrent flag submissions for the same (ModerationRequestId, ReportedByUserId) because the catch handler throws unconditionally when concurrent requests race past the check at line 142 and trigger the unique index UX_ModerationReports_Request_Reporter. Restore the symmetric request-level recovery by re-querying GetByRequestAndReporterAsync and returning the concurrent report when found, re-throwing only when none exists.

catch (Exception ex)
{
	Logging.LogException(ex);

	var concurrent = await _moderationReportRepository.GetByRequestAndReporterAsync(
		request.ModerationRequestId, reportedByUserId);
	if (concurrent == null)
		throw;

	return concurrent;
}
Prompt for LLM

File Core/Resgrid.Services/ModerationService.cs:

Line 201 to 205:

Race condition recovery loss in FlagAsync causes unhandled 500 exceptions during concurrent flag submissions for the same (ModerationRequestId, ReportedByUserId) because the catch handler throws unconditionally when concurrent requests race past the check at line 142 and trigger the unique index UX_ModerationReports_Request_Reporter. Restore the symmetric request-level recovery by re-querying GetByRequestAndReporterAsync and returning the concurrent report when found, re-throwing only when none exists.

Suggested Code:

catch (Exception ex)
{
	Logging.LogException(ex);

	var concurrent = await _moderationReportRepository.GetByRequestAndReporterAsync(
		request.ModerationRequestId, reportedByUserId);
	if (concurrent == null)
		throw;

	return concurrent;
}

Talk to Kody by mentioning @kody

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

return existingReport;

var reopenRequest = request.Status == (int)ModerationRequestStatus.Completed;
await _unitOfWork.CreateOrGetConnectionAsync(cancellationToken);

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 propagation occurs because the awaited _unitOfWork.CreateOrGetConnectionAsync call sits outside the try/catch block starting at line 150, preventing unit of work cleanup and error handling context. Wrap this await in its own try/catch or move it inside the existing try block, ensuring the catch path safely handles a null or unestablished connection.

Kody rule violation: Handle async operations with proper error handling

Prompt for LLM

File Core/Resgrid.Services/ModerationService.cs:

Line 148:

Unhandled exception propagation occurs because the awaited _unitOfWork.CreateOrGetConnectionAsync call sits outside the try/catch block starting at line 150, preventing unit of work cleanup and error handling context. Wrap this await in its own try/catch or move it inside the existing try block, ensuring the catch path safely handles a null or unestablished connection.

Talk to Kody by mentioning @kody

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

return existingReport;

var reopenRequest = request.Status == (int)ModerationRequestStatus.Completed;
await _unitOfWork.CreateOrGetConnectionAsync(cancellationToken);

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

Missing error mapping occurs because the external _unitOfWork.CreateOrGetConnectionAsync database connection call lacks a try/catch for network or configuration failures. Wrap the call in a try/catch to log the failure with operation and department context, and map the exception to an application-level error.

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

Prompt for LLM

File Core/Resgrid.Services/ModerationService.cs:

Line 148:

Missing error mapping occurs because the external _unitOfWork.CreateOrGetConnectionAsync database connection call lacks a try/catch for network or configuration failures. Wrap the call in a try/catch to log the failure with operation and department context, and map the exception to an application-level error.

Talk to Kody by mentioning @kody

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

SELECT 1
FROM ModerationActions a
WHERE a.ModerationRequestId = r.ModerationRequestId
AND a.ActorRole = 'LegacyImport'

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 low

String literal duplication of 'LegacyImport' across multiple SQL commands risks inconsistency between the existence-check query and INSERT statements if the actor-role identifier changes. Define a private const string at the class level, such as LegacyImportActorRole = "LegacyImport", and interpolate or parameterize it into each SQL command text.

Kody rule violation: Centralize string constants

Prompt for LLM

File Providers/Resgrid.Providers.Migrations/Migrations/M0112_AddModeration.cs:

Line 154:

String literal duplication of 'LegacyImport' across multiple SQL commands risks inconsistency between the existence-check query and INSERT statements if the actor-role identifier changes. Define a private const string at the class level, such as `LegacyImportActorRole = "LegacyImport"`, and interpolate or parameterize it into each SQL command text.

Talk to Kody by mentioning @kody

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

SELECT 1
FROM ModerationActions a
WHERE a.ModerationRequestId = r.ModerationRequestId
AND a.ActorRole = 'LegacyImport'

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 low

Magic string comparison against 'LegacyImport' obscures the finite set of valid ActorRole values and introduces fragility. Define an ActorRole enum or constants class and interpolate its member, such as ActorRoles.LegacyImport, into the SQL command text.

Kody rule violation: Use enums instead of magic strings

Prompt for LLM

File Providers/Resgrid.Providers.Migrations/Migrations/M0112_AddModeration.cs:

Line 154:

Magic string comparison against 'LegacyImport' obscures the finite set of valid ActorRole values and introduces fragility. Define an ActorRole enum or constants class and interpolate its member, such as `ActorRoles.LegacyImport`, into the SQL command text.

Talk to Kody by mentioning @kody

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

{
Data = requests.Select(x => ConvertRequest(x, true)).ToList(),
Page = Math.Max(page, 1),
PageSize = Math.Min(Math.Max(pageSize, 1), 200),

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 low

Magic number usage of the literal 200 to cap the page size obscures the limit's intent and hinders discoverability. Extract this value to a named constant like private const int MaxPageSize = 200; and reference it within the Math.Min expression.

Kody rule violation: Replace magic numbers with named constants

Prompt for LLM

File Web/Resgrid.Web.Services/Controllers/v4/ModerationController.cs:

Line 123:

Magic number usage of the literal 200 to cap the page size obscures the limit's intent and hinders discoverability. Extract this value to a named constant like `private const int MaxPageSize = 200;` and reference it within the Math.Min expression.

Talk to Kody by mentioning @kody

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

@Resgrid-Bot

Resgrid-Bot commented Aug 5, 2026

Copy link
Copy Markdown

Code Review Completed! 🔥

The code review was successfully completed based on your current configurations.

Kody Guide: Usage and Configuration
Interacting with Kody
  • Request a Review: Ask Kody to review your PR manually by adding a comment with the @kody start-review command at the root of your PR.

  • Validate Business Logic: Ask Kody to validate your code against business rules by adding a comment with the @kody -v business-logic command.

  • Provide Feedback: Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

Current Kody Configuration
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug
Performance
Security
Business Logic

Access your configuration settings here.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs (3)

204-224: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add a unique tie-breaker to the paginated ordering.

SearchAsync uses offset pagination and orders only by ModifiedOn. Rows with equal timestamps have no deterministic order. Consecutive page requests can repeat or omit requests.

Add ModerationRequestId DESC after ModifiedOn DESC in both database-specific queries.

Suggested ordering change
-ORDER BY r.modifiedon DESC
+ORDER BY r.modifiedon DESC, r.moderationrequestid DESC

-ORDER BY r.[ModifiedOn] DESC
+ORDER BY r.[ModifiedOn] DESC, r.[ModerationRequestId] DESC
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs`
around lines 204 - 224, Add ModerationRequestId DESC as a secondary ordering
criterion after ModifiedOn DESC in both database-specific SQL query branches of
SearchAsync, preserving the existing pagination and primary timestamp ordering.

113-122: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Bound criteria.Page before computing Offset.

criteria.Page has only a lower bound. Line 122 performs int multiplication. A large page can wrap Offset to a negative value and fail the SQL query. A large valid offset can also force an expensive scan.

Apply a product-defined maximum page or offset. Compute the offset with checked long arithmetic before adding it to parameters.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs`
around lines 113 - 122, Update the pagination setup in the moderation repository
method around criteria.Page and the Offset parameter: enforce the
product-defined maximum page or offset, then compute the offset using checked
long arithmetic before passing it to parameters.Add. Preserve the existing
lower-bound page behavior and page-size limits while preventing integer overflow
and excessively expensive scans.

238-249: 🩺 Stability & Availability | 🟠 Major

Select the connection and transaction from the same branch.

At Line 239 and Lines 395 and 435, the lambda reads _unitOfWork.Transaction before the fallback checks _unitOfWork?.Connection. If _unitOfWork is null, the method throws before opening the standalone connection.

At Lines 299 and 339, the code can pass a unit-of-work transaction to a newly created connection when the fallback is selected. Pass a transaction only when the selected connection belongs to the unit of work. Otherwise, pass null.

#!/usr/bin/env bash
set -euo pipefail

rg -n -C 8 'interface IUnitOfWork|class .*UnitOfWork|Transaction|CreateOrGetConnection|Connection' --glob '*.cs'
rg -n -C 8 'GetByRequestAndReporterAsync|GetByRequestIdsAsync|ModerationRequestRepository|ModerationActionRepository' --glob '*.cs'

Also applies to: 298-308, 339-350, 394-404, 434-444

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs`
around lines 238 - 249, Update the moderation query methods at the affected
`select` blocks to choose the connection and transaction together: use the
unit-of-work transaction only when `_unitOfWork?.Connection` is selected, and
pass null for standalone connections created by `_connectionProvider.Create()`.
Ensure `_unitOfWork.Transaction` is not accessed when `_unitOfWork` is null,
including the methods around `GetByRequestAndReporterAsync` and
`GetByRequestIdsAsync`.
🧹 Nitpick comments (1)
Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs (1)

23-30: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Use the required service-locator resolution pattern.

These constructors inject IConnectionProvider, SqlConfiguration, IUnitOfWork, and IQueryFactory. Resolve these dependencies through Bootstrapper.GetKernel().Resolve<T>() in each repository constructor instead. Use the same resolved instances for the base constructor and repository fields.

As per coding guidelines, use the Service Locator pattern via Bootstrapper.GetKernel().Resolve<T>() to resolve dependencies explicitly in constructors, rather than constructor injection.

Also applies to: 258-265, 366-373

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs`
around lines 23 - 30, Update the constructors for the moderation repositories,
including the ones around the referenced additional locations, to resolve
IConnectionProvider, SqlConfiguration, IUnitOfWork, and IQueryFactory through
Bootstrapper.GetKernel().Resolve<T>() instead of accepting them as parameters.
Reuse those resolved instances for the base constructor invocation and
repository fields, preserving the existing initialization behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs`:
- Around line 204-224: Add ModerationRequestId DESC as a secondary ordering
criterion after ModifiedOn DESC in both database-specific SQL query branches of
SearchAsync, preserving the existing pagination and primary timestamp ordering.
- Around line 113-122: Update the pagination setup in the moderation repository
method around criteria.Page and the Offset parameter: enforce the
product-defined maximum page or offset, then compute the offset using checked
long arithmetic before passing it to parameters.Add. Preserve the existing
lower-bound page behavior and page-size limits while preventing integer overflow
and excessively expensive scans.
- Around line 238-249: Update the moderation query methods at the affected
`select` blocks to choose the connection and transaction together: use the
unit-of-work transaction only when `_unitOfWork?.Connection` is selected, and
pass null for standalone connections created by `_connectionProvider.Create()`.
Ensure `_unitOfWork.Transaction` is not accessed when `_unitOfWork` is null,
including the methods around `GetByRequestAndReporterAsync` and
`GetByRequestIdsAsync`.

---

Nitpick comments:
In `@Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs`:
- Around line 23-30: Update the constructors for the moderation repositories,
including the ones around the referenced additional locations, to resolve
IConnectionProvider, SqlConfiguration, IUnitOfWork, and IQueryFactory through
Bootstrapper.GetKernel().Resolve<T>() instead of accepting them as parameters.
Reuse those resolved instances for the base constructor invocation and
repository fields, preserving the existing initialization behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f1bb1bf9-bfc3-4eca-ba52-dd2baa9e13fc

📥 Commits

Reviewing files that changed from the base of the PR and between f7eb8f8 and 89ad6c5.

⛔ Files ignored due to path filters (1)
  • Tests/Resgrid.Tests/Services/ModerationServiceTests.cs is excluded by !**/Tests/**
📒 Files selected for processing (3)
  • Core/Resgrid.Model/Repositories/IModerationRepositories.cs
  • Core/Resgrid.Services/ModerationService.cs
  • Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs
🚧 Files skipped from review as they are similar to previous changes (2)
  • Core/Resgrid.Model/Repositories/IModerationRepositories.cs
  • Core/Resgrid.Services/ModerationService.cs

Comment on lines +390 to +393
await NotifyReportersAsync(request, reports, (ModerationDisposition)request.Disposition,
request.AdminNote, cancellationToken);
await RecordActionAsync(request, ModerationActionType.ReportersNotified, null, null,
request.Status, request.Status, null, null, cancellationToken);

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 Bug high

Partial send failure in NotifyReportersAsync leaves the aggregate ReportersNotified guard unset, causing retries to duplicate notifications to already-notified recipients. Track per-recipient notification state (e.g., persist a per-recipient ModerationAction marker before sending) so retries skip already-notified recipients.

// Inner NotifyReportersAsync should record a per-recipient marker before/at send time
// (or skip recipients already marked) so retries do not duplicate notifications;
// then record the aggregate ReportersNotified guard only after all recipients succeed.
Prompt for LLM

File Core/Resgrid.Services/ModerationService.cs:

Line 390 to 393:

Partial send failure in NotifyReportersAsync leaves the aggregate ReportersNotified guard unset, causing retries to duplicate notifications to already-notified recipients. Track per-recipient notification state (e.g., persist a per-recipient ModerationAction marker before sending) so retries skip already-notified recipients.

Suggested Code:

// Inner NotifyReportersAsync should record a per-recipient marker before/at send time
// (or skip recipients already marked) so retries do not duplicate notifications;
// then record the aggregate ReportersNotified guard only after all recipients succeed.

Talk to Kody by mentioning @kody

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

Comment on lines +204 to +205
var concurrent = await _moderationReportRepository.GetByRequestAndReporterAsync(
request.ModerationRequestId, reportedByUserId, useUnitOfWork: false);

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 catch block: GetByRequestAndReporterAsync at line 201 (and lines 392-393) can fail on DB timeout or connection loss, discarding the original exception context and propagating an opaque error. Wrap the recovery query in a nested try/catch, log the secondary error with context, and rethrow the original exception.

Kody rule violation: Handle async operations with proper error handling

Prompt for LLM

File Core/Resgrid.Services/ModerationService.cs:

Line 204 to 205:

Unhandled exception in catch block: GetByRequestAndReporterAsync at line 201 (and lines 392-393) can fail on DB timeout or connection loss, discarding the original exception context and propagating an opaque error. Wrap the recovery query in a nested try/catch, log the secondary error with context, and rethrow the original exception.

Talk to Kody by mentioning @kody

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

Comment on lines +204 to +205
var concurrent = await _moderationReportRepository.GetByRequestAndReporterAsync(
request.ModerationRequestId, reportedByUserId, useUnitOfWork: false);

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 external call in catch block: GetByRequestAndReporterAsync can fail independently, discarding the original exception context and producing an opaque error for callers. Wrap the call in try/catch with structured context (requestId, reporterId) and either rethrow the original exception or map the secondary failure to an application-level error.

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

Prompt for LLM

File Core/Resgrid.Services/ModerationService.cs:

Line 204 to 205:

Unhandled external call in catch block: GetByRequestAndReporterAsync can fail independently, discarding the original exception context and producing an opaque error for callers. Wrap the call in try/catch with structured context (requestId, reporterId) and either rethrow the original exception or map the secondary failure to an application-level error.

Talk to Kody by mentioning @kody

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

SetupMessageEvidence();
_requests.Setup(x => x.GetByItemAsync(7, (int)ModerationItemType.Message, "42")).ReturnsAsync(request);
_reports.Setup(x => x.GetByRequestAndReporterAsync(request.ModerationRequestId, "reporter"))
.ReturnsAsync((ModerationReport)null);

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 low

Unsafe cast risk: use the as operator or pattern matching for safe casts and guard null results before usage.

Kody rule violation: Use safe type casting with as operator

Prompt for LLM

File Tests/Resgrid.Tests/Services/ModerationServiceTests.cs:

Line 194:

Unsafe cast risk: use the as operator or pattern matching for safe casts and guard null results before usage.

Talk to Kody by mentioning @kody

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

@ucswift

ucswift commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Approve

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This PR is approved.

@ucswift
ucswift merged commit 7f15404 into master Aug 5, 2026
This was referenced Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants