From aa78af8da8b03a6b046d5a86f6bb138f8d93ef5a Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 24 Sep 2026 07:43:53 -0700 Subject: [PATCH 1/3] RG-T41 Fixing Postgres Profile error --- ...DepartmentProfilesLogoGooglePlusColumns.cs | 36 +++++++++++++++ .../Sql/M0001_InitialMigration.sql | Bin 323174 -> 323182 bytes ...partmentProfilesLogoGooglePlusColumnsPg.cs | 43 ++++++++++++++++++ .../Sql/M0001_InitialMigration.sql | Bin 261268 -> 261276 bytes 4 files changed, 79 insertions(+) create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0231_FixDepartmentProfilesLogoGooglePlusColumns.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0231_FixDepartmentProfilesLogoGooglePlusColumnsPg.cs diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0231_FixDepartmentProfilesLogoGooglePlusColumns.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0231_FixDepartmentProfilesLogoGooglePlusColumns.cs new file mode 100644 index 000000000..ad6decf70 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0231_FixDepartmentProfilesLogoGooglePlusColumns.cs @@ -0,0 +1,36 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// 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. + /// + [Migration(231)] + public class M0231_FixDepartmentProfilesLogoGooglePlusColumns : Migration + { + public override void Up() + { + Execute.Sql(@" +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. + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Sql/M0001_InitialMigration.sql b/Providers/Resgrid.Providers.Migrations/Sql/M0001_InitialMigration.sql index 25c4f74fdf606869dbbb8040d4863beb6b86d71d..f15ff796bad65d7e2da04c58321adb71b320030a 100644 GIT binary patch delta 62 zcmaEMNBG?x;f5`Y@5GqX8S + /// 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. + /// + [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; + 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; + END IF; +END $$;"); + } + + public override void Down() + { + // One-way typo fix; nothing to restore. + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Sql/M0001_InitialMigration.sql b/Providers/Resgrid.Providers.MigrationsPg/Sql/M0001_InitialMigration.sql index d1c04dfe5e7792a1c3fad86bb90e5ba47b49826f..ea686861fcb5b9dd21919ad0773a377d9c251238 100644 GIT binary patch delta 44 wcmbR8lYh=n{)R1#X`0OG4EfU&tr$hOS7|aXU Date: Thu, 24 Sep 2026 07:58:54 -0700 Subject: [PATCH 2/3] RG-T55 RMS Bulk Edit fix --- .../Records/RecordsBulkPacketService.cs | 5 ++++- .../Rms/RecordsBulkPacketServiceTests.cs | 14 +++++++++++--- .../Areas/User/Views/Records/Index.cshtml | 3 ++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/Core/Resgrid.Services/Records/RecordsBulkPacketService.cs b/Core/Resgrid.Services/Records/RecordsBulkPacketService.cs index 63704dca9..9628f8e06 100644 --- a/Core/Resgrid.Services/Records/RecordsBulkPacketService.cs +++ b/Core/Resgrid.Services/Records/RecordsBulkPacketService.cs @@ -167,6 +167,9 @@ public async Task 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)) + throw new ArgumentException("The chosen reviewer does not hold the ReviewRecords permission.", nameof(request)); var result = new RecordsBulkResult(); foreach (var id in ids) @@ -179,7 +182,7 @@ public async Task 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"); } } return result; } diff --git a/Tests/Resgrid.Tests/Rms/RecordsBulkPacketServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsBulkPacketServiceTests.cs index 9ac4b3e25..481422312 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsBulkPacketServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsBulkPacketServiceTests.cs @@ -150,16 +150,24 @@ 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())).ReturnsAsync(new RecordAggregate()); _records.Setup(r => r.AssignReviewerAsync(Dept, Exporter, "draft", "reviewer", "rotation", It.IsAny())).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())).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())).ThrowsAsync(new KeyNotFoundException("Record missing does not exist in this department.")); + _records.Setup(r => r.AssignReviewerAsync(Dept, Exporter, "r2", "reviewer", "rotation", It.IsAny())).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 { "r1", "draft", "hidden" }, ReviewerUserId = "reviewer", Reason = "rotation" }); + var result = await _service.AssignForReviewAsync(Dept, Exporter, new RecordsBulkAssignRequest { RecordIds = new List { "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 inactive = () => _service.AssignForReviewAsync(Dept, Exporter, new RecordsBulkAssignRequest { RecordIds = new List { "r1" }, ReviewerUserId = "gone" }); await inactive.Should().ThrowAsync(); + + // A reviewer without ReviewRecords fails the whole request up front rather than skipping every row. + Func notReviewer = () => _service.AssignForReviewAsync(Dept, Exporter, new RecordsBulkAssignRequest { RecordIds = new List { "r1" }, ReviewerUserId = "member" }); + (await notReviewer.Should().ThrowAsync()).Which.Message.Should().Contain("ReviewRecords"); } } } diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml index 92a424618..44a3c66fa 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml @@ -235,7 +235,8 @@ @foreach (var r in Model.Records) { - @if (bulk) { } + @* Bulk assign and packets operate on operational Records only; Incident Reports have their own workflow. *@ + @if (bulk) { @if (r.RecordKind == (int)RmsRecordKind.Operational) { } } @if (!string.IsNullOrWhiteSpace(r.RecordNumber)) { From be12a6bb92f85d18dde9a743aab17f43f86de5c3 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 24 Sep 2026 08:26:09 -0700 Subject: [PATCH 3/3] RG-T41 PR #526 Fixes --- .../Records/RecordsBulkPacketService.cs | 4 ++++ .../Rms/RecordsBulkPacketServiceTests.cs | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/Core/Resgrid.Services/Records/RecordsBulkPacketService.cs b/Core/Resgrid.Services/Records/RecordsBulkPacketService.cs index 9628f8e06..ae8de7a89 100644 --- a/Core/Resgrid.Services/Records/RecordsBulkPacketService.cs +++ b/Core/Resgrid.Services/Records/RecordsBulkPacketService.cs @@ -183,6 +183,10 @@ public async Task AssignForReviewAsync(int departmentId, stri catch (RecordTransitionException) { Skip(result, id, "not_awaiting_review"); } catch (UnauthorizedAccessException) { Skip(result, id, "not_visible"); } catch (KeyNotFoundException) { Skip(result, id, "not_found"); } + // 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; } diff --git a/Tests/Resgrid.Tests/Rms/RecordsBulkPacketServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsBulkPacketServiceTests.cs index 481422312..129d8c9bc 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsBulkPacketServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsBulkPacketServiceTests.cs @@ -168,6 +168,19 @@ public async Task Assign_for_review_touches_only_records_awaiting_review_and_rep // A reviewer without ReviewRecords fails the whole request up front rather than skipping every row. Func notReviewer = () => _service.AssignForReviewAsync(Dept, Exporter, new RecordsBulkAssignRequest { RecordIds = new List { "r1" }, ReviewerUserId = "member" }); (await notReviewer.Should().ThrowAsync()).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())).ThrowsAsync(new ArgumentException("The chosen reviewer does not hold the ReviewRecords permission.")); + var partial = await _service.AssignForReviewAsync(Dept, Exporter, new RecordsBulkAssignRequest { RecordIds = new List { "r1", "r2" }, ReviewerUserId = "reviewer", Reason = "rotation" }); + 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())).ThrowsAsync(new RecordConcurrencyException("busy", 3, 4)); + _records.Setup(r => r.AssignReviewerAsync(Dept, Exporter, "r3", "reviewer", "rotation", It.IsAny())).ReturnsAsync(new RecordAggregate()); + var conflicted = await _service.AssignForReviewAsync(Dept, Exporter, new RecordsBulkAssignRequest { RecordIds = new List { "r1", "busy", "r3" }, ReviewerUserId = "reviewer", Reason = "rotation" }); + conflicted.Processed.Should().Be(2); + conflicted.Skips.Select(s => s.RecordId + ":" + s.Reason).Should().BeEquivalentTo("busy:conflict"); } } }