From ea6eef3e8a337eadda8f8dd62773cdede0f0859e Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 4 Aug 2026 13:15:34 -0700 Subject: [PATCH 1/2] RG-T129 Notification System bug fixes --- Core/Resgrid.Services/NotificationService.cs | 90 ++++----- .../Services/NotificationServiceTests.cs | 189 ++++++++++++++++++ .../resgrid.notifications.addNotification.js | 10 +- 3 files changed, 235 insertions(+), 54 deletions(-) diff --git a/Core/Resgrid.Services/NotificationService.cs b/Core/Resgrid.Services/NotificationService.cs index 91e44766b..632f40c5c 100644 --- a/Core/Resgrid.Services/NotificationService.cs +++ b/Core/Resgrid.Services/NotificationService.cs @@ -461,7 +461,7 @@ public async Task GetGroupForEventAsync(ProcessedNotification n return await _departmentGroupsService.GetGroupByIdAsync(unitEvent.Unit.StationGroupId.GetValueOrDefault()); } } - else if (notification.Type == EventTypes.PersonnelStatusChanged) + else if (notification.Type == EventTypes.PersonnelStaffingChanged) { var userStaffing = await _userStateService.GetUserStateByIdAsync(dynamicData.StateId); @@ -498,94 +498,84 @@ public async Task ValidateNotificationForProcessingAsync(ProcessedNotifica switch (notification.Type) { case EventTypes.UnitStatusChanged: - if (!String.IsNullOrWhiteSpace(setting.BeforeData) && !String.IsNullOrWhiteSpace(setting.CurrentData)) { - 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; + var currentData = String.IsNullOrWhiteSpace(setting.CurrentData) ? "-1" : setting.CurrentData; + + if (beforeData.Contains("-1") && currentData.Contains("-1")) return true; - bool beforeAny = setting.BeforeData.Contains("-1"); - bool currentAny = setting.CurrentData.Contains("-1"); + bool beforeAny = beforeData.Contains("-1"); + bool currentAny = currentData.Contains("-1"); UnitState beforeState = null; - UnitState currentState = null; - - currentState = await _unitsService.GetUnitStateByIdAsync(dynamicData.StateId); + UnitState currentState = await _unitsService.GetUnitStateByIdAsync(dynamicData.StateId); if (currentState != null) { if (!beforeAny) beforeState = await _unitsService.GetLastUnitStateBeforeIdAsync(currentState.UnitId, currentState.UnitStateId); - if ((currentAny || currentState.State == int.Parse(setting.CurrentData)) && - (beforeAny || beforeState.State == int.Parse(setting.BeforeData))) + if ((currentAny || currentState.State == int.Parse(currentData)) && + (beforeAny || (beforeState != null && beforeState.State == int.Parse(beforeData)))) return true; } } - else - { - return false; - } break; case EventTypes.PersonnelStaffingChanged: - if (!String.IsNullOrWhiteSpace(setting.BeforeData) && !String.IsNullOrWhiteSpace(setting.CurrentData)) { - if (setting.BeforeData.Contains("-1") && setting.CurrentData.Contains("-1")) - return true; + // 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; + var currentData = String.IsNullOrWhiteSpace(setting.CurrentData) ? "-1" : setting.CurrentData; - bool beforeAny = false; - if (!string.IsNullOrWhiteSpace(setting.BeforeData)) - beforeAny = setting.BeforeData.Contains("-1"); + if (beforeData.Contains("-1") && currentData.Contains("-1")) + return true; - bool currentAny = false; - if (!string.IsNullOrWhiteSpace(setting.CurrentData)) - currentAny = setting.CurrentData.Contains("-1"); + bool beforeAny = beforeData.Contains("-1"); + bool currentAny = currentData.Contains("-1"); UserState beforeState = null; - UserState currentState = null; - - currentState = await _userStateService.GetUserStateByIdAsync((int)dynamicData.StateId); + UserState currentState = await _userStateService.GetUserStateByIdAsync((int)dynamicData.StateId); if (currentState != null) { if (!beforeAny) beforeState = await _userStateService.GetPreviousUserStateAsync(currentState.UserId, currentState.UserStateId); - if ((currentAny || currentState.State == int.Parse(setting.CurrentData)) && - (beforeAny || beforeState.State == int.Parse(setting.BeforeData))) + if ((currentAny || currentState.State == int.Parse(currentData)) && + (beforeAny || (beforeState != null && beforeState.State == int.Parse(beforeData)))) return true; } - - return false; - } - else - { - return false; } break; case EventTypes.PersonnelStatusChanged: - if (!String.IsNullOrWhiteSpace(setting.BeforeData) && !String.IsNullOrWhiteSpace(setting.CurrentData)) { - 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; + var currentData = String.IsNullOrWhiteSpace(setting.CurrentData) ? "-1" : setting.CurrentData; + + if (beforeData.Contains("-1") && currentData.Contains("-1")) return true; - bool beforeAny = setting.BeforeData.Contains("-1"); - bool currentAny = setting.CurrentData.Contains("-1"); + bool beforeAny = beforeData.Contains("-1"); + bool currentAny = currentData.Contains("-1"); ActionLog beforeState = null; - ActionLog currentState = null; - - currentState = await _actionLogsService.GetActionLogByIdAsync((int)dynamicData.StateId); + ActionLog currentState = await _actionLogsService.GetActionLogByIdAsync((int)dynamicData.StateId); - if (!beforeAny) - beforeState = await _actionLogsService.GetPreviousActionLogAsync(currentState.UserId, currentState.ActionLogId); + if (currentState != null) + { + if (!beforeAny) + beforeState = await _actionLogsService.GetPreviousActionLogAsync(currentState.UserId, currentState.ActionLogId); - if ((currentAny || currentState.ActionTypeId == int.Parse(setting.CurrentData)) && - (beforeAny || beforeState.ActionTypeId == int.Parse(setting.BeforeData))) - return true; - } - else - { - return false; + if ((currentAny || currentState.ActionTypeId == int.Parse(currentData)) && + (beforeAny || (beforeState != null && beforeState.ActionTypeId == int.Parse(beforeData)))) + return true; + } } break; case EventTypes.RolesInGroupAvailabilityAlert: diff --git a/Tests/Resgrid.Tests/Services/NotificationServiceTests.cs b/Tests/Resgrid.Tests/Services/NotificationServiceTests.cs index b2127d45a..949b4de32 100644 --- a/Tests/Resgrid.Tests/Services/NotificationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/NotificationServiceTests.cs @@ -751,6 +751,57 @@ public async Task should_not_process_incorrect_after_data() result.Should().BeFalse(); } + + [Test] + public async Task should_process_when_before_and_current_data_are_empty() + { + var notification = new DepartmentNotification + { + EventType = (int)EventTypes.UnitStatusChanged, + Everyone = true, + DepartmentId = 1, + BeforeData = "", + CurrentData = null + }; + + var processedNotification = new ProcessedNotification + { + DepartmentId = 1, + MessageId = "123456", + Data = new NotificationItem() { StateId = 3, DepartmentId = 1, PreviousStateId = 2 }.SerializeProto(), + Type = EventTypes.UnitStatusChanged + }; + + var result = await _notificationServiceMock.ValidateNotificationForProcessingAsync(processedNotification, notification); + + result.Should().BeTrue(); + } + + [Test] + public async Task should_not_throw_when_no_previous_unit_state_exists() + { + var notification = new DepartmentNotification + { + EventType = (int)EventTypes.UnitStatusChanged, + Everyone = true, + DepartmentId = 1, + BeforeData = ((int)UnitStateTypes.Available).ToString(), + CurrentData = "-1" + }; + + // StateId 1 has no mocked prior unit state, so the previous-state lookup returns null + var processedNotification = new ProcessedNotification + { + DepartmentId = 1, + MessageId = "123456", + Data = new NotificationItem() { StateId = 1, DepartmentId = 1, PreviousStateId = 0 }.SerializeProto(), + Type = EventTypes.UnitStatusChanged + }; + + var result = await _notificationServiceMock.ValidateNotificationForProcessingAsync(processedNotification, notification); + + result.Should().BeFalse(); + } } [TestFixture] @@ -905,6 +956,57 @@ public async Task should_not_process_incorrect_after_data() result.Should().BeFalse(); } + + [Test] + public async Task should_process_when_before_and_current_data_are_empty() + { + var notification = new DepartmentNotification + { + EventType = (int)EventTypes.PersonnelStaffingChanged, + Everyone = true, + DepartmentId = 1, + BeforeData = "", + CurrentData = null + }; + + var processedNotification = new ProcessedNotification + { + DepartmentId = 1, + MessageId = "123456", + Data = new NotificationItem() { StateId = 1, DepartmentId = 1, PreviousStateId = 1 }.SerializeProto(), + Type = EventTypes.PersonnelStaffingChanged + }; + + var result = await _notificationServiceMock.ValidateNotificationForProcessingAsync(processedNotification, notification); + + result.Should().BeTrue(); + } + + [Test] + public async Task should_not_throw_when_no_previous_user_state_exists() + { + var notification = new DepartmentNotification + { + EventType = (int)EventTypes.PersonnelStaffingChanged, + Everyone = true, + DepartmentId = 1, + BeforeData = ((int)UserStateTypes.Unavailable).ToString(), + CurrentData = "-1" + }; + + // StateId 1 has no mocked previous user state, so the previous-state lookup returns null + var processedNotification = new ProcessedNotification + { + DepartmentId = 1, + MessageId = "123456", + Data = new NotificationItem() { StateId = 1, DepartmentId = 1, PreviousStateId = 0 }.SerializeProto(), + Type = EventTypes.PersonnelStaffingChanged + }; + + var result = await _notificationServiceMock.ValidateNotificationForProcessingAsync(processedNotification, notification); + + result.Should().BeFalse(); + } } [TestFixture] @@ -1059,6 +1161,93 @@ public async Task should_not_process_incorrect_after_data() result.Should().BeFalse(); } + + [Test] + public async Task should_process_when_before_and_current_data_are_empty() + { + var notification = new DepartmentNotification + { + EventType = (int)EventTypes.PersonnelStatusChanged, + Everyone = true, + DepartmentId = 1, + BeforeData = "", + CurrentData = null + }; + + var processedNotification = new ProcessedNotification + { + DepartmentId = 1, + MessageId = "123456", + Data = new NotificationItem() { StateId = 2, DepartmentId = 1, PreviousStateId = 1 }.SerializeProto(), + Type = EventTypes.PersonnelStatusChanged + }; + + var result = await _notificationServiceMock.ValidateNotificationForProcessingAsync(processedNotification, notification); + + result.Should().BeTrue(); + } + + [Test] + public async Task should_not_throw_when_no_previous_action_log_exists() + { + var notification = new DepartmentNotification + { + EventType = (int)EventTypes.PersonnelStatusChanged, + Everyone = true, + DepartmentId = 1, + BeforeData = ((int)ActionTypes.NotResponding).ToString(), + CurrentData = "-1" + }; + + // StateId 1 has no mocked previous action log, so the previous-state lookup returns null + var processedNotification = new ProcessedNotification + { + DepartmentId = 1, + MessageId = "123456", + Data = new NotificationItem() { StateId = 1, DepartmentId = 1, PreviousStateId = 0 }.SerializeProto(), + Type = EventTypes.PersonnelStatusChanged + }; + + var result = await _notificationServiceMock.ValidateNotificationForProcessingAsync(processedNotification, notification); + + result.Should().BeFalse(); + } + } + + [TestFixture] + public class when_getting_the_group_for_an_event : with_the_notification_service + { + [Test] + public async Task should_return_group_for_personnel_staffing_changed() + { + var processedNotification = new ProcessedNotification + { + MessageId = "123456", + Data = new NotificationItem() { StateId = 1, PreviousStateId = 0 }.SerializeProto(), + Type = EventTypes.PersonnelStaffingChanged + }; + + var group = await _notificationServiceMock.GetGroupForEventAsync(processedNotification); + + group.Should().NotBeNull(); + group.DepartmentGroupId.Should().Be(1); + } + + [Test] + public async Task should_return_group_for_personnel_status_changed() + { + var processedNotification = new ProcessedNotification + { + MessageId = "123456", + Data = new NotificationItem() { StateId = 1, PreviousStateId = 0 }.SerializeProto(), + Type = EventTypes.PersonnelStatusChanged + }; + + var group = await _notificationServiceMock.GetGroupForEventAsync(processedNotification); + + group.Should().NotBeNull(); + group.DepartmentGroupId.Should().Be(1); + } } [TestFixture] diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/notifications/resgrid.notifications.addNotification.js b/Web/Resgrid.Web/wwwroot/js/app/internal/notifications/resgrid.notifications.addNotification.js index 694c2ed56..bf24fd0ce 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/notifications/resgrid.notifications.addNotification.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/notifications/resgrid.notifications.addNotification.js @@ -114,7 +114,9 @@ var resgrid; function initDropDown(selector, url) { $.getJSON(url, function (data) { - var $sel = $(selector).empty().append(''); + // "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(''); $.each(data, function (i, item) { $sel.append(''); }); }); } @@ -127,21 +129,21 @@ var resgrid; function setUnitStateDataDropdowns() { $('#beforeStateControl').empty().append(''); $('#currentStateControl').empty().append(''); - var url = resgrid.absoluteBaseUrl + '/User/CustomStatuses/GetUnitStatusesLevelsForDepartmentCombined?includeAny=True'; + var url = resgrid.absoluteBaseUrl + '/User/CustomStatuses/GetUnitStatusesLevelsForDepartmentCombined?includeAny=False'; initDropDown('#Notification_BeforeData', url); initDropDown('#Notification_CurrentData', url); } function setPersonnelStaffingDataDropdowns() { $('#beforeStateControl').empty().append(''); $('#currentStateControl').empty().append(''); - var url = resgrid.absoluteBaseUrl + '/User/CustomStatuses/GetPersonnelStaffingLevelsForDepartment?includeAny=True'; + var url = resgrid.absoluteBaseUrl + '/User/CustomStatuses/GetPersonnelStaffingLevelsForDepartment?includeAny=False'; initDropDown('#Notification_BeforeData', url); initDropDown('#Notification_CurrentData', url); } function setPersonnelStatusDataDropdowns() { $('#beforeStateControl').empty().append(''); $('#currentStateControl').empty().append(''); - var url = resgrid.absoluteBaseUrl + '/User/CustomStatuses/GetPersonnelStatusesForDepartment?includeAny=True'; + var url = resgrid.absoluteBaseUrl + '/User/CustomStatuses/GetPersonnelStatusesForDepartment?includeAny=False'; initDropDown('#Notification_BeforeData', url); initDropDown('#Notification_CurrentData', url); } From 4bfbfd3f2d469571806eeda10094ace38ef6880b Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 4 Aug 2026 17:10:14 -0700 Subject: [PATCH 2/2] RG-T129 PR#446 Fixes, Moderation system expansion --- .../Areas/User/Dispatch/Call.ar.resx | 12 +- .../Areas/User/Dispatch/Call.de.resx | 18 +- .../Areas/User/Dispatch/Call.en.resx | 10 +- .../Areas/User/Dispatch/Call.es.resx | 10 +- .../Areas/User/Dispatch/Call.fr.resx | 18 +- .../Areas/User/Dispatch/Call.it.resx | 16 +- .../Areas/User/Dispatch/Call.pl.resx | 18 +- .../Areas/User/Dispatch/Call.sv.resx | 18 +- .../Areas/User/Dispatch/Call.uk.resx | 18 +- .../Areas/User/Moderation/Moderation.ar.resx | 156 ++++ .../Areas/User/Moderation/Moderation.cs | 65 ++ .../Areas/User/Moderation/Moderation.de.resx | 156 ++++ .../Areas/User/Moderation/Moderation.en.resx | 156 ++++ .../Areas/User/Moderation/Moderation.es.resx | 156 ++++ .../Areas/User/Moderation/Moderation.fr.resx | 156 ++++ .../Areas/User/Moderation/Moderation.it.resx | 156 ++++ .../Areas/User/Moderation/Moderation.pl.resx | 156 ++++ .../Areas/User/Moderation/Moderation.sv.resx | 156 ++++ .../Areas/User/Moderation/Moderation.uk.resx | 156 ++++ Core/Resgrid.Localization/Common.ar.resx | 2 +- Core/Resgrid.Localization/Common.de.resx | 3 +- Core/Resgrid.Localization/Common.en.resx | 2 +- Core/Resgrid.Localization/Common.es.resx | 2 +- Core/Resgrid.Localization/Common.fr.resx | 3 +- Core/Resgrid.Localization/Common.it.resx | 3 +- Core/Resgrid.Localization/Common.pl.resx | 3 +- Core/Resgrid.Localization/Common.sv.resx | 3 +- Core/Resgrid.Localization/Common.uk.resx | 3 +- Core/Resgrid.Model/AuditLogTypes.cs | 7 +- Core/Resgrid.Model/Chat/ChatMessage.cs | 3 + Core/Resgrid.Model/ChatbotDepartmentConfig.cs | 21 + Core/Resgrid.Model/FormAutomation.cs | 4 +- Core/Resgrid.Model/Message.cs | 7 +- Core/Resgrid.Model/Moderation/Moderation.cs | 203 +++++ .../Repositories/IChatRepositories.cs | 4 +- .../Repositories/IModerationRepositories.cs | 25 + .../Services/IModerationService.cs | 30 + Core/Resgrid.Services/AuditService.cs | 8 + Core/Resgrid.Services/ChatMessageService.cs | 9 +- Core/Resgrid.Services/MessageService.cs | 3 + Core/Resgrid.Services/ModerationService.cs | 723 ++++++++++++++++++ Core/Resgrid.Services/NotificationService.cs | 17 +- Core/Resgrid.Services/Resgrid.Services.csproj | 1 + Core/Resgrid.Services/ServicesModule.cs | 1 + .../M0111_CascadeCommunicationTestDeletes.cs | 43 ++ .../Migrations/M0112_AddModeration.cs | 252 ++++++ ...M0111_CascadeCommunicationTestDeletesPg.cs | 43 ++ .../Migrations/M0112_AddModerationPg.cs | 249 ++++++ .../ChatRepositories.cs | 7 +- .../ModerationRepositories.cs | 316 ++++++++ .../Modules/ApiDataModule.cs | 3 + .../Modules/DataModule.cs | 3 + .../Modules/NonWebDataModule.cs | 3 + .../Modules/TestingDataModule.cs | 3 + .../ChatbotDeptConfigAndSessionTests.cs | 38 + .../ModerationLocalizationTests.cs | 124 +++ .../Models/FormAutomationTests.cs | 35 + .../Services/MessageServiceInboxTests.cs | 28 + .../Services/ModerationServiceTests.cs | 269 +++++++ .../Services/NotificationServiceTests.cs | 27 + .../Web/Services/CallsControllerTests.cs | 83 ++ .../Web/Services/MessagesControllerTests.cs | 126 +++ .../Web/User/SubscriptionControllerTests.cs | 108 +++ .../Controllers/v4/CallsController.cs | 18 +- .../Controllers/v4/ChatController.cs | 27 +- .../Controllers/v4/MessagesController.cs | 2 +- .../Controllers/v4/ModerationController.cs | 278 +++++++ .../Models/v4/Calls/EditCallInput.cs | 1 + .../Models/v4/Chat/ChatApiModels.cs | 5 + .../v4/Moderation/ModerationApiModels.cs | 102 +++ .../Resgrid.Web.Services.xml | 26 + .../components/chat/ChatModerationElement.tsx | 31 +- .../src/components/chat/ChatPageElement.tsx | 17 +- .../Apps/src/components/chat/FlagDialog.tsx | 117 +-- .../components/chat/atoms/MessageBubble.tsx | 5 +- .../User/Apps/src/components/chat/chat.css | 60 ++ .../Apps/src/components/chat/chatFormat.ts | 2 +- .../Apps/src/components/chat/chatStore.ts | 3 +- .../components/chat/moderation/ActionsTab.tsx | 43 +- .../components/chat/moderation/ExportsTab.tsx | 39 +- .../components/chat/moderation/FlagsTab.tsx | 131 +--- .../moderation/ModerationRequestsTable.tsx | 285 +++++++ .../components/chat/moderation/ReportsTab.tsx | 13 + .../chat/moderation/SettingsTab.tsx | 27 +- .../Apps/src/components/chat/moderationApi.ts | 134 ++++ .../src/components/chat/moderationI18n.ts | 13 + .../User/Apps/src/components/chat/types.ts | 3 + .../Areas/User/Apps/src/elements.ts | 2 +- .../Areas/User/Controllers/ChatController.cs | 5 +- .../User/Controllers/DispatchController.cs | 113 ++- .../User/Controllers/MessagesController.cs | 46 +- .../User/Controllers/ModerationController.cs | 30 + .../Controllers/SubscriptionController.cs | 5 +- .../User/Models/Dispatch/FlagCallImageView.cs | 2 + .../User/Models/Dispatch/FlagCallNoteView.cs | 2 + .../User/Models/Messages/ViewMessageView.cs | 4 + .../Areas/User/Views/Chat/Moderation.cshtml | 2 +- .../User/Views/Dispatch/FlagCallImage.cshtml | 20 +- .../User/Views/Dispatch/FlagCallNote.cshtml | 20 +- .../Areas/User/Views/Dispatch/ViewCall.cshtml | 17 +- .../User/Views/Messages/ViewMessage.cshtml | 36 +- .../Areas/User/Views/Moderation/Index.cshtml | 30 + .../User/Views/Shared/_Navigation.cshtml | 6 +- .../User/Views/Shared/_UserLayout.cshtml | 2 + .../resgrid.templates.newtemplate.js | 6 +- 105 files changed, 5733 insertions(+), 454 deletions(-) create mode 100644 Core/Resgrid.Localization/Areas/User/Moderation/Moderation.ar.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Moderation/Moderation.cs create mode 100644 Core/Resgrid.Localization/Areas/User/Moderation/Moderation.de.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Moderation/Moderation.en.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Moderation/Moderation.es.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Moderation/Moderation.fr.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Moderation/Moderation.it.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Moderation/Moderation.pl.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Moderation/Moderation.sv.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Moderation/Moderation.uk.resx create mode 100644 Core/Resgrid.Model/Moderation/Moderation.cs create mode 100644 Core/Resgrid.Model/Repositories/IModerationRepositories.cs create mode 100644 Core/Resgrid.Model/Services/IModerationService.cs create mode 100644 Core/Resgrid.Services/ModerationService.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0111_CascadeCommunicationTestDeletes.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0112_AddModeration.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0111_CascadeCommunicationTestDeletesPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0112_AddModerationPg.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs create mode 100644 Tests/Resgrid.Tests/Localization/ModerationLocalizationTests.cs create mode 100644 Tests/Resgrid.Tests/Models/FormAutomationTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ModerationServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs create mode 100644 Tests/Resgrid.Tests/Web/Services/MessagesControllerTests.cs create mode 100644 Tests/Resgrid.Tests/Web/User/SubscriptionControllerTests.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/ModerationController.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Moderation/ModerationApiModels.cs create mode 100644 Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderation/ModerationRequestsTable.tsx create mode 100644 Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderation/ReportsTab.tsx create mode 100644 Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderationApi.ts create mode 100644 Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderationI18n.ts create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/ModerationController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/Moderation/Index.cshtml diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx index def2f83ed..014bbf03c 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx @@ -64,17 +64,17 @@ نوع الملف البحث عن عنوان البحث عن موقع w3w - تعليم ملاحظة البلاغ - تعليم صورة البلاغ + الإبلاغ عن ملاحظة البلاغ للمراجعة + الإبلاغ عن صورة البلاغ للمراجعة تعليم ملف البلاغ أضيفت الصورة بواسطة طابع وقت الصورة أضيف الملف بواسطة طابع وقت الملف - تعليم الصورة + الإبلاغ عن الصورة تعليم الملف - سبب التعليم - سبب التعليم + سبب الإبلاغ + سبب الإبلاغ GPS معرف الحادثة معرف الحادثة الخارجي الرئيسي أو الأصل @@ -278,4 +278,4 @@ تاريخ الإرسال المجدول - \ No newline at end of file + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx index 0a7621e14..049c909cf 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx @@ -150,16 +150,16 @@ A description of the nature of the call - Call Note Added By + Einsatznotiz hinzugefügt von - Call Note Added On + Einsatznotiz hinzugefügt am Call Notes - Call Note Text + Text der Einsatznotiz Call Number @@ -234,7 +234,7 @@ This is an unauthenticated page for viewing this call only. You are not logged into Resgrid. Do not share this url. - File Name + Dateiname File Type @@ -246,10 +246,10 @@ Find w3w Location - Flag Call Note + Einsatznotiz zur Moderation melden - Anrufbild markieren + Einsatzbild zur Moderation melden Anrufdatei markieren @@ -267,16 +267,16 @@ Dateizeitstempel - Bild markieren + Bild melden Datei markieren - Flagged Reason + Meldegrund - Reason for flagging + Grund für die Meldung GPS diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx index dae748e0a..7e75d5ea7 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx @@ -295,10 +295,10 @@ Find w3w Location - Flag Call Note + Report Call Note for Moderation - Flag Call Image + Report Call Image for Moderation Flag Call File @@ -316,16 +316,16 @@ File Timestamp - Flag Image + Report Image Flag File - Flagged Reason + Report Reason - Reason for flagging + Reason for reporting GPS diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx index 4667a209a..fdd543286 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx @@ -295,10 +295,10 @@ Encuentra la ubicación de w3w - Marcar nota de llamada + Reportar nota de llamada para moderación - Marcar imagen del incidente + Reportar imagen de la llamada para moderación Marcar archivo del incidente @@ -316,16 +316,16 @@ Marca de tiempo de archivo - Marcar imagen + Reportar imagen Marcar archivo - Motivo marcado + Motivo del reporte - Motivo de marcar + Motivo para reportar GPS diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx index 03b749255..7c8b65f2d 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx @@ -150,16 +150,16 @@ A description of the nature of the call - Call Note Added By + Note d’appel ajoutée par - Call Note Added On + Note d’appel ajoutée le Call Notes - Call Note Text + Texte de la note d’appel Call Number @@ -234,7 +234,7 @@ This is an unauthenticated page for viewing this call only. You are not logged into Resgrid. Do not share this url. - File Name + Nom du fichier File Type @@ -246,10 +246,10 @@ Find w3w Location - Flag Call Note + Signaler la note d’appel pour modération - Signaler l'image de l'appel + Signaler l’image de l’appel pour modération Signaler le fichier de l'appel @@ -267,16 +267,16 @@ Horodatage du fichier - Signaler l'image + Signaler l’image Signaler le fichier - Flagged Reason + Motif du signalement - Reason for flagging + Motif du signalement GPS diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx index 6c600e50b..df3f925d2 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx @@ -150,16 +150,16 @@ A description of the nature of the call - Call Note Added By + Nota della chiamata aggiunta da - Call Note Added On + Nota della chiamata aggiunta il Call Notes - Call Note Text + Testo della nota della chiamata Call Number @@ -234,7 +234,7 @@ This is an unauthenticated page for viewing this call only. You are not logged into Resgrid. Do not share this url. - File Name + Nome del file File Type @@ -246,10 +246,10 @@ Find w3w Location - Flag Call Note + Segnala la nota della chiamata per la moderazione - Segnala immagine chiamata + Segnala l’immagine della chiamata per la moderazione Segnala file chiamata @@ -273,10 +273,10 @@ Segnala file - Flagged Reason + Motivo della segnalazione - Reason for flagging + Motivo della segnalazione GPS diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx index 50826950a..19aa4c7d3 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx @@ -150,16 +150,16 @@ A description of the nature of the call - Call Note Added By + Notatkę dodał(a) - Call Note Added On + Notatkę dodano Call Notes - Call Note Text + Tekst notatki ze zgłoszenia Call Number @@ -234,7 +234,7 @@ This is an unauthenticated page for viewing this call only. You are not logged into Resgrid. Do not share this url. - File Name + Nazwa pliku File Type @@ -246,10 +246,10 @@ Find w3w Location - Flag Call Note + Zgłoś notatkę ze zgłoszenia do moderacji - Oznacz obraz zgłoszenia + Zgłoś obraz ze zgłoszenia do moderacji Oznacz plik zgłoszenia @@ -267,16 +267,16 @@ Znacznik czasu pliku - Oznacz obraz + Zgłoś obraz Oznacz plik - Flagged Reason + Powód zgłoszenia - Reason for flagging + Powód zgłoszenia GPS diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx index ddb0ee1d0..cd2d8b843 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx @@ -150,16 +150,16 @@ A description of the nature of the call - Call Note Added By + Samtalsanteckning tillagd av - Call Note Added On + Samtalsanteckning tillagd Call Notes - Call Note Text + Samtalsanteckningens text Call Number @@ -234,7 +234,7 @@ This is an unauthenticated page for viewing this call only. You are not logged into Resgrid. Do not share this url. - File Name + Filnamn File Type @@ -246,10 +246,10 @@ Find w3w Location - Flag Call Note + Rapportera samtalsanteckning för moderering - Flagga samtalsbild + Rapportera samtalsbild för moderering Flagga samtalsfil @@ -267,16 +267,16 @@ Filtidsstämpel - Flagga bild + Rapportera bild Flagga fil - Flagged Reason + Rapportorsak - Reason for flagging + Orsak till rapporteringen GPS diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx index 6e6e7b2e5..bae02ba7e 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx @@ -150,16 +150,16 @@ A description of the nature of the call - Call Note Added By + Нотатку виклику додав(ла) - Call Note Added On + Нотатку виклику додано Call Notes - Call Note Text + Текст нотатки виклику Call Number @@ -234,7 +234,7 @@ This is an unauthenticated page for viewing this call only. You are not logged into Resgrid. Do not share this url. - File Name + Назва файлу File Type @@ -246,10 +246,10 @@ Find w3w Location - Flag Call Note + Повідомити про нотатку виклику для модерації - Позначити зображення виклику + Повідомити про зображення виклику для модерації Позначити файл виклику @@ -267,16 +267,16 @@ Мітка часу файлу - Позначити зображення + Повідомити про зображення Позначити файл - Flagged Reason + Причина повідомлення - Reason for flagging + Причина повідомлення GPS diff --git a/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.ar.resx b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.ar.resx new file mode 100644 index 000000000..521f23055 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.ar.resx @@ -0,0 +1,156 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + القسم والمستخدم المبلّغ والعنصر مطلوبة. + أُعيد فتح الطلب بعد بلاغ جديد. + هذا الطلب خارج نطاق مراجعتك. + لا يمكن إكمال إلا طلبات المراجعة المعلّقة. + تعذّرت إزالة المحتوى المبلّغ عنه. + هذا الدليل خارج نطاق مراجعتك. + تم تنزيل الدليل الأصلي. + رسالة المحادثة غير متاحة أو لا يمكن الإبلاغ عنها. + الرسالة غير متاحة أو لا يمكن الإبلاغ عنها. + معرّف ملاحظة البلاغ غير صالح. + ملاحظة البلاغ غير متاحة أو لا يمكن الإبلاغ عنها. + معرّف صورة البلاغ غير صالح. + صورة البلاغ غير متاحة أو لا يمكن الإبلاغ عنها. + أُزيل المحتوى بعد المراجعة + أُزيلت هذه الرسالة بعد مراجعة المحتوى. + أُزيلت ملاحظة البلاغ هذه بعد مراجعة المحتوى. + أُزيلت هذه الرسالة بعد مراجعة المحتوى. + تمت إزالة المحتوى المبلّغ عنه. + تمت مراجعة الطلب ولم تتم إزالة أي محتوى. + <br/><br/><strong>ملاحظة المسؤول:</strong> {0} + اكتمل طلب مراجعة المحتوى + اكتمل بلاغ المراجعة الخاص بك بشأن {0} {1}. {2}{3} + النظام + رسالة محادثة + رسالة + ملاحظة بلاغ + صورة بلاغ + الطلبات + التقارير + عناصر تحكم المحادثة + إعدادات المحادثة + تصدير المحادثة + ابحث في طلبات المراجعة الجديدة والمكتملة حسب المستخدم الذي أضاف المحتوى أو المستخدم الذي أبلغ عنه. + الحالة + الكل + معلّق + مكتمل + نوع المحتوى + أضافه معرّف المستخدم + أبلغ عنه معرّف المستخدم + من + إلى + تحديث + جارٍ تحميل طلبات المراجعة… + لا توجد طلبات مراجعة تطابق عوامل التصفية هذه. + العنصر + الدليل الأصلي + أضافه + البلاغات + الإجراء + تمت إزالة المحتوى + لا إجراء + نوع محتوى غير معروف + المعرّف {0} + البلاغ {0} + تنزيل الدليل المحفوظ + النظام أو غير معروف + المجموعة {0} + ملاحظة الإكمال التي ستُرسل إلى المستخدمين المبلّغين (اختياري) + إزالة المحتوى + إكمال بلا إجراء + أكمله + لا توجد ملاحظة إكمال. + إخفاء سجل التدقيق + سجل التدقيق ({0}) + بواسطة + لا توجد ملاحظة. + المستخدم المبلّغ + مسؤول القسم + مسؤول المجموعة + استيراد قديم + دور غير معروف + لا يوجد عنوان IP + التتبّع {0} + غير متاح + + تعذّر تحميل طلبات المراجعة. + تعذّر إكمال طلب المراجعة. + أخرى + محتوى غير لائق + مضايقة أو إساءة + محتوى غير مرغوب فيه + معلومات حساسة + مخالفة السياسات + تم إرسال البلاغ + أُعيد فتح الطلب + اكتمل بلا إجراء + تمت إزالة المحتوى + تم تنزيل الدليل + حالة بلاغ المراجعة + الإبلاغ عن الرسالة للمراجعة + إغلاق + إلغاء + إبلاغ + جارٍ التحقق من حالة البلاغ… + الحالة + تمت إزالة المحتوى + لم تتم إزالة أي محتوى + ملاحظة المراجع + السبب + أضف ملاحظة (اختياري) + حالة بلاغ المراجعة الخاص بك هي + الإبلاغ للمراجعة + اشرح سبب ضرورة مراجعة هذه الرسالة + تم إرسال بلاغ المراجعة الخاص بك. + حذف الرسالة + كتم المستخدم + حظر المستخدم + إلغاء حظر المستخدم + قفل القناة + فتح القناة + حل البلاغ + الإجراء رقم {0} + جارٍ التحميل… + لا توجد أنشطة مراجعة. + الهدف + الوقت + الوحدة {0} + السابق + الصفحة {0} + التالي + السماح بإرفاق الصور + السماح بصور GIF + السماح بمشاركة الموقع + الرسائل العاجلة تتجاوز الكتم + المساعد مفعّل + الاحتفاظ بالرسائل (بالأيام، 0 = دائمًا) + الحد الأقصى لحجم المرفق (ميغابايت) + جارٍ الحفظ… + حفظ الإعدادات + تم الحفظ + تعذّر حفظ إعدادات المراجعة. + في قائمة الانتظار + قيد التشغيل + مكتمل + فشل + تعذّر طلب تصدير المحادثة. + التنسيق + تاريخ البدء + تاريخ الانتهاء + جارٍ الطلب… + طلب التصدير + تاريخ الطلب + النطاق + تنزيل + لا توجد مهام تصدير. + الآن + دليل-المراجعة-{0} + تم حذف هذه الرسالة. + diff --git a/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.cs b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.cs new file mode 100644 index 000000000..3a9592b6f --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Resources; + +namespace Resgrid.Localization.Areas.User.Moderation +{ + /// Marker type used by ASP.NET Core localization for moderation resources. + public class Moderation + { + } + + /// + /// Culture-explicit access to moderation resources for background/service messages where the + /// recipient's preferred culture can differ from the current request culture. + /// + public static class ModerationResources + { + private static readonly ResourceManager ResourceManager = new ResourceManager( + typeof(Moderation).FullName!, typeof(Moderation).Assembly); + + public static string Get(string key, string? culture, params object[] arguments) + { + var cultureInfo = GetSupportedCulture(culture); + var value = ResourceManager.GetString(key, cultureInfo) + ?? ResourceManager.GetString(key, CultureInfo.GetCultureInfo("en")) + ?? key; + + return arguments == null || arguments.Length == 0 + ? value + : string.Format(cultureInfo, value, arguments); + } + + public static string GetCurrent(string key, params object[] arguments) + { + return Get(key, CultureInfo.CurrentUICulture.Name, arguments); + } + + public static IReadOnlyDictionary GetAll(string culture) + { + var resourceSet = ResourceManager.GetResourceSet(GetSupportedCulture(culture), true, false); + if (resourceSet == null) + return new Dictionary(); + + return resourceSet.Cast() + .Where(x => x.Key is string && x.Value is string) + .ToDictionary(x => (string)x.Key, x => (string)x.Value!); + } + + private static CultureInfo GetSupportedCulture(string? culture) + { + var candidate = string.IsNullOrWhiteSpace(culture) ? "en" : culture.Trim(); + var separator = candidate.IndexOfAny(new[] { '-', '_' }); + if (separator > 0) + candidate = candidate.Substring(0, separator); + + candidate = candidate.ToLowerInvariant(); + return SupportedLocales.SupportedLanguagesMap.ContainsKey(candidate) + ? CultureInfo.GetCultureInfo(candidate) + : CultureInfo.GetCultureInfo("en"); + } + } +} diff --git a/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.de.resx b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.de.resx new file mode 100644 index 000000000..d5d046748 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.de.resx @@ -0,0 +1,156 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Abteilung, meldende Person und Element sind erforderlich. + Der Vorgang wurde nach einer neuen Meldung erneut geöffnet. + Dieser Vorgang liegt außerhalb Ihres Moderationsbereichs. + Nur ausstehende Moderationsvorgänge können abgeschlossen werden. + Der gemeldete Inhalt konnte nicht entfernt werden. + Dieses Beweismaterial liegt außerhalb Ihres Moderationsbereichs. + Originalbeweismaterial heruntergeladen. + Die Chatnachricht ist nicht verfügbar oder kann nicht gemeldet werden. + Die Nachricht ist nicht verfügbar oder kann nicht gemeldet werden. + Die Kennung der Einsatznotiz ist ungültig. + Die Einsatznotiz ist nicht verfügbar oder kann nicht gemeldet werden. + Die Kennung des Einsatzbildes ist ungültig. + Das Einsatzbild ist nicht verfügbar oder kann nicht gemeldet werden. + Inhalt durch Moderation entfernt + Diese Nachricht wurde durch die Moderation entfernt. + Diese Einsatznotiz wurde durch die Moderation entfernt. + Diese Nachricht wurde durch die Moderation entfernt. + Der gemeldete Inhalt wurde entfernt. + Der Vorgang wurde geprüft und es wurden keine Inhalte entfernt. + <br/><br/><strong>Hinweis der Administration:</strong> {0} + Moderationsvorgang abgeschlossen + Ihre Moderationsmeldung zu {0} {1} wurde abgeschlossen. {2}{3} + System + Chatnachricht + Nachricht + Einsatznotiz + Einsatzbild + Vorgänge + Berichte + Chat-Steuerung + Chat-Einstellungen + Chat-Exporte + Suchen Sie neue und abgeschlossene Moderationsvorgänge nach der Person, die den Inhalt hinzugefügt oder gemeldet hat. + Status + Alle + Ausstehend + Abgeschlossen + Inhaltstyp + Hinzugefügt von Benutzer-ID + Gemeldet von Benutzer-ID + Von + Bis + Aktualisieren + Moderationsvorgänge werden geladen… + Keine Moderationsvorgänge entsprechen diesen Filtern. + Element + Originalbeweismaterial + Hinzugefügt von + Meldungen + Aktion + Inhalt entfernt + Keine Aktion + Unbekannter Inhaltstyp + ID {0} + Einsatz {0} + Gespeichertes Beweismaterial herunterladen + System oder unbekannt + Gruppe {0} + Abschlussnotiz an meldende Benutzer (optional) + Inhalt entfernen + Ohne Aktion abschließen + Abgeschlossen von + Keine Abschlussnotiz. + Auditprotokoll ausblenden + Auditprotokoll ({0}) + von + Keine Notiz. + Meldende Person + Abteilungsadministration + Gruppenadministration + Altimport + Unbekannte Rolle + Keine IP-Adresse + Trace {0} + nicht verfügbar + + Die Moderationsvorgänge konnten nicht geladen werden. + Der Moderationsvorgang konnte nicht abgeschlossen werden. + Sonstiges + Unangemessener Inhalt + Belästigung oder Missbrauch + Spam + Vertrauliche Informationen + Richtlinienverstoß + Meldung eingereicht + Vorgang erneut geöffnet + Ohne Aktion abgeschlossen + Inhalt entfernt + Beweismaterial heruntergeladen + Status der Moderationsmeldung + Nachricht zur Moderation melden + Schließen + Abbrechen + Melden + Meldungsstatus wird geprüft… + Status + Der Inhalt wurde entfernt + Es wurde kein Inhalt entfernt + Moderationsnotiz + Grund + Notiz hinzufügen (optional) + Ihre Moderationsmeldung ist + Zur Moderation melden + Erklären Sie, warum diese Nachricht geprüft werden sollte + Ihre Moderationsmeldung wurde eingereicht. + Nachricht löschen + Benutzer stummschalten + Benutzer sperren + Benutzersperre aufheben + Kanal sperren + Kanal entsperren + Meldung klären + Aktion Nr. {0} + Wird geladen… + Keine Moderationsaktivität. + Ziel + Zeitpunkt + Einheit {0} + Zurück + Seite {0} + Weiter + Bildanhänge erlauben + GIFs erlauben + Standortfreigabe erlauben + Dringende Nachrichten umgehen die Stummschaltung + Assistent aktiviert + Nachrichtenaufbewahrung (Tage, 0 = unbegrenzt) + Maximale Anhangsgröße (MB) + Speichern… + Einstellungen speichern + Gespeichert + Die Moderationseinstellungen konnten nicht gespeichert werden. + In Warteschlange + Wird ausgeführt + Abgeschlossen + Fehlgeschlagen + Der Chat-Export konnte nicht angefordert werden. + Format + Startdatum + Enddatum + Wird angefordert… + Export anfordern + Angefordert + Zeitraum + Herunterladen + Keine Exportaufträge. + Jetzt + moderationsbeweis-{0} + Diese Nachricht wurde gelöscht. + diff --git a/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.en.resx b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.en.resx new file mode 100644 index 000000000..0169a7113 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.en.resx @@ -0,0 +1,156 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Department, reporter, and item are required. + Request reopened after a new report. + This request is outside your moderation scope. + Only pending moderation requests can be completed. + The reported content could not be removed. + This evidence is outside your moderation scope. + Original evidence downloaded. + The chat message is unavailable or cannot be reported. + The message is unavailable or cannot be reported. + The call note identifier is invalid. + The call note is unavailable or cannot be reported. + The call image identifier is invalid. + The call image is unavailable or cannot be reported. + Content removed by moderation + This message was removed by moderation. + This call note was removed by moderation. + This message was removed by moderation. + The reported content was removed. + The request was reviewed and no content was removed. + <br/><br/><strong>Administrator note:</strong> {0} + Moderation Request Completed + Your moderation report for {0} {1} has been completed. {2}{3} + System + Chat message + Message + Call note + Call image + Requests + Reports + Chat controls + Chat settings + Chat exports + Search new and completed moderation requests by the user who added the content or the user who reported it. + Status + All + Pending + Completed + Content type + Added by user ID + Reported by user ID + From + To + Refresh + Loading moderation requests… + No moderation requests match these filters. + Item + Original evidence + Added by + Reports + Action + Content removed + No action + Unknown content type + ID {0} + Call {0} + Download retained evidence + System or unknown + group {0} + Completion note sent to reporting users (optional) + Remove content + Complete—no action + Completed by + No completion note. + Hide audit trail + Audit trail ({0}) + by + No note. + Reporter + Department administrator + Group administrator + Legacy import + Unknown role + No IP address + trace {0} + not available + + Unable to load moderation requests. + Unable to complete the moderation request. + Other + Inappropriate content + Harassment or abuse + Spam + Sensitive information + Policy violation + Report submitted + Request reopened + Completed without action + Content removed + Evidence downloaded + Moderation report status + Report message for moderation + Close + Cancel + Report + Checking report status… + Status + Content was removed + No content was removed + Moderator note + Reason + Add a note (optional) + Your moderation report is + Report for moderation + Explain why this message should be reviewed + Your moderation report was submitted. + Delete message + Mute user + Ban user + Unban user + Lock channel + Unlock channel + Resolve report + Action #{0} + Loading… + No moderation activity. + Target + When + Unit {0} + Previous + Page {0} + Next + Allow image attachments + Allow GIFs + Allow location sharing + Urgent messages override mute + Assistant enabled + Message retention (days, 0 = forever) + Maximum attachment size (MB) + Saving… + Save settings + Saved + Failed to save moderation settings. + Queued + Running + Complete + Failed + Failed to request a chat export. + Format + Start date + End date + Requesting… + Request export + Requested + Range + Download + No export jobs. + Now + moderation-evidence-{0} + This message was deleted. + diff --git a/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.es.resx b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.es.resx new file mode 100644 index 000000000..6281daed3 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.es.resx @@ -0,0 +1,156 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Se requieren el departamento, el usuario que reporta y el elemento. + La solicitud se reabrió tras un nuevo reporte. + Esta solicitud está fuera de su ámbito de moderación. + Solo se pueden completar las solicitudes de moderación pendientes. + No se pudo eliminar el contenido reportado. + Esta evidencia está fuera de su ámbito de moderación. + Se descargó la evidencia original. + El mensaje de chat no está disponible o no se puede reportar. + El mensaje no está disponible o no se puede reportar. + El identificador de la nota de llamada no es válido. + La nota de llamada no está disponible o no se puede reportar. + El identificador de la imagen de llamada no es válido. + La imagen de llamada no está disponible o no se puede reportar. + Contenido eliminado por moderación + Este mensaje fue eliminado por moderación. + Esta nota de llamada fue eliminada por moderación. + Este mensaje fue eliminado por moderación. + El contenido reportado fue eliminado. + La solicitud fue revisada y no se eliminó ningún contenido. + <br/><br/><strong>Nota del administrador:</strong> {0} + Solicitud de moderación completada + Su reporte de moderación sobre {0} {1} se ha completado. {2}{3} + Sistema + mensaje de chat + mensaje + nota de llamada + imagen de llamada + Solicitudes + Informes + Controles del chat + Configuración del chat + Exportaciones del chat + Busque solicitudes de moderación nuevas y completadas por el usuario que agregó el contenido o por quien lo reportó. + Estado + Todos + Pendiente + Completada + Tipo de contenido + Agregado por ID de usuario + Reportado por ID de usuario + Desde + Hasta + Actualizar + Cargando solicitudes de moderación… + Ninguna solicitud de moderación coincide con estos filtros. + Elemento + Evidencia original + Agregado por + Reportes + Acción + Contenido eliminado + Sin acción + Tipo de contenido desconocido + ID {0} + Llamada {0} + Descargar evidencia conservada + Sistema o desconocido + grupo {0} + Nota de finalización enviada a los usuarios que reportaron (opcional) + Eliminar contenido + Completar sin acción + Completada por + Sin nota de finalización. + Ocultar registro de auditoría + Registro de auditoría ({0}) + por + Sin nota. + Usuario que reportó + Administrador del departamento + Administrador del grupo + Importación heredada + Rol desconocido + Sin dirección IP + seguimiento {0} + no disponible + + No se pudieron cargar las solicitudes de moderación. + No se pudo completar la solicitud de moderación. + Otro + Contenido inapropiado + Acoso o abuso + Correo no deseado + Información confidencial + Infracción de políticas + Reporte enviado + Solicitud reabierta + Completada sin acción + Contenido eliminado + Evidencia descargada + Estado del reporte de moderación + Reportar mensaje para moderación + Cerrar + Cancelar + Reportar + Comprobando el estado del reporte… + Estado + El contenido fue eliminado + No se eliminó ningún contenido + Nota del moderador + Motivo + Agregue una nota (opcional) + Su reporte de moderación está + Reportar para moderación + Explique por qué se debe revisar este mensaje + Su reporte de moderación fue enviado. + Eliminar mensaje + Silenciar usuario + Bloquear usuario + Desbloquear usuario + Bloquear canal + Desbloquear canal + Resolver reporte + Acción n.º {0} + Cargando… + No hay actividad de moderación. + Objetivo + Cuándo + Unidad {0} + Anterior + Página {0} + Siguiente + Permitir imágenes adjuntas + Permitir GIF + Permitir compartir la ubicación + Los mensajes urgentes ignoran el silencio + Asistente activado + Conservación de mensajes (días, 0 = para siempre) + Tamaño máximo de archivo adjunto (MB) + Guardando… + Guardar configuración + Guardado + No se pudo guardar la configuración de moderación. + En cola + En curso + Completada + Fallida + No se pudo solicitar una exportación del chat. + Formato + Fecha de inicio + Fecha de fin + Solicitando… + Solicitar exportación + Solicitada + Intervalo + Descargar + No hay tareas de exportación. + Ahora + evidencia-moderacion-{0} + Este mensaje fue eliminado. + diff --git a/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.fr.resx b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.fr.resx new file mode 100644 index 000000000..76aaaafc5 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.fr.resx @@ -0,0 +1,156 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Le service, l’auteur du signalement et l’élément sont obligatoires. + La demande a été rouverte après un nouveau signalement. + Cette demande ne relève pas de votre périmètre de modération. + Seules les demandes de modération en attente peuvent être terminées. + Le contenu signalé n’a pas pu être supprimé. + Cette preuve ne relève pas de votre périmètre de modération. + Preuve d’origine téléchargée. + Le message de discussion est indisponible ou ne peut pas être signalé. + Le message est indisponible ou ne peut pas être signalé. + L’identifiant de la note d’appel n’est pas valide. + La note d’appel est indisponible ou ne peut pas être signalée. + L’identifiant de l’image d’appel n’est pas valide. + L’image d’appel est indisponible ou ne peut pas être signalée. + Contenu supprimé par la modération + Ce message a été supprimé par la modération. + Cette note d’appel a été supprimée par la modération. + Ce message a été supprimé par la modération. + Le contenu signalé a été supprimé. + La demande a été examinée et aucun contenu n’a été supprimé. + <br/><br/><strong>Note de l’administrateur :</strong> {0} + Demande de modération terminée + Votre signalement de modération concernant {0} {1} est terminé. {2}{3} + Système + message de discussion + message + note d’appel + image d’appel + Demandes + Rapports + Contrôles de discussion + Paramètres de discussion + Exports de discussion + Recherchez les demandes de modération nouvelles et terminées par l’utilisateur qui a ajouté le contenu ou celui qui l’a signalé. + État + Tous + En attente + Terminée + Type de contenu + Ajouté par l’ID utilisateur + Signalé par l’ID utilisateur + Du + Au + Actualiser + Chargement des demandes de modération… + Aucune demande de modération ne correspond à ces filtres. + Élément + Preuve d’origine + Ajouté par + Signalements + Action + Contenu supprimé + Aucune action + Type de contenu inconnu + ID {0} + Appel {0} + Télécharger la preuve conservée + Système ou inconnu + groupe {0} + Note de clôture envoyée aux utilisateurs ayant signalé (facultatif) + Supprimer le contenu + Terminer sans action + Terminée par + Aucune note de clôture. + Masquer le journal d’audit + Journal d’audit ({0}) + par + Aucune note. + Auteur du signalement + Administrateur du service + Administrateur du groupe + Importation héritée + Rôle inconnu + Aucune adresse IP + trace {0} + indisponible + + Impossible de charger les demandes de modération. + Impossible de terminer la demande de modération. + Autre + Contenu inapproprié + Harcèlement ou abus + Courrier indésirable + Informations sensibles + Violation de la politique + Signalement envoyé + Demande rouverte + Terminée sans action + Contenu supprimé + Preuve téléchargée + État du signalement de modération + Signaler le message pour modération + Fermer + Annuler + Signaler + Vérification de l’état du signalement… + État + Le contenu a été supprimé + Aucun contenu n’a été supprimé + Note du modérateur + Motif + Ajouter une note (facultatif) + Votre signalement de modération est + Signaler pour modération + Expliquez pourquoi ce message doit être examiné + Votre signalement de modération a été envoyé. + Supprimer le message + Mettre l’utilisateur en sourdine + Bannir l’utilisateur + Annuler le bannissement + Verrouiller le canal + Déverrouiller le canal + Résoudre le signalement + Action n° {0} + Chargement… + Aucune activité de modération. + Cible + Date + Unité {0} + Précédent + Page {0} + Suivant + Autoriser les images jointes + Autoriser les GIF + Autoriser le partage de position + Les messages urgents ignorent la sourdine + Assistant activé + Conservation des messages (jours, 0 = illimitée) + Taille maximale des pièces jointes (Mo) + Enregistrement… + Enregistrer les paramètres + Enregistré + Impossible d’enregistrer les paramètres de modération. + En attente + En cours + Terminé + Échec + Impossible de demander un export de discussion. + Format + Date de début + Date de fin + Demande en cours… + Demander l’export + Demandé + Période + Télécharger + Aucune tâche d’export. + Maintenant + preuve-moderation-{0} + Ce message a été supprimé. + diff --git a/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.it.resx b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.it.resx new file mode 100644 index 000000000..d974d08c4 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.it.resx @@ -0,0 +1,156 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Il dipartimento, l’utente segnalante e l’elemento sono obbligatori. + La richiesta è stata riaperta dopo una nuova segnalazione. + Questa richiesta non rientra nel tuo ambito di moderazione. + È possibile completare solo le richieste di moderazione in sospeso. + Non è stato possibile rimuovere il contenuto segnalato. + Questa prova non rientra nel tuo ambito di moderazione. + Prova originale scaricata. + Il messaggio della chat non è disponibile o non può essere segnalato. + Il messaggio non è disponibile o non può essere segnalato. + L’identificatore della nota della chiamata non è valido. + La nota della chiamata non è disponibile o non può essere segnalata. + L’identificatore dell’immagine della chiamata non è valido. + L’immagine della chiamata non è disponibile o non può essere segnalata. + Contenuto rimosso dalla moderazione + Questo messaggio è stato rimosso dalla moderazione. + Questa nota della chiamata è stata rimossa dalla moderazione. + Questo messaggio è stato rimosso dalla moderazione. + Il contenuto segnalato è stato rimosso. + La richiesta è stata esaminata e non è stato rimosso alcun contenuto. + <br/><br/><strong>Nota dell’amministratore:</strong> {0} + Richiesta di moderazione completata + La tua segnalazione di moderazione relativa a {0} {1} è stata completata. {2}{3} + Sistema + messaggio della chat + messaggio + nota della chiamata + immagine della chiamata + Richieste + Rapporti + Controlli della chat + Impostazioni della chat + Esportazioni della chat + Cerca le richieste di moderazione nuove e completate in base all’utente che ha aggiunto il contenuto o che lo ha segnalato. + Stato + Tutte + In sospeso + Completata + Tipo di contenuto + Aggiunto dall’ID utente + Segnalato dall’ID utente + Da + A + Aggiorna + Caricamento delle richieste di moderazione… + Nessuna richiesta di moderazione corrisponde a questi filtri. + Elemento + Prova originale + Aggiunto da + Segnalazioni + Azione + Contenuto rimosso + Nessuna azione + Tipo di contenuto sconosciuto + ID {0} + Chiamata {0} + Scarica la prova conservata + Sistema o sconosciuto + gruppo {0} + Nota di completamento inviata agli utenti segnalanti (facoltativa) + Rimuovi contenuto + Completa senza azione + Completata da + Nessuna nota di completamento. + Nascondi registro di controllo + Registro di controllo ({0}) + da + Nessuna nota. + Utente segnalante + Amministratore del dipartimento + Amministratore del gruppo + Importazione precedente + Ruolo sconosciuto + Nessun indirizzo IP + traccia {0} + non disponibile + + Impossibile caricare le richieste di moderazione. + Impossibile completare la richiesta di moderazione. + Altro + Contenuto inappropriato + Molestie o abusi + Posta indesiderata + Informazioni sensibili + Violazione delle norme + Segnalazione inviata + Richiesta riaperta + Completata senza azione + Contenuto rimosso + Prova scaricata + Stato della segnalazione di moderazione + Segnala il messaggio per la moderazione + Chiudi + Annulla + Segnala + Verifica dello stato della segnalazione… + Stato + Il contenuto è stato rimosso + Non è stato rimosso alcun contenuto + Nota del moderatore + Motivo + Aggiungi una nota (facoltativa) + La tua segnalazione di moderazione è + Segnala per la moderazione + Spiega perché questo messaggio dovrebbe essere esaminato + La tua segnalazione di moderazione è stata inviata. + Elimina messaggio + Silenzia utente + Blocca utente + Sblocca utente + Blocca canale + Sblocca canale + Risolvi segnalazione + Azione n. {0} + Caricamento… + Nessuna attività di moderazione. + Destinazione + Data + Unità {0} + Precedente + Pagina {0} + Successiva + Consenti allegati immagine + Consenti GIF + Consenti condivisione della posizione + I messaggi urgenti ignorano il silenziamento + Assistente abilitato + Conservazione dei messaggi (giorni, 0 = per sempre) + Dimensione massima allegato (MB) + Salvataggio… + Salva impostazioni + Salvato + Impossibile salvare le impostazioni di moderazione. + In coda + In corso + Completata + Non riuscita + Impossibile richiedere un’esportazione della chat. + Formato + Data di inizio + Data di fine + Richiesta in corso… + Richiedi esportazione + Richiesta + Intervallo + Scarica + Nessuna attività di esportazione. + Ora + prova-moderazione-{0} + Questo messaggio è stato eliminato. + diff --git a/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.pl.resx b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.pl.resx new file mode 100644 index 000000000..9b2096bbb --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.pl.resx @@ -0,0 +1,156 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Wymagane są dział, osoba zgłaszająca i element. + Wniosek został ponownie otwarty po nowym zgłoszeniu. + Ten wniosek znajduje się poza zakresem Twojej moderacji. + Można zakończyć tylko oczekujące wnioski o moderację. + Nie udało się usunąć zgłoszonej treści. + Ten materiał dowodowy znajduje się poza zakresem Twojej moderacji. + Pobrano oryginalny materiał dowodowy. + Wiadomość czatu jest niedostępna lub nie można jej zgłosić. + Wiadomość jest niedostępna lub nie można jej zgłosić. + Identyfikator notatki ze zgłoszenia jest nieprawidłowy. + Notatka ze zgłoszenia jest niedostępna lub nie można jej zgłosić. + Identyfikator obrazu ze zgłoszenia jest nieprawidłowy. + Obraz ze zgłoszenia jest niedostępny lub nie można go zgłosić. + Treść usunięta przez moderację + Ta wiadomość została usunięta przez moderację. + Ta notatka ze zgłoszenia została usunięta przez moderację. + Ta wiadomość została usunięta przez moderację. + Zgłoszona treść została usunięta. + Wniosek został rozpatrzony i nie usunięto żadnej treści. + <br/><br/><strong>Notatka administratora:</strong> {0} + Wniosek o moderację zakończony + Twoje zgłoszenie do moderacji dotyczące elementu {0} {1} zostało zakończone. {2}{3} + System + wiadomość czatu + wiadomość + notatka ze zgłoszenia + obraz ze zgłoszenia + Wnioski + Raporty + Kontrola czatu + Ustawienia czatu + Eksporty czatu + Wyszukuj nowe i zakończone wnioski o moderację według użytkownika, który dodał treść, lub użytkownika, który ją zgłosił. + Stan + Wszystkie + Oczekujący + Zakończony + Typ treści + Dodał użytkownik o identyfikatorze + Zgłosił użytkownik o identyfikatorze + Od + Do + Odśwież + Ładowanie wniosków o moderację… + Żaden wniosek o moderację nie pasuje do tych filtrów. + Element + Oryginalny materiał dowodowy + Dodane przez + Zgłoszenia + Działanie + Treść usunięta + Bez działania + Nieznany typ treści + ID {0} + Zgłoszenie {0} + Pobierz zachowany materiał dowodowy + System lub nieznany + grupa {0} + Notatka końcowa wysyłana zgłaszającym użytkownikom (opcjonalnie) + Usuń treść + Zakończ bez działania + Zakończone przez + Brak notatki końcowej. + Ukryj dziennik audytu + Dziennik audytu ({0}) + przez + Brak notatki. + Osoba zgłaszająca + Administrator działu + Administrator grupy + Import starszych danych + Nieznana rola + Brak adresu IP + ślad {0} + niedostępne + + Nie można załadować wniosków o moderację. + Nie można zakończyć wniosku o moderację. + Inne + Nieodpowiednia treść + Nękanie lub przemoc + Niechciana treść + Informacje poufne + Naruszenie zasad + Zgłoszenie przesłane + Wniosek ponownie otwarty + Zakończono bez działania + Treść usunięta + Materiał dowodowy pobrany + Stan zgłoszenia do moderacji + Zgłoś wiadomość do moderacji + Zamknij + Anuluj + Zgłoś + Sprawdzanie stanu zgłoszenia… + Stan + Treść została usunięta + Nie usunięto żadnej treści + Notatka moderatora + Powód + Dodaj notatkę (opcjonalnie) + Twoje zgłoszenie do moderacji ma stan + Zgłoś do moderacji + Wyjaśnij, dlaczego ta wiadomość powinna zostać sprawdzona + Twoje zgłoszenie do moderacji zostało przesłane. + Usuń wiadomość + Wycisz użytkownika + Zablokuj użytkownika + Odblokuj użytkownika + Zablokuj kanał + Odblokuj kanał + Rozstrzygnij zgłoszenie + Działanie nr {0} + Ładowanie… + Brak aktywności moderacyjnej. + Cel + Czas + Jednostka {0} + Poprzednia + Strona {0} + Następna + Zezwalaj na załączniki graficzne + Zezwalaj na pliki GIF + Zezwalaj na udostępnianie lokalizacji + Pilne wiadomości pomijają wyciszenie + Asystent włączony + Przechowywanie wiadomości (dni, 0 = bezterminowo) + Maksymalny rozmiar załącznika (MB) + Zapisywanie… + Zapisz ustawienia + Zapisano + Nie udało się zapisać ustawień moderacji. + W kolejce + W toku + Zakończony + Niepowodzenie + Nie udało się zażądać eksportu czatu. + Format + Data początkowa + Data końcowa + Wysyłanie żądania… + Zażądaj eksportu + Zażądano + Zakres + Pobierz + Brak zadań eksportu. + Teraz + dowod-moderacji-{0} + Ta wiadomość została usunięta. + diff --git a/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.sv.resx b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.sv.resx new file mode 100644 index 000000000..f39fe698e --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.sv.resx @@ -0,0 +1,156 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Avdelning, rapportör och objekt krävs. + Ärendet öppnades igen efter en ny rapport. + Det här ärendet ligger utanför ditt modereringsområde. + Endast väntande modereringsärenden kan slutföras. + Det rapporterade innehållet kunde inte tas bort. + Det här bevismaterialet ligger utanför ditt modereringsområde. + Det ursprungliga bevismaterialet hämtades. + Chattmeddelandet är inte tillgängligt eller kan inte rapporteras. + Meddelandet är inte tillgängligt eller kan inte rapporteras. + Identifieraren för samtalsanteckningen är ogiltig. + Samtalsanteckningen är inte tillgänglig eller kan inte rapporteras. + Identifieraren för samtalsbilden är ogiltig. + Samtalsbilden är inte tillgänglig eller kan inte rapporteras. + Innehåll borttaget av moderator + Det här meddelandet togs bort av en moderator. + Den här samtalsanteckningen togs bort av en moderator. + Det här meddelandet togs bort av en moderator. + Det rapporterade innehållet togs bort. + Ärendet granskades och inget innehåll togs bort. + <br/><br/><strong>Administratörens anteckning:</strong> {0} + Modereringsärendet är slutfört + Din modereringsrapport för {0} {1} är slutförd. {2}{3} + System + chattmeddelande + meddelande + samtalsanteckning + samtalsbild + Ärenden + Rapporter + Chattkontroller + Chattinställningar + Chattexporter + Sök nya och slutförda modereringsärenden efter användaren som lade till innehållet eller användaren som rapporterade det. + Status + Alla + Väntande + Slutförd + Innehållstyp + Tillagt av användar-ID + Rapporterat av användar-ID + Från + Till + Uppdatera + Läser in modereringsärenden… + Inga modereringsärenden matchar filtren. + Objekt + Ursprungligt bevismaterial + Tillagt av + Rapporter + Åtgärd + Innehåll borttaget + Ingen åtgärd + Okänd innehållstyp + ID {0} + Samtal {0} + Hämta sparat bevismaterial + System eller okänd + grupp {0} + Slutanteckning som skickas till rapporterande användare (valfritt) + Ta bort innehåll + Slutför utan åtgärd + Slutförd av + Ingen slutanteckning. + Dölj granskningslogg + Granskningslogg ({0}) + av + Ingen anteckning. + Rapportör + Avdelningsadministratör + Gruppadministratör + Äldre import + Okänd roll + Ingen IP-adress + spårning {0} + inte tillgänglig + + Det gick inte att läsa in modereringsärendena. + Det gick inte att slutföra modereringsärendet. + Annat + Olämpligt innehåll + Trakasserier eller övergrepp + Skräppost + Känslig information + Policyöverträdelse + Rapport inskickad + Ärendet öppnades igen + Slutförd utan åtgärd + Innehåll borttaget + Bevismaterial hämtat + Status för modereringsrapport + Rapportera meddelande för moderering + Stäng + Avbryt + Rapportera + Kontrollerar rapportstatus… + Status + Innehållet togs bort + Inget innehåll togs bort + Moderatorns anteckning + Orsak + Lägg till en anteckning (valfritt) + Din modereringsrapport är + Rapportera för moderering + Förklara varför det här meddelandet bör granskas + Din modereringsrapport skickades. + Ta bort meddelande + Tysta användare + Blockera användare + Häv blockering av användare + Lås kanal + Lås upp kanal + Lös rapport + Åtgärd nr {0} + Läser in… + Ingen modereringsaktivitet. + Mål + Tidpunkt + Enhet {0} + Föregående + Sida {0} + Nästa + Tillåt bildbilagor + Tillåt GIF-bilder + Tillåt platsdelning + Brådskande meddelanden åsidosätter tyst läge + Assistent aktiverad + Lagring av meddelanden (dagar, 0 = för alltid) + Största bilagestorlek (MB) + Sparar… + Spara inställningar + Sparat + Det gick inte att spara modereringsinställningarna. + I kö + Pågår + Slutförd + Misslyckades + Det gick inte att begära en chattexport. + Format + Startdatum + Slutdatum + Begär… + Begär export + Begärd + Intervall + Hämta + Inga exportjobb. + Nu + modereringsbevis-{0} + Det här meddelandet har tagits bort. + diff --git a/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.uk.resx b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.uk.resx new file mode 100644 index 000000000..c1fbefb3f --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.uk.resx @@ -0,0 +1,156 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Необхідно вказати підрозділ, користувача, який повідомляє, і елемент. + Запит повторно відкрито після нового повідомлення. + Цей запит перебуває поза межами вашої модерації. + Завершити можна лише запити на модерацію, що очікують розгляду. + Не вдалося видалити вміст, про який повідомлено. + Цей доказ перебуває поза межами вашої модерації. + Оригінальний доказ завантажено. + Повідомлення чату недоступне або про нього неможливо повідомити. + Повідомлення недоступне або про нього неможливо повідомити. + Ідентифікатор нотатки виклику недійсний. + Нотатка виклику недоступна або про неї неможливо повідомити. + Ідентифікатор зображення виклику недійсний. + Зображення виклику недоступне або про нього неможливо повідомити. + Вміст видалено модерацією + Це повідомлення видалено модерацією. + Цю нотатку виклику видалено модерацією. + Це повідомлення видалено модерацією. + Вміст, про який повідомлено, видалено. + Запит розглянуто, вміст не видалено. + <br/><br/><strong>Примітка адміністратора:</strong> {0} + Запит на модерацію завершено + Ваше повідомлення для модерації щодо {0} {1} завершено. {2}{3} + Система + повідомлення чату + повідомлення + нотатка виклику + зображення виклику + Запити + Звіти + Керування чатом + Налаштування чату + Експорт чату + Шукайте нові й завершені запити на модерацію за користувачем, який додав вміст, або користувачем, який повідомив про нього. + Стан + Усі + Очікує + Завершено + Тип вмісту + Додано користувачем з ID + Повідомлено користувачем з ID + Від + До + Оновити + Завантаження запитів на модерацію… + Жоден запит на модерацію не відповідає цим фільтрам. + Елемент + Оригінальний доказ + Додано + Повідомлення + Дія + Вміст видалено + Без дій + Невідомий тип вмісту + ID {0} + Виклик {0} + Завантажити збережений доказ + Система або невідомо + група {0} + Примітка про завершення для користувачів, які повідомили (необов’язково) + Видалити вміст + Завершити без дій + Завершив(ла) + Примітки про завершення немає. + Приховати журнал аудиту + Журнал аудиту ({0}) + користувачем + Примітки немає. + Користувач, який повідомив + Адміністратор підрозділу + Адміністратор групи + Імпорт застарілих даних + Невідома роль + IP-адреси немає + трасування {0} + недоступно + + Не вдалося завантажити запити на модерацію. + Не вдалося завершити запит на модерацію. + Інше + Неприйнятний вміст + Домагання або образи + Небажаний вміст + Конфіденційна інформація + Порушення правил + Повідомлення надіслано + Запит повторно відкрито + Завершено без дій + Вміст видалено + Доказ завантажено + Стан повідомлення для модерації + Повідомити про повідомлення для модерації + Закрити + Скасувати + Повідомити + Перевірка стану повідомлення… + Стан + Вміст видалено + Вміст не видалено + Примітка модератора + Причина + Додайте примітку (необов’язково) + Стан вашого повідомлення для модерації: + Повідомити для модерації + Поясніть, чому це повідомлення слід перевірити + Ваше повідомлення для модерації надіслано. + Видалити повідомлення + Вимкнути звук для користувача + Заблокувати користувача + Розблокувати користувача + Заблокувати канал + Розблокувати канал + Розглянути повідомлення + Дія № {0} + Завантаження… + Дій модерації немає. + Ціль + Час + Підрозділ {0} + Попередня + Сторінка {0} + Наступна + Дозволити вкладення зображень + Дозволити GIF + Дозволити надсилання місцезнаходження + Термінові повідомлення обходять вимкнення звуку + Помічник увімкнений + Зберігання повідомлень (днів, 0 = безстроково) + Максимальний розмір вкладення (МБ) + Збереження… + Зберегти налаштування + Збережено + Не вдалося зберегти налаштування модерації. + У черзі + Виконується + Завершено + Помилка + Не вдалося запросити експорт чату. + Формат + Дата початку + Дата завершення + Надсилання запиту… + Запросити експорт + Запитано + Діапазон + Завантажити + Завдань експорту немає. + Зараз + доказ-модерації-{0} + Це повідомлення видалено. + diff --git a/Core/Resgrid.Localization/Common.ar.resx b/Core/Resgrid.Localization/Common.ar.resx index 75f8a75a8..dd4dc2892 100644 --- a/Core/Resgrid.Localization/Common.ar.resx +++ b/Core/Resgrid.Localization/Common.ar.resx @@ -42,7 +42,7 @@ Assistant - Chat Moderation + مراجعة المحتوى اتصل بنا أُنشئ في diff --git a/Core/Resgrid.Localization/Common.de.resx b/Core/Resgrid.Localization/Common.de.resx index 56744b830..5a77731e7 100644 --- a/Core/Resgrid.Localization/Common.de.resx +++ b/Core/Resgrid.Localization/Common.de.resx @@ -165,7 +165,7 @@ Assistant - Chat Moderation + Moderation Kontaktieren Sie uns @@ -633,4 +633,3 @@ POIs - diff --git a/Core/Resgrid.Localization/Common.en.resx b/Core/Resgrid.Localization/Common.en.resx index a3975e2ea..2fd7d7e62 100644 --- a/Core/Resgrid.Localization/Common.en.resx +++ b/Core/Resgrid.Localization/Common.en.resx @@ -217,7 +217,7 @@ Assistant - Chat Moderation + Moderation Contact Us diff --git a/Core/Resgrid.Localization/Common.es.resx b/Core/Resgrid.Localization/Common.es.resx index e29cd33e7..cbcd2dd4e 100644 --- a/Core/Resgrid.Localization/Common.es.resx +++ b/Core/Resgrid.Localization/Common.es.resx @@ -211,7 +211,7 @@ Assistant - Chat Moderation + Moderación Contáctenos diff --git a/Core/Resgrid.Localization/Common.fr.resx b/Core/Resgrid.Localization/Common.fr.resx index b00166b53..bdac4533f 100644 --- a/Core/Resgrid.Localization/Common.fr.resx +++ b/Core/Resgrid.Localization/Common.fr.resx @@ -165,7 +165,7 @@ Assistant - Chat Moderation + Modération Contactez-nous @@ -633,4 +633,3 @@ POI - diff --git a/Core/Resgrid.Localization/Common.it.resx b/Core/Resgrid.Localization/Common.it.resx index 75b8614a5..0e586af2c 100644 --- a/Core/Resgrid.Localization/Common.it.resx +++ b/Core/Resgrid.Localization/Common.it.resx @@ -165,7 +165,7 @@ Assistant - Chat Moderation + Moderazione Contattaci @@ -633,4 +633,3 @@ POI - diff --git a/Core/Resgrid.Localization/Common.pl.resx b/Core/Resgrid.Localization/Common.pl.resx index f12241802..8a89b2ecf 100644 --- a/Core/Resgrid.Localization/Common.pl.resx +++ b/Core/Resgrid.Localization/Common.pl.resx @@ -165,7 +165,7 @@ Assistant - Chat Moderation + Moderacja Skontaktuj się z nami @@ -633,4 +633,3 @@ POI - diff --git a/Core/Resgrid.Localization/Common.sv.resx b/Core/Resgrid.Localization/Common.sv.resx index cb76184f0..365f1d326 100644 --- a/Core/Resgrid.Localization/Common.sv.resx +++ b/Core/Resgrid.Localization/Common.sv.resx @@ -165,7 +165,7 @@ Assistant - Chat Moderation + Moderering Kontakta oss @@ -633,4 +633,3 @@ POI - diff --git a/Core/Resgrid.Localization/Common.uk.resx b/Core/Resgrid.Localization/Common.uk.resx index 99be96bbf..769a1e921 100644 --- a/Core/Resgrid.Localization/Common.uk.resx +++ b/Core/Resgrid.Localization/Common.uk.resx @@ -165,7 +165,7 @@ Assistant - Chat Moderation + Модерація Зв'яжіться з нами @@ -633,4 +633,3 @@ POI - diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs index c1a8b2104..fd19d4e41 100644 --- a/Core/Resgrid.Model/AuditLogTypes.cs +++ b/Core/Resgrid.Model/AuditLogTypes.cs @@ -186,6 +186,11 @@ public enum AuditLogTypes ChatFlagResolved, ChatSettingsChanged, ChatExportRequested, - ChatExportDownloaded + ChatExportDownloaded, + // General moderation + ModerationReportSubmitted, + ModerationRequestReopened, + ModerationRequestCompleted, + ModerationEvidenceDownloaded } } diff --git a/Core/Resgrid.Model/Chat/ChatMessage.cs b/Core/Resgrid.Model/Chat/ChatMessage.cs index c78d592e1..64399f2cc 100644 --- a/Core/Resgrid.Model/Chat/ChatMessage.cs +++ b/Core/Resgrid.Model/Chat/ChatMessage.cs @@ -69,6 +69,9 @@ public class ChatMessage : IEntity public string DeletedByUserId { get; set; } + /// True when the tombstone was applied by a moderator rather than the sender. + public bool IsModerated { get; set; } + public DateTime? PinnedOn { get; set; } public string PinnedByUserId { get; set; } diff --git a/Core/Resgrid.Model/ChatbotDepartmentConfig.cs b/Core/Resgrid.Model/ChatbotDepartmentConfig.cs index bedf68221..fcf1f2b53 100644 --- a/Core/Resgrid.Model/ChatbotDepartmentConfig.cs +++ b/Core/Resgrid.Model/ChatbotDepartmentConfig.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.ComponentModel.DataAnnotations.Schema; using Newtonsoft.Json; +using ProtoBuf; namespace Resgrid.Model { @@ -17,38 +19,57 @@ namespace Resgrid.Model /// NluProvider column that exists on the table is deliberately left unmapped/unused. /// [Table("ChatbotDepartmentConfigs")] + [ProtoContract] public class ChatbotDepartmentConfig : IEntity { + [ProtoMember(1)] public string Id { get; set; } + [ProtoMember(2)] public int DepartmentId { get; set; } + [ProtoMember(3)] public bool IsEnabled { get; set; } /// Comma-separated platform names this department allows, or "*" for all. + [ProtoMember(4)] public string AllowedPlatforms { get; set; } = "*"; + [ProtoMember(5)] public int MaxSessionsPerUser { get; set; } = 3; + [ProtoMember(6)] public int SessionTtlMinutes { get; set; } = 30; + [ProtoMember(7)] public bool AllowDispatchViaChatbot { get; set; } + [ProtoMember(8)] public bool RequireConfirmationForStatusChange { get; set; } // --- Per-department LLM/AI override (added M0070). Key is encrypted at rest. --- + [ProtoMember(9)] public string LlmApiEndpoint { get; set; } + [ProtoMember(10)] public string LlmApiKey { get; set; } + [ProtoMember(11)] public string LlmModelName { get; set; } // --- Per-department rate limits (null => fall back to system defaults). --- + [ProtoMember(12)] public int? MessagesPerUserPerMinute { get; set; } + [ProtoMember(13)] public int? MessagesPerDepartmentPerMinute { get; set; } + [DefaultValue(true)] + [ProtoMember(14)] public bool RequireLinkingConfirmation { get; set; } = true; + [ProtoMember(15)] public bool ProactiveNotificationsEnabled { get; set; } + [ProtoMember(16)] public DateTime CreatedAt { get; set; } + [ProtoMember(17)] public DateTime? UpdatedAt { get; set; } [NotMapped] diff --git a/Core/Resgrid.Model/FormAutomation.cs b/Core/Resgrid.Model/FormAutomation.cs index a68ad80f7..e00646e58 100644 --- a/Core/Resgrid.Model/FormAutomation.cs +++ b/Core/Resgrid.Model/FormAutomation.cs @@ -41,9 +41,9 @@ public object IdValue public string IdName => "FormAutomationId"; [NotMapped] - public int IdType => 0; + public int IdType => 1; [NotMapped] - public IEnumerable IgnoredProperties => new string[] { "IdValue", "TableName", "IdName", "Department", "Message" }; + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "Form" }; } } diff --git a/Core/Resgrid.Model/Message.cs b/Core/Resgrid.Model/Message.cs index 942aa27f6..4dba9202e 100644 --- a/Core/Resgrid.Model/Message.cs +++ b/Core/Resgrid.Model/Message.cs @@ -13,6 +13,9 @@ namespace Resgrid.Model [Table("Messages")] public class Message : IEntity { + public const int MaximumSubjectLength = 150; + public const int MaximumBodyLength = 4000; + [Key] [Required] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] @@ -20,7 +23,7 @@ public class Message : IEntity public int MessageId { get; set; } [Required] - [MaxLength(150)] + [MaxLength(MaximumSubjectLength)] [ProtoMember(2)] public string Subject { get; set; } @@ -43,7 +46,7 @@ public class Message : IEntity public bool SystemGenerated { get; set; } [Required] - [MaxLength(4000)] + [MaxLength(MaximumBodyLength)] [ProtoMember(7)] public string Body { get; set; } diff --git a/Core/Resgrid.Model/Moderation/Moderation.cs b/Core/Resgrid.Model/Moderation/Moderation.cs new file mode 100644 index 000000000..5937492f9 --- /dev/null +++ b/Core/Resgrid.Model/Moderation/Moderation.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + public enum ModerationItemType + { + ChatMessage = 0, + Message = 1, + CallNote = 2, + CallImage = 3 + } + + public enum ModerationReason + { + Other = 0, + Inappropriate = 1, + Harassment = 2, + Spam = 3, + SensitiveInformation = 4, + PolicyViolation = 5 + } + + public enum ModerationRequestStatus + { + Pending = 0, + Completed = 1 + } + + public enum ModerationDisposition + { + None = 0, + NoAction = 1, + ContentRemoved = 2 + } + + public enum ModerationActionType + { + ReportSubmitted = 0, + RequestReopened = 1, + CompletedNoAction = 2, + ContentRemoved = 3, + EvidenceDownloaded = 4 + } + + /// + /// One permanent moderation case per department/content item. The original content and metadata are + /// captured when the first report is submitted and are never refreshed from the live item. + /// + public class ModerationRequest : IEntity + { + public string ModerationRequestId { get; set; } + public int DepartmentId { get; set; } + public int ItemType { get; set; } + public string ItemId { get; set; } + public int? CallId { get; set; } + public string ChatChannelId { get; set; } + public string ContentAuthorUserId { get; set; } + public int? ContentAuthorUnitId { get; set; } + public DateTime? ContentCreatedOn { get; set; } + public string OriginalSubject { get; set; } + public string OriginalText { get; set; } + public string OriginalFileName { get; set; } + public string OriginalContentType { get; set; } + + [JsonIgnore] + public byte[] OriginalContent { get; set; } + + public string OriginalMetadataJson { get; set; } + public int Status { get; set; } + public int Disposition { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public string CompletedByUserId { get; set; } + public DateTime? CompletedOn { get; set; } + public string AdminNote { get; set; } + + [NotMapped] + public List Reports { get; set; } = new List(); + + [NotMapped] + public List Actions { get; set; } = new List(); + + [NotMapped] + public string TableName => "ModerationRequests"; + + [NotMapped] + public string IdName => "ModerationRequestId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return ModerationRequestId; } + set { ModerationRequestId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] + { + "IdValue", "IdType", "TableName", "IdName", "Reports", "Actions" + }; + } + + /// A reporter's flag within a shared moderation request. + public class ModerationReport : IEntity + { + public string ModerationReportId { get; set; } + public string ModerationRequestId { get; set; } + public int DepartmentId { get; set; } + public string ReportedByUserId { get; set; } + public int? ReporterGroupId { get; set; } + public int Reason { get; set; } + public string Note { get; set; } + public DateTime ReportedOn { get; set; } + + [NotMapped] + public string TableName => "ModerationReports"; + + [NotMapped] + public string IdName => "ModerationReportId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return ModerationReportId; } + set { ModerationReportId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// + /// Immutable moderation audit entry. The first ReportSubmitted action holds the original evidence as + /// well as its request snapshot so the audit trail survives removal of the live content. + /// + public class ModerationAction : IEntity + { + public string ModerationActionId { get; set; } + public string ModerationRequestId { get; set; } + public int DepartmentId { get; set; } + public int ActionType { get; set; } + public string PerformedByUserId { get; set; } + public DateTime PerformedOn { get; set; } + public string Note { get; set; } + public int? PreviousStatus { get; set; } + public int? NewStatus { get; set; } + public string ActorRole { get; set; } + public string IpAddress { get; set; } + public string UserAgent { get; set; } + public string TraceId { get; set; } + public string ServerName { get; set; } + public string DetailsJson { get; set; } + public string EvidenceText { get; set; } + + [JsonIgnore] + public byte[] EvidenceContent { get; set; } + + public string EvidenceMetadataJson { get; set; } + + [NotMapped] + public string TableName => "ModerationActions"; + + [NotMapped] + public string IdName => "ModerationActionId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return ModerationActionId; } + set { ModerationActionId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + public class ModerationSearchCriteria + { + public ModerationRequestStatus? Status { get; set; } + public ModerationItemType? ItemType { get; set; } + public string ContentAuthorUserId { get; set; } + public string ReportedByUserId { get; set; } + public DateTime? From { get; set; } + public DateTime? To { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } + } +} diff --git a/Core/Resgrid.Model/Repositories/IChatRepositories.cs b/Core/Resgrid.Model/Repositories/IChatRepositories.cs index 64a928cc1..2070a184b 100644 --- a/Core/Resgrid.Model/Repositories/IChatRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IChatRepositories.cs @@ -144,8 +144,8 @@ public interface IChatMessageRepository : IRepository /// Task UpdateBodyAsync(string chatMessageId, string body, DateTime editedOn, CancellationToken cancellationToken); - /// Targeted tombstone (body/metadata cleared, DeletedOn/DeletedByUserId stamped) guarded by DeletedOn IS NULL. - Task TombstoneAsync(string chatMessageId, DateTime deletedOn, string deletedByUserId, CancellationToken cancellationToken); + /// Targeted tombstone (body/metadata cleared, deletion and moderation state stamped) guarded by DeletedOn IS NULL. + Task TombstoneAsync(string chatMessageId, DateTime deletedOn, string deletedByUserId, bool isModerated, CancellationToken cancellationToken); /// Targeted pin update guarded by DeletedOn IS NULL. Task SetPinnedAsync(string chatMessageId, DateTime? pinnedOn, string pinnedByUserId, CancellationToken cancellationToken); diff --git a/Core/Resgrid.Model/Repositories/IModerationRepositories.cs b/Core/Resgrid.Model/Repositories/IModerationRepositories.cs new file mode 100644 index 000000000..c7e07d6f2 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IModerationRepositories.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface IModerationRequestRepository : IRepository + { + Task GetByItemAsync(int departmentId, int itemType, string itemId); + + Task> SearchAsync(int departmentId, ModerationSearchCriteria criteria, + IEnumerable visibleGroupIds, string reporterUserId); + } + + public interface IModerationReportRepository : IRepository + { + Task GetByRequestAndReporterAsync(string moderationRequestId, string reportedByUserId); + Task> GetByRequestAsync(string moderationRequestId); + } + + public interface IModerationActionRepository : IRepository + { + Task> GetByRequestAsync(string moderationRequestId); + } +} diff --git a/Core/Resgrid.Model/Services/IModerationService.cs b/Core/Resgrid.Model/Services/IModerationService.cs new file mode 100644 index 000000000..0f8a3a17f --- /dev/null +++ b/Core/Resgrid.Model/Services/IModerationService.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + public interface IModerationService + { + Task FlagAsync(int departmentId, string reportedByUserId, ModerationItemType itemType, + string itemId, ModerationReason reason, string note, ChatModerationContext context = null, + CancellationToken cancellationToken = default(CancellationToken)); + + Task CanModerateAsync(int departmentId, string userId); + + Task> SearchRequestsAsync(int departmentId, string viewerUserId, + ModerationSearchCriteria criteria); + + Task GetRequestAsync(string moderationRequestId, int departmentId, string viewerUserId); + + Task GetReporterRequestAsync(int departmentId, string reporterUserId, + ModerationItemType itemType, string itemId); + + Task CompleteRequestAsync(string moderationRequestId, int departmentId, + string completedByUserId, ModerationDisposition disposition, string adminNote, + ChatModerationContext context = null, CancellationToken cancellationToken = default(CancellationToken)); + + Task RecordEvidenceAccessAsync(string moderationRequestId, int departmentId, string viewedByUserId, + ChatModerationContext context = null, CancellationToken cancellationToken = default(CancellationToken)); + } +} diff --git a/Core/Resgrid.Services/AuditService.cs b/Core/Resgrid.Services/AuditService.cs index fd5aee482..37de91031 100644 --- a/Core/Resgrid.Services/AuditService.cs +++ b/Core/Resgrid.Services/AuditService.cs @@ -222,6 +222,14 @@ public string GetAuditLogTypeString(AuditLogTypes logType) return "Chat Export Requested"; case AuditLogTypes.ChatExportDownloaded: return "Chat Export Downloaded"; + case AuditLogTypes.ModerationReportSubmitted: + return "Moderation Report Submitted"; + case AuditLogTypes.ModerationRequestReopened: + return "Moderation Request Reopened"; + case AuditLogTypes.ModerationRequestCompleted: + return "Moderation Request Completed"; + case AuditLogTypes.ModerationEvidenceDownloaded: + return "Moderation Evidence Downloaded"; } return $"Unknown ({logType})"; diff --git a/Core/Resgrid.Services/ChatMessageService.cs b/Core/Resgrid.Services/ChatMessageService.cs index 959acded9..6de832918 100644 --- a/Core/Resgrid.Services/ChatMessageService.cs +++ b/Core/Resgrid.Services/ChatMessageService.cs @@ -303,13 +303,14 @@ public async Task> GetThreadPageAsync(string threadRootMessage await SaveEditHistoryAsync(message, asModerator && !isSender ? ChatMessageEditType.ModeratorDelete : ChatMessageEditType.SenderDelete, byUserId, cancellationToken); var deletedOn = DateTime.UtcNow; - if (!await _chatMessageRepository.TombstoneAsync(chatMessageId, deletedOn, byUserId, cancellationToken)) + if (!await _chatMessageRepository.TombstoneAsync(chatMessageId, deletedOn, byUserId, asModerator, cancellationToken)) return false; message.Body = null; message.MetadataJson = null; message.DeletedOn = deletedOn; message.DeletedByUserId = byUserId; + message.IsModerated = asModerator; var channel = await _chatChannelRepository.GetByIdAsync(message.ChatChannelId); PublishEvent(channel, ChatEventKinds.MessageDeleted, new @@ -318,7 +319,8 @@ public async Task> GetThreadPageAsync(string threadRootMessage message.ChatChannelId, message.MessageSeq, message.DeletedOn, - DeletedByModerator = asModerator && !isSender + DeletedByModerator = asModerator, + message.IsModerated }); return true; @@ -707,7 +709,8 @@ private object BuildMessageDto(ChatMessage message) message.MetadataJson, message.ClientMessageId, message.SentOn, - message.EditedOn + message.EditedOn, + message.IsModerated }; } diff --git a/Core/Resgrid.Services/MessageService.cs b/Core/Resgrid.Services/MessageService.cs index c24b08056..f6def6c83 100644 --- a/Core/Resgrid.Services/MessageService.cs +++ b/Core/Resgrid.Services/MessageService.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Queue; using Resgrid.Model.Repositories; @@ -39,6 +40,8 @@ public async Task GetMessageByIdAsync(int messageId) public async Task SaveMessageAsync(Message message, CancellationToken cancellationToken = default(CancellationToken)) { + message.Subject = message.Subject?.Truncate(Message.MaximumSubjectLength); + message.Body = message.Body?.Truncate(Message.MaximumBodyLength); message.SentOn = message.SentOn.ToUniversalTime(); if (message.ReadOn.HasValue) diff --git a/Core/Resgrid.Services/ModerationService.cs b/Core/Resgrid.Services/ModerationService.cs new file mode 100644 index 000000000..19a0615f6 --- /dev/null +++ b/Core/Resgrid.Services/ModerationService.cs @@ -0,0 +1,723 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Localization.Areas.User.Moderation; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + /// Content-agnostic moderation requests for chat messages, Resgrid Messages, call notes and call + /// images. One request owns every report for an item and preserves the first-seen evidence forever. + /// + public class ModerationService : IModerationService + { + public static string ModeratedChatMessage => ModerationResources.GetCurrent("MessageRemovedByModeration"); + public static string ModeratedMessageSubject => ModerationResources.GetCurrent("ModeratedMessageSubject"); + public static string ModeratedMessageBody => ModerationResources.GetCurrent("ModeratedMessageBody"); + public static string ModeratedCallNote => ModerationResources.GetCurrent("ModeratedCallNote"); + + private readonly IModerationRequestRepository _moderationRequestRepository; + private readonly IModerationReportRepository _moderationReportRepository; + private readonly IModerationActionRepository _moderationActionRepository; + private readonly IChatMessageRepository _chatMessageRepository; + private readonly IChatAttachmentRepository _chatAttachmentRepository; + private readonly IChatChannelService _chatChannelService; + private readonly IChatPermissionService _chatPermissionService; + private readonly IChatMessageService _chatMessageService; + private readonly IMessageService _messageService; + private readonly ICallNotesRepository _callNotesRepository; + private readonly ICallAttachmentRepository _callAttachmentRepository; + private readonly ICallsService _callsService; + private readonly IDepartmentGroupsService _departmentGroupsService; + private readonly IAuthorizationService _authorizationService; + private readonly IAuditService _auditService; + private readonly IUserProfileService _userProfileService; + + public ModerationService(IModerationRequestRepository moderationRequestRepository, + IModerationReportRepository moderationReportRepository, IModerationActionRepository moderationActionRepository, + IChatMessageRepository chatMessageRepository, IChatAttachmentRepository chatAttachmentRepository, + IChatChannelService chatChannelService, IChatPermissionService chatPermissionService, + IChatMessageService chatMessageService, IMessageService messageService, + ICallNotesRepository callNotesRepository, ICallAttachmentRepository callAttachmentRepository, + ICallsService callsService, IDepartmentGroupsService departmentGroupsService, + IAuthorizationService authorizationService, IAuditService auditService, + IUserProfileService userProfileService) + { + _moderationRequestRepository = moderationRequestRepository; + _moderationReportRepository = moderationReportRepository; + _moderationActionRepository = moderationActionRepository; + _chatMessageRepository = chatMessageRepository; + _chatAttachmentRepository = chatAttachmentRepository; + _chatChannelService = chatChannelService; + _chatPermissionService = chatPermissionService; + _chatMessageService = chatMessageService; + _messageService = messageService; + _callNotesRepository = callNotesRepository; + _callAttachmentRepository = callAttachmentRepository; + _callsService = callsService; + _departmentGroupsService = departmentGroupsService; + _authorizationService = authorizationService; + _auditService = auditService; + _userProfileService = userProfileService; + } + + public async Task FlagAsync(int departmentId, string reportedByUserId, + ModerationItemType itemType, string itemId, ModerationReason reason, string note, + ChatModerationContext context = null, CancellationToken cancellationToken = default(CancellationToken)) + { + if (departmentId <= 0 || string.IsNullOrWhiteSpace(reportedByUserId) || string.IsNullOrWhiteSpace(itemId)) + throw new ArgumentException(ModerationResources.GetCurrent("RequiredModerationContext")); + + if (!Enum.IsDefined(typeof(ModerationItemType), itemType) || !Enum.IsDefined(typeof(ModerationReason), reason)) + throw new ArgumentOutOfRangeException(nameof(itemType)); + + var evidence = await LoadEvidenceAsync(departmentId, reportedByUserId, itemType, itemId); + var request = await _moderationRequestRepository.GetByItemAsync(departmentId, (int)itemType, itemId); + var createdRequest = request == null; + + if (createdRequest) + { + var now = DateTime.UtcNow; + request = new ModerationRequest + { + ModerationRequestId = Guid.NewGuid().ToString(), + DepartmentId = departmentId, + ItemType = (int)itemType, + ItemId = itemId, + CallId = evidence.CallId, + ChatChannelId = evidence.ChatChannelId, + ContentAuthorUserId = evidence.ContentAuthorUserId, + ContentAuthorUnitId = evidence.ContentAuthorUnitId, + ContentCreatedOn = evidence.ContentCreatedOn, + OriginalSubject = evidence.Subject, + OriginalText = evidence.Text, + OriginalFileName = evidence.FileName, + OriginalContentType = evidence.ContentType, + OriginalContent = evidence.Content, + OriginalMetadataJson = evidence.MetadataJson, + Status = (int)ModerationRequestStatus.Pending, + Disposition = (int)ModerationDisposition.None, + CreatedOn = now, + ModifiedOn = now + }; + + try + { + request = await _moderationRequestRepository.InsertAsync(request, cancellationToken); + } + catch + { + // The unique department/type/item index is the race backstop. If another reporter won + // the insert, join that request; otherwise preserve the original failure. + var concurrent = await _moderationRequestRepository.GetByItemAsync(departmentId, (int)itemType, itemId); + if (concurrent == null) + throw; + + request = concurrent; + createdRequest = false; + } + } + + var existingReport = await _moderationReportRepository.GetByRequestAndReporterAsync( + request.ModerationRequestId, reportedByUserId); + if (existingReport != null) + return existingReport; + + if (request.Status == (int)ModerationRequestStatus.Completed) + { + var previousStatus = request.Status; + request.Status = (int)ModerationRequestStatus.Pending; + request.Disposition = (int)ModerationDisposition.None; + request.CompletedByUserId = null; + request.CompletedOn = null; + request.AdminNote = null; + request.ModifiedOn = DateTime.UtcNow; + await _moderationRequestRepository.UpdateAsync(request, cancellationToken); + + await RecordActionAsync(request, ModerationActionType.RequestReopened, reportedByUserId, + ModerationResources.GetCurrent("RequestReopenedAudit"), previousStatus, request.Status, + context, null, cancellationToken); + await RecordDepartmentAuditAsync(request, AuditLogTypes.ModerationRequestReopened, + reportedByUserId, context, new { request.ItemType, request.ItemId }, cancellationToken); + } + + var groupMember = await _departmentGroupsService.GetGroupMemberForUserAsync(reportedByUserId, departmentId); + var report = new ModerationReport + { + ModerationReportId = Guid.NewGuid().ToString(), + ModerationRequestId = request.ModerationRequestId, + DepartmentId = departmentId, + ReportedByUserId = reportedByUserId, + ReporterGroupId = groupMember?.DepartmentGroupId, + Reason = (int)reason, + Note = note, + ReportedOn = DateTime.UtcNow + }; + + try + { + report = await _moderationReportRepository.InsertAsync(report, cancellationToken); + } + catch + { + // A unique request/reporter index prevents duplicate reports under concurrent submissions. + var concurrent = await _moderationReportRepository.GetByRequestAndReporterAsync( + request.ModerationRequestId, reportedByUserId); + if (concurrent == null) + throw; + + return concurrent; + } + request.ModifiedOn = report.ReportedOn; + await _moderationRequestRepository.UpdateAsync(request, cancellationToken); + + await RecordActionAsync(request, ModerationActionType.ReportSubmitted, reportedByUserId, note, + null, request.Status, context, + new + { + report.ModerationReportId, + report.ReporterGroupId, + Reason = reason.ToString() + }, + cancellationToken, createdRequest); + + await RecordDepartmentAuditAsync(request, AuditLogTypes.ModerationReportSubmitted, + reportedByUserId, context, + new { report.ModerationReportId, report.ReporterGroupId, Reason = reason.ToString(), note }, + cancellationToken); + + return report; + } + + public async Task CanModerateAsync(int departmentId, string userId) + { + if (await _authorizationService.CanUserModifyDepartmentAsync(userId, departmentId)) + return true; + + var groups = await GetAdminGroupIdsAsync(departmentId, userId); + return groups.Count > 0; + } + + public async Task> SearchRequestsAsync(int departmentId, string viewerUserId, + ModerationSearchCriteria criteria) + { + var isDepartmentAdmin = await _authorizationService.CanUserModifyDepartmentAsync(viewerUserId, departmentId); + var groupIds = isDepartmentAdmin ? null : await GetAdminGroupIdsAsync(departmentId, viewerUserId); + var reporterScope = isDepartmentAdmin ? null : viewerUserId; + var requests = await _moderationRequestRepository.SearchAsync(departmentId, criteria, groupIds, reporterScope); + return await HydrateAsync(requests, isDepartmentAdmin ? null : groupIds, viewerUserId); + } + + public async Task GetRequestAsync(string moderationRequestId, int departmentId, + string viewerUserId) + { + var request = await _moderationRequestRepository.GetByIdAsync(moderationRequestId); + if (request == null || request.DepartmentId != departmentId) + return null; + + var reports = (await _moderationReportRepository.GetByRequestAsync(moderationRequestId))?.ToList() + ?? new List(); + if (!await CanViewRequestAsync(request, reports, viewerUserId, requireAdmin: false)) + return null; + + var actions = (await _moderationActionRepository.GetByRequestAsync(moderationRequestId))?.ToList() + ?? new List(); + await ApplyViewerScopeAsync(request, reports, actions, viewerUserId); + return request; + } + + public async Task GetReporterRequestAsync(int departmentId, string reporterUserId, + ModerationItemType itemType, string itemId) + { + var request = await _moderationRequestRepository.GetByItemAsync(departmentId, (int)itemType, itemId); + if (request == null) + return null; + + var report = await _moderationReportRepository.GetByRequestAndReporterAsync(request.ModerationRequestId, + reporterUserId); + if (report == null) + return null; + + request.Reports = new List { report }; + request.Actions = (await _moderationActionRepository.GetByRequestAsync(request.ModerationRequestId))?.ToList() + ?? new List(); + return request; + } + + public async Task CompleteRequestAsync(string moderationRequestId, int departmentId, + string completedByUserId, ModerationDisposition disposition, string adminNote, + ChatModerationContext context = null, CancellationToken cancellationToken = default(CancellationToken)) + { + if (disposition != ModerationDisposition.NoAction && disposition != ModerationDisposition.ContentRemoved) + throw new ArgumentOutOfRangeException(nameof(disposition)); + + var request = await _moderationRequestRepository.GetByIdAsync(moderationRequestId); + if (request == null || request.DepartmentId != departmentId) + return null; + + var reports = (await _moderationReportRepository.GetByRequestAsync(moderationRequestId))?.ToList() + ?? new List(); + if (!await CanViewRequestAsync(request, reports, completedByUserId, requireAdmin: true)) + throw new UnauthorizedAccessException(ModerationResources.GetCurrent("RequestOutsideScope")); + + if (request.Status != (int)ModerationRequestStatus.Pending) + throw new InvalidOperationException(ModerationResources.GetCurrent("OnlyPendingRequests")); + + if (disposition == ModerationDisposition.ContentRemoved && !await RemoveLiveContentAsync(request, completedByUserId, cancellationToken)) + throw new InvalidOperationException(ModerationResources.GetCurrent("ContentCouldNotBeRemoved")); + + var previousStatus = request.Status; + request.Status = (int)ModerationRequestStatus.Completed; + request.Disposition = (int)disposition; + request.CompletedByUserId = completedByUserId; + request.CompletedOn = DateTime.UtcNow; + request.ModifiedOn = request.CompletedOn.Value; + request.AdminNote = adminNote; + request = await _moderationRequestRepository.UpdateAsync(request, cancellationToken); + + var actionType = disposition == ModerationDisposition.ContentRemoved + ? ModerationActionType.ContentRemoved + : ModerationActionType.CompletedNoAction; + await RecordActionAsync(request, actionType, completedByUserId, adminNote, previousStatus, + request.Status, context, new { Disposition = disposition.ToString() }, cancellationToken); + await RecordDepartmentAuditAsync(request, AuditLogTypes.ModerationRequestCompleted, + completedByUserId, context, new { Disposition = disposition.ToString(), adminNote }, cancellationToken); + + await NotifyReportersAsync(request, reports, disposition, adminNote, cancellationToken); + + var actions = (await _moderationActionRepository.GetByRequestAsync(request.ModerationRequestId))?.ToList() + ?? new List(); + await ApplyViewerScopeAsync(request, reports, actions, completedByUserId); + return request; + } + + public async Task RecordEvidenceAccessAsync(string moderationRequestId, int departmentId, + string viewedByUserId, ChatModerationContext context = null, + CancellationToken cancellationToken = default(CancellationToken)) + { + var request = await _moderationRequestRepository.GetByIdAsync(moderationRequestId); + if (request == null || request.DepartmentId != departmentId) + return false; + + var reports = (await _moderationReportRepository.GetByRequestAsync(moderationRequestId))?.ToList() + ?? new List(); + if (!await CanViewRequestAsync(request, reports, viewedByUserId, requireAdmin: true)) + throw new UnauthorizedAccessException(ModerationResources.GetCurrent("EvidenceOutsideScope")); + + await RecordActionAsync(request, ModerationActionType.EvidenceDownloaded, viewedByUserId, + ModerationResources.GetCurrent("EvidenceDownloadedAudit"), request.Status, request.Status, context, null, + cancellationToken); + await RecordDepartmentAuditAsync(request, AuditLogTypes.ModerationEvidenceDownloaded, + viewedByUserId, context, new { request.OriginalFileName, request.OriginalContentType }, + cancellationToken); + return true; + } + + private async Task LoadEvidenceAsync(int departmentId, string reporterUserId, + ModerationItemType itemType, string itemId) + { + switch (itemType) + { + case ModerationItemType.ChatMessage: + { + var message = await _chatMessageRepository.GetByIdAsync(itemId); + if (message == null || message.DepartmentId != departmentId || message.DeletedOn.HasValue) + throw new InvalidOperationException(ModerationResources.GetCurrent("ChatMessageUnavailable")); + + var channel = await _chatChannelService.GetChannelByIdAsync(message.ChatChannelId); + if (channel == null || channel.DepartmentId != departmentId || + !await _chatPermissionService.CanAccessChannelAsync(channel, reporterUserId, null)) + throw new UnauthorizedAccessException(); + + ChatAttachment attachment = null; + var attachmentMetadata = await _chatAttachmentRepository.GetMetadataByMessageIdsAsync(new[] { itemId }); + var firstAttachment = attachmentMetadata?.FirstOrDefault(); + if (firstAttachment != null) + attachment = await _chatAttachmentRepository.GetByIdAsync(firstAttachment.ChatAttachmentId); + + return new ModerationEvidence + { + ChatChannelId = message.ChatChannelId, + ContentAuthorUserId = message.SenderUserId, + ContentAuthorUnitId = message.SenderUnitId, + ContentCreatedOn = message.SentOn, + Text = message.Body, + FileName = attachment?.FileName, + ContentType = attachment?.ContentType, + Content = attachment?.Data, + MetadataJson = JsonConvert.SerializeObject(new + { + message.MessageType, + message.Priority, + message.SenderDisplayName, + message.MetadataJson, + Attachment = attachment == null ? null : new + { + attachment.ChatAttachmentId, + attachment.FileName, + attachment.ContentType, + attachment.Size, + attachment.Sha256 + } + }) + }; + } + + case ModerationItemType.Message: + { + if (!int.TryParse(itemId, out var messageId) || + !await _authorizationService.CanUserViewMessageAsync(reporterUserId, messageId)) + throw new UnauthorizedAccessException(); + + var message = await _messageService.GetMessageByIdAsync(messageId); + if (message == null) + throw new InvalidOperationException(ModerationResources.GetCurrent("MessageUnavailable")); + + return new ModerationEvidence + { + ContentAuthorUserId = message.SendingUserId, + ContentCreatedOn = message.SentOn, + Subject = message.Subject, + Text = message.Body, + MetadataJson = JsonConvert.SerializeObject(new + { + message.Type, + message.IsBroadcast, + message.SystemGenerated, + message.ReceivingUserId, + Recipients = message.GetRecipients() + }) + }; + } + + case ModerationItemType.CallNote: + { + if (!int.TryParse(itemId, out var callNoteId)) + throw new ArgumentException(ModerationResources.GetCurrent("InvalidCallNoteId")); + + var note = await _callNotesRepository.GetByIdAsync(callNoteId); + var call = note == null ? null : await _callsService.GetCallByIdAsync(note.CallId, false); + if (note == null || call == null || call.DepartmentId != departmentId || note.IsDeleted) + throw new InvalidOperationException(ModerationResources.GetCurrent("CallNoteUnavailable")); + if (!await _authorizationService.CanUserViewCallAsync(reporterUserId, note.CallId)) + throw new UnauthorizedAccessException(); + + return new ModerationEvidence + { + CallId = note.CallId, + ContentAuthorUserId = note.UserId, + ContentCreatedOn = note.Timestamp, + Text = note.Note, + MetadataJson = JsonConvert.SerializeObject(new + { + note.Source, + note.Latitude, + note.Longitude + }) + }; + } + + case ModerationItemType.CallImage: + { + if (!int.TryParse(itemId, out var callAttachmentId)) + throw new ArgumentException(ModerationResources.GetCurrent("InvalidCallImageId")); + + var attachment = await _callAttachmentRepository.GetByIdAsync(callAttachmentId); + var call = attachment == null ? null : await _callsService.GetCallByIdAsync(attachment.CallId, false); + if (attachment == null || call == null || call.DepartmentId != departmentId || attachment.IsDeleted || + attachment.CallAttachmentType != (int)CallAttachmentTypes.Image) + throw new InvalidOperationException(ModerationResources.GetCurrent("CallImageUnavailable")); + if (!await _authorizationService.CanUserViewCallAsync(reporterUserId, attachment.CallId)) + throw new UnauthorizedAccessException(); + + return new ModerationEvidence + { + CallId = attachment.CallId, + ContentAuthorUserId = attachment.UserId, + ContentCreatedOn = attachment.Timestamp, + FileName = attachment.FileName, + ContentType = "image/jpeg", + Content = attachment.Data, + MetadataJson = JsonConvert.SerializeObject(new + { + attachment.Name, + attachment.Size, + attachment.Latitude, + attachment.Longitude + }) + }; + } + + default: + throw new ArgumentOutOfRangeException(nameof(itemType)); + } + } + + private async Task RemoveLiveContentAsync(ModerationRequest request, string byUserId, + CancellationToken cancellationToken) + { + switch ((ModerationItemType)request.ItemType) + { + case ModerationItemType.ChatMessage: + return await _chatMessageService.DeleteMessageAsync(request.ItemId, byUserId, true, + ModeratedChatMessage, cancellationToken); + + case ModerationItemType.Message: + if (!int.TryParse(request.ItemId, out var messageId)) + return false; + var message = await _messageService.GetMessageByIdAsync(messageId); + if (message == null) + return false; + message.Subject = ModeratedMessageSubject; + message.Body = ModeratedMessageBody; + return await _messageService.SaveMessageAsync(message, cancellationToken) != null; + + case ModerationItemType.CallNote: + if (!int.TryParse(request.ItemId, out var callNoteId)) + return false; + var note = await _callNotesRepository.GetByIdAsync(callNoteId); + if (note == null) + return false; + note.Note = ModeratedCallNote; + note.IsDeleted = true; + note.DeletedByUserId = byUserId; + note.DeletedOn = DateTime.UtcNow; + note.IsFlagged = false; + note.FlaggedReason = null; + note.FlaggedByUserId = null; + note.FlaggedOn = null; + return await _callNotesRepository.SaveOrUpdateAsync(note, cancellationToken) != null; + + case ModerationItemType.CallImage: + if (!int.TryParse(request.ItemId, out var callAttachmentId)) + return false; + var attachment = await _callAttachmentRepository.GetByIdAsync(callAttachmentId); + if (attachment == null) + return false; + attachment.Data = null; + attachment.Size = 0; + attachment.IsDeleted = true; + attachment.DeletedByUserId = byUserId; + attachment.DeletedOn = DateTime.UtcNow; + attachment.IsFlagged = false; + attachment.FlaggedReason = null; + attachment.FlaggedByUserId = null; + attachment.FlaggedOn = null; + return await _callAttachmentRepository.SaveOrUpdateAsync(attachment, cancellationToken) != null; + + default: + return false; + } + } + + private async Task> HydrateAsync(IEnumerable requests, + List visibleGroupIds, string viewerUserId) + { + var result = requests?.ToList() ?? new List(); + foreach (var request in result) + { + var reports = (await _moderationReportRepository.GetByRequestAsync(request.ModerationRequestId))?.ToList() + ?? new List(); + var actions = (await _moderationActionRepository.GetByRequestAsync(request.ModerationRequestId))?.ToList() + ?? new List(); + + if (visibleGroupIds == null) + { + request.Reports = reports; + request.Actions = actions; + } + else + { + ApplyGroupScope(request, reports, actions, visibleGroupIds, viewerUserId); + } + } + + return result; + } + + private async Task ApplyViewerScopeAsync(ModerationRequest request, List reports, + List actions, string viewerUserId) + { + if (await _authorizationService.CanUserModifyDepartmentAsync(viewerUserId, request.DepartmentId)) + { + request.Reports = reports; + request.Actions = actions; + return; + } + + ApplyGroupScope(request, reports, actions, + await GetAdminGroupIdsAsync(request.DepartmentId, viewerUserId), viewerUserId); + } + + private static void ApplyGroupScope(ModerationRequest request, List reports, + List actions, IEnumerable visibleGroupIds, string viewerUserId) + { + var groups = visibleGroupIds?.ToHashSet() ?? new HashSet(); + var visibleReports = reports.Where(x => + string.Equals(x.ReportedByUserId, viewerUserId, StringComparison.OrdinalIgnoreCase) || + (x.ReporterGroupId.HasValue && groups.Contains(x.ReporterGroupId.Value))).ToList(); + var visibleReporters = visibleReports.Select(x => x.ReportedByUserId) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + request.Reports = visibleReports; + request.Actions = actions.Where(x => + x.ActionType == (int)ModerationActionType.CompletedNoAction || + x.ActionType == (int)ModerationActionType.ContentRemoved || + x.ActionType == (int)ModerationActionType.EvidenceDownloaded || + visibleReporters.Contains(x.PerformedByUserId)).ToList(); + } + + private async Task CanViewRequestAsync(ModerationRequest request, List reports, + string userId, bool requireAdmin) + { + if (await _authorizationService.CanUserModifyDepartmentAsync(userId, request.DepartmentId)) + return true; + + var groupIds = await GetAdminGroupIdsAsync(request.DepartmentId, userId); + if (groupIds.Count > 0 && reports.Any(x => x.ReporterGroupId.HasValue && groupIds.Contains(x.ReporterGroupId.Value))) + return true; + + return !requireAdmin && reports.Any(x => string.Equals(x.ReportedByUserId, userId, StringComparison.OrdinalIgnoreCase)); + } + + private async Task> GetAdminGroupIdsAsync(int departmentId, string userId) + { + var admins = await _departmentGroupsService.GetAllGroupAdminsByDepartmentIdAsync(departmentId); + return admins? + .Where(x => x.IsAdmin == true && string.Equals(x.UserId, userId, StringComparison.OrdinalIgnoreCase)) + .Select(x => x.DepartmentGroupId) + .Distinct() + .ToList() ?? new List(); + } + + private async Task RecordActionAsync(ModerationRequest request, ModerationActionType actionType, + string byUserId, string note, int? previousStatus, int? newStatus, ChatModerationContext context, + object details, CancellationToken cancellationToken, bool includeEvidence = false) + { + await _moderationActionRepository.InsertAsync(new ModerationAction + { + ModerationActionId = Guid.NewGuid().ToString(), + ModerationRequestId = request.ModerationRequestId, + DepartmentId = request.DepartmentId, + ActionType = (int)actionType, + PerformedByUserId = byUserId, + PerformedOn = DateTime.UtcNow, + Note = note, + PreviousStatus = previousStatus, + NewStatus = newStatus, + ActorRole = context?.ActorRole, + IpAddress = context?.IpAddress, + UserAgent = context?.UserAgent, + TraceId = context?.TraceId, + ServerName = Environment.MachineName, + DetailsJson = details == null ? null : JsonConvert.SerializeObject(details), + EvidenceText = includeEvidence ? request.OriginalText : null, + EvidenceContent = includeEvidence ? request.OriginalContent : null, + EvidenceMetadataJson = includeEvidence ? request.OriginalMetadataJson : null + }, cancellationToken); + } + + private async Task RecordDepartmentAuditAsync(ModerationRequest request, AuditLogTypes logType, + string byUserId, ChatModerationContext context, object details, CancellationToken cancellationToken) + { + await _auditService.SaveAuditLogAsync(new AuditLog + { + LogType = (int)logType, + DepartmentId = request.DepartmentId, + UserId = byUserId, + Message = _auditService.GetAuditLogTypeString(logType), + Data = JsonConvert.SerializeObject(new + { + result = "Success", + request.ModerationRequestId, + request.ItemType, + request.ItemId, + actorRole = context?.ActorRole, + traceId = context?.TraceId, + details + }), + LoggedOn = DateTime.UtcNow, + ObjectId = request.ModerationRequestId, + ObjectDepartmentId = request.DepartmentId, + IpAddress = context?.IpAddress, + UserAgent = context?.UserAgent, + ServerName = Environment.MachineName + }, cancellationToken); + } + + private async Task NotifyReportersAsync(ModerationRequest request, IEnumerable reports, + ModerationDisposition disposition, string adminNote, CancellationToken cancellationToken) + { + var recipients = reports + .Select(x => x.ReportedByUserId) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Where(x => !string.Equals(x, request.ContentAuthorUserId, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (var recipient in recipients) + { + var profile = await _userProfileService.GetProfileByUserIdAsync(recipient); + var culture = string.IsNullOrWhiteSpace(profile?.Language) ? "en" : profile.Language; + var action = ModerationResources.Get( + disposition == ModerationDisposition.ContentRemoved + ? "CompletionContentRemoved" + : "CompletionNoContentRemoved", culture); + var note = string.IsNullOrWhiteSpace(adminNote) + ? string.Empty + : ModerationResources.Get("CompletionAdminNoteHtml", culture, + System.Net.WebUtility.HtmlEncode(adminNote)); + var itemType = ModerationResources.Get(GetItemTypeResourceKey((ModerationItemType)request.ItemType), culture); + var message = await _messageService.SaveMessageAsync(new Message + { + Subject = ModerationResources.Get("CompletionSubject", culture), + Body = ModerationResources.Get("CompletionBody", culture, itemType, request.ItemId, action, note), + ReceivingUserId = recipient, + SystemGenerated = true, + SentOn = DateTime.UtcNow + }, cancellationToken); + + await _messageService.SendMessageAsync(message, + ModerationResources.Get("SystemSenderName", culture), request.DepartmentId, false, cancellationToken); + } + } + + private static string GetItemTypeResourceKey(ModerationItemType itemType) + { + switch (itemType) + { + case ModerationItemType.ChatMessage: + return "ItemTypeChatMessage"; + case ModerationItemType.Message: + return "ItemTypeMessage"; + case ModerationItemType.CallNote: + return "ItemTypeCallNote"; + case ModerationItemType.CallImage: + return "ItemTypeCallImage"; + default: + return "UnknownContentType"; + } + } + + private class ModerationEvidence + { + public int? CallId { get; set; } + public string ChatChannelId { get; set; } + public string ContentAuthorUserId { get; set; } + public int? ContentAuthorUnitId { get; set; } + public DateTime? ContentCreatedOn { get; set; } + public string Subject { get; set; } + public string Text { get; set; } + public string FileName { get; set; } + public string ContentType { get; set; } + public byte[] Content { get; set; } + public string MetadataJson { get; set; } + } + } +} diff --git a/Core/Resgrid.Services/NotificationService.cs b/Core/Resgrid.Services/NotificationService.cs index 632f40c5c..f947a2f98 100644 --- a/Core/Resgrid.Services/NotificationService.cs +++ b/Core/Resgrid.Services/NotificationService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -504,11 +505,15 @@ public async Task ValidateNotificationForProcessingAsync(ProcessedNotifica var beforeData = String.IsNullOrWhiteSpace(setting.BeforeData) ? "-1" : setting.BeforeData; var currentData = String.IsNullOrWhiteSpace(setting.CurrentData) ? "-1" : setting.CurrentData; - if (beforeData.Contains("-1") && currentData.Contains("-1")) - return true; + if (!int.TryParse(beforeData, NumberStyles.Integer, CultureInfo.InvariantCulture, out int beforeStateValue) || + !int.TryParse(currentData, NumberStyles.Integer, CultureInfo.InvariantCulture, out int currentStateValue)) + return false; - bool beforeAny = beforeData.Contains("-1"); - bool currentAny = currentData.Contains("-1"); + bool beforeAny = beforeStateValue == -1; + bool currentAny = currentStateValue == -1; + + if (beforeAny && currentAny) + return true; UnitState beforeState = null; UnitState currentState = await _unitsService.GetUnitStateByIdAsync(dynamicData.StateId); @@ -518,8 +523,8 @@ public async Task ValidateNotificationForProcessingAsync(ProcessedNotifica if (!beforeAny) beforeState = await _unitsService.GetLastUnitStateBeforeIdAsync(currentState.UnitId, currentState.UnitStateId); - if ((currentAny || currentState.State == int.Parse(currentData)) && - (beforeAny || (beforeState != null && beforeState.State == int.Parse(beforeData)))) + if ((currentAny || currentState.State == currentStateValue) && + (beforeAny || (beforeState != null && beforeState.State == beforeStateValue))) return true; } } diff --git a/Core/Resgrid.Services/Resgrid.Services.csproj b/Core/Resgrid.Services/Resgrid.Services.csproj index b50c7cac4..9f3a6c997 100644 --- a/Core/Resgrid.Services/Resgrid.Services.csproj +++ b/Core/Resgrid.Services/Resgrid.Services.csproj @@ -31,6 +31,7 @@ + diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 381eb06e0..ef0b45471 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -22,6 +22,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().SingleInstance().AutoActivate(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0111_CascadeCommunicationTestDeletes.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0111_CascadeCommunicationTestDeletes.cs new file mode 100644 index 000000000..33610d4dd --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0111_CascadeCommunicationTestDeletes.cs @@ -0,0 +1,43 @@ +using System.Data; +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(111)] + public class M0111_CascadeCommunicationTestDeletes : Migration + { + public override void Up() + { + Delete.ForeignKey("FK_CommunicationTestResults_CommunicationTestRuns") + .OnTable("CommunicationTestResults"); + Delete.ForeignKey("FK_CommunicationTestRuns_CommunicationTests") + .OnTable("CommunicationTestRuns"); + + Create.ForeignKey("FK_CommunicationTestRuns_CommunicationTests") + .FromTable("CommunicationTestRuns").ForeignColumn("CommunicationTestId") + .ToTable("CommunicationTests").PrimaryColumn("CommunicationTestId") + .OnDelete(Rule.Cascade); + + Create.ForeignKey("FK_CommunicationTestResults_CommunicationTestRuns") + .FromTable("CommunicationTestResults").ForeignColumn("CommunicationTestRunId") + .ToTable("CommunicationTestRuns").PrimaryColumn("CommunicationTestRunId") + .OnDelete(Rule.Cascade); + } + + public override void Down() + { + Delete.ForeignKey("FK_CommunicationTestResults_CommunicationTestRuns") + .OnTable("CommunicationTestResults"); + Delete.ForeignKey("FK_CommunicationTestRuns_CommunicationTests") + .OnTable("CommunicationTestRuns"); + + Create.ForeignKey("FK_CommunicationTestRuns_CommunicationTests") + .FromTable("CommunicationTestRuns").ForeignColumn("CommunicationTestId") + .ToTable("CommunicationTests").PrimaryColumn("CommunicationTestId"); + + Create.ForeignKey("FK_CommunicationTestResults_CommunicationTestRuns") + .FromTable("CommunicationTestResults").ForeignColumn("CommunicationTestRunId") + .ToTable("CommunicationTestRuns").PrimaryColumn("CommunicationTestRunId"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0112_AddModeration.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0112_AddModeration.cs new file mode 100644 index 000000000..48be3ad52 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0112_AddModeration.cs @@ -0,0 +1,252 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(112)] + public class M0112_AddModeration : Migration + { + public override void Up() + { + if (Schema.Table("ChatMessages").Exists() && !Schema.Table("ChatMessages").Column("IsModerated").Exists()) + { + Alter.Table("ChatMessages") + .AddColumn("IsModerated").AsBoolean().NotNullable().WithDefaultValue(false); + } + + if (!Schema.Table("ModerationRequests").Exists()) + { + Create.Table("ModerationRequests") + .WithColumn("ModerationRequestId").AsString(128).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ItemType").AsInt32().NotNullable() + .WithColumn("ItemId").AsString(128).NotNullable() + .WithColumn("CallId").AsInt32().Nullable() + .WithColumn("ChatChannelId").AsString(128).Nullable() + .WithColumn("ContentAuthorUserId").AsString(450).Nullable() + .WithColumn("ContentAuthorUnitId").AsInt32().Nullable() + .WithColumn("ContentCreatedOn").AsDateTime2().Nullable() + .WithColumn("OriginalSubject").AsString(int.MaxValue).Nullable() + .WithColumn("OriginalText").AsString(int.MaxValue).Nullable() + .WithColumn("OriginalFileName").AsString(int.MaxValue).Nullable() + .WithColumn("OriginalContentType").AsString(256).Nullable() + .WithColumn("OriginalContent").AsBinary(int.MaxValue).Nullable() + .WithColumn("OriginalMetadataJson").AsString(int.MaxValue).Nullable() + .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Disposition").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime) + .WithColumn("ModifiedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime) + .WithColumn("CompletedByUserId").AsString(450).Nullable() + .WithColumn("CompletedOn").AsDateTime2().Nullable() + .WithColumn("AdminNote").AsString(int.MaxValue).Nullable(); + + Create.Index("UX_ModerationRequests_Department_Item") + .OnTable("ModerationRequests") + .OnColumn("DepartmentId").Ascending() + .OnColumn("ItemType").Ascending() + .OnColumn("ItemId").Ascending() + .WithOptions().Unique(); + + Create.Index("IX_ModerationRequests_Department_Status_ModifiedOn") + .OnTable("ModerationRequests") + .OnColumn("DepartmentId").Ascending() + .OnColumn("Status").Ascending() + .OnColumn("ModifiedOn").Descending(); + + Create.Index("IX_ModerationRequests_Department_Author") + .OnTable("ModerationRequests") + .OnColumn("DepartmentId").Ascending() + .OnColumn("ContentAuthorUserId").Ascending(); + } + + if (!Schema.Table("ModerationReports").Exists()) + { + Create.Table("ModerationReports") + .WithColumn("ModerationReportId").AsString(128).NotNullable().PrimaryKey() + .WithColumn("ModerationRequestId").AsString(128).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ReportedByUserId").AsString(450).NotNullable() + .WithColumn("ReporterGroupId").AsInt32().Nullable() + .WithColumn("Reason").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Note").AsString(int.MaxValue).Nullable() + .WithColumn("ReportedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime); + + Create.Index("UX_ModerationReports_Request_Reporter") + .OnTable("ModerationReports") + .OnColumn("ModerationRequestId").Ascending() + .OnColumn("ReportedByUserId").Ascending() + .WithOptions().Unique(); + + Create.Index("IX_ModerationReports_Department_Group") + .OnTable("ModerationReports") + .OnColumn("DepartmentId").Ascending() + .OnColumn("ReporterGroupId").Ascending(); + + Create.Index("IX_ModerationReports_Department_Reporter") + .OnTable("ModerationReports") + .OnColumn("DepartmentId").Ascending() + .OnColumn("ReportedByUserId").Ascending(); + } + + if (!Schema.Table("ModerationActions").Exists()) + { + Create.Table("ModerationActions") + .WithColumn("ModerationActionId").AsString(128).NotNullable().PrimaryKey() + .WithColumn("ModerationRequestId").AsString(128).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ActionType").AsInt32().NotNullable() + .WithColumn("PerformedByUserId").AsString(450).Nullable() + .WithColumn("PerformedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime) + .WithColumn("Note").AsString(int.MaxValue).Nullable() + .WithColumn("PreviousStatus").AsInt32().Nullable() + .WithColumn("NewStatus").AsInt32().Nullable() + .WithColumn("ActorRole").AsString(128).Nullable() + .WithColumn("IpAddress").AsString(128).Nullable() + .WithColumn("UserAgent").AsString(int.MaxValue).Nullable() + .WithColumn("TraceId").AsString(256).Nullable() + .WithColumn("ServerName").AsString(256).Nullable() + .WithColumn("DetailsJson").AsString(int.MaxValue).Nullable() + .WithColumn("EvidenceText").AsString(int.MaxValue).Nullable() + .WithColumn("EvidenceContent").AsBinary(int.MaxValue).Nullable() + .WithColumn("EvidenceMetadataJson").AsString(int.MaxValue).Nullable(); + + Create.Index("IX_ModerationActions_Request_PerformedOn") + .OnTable("ModerationActions") + .OnColumn("ModerationRequestId").Ascending() + .OnColumn("PerformedOn").Ascending(); + } + + Create.ForeignKey("FK_ModerationReports_ModerationRequests") + .FromTable("ModerationReports").ForeignColumn("ModerationRequestId") + .ToTable("ModerationRequests").PrimaryColumn("ModerationRequestId"); + + Create.ForeignKey("FK_ModerationActions_ModerationRequests") + .FromTable("ModerationActions").ForeignColumn("ModerationRequestId") + .ToTable("ModerationRequests").PrimaryColumn("ModerationRequestId"); + + ImportLegacyFlags(); + } + + private void ImportLegacyFlags() + { + Execute.Sql(@" +INSERT INTO ModerationRequests + (ModerationRequestId, DepartmentId, ItemType, ItemId, ChatChannelId, ContentAuthorUserId, + ContentAuthorUnitId, ContentCreatedOn, OriginalText, OriginalFileName, OriginalContentType, + OriginalContent, OriginalMetadataJson, Status, Disposition, CreatedOn, ModifiedOn, + CompletedByUserId, CompletedOn, AdminNote) +SELECT MIN(f.ChatMessageFlagId), f.DepartmentId, 0, f.ChatMessageId, MIN(f.ChatChannelId), + MIN(m.SenderUserId), MIN(m.SenderUnitId), MIN(m.SentOn), + COALESCE(MIN(m.Body), (SELECT TOP 1 e.PriorBody FROM ChatMessageEdits e + WHERE e.ChatMessageId = f.ChatMessageId ORDER BY e.EditedOn DESC)), + (SELECT TOP 1 ca.FileName FROM ChatAttachments ca + WHERE ca.ChatMessageId = f.ChatMessageId ORDER BY ca.UploadedOn), + (SELECT TOP 1 ca.ContentType FROM ChatAttachments ca + WHERE ca.ChatMessageId = f.ChatMessageId ORDER BY ca.UploadedOn), + (SELECT TOP 1 ca.Data FROM ChatAttachments ca + WHERE ca.ChatMessageId = f.ChatMessageId ORDER BY ca.UploadedOn), + MIN(m.MetadataJson), + CASE WHEN SUM(CASE WHEN f.Status = 0 THEN 1 ELSE 0 END) > 0 THEN 0 ELSE 1 END, + 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, + MIN(f.FlaggedOn), MAX(COALESCE(f.ReviewedOn, f.FlaggedOn)), MAX(f.ReviewedByUserId), + MAX(f.ReviewedOn), MAX(f.ResolutionNote) +FROM ChatMessageFlags f +LEFT JOIN ChatMessages m ON m.ChatMessageId = f.ChatMessageId +GROUP BY f.DepartmentId, f.ChatMessageId; + +INSERT INTO ModerationReports + (ModerationReportId, ModerationRequestId, DepartmentId, ReportedByUserId, ReporterGroupId, + Reason, Note, ReportedOn) +SELECT MIN(f.ChatMessageFlagId), + (SELECT MIN(f2.ChatMessageFlagId) FROM ChatMessageFlags f2 + WHERE f2.DepartmentId = f.DepartmentId AND f2.ChatMessageId = f.ChatMessageId), + f.DepartmentId, f.FlaggedByUserId, + (SELECT TOP 1 gm.DepartmentGroupId FROM DepartmentGroupMembers gm + WHERE gm.DepartmentId = f.DepartmentId AND gm.UserId = f.FlaggedByUserId + ORDER BY gm.DepartmentGroupMemberId), + MIN(f.Reason), MIN(f.Note), MIN(f.FlaggedOn) +FROM ChatMessageFlags f +WHERE f.FlaggedByUserId IS NOT NULL +GROUP BY f.DepartmentId, f.ChatMessageId, f.FlaggedByUserId; + +INSERT INTO ModerationRequests + (ModerationRequestId, DepartmentId, ItemType, ItemId, CallId, ContentAuthorUserId, + 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, + CONCAT('{""source"":', n.Source, ',""latitude"":', COALESCE(CONVERT(varchar(64), n.Latitude), 'null'), + ',""longitude"":', COALESCE(CONVERT(varchar(64), n.Longitude), 'null'), '}'), + 0, 0, COALESCE(n.FlaggedOn, n.Timestamp), COALESCE(n.FlaggedOn, n.Timestamp) +FROM CallNotes n +INNER JOIN Calls c ON c.CallId = n.CallId +WHERE n.IsFlagged = 1; + +INSERT INTO ModerationReports + (ModerationReportId, ModerationRequestId, DepartmentId, ReportedByUserId, ReporterGroupId, + Reason, Note, ReportedOn) +SELECT CONCAT('callnote-', n.CallNoteId), CONCAT('callnote-', n.CallNoteId), c.DepartmentId, + n.FlaggedByUserId, + (SELECT TOP 1 gm.DepartmentGroupId FROM DepartmentGroupMembers gm + WHERE gm.DepartmentId = c.DepartmentId AND gm.UserId = n.FlaggedByUserId + ORDER BY gm.DepartmentGroupMemberId), + 0, n.FlaggedReason, COALESCE(n.FlaggedOn, n.Timestamp) +FROM CallNotes n +INNER JOIN Calls c ON c.CallId = n.CallId +WHERE n.IsFlagged = 1 AND n.FlaggedByUserId IS NOT NULL; + +INSERT INTO ModerationRequests + (ModerationRequestId, DepartmentId, ItemType, ItemId, CallId, ContentAuthorUserId, + ContentCreatedOn, OriginalFileName, OriginalContentType, OriginalContent, OriginalMetadataJson, + Status, Disposition, CreatedOn, ModifiedOn) +SELECT CONCAT('callimage-', a.CallAttachmentId), c.DepartmentId, 3, + CONVERT(varchar(32), a.CallAttachmentId), a.CallId, a.UserId, a.Timestamp, a.FileName, + 'image/jpeg', a.Data, + CONCAT('{""name"":""', COALESCE(REPLACE(a.Name, '""', '\""'), ''), + '"",""size"":', COALESCE(CONVERT(varchar(32), a.Size), 'null'), '}'), + 0, 0, COALESCE(a.FlaggedOn, a.Timestamp, GETUTCDATE()), + COALESCE(a.FlaggedOn, a.Timestamp, GETUTCDATE()) +FROM CallAttachments a +INNER JOIN Calls c ON c.CallId = a.CallId +WHERE a.IsFlagged = 1 AND a.CallAttachmentType = 2; + +INSERT INTO ModerationReports + (ModerationReportId, ModerationRequestId, DepartmentId, ReportedByUserId, ReporterGroupId, + Reason, Note, ReportedOn) +SELECT CONCAT('callimage-', a.CallAttachmentId), CONCAT('callimage-', a.CallAttachmentId), + c.DepartmentId, a.FlaggedByUserId, + (SELECT TOP 1 gm.DepartmentGroupId FROM DepartmentGroupMembers gm + WHERE gm.DepartmentId = c.DepartmentId AND gm.UserId = a.FlaggedByUserId + ORDER BY gm.DepartmentGroupMemberId), + 0, a.FlaggedReason, COALESCE(a.FlaggedOn, a.Timestamp, GETUTCDATE()) +FROM CallAttachments a +INNER JOIN Calls c ON c.CallId = a.CallId +WHERE a.IsFlagged = 1 AND a.CallAttachmentType = 2 AND a.FlaggedByUserId IS NOT NULL; + +INSERT INTO ModerationActions + (ModerationActionId, ModerationRequestId, DepartmentId, ActionType, PerformedByUserId, + PerformedOn, NewStatus, ActorRole, ServerName, DetailsJson, EvidenceText, EvidenceContent, + EvidenceMetadataJson) +SELECT r.ModerationRequestId, r.ModerationRequestId, r.DepartmentId, 0, + (SELECT TOP 1 rp.ReportedByUserId FROM ModerationReports rp + WHERE rp.ModerationRequestId = r.ModerationRequestId ORDER BY rp.ReportedOn), + r.CreatedOn, r.Status, 'LegacyImport', HOST_NAME(), '{""imported"":true}', r.OriginalText, + r.OriginalContent, r.OriginalMetadataJson +FROM ModerationRequests r;"); + } + + public override void Down() + { + if (Schema.Table("ModerationActions").Exists()) + Delete.Table("ModerationActions"); + + if (Schema.Table("ModerationReports").Exists()) + Delete.Table("ModerationReports"); + + if (Schema.Table("ModerationRequests").Exists()) + Delete.Table("ModerationRequests"); + + if (Schema.Table("ChatMessages").Exists() && Schema.Table("ChatMessages").Column("IsModerated").Exists()) + Delete.Column("IsModerated").FromTable("ChatMessages"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0111_CascadeCommunicationTestDeletesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0111_CascadeCommunicationTestDeletesPg.cs new file mode 100644 index 000000000..bc4b8d429 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0111_CascadeCommunicationTestDeletesPg.cs @@ -0,0 +1,43 @@ +using System.Data; +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(111)] + public class M0111_CascadeCommunicationTestDeletesPg : Migration + { + public override void Up() + { + Delete.ForeignKey("fk_communicationtestresults_communicationtestruns") + .OnTable("communicationtestresults"); + Delete.ForeignKey("fk_communicationtestruns_communicationtests") + .OnTable("communicationtestruns"); + + Create.ForeignKey("fk_communicationtestruns_communicationtests") + .FromTable("communicationtestruns").ForeignColumn("communicationtestid") + .ToTable("communicationtests").PrimaryColumn("communicationtestid") + .OnDelete(Rule.Cascade); + + Create.ForeignKey("fk_communicationtestresults_communicationtestruns") + .FromTable("communicationtestresults").ForeignColumn("communicationtestrunid") + .ToTable("communicationtestruns").PrimaryColumn("communicationtestrunid") + .OnDelete(Rule.Cascade); + } + + public override void Down() + { + Delete.ForeignKey("fk_communicationtestresults_communicationtestruns") + .OnTable("communicationtestresults"); + Delete.ForeignKey("fk_communicationtestruns_communicationtests") + .OnTable("communicationtestruns"); + + Create.ForeignKey("fk_communicationtestruns_communicationtests") + .FromTable("communicationtestruns").ForeignColumn("communicationtestid") + .ToTable("communicationtests").PrimaryColumn("communicationtestid"); + + Create.ForeignKey("fk_communicationtestresults_communicationtestruns") + .FromTable("communicationtestresults").ForeignColumn("communicationtestrunid") + .ToTable("communicationtestruns").PrimaryColumn("communicationtestrunid"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0112_AddModerationPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0112_AddModerationPg.cs new file mode 100644 index 000000000..60725ba3f --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0112_AddModerationPg.cs @@ -0,0 +1,249 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(112)] + public class M0112_AddModerationPg : Migration + { + public override void Up() + { + if (Schema.Table("chatmessages").Exists() && !Schema.Table("chatmessages").Column("ismoderated").Exists()) + { + Alter.Table("chatmessages") + .AddColumn("ismoderated").AsBoolean().NotNullable().WithDefaultValue(false); + } + + if (!Schema.Table("moderationrequests").Exists()) + { + Create.Table("moderationrequests") + .WithColumn("moderationrequestid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("itemtype").AsInt32().NotNullable() + .WithColumn("itemid").AsCustom("citext").NotNullable() + .WithColumn("callid").AsInt32().Nullable() + .WithColumn("chatchannelid").AsCustom("citext").Nullable() + .WithColumn("contentauthoruserid").AsCustom("citext").Nullable() + .WithColumn("contentauthorunitid").AsInt32().Nullable() + .WithColumn("contentcreatedon").AsDateTime2().Nullable() + .WithColumn("originalsubject").AsCustom("text").Nullable() + .WithColumn("originaltext").AsCustom("text").Nullable() + .WithColumn("originalfilename").AsCustom("text").Nullable() + .WithColumn("originalcontenttype").AsCustom("citext").Nullable() + .WithColumn("originalcontent").AsCustom("bytea").Nullable() + .WithColumn("originalmetadatajson").AsCustom("text").Nullable() + .WithColumn("status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("disposition").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime) + .WithColumn("modifiedon").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime) + .WithColumn("completedbyuserid").AsCustom("citext").Nullable() + .WithColumn("completedon").AsDateTime2().Nullable() + .WithColumn("adminnote").AsCustom("text").Nullable(); + + Create.Index("ux_moderationrequests_department_item") + .OnTable("moderationrequests") + .OnColumn("departmentid").Ascending() + .OnColumn("itemtype").Ascending() + .OnColumn("itemid").Ascending() + .WithOptions().Unique(); + + Create.Index("ix_moderationrequests_department_status_modifiedon") + .OnTable("moderationrequests") + .OnColumn("departmentid").Ascending() + .OnColumn("status").Ascending() + .OnColumn("modifiedon").Descending(); + + Create.Index("ix_moderationrequests_department_author") + .OnTable("moderationrequests") + .OnColumn("departmentid").Ascending() + .OnColumn("contentauthoruserid").Ascending(); + } + + if (!Schema.Table("moderationreports").Exists()) + { + Create.Table("moderationreports") + .WithColumn("moderationreportid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("moderationrequestid").AsCustom("citext").NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("reportedbyuserid").AsCustom("citext").NotNullable() + .WithColumn("reportergroupid").AsInt32().Nullable() + .WithColumn("reason").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("note").AsCustom("text").Nullable() + .WithColumn("reportedon").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime); + + Create.Index("ux_moderationreports_request_reporter") + .OnTable("moderationreports") + .OnColumn("moderationrequestid").Ascending() + .OnColumn("reportedbyuserid").Ascending() + .WithOptions().Unique(); + + Create.Index("ix_moderationreports_department_group") + .OnTable("moderationreports") + .OnColumn("departmentid").Ascending() + .OnColumn("reportergroupid").Ascending(); + + Create.Index("ix_moderationreports_department_reporter") + .OnTable("moderationreports") + .OnColumn("departmentid").Ascending() + .OnColumn("reportedbyuserid").Ascending(); + } + + if (!Schema.Table("moderationactions").Exists()) + { + Create.Table("moderationactions") + .WithColumn("moderationactionid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("moderationrequestid").AsCustom("citext").NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("actiontype").AsInt32().NotNullable() + .WithColumn("performedbyuserid").AsCustom("citext").Nullable() + .WithColumn("performedon").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime) + .WithColumn("note").AsCustom("text").Nullable() + .WithColumn("previousstatus").AsInt32().Nullable() + .WithColumn("newstatus").AsInt32().Nullable() + .WithColumn("actorrole").AsCustom("citext").Nullable() + .WithColumn("ipaddress").AsCustom("citext").Nullable() + .WithColumn("useragent").AsCustom("text").Nullable() + .WithColumn("traceid").AsCustom("citext").Nullable() + .WithColumn("servername").AsCustom("citext").Nullable() + .WithColumn("detailsjson").AsCustom("text").Nullable() + .WithColumn("evidencetext").AsCustom("text").Nullable() + .WithColumn("evidencecontent").AsCustom("bytea").Nullable() + .WithColumn("evidencemetadatajson").AsCustom("text").Nullable(); + + Create.Index("ix_moderationactions_request_performedon") + .OnTable("moderationactions") + .OnColumn("moderationrequestid").Ascending() + .OnColumn("performedon").Ascending(); + } + + Create.ForeignKey("fk_moderationreports_moderationrequests") + .FromTable("moderationreports").ForeignColumn("moderationrequestid") + .ToTable("moderationrequests").PrimaryColumn("moderationrequestid"); + + Create.ForeignKey("fk_moderationactions_moderationrequests") + .FromTable("moderationactions").ForeignColumn("moderationrequestid") + .ToTable("moderationrequests").PrimaryColumn("moderationrequestid"); + + ImportLegacyFlags(); + } + + private void ImportLegacyFlags() + { + Execute.Sql(@" +INSERT INTO moderationrequests + (moderationrequestid, departmentid, itemtype, itemid, chatchannelid, contentauthoruserid, + contentauthorunitid, contentcreatedon, originaltext, originalfilename, originalcontenttype, + originalcontent, originalmetadatajson, status, disposition, createdon, modifiedon, + completedbyuserid, completedon, adminnote) +SELECT MIN(f.chatmessageflagid::text), f.departmentid, 0, f.chatmessageid, MIN(f.chatchannelid::text), + 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 + WHERE ca.chatmessageid = f.chatmessageid ORDER BY ca.uploadedon LIMIT 1), + (SELECT ca.contenttype FROM chatattachments ca + WHERE ca.chatmessageid = f.chatmessageid ORDER BY ca.uploadedon LIMIT 1), + (SELECT ca.data FROM chatattachments ca + WHERE ca.chatmessageid = f.chatmessageid ORDER BY ca.uploadedon LIMIT 1), + MIN(m.metadatajson), + CASE WHEN SUM(CASE WHEN f.status = 0 THEN 1 ELSE 0 END) > 0 THEN 0 ELSE 1 END, + 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, + MIN(f.flaggedon), MAX(COALESCE(f.reviewedon, f.flaggedon)), MAX(f.reviewedbyuserid::text), + MAX(f.reviewedon), MAX(f.resolutionnote) +FROM chatmessageflags f +LEFT JOIN chatmessages m ON m.chatmessageid = f.chatmessageid +GROUP BY f.departmentid, f.chatmessageid; + +INSERT INTO moderationreports + (moderationreportid, moderationrequestid, departmentid, reportedbyuserid, reportergroupid, + reason, note, reportedon) +SELECT MIN(f.chatmessageflagid::text), + (SELECT MIN(f2.chatmessageflagid::text) FROM chatmessageflags f2 + WHERE f2.departmentid = f.departmentid AND f2.chatmessageid = f.chatmessageid), + f.departmentid, f.flaggedbyuserid, + (SELECT gm.departmentgroupid FROM departmentgroupmembers gm + WHERE gm.departmentid = f.departmentid AND gm.userid = f.flaggedbyuserid + ORDER BY gm.departmentgroupmemberid LIMIT 1), + MIN(f.reason), MIN(f.note), MIN(f.flaggedon) +FROM chatmessageflags f +WHERE f.flaggedbyuserid IS NOT NULL +GROUP BY f.departmentid, f.chatmessageid, f.flaggedbyuserid; + +INSERT INTO moderationrequests + (moderationrequestid, departmentid, itemtype, itemid, callid, contentauthoruserid, + contentcreatedon, originaltext, originalmetadatajson, status, disposition, createdon, modifiedon) +SELECT CONCAT('callnote-', n.callnoteid), c.departmentid, 2, n.callnoteid::text, + n.callid, n.userid, n.timestamp, n.note, + json_build_object('source', n.source, 'latitude', n.latitude, 'longitude', n.longitude)::text, + 0, 0, COALESCE(n.flaggedon, n.timestamp), COALESCE(n.flaggedon, n.timestamp) +FROM callnotes n +INNER JOIN calls c ON c.callid = n.callid +WHERE n.isflagged = true; + +INSERT INTO moderationreports + (moderationreportid, moderationrequestid, departmentid, reportedbyuserid, reportergroupid, + reason, note, reportedon) +SELECT CONCAT('callnote-', n.callnoteid), CONCAT('callnote-', n.callnoteid), c.departmentid, + n.flaggedbyuserid, + (SELECT gm.departmentgroupid FROM departmentgroupmembers gm + WHERE gm.departmentid = c.departmentid AND gm.userid = n.flaggedbyuserid + ORDER BY gm.departmentgroupmemberid LIMIT 1), + 0, n.flaggedreason, COALESCE(n.flaggedon, n.timestamp) +FROM callnotes n +INNER JOIN calls c ON c.callid = n.callid +WHERE n.isflagged = true AND n.flaggedbyuserid IS NOT NULL; + +INSERT INTO moderationrequests + (moderationrequestid, departmentid, itemtype, itemid, callid, contentauthoruserid, + contentcreatedon, originalfilename, originalcontenttype, originalcontent, originalmetadatajson, + status, disposition, createdon, modifiedon) +SELECT CONCAT('callimage-', a.callattachmentid), c.departmentid, 3, a.callattachmentid::text, + a.callid, a.userid, a.timestamp, a.filename, 'image/jpeg', a.data, + json_build_object('name', a.name, 'size', a.size)::text, + 0, 0, COALESCE(a.flaggedon, a.timestamp, NOW() AT TIME ZONE 'utc'), + COALESCE(a.flaggedon, a.timestamp, NOW() AT TIME ZONE 'utc') +FROM callattachments a +INNER JOIN calls c ON c.callid = a.callid +WHERE a.isflagged = true AND a.callattachmenttype = 2; + +INSERT INTO moderationreports + (moderationreportid, moderationrequestid, departmentid, reportedbyuserid, reportergroupid, + reason, note, reportedon) +SELECT CONCAT('callimage-', a.callattachmentid), CONCAT('callimage-', a.callattachmentid), + c.departmentid, a.flaggedbyuserid, + (SELECT gm.departmentgroupid FROM departmentgroupmembers gm + WHERE gm.departmentid = c.departmentid AND gm.userid = a.flaggedbyuserid + ORDER BY gm.departmentgroupmemberid LIMIT 1), + 0, a.flaggedreason, COALESCE(a.flaggedon, a.timestamp, NOW() AT TIME ZONE 'utc') +FROM callattachments a +INNER JOIN calls c ON c.callid = a.callid +WHERE a.isflagged = true AND a.callattachmenttype = 2 AND a.flaggedbyuserid IS NOT NULL; + +INSERT INTO moderationactions + (moderationactionid, moderationrequestid, departmentid, actiontype, performedbyuserid, + performedon, newstatus, actorrole, servername, detailsjson, evidencetext, evidencecontent, + evidencemetadatajson) +SELECT r.moderationrequestid, r.moderationrequestid, r.departmentid, 0, + (SELECT rp.reportedbyuserid FROM moderationreports rp + WHERE rp.moderationrequestid = r.moderationrequestid ORDER BY rp.reportedon LIMIT 1), + r.createdon, r.status, 'LegacyImport', inet_server_addr()::text, '{""imported"":true}', + r.originaltext, r.originalcontent, r.originalmetadatajson +FROM moderationrequests r;"); + } + + public override void Down() + { + if (Schema.Table("moderationactions").Exists()) + Delete.Table("moderationactions"); + + if (Schema.Table("moderationreports").Exists()) + Delete.Table("moderationreports"); + + if (Schema.Table("moderationrequests").Exists()) + Delete.Table("moderationrequests"); + + if (Schema.Table("chatmessages").Exists() && Schema.Table("chatmessages").Column("ismoderated").Exists()) + Delete.Column("ismoderated").FromTable("chatmessages"); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs index 1cf7a962d..1dab7e26a 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs @@ -1491,7 +1491,7 @@ public async Task UpdateBodyAsync(string chatMessageId, string body, DateT } } - public async Task TombstoneAsync(string chatMessageId, DateTime deletedOn, string deletedByUserId, CancellationToken cancellationToken) + public async Task TombstoneAsync(string chatMessageId, DateTime deletedOn, string deletedByUserId, bool isModerated, CancellationToken cancellationToken) { try { @@ -1499,10 +1499,11 @@ public async Task TombstoneAsync(string chatMessageId, DateTime deletedOn, parameters.Add("Id", chatMessageId); parameters.Add("DeletedOn", deletedOn, DbType.DateTime2); parameters.Add("DeletedByUserId", deletedByUserId); + parameters.Add("IsModerated", isModerated); var notation = _sqlConfiguration.ParameterNotation; var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres - ? $"UPDATE {_sqlConfiguration.SchemaName}.chatmessages SET body = NULL, metadatajson = NULL, deletedon = {notation}DeletedOn, deletedbyuserid = {notation}DeletedByUserId WHERE chatmessageid = {notation}Id AND deletedon IS NULL" - : $"UPDATE {_sqlConfiguration.SchemaName}.[ChatMessages] SET [Body] = NULL, [MetadataJson] = NULL, [DeletedOn] = {notation}DeletedOn, [DeletedByUserId] = {notation}DeletedByUserId WHERE [ChatMessageId] = {notation}Id AND [DeletedOn] IS NULL"; + ? $"UPDATE {_sqlConfiguration.SchemaName}.chatmessages SET body = NULL, metadatajson = NULL, deletedon = {notation}DeletedOn, deletedbyuserid = {notation}DeletedByUserId, ismoderated = {notation}IsModerated WHERE chatmessageid = {notation}Id AND deletedon IS NULL" + : $"UPDATE {_sqlConfiguration.SchemaName}.[ChatMessages] SET [Body] = NULL, [MetadataJson] = NULL, [DeletedOn] = {notation}DeletedOn, [DeletedByUserId] = {notation}DeletedByUserId, [IsModerated] = {notation}IsModerated WHERE [ChatMessageId] = {notation}Id AND [DeletedOn] IS NULL"; var execute = new Func>(connection => connection.ExecuteAsync(sql, parameters, _unitOfWork.Transaction)); diff --git a/Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs new file mode 100644 index 000000000..afbd5182d --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs @@ -0,0 +1,316 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + public class ModerationRequestRepository : RepositoryBase, IModerationRequestRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IUnitOfWork _unitOfWork; + + public ModerationRequestRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _unitOfWork = unitOfWork; + } + + public async Task GetByItemAsync(int departmentId, int itemType, string itemId) + { + try + { + var parameters = new DynamicParametersExtension(); + parameters.Add("DepartmentId", departmentId); + parameters.Add("ItemType", itemType); + parameters.Add("ItemId", itemId); + var notation = _sqlConfiguration.ParameterNotation; + var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres + ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.moderationrequests WHERE departmentid = {notation}DepartmentId AND itemtype = {notation}ItemType AND itemid = {notation}ItemId" + : $"SELECT * FROM {_sqlConfiguration.SchemaName}.[ModerationRequests] WHERE [DepartmentId] = {notation}DepartmentId AND [ItemType] = {notation}ItemType AND [ItemId] = {notation}ItemId"; + + return (await QueryAsync(sql, parameters)).FirstOrDefault(); + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + public async Task> SearchAsync(int departmentId, ModerationSearchCriteria criteria, + IEnumerable visibleGroupIds, string reporterUserId) + { + criteria ??= new ModerationSearchCriteria(); + var pageSize = criteria.PageSize <= 0 ? 50 : Math.Min(criteria.PageSize, 200); + var page = Math.Max(criteria.Page, 1); + + try + { + var parameters = new DynamicParametersExtension(); + parameters.Add("DepartmentId", departmentId); + parameters.Add("PageSize", pageSize); + parameters.Add("Offset", (page - 1) * pageSize); + var notation = _sqlConfiguration.ParameterNotation; + var postgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + var requestAlias = postgres ? "r" : "r"; + var filters = new List(); + + if (criteria.Status.HasValue) + { + parameters.Add("Status", (int)criteria.Status.Value); + filters.Add(postgres ? $"r.status = {notation}Status" : $"r.[Status] = {notation}Status"); + } + + if (criteria.ItemType.HasValue) + { + parameters.Add("ItemType", (int)criteria.ItemType.Value); + filters.Add(postgres ? $"r.itemtype = {notation}ItemType" : $"r.[ItemType] = {notation}ItemType"); + } + + if (!string.IsNullOrWhiteSpace(criteria.ContentAuthorUserId)) + { + parameters.Add("ContentAuthorUserId", criteria.ContentAuthorUserId); + filters.Add(postgres + ? $"r.contentauthoruserid = {notation}ContentAuthorUserId" + : $"r.[ContentAuthorUserId] = {notation}ContentAuthorUserId"); + } + + if (!string.IsNullOrWhiteSpace(criteria.ReportedByUserId)) + { + parameters.Add("ReportedByUserId", criteria.ReportedByUserId); + if (string.IsNullOrWhiteSpace(reporterUserId)) + { + filters.Add(postgres + ? $"EXISTS (SELECT 1 FROM {_sqlConfiguration.SchemaName}.moderationreports rf WHERE rf.moderationrequestid = r.moderationrequestid AND rf.reportedbyuserid = {notation}ReportedByUserId)" + : $"EXISTS (SELECT 1 FROM {_sqlConfiguration.SchemaName}.[ModerationReports] rf WHERE rf.[ModerationRequestId] = r.[ModerationRequestId] AND rf.[ReportedByUserId] = {notation}ReportedByUserId)"); + } + } + + if (criteria.From.HasValue) + { + parameters.Add("From", criteria.From.Value); + filters.Add(postgres ? $"r.createdon >= {notation}From" : $"r.[CreatedOn] >= {notation}From"); + } + + if (criteria.To.HasValue) + { + parameters.Add("To", criteria.To.Value); + filters.Add(postgres ? $"r.createdon < {notation}To" : $"r.[CreatedOn] < {notation}To"); + } + + var groupIds = visibleGroupIds?.Distinct().ToList() ?? new List(); + if (!string.IsNullOrWhiteSpace(reporterUserId)) + { + parameters.Add("ReporterUserId", reporterUserId); + if (groupIds.Count > 0) + { + parameters.Add("VisibleGroupIds", groupIds); + var requestedReporter = string.IsNullOrWhiteSpace(criteria.ReportedByUserId) + ? string.Empty + : postgres + ? $" AND rv.reportedbyuserid = {notation}ReportedByUserId" + : $" AND rv.[ReportedByUserId] = {notation}ReportedByUserId"; + filters.Add(postgres + ? $"EXISTS (SELECT 1 FROM {_sqlConfiguration.SchemaName}.moderationreports rv WHERE rv.moderationrequestid = r.moderationrequestid{requestedReporter} AND (rv.reportedbyuserid = {notation}ReporterUserId OR rv.reportergroupid IN {notation}VisibleGroupIds))" + : $"EXISTS (SELECT 1 FROM {_sqlConfiguration.SchemaName}.[ModerationReports] rv WHERE rv.[ModerationRequestId] = r.[ModerationRequestId]{requestedReporter} AND (rv.[ReportedByUserId] = {notation}ReporterUserId OR rv.[ReporterGroupId] IN {notation}VisibleGroupIds))"); + } + else + { + var requestedReporter = string.IsNullOrWhiteSpace(criteria.ReportedByUserId) + ? string.Empty + : postgres + ? $" AND rv.reportedbyuserid = {notation}ReportedByUserId" + : $" AND rv.[ReportedByUserId] = {notation}ReportedByUserId"; + filters.Add(postgres + ? $"EXISTS (SELECT 1 FROM {_sqlConfiguration.SchemaName}.moderationreports rv WHERE rv.moderationrequestid = r.moderationrequestid{requestedReporter} AND rv.reportedbyuserid = {notation}ReporterUserId)" + : $"EXISTS (SELECT 1 FROM {_sqlConfiguration.SchemaName}.[ModerationReports] rv WHERE rv.[ModerationRequestId] = r.[ModerationRequestId]{requestedReporter} AND rv.[ReportedByUserId] = {notation}ReporterUserId)"); + } + } + + var extra = filters.Count > 0 ? " AND " + string.Join(" AND ", filters) : string.Empty; + string sql; + if (postgres) + { + sql = $@"SELECT r.moderationrequestid, r.departmentid, r.itemtype, r.itemid, r.callid, + r.chatchannelid, r.contentauthoruserid, r.contentauthorunitid, r.contentcreatedon, + r.originalsubject, r.originaltext, r.originalfilename, r.originalcontenttype, + r.originalmetadatajson, r.status, r.disposition, r.createdon, r.modifiedon, + r.completedbyuserid, r.completedon, r.adminnote +FROM {_sqlConfiguration.SchemaName}.moderationrequests {requestAlias} +WHERE r.departmentid = {notation}DepartmentId{extra} +ORDER BY r.modifiedon DESC +LIMIT {notation}PageSize OFFSET {notation}Offset"; + } + else + { + sql = $@"SELECT r.[ModerationRequestId], r.[DepartmentId], r.[ItemType], r.[ItemId], r.[CallId], + r.[ChatChannelId], r.[ContentAuthorUserId], r.[ContentAuthorUnitId], r.[ContentCreatedOn], + r.[OriginalSubject], r.[OriginalText], r.[OriginalFileName], r.[OriginalContentType], + r.[OriginalMetadataJson], r.[Status], r.[Disposition], r.[CreatedOn], r.[ModifiedOn], + r.[CompletedByUserId], r.[CompletedOn], r.[AdminNote] +FROM {_sqlConfiguration.SchemaName}.[ModerationRequests] r +WHERE r.[DepartmentId] = {notation}DepartmentId{extra} +ORDER BY r.[ModifiedOn] DESC +OFFSET {notation}Offset ROWS FETCH NEXT {notation}PageSize ROWS ONLY"; + } + + return await QueryAsync(sql, parameters); + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + private async Task> QueryAsync(string sql, object parameters) + { + var select = new Func>>(connection => + connection.QueryAsync(sql, parameters, _unitOfWork.Transaction)); + + if (_unitOfWork?.Connection == null) + { + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(); + return await select(connection); + } + + return await select(_unitOfWork.CreateOrGetConnection()); + } + } + + public class ModerationReportRepository : RepositoryBase, IModerationReportRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IUnitOfWork _unitOfWork; + + public ModerationReportRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _unitOfWork = unitOfWork; + } + + public async Task GetByRequestAndReporterAsync(string moderationRequestId, string reportedByUserId) + { + var rows = await QueryAsync(moderationRequestId, reportedByUserId); + return rows.FirstOrDefault(); + } + + public Task> GetByRequestAsync(string moderationRequestId) + { + return QueryAsync(moderationRequestId, null); + } + + private async Task> QueryAsync(string moderationRequestId, string reportedByUserId) + { + try + { + var parameters = new DynamicParametersExtension(); + parameters.Add("ModerationRequestId", moderationRequestId); + var notation = _sqlConfiguration.ParameterNotation; + var postgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + var reporterClause = string.Empty; + if (!string.IsNullOrWhiteSpace(reportedByUserId)) + { + parameters.Add("ReportedByUserId", reportedByUserId); + reporterClause = postgres + ? $" AND reportedbyuserid = {notation}ReportedByUserId" + : $" AND [ReportedByUserId] = {notation}ReportedByUserId"; + } + + var sql = postgres + ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.moderationreports WHERE moderationrequestid = {notation}ModerationRequestId{reporterClause} ORDER BY reportedon" + : $"SELECT * FROM {_sqlConfiguration.SchemaName}.[ModerationReports] WHERE [ModerationRequestId] = {notation}ModerationRequestId{reporterClause} ORDER BY [ReportedOn]"; + + var select = new Func>>(connection => + connection.QueryAsync(sql, parameters, _unitOfWork.Transaction)); + + if (_unitOfWork?.Connection == null) + { + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(); + return await select(connection); + } + + return await select(_unitOfWork.CreateOrGetConnection()); + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + } + + public class ModerationActionRepository : RepositoryBase, IModerationActionRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IUnitOfWork _unitOfWork; + + public ModerationActionRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _unitOfWork = unitOfWork; + } + + public async Task> GetByRequestAsync(string moderationRequestId) + { + try + { + var parameters = new DynamicParametersExtension(); + parameters.Add("ModerationRequestId", moderationRequestId); + var notation = _sqlConfiguration.ParameterNotation; + var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres + ? $@"SELECT moderationactionid, moderationrequestid, departmentid, actiontype, + performedbyuserid, performedon, note, previousstatus, newstatus, actorrole, ipaddress, + useragent, traceid, servername, detailsjson, evidencetext, evidencemetadatajson +FROM {_sqlConfiguration.SchemaName}.moderationactions +WHERE moderationrequestid = {notation}ModerationRequestId ORDER BY performedon" + : $@"SELECT [ModerationActionId], [ModerationRequestId], [DepartmentId], [ActionType], + [PerformedByUserId], [PerformedOn], [Note], [PreviousStatus], [NewStatus], [ActorRole], [IpAddress], + [UserAgent], [TraceId], [ServerName], [DetailsJson], [EvidenceText], [EvidenceMetadataJson] +FROM {_sqlConfiguration.SchemaName}.[ModerationActions] +WHERE [ModerationRequestId] = {notation}ModerationRequestId ORDER BY [PerformedOn]"; + + var select = new Func>>(connection => + connection.QueryAsync(sql, parameters, _unitOfWork.Transaction)); + + if (_unitOfWork?.Connection == null) + { + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(); + return await select(connection); + } + + return await select(_unitOfWork.CreateOrGetConnection()); + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs index dfd128b73..d20dfd91d 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs @@ -130,6 +130,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index 768282971..b7f871f0c 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -142,6 +142,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs index fd5259fbf..cc8085732 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs @@ -69,6 +69,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index eb484d3e8..aeac592d5 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -128,6 +128,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Tests/Resgrid.Tests/Chatbot/ChatbotDeptConfigAndSessionTests.cs b/Tests/Resgrid.Tests/Chatbot/ChatbotDeptConfigAndSessionTests.cs index f649407b1..2dcc66994 100644 --- a/Tests/Resgrid.Tests/Chatbot/ChatbotDeptConfigAndSessionTests.cs +++ b/Tests/Resgrid.Tests/Chatbot/ChatbotDeptConfigAndSessionTests.cs @@ -7,6 +7,7 @@ using Resgrid.Config; using Resgrid.Chatbot.Models; using Resgrid.Chatbot.Services; +using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; @@ -32,6 +33,43 @@ private static Mock CacheMock() // ---- Per-department LLM override --------------------------------------------------------- + [Test] + public void DepartmentConfig_SerializesForDistributedCache() + { + var createdAt = DateTime.UtcNow.AddDays(-1); + var updatedAt = DateTime.UtcNow; + var config = new ChatbotDepartmentConfig + { + Id = "config-id", + DepartmentId = 5, + IsEnabled = true, + AllowedPlatforms = "Discord,Telegram", + MaxSessionsPerUser = 4, + SessionTtlMinutes = 45, + AllowDispatchViaChatbot = true, + RequireConfirmationForStatusChange = true, + LlmApiEndpoint = "https://dept.example/v1/chat/completions", + LlmApiKey = "ENCRYPTED", + LlmModelName = "dept-model", + MessagesPerUserPerMinute = 12, + MessagesPerDepartmentPerMinute = 120, + RequireLinkingConfirmation = false, + ProactiveNotificationsEnabled = true, + CreatedAt = createdAt, + UpdatedAt = updatedAt + }; + + var serialized = ObjectSerialization.Serialize(config); + var deserialized = ObjectSerialization.Deserialize(serialized); + + deserialized.Should().BeEquivalentTo(config, options => options + .Excluding(value => value.IdValue) + .Excluding(value => value.TableName) + .Excluding(value => value.IdName) + .Excluding(value => value.IdType) + .Excluding(value => value.IgnoredProperties)); + } + [Test] public async Task GetLlmOverride_WhenEndpointAndKeyConfigured_ReturnsDecryptedOverride() { diff --git a/Tests/Resgrid.Tests/Localization/ModerationLocalizationTests.cs b/Tests/Resgrid.Tests/Localization/ModerationLocalizationTests.cs new file mode 100644 index 000000000..e84ca4216 --- /dev/null +++ b/Tests/Resgrid.Tests/Localization/ModerationLocalizationTests.cs @@ -0,0 +1,124 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Resources; +using System.Text.RegularExpressions; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Localization; +using Resgrid.Localization.Areas.User.Moderation; + +namespace Resgrid.Tests.Localization +{ + [TestFixture] + public class ModerationLocalizationTests + { + private static readonly string[] SupportedCultures = SupportedLocales.GetSupportedCultures(); + + private static readonly string[] RepresentativeTranslatedKeys = + { + "ReportsDescription", + "CompletionSubject", + "ModerationReportStatus", + "MessageRetention", + "ReportForModeration", + "ContentCouldNotBeRemoved" + }; + + private static readonly string[] CallReportKeys = + { + "FlagCallNoteHeader", + "FlagCallImageHeader", + "CallNoteTextLabel", + "CallNoteAddedOnLabel", + "CallNoteAddedByLabel", + "CallImageTimestampLabel", + "CallImageAddedByLabel", + "FileName", + "FlaggedReasonLabel", + "FlaggedReasonPlaceholder" + }; + + [Test] + public void EverySupportedCultureContainsEveryModerationKey() + { + var english = ModerationResources.GetAll("en"); + english.Should().NotBeEmpty(); + + foreach (var culture in SupportedCultures) + { + var resources = ModerationResources.GetAll(culture); + resources.Keys.Should().BeEquivalentTo(english.Keys, + $"the {culture} moderation resources must not fall back to English because of missing keys"); + resources.Values.Should().OnlyContain(value => !string.IsNullOrWhiteSpace(value)); + AssertFormatPlaceholdersMatch(english, resources, culture); + } + } + + [Test] + public void EveryNonEnglishCultureContainsActualTranslations() + { + var english = ModerationResources.GetAll("en"); + + foreach (var culture in SupportedCultures.Where(x => x != "en")) + { + var resources = ModerationResources.GetAll(culture); + foreach (var key in RepresentativeTranslatedKeys) + resources[key].Should().NotBe(english[key], $"{key} must be translated for {culture}"); + } + } + + [Test] + public void ExplicitCultureFormatsReporterNotificationWithoutUsingThreadCulture() + { + ModerationResources.Get("CompletionSubject", "es") + .Should().Be("Solicitud de moderación completada"); + ModerationResources.Get("CompletionBody", "es", "mensaje", "42", + "El contenido reportado fue eliminado.", string.Empty) + .Should().Contain("mensaje 42").And.Contain("se ha completado"); + ModerationResources.Get("CompletionSubject", "unsupported") + .Should().Be(ModerationResources.Get("CompletionSubject", "en")); + ModerationResources.Get("CompletionSubject", null) + .Should().Be(ModerationResources.Get("CompletionSubject", "en")); + } + + [Test] + public void ExistingModerationEntryPointsAreBroadAndTranslated() + { + var assembly = typeof(Common).Assembly; + var commonResources = new ResourceManager(typeof(Common).FullName!, assembly); + var callResources = new ResourceManager( + typeof(Resgrid.Localization.Areas.User.Dispatch.Call).FullName!, assembly); + var englishCallValues = CallReportKeys.ToDictionary(key => key, + key => callResources.GetString(key, CultureInfo.GetCultureInfo("en"))); + + commonResources.GetString("ChatModerationModule", CultureInfo.GetCultureInfo("en")) + .Should().Be("Moderation"); + + foreach (var culture in SupportedCultures.Where(x => x != "en")) + { + var cultureInfo = CultureInfo.GetCultureInfo(culture); + commonResources.GetString("ChatModerationModule", cultureInfo) + .Should().NotBeNullOrWhiteSpace().And.NotBe("Chat Moderation"); + + foreach (var key in CallReportKeys) + { + var value = callResources.GetString(key, cultureInfo); + value.Should().NotBeNullOrWhiteSpace().And.NotBe(englishCallValues[key], + $"{key} must be translated for {culture}"); + } + } + } + + private static void AssertFormatPlaceholdersMatch(IReadOnlyDictionary english, + IReadOnlyDictionary translated, string culture) + { + foreach (var pair in english) + { + var expected = Regex.Matches(pair.Value, @"\{\d+\}").Select(x => x.Value).OrderBy(x => x); + var actual = Regex.Matches(translated[pair.Key], @"\{\d+\}").Select(x => x.Value).OrderBy(x => x); + actual.Should().Equal(expected, $"format placeholders for {pair.Key} must match in {culture}"); + } + } + } +} diff --git a/Tests/Resgrid.Tests/Models/FormAutomationTests.cs b/Tests/Resgrid.Tests/Models/FormAutomationTests.cs new file mode 100644 index 000000000..e5db61705 --- /dev/null +++ b/Tests/Resgrid.Tests/Models/FormAutomationTests.cs @@ -0,0 +1,35 @@ +using System; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Repositories.DataRepository.Extensions; +using Resgrid.Repositories.DataRepository.Servers.SqlServer; + +namespace Resgrid.Tests.Models +{ + [TestFixture] + public class FormAutomationTests + { + [Test] + public void PersistenceMetadata_UsesStringIdAndExcludesNonColumnProperties() + { + var automation = new FormAutomation + { + FormAutomationId = Guid.NewGuid().ToString(), + FormId = Guid.NewGuid().ToString(), + Form = new Form() + }; + + var columns = automation + .GetColumns(new SqlServerConfiguration(), ignoreProperties: automation.IgnoredProperties) + .ToList(); + + automation.IdType.Should().Be(1); + columns.Should().Contain(x => x.Equals("[FormAutomationId]", StringComparison.OrdinalIgnoreCase)); + columns.Should().Contain(x => x.Equals("[FormId]", StringComparison.OrdinalIgnoreCase)); + columns.Should().NotContain(x => x.Contains("IdType", StringComparison.OrdinalIgnoreCase)); + columns.Should().NotContain(x => x.Equals("[Form]", StringComparison.OrdinalIgnoreCase)); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/MessageServiceInboxTests.cs b/Tests/Resgrid.Tests/Services/MessageServiceInboxTests.cs index 6b3982c69..066053a42 100644 --- a/Tests/Resgrid.Tests/Services/MessageServiceInboxTests.cs +++ b/Tests/Resgrid.Tests/Services/MessageServiceInboxTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Moq; @@ -19,6 +20,33 @@ namespace Resgrid.Tests.Services [TestFixture] public class MessageServiceInboxTests { + [Test] + public async Task SaveMessageTruncatesValuesToDatabaseColumnLengths() + { + var repository = new Mock(); + repository + .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Message savedMessage, CancellationToken _, bool _) => savedMessage); + var service = new MessageService(repository.Object, null, null, null, null, null); + var message = new Message + { + Subject = new string('s', Message.MaximumSubjectLength + 1), + Body = new string('b', Message.MaximumBodyLength + 1), + SentOn = DateTime.UtcNow + }; + + var savedMessage = await service.SaveMessageAsync(message); + + savedMessage.Subject.Should().HaveLength(Message.MaximumSubjectLength); + savedMessage.Body.Should().HaveLength(Message.MaximumBodyLength); + repository.Verify( + x => x.SaveOrUpdateAsync( + It.Is(value => value.Subject.Length == Message.MaximumSubjectLength && value.Body.Length == Message.MaximumBodyLength), + It.IsAny(), + It.IsAny()), + Times.Once); + } + [Test] public async Task InboxAndUnreadCountExcludeExpiredMessages() { diff --git a/Tests/Resgrid.Tests/Services/ModerationServiceTests.cs b/Tests/Resgrid.Tests/Services/ModerationServiceTests.cs new file mode 100644 index 000000000..be4c5039a --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ModerationServiceTests.cs @@ -0,0 +1,269 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class ModerationServiceTests + { + private Mock _requests; + private Mock _reports; + private Mock _actions; + private Mock _chatMessages; + private Mock _chatAttachments; + private Mock _chatChannels; + private Mock _chatPermissions; + private Mock _chatMessageService; + private Mock _messages; + private Mock _callNotes; + private Mock _callAttachments; + private Mock _calls; + private Mock _groups; + private Mock _authorization; + private Mock _audit; + private Mock _userProfiles; + + [SetUp] + public void SetUp() + { + _requests = new Mock(); + _reports = new Mock(); + _actions = new Mock(); + _chatMessages = new Mock(); + _chatAttachments = new Mock(); + _chatChannels = new Mock(); + _chatPermissions = new Mock(); + _chatMessageService = new Mock(); + _messages = new Mock(); + _callNotes = new Mock(); + _callAttachments = new Mock(); + _calls = new Mock(); + _groups = new Mock(); + _authorization = new Mock(); + _audit = new Mock(); + _userProfiles = new Mock(); + + _actions.Setup(x => x.GetByRequestAsync(It.IsAny())) + .ReturnsAsync(new List()); + _actions.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ModerationAction value, CancellationToken _, bool _) => value); + _audit.Setup(x => x.SaveAuditLogAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((AuditLog value, CancellationToken _) => value); + _userProfiles.Setup(x => x.GetProfileByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((string userId, bool _) => new UserProfile { UserId = userId, Language = "en" }); + } + + [Test] + public async Task MultipleReportersShareOneRequestAndOnlyFirstActionStoresEvidence() + { + ModerationRequest storedRequest = null; + var storedReports = new List(); + var storedActions = new List(); + var sourceMessage = new Message + { + MessageId = 42, + SendingUserId = "author", + Subject = "Original subject", + Body = "Original body", + SentOn = DateTime.UtcNow + }; + + _authorization.Setup(x => x.CanUserViewMessageAsync(It.IsAny(), 42)).ReturnsAsync(true); + _messages.Setup(x => x.GetMessageByIdAsync(42)).ReturnsAsync(sourceMessage); + _requests.Setup(x => x.GetByItemAsync(7, (int)ModerationItemType.Message, "42")) + .ReturnsAsync(() => storedRequest); + _requests.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ModerationRequest value, CancellationToken _, bool _) => storedRequest = value); + _requests.Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ModerationRequest value, CancellationToken _, bool _) => value); + _reports.Setup(x => x.GetByRequestAndReporterAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((string requestId, string userId) => storedReports.FirstOrDefault(x => + x.ModerationRequestId == requestId && x.ReportedByUserId == userId)); + _reports.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ModerationReport value, CancellationToken _, bool _) => + { + storedReports.Add(value); + return value; + }); + _groups.Setup(x => x.GetGroupMemberForUserAsync(It.IsAny(), 7)) + .ReturnsAsync((string userId, int _) => new DepartmentGroupMember + { + DepartmentId = 7, + DepartmentGroupId = userId == "reporter-a" ? 10 : 20, + UserId = userId + }); + _actions.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ModerationAction value, CancellationToken _, bool _) => + { + storedActions.Add(value); + return value; + }); + + var service = CreateService(); + var first = await service.FlagAsync(7, "reporter-a", ModerationItemType.Message, "42", + ModerationReason.Harassment, "First report"); + var second = await service.FlagAsync(7, "reporter-b", ModerationItemType.Message, "42", + ModerationReason.Spam, "Second report"); + + first.ModerationRequestId.Should().Be(second.ModerationRequestId); + storedReports.Should().HaveCount(2); + storedRequest.OriginalSubject.Should().Be("Original subject"); + storedRequest.OriginalText.Should().Be("Original body"); + storedActions.Should().HaveCount(2); + storedActions.Count(x => x.EvidenceText == "Original body").Should().Be(1); + _requests.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task RemovingMessageRetainsEvidenceAndNotifiesReportersExceptContentAuthor() + { + var request = CreateRequest(); + var reports = new List + { + CreateReport(request, "reporter", 10), + CreateReport(request, "author", 10) + }; + var liveMessage = new Message { MessageId = 42, Subject = "Live subject", Body = "Live body" }; + var savedMessages = new List(); + + _requests.Setup(x => x.GetByIdAsync(request.ModerationRequestId)).ReturnsAsync(request); + _requests.Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ModerationRequest value, CancellationToken _, bool _) => value); + _reports.Setup(x => x.GetByRequestAsync(request.ModerationRequestId)).ReturnsAsync(reports); + _authorization.Setup(x => x.CanUserModifyDepartmentAsync("department-admin", 7)).ReturnsAsync(true); + _messages.Setup(x => x.GetMessageByIdAsync(42)).ReturnsAsync(liveMessage); + _messages.Setup(x => x.SaveMessageAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Message value, CancellationToken _) => + { + savedMessages.Add(value); + return value; + }); + _messages.Setup(x => x.SendMessageAsync(It.IsAny(), "System", 7, false, It.IsAny())) + .ReturnsAsync(true); + _userProfiles.Setup(x => x.GetProfileByUserIdAsync("reporter", It.IsAny())) + .ReturnsAsync(new UserProfile { UserId = "reporter", Language = "es" }); + + var service = CreateService(); + var completed = await service.CompleteRequestAsync(request.ModerationRequestId, 7, + "department-admin", ModerationDisposition.ContentRemoved, "Confirmed policy violation"); + var evidenceAccessRecorded = await service.RecordEvidenceAccessAsync(request.ModerationRequestId, 7, + "department-admin"); + + liveMessage.Subject.Should().Be(ModerationService.ModeratedMessageSubject); + liveMessage.Body.Should().Be(ModerationService.ModeratedMessageBody); + completed.OriginalText.Should().Be("Permanent original body"); + completed.Status.Should().Be((int)ModerationRequestStatus.Completed); + evidenceAccessRecorded.Should().BeTrue(); + savedMessages.Should().ContainSingle(x => x.ReceivingUserId == "reporter" && + x.Subject == "Solicitud de moderación completada" && + x.Body.Contains("Confirmed policy violation") && + x.Body.Contains("se ha completado")); + savedMessages.Should().NotContain(x => x.ReceivingUserId == "author"); + _messages.Verify(x => x.SendMessageAsync( + It.Is(m => m.ReceivingUserId == "reporter"), "Sistema", 7, false, + It.IsAny()), Times.Once); + _actions.Verify(x => x.InsertAsync( + It.Is(a => a.ActionType == (int)ModerationActionType.EvidenceDownloaded), + It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task GroupAdminSearchHidesReportsFromOtherGroupsButKeepsCompletionAudit() + { + var request = CreateRequest(); + var reports = new List + { + CreateReport(request, "group-10-user", 10), + CreateReport(request, "group-20-user", 20) + }; + var actions = new List + { + CreateAction(request, ModerationActionType.ReportSubmitted, "group-10-user"), + CreateAction(request, ModerationActionType.ReportSubmitted, "group-20-user"), + CreateAction(request, ModerationActionType.CompletedNoAction, "group-20-admin") + }; + + _authorization.Setup(x => x.CanUserModifyDepartmentAsync("group-10-admin", 7)).ReturnsAsync(false); + _groups.Setup(x => x.GetAllGroupAdminsByDepartmentIdAsync(7)).ReturnsAsync(new List + { + new DepartmentGroupMember { DepartmentId = 7, DepartmentGroupId = 10, UserId = "group-10-admin", IsAdmin = true } + }); + _requests.Setup(x => x.SearchAsync(7, It.IsAny(), + It.Is>(ids => ids.SequenceEqual(new[] { 10 })), "group-10-admin")) + .ReturnsAsync(new[] { request }); + _reports.Setup(x => x.GetByRequestAsync(request.ModerationRequestId)).ReturnsAsync(reports); + _actions.Setup(x => x.GetByRequestAsync(request.ModerationRequestId)).ReturnsAsync(actions); + + var result = await CreateService().SearchRequestsAsync(7, "group-10-admin", + new ModerationSearchCriteria { Page = 1, PageSize = 50 }); + + result.Should().ContainSingle(); + result[0].Reports.Should().ContainSingle(x => x.ReportedByUserId == "group-10-user"); + result[0].Reports.Should().NotContain(x => x.ReportedByUserId == "group-20-user"); + result[0].Actions.Should().Contain(x => x.ActionType == (int)ModerationActionType.CompletedNoAction); + result[0].Actions.Should().NotContain(x => x.PerformedByUserId == "group-20-user"); + } + + private ModerationService CreateService() + { + return new ModerationService(_requests.Object, _reports.Object, _actions.Object, + _chatMessages.Object, _chatAttachments.Object, _chatChannels.Object, _chatPermissions.Object, + _chatMessageService.Object, _messages.Object, _callNotes.Object, _callAttachments.Object, + _calls.Object, _groups.Object, _authorization.Object, _audit.Object, _userProfiles.Object); + } + + private static ModerationRequest CreateRequest() + { + return new ModerationRequest + { + ModerationRequestId = "request-1", + DepartmentId = 7, + ItemType = (int)ModerationItemType.Message, + ItemId = "42", + ContentAuthorUserId = "author", + OriginalSubject = "Permanent original subject", + OriginalText = "Permanent original body", + Status = (int)ModerationRequestStatus.Pending, + Disposition = (int)ModerationDisposition.None, + CreatedOn = DateTime.UtcNow, + ModifiedOn = DateTime.UtcNow + }; + } + + private static ModerationReport CreateReport(ModerationRequest request, string userId, int groupId) + { + return new ModerationReport + { + ModerationReportId = Guid.NewGuid().ToString(), + ModerationRequestId = request.ModerationRequestId, + DepartmentId = request.DepartmentId, + ReportedByUserId = userId, + ReporterGroupId = groupId, + ReportedOn = DateTime.UtcNow + }; + } + + private static ModerationAction CreateAction(ModerationRequest request, ModerationActionType type, string userId) + { + return new ModerationAction + { + ModerationActionId = Guid.NewGuid().ToString(), + ModerationRequestId = request.ModerationRequestId, + DepartmentId = request.DepartmentId, + ActionType = (int)type, + PerformedByUserId = userId, + PerformedOn = DateTime.UtcNow + }; + } + } +} diff --git a/Tests/Resgrid.Tests/Services/NotificationServiceTests.cs b/Tests/Resgrid.Tests/Services/NotificationServiceTests.cs index 949b4de32..5f4e4cf6f 100644 --- a/Tests/Resgrid.Tests/Services/NotificationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/NotificationServiceTests.cs @@ -752,6 +752,33 @@ public async Task should_not_process_incorrect_after_data() result.Should().BeFalse(); } + [TestCase("invalid", "-1")] + [TestCase("-1", "invalid")] + [TestCase("-1", "invalid-1")] + public async Task should_not_process_non_numeric_filter_data(string beforeData, string currentData) + { + var notification = new DepartmentNotification + { + EventType = (int)EventTypes.UnitStatusChanged, + Everyone = true, + DepartmentId = 1, + BeforeData = beforeData, + CurrentData = currentData + }; + + var processedNotification = new ProcessedNotification + { + DepartmentId = 1, + MessageId = "123456", + Data = new NotificationItem() { StateId = 3, DepartmentId = 1, PreviousStateId = 2 }.SerializeProto(), + Type = EventTypes.UnitStatusChanged + }; + + var result = await _notificationServiceMock.ValidateNotificationForProcessingAsync(processedNotification, notification); + + result.Should().BeFalse(); + } + [Test] public async Task should_process_when_before_and_current_data_are_empty() { diff --git a/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs new file mode 100644 index 000000000..78425f524 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs @@ -0,0 +1,83 @@ +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Controllers.v4; +using Resgrid.Web.Services.Models.v4.Calls; + +namespace Resgrid.Tests.Web.Services +{ + [TestFixture] + public class CallsControllerTests + { + private Mock _callsService; + private Mock _authorizationService; + private CallsController _controller; + + [SetUp] + public void SetUp() + { + _callsService = new Mock(); + _authorizationService = new Mock(); + _controller = new CallsController( + _callsService.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + _authorizationService.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + } + + [TestCase(null)] + [TestCase("")] + [TestCase("u3246")] + [TestCase("2147483648")] + public async Task GetCall_ReturnsBadRequest_WhenCallIdIsInvalid(string callId) + { + var response = await _controller.GetCall(callId); + + response.Result.Should().BeOfType(); + _callsService.Verify( + service => service.GetCallByIdAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestCase(null)] + [TestCase("")] + [TestCase("not-a-number")] + [TestCase("2147483648")] + public async Task EditCall_ReturnsBadRequest_WhenIdIsInvalid(string id) + { + var response = await _controller.EditCall(new EditCallInput { Id = id }, CancellationToken.None); + + response.Result.Should().BeOfType(); + _authorizationService.Verify( + service => service.CanUserEditCallAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/MessagesControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/MessagesControllerTests.cs new file mode 100644 index 000000000..5f1d1143d --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/MessagesControllerTests.cs @@ -0,0 +1,126 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Controllers.v4; +using Resgrid.Web.Services.Models.v4.Messages; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Tests.Web.Services +{ + [TestFixture] + [NonParallelizable] + public class MessagesControllerTests + { + private const int DepartmentId = 10; + private const int RoleId = 6727; + private const string SenderUserId = "sender-user"; + private const string RecipientUserId = "recipient-user"; + + private Mock _departmentsService; + private Mock _departmentGroupsService; + private Mock _personnelRolesService; + private Mock _messageService; + private MessagesController _controller; + private Activity _activity; + + [SetUp] + public void SetUp() + { + _departmentsService = new Mock(); + _departmentGroupsService = new Mock(); + _personnelRolesService = new Mock(); + _messageService = new Mock(); + + _departmentsService + .Setup(service => service.GetAllMembersForDepartmentAsync(DepartmentId)) + .ReturnsAsync(new List + { + new DepartmentMember { DepartmentId = DepartmentId, UserId = RecipientUserId } + }); + _departmentGroupsService + .Setup(service => service.GetAllGroupsForDepartmentAsync(DepartmentId)) + .ReturnsAsync(new List()); + _personnelRolesService + .Setup(service => service.GetAllRolesForDepartmentAsync(DepartmentId)) + .ReturnsAsync(new List + { + new PersonnelRole { PersonnelRoleId = RoleId, DepartmentId = DepartmentId, Name = "Test role" } + }); + _personnelRolesService + .Setup(service => service.GetAllMembersOfRoleAsync(RoleId)) + .ReturnsAsync(new List + { + new PersonnelRoleUser { PersonnelRoleId = RoleId, DepartmentId = DepartmentId, UserId = RecipientUserId } + }); + + var httpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, SenderUserId), + new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString()) + }, "test")) + }; + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + _activity = new Activity("MessagesControllerTests").Start(); + + _controller = new MessagesController( + Mock.Of(), + _departmentsService.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + _messageService.Object, + Mock.Of(), + _departmentGroupsService.Object, + _personnelRolesService.Object, + Mock.Of(), + Mock.Of()) + { + ControllerContext = new ControllerContext { HttpContext = httpContext } + }; + } + + [TearDown] + public void TearDown() + { + ClaimsAuthorizationHelper._httpContextAccessor = null; + _activity?.Stop(); + } + + [Test] + public async Task SendMessage_UsesParsedRoleId_ForPrefixedRoleRecipient() + { + Message messageToSave = null; + _messageService + .Setup(service => service.SaveMessageAsync(It.IsAny(), It.IsAny())) + .Callback((message, _) => messageToSave = message) + .ReturnsAsync(new Message { MessageId = 123 }); + + var response = await _controller.SendMessage(new NewMessageInput + { + Title = "Test message", + Body = "Test body", + Recipients = new List + { + new MessageRecipientInput { Id = $"R:{RoleId}", Type = 3, Name = "Test role" } + } + }, CancellationToken.None); + + response.Value.Should().NotBeNull(); + messageToSave.Should().NotBeNull(); + messageToSave.GetRecipients().Should().ContainSingle().Which.Should().Be(RecipientUserId); + _personnelRolesService.Verify(service => service.GetAllMembersOfRoleAsync(RoleId), Times.Once); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/User/SubscriptionControllerTests.cs b/Tests/Resgrid.Tests/Web/User/SubscriptionControllerTests.cs new file mode 100644 index 000000000..7532e98f5 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/User/SubscriptionControllerTests.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Identity; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Web.Areas.User.Controllers; +using Resgrid.Web.Areas.User.Models.Subscription; +using Resgrid.Web.Options; + +namespace Resgrid.Tests.Web.User +{ + [TestFixture] + [NonParallelizable] + public class SubscriptionControllerTests + { + private const int DepartmentId = 10; + private const string UserId = "subscription-admin"; + + [TearDown] + public void TearDown() + { + Resgrid.Web.Helpers.ClaimsAuthorizationHelper._httpContextAccessor = null; + } + + [Test] + public async Task Index_TreatsSerializedMaxDateAsNeverExpiring() + { + var department = new Department + { + DepartmentId = DepartmentId, + Name = "Test Department", + TimeZone = "New Zealand Standard Time" + }; + var payment = new Payment + { + DepartmentId = DepartmentId, + PlanId = 1, + EndingOn = DateTime.MaxValue.AddTicks(-1) + }; + + var departmentsService = new Mock(); + departmentsService.Setup(x => x.GetDepartmentByIdAsync(DepartmentId, false)).ReturnsAsync(department); + departmentsService.Setup(x => x.GetAllUsersForDepartmentUnlimitedMinusDisabledAsync(DepartmentId, false)) + .ReturnsAsync(new List()); + + var subscriptionsService = new Mock(); + subscriptionsService.Setup(x => x.GetCurrentPlanForDepartmentAsync(DepartmentId, false)) + .ReturnsAsync(new Plan { PlanId = 1, Name = "Forever Free", Frequency = (int)PlanFrequency.Never }); + subscriptionsService.Setup(x => x.GetCurrentPaymentForDepartmentAsync(DepartmentId, true)).ReturnsAsync(payment); + subscriptionsService.Setup(x => x.GetAllPaymentsForDepartmentAsync(DepartmentId)) + .ReturnsAsync(new List { payment }); + + var authorizationService = new Mock(); + authorizationService.Setup(x => x.CanUserManageSubscriptionAsync(UserId, DepartmentId)).ReturnsAsync(true); + + var unitsService = new Mock(); + unitsService.Setup(x => x.GetUnitsForDepartmentUnlimitedAsync(DepartmentId)).ReturnsAsync(new List()); + + var departmentSettingsService = new Mock(); + departmentSettingsService.Setup(x => x.GetPaddleCustomerIdForDepartmentAsync(DepartmentId)) + .ReturnsAsync("paddle-customer"); + + var httpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, UserId), + new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString()) + }, "test")) + }; + Resgrid.Web.Helpers.ClaimsAuthorizationHelper._httpContextAccessor = + new HttpContextAccessor { HttpContext = httpContext }; + + var controller = new SubscriptionController( + departmentsService.Object, + Mock.Of(), + Mock.Of(), + authorizationService.Object, + subscriptionsService.Object, + Mock.Of(), + unitsService.Object, + departmentSettingsService.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Options.Create(new AppOptions()), + Mock.Of()) + { + ControllerContext = new ControllerContext { HttpContext = httpContext } + }; + + var result = await controller.Index(); + + var model = result.Should().BeOfType().Subject.Model + .Should().BeOfType().Subject; + model.Expires.Should().Be("Never"); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs index 0d18581a7..a34035634 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs @@ -18,6 +18,7 @@ using System.Threading; using System.Collections.ObjectModel; using System.Collections.Generic; +using System.Globalization; using Resgrid.Model.Events; using Resgrid.Model.Queue; using Resgrid.Web.Services.Models.v4.CallProtocols; @@ -163,11 +164,11 @@ public async Task> GetActiveCalls() [Authorize(Policy = ResgridResources.Call_View)] public async Task> GetCall(string callId, [FromQuery] string departmentId = null) { - if (String.IsNullOrWhiteSpace(callId)) + if (!int.TryParse(callId, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedCallId)) return BadRequest(); var result = new CallResult(); - var c = await _callsService.GetCallByIdAsync(int.Parse(callId)); + var c = await _callsService.GetCallByIdAsync(parsedCallId); if (c == null) { @@ -180,7 +181,7 @@ public async Task> GetCall(string callId, [FromQuery if (c.DepartmentId != effectiveDepartmentId) return Unauthorized(); - if (!IsSystemApiKeyRequest && !await _authorizationService.CanUserViewCallAsync(UserId, int.Parse(callId))) + if (!IsSystemApiKeyRequest && !await _authorizationService.CanUserViewCallAsync(UserId, parsedCallId)) return Unauthorized(); c = await _callsService.PopulateCallData(c, false, true, true, false, false, false, true, true, true); @@ -853,12 +854,16 @@ public async Task> EditCall([FromBody] EditCallInpu { var result = new EditCallResult(); - 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)) + return BadRequest(); + + var canDoOperation = await _authorizationService.CanUserEditCallAsync(UserId, callId); if (!canDoOperation) return Unauthorized(); - var call = await _callsService.GetCallByIdAsync(int.Parse(editCallInput.Id)); + var call = await _callsService.GetCallByIdAsync(callId); call = await _callsService.PopulateCallData(call, true, true, true, true, true, true, true, true, true); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); @@ -869,9 +874,6 @@ public async Task> EditCall([FromBody] EditCallInpu return Ok(result); } - if (!ModelState.IsValid) - return BadRequest(); - if (call.DepartmentId != DepartmentId) return Unauthorized(); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs index 5cfb55749..1e222abf9 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs @@ -43,6 +43,7 @@ public class ChatController : V4AuthenticatedApiControllerbase private readonly IChatPermissionService _chatPermissionService; private readonly IChatMessageService _chatMessageService; private readonly IChatModerationService _chatModerationService; + private readonly IModerationService _moderationService; private readonly IChatPresenceService _chatPresenceService; private readonly IChatAttachmentRepository _chatAttachmentRepository; private readonly IGifProvider _gifProvider; @@ -56,6 +57,7 @@ public ChatController( IChatPermissionService chatPermissionService, IChatMessageService chatMessageService, IChatModerationService chatModerationService, + IModerationService moderationService, IChatPresenceService chatPresenceService, IChatAttachmentRepository chatAttachmentRepository, IGifProvider gifProvider, @@ -68,6 +70,7 @@ public ChatController( _chatPermissionService = chatPermissionService; _chatMessageService = chatMessageService; _chatModerationService = chatModerationService; + _moderationService = moderationService; _chatPresenceService = chatPresenceService; _chatAttachmentRepository = chatAttachmentRepository; _gifProvider = gifProvider; @@ -1276,6 +1279,10 @@ public async Task GetAttachment(string attachmentId) if (attachment == null || attachment.DepartmentId != DepartmentId || attachment.Data == null) return NotFound(); + var message = await _chatMessageService.GetMessageByIdAsync(attachment.ChatMessageId); + if (message == null || message.DeletedOn.HasValue) + return NotFound(); + var channel = await _chatChannelService.GetChannelByIdAsync(attachment.ChatChannelId); if (channel == null || channel.DepartmentId != DepartmentId) return NotFound(); @@ -1304,6 +1311,10 @@ public async Task GetAttachmentThumbnail(string attachmentId) if (attachment == null || attachment.DepartmentId != DepartmentId || (attachment.ThumbnailData == null && attachment.Data == null)) return NotFound(); + var message = await _chatMessageService.GetMessageByIdAsync(attachment.ChatMessageId); + if (message == null || message.DeletedOn.HasValue) + return NotFound(); + var channel = await _chatChannelService.GetChannelByIdAsync(attachment.ChatChannelId); if (channel == null || channel.DepartmentId != DepartmentId) return NotFound(); @@ -1463,7 +1474,9 @@ public async Task> FlagMessage(string messageId, return accessCheck; var result = new ChatActionResult(); - var flag = await _chatModerationService.FlagMessageAsync(messageId, UserId, (ChatFlagReason)input.Reason, input.Note, cancellationToken); + var flag = await _moderationService.FlagAsync(DepartmentId, UserId, + ModerationItemType.ChatMessage, messageId, (ModerationReason)input.Reason, input.Note, + BuildModerationContext("Reporter"), cancellationToken); result.Success = flag != null; result.Status = result.Success ? ResponseHelper.Created : ResponseHelper.Failure; @@ -1626,6 +1639,7 @@ private static ChatMessageResultData ConvertMessageResultData(ChatMessage messag EditedOn = message.EditedOn, DeletedOn = message.DeletedOn, DeletedByUserId = includeModeratorInternals ? message.DeletedByUserId : null, + IsModerated = message.IsModerated, PinnedOn = message.PinnedOn, PinnedByUserId = includeModeratorInternals ? message.PinnedByUserId : null }; @@ -1720,6 +1734,17 @@ private static ChatAckResultData ConvertAckResultData(ChatMessageAck ack) }; } + private ChatModerationContext BuildModerationContext(string actorRole) + { + return new ChatModerationContext + { + IpAddress = HttpContext?.Connection?.RemoteIpAddress?.ToString(), + UserAgent = Request?.Headers["User-Agent"].ToString(), + TraceId = HttpContext?.TraceIdentifier, + ActorRole = actorRole + }; + } + #endregion Private Helpers } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/MessagesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/MessagesController.cs index eceb44a32..a6fab4612 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/MessagesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/MessagesController.cs @@ -385,7 +385,7 @@ public async Task> SendMessage([FromBody] NewMes if (departmentRoles.Any(x => x.PersonnelRoleId == roleId)) { var roleMembers = - await _personnelRolesService.GetAllMembersOfRoleAsync(int.Parse(role.Id)); + await _personnelRolesService.GetAllMembersOfRoleAsync(roleId); foreach (var member in roleMembers) { diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ModerationController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ModerationController.cs new file mode 100644 index 000000000..090d15299 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/ModerationController.cs @@ -0,0 +1,278 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Moderation; +using IAuthorizationService = Resgrid.Model.Services.IAuthorizationService; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// Department and group-scoped moderation requests across supported content types. + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class ModerationController : V4AuthenticatedApiControllerbase + { + private readonly IModerationService _moderationService; + private readonly IAuthorizationService _authorizationService; + + public ModerationController(IModerationService moderationService, IAuthorizationService authorizationService) + { + _moderationService = moderationService; + _authorizationService = authorizationService; + } + + /// Reports an accessible chat message, Message, call note or call image. + [HttpPost("Flag")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> Flag([FromBody] FlagModerationInput input, + CancellationToken cancellationToken) + { + if (!ModelState.IsValid || input == null) + return BadRequest(); + + try + { + var report = await _moderationService.FlagAsync(DepartmentId, UserId, + (ModerationItemType)input.ItemType, input.ItemId, (ModerationReason)input.Reason, + input.Note, BuildContext("Reporter"), cancellationToken); + var request = await _moderationService.GetReporterRequestAsync(DepartmentId, UserId, + (ModerationItemType)input.ItemType, input.ItemId); + var result = new ModerationActionResult + { + Success = report != null, + Data = ConvertRequest(request, false), + Status = report != null ? ResponseHelper.Created : ResponseHelper.Failure + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (UnauthorizedAccessException) + { + return Unauthorized(); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// Returns the caller's private flag status for one content item. + [HttpGet("GetMyStatus")] + public async Task> GetMyStatus(int itemType, string itemId) + { + if (!Enum.IsDefined(typeof(ModerationItemType), itemType) || string.IsNullOrWhiteSpace(itemId)) + return BadRequest(); + + var request = await _moderationService.GetReporterRequestAsync(DepartmentId, UserId, + (ModerationItemType)itemType, itemId); + if (request == null) + return NotFound(); + + var result = new GetModerationRequestResult + { + Data = ConvertRequest(request, false), + Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Scoped dashboard/report search for pending and completed moderation requests. + [HttpGet("GetRequests")] + public async Task> GetRequests(int? status = null, + int? itemType = null, string contentAuthorUserId = null, string reportedByUserId = null, + DateTime? from = null, DateTime? to = null, int page = 1, int pageSize = 50) + { + if (!await _moderationService.CanModerateAsync(DepartmentId, UserId)) + return Unauthorized(); + + if (status.HasValue && !Enum.IsDefined(typeof(ModerationRequestStatus), status.Value)) + return BadRequest(); + if (itemType.HasValue && !Enum.IsDefined(typeof(ModerationItemType), itemType.Value)) + return BadRequest(); + + var requests = await _moderationService.SearchRequestsAsync(DepartmentId, UserId, + new ModerationSearchCriteria + { + Status = status.HasValue ? (ModerationRequestStatus?)status.Value : null, + ItemType = itemType.HasValue ? (ModerationItemType?)itemType.Value : null, + ContentAuthorUserId = contentAuthorUserId, + ReportedByUserId = reportedByUserId, + From = from, + To = to, + Page = page, + PageSize = pageSize + }); + + var result = new GetModerationRequestsResult + { + Data = requests.Select(x => ConvertRequest(x, true)).ToList(), + Page = Math.Max(page, 1), + PageSize = requests.Count, + Status = requests.Count > 0 ? ResponseHelper.Success : ResponseHelper.NotFound + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Returns a scoped moderation request and its complete action history. + [HttpGet("GetRequest")] + public async Task> GetRequest(string requestId) + { + if (!await _moderationService.CanModerateAsync(DepartmentId, UserId)) + return Unauthorized(); + + var request = await _moderationService.GetRequestAsync(requestId, DepartmentId, UserId); + if (request == null) + return NotFound(); + + var result = new GetModerationRequestResult + { + Data = ConvertRequest(request, true), + Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Completes a scoped request with no action or by removing the live content. + [HttpPost("Complete")] + public async Task> Complete(string requestId, + [FromBody] CompleteModerationInput input, CancellationToken cancellationToken) + { + if (!ModelState.IsValid || input == null || string.IsNullOrWhiteSpace(requestId)) + return BadRequest(); + if (!await _moderationService.CanModerateAsync(DepartmentId, UserId)) + return Unauthorized(); + + var isDepartmentAdmin = await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId); + try + { + var request = await _moderationService.CompleteRequestAsync(requestId, DepartmentId, UserId, + (ModerationDisposition)input.Disposition, input.AdminNote, + BuildContext(isDepartmentAdmin ? "DepartmentAdmin" : "GroupAdmin"), cancellationToken); + if (request == null) + return NotFound(); + + var result = new ModerationActionResult + { + Success = true, + Data = ConvertRequest(request, true), + Status = ResponseHelper.Updated + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (UnauthorizedAccessException) + { + return Unauthorized(); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// Downloads permanently retained image evidence for an authorized administrator. + [HttpGet("DownloadEvidence")] + public async Task DownloadEvidence(string requestId) + { + if (!await _moderationService.CanModerateAsync(DepartmentId, UserId)) + return Unauthorized(); + + var request = await _moderationService.GetRequestAsync(requestId, DepartmentId, UserId); + if (request?.OriginalContent == null) + return NotFound(); + + var isDepartmentAdmin = await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId); + await _moderationService.RecordEvidenceAccessAsync(requestId, DepartmentId, UserId, + BuildContext(isDepartmentAdmin ? "DepartmentAdmin" : "GroupAdmin")); + + return File(request.OriginalContent, + string.IsNullOrWhiteSpace(request.OriginalContentType) ? "application/octet-stream" : request.OriginalContentType, + string.IsNullOrWhiteSpace(request.OriginalFileName) ? $"moderation-evidence-{requestId}" : request.OriginalFileName); + } + + private ChatModerationContext BuildContext(string actorRole) + { + return new ChatModerationContext + { + IpAddress = HttpContext?.Connection?.RemoteIpAddress?.ToString(), + UserAgent = Request?.Headers != null ? Request.Headers["User-Agent"].ToString() : null, + TraceId = HttpContext?.TraceIdentifier, + ActorRole = actorRole + }; + } + + private static ModerationRequestResultData ConvertRequest(ModerationRequest request, bool includeAudit) + { + if (request == null) + return null; + + return new ModerationRequestResultData + { + ModerationRequestId = request.ModerationRequestId, + ItemType = request.ItemType, + ItemId = request.ItemId, + CallId = request.CallId, + ChatChannelId = request.ChatChannelId, + ContentAuthorUserId = request.ContentAuthorUserId, + ContentAuthorUnitId = request.ContentAuthorUnitId, + ContentCreatedOn = request.ContentCreatedOn, + OriginalSubject = request.OriginalSubject, + OriginalText = request.OriginalText, + OriginalFileName = request.OriginalFileName, + OriginalContentType = request.OriginalContentType, + HasOriginalContent = request.OriginalContent != null || !string.IsNullOrWhiteSpace(request.OriginalFileName), + OriginalMetadataJson = request.OriginalMetadataJson, + Status = request.Status, + Disposition = request.Disposition, + CreatedOn = request.CreatedOn, + ModifiedOn = request.ModifiedOn, + CompletedByUserId = request.CompletedByUserId, + CompletedOn = request.CompletedOn, + AdminNote = request.AdminNote, + Reports = request.Reports.Select(x => new ModerationReportResultData + { + ModerationReportId = x.ModerationReportId, + ReportedByUserId = x.ReportedByUserId, + ReporterGroupId = x.ReporterGroupId, + Reason = x.Reason, + Note = x.Note, + ReportedOn = x.ReportedOn + }).ToList(), + Actions = includeAudit + ? request.Actions.Select(x => new ModerationActionResultData + { + ModerationActionId = x.ModerationActionId, + ActionType = x.ActionType, + PerformedByUserId = x.PerformedByUserId, + PerformedOn = x.PerformedOn, + Note = x.Note, + PreviousStatus = x.PreviousStatus, + NewStatus = x.NewStatus, + ActorRole = x.ActorRole, + IpAddress = x.IpAddress, + UserAgent = x.UserAgent, + TraceId = x.TraceId, + ServerName = x.ServerName, + DetailsJson = x.DetailsJson, + HasEvidence = !string.IsNullOrWhiteSpace(x.EvidenceText) || !string.IsNullOrWhiteSpace(x.EvidenceMetadataJson) + }).ToList() + : new System.Collections.Generic.List() + }; + } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs index 080770931..0bdfce2da 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs @@ -13,6 +13,7 @@ public class EditCallInput /// /// Id of the call to update /// + [Required] public string Id { get; set; } /// diff --git a/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs index 6d7e5e7e0..fc3fbf221 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs @@ -534,6 +534,11 @@ public class ChatMessageResultData /// public string DeletedByUserId { get; set; } + /// + /// Whether the tombstone represents a moderation action + /// + public bool IsModerated { get; set; } + /// /// When the message was pinned /// diff --git a/Web/Resgrid.Web.Services/Models/v4/Moderation/ModerationApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Moderation/ModerationApiModels.cs new file mode 100644 index 000000000..9309e5a28 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Moderation/ModerationApiModels.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Resgrid.Web.Services.Models.v4.Moderation +{ + public class GetModerationRequestsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class GetModerationRequestResult : StandardApiResponseV4Base + { + public ModerationRequestResultData Data { get; set; } + } + + public class ModerationActionResult : StandardApiResponseV4Base + { + public bool Success { get; set; } + public ModerationRequestResultData Data { get; set; } + } + + public class ModerationRequestResultData + { + public string ModerationRequestId { get; set; } + public int ItemType { get; set; } + public string ItemId { get; set; } + public int? CallId { get; set; } + public string ChatChannelId { get; set; } + public string ContentAuthorUserId { get; set; } + public int? ContentAuthorUnitId { get; set; } + public DateTime? ContentCreatedOn { get; set; } + public string OriginalSubject { get; set; } + public string OriginalText { get; set; } + public string OriginalFileName { get; set; } + public string OriginalContentType { get; set; } + public bool HasOriginalContent { get; set; } + public string OriginalMetadataJson { get; set; } + public int Status { get; set; } + public int Disposition { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public string CompletedByUserId { get; set; } + public DateTime? CompletedOn { get; set; } + public string AdminNote { get; set; } + public List Reports { get; set; } = new List(); + public List Actions { get; set; } = new List(); + } + + public class ModerationReportResultData + { + public string ModerationReportId { get; set; } + public string ReportedByUserId { get; set; } + public int? ReporterGroupId { get; set; } + public int Reason { get; set; } + public string Note { get; set; } + public DateTime ReportedOn { get; set; } + } + + public class ModerationActionResultData + { + public string ModerationActionId { get; set; } + public int ActionType { get; set; } + public string PerformedByUserId { get; set; } + public DateTime PerformedOn { get; set; } + public string Note { get; set; } + public int? PreviousStatus { get; set; } + public int? NewStatus { get; set; } + public string ActorRole { get; set; } + public string IpAddress { get; set; } + public string UserAgent { get; set; } + public string TraceId { get; set; } + public string ServerName { get; set; } + public string DetailsJson { get; set; } + public bool HasEvidence { get; set; } + } + + public class FlagModerationInput + { + [Range(0, 3)] + public int ItemType { get; set; } + + [Required] + [StringLength(128)] + public string ItemId { get; set; } + + [Range(0, 5)] + public int Reason { get; set; } + + [StringLength(4000)] + public string Note { get; set; } + } + + public class CompleteModerationInput + { + [Range(1, 2)] + public int Disposition { get; set; } + + [StringLength(4000)] + public string AdminNote { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 506b1a604..7563f99e1 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -1889,6 +1889,27 @@ MessageId of the message to delete Returns OK status code if successful + + Department and group-scoped moderation requests across supported content types. + + + Reports an accessible chat message, Message, call note or call image. + + + Returns the caller's private flag status for one content item. + + + Scoped dashboard/report search for pending and completed moderation requests. + + + Returns a scoped moderation request and its complete action history. + + + Completes a scoped request with no action or by removing the live content. + + + Downloads permanently retained image evidence for an authorized administrator. + Mutual-aid resource aggregation for incident command: own-department + linked-department units/personnel, @@ -7442,6 +7463,11 @@ Who deleted the message + + + Whether the tombstone represents a moderation action + + When the message was pinned diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatModerationElement.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatModerationElement.tsx index 2c60a1a2f..771026664 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatModerationElement.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatModerationElement.tsx @@ -1,31 +1,37 @@ import { useState } from 'react'; import './chat.css'; import FlagsTab from './moderation/FlagsTab'; +import ReportsTab from './moderation/ReportsTab'; import ActionsTab from './moderation/ActionsTab'; import SettingsTab from './moderation/SettingsTab'; import ExportsTab from './moderation/ExportsTab'; +import { moderationText } from './moderationI18n'; -type ModTab = 'flags' | 'actions' | 'settings' | 'exports'; - -const TABS: { key: ModTab; label: string }[] = [ - { key: 'flags', label: 'Flags' }, - { key: 'actions', label: 'Actions log' }, - { key: 'settings', label: 'Settings' }, - { key: 'exports', label: 'Exports' }, -]; +type ModTab = 'requests' | 'reports' | 'actions' | 'settings' | 'exports'; export interface ChatModerationElementProps { hostElement?: HTMLElement; + departmentAdmin?: boolean; } -export default function ChatModerationElement(_props: ChatModerationElementProps) { - const [tab, setTab] = useState('flags'); +export default function ChatModerationElement({ departmentAdmin = false }: ChatModerationElementProps) { + const [tab, setTab] = useState('requests'); + const sharedTabs: { key: ModTab; label: string }[] = [ + { key: 'requests', label: moderationText('TabRequests') }, + { key: 'reports', label: moderationText('TabReports') }, + ]; + const departmentTabs: { key: ModTab; label: string }[] = [ + { key: 'actions', label: moderationText('TabChatControls') }, + { key: 'settings', label: moderationText('TabChatSettings') }, + { key: 'exports', label: moderationText('TabChatExports') }, + ]; + const tabs = departmentAdmin ? [...sharedTabs, ...departmentTabs] : sharedTabs; return (
- {TABS.map((item) => ( + {tabs.map((item) => ( - - + existingRequest ? ( + + ) : ( + <> + + + + ) } > -
- - -
-