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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Core/Resgrid.Services/Records/RecordsBulkPacketService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ public async Task<RecordsBulkResult> AssignForReviewAsync(int departmentId, stri
throw new UnauthorizedAccessException("Bulk assign-for-review requires the ReviewRecords permission.");
if (!await _authorization.IsActiveMemberAsync(request.ReviewerUserId, departmentId))
throw new ArgumentException("The reviewer is not an active member of this department.", nameof(request));
// Checked once here: the per-record check would otherwise reject every row for the same reviewer-level reason.
if (!await _authorization.HasPermissionAsync(request.ReviewerUserId, departmentId, PermissionTypes.ReviewRecords))

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 authorization exceptions can escape from the awaited _authorization.HasPermissionAsync call when the operation is rejected, preventing the service from handling the authorization failure. Wrap the call in try/catch to handle or log the exception with appropriate context before rethrowing it.

Kody rule violation: Handle async operations with proper error handling

try
{
    if (!await _authorization.HasPermissionAsync(request.ReviewerUserId, departmentId, PermissionTypes.ReviewRecords))
        throw new ArgumentException("The chosen reviewer does not hold the ReviewRecords permission.", nameof(request));
}
catch (Exception ex)
{
    // Handle or log the authorization failure with appropriate context.
    throw;
}
Prompt for LLM

File Core/Resgrid.Services/Records/RecordsBulkPacketService.cs:

Line 171:

Unhandled authorization exceptions can escape from the awaited _authorization.HasPermissionAsync call when the operation is rejected, preventing the service from handling the authorization failure. Wrap the call in try/catch to handle or log the exception with appropriate context before rethrowing it.

Suggested Code:

try
{
    if (!await _authorization.HasPermissionAsync(request.ReviewerUserId, departmentId, PermissionTypes.ReviewRecords))
        throw new ArgumentException("The chosen reviewer does not hold the ReviewRecords permission.", nameof(request));
}
catch (Exception ex)
{
    // Handle or log the authorization failure with appropriate context.
    throw;
}

Talk to Kody by mentioning @kody

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

​

​

throw new ArgumentException("The chosen reviewer does not hold the ReviewRecords permission.", nameof(request));

var result = new RecordsBulkResult();
foreach (var id in ids)
Expand All @@ -179,7 +182,11 @@ public async Task<RecordsBulkResult> AssignForReviewAsync(int departmentId, stri
}
catch (RecordTransitionException) { Skip(result, id, "not_awaiting_review"); }
catch (UnauthorizedAccessException) { Skip(result, id, "not_visible"); }
catch (ArgumentException) { Skip(result, id, "not_found"); }
catch (KeyNotFoundException) { Skip(result, id, "not_found"); }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Someone else wrote the row between load and save; skip it rather than abort the rows after it.
catch (RecordConcurrencyException) { Skip(result, id, "conflict"); }
// The reviewer lost ReviewRecords after the precheck; earlier rows are already committed, so keep the batch result.
catch (ArgumentException) { Skip(result, id, "reviewer_not_eligible"); }
}
return result;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using FluentMigrator;

namespace Resgrid.Providers.Migrations.Migrations
{
/// <summary>
/// The initial schema created DepartmentProfiles with misspelled "Lo" and "oglePlus" columns (the same
/// dropped-"go" damage M0113 repaired on Notes and Documents) while the entity, and so every
/// Dapper-generated INSERT/UPDATE, uses "Logo" and "GooglePlus" — no department profile could ever be
/// saved against a database built from M0001, and the Department Profile page failed on first load when
/// it tried to create the row. Renames each column where the typo exists; guarded so databases that
/// already have the correct column (every database that predates M0001, or one hand-fixed) are untouched.
///
/// No data moves: because every save failed, a database carrying the typo holds no DepartmentProfiles
/// rows, so M0172's one-time legacy logo copy (which it skipped here, finding no Logo column) has
/// nothing to pick up.
/// </summary>
[Migration(231)]
public class M0231_FixDepartmentProfilesLogoGooglePlusColumns : Migration
{
public override void Up()
{
Execute.Sql(@"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Kody Rules high

The Execute.Sql call in Providers/Resgrid.Providers.Migrations/Migrations/M0231_FixDepartmentProfilesLogoGooglePlusColumns.cs:26-26 does not provide migration or operation context when the database operation fails. Wrap it in try/catch and rethrow an InvalidOperationException identifying migration M0231 and the failed DepartmentProfiles.Logo column rename.

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

try
{
	Execute.Sql(@"...");
}
catch (Exception ex)
{
	throw new InvalidOperationException("Failed to rename DepartmentProfiles.Logo column during migration M0231.", ex);
}
Prompt for LLM

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

Line 22:

The Execute.Sql call in `Providers/Resgrid.Providers.Migrations/Migrations/M0231_FixDepartmentProfilesLogoGooglePlusColumns.cs:26-26` does not provide migration or operation context when the database operation fails. Wrap it in try/catch and rethrow an `InvalidOperationException` identifying migration M0231 and the failed `DepartmentProfiles.Logo` column rename.

Suggested Code:

try
{
	Execute.Sql(@"...");
}
catch (Exception ex)
{
	throw new InvalidOperationException("Failed to rename DepartmentProfiles.Logo column during migration M0231.", ex);
}

Talk to Kody by mentioning @kody

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

​

​

IF COL_LENGTH('dbo.DepartmentProfiles', 'Lo') IS NOT NULL AND COL_LENGTH('dbo.DepartmentProfiles', 'Logo') IS NULL
EXEC sp_rename 'dbo.DepartmentProfiles.Lo', 'Logo', 'COLUMN';");

Execute.Sql(@"
IF COL_LENGTH('dbo.DepartmentProfiles', 'oglePlus') IS NOT NULL AND COL_LENGTH('dbo.DepartmentProfiles', 'GooglePlus') IS NULL
EXEC sp_rename 'dbo.DepartmentProfiles.oglePlus', 'GooglePlus', 'COLUMN';");
}

public override void Down()
{
// One-way typo fix; nothing to restore.
}
}
}
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using FluentMigrator;

namespace Resgrid.Providers.MigrationsPg.Migrations
{
/// <summary>
/// The initial schema created departmentprofiles with misspelled "lo" and "ogleplus" columns (the same
/// dropped-"go" damage M0113 repaired on notes and documents) while the entity, and so every
/// Dapper-generated INSERT/UPDATE, uses "logo" and "googleplus" — every save against a database built
/// from M0001 failed with 42703 "column googleplus of relation departmentprofiles does not exist", which
/// broke the Department Profile page on first load when it tried to create the row. Renames each column
/// where the typo exists; guarded so databases that already have the correct column (or were hand-fixed)
/// are untouched.
///
/// No data moves: because every save failed, a database carrying the typo holds no departmentprofiles
/// rows, so M0172's one-time legacy logo copy (which it skipped here, finding no logo column) has
/// nothing to pick up.
/// </summary>
[Migration(231)]
public class M0231_FixDepartmentProfilesLogoGooglePlusColumnsPg : Migration
{
public override void Up()
{
Execute.Sql(@"
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'departmentprofiles' AND column_name = 'lo')
AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'departmentprofiles' AND column_name = 'logo') THEN
ALTER TABLE public.departmentprofiles RENAME COLUMN lo TO logo;

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

The column renames in Providers/Resgrid.Providers.MigrationsPg/Migrations/M0231_FixDepartmentProfilesLogoGooglePlusColumnsPg.cs:33-33 and Providers/Resgrid.Providers.Migrations/Migrations/M0231_FixDepartmentProfilesLogoGooglePlusColumns.cs:24-24, :28-28, and :33-33 acquire an exclusive table lock that can block traffic. Document the lock impact, provide an online or expand-contract rollout strategy where required, and include a rollback plan for migration M0231.

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

ALTER TABLE public.departmentprofiles RENAME COLUMN lo TO logo;
Prompt for LLM

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

Line 28:

The column renames in `Providers/Resgrid.Providers.MigrationsPg/Migrations/M0231_FixDepartmentProfilesLogoGooglePlusColumnsPg.cs:33-33` and `Providers/Resgrid.Providers.Migrations/Migrations/M0231_FixDepartmentProfilesLogoGooglePlusColumns.cs:24-24`, `:28-28`, and `:33-33` acquire an exclusive table lock that can block traffic. Document the lock impact, provide an online or expand-contract rollout strategy where required, and include a rollback plan for migration M0231.

Suggested Code:

ALTER TABLE public.departmentprofiles RENAME COLUMN lo TO logo;

Talk to Kody by mentioning @kody

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

​

​

END IF;

IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'departmentprofiles' AND column_name = 'ogleplus')
AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'departmentprofiles' AND column_name = 'googleplus') THEN
ALTER TABLE public.departmentprofiles RENAME COLUMN ogleplus TO googleplus;
Comment on lines +26 to +33

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

The PostgreSQL migration checks only for lo and ogleplus, while the checked-in M0001 schema creates departmentprofiles.logo and departmentprofiles.googleplus, so databases created from the repository's canonical M0001 never satisfy either IF condition and cannot repair the reported profile-column failure. Align the migration with the actual schema/version containing the typo, or update the initial migration/test fixture and migration detection to explicitly cover the failing deployed shape.

-- Detect and repair the actual legacy schema shape used by affected databases;
-- keep the guard consistent with the schema/version that produced the failure.
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'departmentprofiles' AND column_name = '<actual_legacy_logo_column>')
	AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'departmentprofiles' AND column_name = 'logo') THEN
	ALTER TABLE public.departmentprofiles RENAME COLUMN <actual_legacy_logo_column> TO logo;
Prompt for LLM

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

Line 26 to 33:

The PostgreSQL migration checks only for `lo` and `ogleplus`, while the checked-in M0001 schema creates `departmentprofiles.logo` and `departmentprofiles.googleplus`, so databases created from the repository's canonical M0001 never satisfy either IF condition and cannot repair the reported profile-column failure. Align the migration with the actual schema/version containing the typo, or update the initial migration/test fixture and migration detection to explicitly cover the failing deployed shape.

Suggested Code:

-- Detect and repair the actual legacy schema shape used by affected databases;
-- keep the guard consistent with the schema/version that produced the failure.
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'departmentprofiles' AND column_name = '<actual_legacy_logo_column>')
	AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'departmentprofiles' AND column_name = 'logo') THEN
	ALTER TABLE public.departmentprofiles RENAME COLUMN <actual_legacy_logo_column> TO logo;

Talk to Kody by mentioning @kody

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

​

​

END IF;
END $$;");
}

public override void Down()
{
// One-way typo fix; nothing to restore.
}
}
}
Binary file not shown.
27 changes: 24 additions & 3 deletions Tests/Resgrid.Tests/Rms/RecordsBulkPacketServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,16 +150,37 @@ public async Task Assign_for_review_touches_only_records_awaiting_review_and_rep
_records.Setup(r => r.AssignReviewerAsync(Dept, Exporter, "r1", "reviewer", "rotation", It.IsAny<CancellationToken>())).ReturnsAsync(new RecordAggregate());
_records.Setup(r => r.AssignReviewerAsync(Dept, Exporter, "draft", "reviewer", "rotation", It.IsAny<CancellationToken>())).ThrowsAsync(new RecordTransitionException("draft", RmsRecordState.Draft, RmsRecordState.Draft, "only a Record awaiting review can be assigned a reviewer"));
_records.Setup(r => r.AssignReviewerAsync(Dept, Exporter, "hidden", "reviewer", "rotation", It.IsAny<CancellationToken>())).ThrowsAsync(new UnauthorizedAccessException());
// An id with no operational Record (deleted since the list rendered, or another kind's id) must not abort the batch.
_records.Setup(r => r.AssignReviewerAsync(Dept, Exporter, "missing", "reviewer", "rotation", It.IsAny<CancellationToken>())).ThrowsAsync(new KeyNotFoundException("Record missing does not exist in this department."));
_records.Setup(r => r.AssignReviewerAsync(Dept, Exporter, "r2", "reviewer", "rotation", It.IsAny<CancellationToken>())).ReturnsAsync(new RecordAggregate());
_authorization.Setup(a => a.HasPermissionAsync("reviewer", Dept, PermissionTypes.ReviewRecords)).ReturnsAsync(true);

var result = await _service.AssignForReviewAsync(Dept, Exporter, new RecordsBulkAssignRequest { RecordIds = new List<string> { "r1", "draft", "hidden" }, ReviewerUserId = "reviewer", Reason = "rotation" });
var result = await _service.AssignForReviewAsync(Dept, Exporter, new RecordsBulkAssignRequest { RecordIds = new List<string> { "r1", "draft", "missing", "hidden", "r2" }, ReviewerUserId = "reviewer", Reason = "rotation" });

result.Processed.Should().Be(1);
result.Skips.Select(s => s.RecordId + ":" + s.Reason).Should().BeEquivalentTo("draft:not_awaiting_review", "hidden:not_visible");
result.Processed.Should().Be(2);
result.Skips.Select(s => s.RecordId + ":" + s.Reason).Should().BeEquivalentTo("draft:not_awaiting_review", "missing:not_found", "hidden:not_visible");
result.Run.Should().BeNull();

_authorization.Setup(a => a.IsActiveMemberAsync("gone", Dept)).ReturnsAsync(false);
Func<Task> inactive = () => _service.AssignForReviewAsync(Dept, Exporter, new RecordsBulkAssignRequest { RecordIds = new List<string> { "r1" }, ReviewerUserId = "gone" });
await inactive.Should().ThrowAsync<ArgumentException>();

// A reviewer without ReviewRecords fails the whole request up front rather than skipping every row.
Func<Task> notReviewer = () => _service.AssignForReviewAsync(Dept, Exporter, new RecordsBulkAssignRequest { RecordIds = new List<string> { "r1" }, ReviewerUserId = "member" });
(await notReviewer.Should().ThrowAsync<ArgumentException>()).Which.Message.Should().Contain("ReviewRecords");

// Permission revoked mid-batch: the rows already assigned are still reported rather than lost behind an error.
_records.Setup(r => r.AssignReviewerAsync(Dept, Exporter, "r2", "reviewer", "rotation", It.IsAny<CancellationToken>())).ThrowsAsync(new ArgumentException("The chosen reviewer does not hold the ReviewRecords permission."));
var partial = await _service.AssignForReviewAsync(Dept, Exporter, new RecordsBulkAssignRequest { RecordIds = new List<string> { "r1", "r2" }, ReviewerUserId = "reviewer", Reason = "rotation" });

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 asynchronous failures from AssignForReviewAsync can become unhandled rejections instead of producing test-context failures. Wrap the awaited service call in try/catch and call Assert.Fail with the exception details.

Kody rule violation: Handle async operations with proper error handling

RecordsBulkAssignResult partial;
try
{
    partial = await _service.AssignForReviewAsync(Dept, Exporter, new RecordsBulkAssignRequest { RecordIds = new List<string> { "r1", "r2" }, ReviewerUserId = "reviewer", Reason = "rotation" });
}
catch (Exception ex)
{
    Assert.Fail($"AssignForReviewAsync failed unexpectedly: {ex}");
}
Prompt for LLM

File Tests/Resgrid.Tests/Rms/RecordsBulkPacketServiceTests.cs:

Line 174:

Unhandled asynchronous failures from AssignForReviewAsync can become unhandled rejections instead of producing test-context failures. Wrap the awaited service call in try/catch and call Assert.Fail with the exception details.

Suggested Code:

RecordsBulkAssignResult partial;
try
{
    partial = await _service.AssignForReviewAsync(Dept, Exporter, new RecordsBulkAssignRequest { RecordIds = new List<string> { "r1", "r2" }, ReviewerUserId = "reviewer", Reason = "rotation" });
}
catch (Exception ex)
{
    Assert.Fail($"AssignForReviewAsync failed unexpectedly: {ex}");
}

Talk to Kody by mentioning @kody

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

​

​

partial.Processed.Should().Be(1);
partial.Skips.Select(s => s.RecordId + ":" + s.Reason).Should().BeEquivalentTo("r2:reviewer_not_eligible");

// A concurrent write on one row is skipped; the rows after it are still assigned.
_records.Setup(r => r.AssignReviewerAsync(Dept, Exporter, "busy", "reviewer", "rotation", It.IsAny<CancellationToken>())).ThrowsAsync(new RecordConcurrencyException("busy", 3, 4));
_records.Setup(r => r.AssignReviewerAsync(Dept, Exporter, "r3", "reviewer", "rotation", It.IsAny<CancellationToken>())).ReturnsAsync(new RecordAggregate());
var conflicted = await _service.AssignForReviewAsync(Dept, Exporter, new RecordsBulkAssignRequest { RecordIds = new List<string> { "r1", "busy", "r3" }, ReviewerUserId = "reviewer", Reason = "rotation" });
conflicted.Processed.Should().Be(2);
conflicted.Skips.Select(s => s.RecordId + ":" + s.Reason).Should().BeEquivalentTo("busy:conflict");
}
}
}
3 changes: 2 additions & 1 deletion Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,8 @@
@foreach (var r in Model.Records)
{
<tr>
@if (bulk) { <td><input type="checkbox" name="ids" value="@r.RmsRecordSearchProjectionId" class="records-bulk-row" /></td> }
@* Bulk assign and packets operate on operational Records only; Incident Reports have their own workflow. *@
@if (bulk) { <td>@if (r.RecordKind == (int)RmsRecordKind.Operational) { <input type="checkbox" name="ids" value="@r.RmsRecordSearchProjectionId" class="records-bulk-row" /> }</td> }
<td>
@if (!string.IsNullOrWhiteSpace(r.RecordNumber))
{
Expand Down
Loading