From c53ae88e0877ad4a17be02e66cdf75ba543916ad Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 3 Sep 2026 13:25:31 -0700 Subject: [PATCH] RG-T55 RMS First pass --- .../AttachmentScanningConfig.cs | 31 + Core/Resgrid.Config/SearchConfig.cs | 28 + .../Areas/User/Department/Department.ar.resx | 93 ++ .../Areas/User/Department/Department.de.resx | 93 ++ .../Areas/User/Department/Department.el.resx | 93 ++ .../Areas/User/Department/Department.en.resx | 93 ++ .../Areas/User/Department/Department.es.resx | 93 ++ .../Areas/User/Department/Department.fr.resx | 93 ++ .../Areas/User/Department/Department.it.resx | 93 ++ .../Areas/User/Department/Department.pl.resx | 93 ++ .../Areas/User/Department/Department.resx | 93 ++ .../Areas/User/Department/Department.sv.resx | 93 ++ .../Areas/User/Department/Department.uk.resx | 93 ++ .../Areas/User/Logs/Logs.ar.resx | 1 + .../Areas/User/Logs/Logs.de.resx | 1 + .../Areas/User/Logs/Logs.el.resx | 1 + .../Areas/User/Logs/Logs.en.resx | 1 + .../Areas/User/Logs/Logs.es.resx | 1 + .../Areas/User/Logs/Logs.fr.resx | 1 + .../Areas/User/Logs/Logs.it.resx | 1 + .../Areas/User/Logs/Logs.pl.resx | 1 + .../Areas/User/Logs/Logs.sv.resx | 1 + .../Areas/User/Logs/Logs.uk.resx | 1 + .../Areas/User/Personnel/Person.ar.resx | 6 + .../Areas/User/Personnel/Person.de.resx | 6 + .../Areas/User/Personnel/Person.el.resx | 6 + .../Areas/User/Personnel/Person.en.resx | 6 + .../Areas/User/Personnel/Person.es.resx | 6 + .../Areas/User/Personnel/Person.fr.resx | 6 + .../Areas/User/Personnel/Person.it.resx | 6 + .../Areas/User/Personnel/Person.pl.resx | 6 + .../Areas/User/Personnel/Person.sv.resx | 6 + .../Areas/User/Personnel/Person.uk.resx | 6 + .../Areas/User/Records/Records.ar.resx | 216 +++ .../Areas/User/Records/Records.cs | 10 + .../Areas/User/Records/Records.de.resx | 216 +++ .../Areas/User/Records/Records.el.resx | 216 +++ .../Areas/User/Records/Records.en.resx | 216 +++ .../Areas/User/Records/Records.es.resx | 216 +++ .../Areas/User/Records/Records.fr.resx | 216 +++ .../Areas/User/Records/Records.it.resx | 216 +++ .../Areas/User/Records/Records.pl.resx | 216 +++ .../Areas/User/Records/Records.sv.resx | 216 +++ .../Areas/User/Records/Records.uk.resx | 216 +++ .../Areas/User/Security/Security.ar.resx | 39 + .../Areas/User/Security/Security.de.resx | 39 + .../Areas/User/Security/Security.el.resx | 39 + .../Areas/User/Security/Security.en.resx | 39 + .../Areas/User/Security/Security.es.resx | 39 + .../Areas/User/Security/Security.fr.resx | 39 + .../Areas/User/Security/Security.it.resx | 39 + .../Areas/User/Security/Security.pl.resx | 39 + .../Areas/User/Security/Security.sv.resx | 39 + .../Areas/User/Security/Security.uk.resx | 39 + .../Areas/User/Workflows/Workflows.ar.resx | 24 + .../Areas/User/Workflows/Workflows.de.resx | 24 + .../Areas/User/Workflows/Workflows.el.resx | 24 + .../Areas/User/Workflows/Workflows.en.resx | 24 + .../Areas/User/Workflows/Workflows.es.resx | 24 + .../Areas/User/Workflows/Workflows.fr.resx | 24 + .../Areas/User/Workflows/Workflows.it.resx | 24 + .../Areas/User/Workflows/Workflows.pl.resx | 24 + .../Areas/User/Workflows/Workflows.resx | 24 + .../Areas/User/Workflows/Workflows.sv.resx | 24 + .../Areas/User/Workflows/Workflows.uk.resx | 24 + Core/Resgrid.Localization/Common.ar.resx | 4 + Core/Resgrid.Localization/Common.de.resx | 4 + Core/Resgrid.Localization/Common.el.resx | 4 + Core/Resgrid.Localization/Common.en.resx | 4 + Core/Resgrid.Localization/Common.es.resx | 4 + Core/Resgrid.Localization/Common.fr.resx | 4 + Core/Resgrid.Localization/Common.it.resx | 4 + Core/Resgrid.Localization/Common.pl.resx | 4 + Core/Resgrid.Localization/Common.sv.resx | 4 + Core/Resgrid.Localization/Common.uk.resx | 4 + Core/Resgrid.Model/DepartmentEmailBranding.cs | 71 + Core/Resgrid.Model/DepartmentProfile.cs | 3 + Core/Resgrid.Model/DepartmentProfileMedia.cs | 112 ++ Core/Resgrid.Model/DepartmentSettingTypes.cs | 28 + .../Events/DomainEventDispatchedEvent.cs | 27 + Core/Resgrid.Model/Events/EventTypes.cs | 20 +- .../Events/RecordsWorkflowEvent.cs | 71 + Core/Resgrid.Model/FeatureFlagKeys.cs | 20 + Core/Resgrid.Model/PermissionActions.cs | 11 +- Core/Resgrid.Model/PermissionTypes.cs | 62 +- .../Resgrid.Model/Providers/IEmailProvider.cs | 6 +- .../Providers/IRecordAttachmentScanner.cs | 22 + .../Records/DomainEventOutboxEntry.cs | 95 ++ .../Records/LogAddedCompatibility.cs | 81 + .../Records/RecordPermissionCatalog.cs | 96 ++ .../Records/RecordPermissionEvaluation.cs | 56 + .../Records/RecordsAccountability.cs | 67 + .../Resgrid.Model/Records/RecordsContracts.cs | 243 +++ .../Records/RecordsDepartmentSettings.cs | 154 ++ .../Records/RecordsGroupScopePreview.cs | 46 + .../Records/RecordsReportingContracts.cs | 51 + .../Records/RecordsSearchContracts.cs | 95 ++ Core/Resgrid.Model/Records/RmsAccessAudit.cs | 64 + Core/Resgrid.Model/Records/RmsAuditEnums.cs | 54 + .../Records/RmsDefinitionKeys.cs | 104 ++ .../Records/RmsDepartmentCutover.cs | 117 ++ Core/Resgrid.Model/Records/RmsEnums.cs | 176 ++ .../Records/RmsExternalReference.cs | 83 + Core/Resgrid.Model/Records/RmsLifecycle.cs | 113 ++ .../Records/RmsOperationalRecord.cs | 156 ++ .../Records/RmsOperationalRecordDetail.cs | 128 ++ .../Records/RmsRecordAttachment.cs | 83 + .../Records/RmsRecordGroupScope.cs | 110 ++ .../Records/RmsRecordParticipant.cs | 77 + .../Records/RmsRecordPrintLayout.cs | 107 ++ .../Records/RmsRecordSearchProjection.cs | 171 ++ .../Records/RmsRecordUnitResponse.cs | 81 + Core/Resgrid.Model/Records/RmsRevision.cs | 89 ++ .../IDepartmentProfileRepositories.cs | 32 + .../Repositories/IRmsLegacyStatsRepository.cs | 21 + .../Repositories/IRmsRepositories.cs | 169 ++ .../Repositories/IWorkflowRunRepository.cs | 3 + .../IDepartmentProfileMediaService.cs | 41 + .../Services/IDepartmentSettingsService.cs | 16 + .../Services/IDomainEventOutboxService.cs | 28 + .../Services/IRecordsAccountabilityService.cs | 24 + .../Services/IRecordsAuthorizationService.cs | 31 + .../Services/IRecordsCutoverService.cs | 41 + .../Services/IRecordsNotificationService.cs | 17 + .../Services/IRecordsPrintLayoutService.cs | 14 + .../Services/IRecordsReportingService.cs | 20 + .../IRecordsSearchIndexMaintenanceService.cs | 18 + .../Services/IRecordsSearchIndexer.cs | 23 + .../Services/IRecordsSearchService.cs | 20 + .../Resgrid.Model/Services/IRecordsService.cs | 79 + .../Services/IRmsInventoryUsageAdapter.cs | 42 + .../Services/IRmsRecordValueService.cs | 29 + Core/Resgrid.Model/UdfEntityType.cs | 10 +- Core/Resgrid.Model/WorkflowHealthSummary.cs | 3 + Core/Resgrid.Model/WorkflowRun.cs | 26 + Core/Resgrid.Model/WorkflowRunEnvelope.cs | 72 + .../WorkflowTemplateVariableCatalog.cs | 83 + .../Resgrid.Model/WorkflowTriggerEventType.cs | 42 +- Core/Resgrid.Search/LuceneRecordsIndexHost.cs | 157 ++ Core/Resgrid.Search/LuceneRecordsIndexer.cs | 90 ++ .../LuceneRecordsSearchService.cs | Bin 0 -> 7611 bytes Core/Resgrid.Search/RecordsIndexFields.cs | 45 + .../RecordsSearchDocumentBuilder.cs | 87 + Core/Resgrid.Search/Resgrid.Search.csproj | 23 + Core/Resgrid.Search/SearchModule.cs | 20 + .../DepartmentLogoRenditions.cs | 125 ++ .../DepartmentProfileMediaService.cs | 308 ++++ .../DepartmentSettingsService.cs | 121 ++ Core/Resgrid.Services/EmailService.cs | 58 +- Core/Resgrid.Services/PermissionsService.cs | 48 +- .../Records/DomainEventOutboxService.cs | 169 ++ .../Records/NullRecordAttachmentScanner.cs | 18 + .../Records/RecordAttachmentHygiene.cs | 141 ++ .../Records/RecordSnapshotSerializer.cs | 228 +++ .../Records/RecordsAccountabilityService.cs | 297 ++++ .../Records/RecordsAuthorizationService.cs | 189 +++ .../Records/RecordsCutoverService.cs | 432 +++++ .../Records/RecordsNotificationService.cs | 99 ++ .../Records/RecordsPrintLayoutService.cs | 113 ++ .../Records/RecordsReportingService.cs | 195 +++ .../RecordsSearchIndexMaintenanceService.cs | 280 ++++ .../Records/RecordsService.cs | 1319 +++++++++++++++ .../Records/RmsInventoryUsageAdapter.cs | 135 ++ .../Records/RmsRecordValueService.cs | 81 + Core/Resgrid.Services/ServicesModule.cs | 17 + Core/Resgrid.Services/UnitsService.cs | 11 +- Core/Resgrid.Services/WorkLogsService.cs | 15 +- .../WorkflowSampleDataGenerator.cs | 133 ++ Core/Resgrid.Services/WorkflowService.cs | 3 + .../WorkflowTemplateContextBuilder.cs | 107 +- .../WorkflowEventProvider.cs | 158 +- .../Resgrid.Providers.Claims/ClaimsLogic.cs | 109 ++ .../ClaimsPrincipalFactory.cs | 1 + .../JwtTokenProvider.cs | 1 + .../ResgridClaimTypes.cs | 22 + .../ResgridIdentity.cs | 6 + .../ResgridResources.cs | 21 + .../PostmarkTemplateProvider.cs | 34 +- .../Template/Call.html | 12 +- .../Template/Message.html | 12 +- .../Template/ReportDelivery.html | 12 +- .../Migrations/M0150_AddRmsRecordsCore.cs | 187 +++ ...0151_AddRmsParticipantsUnitsAttachments.cs | 122 ++ .../M0152_SeedRecordsFeatureFlags.cs | 44 + .../Migrations/M0153_AddDomainEventOutbox.cs | 89 ++ .../M0154_AddRmsDepartmentCutover.cs | 67 + .../M0155_AddRmsRevisionsAndAudit.cs | 82 + .../M0156_AddRmsRecordSearchProjection.cs | 99 ++ .../M0157_AddRmsGroupScopeAndShares.cs | 69 + .../Migrations/M0160_AddRmsPrintLayouts.cs | 43 + ...72_AddDepartmentProfileMediaAndBranding.cs | 68 + .../Migrations/M0150_AddRmsRecordsCorePg.cs | 169 ++ ...51_AddRmsParticipantsUnitsAttachmentsPg.cs | 113 ++ .../M0152_SeedRecordsFeatureFlagsPg.cs | 40 + .../M0153_AddDomainEventOutboxPg.cs | 81 + .../M0154_AddRmsDepartmentCutoverPg.cs | 63 + .../M0155_AddRmsRevisionsAndAuditPg.cs | 75 + .../M0156_AddRmsRecordSearchProjectionPg.cs | 91 ++ .../M0157_AddRmsGroupScopeAndSharesPg.cs | 61 + .../Migrations/M0160_AddRmsPrintLayoutsPg.cs | 39 + ..._AddDepartmentProfileMediaAndBrandingPg.cs | 64 + .../ClamAvAttachmentScanner.cs | 129 ++ .../Resgrid.Providers.Scanning.csproj | 20 + .../ScanningProviderModule.cs | 18 + .../DepartmentProfileRepositories.cs | 86 + .../Modules/DataModule.cs | 21 + .../Modules/TestingDataModule.cs | 21 + .../RmsLegacyStatsRepository.cs | 42 + .../RmsRepositories.cs | 804 ++++++++++ .../WorkflowRunRepository.cs | 31 + Resgrid.sln | 78 + .../Allocations/trigger-baseline.json | 61 + Tests/Resgrid.Tests/Bootstrapper.cs | 2 + .../TranslationCompletenessTests.cs | 13 + .../Providers/ClamAvAttachmentScannerTests.cs | 276 ++++ .../DepartmentEmailBrandingTemplateTests.cs | 230 +++ .../Fixtures/EmailBaseline/Call.html | 512 ++++++ .../Fixtures/EmailBaseline/Message.html | 466 ++++++ .../EmailBaseline/ReportDelivery.html | 458 ++++++ .../TransactionalEmailTemplateTests.cs | 20 +- Tests/Resgrid.Tests/Resgrid.Tests.csproj | 5 + .../ClaimsAuthorizationHelperParityTests.cs | 107 ++ .../Rms/DepartmentProfileMediaServiceTests.cs | 306 ++++ .../Rms/DomainEventOutboxServiceTests.cs | 140 ++ Tests/Resgrid.Tests/Rms/FakeRmsStore.cs | 225 +++ .../Rms/LegacyWriteGuardTests.cs | 105 ++ .../Rms/PermissionsServiceSelectRolesTests.cs | 82 + .../Rms/RecordAttachmentHygieneTests.cs | 97 ++ Tests/Resgrid.Tests/Rms/RecordClaimsTests.cs | 246 +++ .../Rms/RecordSnapshotSerializerTests.cs | 104 ++ .../Rms/RecordsAccountabilityServiceTests.cs | 205 +++ .../Rms/RecordsCutoverServiceTests.cs | 255 +++ .../Rms/RecordsGroupScopePreviewTests.cs | 73 + .../Rms/RecordsListExportTests.cs | 99 ++ .../Rms/RecordsNotificationServiceTests.cs | 110 ++ .../Rms/RecordsPermissionRowsTests.cs | 162 ++ .../Rms/RecordsPrintLayoutServiceTests.cs | 76 + .../Rms/RecordsReportingServiceTests.cs | 177 ++ ...cordsSearchIndexMaintenanceServiceTests.cs | 185 +++ Tests/Resgrid.Tests/Rms/RecordsSearchTests.cs | 194 +++ .../Resgrid.Tests/Rms/RecordsServiceTests.cs | 541 +++++++ .../Rms/RecordsWorkflowTriggerTests.cs | 295 ++++ .../Rms/RmsCascadeSafetyTests.cs | 81 + .../Rms/RmsContainerCompositionTests.cs | 66 + .../Rms/RmsIdentifierPinTests.cs | 128 ++ .../Rms/RmsInventoryUsageAdapterTests.cs | 91 ++ Tests/Resgrid.Tests/Rms/RmsLifecycleTests.cs | 148 ++ .../Rms/SystemApiKeyRecordPolicyTests.cs | 66 + .../Rms/WorkflowRunEnvelopeTests.cs | 72 + .../DocumentDatabaseProviderSelectionTests.cs | 3 +- .../Services/EmailServiceBrandingTests.cs | 152 ++ .../Services/EmailServiceUsabilityTests.cs | 3 +- .../UnitsServiceProtectedWriteTests.cs | 3 +- .../WorkflowTemplateContextBuilderTests.cs | 76 +- .../Web/User/SecurityControllerTests.cs | 3 +- .../Helpers/ClaimsAuthorizationHelper.cs | 92 ++ .../Resgrid.Web.Services.csproj | 2 + Web/Resgrid.Web.Services/Startup.cs | 22 + .../User/Controllers/DepartmentController.cs | 226 ++- .../Areas/User/Controllers/LogsController.cs | 15 +- .../User/Controllers/PersonnelController.cs | 15 +- .../User/Controllers/RecordsController.cs | 1418 +++++++++++++++++ .../User/Controllers/ReportsController.cs | 52 +- .../User/Controllers/SecurityController.cs | 14 +- .../Areas/User/Controllers/UnitsController.cs | 13 +- .../User/Controllers/WorkflowsController.cs | 20 +- .../Areas/User/Models/DeletePersonModel.cs | 3 + .../Areas/User/Models/Logs/LogsIndexView.cs | 3 + .../User/Models/Records/RecordsListExport.cs | 137 ++ .../User/Models/Records/RecordsViewModels.cs | 209 +++ .../User/Models/Security/PermissionsView.cs | 10 +- .../Models/Security/RecordsPermissionRow.cs | 83 + .../Models/Settings/DepartmentProfileModel.cs | 55 + .../Areas/User/Models/Units/ViewLogsView.cs | 2 + .../User/Views/Department/Profile.cshtml | 164 ++ .../Areas/User/Views/Logs/Index.cshtml | 6 +- .../User/Views/Personnel/DeletePerson.cshtml | 13 + .../User/Views/Records/Accountability.cshtml | 151 ++ .../Areas/User/Views/Records/Activate.cshtml | 108 ++ .../Areas/User/Views/Records/Details.cshtml | 338 ++++ .../Areas/User/Views/Records/Diff.cshtml | 65 + .../Areas/User/Views/Records/Edit.cshtml | 282 ++++ .../Areas/User/Views/Records/Index.cshtml | 178 +++ .../Areas/User/Views/Records/Print.cshtml | 205 +++ .../Areas/User/Views/Records/PrintDiff.cshtml | 72 + .../User/Views/Records/PrintRevision.cshtml | 145 ++ .../Areas/User/Views/Records/Revision.cshtml | 107 ++ .../Areas/User/Views/Records/Settings.cshtml | 219 +++ .../User/Views/Records/_PrintFooter.cshtml | 22 + .../User/Views/Records/_PrintHeader.cshtml | 29 + .../Areas/User/Views/Security/Index.cshtml | 31 + .../User/Views/Shared/_Navigation.cshtml | 11 +- .../Areas/User/Views/Shared/_TopNavbar.cshtml | 1 + .../Areas/User/Views/Units/ViewLogs.cshtml | 10 +- .../Areas/User/Views/Workflows/Edit.cshtml | 4 + .../Areas/User/Views/Workflows/Health.cshtml | 15 +- .../Areas/User/Views/Workflows/New.cshtml | 19 + .../User/Views/Workflows/RunDetail.cshtml | 12 + .../Areas/User/Views/Workflows/Runs.cshtml | 3 +- .../Helpers/ClaimsAuthorizationHelper.cs | 92 ++ Web/Resgrid.Web/Resgrid.Web.csproj | 2 + Web/Resgrid.Web/Startup.cs | 22 + Web/Resgrid.Web/WebBootstrapper.cs | 2 + .../security/resgrid.security.permissions.js | 60 +- .../DomainEventOutboxDispatchCommand.cs | 19 + .../Commands/RecordsSearchIndexCommand.cs | 19 + Workers/Resgrid.Workers.Console/Program.cs | 17 + .../Tasks/DomainEventOutboxDispatchTask.cs | 45 + .../Tasks/RecordsSearchIndexTask.cs | 45 + .../Resgrid.Workers.Framework/Bootstrapper.cs | 2 + .../Logic/DomainEventOutboxDispatchLogic.cs | 61 + .../Logic/NotificationBroadcastLogic.cs | 9 + .../Logic/RecordsSearchIndexLogic.cs | 44 + .../Resgrid.Workers.Framework.csproj | 2 + 314 files changed, 28099 insertions(+), 100 deletions(-) create mode 100644 Core/Resgrid.Config/AttachmentScanningConfig.cs create mode 100644 Core/Resgrid.Config/SearchConfig.cs create mode 100644 Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Records/Records.cs create mode 100644 Core/Resgrid.Localization/Areas/User/Records/Records.de.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Records/Records.el.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Records/Records.en.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Records/Records.es.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Records/Records.it.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx create mode 100644 Core/Resgrid.Model/DepartmentEmailBranding.cs create mode 100644 Core/Resgrid.Model/DepartmentProfileMedia.cs create mode 100644 Core/Resgrid.Model/Events/DomainEventDispatchedEvent.cs create mode 100644 Core/Resgrid.Model/Events/RecordsWorkflowEvent.cs create mode 100644 Core/Resgrid.Model/Providers/IRecordAttachmentScanner.cs create mode 100644 Core/Resgrid.Model/Records/DomainEventOutboxEntry.cs create mode 100644 Core/Resgrid.Model/Records/LogAddedCompatibility.cs create mode 100644 Core/Resgrid.Model/Records/RecordPermissionCatalog.cs create mode 100644 Core/Resgrid.Model/Records/RecordPermissionEvaluation.cs create mode 100644 Core/Resgrid.Model/Records/RecordsAccountability.cs create mode 100644 Core/Resgrid.Model/Records/RecordsContracts.cs create mode 100644 Core/Resgrid.Model/Records/RecordsDepartmentSettings.cs create mode 100644 Core/Resgrid.Model/Records/RecordsGroupScopePreview.cs create mode 100644 Core/Resgrid.Model/Records/RecordsReportingContracts.cs create mode 100644 Core/Resgrid.Model/Records/RecordsSearchContracts.cs create mode 100644 Core/Resgrid.Model/Records/RmsAccessAudit.cs create mode 100644 Core/Resgrid.Model/Records/RmsAuditEnums.cs create mode 100644 Core/Resgrid.Model/Records/RmsDefinitionKeys.cs create mode 100644 Core/Resgrid.Model/Records/RmsDepartmentCutover.cs create mode 100644 Core/Resgrid.Model/Records/RmsEnums.cs create mode 100644 Core/Resgrid.Model/Records/RmsExternalReference.cs create mode 100644 Core/Resgrid.Model/Records/RmsLifecycle.cs create mode 100644 Core/Resgrid.Model/Records/RmsOperationalRecord.cs create mode 100644 Core/Resgrid.Model/Records/RmsOperationalRecordDetail.cs create mode 100644 Core/Resgrid.Model/Records/RmsRecordAttachment.cs create mode 100644 Core/Resgrid.Model/Records/RmsRecordGroupScope.cs create mode 100644 Core/Resgrid.Model/Records/RmsRecordParticipant.cs create mode 100644 Core/Resgrid.Model/Records/RmsRecordPrintLayout.cs create mode 100644 Core/Resgrid.Model/Records/RmsRecordSearchProjection.cs create mode 100644 Core/Resgrid.Model/Records/RmsRecordUnitResponse.cs create mode 100644 Core/Resgrid.Model/Records/RmsRevision.cs create mode 100644 Core/Resgrid.Model/Repositories/IDepartmentProfileRepositories.cs create mode 100644 Core/Resgrid.Model/Repositories/IRmsLegacyStatsRepository.cs create mode 100644 Core/Resgrid.Model/Repositories/IRmsRepositories.cs create mode 100644 Core/Resgrid.Model/Services/IDepartmentProfileMediaService.cs create mode 100644 Core/Resgrid.Model/Services/IDomainEventOutboxService.cs create mode 100644 Core/Resgrid.Model/Services/IRecordsAccountabilityService.cs create mode 100644 Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs create mode 100644 Core/Resgrid.Model/Services/IRecordsCutoverService.cs create mode 100644 Core/Resgrid.Model/Services/IRecordsNotificationService.cs create mode 100644 Core/Resgrid.Model/Services/IRecordsPrintLayoutService.cs create mode 100644 Core/Resgrid.Model/Services/IRecordsReportingService.cs create mode 100644 Core/Resgrid.Model/Services/IRecordsSearchIndexMaintenanceService.cs create mode 100644 Core/Resgrid.Model/Services/IRecordsSearchIndexer.cs create mode 100644 Core/Resgrid.Model/Services/IRecordsSearchService.cs create mode 100644 Core/Resgrid.Model/Services/IRecordsService.cs create mode 100644 Core/Resgrid.Model/Services/IRmsInventoryUsageAdapter.cs create mode 100644 Core/Resgrid.Model/Services/IRmsRecordValueService.cs create mode 100644 Core/Resgrid.Model/WorkflowRunEnvelope.cs create mode 100644 Core/Resgrid.Search/LuceneRecordsIndexHost.cs create mode 100644 Core/Resgrid.Search/LuceneRecordsIndexer.cs create mode 100644 Core/Resgrid.Search/LuceneRecordsSearchService.cs create mode 100644 Core/Resgrid.Search/RecordsIndexFields.cs create mode 100644 Core/Resgrid.Search/RecordsSearchDocumentBuilder.cs create mode 100644 Core/Resgrid.Search/Resgrid.Search.csproj create mode 100644 Core/Resgrid.Search/SearchModule.cs create mode 100644 Core/Resgrid.Services/DepartmentLogoRenditions.cs create mode 100644 Core/Resgrid.Services/DepartmentProfileMediaService.cs create mode 100644 Core/Resgrid.Services/Records/DomainEventOutboxService.cs create mode 100644 Core/Resgrid.Services/Records/NullRecordAttachmentScanner.cs create mode 100644 Core/Resgrid.Services/Records/RecordAttachmentHygiene.cs create mode 100644 Core/Resgrid.Services/Records/RecordSnapshotSerializer.cs create mode 100644 Core/Resgrid.Services/Records/RecordsAccountabilityService.cs create mode 100644 Core/Resgrid.Services/Records/RecordsAuthorizationService.cs create mode 100644 Core/Resgrid.Services/Records/RecordsCutoverService.cs create mode 100644 Core/Resgrid.Services/Records/RecordsNotificationService.cs create mode 100644 Core/Resgrid.Services/Records/RecordsPrintLayoutService.cs create mode 100644 Core/Resgrid.Services/Records/RecordsReportingService.cs create mode 100644 Core/Resgrid.Services/Records/RecordsSearchIndexMaintenanceService.cs create mode 100644 Core/Resgrid.Services/Records/RecordsService.cs create mode 100644 Core/Resgrid.Services/Records/RmsInventoryUsageAdapter.cs create mode 100644 Core/Resgrid.Services/Records/RmsRecordValueService.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0150_AddRmsRecordsCore.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0151_AddRmsParticipantsUnitsAttachments.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0152_SeedRecordsFeatureFlags.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0153_AddDomainEventOutbox.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0154_AddRmsDepartmentCutover.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0155_AddRmsRevisionsAndAudit.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0156_AddRmsRecordSearchProjection.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0157_AddRmsGroupScopeAndShares.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0160_AddRmsPrintLayouts.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0172_AddDepartmentProfileMediaAndBranding.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0150_AddRmsRecordsCorePg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0151_AddRmsParticipantsUnitsAttachmentsPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0152_SeedRecordsFeatureFlagsPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0153_AddDomainEventOutboxPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0154_AddRmsDepartmentCutoverPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0155_AddRmsRevisionsAndAuditPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0156_AddRmsRecordSearchProjectionPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0157_AddRmsGroupScopeAndSharesPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0160_AddRmsPrintLayoutsPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0172_AddDepartmentProfileMediaAndBrandingPg.cs create mode 100644 Providers/Resgrid.Providers.Scanning/ClamAvAttachmentScanner.cs create mode 100644 Providers/Resgrid.Providers.Scanning/Resgrid.Providers.Scanning.csproj create mode 100644 Providers/Resgrid.Providers.Scanning/ScanningProviderModule.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/DepartmentProfileRepositories.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/RmsLegacyStatsRepository.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs create mode 100644 Tests/Resgrid.Tests/Allocations/trigger-baseline.json create mode 100644 Tests/Resgrid.Tests/Providers/ClamAvAttachmentScannerTests.cs create mode 100644 Tests/Resgrid.Tests/Providers/DepartmentEmailBrandingTemplateTests.cs create mode 100644 Tests/Resgrid.Tests/Providers/Fixtures/EmailBaseline/Call.html create mode 100644 Tests/Resgrid.Tests/Providers/Fixtures/EmailBaseline/Message.html create mode 100644 Tests/Resgrid.Tests/Providers/Fixtures/EmailBaseline/ReportDelivery.html create mode 100644 Tests/Resgrid.Tests/Rms/ClaimsAuthorizationHelperParityTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/DepartmentProfileMediaServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/DomainEventOutboxServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/FakeRmsStore.cs create mode 100644 Tests/Resgrid.Tests/Rms/LegacyWriteGuardTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/PermissionsServiceSelectRolesTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordAttachmentHygieneTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordClaimsTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordSnapshotSerializerTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsAccountabilityServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsCutoverServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsGroupScopePreviewTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsListExportTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsNotificationServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsPermissionRowsTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsPrintLayoutServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsReportingServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsSearchIndexMaintenanceServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsSearchTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsWorkflowTriggerTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RmsCascadeSafetyTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RmsContainerCompositionTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RmsInventoryUsageAdapterTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RmsLifecycleTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/SystemApiKeyRecordPolicyTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/WorkflowRunEnvelopeTests.cs create mode 100644 Tests/Resgrid.Tests/Services/EmailServiceBrandingTests.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Records/RecordsListExport.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Security/RecordsPermissionRow.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Settings/DepartmentProfileModel.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/Department/Profile.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Records/Accountability.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Records/Activate.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Records/Details.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Records/Diff.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Records/Edit.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Records/Print.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Records/PrintDiff.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Records/PrintRevision.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Records/Revision.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Records/Settings.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Records/_PrintFooter.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Records/_PrintHeader.cshtml create mode 100644 Workers/Resgrid.Workers.Console/Commands/DomainEventOutboxDispatchCommand.cs create mode 100644 Workers/Resgrid.Workers.Console/Commands/RecordsSearchIndexCommand.cs create mode 100644 Workers/Resgrid.Workers.Console/Tasks/DomainEventOutboxDispatchTask.cs create mode 100644 Workers/Resgrid.Workers.Console/Tasks/RecordsSearchIndexTask.cs create mode 100644 Workers/Resgrid.Workers.Framework/Logic/DomainEventOutboxDispatchLogic.cs create mode 100644 Workers/Resgrid.Workers.Framework/Logic/RecordsSearchIndexLogic.cs diff --git a/Core/Resgrid.Config/AttachmentScanningConfig.cs b/Core/Resgrid.Config/AttachmentScanningConfig.cs new file mode 100644 index 000000000..2850e3207 --- /dev/null +++ b/Core/Resgrid.Config/AttachmentScanningConfig.cs @@ -0,0 +1,31 @@ +namespace Resgrid.Config +{ + /// + /// Malware scanning for Records attachments (RMS plan section 4.7) through a clamd daemon. Off by default: + /// with scanning disabled every attachment is stored as Skipped, exactly as with no scanner registered. + /// Environment keys: RESGRID:AttachmentScanningConfig:Enabled, :Host, :Port, :TimeoutSeconds, :FailClosed. + /// + public static class AttachmentScanningConfig + { + /// Master switch. When false the scanner never opens a connection. + public static bool Enabled = false; + + /// clamd host (the ClamAV service name inside the compose/k8s network). + public static string Host = "clamav"; + + /// clamd TCP port. + public static int Port = 3310; + + /// Connect, stream and reply budget for one scan. + public static int TimeoutSeconds = 30; + + /// INSTREAM chunk size in bytes. + public static int ChunkSize = 64 * 1024; + + /// + /// What an unreachable or erroring engine means for the upload: true rejects the attachment (the safe + /// default for a records system), false stores it as Pending with the reason on the row. + /// + public static bool FailClosed = true; + } +} diff --git a/Core/Resgrid.Config/SearchConfig.cs b/Core/Resgrid.Config/SearchConfig.cs new file mode 100644 index 000000000..ad85f1e31 --- /dev/null +++ b/Core/Resgrid.Config/SearchConfig.cs @@ -0,0 +1,28 @@ +namespace Resgrid.Config +{ + /// + /// Shared Lucene.NET search host (Unified Search plan section 9.2, absorbed by RMS-1). Off by default: with the + /// host disabled the Records queue keeps running on the indexed projection table and free-text search is + /// reported as unavailable rather than silently degrading to LIKE. + /// + public static class SearchConfig + { + /// Master switch for the search host in every process. + public static bool Enabled = false; + + /// Root directory shared by the API/Web reader and the worker writer (Docker volume). + public static string IndexPath = "/data/search"; + + /// Hard limit on hits a single query may return. + public static int MaxResults = 200; + + /// IndexWriter RAM buffer before a flush. + public static int RamBufferSizeMb = 16; + + /// Rows fetched per page while rebuilding or catching up a department. + public static int IndexBatchSize = 500; + + /// Maximum departments one maintenance sweep rebuilds before yielding to the next run. + public static int MaxRebuildsPerSweep = 5; + } +} diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx index 413216115..2093cb991 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx @@ -1050,4 +1050,97 @@ عائدة + + ملف القسم + + + هذه هي الهوية التي يطبعها Resgrid على السجلات ويعرضها في الرسائل ذات العلامة التجارية ويستخدمها أينما يقدّم القسم نفسه. اترك الحقل فارغًا للرجوع إلى إعدادات القسم. + + + الهوية + + + الاسم المعروض + + + الاسم المختصر + + + الرمز + + + الوصف + + + رقم الهاتف + + + الموقع الإلكتروني + + + العنوان + + + العنوان المطبوع على السجلات والرسائل هو عنوان القسم: + + + روابط التواصل الاجتماعي + + + الشعار + + + PNG أو JPEG بحجم يصل إلى 2 ميغابايت وبعرض 200 بكسل على الأقل على الضلع الأطول (يُنصح بـ 512 بكسل أو أكثر). يُعاد ترميز الملف مع إزالة بياناته الوصفية، وتُنشأ منه نسخ رأس الطباعة ورأس البريد والصورة المصغّرة. + + + رفع الشعار + + + إزالة الشعار + + + لم يُرفع أي شعار. تستخدم المطبوعات والرسائل اسم القسم فقط. + + + رأس الطباعة + + + رأس البريد + + + صورة مصغّرة + + + استخدام رأس القسم في الرسائل التشغيلية + + + تعرض رسائل النداءات والرسائل وتسليم التقارير وسير العمل شعارك واسمك بدلًا من رأس Resgrid؛ وتُرسل إشعارات السجلات باسم «اسم الجهة via Resgrid». تبقى رسائل الحساب والفوترة دائمًا بعلامة Resgrid. + + + الرابط العام للرأس + + + لا يمكن لبرامج البريد تسجيل الدخول، لذا يُقدَّم الرأس عبر هذا الرابط الذي لا يمكن تخمينه. تؤدي إعادة إنشائه إلى إبطال الصورة في كل رسالة أُرسلت من قبل. + + + إعادة إنشاء الرابط + + + تم حفظ ملف القسم. + + + تم رفع الشعار وإنشاء النسخ. + + + تمت إزالة الشعار. + + + اختر ملف PNG أو JPEG لرفعه. + + + صدر رابط رأس جديد؛ لن تعرض الرسائل المرسلة سابقًا الشعار بعد الآن. + + + تعذّر حفظ الملف؛ راجع الحقول المميّزة. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx index 420c8d92a..cd77c21c1 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx @@ -1001,4 +1001,97 @@ Rückfahrt + + Abteilungsprofil + + + Dies ist die Identität, die Resgrid auf Berichte druckt, in gebrandeten E-Mails zeigt und überall verwendet, wo sich die Abteilung präsentiert. Lassen Sie ein Feld leer, um auf die Abteilungseinstellungen zurückzugreifen. + + + Identität + + + Anzeigename + + + Kurzname + + + Kennung + + + Beschreibung + + + Telefonnummer + + + Website + + + Adresse + + + Die auf Berichten und E-Mails gedruckte Adresse ist die Abteilungsadresse: + + + Soziale Netzwerke + + + Logo + + + PNG oder JPEG, bis 2 MB, mindestens 200 px an der langen Kante (512 px oder mehr empfohlen). Der Upload wird ohne Metadaten neu kodiert; daraus entstehen die Varianten für Druckkopf, E-Mail-Kopfzeile und Miniaturbild. + + + Logo hochladen + + + Logo entfernen + + + Es wurde kein Logo hochgeladen. Ausdrucke und E-Mails verwenden nur den Abteilungsnamen. + + + Druckkopf + + + E-Mail-Kopfzeile + + + Miniaturbild + + + Abteilungs-Kopfzeile in operativen E-Mails verwenden + + + E-Mails zu Einsätzen, Nachrichten, Berichtszustellung und Workflows zeigen Ihr Logo und Ihren Namen statt der Resgrid-Kopfzeile; Benachrichtigungen zu Berichten werden als „Abteilungsname via Resgrid“ gesendet. Konto- und Abrechnungs-E-Mails bleiben immer Resgrid-gebrandet. + + + Öffentlicher Kopfzeilen-Link + + + E-Mail-Programme können sich nicht anmelden, daher wird die Kopfzeile über diesen nicht erratbaren Link ausgeliefert. Beim Neuerzeugen wird das Bild in allen bereits gesendeten E-Mails ungültig. + + + Link neu erzeugen + + + Abteilungsprofil gespeichert. + + + Logo hochgeladen und Varianten erzeugt. + + + Logo entfernt. + + + Wählen Sie eine PNG- oder JPEG-Datei zum Hochladen. + + + Ein neuer Kopfzeilen-Link wurde ausgestellt; bereits gesendete E-Mails zeigen das Logo nicht mehr. + + + Das Profil konnte nicht gespeichert werden; prüfen Sie die markierten Felder. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx index 8ae244736..10a7e5f86 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx @@ -1080,4 +1080,97 @@ Επιστροφή + + Προφίλ υπηρεσίας + + + Αυτή είναι η ταυτότητα που το Resgrid εκτυπώνει στις αναφορές, εμφανίζει στα επώνυμα email και χρησιμοποιεί όπου παρουσιάζεται η υπηρεσία. Αφήστε ένα πεδίο κενό για να χρησιμοποιηθούν οι ρυθμίσεις της υπηρεσίας. + + + Ταυτότητα + + + Εμφανιζόμενο όνομα + + + Σύντομο όνομα + + + Κωδικός + + + Περιγραφή + + + Αριθμός τηλεφώνου + + + Ιστότοπος + + + Διεύθυνση + + + Η διεύθυνση που εκτυπώνεται σε αναφορές και email είναι η διεύθυνση της υπηρεσίας: + + + Κοινωνικά δίκτυα + + + Λογότυπο + + + PNG ή JPEG, έως 2 MB, τουλάχιστον 200 px στη μεγάλη πλευρά (συνιστώνται 512 px ή περισσότερα). Το αρχείο επανακωδικοποιείται χωρίς μεταδεδομένα και από αυτό δημιουργούνται οι εκδοχές για κεφαλίδα εκτύπωσης, κεφαλίδα email και μικρογραφία. + + + Μεταφόρτωση λογοτύπου + + + Αφαίρεση λογοτύπου + + + Δεν έχει μεταφορτωθεί λογότυπο. Οι εκτυπώσεις και τα email χρησιμοποιούν μόνο το όνομα της υπηρεσίας. + + + Κεφαλίδα εκτύπωσης + + + Κεφαλίδα email + + + Μικρογραφία + + + Χρήση της κεφαλίδας της υπηρεσίας στα επιχειρησιακά email + + + Τα email για κλήσεις, μηνύματα, παράδοση αναφορών και ροές εργασίας εμφανίζουν το λογότυπο και το όνομά σας αντί για την κεφαλίδα του Resgrid· οι ειδοποιήσεις αναφορών αποστέλλονται ως «όνομα υπηρεσίας via Resgrid». Τα email λογαριασμού και χρέωσης παραμένουν πάντα με την επωνυμία Resgrid. + + + Δημόσιος σύνδεσμος κεφαλίδας + + + Οι εφαρμογές email δεν μπορούν να συνδεθούν, οπότε η κεφαλίδα σερβίρεται από αυτόν τον μη προβλέψιμο σύνδεσμο. Η αναδημιουργία του ακυρώνει την εικόνα σε κάθε email που έχει ήδη σταλεί. + + + Αναδημιουργία συνδέσμου + + + Το προφίλ της υπηρεσίας αποθηκεύτηκε. + + + Το λογότυπο μεταφορτώθηκε και οι εκδοχές δημιουργήθηκαν. + + + Το λογότυπο αφαιρέθηκε. + + + Επιλέξτε αρχείο PNG ή JPEG για μεταφόρτωση. + + + Εκδόθηκε νέος σύνδεσμος κεφαλίδας· τα email που έχουν ήδη σταλεί δεν θα εμφανίζουν πλέον το λογότυπο. + + + Το προφίλ δεν ήταν δυνατό να αποθηκευτεί· ελέγξτε τα επισημασμένα πεδία. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx index 8befe4a97..4bd80caa0 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx @@ -1086,4 +1086,97 @@ Prevents department and group administrators from choosing or seeing a member's new password. Reset actions send the member a short-lived, single-use email link instead. Existing sessions are revoked after the member successfully changes the password. + + Department Profile + + + This is the identity Resgrid prints on records, shows on branded emails and uses wherever the department presents itself. Leave a field empty to fall back to the department settings. + + + Identity + + + Display name + + + Short name + + + Code + + + Description + + + Phone number + + + Website + + + Address + + + The address printed on records and emails is the department address: + + + Social links + + + Logo + + + PNG or JPEG, up to 2 MB, at least 200 px on the long edge (512 px or larger recommended). The upload is re-encoded with its metadata removed, and the print header, email masthead and thumbnail renditions are generated from it. + + + Upload logo + + + Remove logo + + + No logo has been uploaded. Prints and emails use the department name alone. + + + Print header + + + Email masthead + + + Thumbnail + + + Use the department masthead on operational emails + + + Call, message, report delivery and workflow emails show your logo and name instead of the Resgrid masthead; records notifications are sent as "department name via Resgrid". Account and billing emails always stay Resgrid-branded. + + + Public masthead link + + + Email clients cannot sign in, so the masthead is served from this unguessable link. Regenerating it invalidates the image in every email already sent. + + + Regenerate link + + + Department profile saved. + + + Logo uploaded and renditions generated. + + + Logo removed. + + + Choose a PNG or JPEG file to upload. + + + A new masthead link has been issued; previously sent emails will no longer show the logo. + + + The profile could not be saved; check the highlighted fields. + diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx index f9529cade..7a327a88a 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx @@ -936,4 +936,97 @@ Regresando + + Perfil del departamento + + + Esta es la identidad que Resgrid imprime en los registros, muestra en los correos con marca y usa dondequiera que el departamento se presente. Deje un campo vacío para usar la configuración del departamento. + + + Identidad + + + Nombre para mostrar + + + Nombre corto + + + Código + + + Descripción + + + Número de teléfono + + + Sitio web + + + Dirección + + + La dirección impresa en registros y correos es la dirección del departamento: + + + Redes sociales + + + Logotipo + + + PNG o JPEG, hasta 2 MB, al menos 200 px en el lado largo (se recomiendan 512 px o más). La imagen se recodifica sin metadatos y a partir de ella se generan las versiones para encabezado de impresión, cabecera de correo y miniatura. + + + Subir logotipo + + + Quitar logotipo + + + No se ha subido ningún logotipo. Las impresiones y los correos usan solo el nombre del departamento. + + + Encabezado de impresión + + + Cabecera de correo + + + Miniatura + + + Usar la cabecera del departamento en los correos operativos + + + Los correos de llamadas, mensajes, entrega de informes y flujos de trabajo muestran su logotipo y nombre en lugar de la cabecera de Resgrid; las notificaciones de registros se envían como «nombre del departamento via Resgrid». Los correos de cuenta y facturación siempre conservan la marca Resgrid. + + + Enlace público de la cabecera + + + Los clientes de correo no pueden iniciar sesión, por lo que la cabecera se sirve desde este enlace imposible de adivinar. Regenerarlo invalida la imagen en todos los correos ya enviados. + + + Regenerar enlace + + + Perfil del departamento guardado. + + + Logotipo subido y versiones generadas. + + + Logotipo eliminado. + + + Elija un archivo PNG o JPEG para subir. + + + Se ha emitido un nuevo enlace de cabecera; los correos ya enviados dejarán de mostrar el logotipo. + + + No se pudo guardar el perfil; revise los campos marcados. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx index 39c0f78ca..bc069c583 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx @@ -1001,4 +1001,97 @@ Retour + + Profil du service + + + C'est l'identité que Resgrid imprime sur les rapports, affiche dans les e-mails personnalisés et utilise partout où le service se présente. Laissez un champ vide pour revenir aux paramètres du service. + + + Identité + + + Nom affiché + + + Nom court + + + Code + + + Description + + + Numéro de téléphone + + + Site web + + + Adresse + + + L'adresse imprimée sur les rapports et les e-mails est celle du service : + + + Réseaux sociaux + + + Logo + + + PNG ou JPEG, 2 Mo maximum, au moins 200 px sur le grand côté (512 px ou plus recommandé). Le fichier est réencodé sans ses métadonnées, puis les versions en-tête d'impression, bandeau d'e-mail et vignette sont générées. + + + Téléverser le logo + + + Supprimer le logo + + + Aucun logo n'a été téléversé. Les impressions et les e-mails n'utilisent que le nom du service. + + + En-tête d'impression + + + Bandeau d'e-mail + + + Vignette + + + Utiliser le bandeau du service dans les e-mails opérationnels + + + Les e-mails d'intervention, de message, de livraison de rapports et de workflows affichent votre logo et votre nom à la place du bandeau Resgrid ; les notifications de rapports sont envoyées sous « nom du service via Resgrid ». Les e-mails de compte et de facturation restent toujours aux couleurs de Resgrid. + + + Lien public du bandeau + + + Les clients de messagerie ne peuvent pas se connecter ; le bandeau est donc servi depuis ce lien impossible à deviner. Le régénérer invalide l'image dans tous les e-mails déjà envoyés. + + + Régénérer le lien + + + Profil du service enregistré. + + + Logo téléversé et versions générées. + + + Logo supprimé. + + + Choisissez un fichier PNG ou JPEG à téléverser. + + + Un nouveau lien de bandeau a été émis ; les e-mails déjà envoyés n'afficheront plus le logo. + + + Le profil n'a pas pu être enregistré ; vérifiez les champs signalés. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx index 03a154616..b522ae83f 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx @@ -1001,4 +1001,97 @@ In rientro + + Profilo del dipartimento + + + Questa è l'identità che Resgrid stampa sui rapporti, mostra nelle e-mail personalizzate e usa ovunque il dipartimento si presenti. Lasciate un campo vuoto per ricorrere alle impostazioni del dipartimento. + + + Identità + + + Nome visualizzato + + + Nome breve + + + Codice + + + Descrizione + + + Numero di telefono + + + Sito web + + + Indirizzo + + + L'indirizzo stampato su rapporti ed e-mail è quello del dipartimento: + + + Social + + + Logo + + + PNG o JPEG, fino a 2 MB, almeno 200 px sul lato lungo (consigliati 512 px o più). Il file viene ricodificato senza metadati e da esso vengono generate le versioni per intestazione di stampa, testata e-mail e miniatura. + + + Carica logo + + + Rimuovi logo + + + Nessun logo caricato. Stampe ed e-mail usano solo il nome del dipartimento. + + + Intestazione di stampa + + + Testata e-mail + + + Miniatura + + + Usa la testata del dipartimento nelle e-mail operative + + + Le e-mail di intervento, messaggi, consegna report e flussi di lavoro mostrano il vostro logo e nome al posto della testata Resgrid; le notifiche dei rapporti vengono inviate come "nome del dipartimento via Resgrid". Le e-mail di account e fatturazione restano sempre con il marchio Resgrid. + + + Link pubblico della testata + + + I client di posta non possono accedere, quindi la testata è servita da questo link non indovinabile. Rigenerarlo invalida l'immagine in tutte le e-mail già inviate. + + + Rigenera link + + + Profilo del dipartimento salvato. + + + Logo caricato e versioni generate. + + + Logo rimosso. + + + Scegliete un file PNG o JPEG da caricare. + + + È stato emesso un nuovo link della testata; le e-mail già inviate non mostreranno più il logo. + + + Impossibile salvare il profilo; controllate i campi evidenziati. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx index 0b6185542..b42bb2655 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx @@ -1001,4 +1001,97 @@ Powrót + + Profil wydziału + + + To tożsamość, którą Resgrid drukuje na raportach, pokazuje w oznakowanych wiadomościach e-mail i stosuje wszędzie tam, gdzie wydział się prezentuje. Pozostaw pole puste, aby użyć ustawień wydziału. + + + Tożsamość + + + Nazwa wyświetlana + + + Nazwa skrócona + + + Kod + + + Opis + + + Numer telefonu + + + Strona WWW + + + Adres + + + Adres drukowany na raportach i w wiadomościach to adres wydziału: + + + Media społecznościowe + + + Logo + + + PNG lub JPEG, do 2 MB, co najmniej 200 px na dłuższym boku (zalecane 512 px lub więcej). Plik jest ponownie kodowany bez metadanych, a na jego podstawie powstają wersje: nagłówek wydruku, nagłówek e-mail i miniatura. + + + Prześlij logo + + + Usuń logo + + + Nie przesłano logo. Wydruki i wiadomości używają wyłącznie nazwy wydziału. + + + Nagłówek wydruku + + + Nagłówek e-mail + + + Miniatura + + + Używaj nagłówka wydziału w wiadomościach operacyjnych + + + Wiadomości o wyjazdach, komunikatach, dostarczaniu raportów i przepływach pracy pokazują Twoje logo i nazwę zamiast nagłówka Resgrid; powiadomienia o raportach są wysyłane jako „nazwa jednostki via Resgrid”. Wiadomości dotyczące konta i rozliczeń zawsze pozostają z marką Resgrid. + + + Publiczny link nagłówka + + + Klienci poczty nie mogą się logować, dlatego nagłówek jest serwowany z tego niemożliwego do odgadnięcia linku. Ponowne wygenerowanie unieważnia obraz we wszystkich już wysłanych wiadomościach. + + + Wygeneruj link ponownie + + + Profil wydziału zapisany. + + + Logo przesłane, wersje wygenerowane. + + + Logo usunięte. + + + Wybierz plik PNG lub JPEG do przesłania. + + + Wydano nowy link nagłówka; wcześniej wysłane wiadomości nie będą już pokazywać logo. + + + Nie udało się zapisać profilu; sprawdź wyróżnione pola. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.resx index 888b3c016..d1fe01d3c 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.resx @@ -681,4 +681,97 @@ Prevents department and group administrators from choosing or seeing a member's new password. Reset actions send the member a short-lived, single-use email link instead. Existing sessions are revoked after the member successfully changes the password. + + Department Profile + + + This is the identity Resgrid prints on records, shows on branded emails and uses wherever the department presents itself. Leave a field empty to fall back to the department settings. + + + Identity + + + Display name + + + Short name + + + Code + + + Description + + + Phone number + + + Website + + + Address + + + The address printed on records and emails is the department address: + + + Social links + + + Logo + + + PNG or JPEG, up to 2 MB, at least 200 px on the long edge (512 px or larger recommended). The upload is re-encoded with its metadata removed, and the print header, email masthead and thumbnail renditions are generated from it. + + + Upload logo + + + Remove logo + + + No logo has been uploaded. Prints and emails use the department name alone. + + + Print header + + + Email masthead + + + Thumbnail + + + Use the department masthead on operational emails + + + Call, message, report delivery and workflow emails show your logo and name instead of the Resgrid masthead; records notifications are sent as "department name via Resgrid". Account and billing emails always stay Resgrid-branded. + + + Public masthead link + + + Email clients cannot sign in, so the masthead is served from this unguessable link. Regenerating it invalidates the image in every email already sent. + + + Regenerate link + + + Department profile saved. + + + Logo uploaded and renditions generated. + + + Logo removed. + + + Choose a PNG or JPEG file to upload. + + + A new masthead link has been issued; previously sent emails will no longer show the logo. + + + The profile could not be saved; check the highlighted fields. + diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx index b3cd3c416..b4a921828 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx @@ -1001,4 +1001,97 @@ Återvänder + + Avdelningsprofil + + + Detta är den identitet som Resgrid skriver på rapporter, visar i profilerade e-postmeddelanden och använder överallt där avdelningen presenterar sig. Lämna ett fält tomt för att falla tillbaka på avdelningsinställningarna. + + + Identitet + + + Visningsnamn + + + Kortnamn + + + Kod + + + Beskrivning + + + Telefonnummer + + + Webbplats + + + Adress + + + Adressen som skrivs på rapporter och e-post är avdelningens adress: + + + Sociala länkar + + + Logotyp + + + PNG eller JPEG, högst 2 MB, minst 200 px på långsidan (512 px eller mer rekommenderas). Filen kodas om utan metadata och därifrån skapas varianterna för utskriftshuvud, e-posthuvud och miniatyr. + + + Ladda upp logotyp + + + Ta bort logotyp + + + Ingen logotyp har laddats upp. Utskrifter och e-post använder bara avdelningens namn. + + + Utskriftshuvud + + + E-posthuvud + + + Miniatyr + + + Använd avdelningens e-posthuvud i operativa e-postmeddelanden + + + E-post om larm, meddelanden, rapportleverans och arbetsflöden visar er logotyp och ert namn i stället för Resgrids huvud; aviseringar om rapporter skickas som ”organisationens namn via Resgrid”. Konto- och faktureringsmeddelanden behåller alltid Resgrids profil. + + + Offentlig länk till e-posthuvudet + + + E-postklienter kan inte logga in, så huvudet levereras via denna länk som inte går att gissa. Om den skapas på nytt blir bilden ogiltig i alla redan skickade meddelanden. + + + Skapa ny länk + + + Avdelningsprofilen har sparats. + + + Logotypen är uppladdad och varianterna skapade. + + + Logotypen har tagits bort. + + + Välj en PNG- eller JPEG-fil att ladda upp. + + + En ny länk till e-posthuvudet har utfärdats; redan skickade meddelanden visar inte längre logotypen. + + + Profilen kunde inte sparas; kontrollera de markerade fälten. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx index 6cadbfba5..64ce1da2e 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx @@ -1001,4 +1001,97 @@ Повернення + + Профіль підрозділу + + + Це ідентичність, яку Resgrid друкує на звітах, показує в брендованих листах і використовує скрізь, де підрозділ себе представляє. Залиште поле порожнім, щоб використати налаштування підрозділу. + + + Ідентичність + + + Відображуване ім'я + + + Коротка назва + + + Код + + + Опис + + + Номер телефону + + + Вебсайт + + + Адреса + + + Адреса, що друкується на звітах і в листах, — це адреса підрозділу: + + + Соціальні мережі + + + Логотип + + + PNG або JPEG, до 2 МБ, щонайменше 200 px по довшій стороні (рекомендовано 512 px і більше). Файл перекодовується без метаданих, і з нього створюються варіанти для друкованої шапки, шапки листа та мініатюри. + + + Завантажити логотип + + + Видалити логотип + + + Логотип не завантажено. У друкованих копіях і листах використовується лише назва підрозділу. + + + Друкована шапка + + + Шапка листа + + + Мініатюра + + + Використовувати шапку підрозділу в операційних листах + + + Листи про виклики, повідомлення, доставку звітів та робочі процеси показують ваш логотип і назву замість шапки Resgrid; сповіщення про звіти надсилаються як «назва підрозділу via Resgrid». Листи щодо облікового запису та оплати завжди залишаються з брендом Resgrid. + + + Публічне посилання на шапку + + + Поштові клієнти не можуть увійти, тому шапка подається за цим невгадуваним посиланням. Його повторне створення робить зображення недійсним в усіх уже надісланих листах. + + + Створити посилання заново + + + Профіль підрозділу збережено. + + + Логотип завантажено, варіанти створено. + + + Логотип видалено. + + + Виберіть файл PNG або JPEG для завантаження. + + + Видано нове посилання на шапку; раніше надіслані листи більше не показуватимуть логотип. + + + Не вдалося зберегти профіль; перевірте виділені поля. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.ar.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.ar.resx index 907520489..209edb68f 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.ar.resx @@ -56,4 +56,5 @@ إغلاق حفظ إلغاء + السجلات مفعّلة لهذه الإدارة. السجلات القديمة للقراءة فقط: يمكن عرضها وطباعتها وتصديرها لكن لا يمكن إنشاؤها أو تعديلها أو حذفها. diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.de.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.de.resx index 7b9a28061..3dfc05a46 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.de.resx @@ -305,4 +305,5 @@ Work Log Information + Akten ist für diese Abteilung aktiv. Bestehende Protokolle sind schreibgeschützt: Sie können angezeigt, gedruckt und exportiert, aber nicht erstellt, bearbeitet oder gelöscht werden. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.el.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.el.resx index 948defbac..ef44e2ca1 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.el.resx @@ -354,4 +354,5 @@ Πληροφορίες Καταγραφής Εργασίας + Τα Αρχεία είναι ενεργά για αυτό το τμήμα. Τα υπάρχοντα ημερολόγια είναι μόνο για ανάγνωση: προβολή, εκτύπωση και εξαγωγή, όχι δημιουργία, επεξεργασία ή διαγραφή. diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.en.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.en.resx index b1c7b7689..843069c1f 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.en.resx @@ -354,4 +354,5 @@ Work Log Information + Records is active for this department. Existing logs are read-only: they can be viewed, printed and exported, but not created, edited or deleted. diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.es.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.es.resx index ce93fc307..678d206d7 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.es.resx @@ -345,4 +345,5 @@ Work Log Information + Registros está activo para este departamento. Las bitácoras existentes son de solo lectura: se pueden ver, imprimir y exportar, pero no crear, editar ni eliminar. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.fr.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.fr.resx index dbaca96e8..d0f3ca194 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.fr.resx @@ -305,4 +305,5 @@ Work Log Information + Les dossiers sont actifs pour ce service. Les journaux existants sont en lecture seule : consultables, imprimables et exportables, mais ni créés, ni modifiés, ni supprimés. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.it.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.it.resx index 9a7a6c7c5..0ce460aca 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.it.resx @@ -305,4 +305,5 @@ Work Log Information + Registri è attivo per questo dipartimento. I log esistenti sono di sola lettura: visualizzabili, stampabili ed esportabili, ma non creabili, modificabili o eliminabili. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.pl.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.pl.resx index 989f0ff3d..1503e0f21 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.pl.resx @@ -305,4 +305,5 @@ Work Log Information + Rejestry są aktywne dla tego wydziału. Istniejące dzienniki są tylko do odczytu: można je przeglądać, drukować i eksportować, ale nie tworzyć, edytować ani usuwać. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.sv.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.sv.resx index a12c4cf35..05ed6cb9a 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.sv.resx @@ -305,4 +305,5 @@ Work Log Information + Journaler är aktivt för denna avdelning. Befintliga loggar är skrivskyddade: de kan visas, skrivas ut och exporteras, men inte skapas, redigeras eller raderas. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.uk.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.uk.resx index fe06aff42..80fb60019 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.uk.resx @@ -305,4 +305,5 @@ Work Log Information + Записи активні для цього підрозділу. Наявні журнали доступні лише для читання: перегляд, друк та експорт, але не створення, редагування чи видалення. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.ar.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.ar.resx index 10608ac9c..b6a768ad6 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.ar.resx @@ -102,4 +102,10 @@ حذف الكل نعم، أنا متأكد عرض الدور + + سجلات غير مكتملة + + + لا يزال هذا العضو يملك {0} من السجلات غير المكتملة. افتح كل سجل وأعد إسناده إلى عضو آخر قبل الحذف، وإلا سيبقى مملوكًا لعضو محذوف. + diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.de.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.de.resx index a2803f329..6ca6511e8 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.de.resx @@ -347,4 +347,10 @@ View Role + + Unfertige Datensätze + + + Dieses Mitglied besitzt noch {0} unfertige Datensätze. Öffnen Sie jeden und weisen Sie ihn vor dem Löschen einem anderen Mitglied zu, sonst bleibt er im Besitz eines gelöschten Mitglieds. + diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.el.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.el.resx index a360a9cbb..15ba7e8d3 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.el.resx @@ -396,4 +396,10 @@ Προβολή Ρόλου + + Μη οριστικοποιημένες εγγραφές + + + Αυτό το μέλος κατέχει ακόμη {0} μη οριστικοποιημένες εγγραφές. Ανοίξτε καθεμία και αναθέστε την σε άλλο μέλος πριν από τη διαγραφή, αλλιώς θα παραμείνει στην κατοχή διαγραμμένου μέλους. + diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.en.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.en.resx index 178d202c2..01a9863c6 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.en.resx @@ -396,4 +396,10 @@ View Role + + Unfinished Records + + + This member still owns {0} unfinished Record(s). Open each one and reassign it to another member before deleting, or it stays owned by a deleted member. + diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.es.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.es.resx index 52664dbe2..759a70ca3 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.es.resx @@ -396,4 +396,10 @@ Ver rol + + Registros sin finalizar + + + Este miembro aún posee {0} registro(s) sin finalizar. Abra cada uno y reasígnelo a otro miembro antes de eliminar; de lo contrario, quedará en manos de un miembro eliminado. + diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.fr.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.fr.resx index c62452328..f2a5143d3 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.fr.resx @@ -347,4 +347,10 @@ View Role + + Enregistrements non finalisés + + + Ce membre possède encore {0} enregistrement(s) non finalisé(s). Ouvrez chacun d'eux et réattribuez-le à un autre membre avant la suppression, sinon il restera la propriété d'un membre supprimé. + diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.it.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.it.resx index b51354b67..149d0f879 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.it.resx @@ -347,4 +347,10 @@ View Role + + Record non finalizzati + + + Questo membro possiede ancora {0} record non finalizzati. Aprire ciascuno e riassegnarlo a un altro membro prima di eliminare, altrimenti resterà di proprietà di un membro eliminato. + diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.pl.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.pl.resx index 24752a991..2da27d77b 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.pl.resx @@ -347,4 +347,10 @@ View Role + + Niezakończone rekordy + + + Ten członek nadal posiada {0} niezakończonych rekordów. Otwórz każdy z nich i przypisz go innemu członkowi przed usunięciem, w przeciwnym razie pozostanie własnością usuniętego członka. + diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.sv.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.sv.resx index a401e2216..0f10843ce 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.sv.resx @@ -347,4 +347,10 @@ View Role + + Ofärdiga poster + + + Den här medlemmen äger fortfarande {0} ofärdiga poster. Öppna var och en och omfördela den till en annan medlem innan du tar bort, annars förblir den ägd av en borttagen medlem. + diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.uk.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.uk.resx index 5ba3decab..88bfff373 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.uk.resx @@ -347,4 +347,10 @@ View Role + + Незавершені записи + + + Цей учасник досі володіє {0} незавершеними записами. Відкрийте кожен і перепризначте його іншому учаснику перед видаленням, інакше він залишиться у власності видаленого учасника. + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx new file mode 100644 index 000000000..3f436a5c6 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx @@ -0,0 +1,216 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + السجلات + سجل جديد + عرض السجل + تعديل السجل + السجلات لسنة + كل السنوات + كل الأنواع + كل الحالات + تصفية + لا توجد سجلات مطابقة للتصفية الحالية. + رقم السجل + مرجع المسودة + النوع + الحالة + الملخص + المؤلف + المالك + وقع في + أُنشئ + مكتمل + الإجراءات + التعريف + السرد + السرد مطلوب. + التقرير الأولي / الحالة + السبب + المحقق + الدورة + رمز الدورة + المدربون + نوع الاجتماع + الميسّر + الموقع + أفراد / حضور آخرون + جهات أخرى + وحدات أخرى + رقم القضية + موقع الجثة + أُعلنت الوفاة بواسطة + الوجهة + اسم جهة الاتصال + رقم جهة الاتصال + الوحدة + وقت النشاط + البلاغ + بدون بلاغ + لقطة البلاغ + المشاركون + الوحدات + المرفقات + إضافة مرفق + تم الإرسال + في الطريق + في الموقع + تم التحرير + في المقر + حفظ المسودة + حفظ وإنهاء + إنهاء + تعديل + التخلي عن التعديل + إلغاء + إلغاء المسودة + رمز السبب + السبب + السبب مطلوب. + أشهد أن هذا السجل دقيق وكامل حسب علمي. + السجل التاريخي + المراجعة + الانتقال + بواسطة + المجموع الاختباري + عرض + مقارنة بالسابق + المراجعة كما كانت + مقارنة المراجعات + لا توجد اختلافات بين هاتين المراجعتين. + تغيّر حقل مقيّد (القيم محجوبة) + القسم + الحقل + قبل + بعد + تصدير + طباعة + قسم مقيّد + ليست لديك صلاحية عرض الأقسام المقيدة من هذا السجل. + يوجد بالفعل سجل من هذا النوع لهذا البلاغ. راجعه قبل المتابعة، أو اذكر سبب الحاجة إلى سجل ثانٍ. + سبب السجل الثاني + تفعيل السجلات + تحل السجلات محل وحدة السجلات القديمة لهذه الإدارة. بعد التفعيل، تبقى السجلات القديمة قابلة للقراءة لكن لا يمكن إنشاؤها أو تعديلها أو حذفها. لا يمكن التراجع بعد إنهاء أي سجل. + السجلات القديمة + سجلات الوحدات القديمة + تغييرات الصلاحيات عند التفعيل + الصلاحية الحالية + صلاحية السجلات + الوصول الفعلي + قصر رؤية السجلات على مجموعة المستخدم + السبب / ملاحظات + أفهم أنه لا يمكن التراجع عن ذلك بعد إنهاء أي سجل، وأن إعداد الاحتفاظ مسؤولية هذه الإدارة. + تفعيل + التفعيل محظور + السجلات مفعّلة لهذه الإدارة لكن لم يتم تنشيطها بعد. يجب على مدير الإدارة تنشيطها قبل إنشاء السجلات. + تم تفعيل السجلات في + إعدادات السجلات + الإعداد الافتراضي لدورة الحياة لتعريفات الإدارة + موعد المراجعة (ساعات) + ترقيم السجلات + تضمين السنة في أرقام السجلات + عدد أرقام التسلسل + تسلسل منفصل لكل محطة/مجموعة + الاحتفاظ + الإعدادات الافتراضية: 7 سنوات للسجلات القياسية؛ دائم لسجلات الطب الشرعي والفئات المقيدة الأخرى. إعداد الاحتفاظ مسؤولية هذه الإدارة وفق قواعد اختصاصها. تطبق التغييرات مستقبلاً وتُدقق. + الافتراضي للإدارة (سنوات، فارغ = افتراضي النظام) + استثناءات لكل تعريف (0 = دائم) + هذه فئة مقيدة. يؤدي تحديد مدة إلى تمكين الحذف التلقائي لهذه السجلات. أكد للتطبيق. + الرؤية بين المجموعات + على مستوى الإدارة (افتراضي) + مقيّد بالمجموعة + يتطلب التقييد بالمجموعة أيضًا قفل صلاحية "رؤية سجلات المجموعات الأخرى" على المجموعة. يرى المؤلفون والمراجعون والمشاركون وطواقم الوحدات ومديرو الإدارة السجل دائمًا. + تم حفظ إعدادات السجلات. + حفظ + تم تغيير هذا السجل بواسطة شخص آخر منذ فتحه. أعد التحميل وطبّق تغييراتك مجددًا. + يوجد تعديل قيد التنفيذ. تبقى آخر مراجعة مكتملة هي المعتمدة حتى إنهاء التعديل. + تم حفظ السجل. + البداية + النهاية + المحطة / المجموعة + المعرف الخارجي + العودة إلى السجلات + الإعدادات + الدور + المصدر + حفظ بصيغة PDF + تصدير CSV + تصدير JSON + طُبع بواسطة + طُبع في + تخطيط الطباعة + تشمل عمليات التصدير {0} صفًا كحد أقصى؛ ضيّق عامل التصفية لتصدير المزيد. + البحث في السجلات + البحث غير متاح حاليًا؛ القائمة أدناه مصفّاة دون نص البحث. + تُعرض أقرب النتائج فقط؛ ضيّق نطاق البحث لرؤية المزيد. + نص السرد مشمول في البحث. + نص السرد غير مفهرس لهذا القسم؛ يشمل البحث أرقام السجلات والملخصات والحقول الآمنة الأخرى. + فهرس البحث + معطّل + متصل + غير متصل + السجلات المفهرسة + يعيد العامل بناء فهرس البحث تلقائيًا كلما تغيّرت سياسة الحماية أو إصدار الكتالوج للقسم. يُسحب البحث في السرد عند الاشتراك في الحماية المتقدمة للبيانات. + تخطيط الطباعة + كيفية عرض الترويسة على مطبوعات السجلات. تأتي الهوية والشعار من ملف القسم؛ أما محتوى السجل فيُطبع دائمًا من المراجعة المثبّتة. + إظهار الشعار + استخدام الاسم المختصر + إظهار العنوان + إظهار رقم الهاتف + إظهار الموقع الإلكتروني + سطر الترويسة 1 + سطر الترويسة 2 + نص التذييل + علامة مائية + حجم الصفحة + تنسيق التاريخ والوقت + سلسلة تنسيق .NET اختيارية، مثل yyyy-MM-dd HH:mm؛ اتركها فارغة لاستخدام الإعداد الافتراضي للقسم. + إصدار التخطيط + إدارة ملف القسم + لم يُرفع أي شعار بعد؛ تعرض المطبوعات اسم القسم فقط. + البحث غير متاح حاليًا، لذا تعذّر تصدير نتائج البحث. امسح البحث لتصدير القائمة المُصفّاة بدلًا من ذلك. + إعادة إسناد المسودة + المالك الجديد + السبب + إعادة إسناد + ينقل ملكية هذا السجل غير المكتمل إلى عضو آخر. لا يتغيّر المؤلف ولا سجل الإنشاء، ويتم تدقيق عملية النقل. + معاينة الأثر + ما الذي سيخفيه نطاق المجموعات قبل أن تؤكده. يحتفظ الأعضاء دائمًا بالسجلات التي كتبوها أو يملكونها أو يراجعونها أو يشاركون فيها أو يستجيبون لها بوحدتهم؛ ويرى مسؤولو الجهة كل شيء. + السجلات في الجهة + السجلات المخفية عن الأعضاء دون مجموعة + سجلات دون ارتباط بمجموعة (تبقى مرئية للجهة كلها) + الأعضاء دون مجموعة + صفوف السجلات القديمة دون مجموعة (تبقى مرئية للجهة كلها) + المجموعة + الأعضاء + السجلات + أفهم ما يخفيه نطاق المجموعات وأرغب في تفعيله. + لم يتم تفعيل نطاق المجموعات: أكّد معاينة الأثر أولًا. تم حفظ الإعدادات الأخرى. + المساءلة + مساءلة السجلات + من يدين بتقرير: السجلات المفتوحة والمراجعات المتأخرة والسجلات المُعادة حسب الشخص أو المحطة أو الوحدة، مع الوقت حتى الإتمام خلال الفترة. تُرسل التذكيرات إلى المالك مرة واحدة يوميًا على الأكثر لكل سجل. + حسب الشخص + حسب المحطة/المجموعة + حسب الوحدة + الفترة (أيام) + مفتوحة + مراجعات متأخرة + مُعادة دون تصحيح + أُتمت خلال الفترة + متوسط الساعات حتى الإتمام + الأقدم المفتوح + تذكير + تذكير الجميع + تم إرسال {0} تذكير(ات)، وتم تخطي {1}. + تم إرسال التذكير. + لم يُرسل التذكير ({0}). + غير مُسند + عرض السجلات المفتوحة + الإجماليات + المالك + المجموعة + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.cs b/Core/Resgrid.Localization/Areas/User/Records/Records.cs new file mode 100644 index 000000000..03978f87f --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Resgrid.Localization.Areas.User.Records +{ + public class Records + { + } +} diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx new file mode 100644 index 000000000..3a32fdc2d --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx @@ -0,0 +1,216 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Akten + Neue Akte + Akte anzeigen + Akte bearbeiten + Akten für das Jahr + Alle Jahre + Alle Typen + Alle Status + Filtern + Keine Akten entsprechen dem aktuellen Filter. + Akten-Nr. + Entwurfsreferenz + Typ + Status + Zusammenfassung + Verfasser + Verantwortlich + Ereignet + Erstellt + Abgeschlossen + Aktionen + Definition + Bericht + Ein Bericht ist erforderlich. + Erstmeldung / Zustand + Ursache + Ermittler + Kurs + Kurscode + Ausbilder + Besprechungsart + Leitung + Ort + Weitere Personen / Teilnehmer + Weitere Organisationen + Weitere Einheiten + Fallnummer + Fundort + Tod festgestellt durch + Ziel + Kontaktname + Kontaktnummer + Einheit + Aktivitätszeit + Einsatz + Kein Einsatz + Einsatz-Momentaufnahme + Teilnehmer + Einheiten + Anhänge + Anhang hinzufügen + Alarmiert + Unterwegs + Vor Ort + Freigegeben + Wache + Entwurf speichern + Speichern und abschließen + Abschließen + Nachtrag + Nachtrag verwerfen + Ungültig machen + Entwurf verwerfen + Grundcode + Begründung + Eine Begründung ist erforderlich. + Ich bestätige, dass diese Akte nach bestem Wissen richtig und vollständig ist. + Verlauf + Revision + Übergang + Durch + Prüfsumme + Anzeigen + Mit vorheriger vergleichen + Revision zum damaligen Stand + Revisionsvergleich + Keine Unterschiede zwischen diesen Revisionen. + Eingeschränktes Feld geändert (Werte zurückgehalten) + Abschnitt + Feld + Vorher + Nachher + Exportieren + Drucken + Eingeschränkter Abschnitt + Sie sind nicht berechtigt, die eingeschränkten Abschnitte dieser Akte anzuzeigen. + Für diesen Einsatz existiert bereits eine Akte dieses Typs. Prüfen Sie sie, bevor Sie fortfahren, oder begründen Sie die zweite Akte. + Grund für die zweite Akte + Akten aktivieren + Akten ersetzt das Modul Protokolle für diese Abteilung. Nach der Aktivierung bleiben bestehende Protokolle lesbar, können aber nicht mehr erstellt, bearbeitet oder gelöscht werden. Nach Abschluss einer Akte ist dies nicht mehr rückgängig zu machen. + Bestehende Protokolle + Bestehende Einheitenprotokolle + Berechtigungsänderungen bei Aktivierung + Bestehende Berechtigung + Akten-Berechtigung + Effektiver Zugriff + Aktensichtbarkeit auf die eigene Gruppe beschränken + Grund / Notizen + Ich verstehe, dass dies nach Abschluss einer Akte nicht rückgängig gemacht werden kann und dass die Aufbewahrungskonfiguration in der Verantwortung dieser Abteilung liegt. + Aktivieren + Aktivierung blockiert + Akten ist für diese Abteilung freigeschaltet, aber noch nicht aktiviert. Ein Administrator muss es aktivieren, bevor Akten erstellt werden können. + Akten aktiviert am + Akten-Einstellungen + Standard-Lebenszyklus für Abteilungsdefinitionen + Prüffrist (Stunden) + Aktennummerierung + Jahr in Aktennummern aufnehmen + Stellen der Laufnummer + Eigene Laufnummer je Wache/Gruppe + Aufbewahrung + Standard: 7 Jahre für normale Akten; unbefristet für Leichenschau und andere eingeschränkte Klassen. Die Aufbewahrungskonfiguration liegt in der Verantwortung dieser Abteilung. Änderungen gelten für die Zukunft und werden protokolliert. + Abteilungsstandard (Jahre, leer = Systemstandard) + Überschreibungen je Definition (0 = unbefristet) + Dies ist eine eingeschränkte Klasse. Eine Frist aktiviert das automatische Löschen dieser Akten. Zum Anwenden bestätigen. + Gruppenübergreifende Sichtbarkeit + Abteilungsweit (Standard) + Gruppenbezogen + Die Gruppenbeschränkung erfordert zusätzlich, dass die Berechtigung „Akten anderer Gruppen sehen“ auf die Gruppe beschränkt ist. Verfasser, Prüfer, Teilnehmer, Besatzungen und Administratoren sehen eine Akte immer. + Akten-Einstellungen gespeichert. + Speichern + Diese Akte wurde seit dem Öffnen von jemand anderem geändert. Laden Sie neu und wenden Sie Ihre Änderungen erneut an. + Ein Nachtrag ist in Bearbeitung. Die letzte abgeschlossene Revision bleibt bis zum Abschluss maßgeblich. + Akte gespeichert. + Beginn + Ende + Wache / Gruppe + Externe ID + Zurück zu Akten + Einstellungen + Rolle + Herkunft + Als PDF speichern + CSV exportieren + JSON exportieren + Gedruckt von + Gedruckt am + Druckvorlage + Exporte enthalten höchstens {0} Zeilen; grenzen Sie den Filter ein, um mehr zu exportieren. + Berichte durchsuchen + Die Suche ist derzeit nicht verfügbar; die Liste unten ist ohne den Suchtext gefiltert. + Es werden nur die besten Treffer angezeigt; grenzen Sie die Suche ein, um mehr zu sehen. + Der Berichtstext wird in die Suche einbezogen. + Der Berichtstext wird für diese Abteilung nicht indexiert; die Suche umfasst Berichtsnummern, Zusammenfassungen und andere unbedenkliche Felder. + Suchindex + Deaktiviert + Online + Offline + Indexierte Berichte + Der Worker baut den Suchindex automatisch neu auf, sobald sich die Schutzrichtlinie oder die Katalogversion der Abteilung ändert. Die Textsuche im Bericht entfällt, sobald Advanced Data Protection aktiviert ist. + Drucklayout + Wie der Briefkopf auf Berichtsausdrucken erscheint. Identität und Logo stammen aus dem Abteilungsprofil; der Berichtsinhalt selbst wird immer aus der festgeschriebenen Revision gedruckt. + Logo anzeigen + Kurznamen verwenden + Adresse anzeigen + Telefonnummer anzeigen + Website anzeigen + Briefkopfzeile 1 + Briefkopfzeile 2 + Fußzeilentext + Wasserzeichen + Seitenformat + Datums- und Zeitformat + Optionale .NET-Formatzeichenfolge, z. B. yyyy-MM-dd HH:mm; leer lassen für die Abteilungsvorgabe. + Layout-Version + Abteilungsprofil verwalten + Es wurde noch kein Logo hochgeladen; Ausdrucke zeigen nur den Abteilungsnamen. + Die Suche ist derzeit nicht verfügbar, daher konnten die Suchtreffer nicht exportiert werden. Leeren Sie die Suche, um stattdessen die gefilterte Liste zu exportieren. + Entwurf neu zuweisen + Neuer Eigentümer + Grund + Neu zuweisen + Überträgt den Besitz dieses unfertigen Datensatzes an ein anderes Mitglied. Autor und Erstellungshistorie bleiben unverändert; die Übertragung wird protokolliert. + Auswirkungsvorschau + Was die Gruppenbeschränkung verbergen würde, bevor Sie sie bestätigen. Mitglieder behalten immer die Datensätze, die sie verfasst, besitzen, prüfen, an denen sie teilnehmen oder mit ihrer Einheit reagieren; Abteilungsadministratoren sehen alles. + Datensätze in der Abteilung + Für Mitglieder ohne Gruppe verborgene Datensätze + Datensätze ohne Gruppenanker (bleiben abteilungsweit) + Mitglieder ohne Gruppe + Alte Protokollzeilen ohne Gruppe (bleiben abteilungsweit) + Gruppe + Mitglieder + Datensätze + Ich verstehe, was die Gruppenbeschränkung verbirgt, und möchte sie aktivieren. + Die Gruppenbeschränkung wurde nicht aktiviert: Bestätigen Sie zuerst die Auswirkungsvorschau. Die übrigen Einstellungen wurden gespeichert. + Verantwortlichkeit + Verantwortlichkeit der Datensätze + Wer einen Bericht schuldet: offene Datensätze, überfällige Prüfungen und zurückgegebene Datensätze nach Person, Wache oder Einheit, mit der Zeit bis zur Fertigstellung im Zeitraum. Erinnerungen gehen an den Eigentümer, höchstens einmal täglich pro Datensatz. + Nach Person + Nach Wache/Gruppe + Nach Einheit + Zeitraum (Tage) + Offen + Überfällige Prüfungen + Zurückgegeben, nicht korrigiert + Im Zeitraum fertiggestellt + Ø Stunden bis Fertigstellung + Ältester offener + Erinnern + Alle erinnern + {0} Erinnerung(en) gesendet, {1} übersprungen. + Erinnerung gesendet. + Erinnerung nicht gesendet ({0}). + Nicht zugewiesen + Offene Datensätze anzeigen + Summen + Eigentümer + Gruppe + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx new file mode 100644 index 000000000..0f610ad36 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx @@ -0,0 +1,216 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Αρχεία + Νέο αρχείο + Προβολή αρχείου + Επεξεργασία αρχείου + Αρχεία για το έτος + Όλα τα έτη + Όλοι οι τύποι + Όλες οι καταστάσεις + Φίλτρο + Κανένα αρχείο δεν ταιριάζει με το τρέχον φίλτρο. + Αρ. αρχείου + Αναφορά προσχεδίου + Τύπος + Κατάσταση + Σύνοψη + Συντάκτης + Κάτοχος + Συνέβη + Δημιουργήθηκε + Οριστικοποιήθηκε + Ενέργειες + Ορισμός + Αφήγηση + Απαιτείται αφήγηση. + Αρχική αναφορά / κατάσταση + Αιτία + Ερευνητής + Μάθημα + Κωδικός μαθήματος + Εκπαιδευτές + Τύπος συνάντησης + Συντονιστής + Τοποθεσία + Λοιπό προσωπικό / παρόντες + Άλλες υπηρεσίες + Άλλες μονάδες + Αριθμός υπόθεσης + Θέση σώματος + Δήλωση θανάτου από + Προορισμός + Όνομα επαφής + Αριθμός επαφής + Μονάδα + Ώρα δραστηριότητας + Κλήση + Χωρίς κλήση + Στιγμιότυπο κλήσης + Συμμετέχοντες + Μονάδες + Συνημμένα + Προσθήκη συνημμένου + Αποστολή + Καθ' οδόν + Επί τόπου + Αποδέσμευση + Στη βάση + Αποθήκευση προσχεδίου + Αποθήκευση και οριστικοποίηση + Οριστικοποίηση + Τροποποίηση + Εγκατάλειψη τροποποίησης + Ακύρωση + Ακύρωση προσχεδίου + Κωδικός αιτίας + Αιτία + Απαιτείται αιτία. + Βεβαιώνω ότι το αρχείο αυτό είναι ακριβές και πλήρες εξ όσων γνωρίζω. + Ιστορικό + Αναθεώρηση + Μετάβαση + Από + Άθροισμα ελέγχου + Προβολή + Σύγκριση με προηγούμενη + Αναθεώρηση όπως ήταν + Σύγκριση αναθεωρήσεων + Δεν υπάρχουν διαφορές μεταξύ των αναθεωρήσεων. + Άλλαξε περιορισμένο πεδίο (τιμές αποκρύφθηκαν) + Ενότητα + Πεδίο + Πριν + Μετά + Εξαγωγή + Εκτύπωση + Περιορισμένη ενότητα + Δεν έχετε δικαίωμα προβολής των περιορισμένων ενοτήτων αυτού του αρχείου. + Υπάρχει ήδη αρχείο αυτού του τύπου για την κλήση. Ελέγξτε το πριν συνεχίσετε ή δηλώστε γιατί χρειάζεται δεύτερο. + Αιτία για δεύτερο αρχείο + Ενεργοποίηση αρχείων + Τα Αρχεία αντικαθιστούν τα Ημερολόγια για αυτό το τμήμα. Μετά την ενεργοποίηση, τα υπάρχοντα ημερολόγια παραμένουν αναγνώσιμα αλλά δεν δημιουργούνται, επεξεργάζονται ή διαγράφονται. Δεν αναιρείται μετά την οριστικοποίηση αρχείου. + Υπάρχοντα ημερολόγια + Υπάρχοντα ημερολόγια μονάδων + Αλλαγές δικαιωμάτων κατά την ενεργοποίηση + Υπάρχον δικαίωμα + Δικαίωμα αρχείων + Ισχύουσα πρόσβαση + Περιορισμός ορατότητας αρχείων στην ομάδα του χρήστη + Αιτία / σημειώσεις + Κατανοώ ότι δεν αναιρείται μετά την οριστικοποίηση αρχείου και ότι η διαμόρφωση διατήρησης είναι ευθύνη του τμήματος. + Ενεργοποίηση + Η ενεργοποίηση αποκλείεται + Τα Αρχεία είναι διαθέσιμα αλλά δεν έχουν ενεργοποιηθεί. Ένας διαχειριστής πρέπει να τα ενεργοποιήσει πριν δημιουργηθούν αρχεία. + Ενεργοποίηση αρχείων στις + Ρυθμίσεις αρχείων + Προεπιλεγμένος κύκλος ζωής για ορισμούς τμήματος + Προθεσμία ελέγχου (ώρες) + Αρίθμηση αρχείων + Συμπερίληψη έτους στους αριθμούς + Ψηφία ακολουθίας + Ξεχωριστή ακολουθία ανά σταθμό/ομάδα + Διατήρηση + Προεπιλογές: 7 έτη για τυπικά αρχεία· μόνιμα για Ιατροδικαστή και άλλες περιορισμένες κλάσεις. Η διατήρηση είναι ευθύνη του τμήματος. Οι αλλαγές ισχύουν μελλοντικά και ελέγχονται. + Προεπιλογή τμήματος (έτη, κενό = συστήματος) + Παρακάμψεις ανά ορισμό (0 = μόνιμο) + Περιορισμένη κλάση. Ο ορισμός περιόδου ενεργοποιεί αυτόματη διαγραφή. Επιβεβαιώστε για εφαρμογή. + Ορατότητα μεταξύ ομάδων + Σε όλο το τμήμα (προεπιλογή) + Ανά ομάδα + Απαιτείται επίσης το δικαίωμα «Προβολή αρχείων άλλων ομάδων» κλειδωμένο ανά ομάδα. Συντάκτες, ελεγκτές, συμμετέχοντες, πληρώματα και διαχειριστές βλέπουν πάντα το αρχείο. + Οι ρυθμίσεις αποθηκεύτηκαν. + Αποθήκευση + Το αρχείο άλλαξε από άλλον από τότε που το ανοίξατε. Επαναφορτώστε και εφαρμόστε ξανά τις αλλαγές. + Τροποποίηση σε εξέλιξη. Η τελευταία οριστικοποιημένη αναθεώρηση παραμένει έγκυρη μέχρι την οριστικοποίηση. + Το αρχείο αποθηκεύτηκε. + Έναρξη + Λήξη + Σταθμός / ομάδα + Εξωτερικό ID + Επιστροφή στα αρχεία + Ρυθμίσεις + Ρόλος + Προέλευση + Αποθήκευση ως PDF + Εξαγωγή CSV + Εξαγωγή JSON + Εκτυπώθηκε από + Εκτυπώθηκε στις + Διάταξη εκτύπωσης + Οι εξαγωγές περιλαμβάνουν έως {0} γραμμές· περιορίστε το φίλτρο για να εξαγάγετε περισσότερες. + Αναζήτηση αναφορών + Η αναζήτηση δεν είναι διαθέσιμη αυτήν τη στιγμή· η παρακάτω λίστα είναι φιλτραρισμένη χωρίς το κείμενο αναζήτησης. + Εμφανίζονται μόνο οι πλησιέστερες αντιστοιχίες· περιορίστε την αναζήτηση για να δείτε περισσότερες. + Το κείμενο της αφήγησης περιλαμβάνεται στην αναζήτηση. + Το κείμενο της αφήγησης δεν ευρετηριάζεται για αυτήν την υπηρεσία· η αναζήτηση καλύπτει αριθμούς αναφορών, περιλήψεις και άλλα ασφαλή πεδία. + Ευρετήριο αναζήτησης + Απενεργοποιημένο + Σε σύνδεση + Εκτός σύνδεσης + Ευρετηριασμένες αναφορές + Ο worker ανακατασκευάζει αυτόματα το ευρετήριο αναζήτησης όποτε αλλάζει η πολιτική προστασίας ή η έκδοση καταλόγου της υπηρεσίας. Η αναζήτηση στην αφήγηση αποσύρεται με την ένταξη στην Προηγμένη Προστασία Δεδομένων. + Διάταξη εκτύπωσης + Πώς αποδίδεται το επιστολόχαρτο στις εκτυπώσεις αναφορών. Η ταυτότητα και το λογότυπο προέρχονται από το προφίλ της υπηρεσίας· το περιεχόμενο της αναφοράς εκτυπώνεται πάντα από την κλειδωμένη αναθεώρηση. + Εμφάνιση λογοτύπου + Χρήση σύντομου ονόματος + Εμφάνιση διεύθυνσης + Εμφάνιση αριθμού τηλεφώνου + Εμφάνιση ιστότοπου + Γραμμή επιστολόχαρτου 1 + Γραμμή επιστολόχαρτου 2 + Κείμενο υποσέλιδου + Υδατογράφημα + Μέγεθος σελίδας + Μορφή ημερομηνίας και ώρας + Προαιρετική συμβολοσειρά μορφής .NET, π.χ. yyyy-MM-dd HH:mm· αφήστε κενό για την προεπιλογή της υπηρεσίας. + Έκδοση διάταξης + Διαχείριση προφίλ υπηρεσίας + Δεν έχει μεταφορτωθεί ακόμη λογότυπο· οι εκτυπώσεις εμφανίζουν μόνο το όνομα της υπηρεσίας. + Η αναζήτηση δεν είναι διαθέσιμη αυτή τη στιγμή, οπότε τα αποτελέσματα δεν εξήχθησαν. Καθαρίστε την αναζήτηση για να εξαγάγετε τη φιλτραρισμένη λίστα. + Ανάθεση προσχεδίου σε άλλον + Νέος κάτοχος + Αιτία + Ανάθεση σε άλλον + Μεταβιβάζει την κυριότητα αυτής της μη οριστικοποιημένης εγγραφής σε άλλο μέλος. Ο συντάκτης και το ιστορικό δημιουργίας δεν αλλάζουν και η μεταβίβαση ελέγχεται. + Προεπισκόπηση επίπτωσης + Τι θα απέκρυπτε ο περιορισμός ανά ομάδα, πριν τον επιβεβαιώσετε. Τα μέλη διατηρούν πάντα τις εγγραφές που συνέταξαν, κατέχουν, ελέγχουν, συμμετέχουν ή ανταποκρίνονται με τη μονάδα τους· οι διαχειριστές της υπηρεσίας βλέπουν τα πάντα. + Εγγραφές στην υπηρεσία + Εγγραφές κρυφές από μέλη χωρίς ομάδα + Εγγραφές χωρίς αγκύρωση ομάδας (παραμένουν ορατές σε όλη την υπηρεσία) + Μέλη χωρίς ομάδα + Παλαιές γραμμές ημερολογίου χωρίς ομάδα (παραμένουν ορατές σε όλη την υπηρεσία) + Ομάδα + Μέλη + Εγγραφές + Κατανοώ τι αποκρύπτει ο περιορισμός ανά ομάδα και θέλω να τον ενεργοποιήσω. + Ο περιορισμός ανά ομάδα δεν ενεργοποιήθηκε: επιβεβαιώστε πρώτα την προεπισκόπηση επίπτωσης. Οι υπόλοιπες ρυθμίσεις αποθηκεύτηκαν. + Λογοδοσία + Λογοδοσία εγγραφών + Ποιος οφείλει αναφορά: ανοικτές εγγραφές, εκπρόθεσμοι έλεγχοι και επιστραμμένες εγγραφές ανά άτομο, σταθμό ή μονάδα, με τον χρόνο έως την οριστικοποίηση στην περίοδο. Οι υπενθυμίσεις πηγαίνουν στον κάτοχο, το πολύ μία φορά την ημέρα ανά εγγραφή. + Ανά άτομο + Ανά σταθμό/ομάδα + Ανά μονάδα + Περίοδος (ημέρες) + Ανοικτές + Εκπρόθεσμοι έλεγχοι + Επιστραμμένες, μη διορθωμένες + Οριστικοποιημένες στην περίοδο + Μέσες ώρες έως την οριστικοποίηση + Παλαιότερη ανοικτή + Υπενθύμιση + Υπενθύμιση σε όλους + Στάλθηκαν {0} υπενθυμίσεις, παραλείφθηκαν {1}. + Η υπενθύμιση στάλθηκε. + Η υπενθύμιση δεν στάλθηκε ({0}). + Μη ανατεθειμένο + Εμφάνιση ανοικτών εγγραφών + Σύνολα + Κάτοχος + Ομάδα + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx new file mode 100644 index 000000000..4cf0dcd8c --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx @@ -0,0 +1,216 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Records + New Record + View Record + Edit Record + Records for year + All years + All types + All states + Filter + No records match the current filter. + Record # + Draft reference + Type + State + Summary + Author + Owner + Occurred + Created + Finalized + Actions + Definition + Narrative + A narrative is required. + Initial report / condition + Cause + Investigator + Course + Course code + Instructors + Meeting type + Facilitator + Location + Other personnel / attendees + Other agencies + Other units + Case number + Body location + Pronounced deceased by + Destination + Contact name + Contact number + Unit + Activity time + Call + No call + Call snapshot + Participants + Units + Attachments + Add attachment + Dispatched + En route + On scene + Released + In quarters + Save draft + Save and finalize + Finalize + Amend + Abandon amendment + Void + Cancel draft + Reason code + Reason + A reason is required. + I attest that this record is accurate and complete to the best of my knowledge. + History + Revision + Transition + By + Checksum + View + Compare with previous + Revision as it stood + Revision comparison + No differences between these revisions. + Restricted field changed (values withheld) + Section + Field + Before + After + Export + Print + Restricted section + You do not have permission to view the restricted sections of this record. + A record of this type already exists for this call. Review it before continuing, or state why a second record is needed. + Reason for a second record + Activate Records + Records replaces the Logs module for this department. After activation, existing Logs remain readable in the Logs module but can no longer be created, edited or deleted. This cannot be undone once a record has been finalized. + Existing logs + Existing unit logs + Permission changes at activation + Existing permission + Records permission + Effective access + Limit record visibility to the viewer's own group + Reason / notes + I understand that this cannot be reversed once a record is finalized, and that retention configuration is this department's responsibility. + Activate + Activation is blocked + Records is enabled for this department but has not been activated yet. A department administrator must activate it before records can be created. + Records activated on + Records Settings + Default lifecycle preset for department definitions + Review due (hours) + Record numbering + Include the year in record numbers + Sequence digits + Separate sequence per station/group + Retention + Shipped defaults: 7 years for standard records; permanent for Coroner and other restricted classes. Retention configuration is this department's responsibility under its own jurisdiction's rules. Changes apply prospectively and are audited. + Department default (years, blank = system default) + Per-definition overrides (0 = permanent) + This is a restricted class. Setting a period enables automatic purge of these records. Confirm to apply. + Cross-group visibility + Department-wide (default) + Group-scoped + Group scoping also requires the "See other groups' Records" permission to be locked to group. Authors, reviewers, named participants, responding unit crews and department administrators always see a record. + Records settings saved. + Save + This record was changed by someone else since you opened it. Reload and apply your changes again. + An amendment is in progress. The last finalized revision remains authoritative until the amendment is finalized. + Record saved. + Start + End + Station / group + External ID + Back to Records + Settings + Role + Provenance + Save as PDF + Export CSV + Export JSON + Printed by + Printed on + Layout + Exports include at most {0} rows; narrow the filter to export more. + Search records + Search is unavailable right now; the list below is filtered without the search text. + Only the closest matches are shown; narrow the search to see more. + Narrative text is included in search. + Narrative text is not indexed for this department; search covers record numbers, summaries and other safe fields. + Search index + Disabled + Online + Offline + Indexed records + The worker rebuilds the search index automatically whenever the department's protection policy or catalog version changes. Narrative search is withdrawn when Advanced Data Protection is enrolled. + Print layout + How the letterhead renders on record prints. Identity and logo come from the Department Profile; the record content itself always prints from the pinned revision. + Show logo + Use the short name + Show address + Show phone number + Show website + Letterhead line 1 + Letterhead line 2 + Footer text + Watermark label + Page size + Date and time format + Optional .NET format string, for example yyyy-MM-dd HH:mm; leave empty for the department default. + Layout version + Manage the Department Profile + No logo has been uploaded yet; prints show the department name only. + Search is unavailable right now, so the search hits could not be exported. Clear the search to export the filtered list instead. + Reassign draft + New owner + Reason + Reassign + Transfers ownership of this unfinished Record to another member. The author and the creation history do not change, and the transfer is audited. + Impact preview + What group scoping would hide, before you confirm it. Members always keep the Records they authored, own, review, participate in or respond to with their unit, and department administrators see everything. + Records in the department + Records hidden from members of no group + Records with no group anchor (stay department-wide) + Members in no group + Legacy log rows with no group (stay department-wide) + Group + Members + Records + I understand what group scoping hides and want to turn it on. + Group scoping was not turned on: confirm the impact preview first. The other settings were saved. + Accountability + Records accountability + Who owes a report: open Records, overdue reviews and returned Records by person, station or unit, with time to finalize over the window. Reminders go to the owner, at most once a day per Record. + By person + By station/group + By unit + Window (days) + Open + Overdue reviews + Returned, not corrected + Finalized in window + Avg. hours to finalize + Oldest open + Remind + Remind all + {0} reminder(s) sent, {1} skipped. + Reminder sent. + Reminder not sent ({0}). + Unassigned + Show open records + Totals + Owner + Group + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx new file mode 100644 index 000000000..fae66b68b --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx @@ -0,0 +1,216 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Registros + Nuevo registro + Ver registro + Editar registro + Registros del año + Todos los años + Todos los tipos + Todos los estados + Filtrar + Ningún registro coincide con el filtro actual. + N.º de registro + Referencia del borrador + Tipo + Estado + Resumen + Autor + Propietario + Ocurrió + Creado + Finalizado + Acciones + Definición + Narrativa + Se requiere una narrativa. + Informe inicial / condición + Causa + Investigador + Curso + Código del curso + Instructores + Tipo de reunión + Facilitador + Ubicación + Otro personal / asistentes + Otras agencias + Otras unidades + Número de caso + Ubicación del cuerpo + Declarado fallecido por + Destino + Nombre de contacto + Número de contacto + Unidad + Hora de la actividad + Llamada + Sin llamada + Instantánea de la llamada + Participantes + Unidades + Adjuntos + Agregar adjunto + Despachado + En camino + En escena + Liberado + En cuartel + Guardar borrador + Guardar y finalizar + Finalizar + Enmendar + Abandonar enmienda + Anular + Cancelar borrador + Código de motivo + Motivo + Se requiere un motivo. + Certifico que este registro es exacto y completo a mi leal saber y entender. + Historial + Revisión + Transición + Por + Suma de verificación + Ver + Comparar con la anterior + Revisión tal como estaba + Comparación de revisiones + No hay diferencias entre estas revisiones. + Campo restringido modificado (valores ocultos) + Sección + Campo + Antes + Después + Exportar + Imprimir + Sección restringida + No tiene permiso para ver las secciones restringidas de este registro. + Ya existe un registro de este tipo para esta llamada. Revíselo antes de continuar o indique por qué se necesita otro. + Motivo del segundo registro + Activar registros + Registros reemplaza el módulo de Bitácoras para este departamento. Tras la activación, las bitácoras existentes siguen siendo legibles pero ya no pueden crearse, editarse ni eliminarse. No se puede deshacer una vez finalizado un registro. + Bitácoras existentes + Bitácoras de unidad existentes + Cambios de permisos al activar + Permiso existente + Permiso de registros + Acceso efectivo + Limitar la visibilidad de registros al grupo del usuario + Motivo / notas + Entiendo que esto no puede revertirse una vez finalizado un registro y que la configuración de retención es responsabilidad de este departamento. + Activar + La activación está bloqueada + Registros está habilitado para este departamento pero aún no se ha activado. Un administrador debe activarlo antes de crear registros. + Registros activados el + Configuración de registros + Ciclo de vida predeterminado para definiciones del departamento + Plazo de revisión (horas) + Numeración de registros + Incluir el año en los números + Dígitos de secuencia + Secuencia separada por estación/grupo + Retención + Valores predeterminados: 7 años para registros estándar; permanente para Forense y otras clases restringidas. La retención es responsabilidad de este departamento. Los cambios se aplican en adelante y se auditan. + Predeterminado del departamento (años, vacío = sistema) + Excepciones por definición (0 = permanente) + Clase restringida. Definir un periodo habilita la eliminación automática de estos registros. Confirme para aplicar. + Visibilidad entre grupos + Todo el departamento (predeterminado) + Por grupo + El alcance por grupo también requiere que el permiso "Ver registros de otros grupos" esté bloqueado por grupo. Autores, revisores, participantes, dotaciones y administradores siempre ven un registro. + Configuración de registros guardada. + Guardar + Otra persona modificó este registro desde que lo abrió. Recargue y aplique sus cambios de nuevo. + Hay una enmienda en curso. La última revisión finalizada sigue siendo la válida hasta que se finalice la enmienda. + Registro guardado. + Inicio + Fin + Estación / grupo + ID externo + Volver a registros + Configuración + Rol + Procedencia + Guardar como PDF + Exportar CSV + Exportar JSON + Impreso por + Impreso el + Diseño de impresión + Las exportaciones incluyen como máximo {0} filas; acote el filtro para exportar más. + Buscar registros + La búsqueda no está disponible en este momento; la lista siguiente está filtrada sin el texto de búsqueda. + Solo se muestran las coincidencias más cercanas; acote la búsqueda para ver más. + El texto narrativo se incluye en la búsqueda. + El texto narrativo no se indexa para este departamento; la búsqueda abarca números de registro, resúmenes y otros campos seguros. + Índice de búsqueda + Desactivado + En línea + Sin conexión + Registros indexados + El worker reconstruye el índice de búsqueda automáticamente cuando cambia la política de protección o la versión del catálogo del departamento. La búsqueda narrativa se retira al inscribirse en la Protección Avanzada de Datos. + Diseño de impresión + Cómo se representa el membrete en las impresiones de registros. La identidad y el logotipo provienen del perfil del departamento; el contenido del registro siempre se imprime desde la revisión fijada. + Mostrar logotipo + Usar el nombre corto + Mostrar dirección + Mostrar número de teléfono + Mostrar sitio web + Línea de membrete 1 + Línea de membrete 2 + Texto de pie de página + Marca de agua + Tamaño de página + Formato de fecha y hora + Cadena de formato .NET opcional, por ejemplo yyyy-MM-dd HH:mm; déjela vacía para usar el valor predeterminado del departamento. + Versión del diseño + Administrar el perfil del departamento + Aún no se ha subido ningún logotipo; las impresiones muestran solo el nombre del departamento. + La búsqueda no está disponible en este momento, por lo que no se pudieron exportar los resultados. Borre la búsqueda para exportar la lista filtrada. + Reasignar borrador + Nuevo propietario + Motivo + Reasignar + Transfiere la propiedad de este registro sin finalizar a otro miembro. El autor y el historial de creación no cambian, y la transferencia queda auditada. + Vista previa del impacto + Lo que ocultaría el alcance por grupo antes de confirmarlo. Los miembros conservan siempre los registros que crearon, poseen, revisan, en los que participan o a los que responden con su unidad; los administradores del departamento lo ven todo. + Registros en el departamento + Registros ocultos para miembros sin grupo + Registros sin ancla de grupo (siguen siendo de todo el departamento) + Miembros sin grupo + Filas de registros antiguos sin grupo (siguen siendo de todo el departamento) + Grupo + Miembros + Registros + Entiendo lo que oculta el alcance por grupo y quiero activarlo. + El alcance por grupo no se activó: confirme primero la vista previa del impacto. El resto de ajustes se guardaron. + Responsabilidad + Responsabilidad de registros + Quién debe un informe: registros abiertos, revisiones vencidas y registros devueltos por persona, estación o unidad, con el tiempo hasta finalizar en el periodo. Los recordatorios van al propietario, como máximo una vez al día por registro. + Por persona + Por estación/grupo + Por unidad + Periodo (días) + Abiertos + Revisiones vencidas + Devueltos sin corregir + Finalizados en el periodo + Horas medias hasta finalizar + Abierto más antiguo + Recordar + Recordar a todos + {0} recordatorio(s) enviado(s), {1} omitido(s). + Recordatorio enviado. + Recordatorio no enviado ({0}). + Sin asignar + Mostrar registros abiertos + Totales + Propietario + Grupo + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx new file mode 100644 index 000000000..bdb79387d --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx @@ -0,0 +1,216 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Dossiers + Nouveau dossier + Voir le dossier + Modifier le dossier + Dossiers de l'année + Toutes les années + Tous les types + Tous les états + Filtrer + Aucun dossier ne correspond au filtre actuel. + N° de dossier + Référence du brouillon + Type + État + Résumé + Auteur + Propriétaire + Survenu + Créé + Finalisé + Actions + Définition + Récit + Un récit est requis. + Rapport initial / état + Cause + Enquêteur + Cours + Code du cours + Instructeurs + Type de réunion + Animateur + Lieu + Autre personnel / participants + Autres organismes + Autres unités + Numéro de dossier + Emplacement du corps + Décès constaté par + Destination + Nom du contact + Numéro du contact + Unité + Heure de l'activité + Appel + Aucun appel + Instantané de l'appel + Participants + Unités + Pièces jointes + Ajouter une pièce jointe + Engagé + En route + Sur les lieux + Libéré + À la caserne + Enregistrer le brouillon + Enregistrer et finaliser + Finaliser + Amender + Abandonner l'amendement + Annuler + Annuler le brouillon + Code de motif + Motif + Un motif est requis. + J'atteste que ce dossier est exact et complet à ma connaissance. + Historique + Révision + Transition + Par + Somme de contrôle + Voir + Comparer avec la précédente + Révision telle qu'elle était + Comparaison des révisions + Aucune différence entre ces révisions. + Champ restreint modifié (valeurs masquées) + Section + Champ + Avant + Après + Exporter + Imprimer + Section restreinte + Vous n'êtes pas autorisé à consulter les sections restreintes de ce dossier. + Un dossier de ce type existe déjà pour cet appel. Consultez-le avant de continuer, ou indiquez pourquoi un second dossier est nécessaire. + Motif du second dossier + Activer les dossiers + Les dossiers remplacent le module Journaux pour ce service. Après l'activation, les journaux existants restent consultables mais ne peuvent plus être créés, modifiés ni supprimés. Irréversible dès qu'un dossier est finalisé. + Journaux existants + Journaux d'unité existants + Modifications de permissions à l'activation + Permission existante + Permission dossiers + Accès effectif + Limiter la visibilité des dossiers au groupe de l'utilisateur + Motif / notes + Je comprends que ceci est irréversible dès qu'un dossier est finalisé et que la configuration de conservation relève de ce service. + Activer + L'activation est bloquée + Les dossiers sont disponibles pour ce service mais pas encore activés. Un administrateur doit les activer avant de créer des dossiers. + Dossiers activés le + Paramètres des dossiers + Cycle de vie par défaut des définitions du service + Délai de revue (heures) + Numérotation des dossiers + Inclure l'année dans les numéros + Chiffres de la séquence + Séquence distincte par caserne/groupe + Conservation + Valeurs par défaut : 7 ans pour les dossiers standard ; permanent pour Coroner et les autres classes restreintes. La conservation relève de ce service. Les modifications s'appliquent pour l'avenir et sont auditées. + Valeur par défaut du service (années, vide = système) + Exceptions par définition (0 = permanent) + Classe restreinte. Définir une durée active la purge automatique de ces dossiers. Confirmez pour appliquer. + Visibilité entre groupes + Tout le service (par défaut) + Par groupe + La portée par groupe exige aussi que la permission « Voir les dossiers des autres groupes » soit verrouillée par groupe. Auteurs, réviseurs, participants, équipages et administrateurs voient toujours un dossier. + Paramètres des dossiers enregistrés. + Enregistrer + Ce dossier a été modifié par quelqu'un d'autre depuis son ouverture. Rechargez et appliquez de nouveau vos modifications. + Un amendement est en cours. La dernière révision finalisée reste de référence jusqu'à sa finalisation. + Dossier enregistré. + Début + Fin + Caserne / groupe + ID externe + Retour aux dossiers + Paramètres + Rôle + Provenance + Enregistrer en PDF + Exporter en CSV + Exporter en JSON + Imprimé par + Imprimé le + Mise en page + Les exports contiennent au plus {0} lignes ; affinez le filtre pour en exporter davantage. + Rechercher des rapports + La recherche est indisponible pour le moment ; la liste ci-dessous est filtrée sans le texte recherché. + Seules les correspondances les plus proches sont affichées ; affinez la recherche pour en voir davantage. + Le texte du récit est inclus dans la recherche. + Le texte du récit n'est pas indexé pour ce service ; la recherche porte sur les numéros de rapport, les résumés et les autres champs sûrs. + Index de recherche + Désactivé + En ligne + Hors ligne + Rapports indexés + Le worker reconstruit automatiquement l'index de recherche dès que la politique de protection ou la version du catalogue du service change. La recherche dans le récit est retirée lors de l'inscription à la Protection avancée des données. + Mise en page d'impression + Comment l'en-tête est rendu sur les impressions de rapports. L'identité et le logo proviennent du profil du service ; le contenu du rapport s'imprime toujours depuis la révision figée. + Afficher le logo + Utiliser le nom court + Afficher l'adresse + Afficher le numéro de téléphone + Afficher le site web + Ligne d'en-tête 1 + Ligne d'en-tête 2 + Texte de pied de page + Filigrane + Format de page + Format de date et d'heure + Chaîne de format .NET facultative, par exemple yyyy-MM-dd HH:mm ; laissez vide pour la valeur par défaut du service. + Version de la mise en page + Gérer le profil du service + Aucun logo n'a encore été téléversé ; les impressions n'affichent que le nom du service. + La recherche est indisponible pour le moment ; les résultats n'ont donc pas pu être exportés. Effacez la recherche pour exporter la liste filtrée. + Réattribuer le brouillon + Nouveau propriétaire + Motif + Réattribuer + Transfère la propriété de cet enregistrement non finalisé à un autre membre. L'auteur et l'historique de création ne changent pas et le transfert est audité. + Aperçu de l'impact + Ce que la restriction par groupe masquerait, avant confirmation. Les membres conservent toujours les enregistrements qu'ils ont rédigés, possèdent, examinent, auxquels ils participent ou répondent avec leur unité ; les administrateurs du service voient tout. + Enregistrements du service + Enregistrements masqués aux membres sans groupe + Enregistrements sans ancre de groupe (restent visibles par tout le service) + Membres sans groupe + Lignes de journaux hérités sans groupe (restent visibles par tout le service) + Groupe + Membres + Enregistrements + Je comprends ce que masque la restriction par groupe et je souhaite l'activer. + La restriction par groupe n'a pas été activée : confirmez d'abord l'aperçu de l'impact. Les autres paramètres ont été enregistrés. + Responsabilité + Responsabilité des enregistrements + Qui doit un rapport : enregistrements ouverts, examens en retard et enregistrements renvoyés par personne, caserne ou unité, avec le délai de finalisation sur la période. Les rappels vont au propriétaire, au plus une fois par jour et par enregistrement. + Par personne + Par caserne/groupe + Par unité + Période (jours) + Ouverts + Examens en retard + Renvoyés, non corrigés + Finalisés sur la période + Heures moyennes avant finalisation + Plus ancien ouvert + Rappeler + Rappeler à tous + {0} rappel(s) envoyé(s), {1} ignoré(s). + Rappel envoyé. + Rappel non envoyé ({0}). + Non attribué + Afficher les enregistrements ouverts + Totaux + Propriétaire + Groupe + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx new file mode 100644 index 000000000..d2c8ad126 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx @@ -0,0 +1,216 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Registri + Nuovo registro + Visualizza registro + Modifica registro + Registri per l'anno + Tutti gli anni + Tutti i tipi + Tutti gli stati + Filtra + Nessun registro corrisponde al filtro corrente. + N. registro + Riferimento bozza + Tipo + Stato + Riepilogo + Autore + Proprietario + Avvenuto + Creato + Finalizzato + Azioni + Definizione + Narrativa + La narrativa è obbligatoria. + Rapporto iniziale / condizione + Causa + Investigatore + Corso + Codice corso + Istruttori + Tipo di riunione + Facilitatore + Luogo + Altro personale / partecipanti + Altre agenzie + Altre unità + Numero caso + Posizione del corpo + Dichiarato deceduto da + Destinazione + Nome contatto + Numero contatto + Unità + Ora attività + Chiamata + Nessuna chiamata + Istantanea chiamata + Partecipanti + Unità + Allegati + Aggiungi allegato + Inviato + In viaggio + Sul posto + Rilasciato + In sede + Salva bozza + Salva e finalizza + Finalizza + Rettifica + Abbandona rettifica + Annulla + Annulla bozza + Codice motivo + Motivo + Il motivo è obbligatorio. + Attesto che questo registro è accurato e completo per quanto a mia conoscenza. + Cronologia + Revisione + Transizione + Da + Checksum + Visualizza + Confronta con precedente + Revisione come era + Confronto revisioni + Nessuna differenza tra queste revisioni. + Campo riservato modificato (valori nascosti) + Sezione + Campo + Prima + Dopo + Esporta + Stampa + Sezione riservata + Non sei autorizzato a visualizzare le sezioni riservate di questo registro. + Esiste già un registro di questo tipo per questa chiamata. Verificalo prima di continuare o indica perché serve un secondo. + Motivo del secondo registro + Attiva registri + Registri sostituisce il modulo Log per questo dipartimento. Dopo l'attivazione i log esistenti restano leggibili ma non possono più essere creati, modificati o eliminati. Non reversibile dopo la finalizzazione di un registro. + Log esistenti + Log unità esistenti + Modifiche permessi all'attivazione + Permesso esistente + Permesso registri + Accesso effettivo + Limita la visibilità dei registri al gruppo dell'utente + Motivo / note + Comprendo che non è reversibile dopo la finalizzazione di un registro e che la configurazione della conservazione è responsabilità di questo dipartimento. + Attiva + Attivazione bloccata + Registri è abilitato per questo dipartimento ma non ancora attivato. Un amministratore deve attivarlo prima di creare registri. + Registri attivati il + Impostazioni registri + Ciclo di vita predefinito per le definizioni del dipartimento + Scadenza revisione (ore) + Numerazione registri + Includi l'anno nei numeri + Cifre della sequenza + Sequenza separata per stazione/gruppo + Conservazione + Predefiniti: 7 anni per i registri standard; permanente per Coroner e altre classi riservate. La conservazione è responsabilità del dipartimento. Le modifiche si applicano in avanti e sono verificate. + Predefinito dipartimento (anni, vuoto = sistema) + Eccezioni per definizione (0 = permanente) + Classe riservata. Impostare un periodo abilita l'eliminazione automatica. Conferma per applicare. + Visibilità tra gruppi + Tutto il dipartimento (predefinito) + Per gruppo + L'ambito di gruppo richiede anche che il permesso "Vedi registri di altri gruppi" sia bloccato per gruppo. Autori, revisori, partecipanti, equipaggi e amministratori vedono sempre un registro. + Impostazioni registri salvate. + Salva + Questo registro è stato modificato da altri da quando lo hai aperto. Ricarica e applica di nuovo le modifiche. + Una rettifica è in corso. L'ultima revisione finalizzata resta valida fino alla finalizzazione della rettifica. + Registro salvato. + Inizio + Fine + Stazione / gruppo + ID esterno + Torna ai registri + Impostazioni + Ruolo + Provenienza + Salva come PDF + Esporta CSV + Esporta JSON + Stampato da + Stampato il + Impaginazione + Le esportazioni includono al massimo {0} righe; restringete il filtro per esportarne di più. + Cerca rapporti + La ricerca non è disponibile al momento; l'elenco sottostante è filtrato senza il testo di ricerca. + Vengono mostrate solo le corrispondenze più vicine; restringete la ricerca per vederne di più. + Il testo della narrativa è incluso nella ricerca. + Il testo della narrativa non è indicizzato per questo dipartimento; la ricerca copre numeri di rapporto, riepiloghi e altri campi sicuri. + Indice di ricerca + Disattivato + In linea + Non in linea + Rapporti indicizzati + Il worker ricostruisce automaticamente l'indice di ricerca ogni volta che cambiano la politica di protezione o la versione del catalogo del dipartimento. La ricerca nella narrativa viene ritirata con l'adesione alla Protezione avanzata dei dati. + Impaginazione di stampa + Come viene resa l'intestazione sulle stampe dei rapporti. Identità e logo provengono dal profilo del dipartimento; il contenuto del rapporto si stampa sempre dalla revisione bloccata. + Mostra logo + Usa il nome breve + Mostra indirizzo + Mostra numero di telefono + Mostra sito web + Riga intestazione 1 + Riga intestazione 2 + Testo a piè di pagina + Filigrana + Formato pagina + Formato di data e ora + Stringa di formato .NET facoltativa, ad esempio yyyy-MM-dd HH:mm; lasciate vuoto per il valore predefinito del dipartimento. + Versione impaginazione + Gestisci il profilo del dipartimento + Nessun logo caricato finora; le stampe mostrano solo il nome del dipartimento. + La ricerca non è disponibile al momento, quindi i risultati non sono stati esportati. Cancellare la ricerca per esportare l'elenco filtrato. + Riassegna bozza + Nuovo proprietario + Motivo + Riassegna + Trasferisce la proprietà di questo record non finalizzato a un altro membro. L'autore e la cronologia di creazione non cambiano e il trasferimento viene registrato. + Anteprima dell'impatto + Cosa nasconderebbe l'ambito per gruppo, prima della conferma. I membri conservano sempre i record che hanno redatto, possiedono, revisionano, a cui partecipano o a cui rispondono con la propria unità; gli amministratori del dipartimento vedono tutto. + Record nel dipartimento + Record nascosti ai membri senza gruppo + Record senza ancoraggio di gruppo (restano visibili a tutto il dipartimento) + Membri senza gruppo + Righe di log legacy senza gruppo (restano visibili a tutto il dipartimento) + Gruppo + Membri + Record + Capisco cosa nasconde l'ambito per gruppo e voglio attivarlo. + L'ambito per gruppo non è stato attivato: confermare prima l'anteprima dell'impatto. Le altre impostazioni sono state salvate. + Responsabilità + Responsabilità dei record + Chi deve un rapporto: record aperti, revisioni scadute e record restituiti per persona, sede o unità, con il tempo di finalizzazione nel periodo. I promemoria vanno al proprietario, al massimo una volta al giorno per record. + Per persona + Per sede/gruppo + Per unità + Periodo (giorni) + Aperti + Revisioni scadute + Restituiti, non corretti + Finalizzati nel periodo + Ore medie alla finalizzazione + Aperto più vecchio + Sollecita + Sollecita tutti + {0} promemoria inviati, {1} saltati. + Promemoria inviato. + Promemoria non inviato ({0}). + Non assegnato + Mostra record aperti + Totali + Proprietario + Gruppo + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx new file mode 100644 index 000000000..a08b75f78 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx @@ -0,0 +1,216 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Rejestry + Nowy rejestr + Wyświetl rejestr + Edytuj rejestr + Rejestry z roku + Wszystkie lata + Wszystkie typy + Wszystkie stany + Filtruj + Żaden rejestr nie pasuje do bieżącego filtru. + Nr rejestru + Odniesienie roboczej + Typ + Stan + Podsumowanie + Autor + Właściciel + Wystąpił + Utworzono + Sfinalizowano + Akcje + Definicja + Opis + Opis jest wymagany. + Raport wstępny / stan + Przyczyna + Śledczy + Kurs + Kod kursu + Instruktorzy + Typ spotkania + Prowadzący + Lokalizacja + Inny personel / uczestnicy + Inne agencje + Inne jednostki + Numer sprawy + Miejsce ciała + Zgon stwierdził + Miejsce docelowe + Nazwa kontaktu + Numer kontaktowy + Jednostka + Czas aktywności + Zgłoszenie + Brak zgłoszenia + Migawka zgłoszenia + Uczestnicy + Jednostki + Załączniki + Dodaj załącznik + Wysłano + W drodze + Na miejscu + Zwolniono + W remizie + Zapisz roboczą + Zapisz i sfinalizuj + Sfinalizuj + Popraw + Porzuć poprawkę + Unieważnij + Anuluj roboczą + Kod powodu + Powód + Powód jest wymagany. + Poświadczam, że ten rejestr jest dokładny i kompletny zgodnie z moją wiedzą. + Historia + Wersja + Przejście + Przez + Suma kontrolna + Wyświetl + Porównaj z poprzednią + Wersja w ówczesnym stanie + Porównanie wersji + Brak różnic między tymi wersjami. + Zmieniono pole zastrzeżone (wartości ukryte) + Sekcja + Pole + Przed + Po + Eksportuj + Drukuj + Sekcja zastrzeżona + Nie masz uprawnień do wyświetlania zastrzeżonych sekcji tego rejestru. + Rejestr tego typu już istnieje dla tego zgłoszenia. Sprawdź go przed kontynuowaniem lub podaj, dlaczego potrzebny jest drugi. + Powód drugiego rejestru + Aktywuj rejestry + Rejestry zastępują moduł Dzienniki dla tego wydziału. Po aktywacji istniejące dzienniki pozostają do odczytu, ale nie można ich tworzyć, edytować ani usuwać. Nie można tego cofnąć po sfinalizowaniu rejestru. + Istniejące dzienniki + Istniejące dzienniki jednostek + Zmiany uprawnień przy aktywacji + Istniejące uprawnienie + Uprawnienie rejestrów + Efektywny dostęp + Ogranicz widoczność rejestrów do grupy użytkownika + Powód / uwagi + Rozumiem, że nie można tego cofnąć po sfinalizowaniu rejestru i że konfiguracja retencji jest obowiązkiem tego wydziału. + Aktywuj + Aktywacja zablokowana + Rejestry są włączone dla tego wydziału, ale jeszcze nie aktywowane. Administrator musi je aktywować przed tworzeniem rejestrów. + Rejestry aktywowano + Ustawienia rejestrów + Domyślny cykl życia dla definicji wydziału + Termin przeglądu (godziny) + Numeracja rejestrów + Uwzględnij rok w numerach + Cyfry sekwencji + Osobna sekwencja dla stacji/grupy + Retencja + Domyślnie: 7 lat dla rejestrów standardowych; bezterminowo dla koronera i innych klas zastrzeżonych. Retencja jest obowiązkiem wydziału. Zmiany działają prospektywnie i są audytowane. + Domyślne wydziału (lata, puste = systemowe) + Nadpisania dla definicji (0 = bezterminowo) + Klasa zastrzeżona. Ustawienie okresu włącza automatyczne usuwanie. Potwierdź, aby zastosować. + Widoczność między grupami + Cały wydział (domyślnie) + Wg grupy + Zakres grupowy wymaga też zablokowania uprawnienia „Zobacz rejestry innych grup” do grupy. Autorzy, recenzenci, uczestnicy, załogi i administratorzy zawsze widzą rejestr. + Zapisano ustawienia rejestrów. + Zapisz + Ten rejestr został zmieniony przez kogoś innego od czasu otwarcia. Załaduj ponownie i zastosuj zmiany. + Trwa poprawka. Ostatnia sfinalizowana wersja pozostaje obowiązująca do sfinalizowania poprawki. + Zapisano rejestr. + Początek + Koniec + Stacja / grupa + ID zewnętrzne + Powrót do rejestrów + Ustawienia + Rola + Pochodzenie + Zapisz jako PDF + Eksportuj CSV + Eksportuj JSON + Wydrukował(a) + Wydrukowano + Układ wydruku + Eksport obejmuje maksymalnie {0} wierszy; zawęź filtr, aby wyeksportować więcej. + Szukaj raportów + Wyszukiwanie jest obecnie niedostępne; poniższa lista jest przefiltrowana bez tekstu wyszukiwania. + Pokazywane są tylko najbliższe dopasowania; zawęź wyszukiwanie, aby zobaczyć więcej. + Treść opisu jest uwzględniana w wyszukiwaniu. + Treść opisu nie jest indeksowana dla tego wydziału; wyszukiwanie obejmuje numery raportów, podsumowania i inne bezpieczne pola. + Indeks wyszukiwania + Wyłączony + Online + Offline + Zaindeksowane raporty + Worker automatycznie przebudowuje indeks wyszukiwania, gdy zmieni się polityka ochrony lub wersja katalogu wydziału. Wyszukiwanie w opisie jest wycofywane po włączeniu Zaawansowanej ochrony danych. + Układ wydruku + Jak nagłówek firmowy jest renderowany na wydrukach raportów. Tożsamość i logo pochodzą z profilu wydziału; treść raportu zawsze drukuje się z przypiętej wersji. + Pokaż logo + Używaj nazwy skróconej + Pokaż adres + Pokaż numer telefonu + Pokaż stronę WWW + Wiersz nagłówka 1 + Wiersz nagłówka 2 + Tekst stopki + Znak wodny + Rozmiar strony + Format daty i godziny + Opcjonalny ciąg formatu .NET, np. yyyy-MM-dd HH:mm; pozostaw puste, aby użyć domyślnego formatu wydziału. + Wersja układu + Zarządzaj profilem wydziału + Nie przesłano jeszcze logo; wydruki pokazują tylko nazwę wydziału. + Wyszukiwanie jest obecnie niedostępne, więc nie udało się wyeksportować wyników. Wyczyść wyszukiwanie, aby wyeksportować przefiltrowaną listę. + Przypisz szkic ponownie + Nowy właściciel + Powód + Przypisz ponownie + Przenosi własność tego niezakończonego rekordu na innego członka. Autor i historia utworzenia pozostają bez zmian, a przeniesienie jest audytowane. + Podgląd wpływu + Co ukryłoby ograniczenie do grup, zanim je potwierdzisz. Członkowie zawsze zachowują rekordy, które napisali, posiadają, przeglądają, w których uczestniczą lub na które odpowiadają swoją jednostką; administratorzy jednostki widzą wszystko. + Rekordy w jednostce + Rekordy ukryte przed członkami bez grupy + Rekordy bez kotwicy grupy (pozostają widoczne dla całej jednostki) + Członkowie bez grupy + Stare wiersze dzienników bez grupy (pozostają widoczne dla całej jednostki) + Grupa + Członkowie + Rekordy + Rozumiem, co ukrywa ograniczenie do grup, i chcę je włączyć. + Ograniczenie do grup nie zostało włączone: najpierw potwierdź podgląd wpływu. Pozostałe ustawienia zostały zapisane. + Odpowiedzialność + Odpowiedzialność za rekordy + Kto zalega z raportem: otwarte rekordy, zaległe przeglądy i zwrócone rekordy według osoby, stacji lub jednostki, z czasem do zakończenia w okresie. Przypomnienia trafiają do właściciela, najwyżej raz dziennie na rekord. + Według osoby + Według stacji/grupy + Według jednostki + Okres (dni) + Otwarte + Zaległe przeglądy + Zwrócone, niepoprawione + Zakończone w okresie + Śr. godzin do zakończenia + Najstarszy otwarty + Przypomnij + Przypomnij wszystkim + Wysłano {0} przypomnień, pominięto {1}. + Przypomnienie wysłane. + Przypomnienia nie wysłano ({0}). + Nieprzypisane + Pokaż otwarte rekordy + Razem + Właściciel + Grupa + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx new file mode 100644 index 000000000..aee86ee6e --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx @@ -0,0 +1,216 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Journaler + Ny journal + Visa journal + Redigera journal + Journaler för år + Alla år + Alla typer + Alla tillstånd + Filtrera + Inga journaler matchar det aktuella filtret. + Journalnr + Utkastreferens + Typ + Tillstånd + Sammanfattning + Författare + Ägare + Inträffade + Skapad + Slutförd + Åtgärder + Definition + Berättelse + En berättelse krävs. + Första rapport / tillstånd + Orsak + Utredare + Kurs + Kurskod + Instruktörer + Mötestyp + Mötesledare + Plats + Övrig personal / deltagare + Andra organisationer + Andra enheter + Ärendenummer + Kroppens plats + Dödförklarad av + Destination + Kontaktnamn + Kontaktnummer + Enhet + Aktivitetstid + Larm + Inget larm + Ögonblicksbild av larm + Deltagare + Enheter + Bilagor + Lägg till bilaga + Utlarmad + På väg + På plats + Frigjord + På station + Spara utkast + Spara och slutför + Slutför + Ändra + Överge ändringen + Ogiltigförklara + Avbryt utkast + Orsakskod + Orsak + En orsak krävs. + Jag intygar att denna journal är korrekt och fullständig såvitt jag vet. + Historik + Revision + Övergång + Av + Kontrollsumma + Visa + Jämför med föregående + Revision som den var + Revisionsjämförelse + Inga skillnader mellan dessa revisioner. + Begränsat fält ändrat (värden undanhållna) + Avsnitt + Fält + Före + Efter + Exportera + Skriv ut + Begränsat avsnitt + Du har inte behörighet att visa de begränsade avsnitten i denna journal. + En journal av denna typ finns redan för detta larm. Granska den innan du fortsätter, eller ange varför en andra journal behövs. + Orsak till andra journalen + Aktivera journaler + Journaler ersätter modulen Loggar för denna avdelning. Efter aktivering förblir befintliga loggar läsbara men kan inte längre skapas, redigeras eller raderas. Kan inte ångras när en journal har slutförts. + Befintliga loggar + Befintliga enhetsloggar + Behörighetsändringar vid aktivering + Befintlig behörighet + Journalbehörighet + Effektiv åtkomst + Begränsa journalers synlighet till användarens egen grupp + Orsak / anteckningar + Jag förstår att detta inte kan ångras när en journal slutförts och att lagringskonfigurationen är denna avdelnings ansvar. + Aktivera + Aktivering blockerad + Journaler är aktiverat för avdelningen men inte startat ännu. En administratör måste aktivera det innan journaler kan skapas. + Journaler aktiverade + Journalinställningar + Standardlivscykel för avdelningsdefinitioner + Granskning inom (timmar) + Journalnumrering + Inkludera år i journalnummer + Sekvenssiffror + Separat sekvens per station/grupp + Lagring + Standard: 7 år för vanliga journaler; permanent för rättsläkare och andra begränsade klasser. Lagringen är avdelningens ansvar. Ändringar gäller framåt och granskas. + Avdelningsstandard (år, tomt = systemstandard) + Undantag per definition (0 = permanent) + Begränsad klass. Att sätta en period aktiverar automatisk gallring. Bekräfta för att tillämpa. + Synlighet mellan grupper + Hela avdelningen (standard) + Gruppbaserad + Gruppbegränsning kräver även att behörigheten ”Se andra gruppers journaler” är låst till grupp. Författare, granskare, deltagare, besättningar och administratörer ser alltid en journal. + Journalinställningar sparade. + Spara + Journalen ändrades av någon annan sedan du öppnade den. Ladda om och gör dina ändringar igen. + En ändring pågår. Den senast slutförda revisionen gäller tills ändringen slutförts. + Journal sparad. + Start + Slut + Station / grupp + Externt ID + Tillbaka till journaler + Inställningar + Roll + Ursprung + Spara som PDF + Exportera CSV + Exportera JSON + Utskriven av + Utskriven den + Utskriftslayout + Exporter innehåller högst {0} rader; begränsa filtret för att exportera fler. + Sök rapporter + Sökningen är inte tillgänglig just nu; listan nedan är filtrerad utan söktexten. + Endast de närmaste träffarna visas; begränsa sökningen för att se fler. + Berättelsetexten ingår i sökningen. + Berättelsetexten indexeras inte för den här avdelningen; sökningen omfattar rapportnummer, sammanfattningar och andra säkra fält. + Sökindex + Inaktiverat + Online + Offline + Indexerade rapporter + Arbetaren bygger om sökindexet automatiskt när avdelningens skyddspolicy eller katalogversion ändras. Sökning i berättelsetext dras tillbaka när Avancerat dataskydd aktiveras. + Utskriftslayout + Hur brevhuvudet återges på rapportutskrifter. Identitet och logotyp kommer från avdelningsprofilen; rapportens innehåll skrivs alltid ut från den låsta revisionen. + Visa logotyp + Använd kortnamnet + Visa adress + Visa telefonnummer + Visa webbplats + Brevhuvudsrad 1 + Brevhuvudsrad 2 + Sidfotstext + Vattenstämpel + Sidstorlek + Datum- och tidsformat + Valfri .NET-formatsträng, till exempel yyyy-MM-dd HH:mm; lämna tomt för avdelningens standard. + Layoutversion + Hantera avdelningsprofilen + Ingen logotyp har laddats upp ännu; utskrifter visar bara avdelningens namn. + Sökningen är inte tillgänglig just nu, så sökträffarna kunde inte exporteras. Rensa sökningen för att exportera den filtrerade listan i stället. + Omfördela utkast + Ny ägare + Orsak + Omfördela + Överför ägandet av den här ofärdiga posten till en annan medlem. Författare och skapandehistorik ändras inte, och överföringen granskas. + Förhandsvisning av påverkan + Vad gruppbegränsningen skulle dölja, innan du bekräftar. Medlemmar behåller alltid poster de skrivit, äger, granskar, deltar i eller svarar på med sin enhet; organisationens administratörer ser allt. + Poster i organisationen + Poster dolda för medlemmar utan grupp + Poster utan gruppankare (förblir synliga för hela organisationen) + Medlemmar utan grupp + Äldre loggrader utan grupp (förblir synliga för hela organisationen) + Grupp + Medlemmar + Poster + Jag förstår vad gruppbegränsningen döljer och vill aktivera den. + Gruppbegränsningen aktiverades inte: bekräfta förhandsvisningen av påverkan först. Övriga inställningar sparades. + Ansvar + Ansvar för poster + Vem som är skyldig en rapport: öppna poster, försenade granskningar och återsända poster per person, station eller enhet, med tid till färdigställande under perioden. Påminnelser går till ägaren, högst en gång per dag och post. + Per person + Per station/grupp + Per enhet + Period (dagar) + Öppna + Försenade granskningar + Återsända, ej rättade + Färdigställda under perioden + Genomsnittliga timmar till färdigställande + Äldsta öppna + Påminn + Påminn alla + {0} påminnelse(r) skickade, {1} hoppades över. + Påminnelse skickad. + Påminnelse inte skickad ({0}). + Ej tilldelad + Visa öppna poster + Totalt + Ägare + Grupp + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx new file mode 100644 index 000000000..d4b8c31fd --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx @@ -0,0 +1,216 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Записи + Новий запис + Перегляд запису + Редагувати запис + Записи за рік + Усі роки + Усі типи + Усі стани + Фільтр + Жоден запис не відповідає поточному фільтру. + № запису + Посилання чернетки + Тип + Стан + Підсумок + Автор + Власник + Відбулося + Створено + Завершено + Дії + Визначення + Опис + Опис є обов'язковим. + Початковий звіт / стан + Причина + Слідчий + Курс + Код курсу + Інструктори + Тип зустрічі + Ведучий + Місце + Інший персонал / учасники + Інші служби + Інші підрозділи + Номер справи + Місцезнаходження тіла + Смерть констатував + Призначення + Ім'я контакту + Номер контакту + Підрозділ + Час активності + Виклик + Без виклику + Знімок виклику + Учасники + Підрозділи + Вкладення + Додати вкладення + Відправлено + В дорозі + На місці + Звільнено + На базі + Зберегти чернетку + Зберегти та завершити + Завершити + Внести правку + Скасувати правку + Анулювати + Скасувати чернетку + Код причини + Причина + Причина є обов'язковою. + Я підтверджую, що цей запис є точним і повним, наскільки мені відомо. + Історія + Ревізія + Перехід + Ким + Контрольна сума + Переглянути + Порівняти з попередньою + Ревізія станом на момент + Порівняння ревізій + Відмінностей між цими ревізіями немає. + Змінено обмежене поле (значення приховано) + Розділ + Поле + До + Після + Експорт + Друк + Обмежений розділ + У вас немає дозволу переглядати обмежені розділи цього запису. + Запис цього типу вже існує для цього виклику. Перегляньте його перед продовженням або вкажіть, чому потрібен другий. + Причина другого запису + Активувати записи + Записи замінюють модуль Журнали для цього підрозділу. Після активації наявні журнали залишаються доступними для читання, але їх не можна створювати, редагувати чи видаляти. Не можна скасувати після завершення запису. + Наявні журнали + Наявні журнали підрозділів + Зміни дозволів під час активації + Наявний дозвіл + Дозвіл записів + Фактичний доступ + Обмежити видимість записів власною групою користувача + Причина / примітки + Я розумію, що це не можна скасувати після завершення запису і що налаштування зберігання є відповідальністю цього підрозділу. + Активувати + Активацію заблоковано + Записи увімкнено для цього підрозділу, але ще не активовано. Адміністратор має активувати їх перед створенням записів. + Записи активовано + Налаштування записів + Типовий життєвий цикл для визначень підрозділу + Термін перевірки (години) + Нумерація записів + Включати рік у номери + Цифри послідовності + Окрема послідовність для станції/групи + Зберігання + Типово: 7 років для стандартних записів; безстроково для коронера та інших обмежених класів. Зберігання є відповідальністю підрозділу. Зміни діють на майбутнє та аудитуються. + Типово для підрозділу (роки, порожньо = системне) + Перевизначення для визначень (0 = безстроково) + Обмежений клас. Встановлення періоду вмикає автоматичне видалення. Підтвердьте для застосування. + Видимість між групами + Увесь підрозділ (типово) + За групою + Обмеження за групою також вимагає, щоб дозвіл «Бачити записи інших груп» був закріплений за групою. Автори, рецензенти, учасники, екіпажі та адміністратори завжди бачать запис. + Налаштування записів збережено. + Зберегти + Цей запис змінив хтось інший після відкриття. Перезавантажте та застосуйте зміни знову. + Триває внесення правки. Остання завершена ревізія залишається чинною до завершення правки. + Запис збережено. + Початок + Кінець + Станція / група + Зовнішній ID + Назад до записів + Налаштування + Роль + Походження + Зберегти як PDF + Експортувати CSV + Експортувати JSON + Надрукував(ла) + Надруковано + Макет друку + Експорт містить щонайбільше {0} рядків; звузьте фільтр, щоб експортувати більше. + Пошук звітів + Пошук зараз недоступний; список нижче відфільтровано без тексту пошуку. + Показано лише найближчі збіги; звузьте пошук, щоб побачити більше. + Текст опису включено до пошуку. + Текст опису не індексується для цього підрозділу; пошук охоплює номери звітів, підсумки та інші безпечні поля. + Пошуковий індекс + Вимкнено + Онлайн + Офлайн + Проіндексовані звіти + Воркер автоматично перебудовує пошуковий індекс щоразу, коли змінюється політика захисту або версія каталогу підрозділу. Пошук в описі вилучається після підключення Розширеного захисту даних. + Макет друку + Як бланк відображається на друкованих копіях звітів. Ідентичність і логотип беруться з профілю підрозділу; вміст звіту завжди друкується із закріпленої редакції. + Показувати логотип + Використовувати коротку назву + Показувати адресу + Показувати номер телефону + Показувати вебсайт + Рядок бланка 1 + Рядок бланка 2 + Текст нижнього колонтитула + Водяний знак + Розмір сторінки + Формат дати й часу + Необов'язковий рядок формату .NET, наприклад yyyy-MM-dd HH:mm; залиште порожнім для типового формату підрозділу. + Версія макета + Керувати профілем підрозділу + Логотип ще не завантажено; на друкованих копіях відображається лише назва підрозділу. + Пошук зараз недоступний, тому результати пошуку не вдалося експортувати. Очистіть пошук, щоб експортувати відфільтрований список. + Перепризначити чернетку + Новий власник + Причина + Перепризначити + Передає право власності на цей незавершений запис іншому учаснику. Автор та історія створення не змінюються, а передача аудитується. + Попередній перегляд впливу + Що приховає обмеження за групами, перш ніж ви це підтвердите. Учасники завжди зберігають записи, які вони створили, якими володіють, які перевіряють, у яких беруть участь або на які реагують своїм підрозділом; адміністратори підрозділу бачать усе. + Записів у підрозділі + Записів, прихованих від учасників без групи + Записів без прив’язки до групи (залишаються видимими всьому підрозділу) + Учасників без групи + Застарілих рядків журналу без групи (залишаються видимими всьому підрозділу) + Група + Учасники + Записи + Я розумію, що приховує обмеження за групами, і хочу його ввімкнути. + Обмеження за групами не ввімкнено: спочатку підтвердьте попередній перегляд впливу. Інші налаштування збережено. + Відповідальність + Відповідальність за записи + Хто заборгував звіт: відкриті записи, прострочені перевірки та повернуті записи за особою, станцією або підрозділом, з часом до завершення за період. Нагадування надходять власнику, не частіше ніж раз на день на запис. + За особою + За станцією/групою + За підрозділом + Період (днів) + Відкриті + Прострочені перевірки + Повернуті, не виправлені + Завершені за період + Сер. годин до завершення + Найстаріший відкритий + Нагадати + Нагадати всім + Надіслано нагадувань: {0}, пропущено: {1}. + Нагадування надіслано. + Нагадування не надіслано ({0}). + Не призначено + Показати відкриті записи + Разом + Власник + Група + diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx index 55d82b2c0..2ce521d8c 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx @@ -351,4 +351,43 @@ Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered. Emergency Break-Glass Access Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered. + السجلات + تتحكم هذه الأذونات في وحدة السجلات، خليفة الدفاتر. وكما هو الحال مع معظم أذونات Resgrid، يعتمد الصف غير المضبوط القيمة الافتراضية المعروضة، وهي تطابق سلوك الدفاتر الحالي. كما يؤدي تفعيل السجلات لقسمك إلى نسخ إعدادات إنشاء دفتر وحذف دفتر إلى الصفوف المقابلة ما لم تكن قد ضبطتها هنا مسبقًا. + السجلات غير مفعّلة لهذا القسم بعد. يمكنك تجهيز هذه الإعدادات الآن؛ وستسري بمجرد تفعيل السجلات. + إنشاء السجلات + من يمكنه إنشاء سجلات جديدة وتحرير مسوداته الخاصة. يطابق إعداد إنشاء دفتر إلى أن تغيّره. + إلغاء السجلات أو إبطالها + من يمكنه إلغاء مسودة أو إبطال سجل نهائي. يحتفظ الإبطال بالسجل وتاريخه؛ ولا يُحذف شيء. يطابق إعداد حذف دفتر إلى أن تغيّره. + مراجعة السجلات + من يمكنه مراجعة السجلات المقدَّمة للمراجعة وإعادتها للتصحيح. يقصر خيار المجموعة فقط مديري المجموعات على سجلات مجموعتهم. + اعتماد السجلات + من يمكنه اعتماد سجل تمت مراجعته قبل إنهائه، لأنواع السجلات التي تستخدم دورة الاعتماد. + إنهاء السجلات + من يمكنه إنهاء سجل، وهو ما يمنحه رقمه ويقفل محتواه. تُنهى أنواع الإدخال السريع عند الحفظ، لذا يطابق هذا إعداد إنشاء دفتر إلى أن تغيّره. + تعديل السجلات النهائية + من يمكنه فتح تعديل على سجل نهائي. ينشئ التعديل مراجعة جديدة؛ وتبقى المراجعة الأصلية كما هي. + إرسال السجلات خارجيًا + من يمكنه إرسال سجل نهائي إلى جهة إبلاغ خارجية مثل نظام إقليمي أو وطني. + طباعة السجلات وتصديرها + من يمكنه طباعة سجل أو تصدير قائمة سجلات. + مشاركة السجلات + من يمكنه مشاركة سجل مع مجموعات أخرى في القسم أو مع أشخاص من خارجه. + عرض الأقسام المقيّدة + من يمكنه قراءة الأقسام المقيّدة في سجل، مثل تفاصيل سجل الطب الشرعي. يرى الآخرون أن القسم موجود دون الاطلاع على محتواه. + عرض الدفاتر القديمة + من يمكنه قراءة تاريخ الدفاتر للقراءة فقط من الفترة السابقة لتفعيل السجلات. + عرض سجلات المجموعات الأخرى + ما إذا كان بإمكان الأفراد رؤية السجلات التابعة لمجموعات أخرى. حدد خيار المجموعة فقط واضبط الرؤية حسب المجموعة في إعدادات السجلات لقصر الأشخاص على سجلات مجموعتهم. اتركه دون تقييد للإبقاء على العرض على مستوى القسم كما هو الحال في الدفاتر اليوم. + إدارة تعريفات السجلات + من يمكنه إنشاء تعريفات السجلات وتحريرها، وهي النماذج التي تُبنى منها السجلات. + نشر تعريفات السجلات + من يمكنه نشر إصدار من تعريف سجل ليُستخدم في السجلات الجديدة. + إدارة تقارير السجلات + من يمكنه إنشاء تقارير السجلات المحفوظة وجداولها وتحريرها. + إدارة إفصاحات السجلات + من يمكنه إعداد حزم الإفصاح عن السجلات العامة وإصدارها. + إدارة الحجز القانوني + من يمكنه فرض الحجز القانوني ورفعه، وهو ما يعلّق مدة الاحتفاظ ويمنع إبطال السجل. + إعادة إسناد مسودات السجلات + من يمكنه نقل مسودة سجل إلى مؤلف آخر، مثلاً عندما يكون المؤلف الأصلي غير متاح. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx index 3b6635afd..615333ba1 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx @@ -953,4 +953,43 @@ Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered. Emergency Break-Glass Access Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered. + Berichte + Diese Berechtigungen steuern das Modul Berichte, den Nachfolger der Protokolle. Wie bei den meisten Resgrid-Berechtigungen gilt für eine nicht gesetzte Zeile der angezeigte Standardwert, der dem heutigen Verhalten der Protokolle entspricht. Beim Aktivieren von Berichten für Ihre Abteilung werden außerdem Ihre Einstellungen für „Protokoll erstellen“ und „Protokoll löschen“ auf die passenden Zeilen übernommen, sofern Sie sie hier nicht bereits gesetzt haben. + Berichte sind für diese Abteilung noch nicht aktiviert. Sie können diese Einstellungen jetzt vorbereiten; sie gelten, sobald Berichte aktiviert werden. + Berichte erstellen + Wer neue Berichte anlegen und eigene Entwürfe bearbeiten darf. Entspricht „Protokoll erstellen“, bis Sie es ändern. + Berichte annullieren oder abbrechen + Wer einen Entwurf abbrechen oder einen abgeschlossenen Bericht annullieren darf. Beim Annullieren bleiben Bericht und Verlauf erhalten; nichts wird gelöscht. Entspricht „Protokoll löschen“, bis Sie es ändern. + Berichte prüfen + Wer zur Prüfung eingereichte Berichte prüfen und zur Korrektur zurückgeben darf. „Nur Gruppe“ beschränkt Gruppenadministratoren auf Berichte ihrer eigenen Gruppe. + Berichte genehmigen + Wer einen geprüften Bericht vor dem Abschluss genehmigen darf, bei Berichtstypen mit Genehmigungsablauf. + Berichte abschließen + Wer einen Bericht abschließen darf; dabei wird die Berichtsnummer vergeben und der Inhalt gesperrt. Schnellerfassungs-Berichtstypen werden beim Speichern abgeschlossen, daher entspricht dies „Protokoll erstellen“, bis Sie es ändern. + Abgeschlossene Berichte ergänzen + Wer eine Ergänzung zu einem abgeschlossenen Bericht eröffnen darf. Eine Ergänzung erzeugt eine neue Revision; die ursprüngliche Revision bleibt unverändert. + Berichte extern übermitteln + Wer einen abgeschlossenen Bericht an ein externes Meldesystem wie ein Landes- oder Bundessystem übermitteln darf. + Berichte drucken und exportieren + Wer einen Bericht drucken oder eine Berichtsliste exportieren darf. + Berichte teilen + Wer einen Bericht mit anderen Gruppen der Abteilung oder mit externen Personen teilen darf. + Eingeschränkte Abschnitte anzeigen + Wer die eingeschränkten Abschnitte eines Berichts lesen darf, etwa die Details eines Leichenschauberichts. Alle anderen sehen, dass der Abschnitt existiert, aber nicht seinen Inhalt. + Alte Protokolle anzeigen + Wer den schreibgeschützten Protokollverlauf aus der Zeit vor der Aktivierung von Berichten lesen darf. + Berichte anderer Gruppen anzeigen + Ob Personal Berichte anderer Gruppen sehen darf. Aktivieren Sie „Nur Gruppe“ und stellen Sie in den Berichtseinstellungen die gruppenbezogene Sichtbarkeit ein, um Personen auf die Berichte ihrer eigenen Gruppe zu beschränken. Lassen Sie es uneingeschränkt, um die heutige abteilungsweite Ansicht der Protokolle beizubehalten. + Berichtsdefinitionen verwalten + Wer Berichtsdefinitionen, also die Formulare, aus denen Berichte aufgebaut sind, erstellen und bearbeiten darf. + Berichtsdefinitionen veröffentlichen + Wer eine Version einer Berichtsdefinition veröffentlichen darf, damit sie für neue Berichte verwendet werden kann. + Berichtsauswertungen verwalten + Wer gespeicherte Berichtsauswertungen und deren Zeitpläne erstellen und bearbeiten darf. + Berichtsoffenlegungen verwalten + Wer Offenlegungspakete für Auskunftsersuchen vorbereiten und freigeben darf. + Rechtliche Sperren verwalten + Wer rechtliche Sperren setzen und aufheben darf; sie setzen die Aufbewahrungsfrist aus und verhindern das Annullieren eines Berichts. + Berichtsentwürfe neu zuweisen + Wer einen Berichtsentwurf einem anderen Verfasser zuweisen darf, etwa wenn der ursprüngliche Verfasser nicht verfügbar ist. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.el.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.el.resx index b101348e1..2cf68d633 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.el.resx @@ -398,6 +398,45 @@ Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered. Emergency Break-Glass Access Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered. + Αναφορές + Αυτά τα δικαιώματα ελέγχουν την ενότητα Αναφορές, τη διάδοχο των Ημερολογίων. Όπως στα περισσότερα δικαιώματα Resgrid, μια γραμμή που δεν έχει οριστεί χρησιμοποιεί την προεπιλογή που εμφανίζεται, η οποία αντιστοιχεί στη σημερινή συμπεριφορά των Ημερολογίων. Η ενεργοποίηση των Αναφορών για την υπηρεσία σας αντιγράφει επίσης τις ρυθμίσεις Δημιουργία ημερολογίου και Διαγραφή ημερολογίου στις αντίστοιχες γραμμές, εκτός αν τις έχετε ήδη ορίσει εδώ. + Οι Αναφορές δεν είναι ακόμη ενεργές για αυτήν την υπηρεσία. Μπορείτε να προετοιμάσετε τις ρυθμίσεις τώρα· θα ισχύσουν μόλις ενεργοποιηθούν οι Αναφορές. + Δημιουργία αναφορών + Ποιος μπορεί να συντάσσει νέες αναφορές και να επεξεργάζεται τα δικά του πρόχειρα. Αντιστοιχεί στη Δημιουργία ημερολογίου μέχρι να το αλλάξετε. + Ακύρωση αναφορών + Ποιος μπορεί να ακυρώσει ένα πρόχειρο ή να ακυρώσει μια οριστικοποιημένη αναφορά. Η ακύρωση διατηρεί την αναφορά και το ιστορικό της· τίποτα δεν διαγράφεται. Αντιστοιχεί στη Διαγραφή ημερολογίου μέχρι να το αλλάξετε. + Έλεγχος αναφορών + Ποιος μπορεί να ελέγχει αναφορές που υποβλήθηκαν για έλεγχο και να τις επιστρέφει για διόρθωση. Το Μόνο ομάδα περιορίζει τους διαχειριστές ομάδας στις αναφορές της δικής τους ομάδας. + Έγκριση αναφορών + Ποιος μπορεί να εγκρίνει μια ελεγμένη αναφορά πριν την οριστικοποίηση, για τύπους αναφορών με κύκλο έγκρισης. + Οριστικοποίηση αναφορών + Ποιος μπορεί να οριστικοποιήσει μια αναφορά, πράγμα που αποδίδει τον αριθμό της και κλειδώνει το περιεχόμενο. Οι τύποι γρήγορης καταχώρισης οριστικοποιούνται κατά την αποθήκευση, οπότε αντιστοιχεί στη Δημιουργία ημερολογίου μέχρι να το αλλάξετε. + Τροποποίηση οριστικοποιημένων αναφορών + Ποιος μπορεί να ανοίξει τροποποίηση σε οριστικοποιημένη αναφορά. Η τροποποίηση δημιουργεί νέα αναθεώρηση· η αρχική αναθεώρηση παραμένει άθικτη. + Εξωτερική υποβολή αναφορών + Ποιος μπορεί να υποβάλει μια οριστικοποιημένη αναφορά σε εξωτερικό προορισμό αναφοράς, όπως ένα περιφερειακό ή εθνικό σύστημα. + Εκτύπωση και εξαγωγή αναφορών + Ποιος μπορεί να εκτυπώσει μια αναφορά ή να εξαγάγει λίστα αναφορών. + Κοινοποίηση αναφορών + Ποιος μπορεί να κοινοποιήσει μια αναφορά σε άλλες ομάδες της υπηρεσίας ή σε άτομα εκτός αυτής. + Προβολή περιορισμένων ενοτήτων + Ποιος μπορεί να διαβάζει τις περιορισμένες ενότητες μιας αναφοράς, όπως τις λεπτομέρειες μιας ιατροδικαστικής αναφοράς. Οι υπόλοιποι βλέπουν ότι η ενότητα υπάρχει, αλλά όχι το περιεχόμενό της. + Προβολή παλαιών ημερολογίων + Ποιος μπορεί να διαβάζει το ιστορικό Ημερολογίων (μόνο για ανάγνωση) πριν από την ενεργοποίηση των Αναφορών. + Προβολή αναφορών άλλων ομάδων + Αν το προσωπικό μπορεί να βλέπει αναφορές που ανήκουν σε άλλες ομάδες. Επιλέξτε Μόνο ομάδα και ορίστε ορατότητα ανά ομάδα στις Ρυθμίσεις αναφορών για να περιορίσετε τα άτομα στις αναφορές της δικής τους ομάδας. Αφήστε το χωρίς περιορισμό για να διατηρήσετε τη σημερινή προβολή σε επίπεδο υπηρεσίας των Ημερολογίων. + Διαχείριση ορισμών αναφορών + Ποιος μπορεί να δημιουργεί και να επεξεργάζεται ορισμούς αναφορών, δηλαδή τις φόρμες από τις οποίες συντίθενται οι αναφορές. + Δημοσίευση ορισμών αναφορών + Ποιος μπορεί να δημοσιεύσει μια έκδοση ορισμού αναφοράς ώστε να χρησιμοποιείται σε νέες αναφορές. + Διαχείριση συγκεντρωτικών αναφορών + Ποιος μπορεί να δημιουργεί και να επεξεργάζεται αποθηκευμένες συγκεντρωτικές αναφορές και τα χρονοδιαγράμματά τους. + Διαχείριση γνωστοποιήσεων αναφορών + Ποιος μπορεί να προετοιμάζει και να παραδίδει πακέτα γνωστοποίησης δημόσιων εγγράφων. + Διαχείριση νομικών δεσμεύσεων + Ποιος μπορεί να θέτει και να αίρει νομικές δεσμεύσεις, οι οποίες αναστέλλουν τη διατήρηση και εμποδίζουν την ακύρωση αναφοράς. + Ανάθεση πρόχειρων αναφορών σε άλλον + Ποιος μπορεί να μεταφέρει ένα πρόχειρο αναφοράς σε άλλον συντάκτη, για παράδειγμα όταν ο αρχικός συντάκτης δεν είναι διαθέσιμος. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx index efd4f3159..dff663d59 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx @@ -398,6 +398,45 @@ Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered. Emergency Break-Glass Access Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered. + Records + These permissions control the Records module, the successor to Logs. As with most Resgrid permissions, an unset row falls through to the default shown, which matches how Logs behave today. Activating Records for your department also copies your Create Log and Delete Log settings onto the matching rows unless you have already set them here. + Records is not active for this department yet. You can prepare these settings now; they take effect once Records is activated. + Create Records + Who can author new records and edit their own drafts. Matches Create Log until you change it. + Void or Cancel Records + Who can cancel a draft or void a finalized record. Voiding keeps the record and its history; nothing is deleted. Matches Delete Log until you change it. + Review Records + Who can review records submitted for review and return them for correction. Group Only limits group admins to records from their own group. + Approve Records + Who can approve a reviewed record before it is finalized, for record types that use the approval lifecycle. + Finalize Records + Who can finalize a record, which assigns its record number and locks its content. Quick Entry record types finalize on save, so this matches Create Log until you change it. + Amend Finalized Records + Who can open an amendment on a finalized record. An amendment creates a new revision; the original revision stays intact. + Submit Records Externally + Who can submit a finalized record to an external reporting destination such as a state or national system. + Print and Export Records + Who can print a record or export a list of records. + Share Records + Who can share a record with other groups in the department or with people outside it. + View Restricted Sections + Who can read the restricted sections of a record, such as the details on a coroner record. Everyone else sees that the section exists but not its contents. + View Legacy Logs + Who can read the read-only Logs history from before Records was activated. + View Records From Other Groups + Whether personnel can see records that belong to other groups. Tick Group Only and set Records Settings to group-scoped visibility to limit people to their own group's records. Leave it unrestricted to keep the department-wide view Logs has today. + Manage Record Definitions + Who can create and edit record definitions, the forms that records are built from. + Publish Record Definitions + Who can publish a record definition version so it can be used for new records. + Manage Record Reports + Who can create and edit saved record reports and their schedules. + Manage Record Disclosures + Who can prepare and release public-records disclosure packages. + Manage Legal Holds + Who can place and release legal holds, which suspend retention and prevent a record from being voided. + Reassign Draft Records + Who can move a draft record to a different author, for example when the original author is unavailable. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx index 5a2b62d40..cb0c2a389 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx @@ -357,6 +357,45 @@ Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered. Emergency Break-Glass Access Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered. + Registros + Estos permisos controlan el módulo Registros, el sucesor de Bitácoras. Como en la mayoría de los permisos de Resgrid, una fila sin configurar usa el valor predeterminado mostrado, que coincide con el comportamiento actual de Bitácoras. Al activar Registros para su departamento también se copian sus ajustes de Crear bitácora y Eliminar bitácora en las filas correspondientes, salvo que ya los haya configurado aquí. + Registros aún no está activo para este departamento. Puede preparar estos ajustes ahora; se aplicarán cuando se active Registros. + Crear registros + Quién puede redactar registros nuevos y editar sus propios borradores. Coincide con Crear bitácora hasta que lo cambie. + Anular o cancelar registros + Quién puede cancelar un borrador o anular un registro finalizado. Al anular se conservan el registro y su historial; no se elimina nada. Coincide con Eliminar bitácora hasta que lo cambie. + Revisar registros + Quién puede revisar los registros enviados a revisión y devolverlos para corrección. Solo grupo limita a los administradores de grupo a los registros de su propio grupo. + Aprobar registros + Quién puede aprobar un registro revisado antes de finalizarlo, en los tipos de registro que usan el ciclo de aprobación. + Finalizar registros + Quién puede finalizar un registro, lo que asigna su número y bloquea su contenido. Los tipos de entrada rápida se finalizan al guardar, por lo que coincide con Crear bitácora hasta que lo cambie. + Enmendar registros finalizados + Quién puede abrir una enmienda sobre un registro finalizado. La enmienda crea una revisión nueva; la revisión original permanece intacta. + Enviar registros externamente + Quién puede enviar un registro finalizado a un destino de reporte externo, como un sistema estatal o nacional. + Imprimir y exportar registros + Quién puede imprimir un registro o exportar una lista de registros. + Compartir registros + Quién puede compartir un registro con otros grupos del departamento o con personas externas. + Ver secciones restringidas + Quién puede leer las secciones restringidas de un registro, como los detalles de un registro forense. Los demás ven que la sección existe, pero no su contenido. + Ver bitácoras antiguas + Quién puede leer el historial de solo lectura de Bitácoras anterior a la activación de Registros. + Ver registros de otros grupos + Si el personal puede ver registros de otros grupos. Marque Solo grupo y configure la visibilidad por grupo en Ajustes de registros para limitar a cada persona a los registros de su propio grupo. Déjelo sin restricción para mantener la vista de todo el departamento que hoy tienen las Bitácoras. + Administrar definiciones de registro + Quién puede crear y editar definiciones de registro, los formularios con los que se construyen los registros. + Publicar definiciones de registro + Quién puede publicar una versión de una definición de registro para que pueda usarse en registros nuevos. + Administrar informes de registros + Quién puede crear y editar informes de registros guardados y sus programaciones. + Administrar divulgaciones de registros + Quién puede preparar y entregar paquetes de divulgación de registros públicos. + Administrar retenciones legales + Quién puede aplicar y levantar retenciones legales, que suspenden la retención e impiden anular un registro. + Reasignar borradores de registro + Quién puede trasladar un borrador de registro a otro autor, por ejemplo cuando el autor original no está disponible. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx index 7e430b908..2e09bf983 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx @@ -953,4 +953,43 @@ Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered. Emergency Break-Glass Access Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered. + Rapports + Ces autorisations contrôlent le module Rapports, successeur des Journaux. Comme pour la plupart des autorisations Resgrid, une ligne non définie applique la valeur par défaut affichée, qui correspond au comportement actuel des Journaux. L'activation des Rapports pour votre service copie également vos réglages Créer un journal et Supprimer un journal sur les lignes correspondantes, sauf si vous les avez déjà définis ici. + Les Rapports ne sont pas encore actifs pour ce service. Vous pouvez préparer ces réglages dès maintenant ; ils s'appliqueront une fois les Rapports activés. + Créer des rapports + Qui peut rédiger de nouveaux rapports et modifier ses propres brouillons. Correspond à Créer un journal jusqu'à ce que vous le changiez. + Annuler des rapports + Qui peut abandonner un brouillon ou annuler un rapport finalisé. L'annulation conserve le rapport et son historique ; rien n'est supprimé. Correspond à Supprimer un journal jusqu'à ce que vous le changiez. + Réviser des rapports + Qui peut réviser les rapports soumis pour révision et les renvoyer pour correction. Groupe uniquement limite les administrateurs de groupe aux rapports de leur propre groupe. + Approuver des rapports + Qui peut approuver un rapport révisé avant sa finalisation, pour les types de rapport utilisant le cycle d'approbation. + Finaliser des rapports + Qui peut finaliser un rapport, ce qui attribue son numéro et verrouille son contenu. Les types de rapport en saisie rapide sont finalisés à l'enregistrement ; cela correspond donc à Créer un journal jusqu'à ce que vous le changiez. + Amender des rapports finalisés + Qui peut ouvrir un amendement sur un rapport finalisé. Un amendement crée une nouvelle révision ; la révision d'origine reste intacte. + Transmettre des rapports en externe + Qui peut transmettre un rapport finalisé à une destination de déclaration externe, comme un système régional ou national. + Imprimer et exporter des rapports + Qui peut imprimer un rapport ou exporter une liste de rapports. + Partager des rapports + Qui peut partager un rapport avec d'autres groupes du service ou avec des personnes extérieures. + Voir les sections restreintes + Qui peut lire les sections restreintes d'un rapport, comme les détails d'un rapport de médecin légiste. Les autres voient que la section existe, mais pas son contenu. + Voir les anciens journaux + Qui peut lire l'historique des Journaux en lecture seule antérieur à l'activation des Rapports. + Voir les rapports des autres groupes + Si le personnel peut voir les rapports appartenant à d'autres groupes. Cochez Groupe uniquement et réglez la visibilité par groupe dans les Paramètres des rapports pour limiter chacun aux rapports de son propre groupe. Laissez sans restriction pour conserver la vue à l'échelle du service qu'ont les Journaux aujourd'hui. + Gérer les définitions de rapport + Qui peut créer et modifier les définitions de rapport, c'est-à-dire les formulaires à partir desquels les rapports sont construits. + Publier les définitions de rapport + Qui peut publier une version de définition de rapport afin qu'elle soit utilisable pour de nouveaux rapports. + Gérer les états de rapports + Qui peut créer et modifier les états de rapports enregistrés et leurs planifications. + Gérer les divulgations de rapports + Qui peut préparer et remettre les dossiers de divulgation de documents publics. + Gérer les conservations légales + Qui peut poser et lever des conservations légales, qui suspendent la rétention et empêchent l'annulation d'un rapport. + Réattribuer les brouillons de rapport + Qui peut transférer un brouillon de rapport à un autre auteur, par exemple lorsque l'auteur d'origine est indisponible. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx index a6a8ff0e7..232dc51cf 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx @@ -953,4 +953,43 @@ Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered. Emergency Break-Glass Access Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered. + Rapporti + Queste autorizzazioni controllano il modulo Rapporti, successore dei Registri. Come per la maggior parte delle autorizzazioni Resgrid, una riga non impostata usa il valore predefinito mostrato, che corrisponde al comportamento attuale dei Registri. Attivando i Rapporti per il dipartimento vengono inoltre copiate le impostazioni Crea registro ed Elimina registro sulle righe corrispondenti, salvo che non le abbiate già impostate qui. + I Rapporti non sono ancora attivi per questo dipartimento. Potete preparare queste impostazioni adesso; avranno effetto quando i Rapporti verranno attivati. + Creare rapporti + Chi può redigere nuovi rapporti e modificare le proprie bozze. Corrisponde a Crea registro finché non lo modificate. + Annullare rapporti + Chi può annullare una bozza o invalidare un rapporto finalizzato. L'invalidazione conserva il rapporto e la sua cronologia; nulla viene eliminato. Corrisponde a Elimina registro finché non lo modificate. + Revisionare rapporti + Chi può revisionare i rapporti inviati in revisione e restituirli per correzione. Solo gruppo limita gli amministratori di gruppo ai rapporti del proprio gruppo. + Approvare rapporti + Chi può approvare un rapporto revisionato prima della finalizzazione, per i tipi di rapporto che usano il ciclo di approvazione. + Finalizzare rapporti + Chi può finalizzare un rapporto, assegnandone il numero e bloccandone il contenuto. I tipi a inserimento rapido vengono finalizzati al salvataggio, quindi corrisponde a Crea registro finché non lo modificate. + Emendare rapporti finalizzati + Chi può aprire un emendamento su un rapporto finalizzato. L'emendamento crea una nuova revisione; la revisione originale resta intatta. + Trasmettere rapporti all'esterno + Chi può trasmettere un rapporto finalizzato a una destinazione di segnalazione esterna, come un sistema regionale o nazionale. + Stampare ed esportare rapporti + Chi può stampare un rapporto o esportare un elenco di rapporti. + Condividere rapporti + Chi può condividere un rapporto con altri gruppi del dipartimento o con persone esterne. + Visualizzare sezioni riservate + Chi può leggere le sezioni riservate di un rapporto, come i dettagli di un rapporto del medico legale. Gli altri vedono che la sezione esiste, ma non il suo contenuto. + Visualizzare registri precedenti + Chi può leggere la cronologia dei Registri in sola lettura precedente all'attivazione dei Rapporti. + Visualizzare rapporti di altri gruppi + Se il personale può vedere i rapporti di altri gruppi. Spuntate Solo gruppo e impostate la visibilità per gruppo nelle Impostazioni rapporti per limitare ciascuno ai rapporti del proprio gruppo. Lasciate senza restrizioni per mantenere la vista a livello di dipartimento che i Registri hanno oggi. + Gestire definizioni di rapporto + Chi può creare e modificare le definizioni di rapporto, i moduli da cui sono costruiti i rapporti. + Pubblicare definizioni di rapporto + Chi può pubblicare una versione di definizione di rapporto affinché sia utilizzabile per nuovi rapporti. + Gestire report sui rapporti + Chi può creare e modificare i report salvati sui rapporti e le loro pianificazioni. + Gestire divulgazioni di rapporti + Chi può preparare e rilasciare i pacchetti di divulgazione di atti pubblici. + Gestire vincoli legali + Chi può applicare e rimuovere i vincoli legali, che sospendono la conservazione e impediscono l'invalidazione di un rapporto. + Riassegnare bozze di rapporto + Chi può trasferire una bozza di rapporto a un altro autore, ad esempio quando l'autore originale non è disponibile. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx index 7866e83ea..a43362f4a 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx @@ -953,4 +953,43 @@ Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered. Emergency Break-Glass Access Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered. + Raporty + Te uprawnienia sterują modułem Raporty, następcą Dzienników. Jak w przypadku większości uprawnień Resgrid, nieustawiony wiersz przyjmuje pokazaną wartość domyślną, odpowiadającą obecnemu zachowaniu Dzienników. Aktywacja Raportów dla wydziału kopiuje też ustawienia Utwórz wpis dziennika i Usuń wpis dziennika do odpowiednich wierszy, chyba że zostały już tu ustawione. + Raporty nie są jeszcze aktywne dla tego wydziału. Możesz przygotować te ustawienia teraz; zaczną obowiązywać po aktywacji Raportów. + Tworzenie raportów + Kto może tworzyć nowe raporty i edytować własne wersje robocze. Odpowiada ustawieniu Utwórz wpis dziennika, dopóki go nie zmienisz. + Unieważnianie lub anulowanie raportów + Kto może anulować wersję roboczą lub unieważnić sfinalizowany raport. Unieważnienie zachowuje raport i jego historię; nic nie jest usuwane. Odpowiada ustawieniu Usuń wpis dziennika, dopóki go nie zmienisz. + Przeglądanie raportów + Kto może sprawdzać raporty przekazane do przeglądu i zwracać je do poprawy. Tylko grupa ogranicza administratorów grup do raportów z ich własnej grupy. + Zatwierdzanie raportów + Kto może zatwierdzić sprawdzony raport przed finalizacją, w typach raportów korzystających z cyklu zatwierdzania. + Finalizowanie raportów + Kto może sfinalizować raport, co nadaje mu numer i blokuje treść. Typy szybkiego wpisu finalizują się przy zapisie, więc odpowiada to ustawieniu Utwórz wpis dziennika, dopóki go nie zmienisz. + Poprawianie sfinalizowanych raportów + Kto może otworzyć poprawkę do sfinalizowanego raportu. Poprawka tworzy nową wersję; wersja pierwotna pozostaje nienaruszona. + Przesyłanie raportów na zewnątrz + Kto może przesłać sfinalizowany raport do zewnętrznego systemu sprawozdawczego, np. wojewódzkiego lub krajowego. + Drukowanie i eksport raportów + Kto może wydrukować raport lub wyeksportować listę raportów. + Udostępnianie raportów + Kto może udostępnić raport innym grupom w wydziale lub osobom spoza niego. + Wyświetlanie sekcji zastrzeżonych + Kto może czytać zastrzeżone sekcje raportu, np. szczegóły raportu koronera. Pozostali widzą, że sekcja istnieje, ale nie jej treść. + Wyświetlanie dawnych dzienników + Kto może czytać historię Dzienników (tylko do odczytu) sprzed aktywacji Raportów. + Wyświetlanie raportów innych grup + Czy personel może widzieć raporty należące do innych grup. Zaznacz Tylko grupa i ustaw widoczność grupową w Ustawieniach raportów, aby ograniczyć osoby do raportów własnej grupy. Pozostaw bez ograniczeń, aby zachować obecny widok całego wydziału znany z Dzienników. + Zarządzanie definicjami raportów + Kto może tworzyć i edytować definicje raportów, czyli formularze, z których budowane są raporty. + Publikowanie definicji raportów + Kto może opublikować wersję definicji raportu, aby można było jej używać w nowych raportach. + Zarządzanie zestawieniami raportów + Kto może tworzyć i edytować zapisane zestawienia raportów oraz ich harmonogramy. + Zarządzanie ujawnieniami raportów + Kto może przygotowywać i wydawać pakiety ujawnienia informacji publicznej. + Zarządzanie blokadami prawnymi + Kto może nakładać i zdejmować blokady prawne, które wstrzymują retencję i uniemożliwiają unieważnienie raportu. + Przypisywanie wersji roboczych innym autorom + Kto może przekazać wersję roboczą raportu innemu autorowi, np. gdy pierwotny autor jest niedostępny. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx index b8f0659bb..76710dd20 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx @@ -953,4 +953,43 @@ Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered. Emergency Break-Glass Access Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered. + Rapporter + Dessa behörigheter styr modulen Rapporter, efterföljaren till Loggar. Som för de flesta Resgrid-behörigheter gäller det visade standardvärdet för en rad som inte ställts in, och det motsvarar hur Loggar fungerar i dag. När Rapporter aktiveras för din avdelning kopieras även inställningarna Skapa logg och Ta bort logg till motsvarande rader, om du inte redan har ställt in dem här. + Rapporter är ännu inte aktiverat för den här avdelningen. Du kan förbereda inställningarna nu; de börjar gälla när Rapporter aktiveras. + Skapa rapporter + Vem som får skriva nya rapporter och redigera sina egna utkast. Motsvarar Skapa logg tills du ändrar det. + Ogiltigförklara eller avbryta rapporter + Vem som får avbryta ett utkast eller ogiltigförklara en slutförd rapport. Ogiltigförklaring behåller rapporten och dess historik; inget raderas. Motsvarar Ta bort logg tills du ändrar det. + Granska rapporter + Vem som får granska rapporter som skickats för granskning och återsända dem för rättelse. Endast grupp begränsar gruppadministratörer till rapporter från den egna gruppen. + Godkänna rapporter + Vem som får godkänna en granskad rapport innan den slutförs, för rapporttyper som använder godkännandeflödet. + Slutföra rapporter + Vem som får slutföra en rapport, vilket tilldelar rapportnumret och låser innehållet. Rapporttyper med snabbinmatning slutförs vid sparande, så detta motsvarar Skapa logg tills du ändrar det. + Komplettera slutförda rapporter + Vem som får öppna en komplettering av en slutförd rapport. En komplettering skapar en ny revision; den ursprungliga revisionen förblir orörd. + Skicka rapporter externt + Vem som får skicka en slutförd rapport till en extern rapporteringsmottagare, till exempel ett regionalt eller nationellt system. + Skriva ut och exportera rapporter + Vem som får skriva ut en rapport eller exportera en lista med rapporter. + Dela rapporter + Vem som får dela en rapport med andra grupper i avdelningen eller med personer utanför den. + Visa begränsade avsnitt + Vem som får läsa de begränsade avsnitten i en rapport, till exempel detaljerna i en rättsläkarrapport. Alla andra ser att avsnittet finns men inte dess innehåll. + Visa äldre loggar + Vem som får läsa den skrivskyddade logghistoriken från tiden innan Rapporter aktiverades. + Visa rapporter från andra grupper + Om personal får se rapporter som tillhör andra grupper. Markera Endast grupp och ställ in gruppbegränsad synlighet i Rapportinställningar för att begränsa personer till den egna gruppens rapporter. Lämna obegränsat för att behålla den avdelningsövergripande vy som Loggar har i dag. + Hantera rapportdefinitioner + Vem som får skapa och redigera rapportdefinitioner, formulären som rapporter byggs av. + Publicera rapportdefinitioner + Vem som får publicera en version av en rapportdefinition så att den kan användas för nya rapporter. + Hantera rapportsammanställningar + Vem som får skapa och redigera sparade rapportsammanställningar och deras scheman. + Hantera utlämnanden av rapporter + Vem som får förbereda och lämna ut paket för utlämnande av allmänna handlingar. + Hantera rättsliga spärrar + Vem som får lägga och häva rättsliga spärrar, som pausar gallring och hindrar att en rapport ogiltigförklaras. + Omfördela rapportutkast + Vem som får flytta ett rapportutkast till en annan författare, till exempel när den ursprungliga författaren inte är tillgänglig. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx index 0f0e26d8e..0bc30b0c5 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx @@ -953,4 +953,43 @@ Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered. Emergency Break-Glass Access Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered. + Звіти + Ці дозволи керують модулем Звіти, наступником Журналів. Як і для більшості дозволів Resgrid, для неналаштованого рядка діє показане значення за замовчуванням, яке відповідає поточній поведінці Журналів. Під час активації Звітів для вашого підрозділу налаштування «Створити запис журналу» та «Видалити запис журналу» також копіюються у відповідні рядки, якщо ви ще не задали їх тут. + Звіти ще не активовано для цього підрозділу. Ви можете підготувати ці налаштування зараз; вони набудуть чинності після активації Звітів. + Створення звітів + Хто може створювати нові звіти та редагувати власні чернетки. Відповідає «Створити запис журналу», доки ви це не зміните. + Анулювання або скасування звітів + Хто може скасувати чернетку або анулювати завершений звіт. Анулювання зберігає звіт та його історію; нічого не видаляється. Відповідає «Видалити запис журналу», доки ви це не зміните. + Перевірка звітів + Хто може перевіряти звіти, подані на перевірку, і повертати їх на виправлення. «Лише група» обмежує адміністраторів груп звітами їхньої власної групи. + Затвердження звітів + Хто може затвердити перевірений звіт перед завершенням, для типів звітів із циклом затвердження. + Завершення звітів + Хто може завершити звіт, що присвоює йому номер і блокує вміст. Типи швидкого введення завершуються під час збереження, тому це відповідає «Створити запис журналу», доки ви це не зміните. + Внесення поправок до завершених звітів + Хто може відкрити поправку до завершеного звіту. Поправка створює нову редакцію; початкова редакція залишається незмінною. + Зовнішнє подання звітів + Хто може подати завершений звіт до зовнішньої системи звітності, наприклад регіональної або національної. + Друк та експорт звітів + Хто може роздрукувати звіт або експортувати список звітів. + Надання спільного доступу до звітів + Хто може надати доступ до звіту іншим групам підрозділу або стороннім особам. + Перегляд обмежених розділів + Хто може читати обмежені розділи звіту, наприклад подробиці звіту судмедексперта. Усі інші бачать, що розділ існує, але не його вміст. + Перегляд старих журналів + Хто може читати історію Журналів (лише для читання) за період до активації Звітів. + Перегляд звітів інших груп + Чи може персонал бачити звіти інших груп. Поставте позначку «Лише група» та встановіть групову видимість у Налаштуваннях звітів, щоб обмежити людей звітами їхньої власної групи. Залиште без обмежень, щоб зберегти поточний загальнопідрозділовий перегляд, як у Журналах. + Керування визначеннями звітів + Хто може створювати та редагувати визначення звітів — форми, з яких будуються звіти. + Публікація визначень звітів + Хто може опублікувати версію визначення звіту, щоб її можна було використовувати для нових звітів. + Керування зведеннями звітів + Хто може створювати та редагувати збережені зведення звітів та їхні розклади. + Керування розкриттям звітів + Хто може готувати та видавати пакети розкриття публічної інформації. + Керування юридичними утриманнями + Хто може накладати та знімати юридичні утримання, які призупиняють строк зберігання та не дозволяють анулювати звіт. + Перепризначення чернеток звітів + Хто може передати чернетку звіту іншому автору, наприклад коли початковий автор недоступний. diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.ar.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.ar.resx index 45eef8e87..7b9ec2019 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.ar.resx @@ -590,4 +590,28 @@ تفاصيل + + «تمت إضافة دفتر» مشغّل توافق. بعد تفعيل السجلات يعمل عند إنهاء سجل خروج أو تدريب أو عمل أو اجتماع أو طب شرعي أو معاودة اتصال، ناقلًا حقول الدفتر القديمة بما فيها السرد، ويُحتفظ به لإصدارين فرعيين أو اثني عشر شهرًا بعد التفعيل. استخدم مشغّلات السجل لسير العمل الجديد. + + + معرّف الحدث + + + معرّف الارتباط + + + معرّف السبب + + + تسلسل الحدث + + + المصدر + + + تم التخطي بسبب + + + المتخطاة + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.de.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.de.resx index 052ae0136..f934cc8d4 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.de.resx @@ -591,4 +591,28 @@ Detail + + „Protokoll hinzugefügt“ ist ein Kompatibilitäts-Auslöser. Sobald Berichte aktiv sind, wird er beim Abschluss eines Einsatz-, Schulungs-, Arbeits-, Besprechungs-, Leichenschau- oder Rückruf-Berichts ausgelöst und überträgt die alten Protokollfelder einschließlich des Berichtstexts; er bleibt zwei Minor-Releases oder zwölf Monate nach der Aktivierung erhalten. Verwenden Sie für neue Workflows die Berichts-Auslöser. + + + Ereignis-ID + + + Korrelations-ID + + + Verursachungs-ID + + + Ereignisfolge + + + Ursprung + + + Übersprungen wegen + + + Übersprungen + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.el.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.el.resx index c71ad45e5..19dd738c0 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.el.resx @@ -614,5 +614,29 @@ Λεπτομέρεια + + Το «Προστέθηκε ημερολόγιο» είναι έναυσμα συμβατότητας. Με ενεργές τις Αναφορές, ενεργοποιείται όταν οριστικοποιείται αναφορά επέμβασης, εκπαίδευσης, εργασίας, σύσκεψης, ιατροδικαστή ή επανάκλησης, μεταφέροντας τα παλιά πεδία ημερολογίου μαζί με την αφήγηση, και διατηρείται για δύο δευτερεύουσες εκδόσεις ή δώδεκα μήνες μετά την ενεργοποίηση. Χρησιμοποιήστε τα εναύσματα Αναφοράς για νέες ροές εργασίας. + + + ID συμβάντος + + + ID συσχέτισης + + + ID αιτίας + + + Ακολουθία συμβάντος + + + Προέλευση + + + Παραλείφθηκε λόγω + + + Παραλειφθείσες + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.en.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.en.resx index 690ca4533..c6bb5fe36 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.en.resx @@ -614,5 +614,29 @@ Detail + + Log Added is a compatibility trigger. Once Records is active it fires when a Run, Training, Work, Meeting, Coroner or Callback record is finalized, carrying the legacy log fields including the narrative, and it is retained for two minor releases or twelve months after activation. Use the Record triggers for new workflows. + + + Event ID + + + Correlation ID + + + Causation ID + + + Event sequence + + + Origin + + + Skipped because + + + Skipped + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.es.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.es.resx index 967a6e461..ed3380817 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.es.resx @@ -612,5 +612,29 @@ Detalle + + «Bitácora añadida» es un disparador de compatibilidad. Con Registros activo se dispara al finalizar un registro de salida, formación, trabajo, reunión, forense o rellamada, con los campos antiguos de la bitácora incluido el texto narrativo, y se conserva durante dos versiones menores o doce meses tras la activación. Use los disparadores de Registro para los flujos nuevos. + + + ID del evento + + + ID de correlación + + + ID de causa + + + Secuencia del evento + + + Origen + + + Omitido por + + + Omitidos + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.fr.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.fr.resx index 48cb1a934..96e63be12 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.fr.resx @@ -591,4 +591,28 @@ Détail + + « Journal ajouté » est un déclencheur de compatibilité. Une fois les Rapports actifs, il se déclenche à la finalisation d'un rapport d'intervention, de formation, de travail, de réunion, de médecin légiste ou de rappel, avec les anciens champs du journal y compris le récit, et il est conservé pendant deux versions mineures ou douze mois après l'activation. Utilisez les déclencheurs Rapport pour les nouveaux workflows. + + + ID de l'événement + + + ID de corrélation + + + ID de causalité + + + Séquence de l'événement + + + Origine + + + Ignoré en raison de + + + Ignorés + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.it.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.it.resx index 915d5d963..69b6f9f31 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.it.resx @@ -591,4 +591,28 @@ Dettaglio + + «Registro aggiunto» è un trigger di compatibilità. Con i Rapporti attivi scatta alla finalizzazione di un rapporto di intervento, formazione, lavoro, riunione, medico legale o richiamo, con i vecchi campi del registro inclusa la narrativa, e viene mantenuto per due versioni minori o dodici mesi dopo l'attivazione. Usate i trigger Rapporto per i nuovi flussi. + + + ID evento + + + ID correlazione + + + ID causa + + + Sequenza evento + + + Origine + + + Saltato per + + + Saltati + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.pl.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.pl.resx index 168bcf924..2a35f8110 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.pl.resx @@ -591,4 +591,28 @@ Szczegóły + + „Dodano wpis dziennika” to wyzwalacz zgodności. Po włączeniu Raportów uruchamia się przy finalizacji raportu z wyjazdu, szkolenia, pracy, spotkania, koronera lub oddzwonienia, przekazując dawne pola dziennika wraz z opisem, i jest utrzymywany przez dwa wydania pomocnicze lub dwanaście miesięcy od aktywacji. W nowych przepływach używaj wyzwalaczy Raport. + + + ID zdarzenia + + + ID korelacji + + + ID przyczyny + + + Sekwencja zdarzenia + + + Pochodzenie + + + Pominięto z powodu + + + Pominięte + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.resx index 43ebe586d..477972cdf 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.resx @@ -611,5 +611,29 @@ + + Log Added is a compatibility trigger. Once Records is active it fires when a Run, Training, Work, Meeting, Coroner or Callback record is finalized, carrying the legacy log fields including the narrative, and it is retained for two minor releases or twelve months after activation. Use the Record triggers for new workflows. + + + Event ID + + + Correlation ID + + + Causation ID + + + Event sequence + + + Origin + + + Skipped because + + + Skipped + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.sv.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.sv.resx index 980d8954c..3c042681f 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.sv.resx @@ -591,4 +591,28 @@ Detalj + + ”Logg tillagd” är en kompatibilitetsutlösare. När Rapporter är aktivt utlöses den när en insats-, utbildnings-, arbets-, mötes-, rättsläkar- eller återkallningsrapport slutförs, med de gamla loggfälten inklusive berättelsen, och den behålls i två mindre versioner eller tolv månader efter aktiveringen. Använd Rapport-utlösarna för nya arbetsflöden. + + + Händelse-ID + + + Korrelations-ID + + + Orsaks-ID + + + Händelsesekvens + + + Ursprung + + + Hoppades över på grund av + + + Överhoppade + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.uk.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.uk.resx index e614ece95..df1e019ff 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.uk.resx @@ -591,4 +591,28 @@ Деталі + + «Додано запис журналу» — це тригер сумісності. Після активації Звітів він спрацьовує при завершенні звіту про виїзд, навчання, роботу, нараду, судмедексперта або зворотний виклик, передаючи старі поля журналу разом з описом, і зберігається два проміжні випуски або дванадцять місяців після активації. Для нових робочих процесів використовуйте тригери Звіт. + + + ID події + + + ID кореляції + + + ID причини + + + Послідовність події + + + Джерело + + + Пропущено через + + + Пропущені + diff --git a/Core/Resgrid.Localization/Common.ar.resx b/Core/Resgrid.Localization/Common.ar.resx index dda00c747..2017fd8f1 100644 --- a/Core/Resgrid.Localization/Common.ar.resx +++ b/Core/Resgrid.Localization/Common.ar.resx @@ -283,4 +283,8 @@ فشل الطلب. حاول مرة أخرى. + السجلات + + ملف القسم + diff --git a/Core/Resgrid.Localization/Common.de.resx b/Core/Resgrid.Localization/Common.de.resx index 5a8e9b4df..35d8c0683 100644 --- a/Core/Resgrid.Localization/Common.de.resx +++ b/Core/Resgrid.Localization/Common.de.resx @@ -716,4 +716,8 @@ Die Anfrage ist fehlgeschlagen. Bitte erneut versuchen. + Akten + + Abteilungsprofil + diff --git a/Core/Resgrid.Localization/Common.el.resx b/Core/Resgrid.Localization/Common.el.resx index 55aff0166..51bbd41c7 100644 --- a/Core/Resgrid.Localization/Common.el.resx +++ b/Core/Resgrid.Localization/Common.el.resx @@ -768,4 +768,8 @@ Το αίτημα απέτυχε. Δοκιμάστε ξανά. + Αρχεία + + Προφίλ υπηρεσίας + diff --git a/Core/Resgrid.Localization/Common.en.resx b/Core/Resgrid.Localization/Common.en.resx index 437d537ee..2178cba1b 100644 --- a/Core/Resgrid.Localization/Common.en.resx +++ b/Core/Resgrid.Localization/Common.en.resx @@ -768,4 +768,8 @@ The request failed. Try again. + Records + + Department Profile + diff --git a/Core/Resgrid.Localization/Common.es.resx b/Core/Resgrid.Localization/Common.es.resx index 89e5899aa..be5817701 100644 --- a/Core/Resgrid.Localization/Common.es.resx +++ b/Core/Resgrid.Localization/Common.es.resx @@ -756,4 +756,8 @@ La solicitud falló. Inténtelo de nuevo. + Registros + + Perfil del departamento + diff --git a/Core/Resgrid.Localization/Common.fr.resx b/Core/Resgrid.Localization/Common.fr.resx index 424ba1010..ef665b569 100644 --- a/Core/Resgrid.Localization/Common.fr.resx +++ b/Core/Resgrid.Localization/Common.fr.resx @@ -716,4 +716,8 @@ La requête a échoué. Réessayez. + Dossiers + + Profil du service + diff --git a/Core/Resgrid.Localization/Common.it.resx b/Core/Resgrid.Localization/Common.it.resx index 9066101fb..87fe2d161 100644 --- a/Core/Resgrid.Localization/Common.it.resx +++ b/Core/Resgrid.Localization/Common.it.resx @@ -716,4 +716,8 @@ La richiesta non è riuscita. Riprova. + Registri + + Profilo del dipartimento + diff --git a/Core/Resgrid.Localization/Common.pl.resx b/Core/Resgrid.Localization/Common.pl.resx index 065c1fb99..605bbba7e 100644 --- a/Core/Resgrid.Localization/Common.pl.resx +++ b/Core/Resgrid.Localization/Common.pl.resx @@ -716,4 +716,8 @@ Żądanie nie powiodło się. Spróbuj ponownie. + Rejestry + + Profil wydziału + diff --git a/Core/Resgrid.Localization/Common.sv.resx b/Core/Resgrid.Localization/Common.sv.resx index b993645b9..4d5b02c06 100644 --- a/Core/Resgrid.Localization/Common.sv.resx +++ b/Core/Resgrid.Localization/Common.sv.resx @@ -716,4 +716,8 @@ Begäran misslyckades. Försök igen. + Journaler + + Avdelningsprofil + diff --git a/Core/Resgrid.Localization/Common.uk.resx b/Core/Resgrid.Localization/Common.uk.resx index a5e4edeeb..c17b5fe1b 100644 --- a/Core/Resgrid.Localization/Common.uk.resx +++ b/Core/Resgrid.Localization/Common.uk.resx @@ -716,4 +716,8 @@ Запит не вдався. Спробуйте ще раз. + Записи + + Профіль підрозділу + diff --git a/Core/Resgrid.Model/DepartmentEmailBranding.cs b/Core/Resgrid.Model/DepartmentEmailBranding.cs new file mode 100644 index 000000000..3e4a542d5 --- /dev/null +++ b/Core/Resgrid.Model/DepartmentEmailBranding.cs @@ -0,0 +1,71 @@ +using System; +using ProtoBuf; + +namespace Resgrid.Model +{ + /// + /// What an operational email needs to render a department masthead (RMS plan section 4.10.1, "Replacing the + /// Resgrid logo in emails"). The identity fields are always filled from the profile or the Department row; + /// the masthead itself is only when the department has a logo AND opted in with + /// UseDepartmentBrandingInEmails. A disabled instance renders today's Resgrid chrome unchanged. + /// + [ProtoContract] + public class DepartmentEmailBranding + { + [ProtoMember(1)] + public int DepartmentId { get; set; } + + /// True only when an EmailMasthead rendition exists and the department opted in. + [ProtoMember(2)] + public bool Enabled { get; set; } + + /// Profile display name, falling back to the Department row name. + [ProtoMember(3)] + public string DisplayName { get; set; } + + /// Anonymous, MediaKey-addressed EmailMasthead URL; null when the masthead is not enabled. + [ProtoMember(4)] + public string LogoUrl { get; set; } + + /// Absolute http(s) website from the profile, or null when none is set or it is not a web URL. + [ProtoMember(5)] + public string Website { get; set; } + + public static DepartmentEmailBranding Disabled(int departmentId, string displayName = null) + { + return new DepartmentEmailBranding { DepartmentId = departmentId, DisplayName = displayName }; + } + + /// + /// Turns whatever an admin typed into the profile Website field into an absolute http(s) URL, or null + /// when it is not one. The value lands in an href, so anything that is not a plain web address + /// (other schemes, credentials in the authority, bare words) is dropped rather than linked. + /// + public static string NormalizeWebsite(string website) + { + if (string.IsNullOrWhiteSpace(website)) + return null; + + var text = website.Trim(); + + if (!Uri.TryCreate(text, UriKind.Absolute, out var uri) || !IsWebScheme(uri)) + { + if (text.Contains("://") || text.StartsWith("//")) + return null; + + if (!Uri.TryCreate("https://" + text, UriKind.Absolute, out uri) || !IsWebScheme(uri)) + return null; + } + + if (!string.IsNullOrEmpty(uri.UserInfo) || string.IsNullOrWhiteSpace(uri.Host) || !uri.Host.Contains('.')) + return null; + + return uri.AbsoluteUri; + } + + private static bool IsWebScheme(Uri uri) + { + return uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps; + } + } +} diff --git a/Core/Resgrid.Model/DepartmentProfile.cs b/Core/Resgrid.Model/DepartmentProfile.cs index 9ae7d55aa..d8e8bf744 100644 --- a/Core/Resgrid.Model/DepartmentProfile.cs +++ b/Core/Resgrid.Model/DepartmentProfile.cs @@ -125,6 +125,9 @@ public class DepartmentProfile : IEntity [ProtoMember(34)] public string PhoneNumber { get; set; } + /// Department-scoped operational emails render the department masthead instead of the Resgrid one (RMS plan section 4.10.1, M0172). + public bool UseDepartmentBrandingInEmails { get; set; } + [NotMapped] [JsonIgnore] public object IdValue diff --git a/Core/Resgrid.Model/DepartmentProfileMedia.cs b/Core/Resgrid.Model/DepartmentProfileMedia.cs new file mode 100644 index 000000000..d44a13205 --- /dev/null +++ b/Core/Resgrid.Model/DepartmentProfileMedia.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Resgrid.Model +{ + /// Which rendition a DepartmentProfileMedia row holds (RMS plan section 4.10.1). + public enum DepartmentProfileMediaKind + { + /// The re-encoded upload. + PrimaryLogo = 1, + /// Fitted to 1200x400 for print headers. + PrintHeader = 2, + /// 188 px wide (the 94 px email slot at 2x density). + EmailMasthead = 3, + /// Fitted to 128x128. + Thumbnail = 4, + /// Optional print-specific art uploaded by an administrator. + PrintHeaderOverride = 5 + } + + /// + /// The department's branding media: the uploaded logo and its server-generated renditions (migration M0172). + /// Bytes live in the row per the existing profile-image convention. MediaKey is the per-department opaque + /// key for the anonymous email-masthead endpoint; regenerating it invalidates every previously sent link. + /// + [Table("DepartmentProfileMedia")] + public class DepartmentProfileMedia : IEntity + { + [Key] + [Required] + public string DepartmentProfileMediaId { get; set; } + + [Required] + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + /// . + public int Kind { get; set; } + + public string ContentType { get; set; } + + public int Width { get; set; } + + public int Height { get; set; } + + public long ByteSize { get; set; } + + public string Checksum { get; set; } + + public byte[] Data { get; set; } + + public string UploadedByUserId { get; set; } + + public DateTime UploadedOn { get; set; } + + public string MediaKey { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime ModifiedOn { get; set; } + + public long RowVersion { get; set; } + + [NotMapped] + public object IdValue + { + get { return DepartmentProfileMediaId; } + set { DepartmentProfileMediaId = value?.ToString(); } + } + + [NotMapped] + public string TableName => "DepartmentProfileMedia"; + + [NotMapped] + public string IdName => "DepartmentProfileMediaId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// + /// The resolved identity block a print header, an email masthead or the profile page renders. Assembled from + /// DepartmentProfile (falling back to the Department row) plus the media metadata; never carries bytes. + /// + public class DepartmentBranding + { + public int DepartmentId { get; set; } + public DepartmentProfile Profile { get; set; } + public string DisplayName { get; set; } + public string ShortName { get; set; } + public string Code { get; set; } + public string AddressText { get; set; } + public string PhoneNumber { get; set; } + public string Website { get; set; } + public bool UseDepartmentBrandingInEmails { get; set; } + public string MediaKey { get; set; } + public List Media { get; set; } = new List(); + + public bool HasLogo => Media != null && Media.Exists(m => m.Kind == (int)DepartmentProfileMediaKind.PrimaryLogo); + + public DepartmentProfileMedia Rendition(DepartmentProfileMediaKind kind) + { + return Media?.Find(m => m.Kind == (int)kind); + } + } +} diff --git a/Core/Resgrid.Model/DepartmentSettingTypes.cs b/Core/Resgrid.Model/DepartmentSettingTypes.cs index 0a1040d04..2aa10cbf5 100644 --- a/Core/Resgrid.Model/DepartmentSettingTypes.cs +++ b/Core/Resgrid.Model/DepartmentSettingTypes.cs @@ -80,5 +80,33 @@ public enum DepartmentSettingTypes /// Their reset action sends the member the hardened, single-use password recovery link instead. /// RequirePasswordResetViaEmail = 63, + + // -- Records (RMS) block 70-77 -- Identifier Allocation Registry section 3.4. 64-69 is the + // cross-plan buffer and must not be taken here. All are edited from the Records Settings screen + // (RMS plan section 4.9); RecordsActivatedOn is deliberately NOT a setting (RmsDepartmentCutover). + + /// Cached scalar: default for new department-owned definitions (locked definitions keep their own). + RecordsDefaultLifecyclePreset = 70, + + /// Cached scalar: review-due target in hours; a per-definition override wins. + RecordsReviewDueHours = 71, + + /// ProtoBuf-serialized : department-wide numbering defaults applied when a definition declares none. + RecordsNumberingConfig = 72, + + /// ProtoBuf-serialized : index scope and the protected degrade mode. + RecordsSearchConfig = 73, + + /// ProtoBuf-serialized : department default years plus per-definition overrides (0 = permanent). + RecordsRetentionPolicy = 74, + + /// Cached scalar : DepartmentWide (0, default) or GroupScoped (1). v1 is on/off only. + RecordsGroupVisibilityMode = 75, + + /// ProtoBuf: RESERVED, unused in v1. Becomes the per-anchor group-scope toggle later without a new value. + RecordsGroupScopeConfig = 76, + + /// ProtoBuf-serialized : public-records statutory clock, default redaction profile, release approver. RMS-3. + RecordsDisclosureConfig = 77, } } diff --git a/Core/Resgrid.Model/Events/DomainEventDispatchedEvent.cs b/Core/Resgrid.Model/Events/DomainEventDispatchedEvent.cs new file mode 100644 index 000000000..87ed6c6b4 --- /dev/null +++ b/Core/Resgrid.Model/Events/DomainEventDispatchedEvent.cs @@ -0,0 +1,27 @@ +namespace Resgrid.Model.Events +{ + /// + /// Raised on the in-process event aggregator when the DomainEventOutbox dispatches a row (post-commit + /// in the producing process, or from worker command 40's catch-up sweep). WorkflowEventProvider and + /// other consumers subscribe to this; the outbox row remains the durable source until every intended + /// consumer has acknowledged. The payload is already the safe projection. + /// + public class DomainEventDispatchedEvent + { + public int DepartmentId { get; set; } + public string EventId { get; set; } + public string ProducerSubsystem { get; set; } + public string EventName { get; set; } + public int SchemaVersion { get; set; } + public string AggregateType { get; set; } + public string AggregateId { get; set; } + public long Sequence { get; set; } + public int? TriggerEventType { get; set; } + public string PayloadJson { get; set; } + public string CorrelationId { get; set; } + public string CausationId { get; set; } + public int OriginClient { get; set; } + public bool IsReplay { get; set; } + public System.DateTime OccurredOn { get; set; } + } +} diff --git a/Core/Resgrid.Model/Events/EventTypes.cs b/Core/Resgrid.Model/Events/EventTypes.cs index 05f0d3337..2ce12f3d1 100644 --- a/Core/Resgrid.Model/Events/EventTypes.cs +++ b/Core/Resgrid.Model/Events/EventTypes.cs @@ -79,7 +79,25 @@ public enum EventTypes WeatherAlertExpired = 23, [NotMapped] - ModerationRequestCompleted = 24 + ModerationRequestCompleted = 24, + + // -- Records (RMS) block 31-33 -- Identifier Allocation Registry section 3.5. 25-29 belong to + // Certifications and 30 is the buffer; neither may be taken here. 31 is raised by + // RecordsService.ReturnForCorrectionAsync and delivered to the author by IRecordsNotificationService + // (NotificationBroadcastLogic); it stays NotMapped because it is author-targeted, not a department + // broadcast the settings UI should offer. 32 and 33 remain NotMapped until their emitters land. + + /// A reviewer returned a Record to its author for correction. On by default once shipped; individually disableable. + [NotMapped] + RecordReturnedForCorrection = 31, + + /// A Record review obligation is overdue (worker 42, RMS-3). + [NotMapped] + RecordReviewOverdue = 32, + + /// A reporting destination rejected a submitted Record revision (RMS-2). + [NotMapped] + RecordSubmissionRejected = 33 } public static class EventOptions diff --git a/Core/Resgrid.Model/Events/RecordsWorkflowEvent.cs b/Core/Resgrid.Model/Events/RecordsWorkflowEvent.cs new file mode 100644 index 000000000..776cc8985 --- /dev/null +++ b/Core/Resgrid.Model/Events/RecordsWorkflowEvent.cs @@ -0,0 +1,71 @@ +using System; +using Newtonsoft.Json.Linq; + +namespace Resgrid.Model.Events +{ + /// + /// A native Records trigger (WorkflowTriggerEventType 100-115) as WorkflowEventProvider hands it to the + /// workflow pipeline: the outbox envelope plus the already-safe payload (record / record_change / extra) + /// parsed into a JObject, so ADP redaction and WorkflowTemplateContextBuilder both see fields rather + /// than an opaque JSON string. Built from the DomainEventDispatchedEvent the outbox dispatcher publishes; + /// nothing here is rehydrated from current record state (RMS plan section 5.6). + /// + public class RecordsWorkflowEvent + { + public int DepartmentId { get; set; } + public string EventId { get; set; } + public string EventName { get; set; } + public int SchemaVersion { get; set; } + public string AggregateType { get; set; } + public string AggregateId { get; set; } + public long Sequence { get; set; } + public int TriggerEventType { get; set; } + public string CorrelationId { get; set; } + public string CausationId { get; set; } + + /// Safe origin name (Web, Responder, Unit, IncidentCommand, Dispatch, Api, System); never a device identifier. + public string OriginClient { get; set; } + public bool IsReplay { get; set; } + public DateTime OccurredOn { get; set; } + + /// The dispatched payload: record, record_change and optional extra objects. + public JObject Payload { get; set; } = new JObject(); + + public static RecordsWorkflowEvent From(DomainEventDispatchedEvent dispatched) + { + if (dispatched == null) + return null; + + JObject payload; + try + { + payload = string.IsNullOrWhiteSpace(dispatched.PayloadJson) ? new JObject() : JObject.Parse(dispatched.PayloadJson); + } + catch (Exception) + { + // A malformed payload still yields a well-formed event; the template simply sees empty namespaces. + payload = new JObject(); + } + + return new RecordsWorkflowEvent + { + DepartmentId = dispatched.DepartmentId, + EventId = dispatched.EventId, + EventName = dispatched.EventName, + SchemaVersion = dispatched.SchemaVersion, + AggregateType = dispatched.AggregateType, + AggregateId = dispatched.AggregateId, + Sequence = dispatched.Sequence, + TriggerEventType = dispatched.TriggerEventType.GetValueOrDefault(), + CorrelationId = dispatched.CorrelationId, + CausationId = dispatched.CausationId, + OriginClient = Enum.IsDefined(typeof(RmsOriginClient), dispatched.OriginClient) + ? ((RmsOriginClient)dispatched.OriginClient).ToString() + : RmsOriginClient.System.ToString(), + IsReplay = dispatched.IsReplay, + OccurredOn = dispatched.OccurredOn, + Payload = payload + }; + } + } +} diff --git a/Core/Resgrid.Model/FeatureFlagKeys.cs b/Core/Resgrid.Model/FeatureFlagKeys.cs index fe70c2284..2a65ad0af 100644 --- a/Core/Resgrid.Model/FeatureFlagKeys.cs +++ b/Core/Resgrid.Model/FeatureFlagKeys.cs @@ -34,5 +34,25 @@ public static class FeatureFlagKeys /// seeded off and permanent by M0126; ordinary department administrators cannot see or change it. /// public const string DepartmentProtectedDataEnrollment = "Security.DepartmentProtectedDataEnrollment"; + + /// + /// Gates the Records module (RMS), the feature-flagged successor to Logs. Flag off leaves the Logs + /// module unchanged; flag on shows Records in the Logs sidebar position (never both). Department + /// activation is a separate, append-only cutover fact on RmsDepartmentCutover; this flag alone never + /// blocks a legacy Log write. Seeded off by M0152. RMS plan section 4.1. + /// + public const string RecordsSystem = "Records.System"; + + /// Field Records surface in the Responder app. Depends on Records.System. Seeded off by M0152. + public const string RecordsFieldResponder = "Records.Field.Responder"; + + /// Field Records surface in the Unit app. Depends on Records.System. Seeded off by M0152. + public const string RecordsFieldUnit = "Records.Field.Unit"; + + /// Field Records surface in the IC app. Depends on Records.System. Seeded off by M0152. + public const string RecordsFieldIncidentCommand = "Records.Field.IncidentCommand"; + + /// Field Records surface in the Dispatch app. Depends on Records.System. Seeded off by M0152. + public const string RecordsFieldDispatch = "Records.Field.Dispatch"; } } diff --git a/Core/Resgrid.Model/PermissionActions.cs b/Core/Resgrid.Model/PermissionActions.cs index 5b8370d4c..ed08b624b 100644 --- a/Core/Resgrid.Model/PermissionActions.cs +++ b/Core/Resgrid.Model/PermissionActions.cs @@ -5,6 +5,15 @@ public enum PermissionActions DepartmentAdminsOnly = 0, DepartmentAndGroupAdmins = 1, DepartmentAdminsAndSelectRoles = 2, - Everyone = 3 + Everyone = 3, + + /// + /// Department admins, group admins, AND the selected personnel roles. Added with RMS (registry + /// section 4.4) because excludes group admins. Appending + /// is safe: consumers that do not recognise the value fall through to deny (fail closed). It is not + /// yet offered in the generic Permissions dropdown because the pre-RMS claim families do not + /// evaluate it; selecting it there would silently revoke access until they do. + /// + DepartmentAndGroupAdminsAndSelectRoles = 4 } } \ No newline at end of file diff --git a/Core/Resgrid.Model/PermissionTypes.cs b/Core/Resgrid.Model/PermissionTypes.cs index 855e02733..d62382104 100644 --- a/Core/Resgrid.Model/PermissionTypes.cs +++ b/Core/Resgrid.Model/PermissionTypes.cs @@ -80,7 +80,67 @@ public enum PermissionTypes /// Emergency break-glass access to protected data. Off by default; every use requires a reason, /// produces notifications, and is subject to review (ADP plan section 12). /// - BreakGlassProtectedData = 39 + BreakGlassProtectedData = 39, + + // -- Records (RMS) block 50-67 -- Identifier Allocation Registry section 4.1 ------------------ + // Values 40-49 are reserved for other pending plans (Contacts 42-43, Certifications 44-46, + // Inventory 47-49) and must not be taken here. A missing Permission row means ALLOWED under the + // documented no-row default below (ClaimsLogic.AddRecordClaims), so every default equals the + // current Logs behavior and activation changes nobody's access by accident. + + /// Author Records (Record_View + Record_Create). No-row default: everyone (matches CreateLog). LockToGroup restricts authoring to the user's own group's subjects. + CreateRecord = 50, + + /// Void a finalized Record or cancel a non-finalized one (Record_Void). No-row default: everyone. That is what AddDeleteLogClaims grants today when no DeleteLog row exists, so parity requires it even though the registry table says "department admins". A configured DeleteLog row is copied verbatim at activation. + DeleteRecord = 51, + + /// Review a Record submitted for review (Record_Review). No-row default: department and group admins. LockToGroup: reviewers see only their group's queue. + ReviewRecords = 52, + + /// Approve step of the Approval/Acknowledgement preset (Record_Approve). No-row default: department admins. + ApproveRecords = 53, + + /// Finalize a Record (Record_Finalize). No-row default: everyone. A legacy Log is created already-final (Quick Entry parity). + FinalizeRecords = 54, + + /// Open and finalize an amendment to a finalized Record (Record_Amend). No-row default: department and group admins. + AmendRecords = 55, + + /// Submit a finalized revision to a reporting destination such as NERIS (Record_Submit). No-row default: department admins. LockToGroup is not meaningful (department-scoped). + SubmitRecords = 56, + + /// Print/export Records (Record_Export). No-row default: everyone (per-record print/export is Logs parity). Bulk export honors the viewer's group scope regardless. + ExportRecords = 57, + + /// Share one Record to another group or externally (Record_Share). No-row default: department admins. + ShareRecordsExternally = 58, + + /// Read restricted sections: Coroner, casualty/exposure, investigation (RecordRestricted_View). No-row default: department admins. Never widened by group scope. + ViewRestrictedRecords = 59, + + /// Read pre-cutover legacy Log/UnitLog history (RecordLegacy_View). No-row default: everyone (everyone holds Log:View today). + ViewLegacyRecords = 60, + + /// Cross-group Record visibility control, mirroring ViewGroupUsers/ViewGroupUnits. Issues no claim; evaluated per Record at the service layer (AuthorizationService.CanUserViewRecordAsync). No-row default: everyone, not locked (department-wide, matching Logs today). RMS plan section 5.7.1. + ViewGroupRecords = 61, + + /// Author department Record definitions (RecordDefinition_Update). No-row default: department admins. RMS-1B. + ManageRecordDefinitions = 62, + + /// Publish/retire Record definition versions (RecordDefinition_Publish). No-row default: department admins. RMS-1B. + PublishRecordDefinitions = 63, + + /// Manage saved Record reports (RecordReport_Update). No-row default: department admins. Report results still honor the runner's group scope. RMS-1B. + ManageRecordReports = 64, + + /// Public-records / access-to-information disclosure workflow (RecordDisclosure_Update). No-row default: department admins, grantable onward to selected roles. RMS-3. + ManageRecordDisclosures = 65, + + /// Place and release legal holds (RecordLegalHold_Update). No-row default: department admins, grantable onward. RMS-3. + ManageRecordLegalHold = 66, + + /// Reassign an unfinalized draft to another author (Record_Reassign). No-row default: department and group admins. + ReassignRecordDrafts = 67 } } diff --git a/Core/Resgrid.Model/Providers/IEmailProvider.cs b/Core/Resgrid.Model/Providers/IEmailProvider.cs index 5d811d5f0..75985b955 100644 --- a/Core/Resgrid.Model/Providers/IEmailProvider.cs +++ b/Core/Resgrid.Model/Providers/IEmailProvider.cs @@ -12,9 +12,9 @@ Task SendPasswordRecoveryMail(string name, string email, string department string resetUrl, string ipAddress, string userAgent, string requestedOn, bool isSsoManaged); Task SendPasswordChangedByAdministratorMail(string name, string userName, string email, string departmentName); Task SendSignupMail(string name, string departmentName, string email); - Task SendMessageMail(string email, string subject, string messageSubject, string messageBody, string senderEmail, string senderName, string sentOn, int messageId); + Task SendMessageMail(string email, string subject, string messageSubject, string messageBody, string senderEmail, string senderName, string sentOn, int messageId, DepartmentEmailBranding branding); Task SendCallMail(string email, string subject, string title, string priority, string natureOfCall, string mapPage, - string address, string dispatchedOn, int callId, string userId, string coordinates, string shortenedAudioUrl); + string address, string dispatchedOn, int callId, string userId, string coordinates, string shortenedAudioUrl, DepartmentEmailBranding branding); Task SendInviteMail(string code, string departmentName, string email, string senderName, string senderEmail); Task SendPaymentReciept(string departmentName, string name, string processDate, string amount, string email, string processor, @@ -42,7 +42,7 @@ Task SendUpgradePaymentReciept(string departmentName, string processDate, Task SendNewDepartmentLinkMail(string name, string departmentName, string data, string email, int departmentId); Task SendTroubleAlertMail(string email, string unitName, string gpsLocation, string personnel, string callAddress, string unitAddress, string dispatchedOn, string callName); Task SendReportDeliveryMail(string email, string subject, string messageBody, string sentOn, - string reportName, string attachmentFilename, byte[] attachmentData, string reportUrl); + string reportName, string attachmentFilename, byte[] attachmentData, string reportUrl, DepartmentEmailBranding branding); Task SendCommunicationTestMail(string email, CommunicationTestEmailContent content); diff --git a/Core/Resgrid.Model/Providers/IRecordAttachmentScanner.cs b/Core/Resgrid.Model/Providers/IRecordAttachmentScanner.cs new file mode 100644 index 000000000..71a46fbd7 --- /dev/null +++ b/Core/Resgrid.Model/Providers/IRecordAttachmentScanner.cs @@ -0,0 +1,22 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Providers +{ + public class RecordAttachmentScanResult + { + public RmsAttachmentScanState State { get; set; } = RmsAttachmentScanState.Skipped; + public string Detail { get; set; } + } + + /// + /// Malware/content scan seam for Records attachments (RMS plan section 4.7). The default registration is a + /// null scanner that reports ; a real provider replaces it in the + /// Autofac module. Bytes reach the scanner only after media hygiene has re-encoded images and refused active + /// content, and a Rejected result stops the attachment from being stored. + /// + public interface IRecordAttachmentScanner + { + Task ScanAsync(string fileName, string contentType, byte[] data, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Records/DomainEventOutboxEntry.cs b/Core/Resgrid.Model/Records/DomainEventOutboxEntry.cs new file mode 100644 index 000000000..8abc164c0 --- /dev/null +++ b/Core/Resgrid.Model/Records/DomainEventOutboxEntry.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// One row of the reusable transactional domain-event outbox (RMS plan section 5.6, M0153). Written + /// in the same transaction as the producing state change; delivered by the in-process post-commit + /// dispatcher and swept by worker command 40. Transport only: the producer owns the event name, + /// schema version, payload contract and trigger semantics. is the safe, + /// already-projected payload (SafeWorkflowEvent built before serialization; never a protected value). + /// + [Table("DomainEventOutbox")] + public class DomainEventOutboxEntry : IEntity + { + public long DomainEventOutboxId { get; set; } + + public int DepartmentId { get; set; } + + /// Immutable event identity; the (WorkflowId, EventId) deduplication key downstream. + public string EventId { get; set; } + + public string ProducerSubsystem { get; set; } + + public string EventName { get; set; } + + public int SchemaVersion { get; set; } + + public string AggregateType { get; set; } + + public string AggregateId { get; set; } + + public int? AggregateVersion { get; set; } + + /// Per-aggregate sequence so delayed or out-of-order execution is diagnosable. + public long Sequence { get; set; } + + /// when the event maps to a Workflow trigger. + public int? TriggerEventType { get; set; } + + public string PayloadJson { get; set; } + + public string CorrelationId { get; set; } + + public string CausationId { get; set; } + + /// . + public int OriginClient { get; set; } + + public string OriginWorkflowRunId { get; set; } + + public int HopCount { get; set; } + + /// . + public int State { get; set; } + + public int Attempts { get; set; } + + public DateTime? NextAttemptOn { get; set; } + + public string LastError { get; set; } + + public string LeaseOwner { get; set; } + + public DateTime? LeaseExpiresOn { get; set; } + + public DateTime OccurredOn { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime? DispatchedOn { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return DomainEventOutboxId; } + set { DomainEventOutboxId = Convert.ToInt64(value); } + } + + [NotMapped] + public string TableName => "DomainEventOutbox"; + + [NotMapped] + public string IdName => "DomainEventOutboxId"; + + [NotMapped] + public int IdType => 0; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Records/LogAddedCompatibility.cs b/Core/Resgrid.Model/Records/LogAddedCompatibility.cs new file mode 100644 index 000000000..70c0b6c83 --- /dev/null +++ b/Core/Resgrid.Model/Records/LogAddedCompatibility.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Resgrid.Model.Events; + +namespace Resgrid.Model +{ + /// + /// The legacy LogAdded workflow compatibility projection (RMS plan section 5.6). During the migration + /// window an eligible Logs-parity Record projects the exact, pre-existing log.* contract once, at + /// finalize, through the DomainEventOutbox, so LogAdded workflows keep firing without a legacy Logs row ever + /// being written. Unit Activity never emitted LogAdded and does not start now; department definitions, + /// incident reports and amendments never use this path. + /// + public static class LogAddedCompatibility + { + public static readonly IReadOnlyCollection EligibleTypes = new HashSet + { + RmsOperationalRecordType.Run, + RmsOperationalRecordType.Training, + RmsOperationalRecordType.Work, + RmsOperationalRecordType.Meeting, + RmsOperationalRecordType.Coroner, + RmsOperationalRecordType.Callback + }; + + public static bool IsEligible(RmsOperationalRecord record) + { + if (record == null || string.IsNullOrWhiteSpace(record.DefinitionKey)) + return false; + + return RmsDefinitionKeys.LockedTypes.TryGetValue(record.DefinitionKey, out var type) && EligibleTypes.Contains(type); + } + + /// + /// Builds the LogAddedEvent-shaped payload. LogId is 0 because no legacy row exists; every other + /// documented log.* field is filled from the Record snapshot. Restricted-section fields + /// (BodyLocation, PronouncedDeceasedBy, CaseNumber, Destination) are not part of the legacy contract and + /// are deliberately never copied. + /// + public static LogAddedEvent Build(RmsOperationalRecord record, RmsOperationalRecordDetail details, DateTime loggedOn) + { + if (record == null) + throw new ArgumentNullException(nameof(record)); + + var type = (RmsOperationalRecordType)record.RecordType.GetValueOrDefault(); + + return new LogAddedEvent + { + DepartmentId = record.DepartmentId, + Log = new Log + { + LogId = 0, + DepartmentId = record.DepartmentId, + LogType = (int)type, + Type = type.ToString(), + Narrative = details?.Narrative, + ExternalId = record.ExternalId, + InitialReport = details?.InitialReport, + StationGroupId = record.StationGroupId, + Course = details?.Course, + CourseCode = details?.CourseCode, + Instructors = details?.Instructors, + Cause = details?.Cause, + InvestigatedByUserId = details?.InvestigatedByUserId, + ContactName = details?.ContactName, + ContactNumber = details?.ContactNumber, + StartedOn = record.StartedOn, + EndedOn = record.EndedOn, + LoggedOn = record.FinalizedOn ?? loggedOn, + LoggedByUserId = record.AuthorUserId, + CallId = record.CallId, + OtherPersonnel = details?.OtherPersonnel, + Location = details?.Location, + OtherAgencies = details?.OtherAgencies, + OtherUnits = details?.OtherUnits + } + }; + } + } +} diff --git a/Core/Resgrid.Model/Records/RecordPermissionCatalog.cs b/Core/Resgrid.Model/Records/RecordPermissionCatalog.cs new file mode 100644 index 000000000..f9db03f22 --- /dev/null +++ b/Core/Resgrid.Model/Records/RecordPermissionCatalog.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// One RMS PermissionTypes row as the admin Permissions screen, ClaimsLogic.AddRecordClaims, and the + /// activation-time row migration all understand it (Identifier Allocation Registry section 4.1). + /// + public sealed class RecordPermissionDescriptor + { + public RecordPermissionDescriptor(PermissionTypes type, PermissionActions noRowDefault, bool lockToGroupMeaningful, string summary, bool everyoneOffered = true) + { + Type = type; + NoRowDefault = noRowDefault; + LockToGroupMeaningful = lockToGroupMeaningful; + Summary = summary; + EveryoneOffered = everyoneOffered; + } + + public PermissionTypes Type { get; } + + /// + /// The PermissionActions value the chain evaluates when the department has no Permission row for + /// this type. Must equal the pre-activation Logs behavior for the parity types. + /// + public PermissionActions NoRowDefault { get; } + + /// Whether the Permissions screen exposes LockToGroup on this row. + public bool LockToGroupMeaningful { get; } + + /// + /// Whether the Permissions screen offers "Everyone" on this row. False for the permissions the plan + /// anchors to department administrators (submission, external sharing, restricted sections, definitions, + /// reports, disclosures, legal hold): they stay grantable onward to groups and roles, never to everyone. + /// + public bool EveryoneOffered { get; } + + public string Summary { get; } + } + + /// + /// The single source of no-row defaults for PermissionTypes 50-67. ClaimsLogic reads it to derive + /// claims, the Permissions admin screen reads it to render the Records block, and the activation + /// command reads it to show the administrator the before/after table. + /// + public static class RecordPermissionCatalog + { + public static readonly IReadOnlyList All = new List + { + new RecordPermissionDescriptor(PermissionTypes.CreateRecord, PermissionActions.Everyone, true, "Author Records"), + new RecordPermissionDescriptor(PermissionTypes.DeleteRecord, PermissionActions.Everyone, true, "Void or cancel Records"), + new RecordPermissionDescriptor(PermissionTypes.ReviewRecords, PermissionActions.DepartmentAndGroupAdmins, true, "Review Records"), + new RecordPermissionDescriptor(PermissionTypes.ApproveRecords, PermissionActions.DepartmentAdminsOnly, true, "Approve Records"), + new RecordPermissionDescriptor(PermissionTypes.FinalizeRecords, PermissionActions.Everyone, true, "Finalize Records"), + new RecordPermissionDescriptor(PermissionTypes.AmendRecords, PermissionActions.DepartmentAndGroupAdmins, true, "Amend finalized Records"), + new RecordPermissionDescriptor(PermissionTypes.SubmitRecords, PermissionActions.DepartmentAdminsOnly, false, "Submit Records to a reporting destination", everyoneOffered: false), + new RecordPermissionDescriptor(PermissionTypes.ExportRecords, PermissionActions.Everyone, true, "Print and export Records"), + new RecordPermissionDescriptor(PermissionTypes.ShareRecordsExternally, PermissionActions.DepartmentAdminsOnly, false, "Share Records with other groups or externally", everyoneOffered: false), + new RecordPermissionDescriptor(PermissionTypes.ViewRestrictedRecords, PermissionActions.DepartmentAdminsOnly, true, "View restricted Record sections", everyoneOffered: false), + new RecordPermissionDescriptor(PermissionTypes.ViewLegacyRecords, PermissionActions.Everyone, true, "View legacy Logs history"), + new RecordPermissionDescriptor(PermissionTypes.ViewGroupRecords, PermissionActions.Everyone, true, "See other groups' Records"), + new RecordPermissionDescriptor(PermissionTypes.ManageRecordDefinitions, PermissionActions.DepartmentAdminsOnly, false, "Manage Record definitions", everyoneOffered: false), + new RecordPermissionDescriptor(PermissionTypes.PublishRecordDefinitions, PermissionActions.DepartmentAdminsOnly, false, "Publish Record definitions", everyoneOffered: false), + new RecordPermissionDescriptor(PermissionTypes.ManageRecordReports, PermissionActions.DepartmentAdminsOnly, false, "Manage saved Record reports", everyoneOffered: false), + new RecordPermissionDescriptor(PermissionTypes.ManageRecordDisclosures, PermissionActions.DepartmentAdminsOnly, false, "Manage public-records disclosures", everyoneOffered: false), + new RecordPermissionDescriptor(PermissionTypes.ManageRecordLegalHold, PermissionActions.DepartmentAdminsOnly, false, "Place and release legal holds", everyoneOffered: false), + new RecordPermissionDescriptor(PermissionTypes.ReassignRecordDrafts, PermissionActions.DepartmentAndGroupAdmins, true, "Reassign draft Records") + }; + + public const int FirstValue = 50; + public const int LastValue = 67; + + public static RecordPermissionDescriptor Get(PermissionTypes type) + { + foreach (var descriptor in All) + { + if (descriptor.Type == type) + return descriptor; + } + + return null; + } + + /// + /// The activation-time Permission-row migration (registry section 4.6): which legacy row seeds + /// which Records rows. Action, Data and LockToGroup are copied verbatim. ViewGroupUsers is read + /// as a suggestion for ViewGroupRecords but never applied silently, so it is not listed here. + /// + public static readonly IReadOnlyList> ActivationRowMapping = + new List> + { + new KeyValuePair(PermissionTypes.CreateLog, new[] { PermissionTypes.CreateRecord, PermissionTypes.FinalizeRecords }), + new KeyValuePair(PermissionTypes.DeleteLog, new[] { PermissionTypes.DeleteRecord }) + }; + } +} diff --git a/Core/Resgrid.Model/Records/RecordPermissionEvaluation.cs b/Core/Resgrid.Model/Records/RecordPermissionEvaluation.cs new file mode 100644 index 000000000..dad183f8e --- /dev/null +++ b/Core/Resgrid.Model/Records/RecordPermissionEvaluation.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model +{ + /// + /// The PermissionActions ladder as one function, including value 4 + /// (DepartmentAndGroupAdminsAndSelectRoles). Department administrators pass every value, an + /// unrecognised future value denies (fail closed), and a malformed role CSV is treated as empty. + /// ClaimsLogic (login-time claims) and RecordsAuthorizationService (per-Record checks) both call this + /// so the two can never disagree. + /// + public static class RecordPermissionEvaluation + { + public static bool IsSatisfied(int action, string roleIdCsv, bool isAdmin, bool isGroupAdmin, IEnumerable roles) + { + if (isAdmin) + return true; + + switch ((PermissionActions)action) + { + case PermissionActions.DepartmentAdminsOnly: + return false; + case PermissionActions.DepartmentAndGroupAdmins: + return isGroupAdmin; + case PermissionActions.DepartmentAdminsAndSelectRoles: + return HasSelectedRole(roleIdCsv, roles); + case PermissionActions.Everyone: + return true; + case PermissionActions.DepartmentAndGroupAdminsAndSelectRoles: + return isGroupAdmin || HasSelectedRole(roleIdCsv, roles); + default: + return false; + } + } + + public static bool HasSelectedRole(string roleIdCsv, IEnumerable roles) + { + if (String.IsNullOrWhiteSpace(roleIdCsv) || roles == null) + return false; + + var roleList = roles as IList ?? roles.ToList(); + if (roleList.Count == 0) + return false; + + foreach (var part in roleIdCsv.Split(',')) + { + if (int.TryParse(part.Trim(), out var roleId) && roleList.Any(r => r.PersonnelRoleId == roleId)) + return true; + } + + return false; + } + } +} diff --git a/Core/Resgrid.Model/Records/RecordsAccountability.cs b/Core/Resgrid.Model/Records/RecordsAccountability.cs new file mode 100644 index 000000000..302d24394 --- /dev/null +++ b/Core/Resgrid.Model/Records/RecordsAccountability.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// Axis of the accountability view (RMS plan section 4.7): who owes a report. + public enum RecordsAccountabilityPivot + { + Person = 1, + Group = 2, + Unit = 3 + } + + /// One open Record inside an accountability row, enough to drill through and to decide on a reminder. + public class RecordsAccountabilityRecord + { + public string RecordId { get; set; } + public string Reference { get; set; } + public string Summary { get; set; } + public RmsRecordState State { get; set; } + public string OwnerUserId { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime? ReviewDueOn { get; set; } + public DateTime? ReturnedOn { get; set; } + public bool OverdueReview { get; set; } + public bool ReturnedNotCorrected { get; set; } + } + + public class RecordsAccountabilityRow + { + /// User ID, group ID or unit ID depending on the pivot; empty for "unassigned". + public string Key { get; set; } + public string Name { get; set; } + public int Open { get; set; } + public int OverdueReviews { get; set; } + public int ReturnedNotCorrected { get; set; } + public int FinalizedInWindow { get; set; } + /// Mean hours from creation to finalization over the window; null with nothing finalized. + public double? AverageHoursToFinalize { get; set; } + public DateTime? OldestOpenOn { get; set; } + public List OpenRecords { get; set; } = new List(); + } + + public class RecordsAccountabilityReport + { + public RecordsAccountabilityPivot Pivot { get; set; } + public int WindowDays { get; set; } + public DateTime GeneratedOn { get; set; } + public List Rows { get; set; } = new List(); + public RecordsAccountabilityRow Totals { get; set; } = new RecordsAccountabilityRow(); + } + + public class RecordsReminderResult + { + public const string ReasonSent = "Sent"; + public const string ReasonNotOpen = "NotOpen"; + public const string ReasonNoRecipient = "NoRecipient"; + public const string ReasonRecentlyReminded = "RecentlyReminded"; + public const string ReasonLimit = "Limit"; + public const string ReasonFailed = "Failed"; + + public string RecordId { get; set; } + public bool Sent { get; set; } + public string Reason { get; set; } + public string RecipientUserId { get; set; } + } +} diff --git a/Core/Resgrid.Model/Records/RecordsContracts.cs b/Core/Resgrid.Model/Records/RecordsContracts.cs new file mode 100644 index 000000000..74640780e --- /dev/null +++ b/Core/Resgrid.Model/Records/RecordsContracts.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// Records module state for one department (flag plus append-only cutover fact). + public class RecordsModuleState + { + public int DepartmentId { get; set; } + + /// Records.System evaluates on for this department. + public bool FlagEnabled { get; set; } + + /// An RmsDepartmentCutover row exists. + public bool Activated { get; set; } + + public DateTime? ActivatedOn { get; set; } + + public int? CutoverId { get; set; } + + public RmsDepartmentCutoverState? CutoverState { get; set; } + + /// True when every legacy Log/UnitLog mutation must be denied (cutover active). + public bool LegacyWritesBlocked { get; set; } + + /// Records routes are usable: flag on and cutover active. + public bool RecordsUsable => FlagEnabled && Activated && CutoverState == RmsDepartmentCutoverState.Active; + } + + /// One row of the before/after Permission table shown at activation (registry section 4.6). + public class RecordsPermissionMappingRow + { + public PermissionTypes Source { get; set; } + public PermissionTypes Target { get; set; } + public bool SourceRowExists { get; set; } + public int? SourceAction { get; set; } + public string SourceData { get; set; } + public bool SourceLockToGroup { get; set; } + /// The action the target will evaluate after activation: the copied row, or the no-row default. + public PermissionActions EffectiveAction { get; set; } + public string Note { get; set; } + } + + /// Everything the administrator sees before confirming activation (RMS plan section 4.1). + public class RecordsActivationPreview + { + public int DepartmentId { get; set; } + public bool FlagEnabled { get; set; } + public bool AlreadyActivated { get; set; } + public int LegacyLogCount { get; set; } + public int LegacyUnitLogCount { get; set; } + /// Rows with the retired LogType = 3 (Event); the Logs module keeps rendering them. + public int LegacyEventTypeLogCount { get; set; } + public string SourceChecksum { get; set; } + public List PermissionMapping { get; set; } = new List(); + /// Suggested LockToGroup for ViewGroupRecords, read from ViewGroupUsers; never applied silently. + public bool SuggestedViewGroupRecordsLockToGroup { get; set; } + /// NotApplicable when the Protected Data subsystem is absent; otherwise the state name (plan section 5.8). + public string ProtectedDataPreflight { get; set; } + public List Blockers { get; set; } = new List(); + public bool CanActivate => !AlreadyActivated && Blockers.Count == 0; + } + + public class RecordsActivationResult + { + public bool Success { get; set; } + public string Error { get; set; } + public RmsDepartmentCutover Cutover { get; set; } + public static RecordsActivationResult Failed(string error) => new RecordsActivationResult { Success = false, Error = error }; + } + + /// Rollback decision frame outcome (RMS plan section 4.1). + public enum RecordsRollbackOutcome + { + CleanRevert = 1, + DrainAndRevert = 2, + NoRollback = 3 + } + + /// + /// Thrown by the legacy Log/UnitLog service boundary after a department has activated Records + /// (RMS plan section 4.1). The message is a stable, user-safe statement. + /// + public class RecordsLegacyWriteBlockedException : InvalidOperationException + { + public const string StableMessage = "Records is enabled for this department; legacy Logs are read-only."; + + public RecordsLegacyWriteBlockedException(int departmentId, string context) + : base(StableMessage) + { + DepartmentId = departmentId; + Context = context; + } + + public int DepartmentId { get; } + public string Context { get; } + } + + /// Thrown when a draft save or transition presents a stale RowVersion (ETag). + public class RecordConcurrencyException : InvalidOperationException + { + public RecordConcurrencyException(string recordId, long expected, long current) + : base($"Record {recordId} changed since it was loaded (expected version {expected}, current {current}).") + { + RecordId = recordId; + ExpectedRowVersion = expected; + CurrentRowVersion = current; + } + + public string RecordId { get; } + public long ExpectedRowVersion { get; } + public long CurrentRowVersion { get; } + } + + /// Thrown when a lifecycle transition is not permitted by the Record's preset and state. + public class RecordTransitionException : InvalidOperationException + { + public RecordTransitionException(string recordId, RmsRecordState from, RmsRecordState to, string reason = null) + : base($"Record {recordId} cannot move from {from} to {to}{(reason == null ? string.Empty : ": " + reason)}.") + { + RecordId = recordId; + From = from; + To = to; + } + + public string RecordId { get; } + public RmsRecordState From { get; } + public RmsRecordState To { get; } + } + + public class RecordParticipantInput + { + public string UserId { get; set; } + public int? UnitId { get; set; } + public string Role { get; set; } + } + + public class RecordUnitResponseInput + { + public int UnitId { get; set; } + public DateTime? Dispatched { get; set; } + public DateTime? Enroute { get; set; } + public DateTime? OnScene { get; set; } + public DateTime? Released { get; set; } + public DateTime? InQuarters { get; set; } + } + + /// Draft create/save input for a locked Logs-parity definition. + public class RecordDraftInput + { + /// One of ; required on create. + public string DefinitionKey { get; set; } + public int? CallId { get; set; } + public int? StationGroupId { get; set; } + public string ExternalId { get; set; } + public DateTime? StartedOn { get; set; } + public DateTime? EndedOn { get; set; } + /// Typed fields; only the columns relevant to the definition are honored. + public RmsOperationalRecordDetail Details { get; set; } + public List Participants { get; set; } = new List(); + public List Units { get; set; } = new List(); + /// Client-supplied GUID for offline-created drafts; server-assigned when null. + public string ClientRecordId { get; set; } + public string IdempotencyKey { get; set; } + public RmsOriginClient OriginClient { get; set; } = RmsOriginClient.Web; + /// Recorded reason when the author continues past a duplicate warning. + public string DuplicateContinueReason { get; set; } + } + + /// A hydrated Record: header, working/revision details, participants, units, attachment metadata. + public class RecordAggregate + { + public RmsOperationalRecord Record { get; set; } + public RmsOperationalRecordDetail Details { get; set; } + public List Participants { get; set; } = new List(); + public List Units { get; set; } = new List(); + public List Attachments { get; set; } = new List(); + public List Revisions { get; set; } = new List(); + public List GroupScope { get; set; } = new List(); + } + + /// + /// The complete, server-authored snapshot serialized into RmsRevision.SnapshotJson. Diffs are computed + /// from two of these; none is ever stored. + /// + public class RecordSnapshot + { + public int SnapshotVersion { get; set; } = 1; + public string RecordId { get; set; } + public int DepartmentId { get; set; } + public string DefinitionKey { get; set; } + public int DefinitionVersion { get; set; } + public int? RecordType { get; set; } + public string RecordNumber { get; set; } + public string DraftReference { get; set; } + public int? StationGroupId { get; set; } + public int? CallId { get; set; } + public string ExternalId { get; set; } + public string AuthorUserId { get; set; } + public DateTime? StartedOn { get; set; } + public DateTime? EndedOn { get; set; } + public RmsOperationalRecordDetail Details { get; set; } + public List Participants { get; set; } = new List(); + public List Units { get; set; } = new List(); + public List Attachments { get; set; } = new List(); + } + + /// One changed field in an on-demand revision diff. + public class RecordFieldDiff + { + public string Section { get; set; } + public string FieldKey { get; set; } + public string OldValue { get; set; } + public string NewValue { get; set; } + /// True when the field is restricted and the viewer lacks RecordRestricted_View: values are withheld. + public bool Withheld { get; set; } + } + + public class DomainEventOutboxHealth + { + public int Pending { get; set; } + public int Failed { get; set; } + public DateTime? OldestPendingCreatedOn { get; set; } + public TimeSpan? Backlog { get; set; } + } + + /// Producer-owned event handed to the outbox; the outbox adds identity, sequence and delivery state. + public class DomainEventEnvelope + { + public string EventName { get; set; } + public int SchemaVersion { get; set; } = 1; + public string AggregateType { get; set; } + public string AggregateId { get; set; } + public int? AggregateVersion { get; set; } + public WorkflowTriggerEventType? Trigger { get; set; } + /// The safe, already-projected payload (never a protected-candidate value). + public object Payload { get; set; } + public string CorrelationId { get; set; } + public string CausationId { get; set; } + public RmsOriginClient OriginClient { get; set; } + public DateTime? OccurredOn { get; set; } + } +} diff --git a/Core/Resgrid.Model/Records/RecordsDepartmentSettings.cs b/Core/Resgrid.Model/Records/RecordsDepartmentSettings.cs new file mode 100644 index 000000000..16518b4a5 --- /dev/null +++ b/Core/Resgrid.Model/Records/RecordsDepartmentSettings.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using ProtoBuf; + +namespace Resgrid.Model +{ + /// + /// Department setting 72 (RecordsNumberingConfig): department-wide numbering defaults applied when a + /// definition does not declare its own policy. RMS plan section 4.1, "Numbering". + /// + [ProtoContract] + public class RecordsNumberingConfig + { + public RecordsNumberingConfig() + { + NumberAssignment = (int)RmsNumberAssignment.OnFinalize; + ResetYearly = true; + SequenceWidth = 4; + IncludeYear = true; + } + + /// RmsNumberAssignment value; default OnFinalize so abandoned drafts leave no gaps. + [ProtoMember(1)] + public int NumberAssignment { get; set; } + + /// Restart the sequence each calendar year (department time zone). + [ProtoMember(2)] + public bool ResetYearly { get; set; } + + /// Zero-padded width of the sequence part, e.g. 4 gives 0184. + [ProtoMember(3)] + public int SequenceWidth { get; set; } + + /// Render the year between prefix and sequence: TRN-2026-0184. + [ProtoMember(4)] + public bool IncludeYear { get; set; } + + /// Scope the sequence per station/group instead of department-wide. + [ProtoMember(5)] + public bool PerGroupSequence { get; set; } + } + + /// Department setting 73 (RecordsSearchConfig): index scope and the protected degrade mode notice. + [ProtoContract] + public class RecordsSearchConfig + { + public RecordsSearchConfig() + { + IndexNarrative = true; + IncludeLegacyHistory = true; + } + + /// Index free-text narrative for unprotected departments. Withdrawn automatically on Protected Data enrollment. + [ProtoMember(1)] + public bool IndexNarrative { get; set; } + + /// Include LegacyLog/LegacyUnitLog documents in the records index. + [ProtoMember(2)] + public bool IncludeLegacyHistory { get; set; } + } + + /// One per-definition retention override inside . + [ProtoContract] + public class RecordsRetentionOverride + { + [ProtoMember(1)] + public string DefinitionKey { get; set; } + + /// 0 = permanent. + [ProtoMember(2)] + public int RetentionYears { get; set; } + + /// Prospective: applies to Records whose latest revision is on or after this date. + [ProtoMember(3)] + public DateTime AppliesFrom { get; set; } + } + + /// + /// Department setting 74 (RecordsRetentionPolicy). Resolution for any Record, first match wins: + /// legal hold, then a per-definition override, then the department default (standard-class + /// definitions only), then the shipped class default in . + /// + [ProtoContract] + public class RecordsRetentionPolicy + { + /// Shipped floor for standard operational and NERIS classes. + public const int StandardClassDefaultYears = 7; + + /// Permanent: no automatic purge. + public const int Permanent = 0; + + public RecordsRetentionPolicy() + { + Overrides = new List(); + } + + /// Null = system class default. + [ProtoMember(1)] + public int? DepartmentDefaultYears { get; set; } + + [ProtoMember(2)] + public List Overrides { get; set; } + + [ProtoMember(3)] + public string LastChangedByUserId { get; set; } + + [ProtoMember(4)] + public DateTime? LastChangedOn { get; set; } + + /// + /// Retention years for a definition under this policy (legal hold is evaluated by the caller + /// first). Restricted-class definitions never inherit the department default; they need an + /// explicit override, which the Records Settings screen only writes after confirmation. + /// + public int ResolveYears(string definitionKey) + { + if (Overrides != null) + { + foreach (var o in Overrides) + { + if (string.Equals(o.DefinitionKey, definitionKey, StringComparison.Ordinal)) + return o.RetentionYears < 0 ? Permanent : o.RetentionYears; + } + } + + if (RmsDefinitionKeys.RestrictedClass.Contains(definitionKey ?? string.Empty)) + return Permanent; + + if (DepartmentDefaultYears.HasValue) + return DepartmentDefaultYears.Value < 0 ? Permanent : DepartmentDefaultYears.Value; + + return StandardClassDefaultYears; + } + } + + /// Department setting 77 (RecordsDisclosureConfig). RMS-3 consumes it; the shape ships now so the value is claimed. + [ProtoContract] + public class RecordsDisclosureConfig + { + public RecordsDisclosureConfig() + { + StatutoryClockDays = 10; + } + + [ProtoMember(1)] + public int StatutoryClockDays { get; set; } + + [ProtoMember(2)] + public string DefaultRedactionProfile { get; set; } + + [ProtoMember(3)] + public string ReleaseApproverUserId { get; set; } + } +} diff --git a/Core/Resgrid.Model/Records/RecordsGroupScopePreview.cs b/Core/Resgrid.Model/Records/RecordsGroupScopePreview.cs new file mode 100644 index 000000000..db74eb3fc --- /dev/null +++ b/Core/Resgrid.Model/Records/RecordsGroupScopePreview.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; + +namespace Resgrid.Model +{ + public class RecordsGroupScopePreviewGroup + { + public int GroupId { get; set; } + public string GroupName { get; set; } + /// Members of the group (who keep seeing the group's Records once scoping is on). + public int MemberCount { get; set; } + /// Records anchored to the group through any anchor type (record group, author, participant, unit, share, call). + public int RecordCount { get; set; } + } + + /// + /// What turning RecordsGroupVisibilityMode to GroupScoped would hide (RMS plan section 5.7.1, "Turning it on"): + /// how many Records become invisible to how many users, by group, plus the legacy rows that stay + /// department-wide because they have no resolvable anchor. Computed on demand for the Records Settings screen; + /// nothing here changes state. + /// + public class RecordsGroupScopePreview + { + public int TotalRecords { get; set; } + + /// Records with no group anchor at all; they stay department-wide under scoping. + public int RecordsWithoutGroupAnchor { get; set; } + + /// Legacy Logs rows with a null StationGroupId; never scoped in v1. + public int LegacyLogsWithoutGroup { get; set; } + + /// Legacy UnitLogs rows; their group derives from the unit and they stay department-wide in v1. + public int LegacyUnitLogs { get; set; } + + public int UsersInDepartment { get; set; } + + /// Members of no group: they keep only the always-visible cases (own, participant, unit crew, shared, admin). + public int UsersWithoutGroup { get; set; } + + public int DepartmentAdmins { get; set; } + + public List Groups { get; set; } = new List(); + + /// Records that a member of no group can no longer list once scoping is on (always-visible cases aside). + public int RecordsHiddenFromUngroupedUsers => TotalRecords - RecordsWithoutGroupAnchor; + } +} diff --git a/Core/Resgrid.Model/Records/RecordsReportingContracts.cs b/Core/Resgrid.Model/Records/RecordsReportingContracts.cs new file mode 100644 index 000000000..2292ee513 --- /dev/null +++ b/Core/Resgrid.Model/Records/RecordsReportingContracts.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// Where a report activity entry came from; totals reconcile across the cutover because both sources feed one shape. + public static class ReportActivitySources + { + public const string LegacyLog = "Log"; + public const string Record = "Record"; + } + + public class ReportActivityParticipant + { + public string UserId { get; set; } + public int? UnitId { get; set; } + } + + public class ReportActivityUnit + { + public int UnitId { get; set; } + public DateTime? Dispatched { get; set; } + public DateTime? Enroute { get; set; } + public DateTime? OnScene { get; set; } + public DateTime? Released { get; set; } + public DateTime? InQuarters { get; set; } + } + + /// + /// Source-agnostic activity row for the department reports (RMS plan section 4.10, "report projections that + /// span the cutover boundary"): a legacy Log before activation, a finalized Record after it, same shape. Only + /// the fields the reports aggregate are carried; no narrative, no restricted section. + /// + public class ReportActivityEntry + { + public string Source { get; set; } + public string SourceId { get; set; } + public RmsOperationalRecordType Type { get; set; } + public DateTime? StartedOn { get; set; } + public DateTime? EndedOn { get; set; } + public DateTime LoggedOn { get; set; } + public string LoggedByUserId { get; set; } + public string Course { get; set; } + public int? CallId { get; set; } + public string CallNumber { get; set; } + public string CallName { get; set; } + public int? StationGroupId { get; set; } + public List Participants { get; set; } = new List(); + public List Units { get; set; } = new List(); + } +} diff --git a/Core/Resgrid.Model/Records/RecordsSearchContracts.cs b/Core/Resgrid.Model/Records/RecordsSearchContracts.cs new file mode 100644 index 000000000..d81fbe1d2 --- /dev/null +++ b/Core/Resgrid.Model/Records/RecordsSearchContracts.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// The RMS-owned Lucene index contract (RMS plan section 5.10). The index generation key is + /// (schemaVersion, protectedCatalogVersion, policyEpoch): any change rebuilds the department's documents so + /// an enrollment or permission change cannot serve stale hits. + /// + public static class RecordsSearchGeneration + { + /// Bump when the document schema in RecordsSearchDocumentBuilder changes. + public const int SchemaVersion = 1; + + public static string Compute(int protectedCatalogVersion, long policyEpoch) + { + return RmsSearchIndexState.BuildGeneration(SchemaVersion, protectedCatalogVersion, policyEpoch); + } + } + + /// Index state values stored on RmsSearchIndexState.State. + public enum RmsSearchIndexBuildState + { + Unknown = 0, + Ready = 1, + Rebuilding = 2, + Failed = 3 + } + + /// One document to index: the safe projection plus, only when the department may index it, the narrative. + public class RecordsSearchDocumentSource + { + public RmsRecordSearchProjection Projection { get; set; } + + /// Null unless narrative indexing is permitted (unprotected department with IndexNarrative on). + public string Narrative { get; set; } + + public string Generation { get; set; } + } + + public class RecordsSearchRequest + { + public string Text { get; set; } + + /// Null means unrestricted; otherwise the viewer's visible group ids plus the always-visible cases. + public List VisibleGroupIds { get; set; } + public string ViewerUserId { get; set; } + public List States { get; set; } + public string DefinitionKey { get; set; } + public int? Year { get; set; } + public int? CallId { get; set; } + public bool IncludeLegacy { get; set; } + public int Skip { get; set; } + public int Take { get; set; } = 50; + } + + public class RecordsSearchHit + { + public string SourceType { get; set; } + public string SourceId { get; set; } + public string RecordNumber { get; set; } + public float Score { get; set; } + } + + public class RecordsSearchResult + { + public List Hits { get; set; } = new List(); + + /// Index-side total before post-retrieval authorization; callers suppress it when a hit is dropped. + public int Total { get; set; } + public bool Truncated { get; set; } + public bool Available { get; set; } = true; + } + + public class RecordsSearchHealth + { + public bool Enabled { get; set; } + public bool Online { get; set; } + public string IndexPath { get; set; } + public int DocumentCount { get; set; } + public string Error { get; set; } + } + + public class RecordsSearchIndexSweepResult + { + public int DepartmentsChecked { get; set; } + public int DepartmentsRebuilt { get; set; } + public int DocumentsIndexed { get; set; } + public int DocumentsDeleted { get; set; } + public int Errors { get; set; } + public bool Skipped { get; set; } + public string Message { get; set; } + } +} diff --git a/Core/Resgrid.Model/Records/RmsAccessAudit.cs b/Core/Resgrid.Model/Records/RmsAccessAudit.cs new file mode 100644 index 000000000..54d656c54 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsAccessAudit.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// RMS access audit row (RMS plan section 5.8): read/search/change/sign/print/export/submit/share/ + /// support/denied action with actor, purpose and correlation. never holds a + /// protected-candidate value. + /// + [Table("RmsAccessAudits")] + public class RmsAccessAudit : IEntity + { + public long RmsAccessAuditId { get; set; } + + public int DepartmentId { get; set; } + + public string RecordId { get; set; } + + public string RevisionId { get; set; } + + /// . + public int Action { get; set; } + + public string ActorUserId { get; set; } + + public string Purpose { get; set; } + + public string CorrelationId { get; set; } + + /// . + public int OriginClient { get; set; } + + public string IpAddress { get; set; } + + public bool Successful { get; set; } + + public DateTime OccurredOn { get; set; } + + public string DetailJson { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsAccessAuditId; } + set { RmsAccessAuditId = Convert.ToInt64(value); } + } + + [NotMapped] + public string TableName => "RmsAccessAudits"; + + [NotMapped] + public string IdName => "RmsAccessAuditId"; + + [NotMapped] + public int IdType => 0; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Records/RmsAuditEnums.cs b/Core/Resgrid.Model/Records/RmsAuditEnums.cs new file mode 100644 index 000000000..bf03739c1 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsAuditEnums.cs @@ -0,0 +1,54 @@ +namespace Resgrid.Model +{ + /// Action recorded in RmsAccessAudits (RMS plan section 5.8). + public enum RmsAccessAuditAction + { + Read = 1, + Search = 2, + Change = 3, + Sign = 4, + Print = 5, + Export = 6, + Submit = 7, + Share = 8, + Support = 9, + Denied = 10, + Admin = 11, + Activation = 12, + LegacyWriteDenied = 13 + } + + /// Source of a row in the records search index / projection (RMS plan section 5.10). + public enum RmsSearchSourceType + { + Record = 1, + LegacyLog = 2, + LegacyUnitLog = 3 + } + + /// Malware/content scan state of an RmsRecordAttachment. + public enum RmsAttachmentScanState + { + Pending = 0, + Clean = 1, + Rejected = 2, + Skipped = 3 + } + + /// Cutover history event kinds written to RmsDepartmentCutoverEvents. + public static class RmsDepartmentCutoverEventTypes + { + public const string Activated = "Activated"; + public const string PermissionRowsMigrated = "PermissionRowsMigrated"; + public const string Reverted = "Reverted"; + public const string Repaired = "Repaired"; + public const string LegacyWriteDenied = "LegacyWriteDenied"; + } + + /// Well-known ProducerSubsystem / AggregateType values on DomainEventOutbox rows. + public static class DomainEventProducers + { + public const string Records = "Records"; + public const string RecordsAggregate = "RmsOperationalRecord"; + } +} diff --git a/Core/Resgrid.Model/Records/RmsDefinitionKeys.cs b/Core/Resgrid.Model/Records/RmsDefinitionKeys.cs new file mode 100644 index 000000000..74bb27c2e --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsDefinitionKeys.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// Stable DefinitionKey values for the locked system definitions (RMS plan section 4.1). These are + /// the Logs-parity types plus Unit Activity (the UnitLog replacement). Department-owned definitions + /// (RMS-1B) use their own keys and never collide with these because system keys are reserved. + /// + public static class RmsDefinitionKeys + { + public const string Run = "system.run"; + public const string Training = "system.training"; + public const string Work = "system.work"; + public const string Meeting = "system.meeting"; + public const string Coroner = "system.coroner"; + public const string Callback = "system.callback"; + public const string UnitActivity = "system.unit-activity"; + + /// The NERIS incident report definition (RMS-2). Reserved now so no department key can take it. + public const string NerisIncidentReport = "system.neris-incident"; + + /// Prefix that every locked system definition key carries; department keys may not use it. + public const string SystemPrefix = "system."; + + /// Current schema version of every locked Logs-parity definition. Bumped only by a product release. + public const int LockedDefinitionVersion = 1; + + /// Restricted-class definitions: permanent retention by default and RecordRestricted_View gates their sections. + public static readonly HashSet RestrictedClass = new HashSet(StringComparer.Ordinal) + { + Coroner + }; + + /// Locked Logs-parity keys in display order, with the RmsOperationalRecordType each maps to. + public static readonly IReadOnlyDictionary LockedTypes = new Dictionary(StringComparer.Ordinal) + { + { Run, RmsOperationalRecordType.Run }, + { Training, RmsOperationalRecordType.Training }, + { Work, RmsOperationalRecordType.Work }, + { Meeting, RmsOperationalRecordType.Meeting }, + { Coroner, RmsOperationalRecordType.Coroner }, + { Callback, RmsOperationalRecordType.Callback }, + { UnitActivity, RmsOperationalRecordType.UnitActivity } + }; + + /// Reverse map from the typed enum to its definition key. + public static string ForType(RmsOperationalRecordType type) + { + switch (type) + { + case RmsOperationalRecordType.Run: return Run; + case RmsOperationalRecordType.Training: return Training; + case RmsOperationalRecordType.Work: return Work; + case RmsOperationalRecordType.Meeting: return Meeting; + case RmsOperationalRecordType.Coroner: return Coroner; + case RmsOperationalRecordType.Callback: return Callback; + case RmsOperationalRecordType.UnitActivity: return UnitActivity; + default: throw new ArgumentOutOfRangeException(nameof(type), type, "Not a locked Logs-parity type."); + } + } + + public static bool IsSystemKey(string definitionKey) + { + return !string.IsNullOrWhiteSpace(definitionKey) && + definitionKey.StartsWith(SystemPrefix, StringComparison.Ordinal); + } + + /// + /// The lifecycle preset every locked Logs-parity definition ships with. A legacy Log is created + /// already-final, so Quick Entry is the only preset that preserves the speed of the current flow. + /// Departments change this per definition in RMS-1B. + /// + public const RmsLifecyclePreset LockedDefaultPreset = RmsLifecyclePreset.QuickEntry; + + /// Cardinality per locked definition (RMS plan section 5.2.1). + public static RmsRecordCardinality CardinalityFor(string definitionKey) + { + switch (definitionKey) + { + case NerisIncidentReport: return RmsRecordCardinality.SingleAuthoritative; + case UnitActivity: return RmsRecordCardinality.OnePerSubjectPerCall; + default: return RmsRecordCardinality.MultiplePerCall; + } + } + + /// Record-number prefix used by the default numbering policy for each locked definition. + public static string DefaultNumberPrefix(string definitionKey) + { + switch (definitionKey) + { + case Run: return "RUN"; + case Training: return "TRN"; + case Work: return "WRK"; + case Meeting: return "MTG"; + case Coroner: return "COR"; + case Callback: return "CBK"; + case UnitActivity: return "UNT"; + default: return "REC"; + } + } + } +} diff --git a/Core/Resgrid.Model/Records/RmsDepartmentCutover.cs b/Core/Resgrid.Model/Records/RmsDepartmentCutover.cs new file mode 100644 index 000000000..5a80b6a6c --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsDepartmentCutover.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// The append-only Records activation fact for one department (RMS plan section 4.1). Created by the + /// activation command inside the same transaction as its audit; nothing else writes + /// . The legacy Log/UnitLog write guard and the rollback runbook key off + /// this row, never off a mutable setting or the feature flag alone. + /// + [Table("RmsDepartmentCutovers")] + public class RmsDepartmentCutover : IEntity + { + public int RmsDepartmentCutoverId { get; set; } + + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + public DateTime ActivatedOn { get; set; } + + public string ActivatedByUserId { get; set; } + + public string Reason { get; set; } + + /// Legacy Log row count at activation, for the rollback decision frame and reconciliation. + public int SourceLegacyLogCount { get; set; } + + public int SourceLegacyUnitLogCount { get; set; } + + public string SourceChecksum { get; set; } + + /// . + public int State { get; set; } + + public DateTime? RevertedOn { get; set; } + + public string RevertedByUserId { get; set; } + + /// The before/after Permission-row table the administrator confirmed (registry section 4.6). + public string PermissionMappingJson { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime ModifiedOn { get; set; } + + public long RowVersion { get; set; } + + [NotMapped] + public bool IsActive => State == (int)RmsDepartmentCutoverState.Active; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsDepartmentCutoverId; } + set { RmsDepartmentCutoverId = (int)value; } + } + + [NotMapped] + public string TableName => "RmsDepartmentCutovers"; + + [NotMapped] + public string IdName => "RmsDepartmentCutoverId"; + + [NotMapped] + public int IdType => 0; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "IsActive" }; + } + + /// Audited history of a department's cutover row. + [Table("RmsDepartmentCutoverEvents")] + public class RmsDepartmentCutoverEvent : IEntity + { + public int RmsDepartmentCutoverEventId { get; set; } + + public int DepartmentId { get; set; } + + public int RmsDepartmentCutoverId { get; set; } + + /// One of . + public string EventType { get; set; } + + public string ActorUserId { get; set; } + + public DateTime OccurredOn { get; set; } + + public string DetailJson { get; set; } + + public DateTime CreatedOn { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsDepartmentCutoverEventId; } + set { RmsDepartmentCutoverEventId = (int)value; } + } + + [NotMapped] + public string TableName => "RmsDepartmentCutoverEvents"; + + [NotMapped] + public string IdName => "RmsDepartmentCutoverEventId"; + + [NotMapped] + public int IdType => 0; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Records/RmsEnums.cs b/Core/Resgrid.Model/Records/RmsEnums.cs new file mode 100644 index 000000000..d968eff16 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsEnums.cs @@ -0,0 +1,176 @@ +using System.ComponentModel.DataAnnotations; + +namespace Resgrid.Model +{ + /// + /// Which physical RMS aggregate a Record reference points at. Per RMS plan decision 26 there is no + /// physical RmsRecord table; RmsRecord/RmsRevision in cross-plan contracts mean the + /// (DepartmentId, RecordId, RecordKind, RevisionId) reference shape exposed by both aggregates. + /// + public enum RmsRecordKind + { + /// RmsOperationalRecord: locked Logs-parity types and department-owned definitions. + Operational = 1, + + /// RmsIncidentReport: the NERIS incident report aggregate (RMS-2). + IncidentReport = 2 + } + + /// + /// One lifecycle state machine serves every preset (RMS plan section 5.7). A preset is a subset of + /// permitted transitions, never a different machine. Persisted as an integer; append-only. + /// + public enum RmsRecordState + { + /// The only state that autosaves; the only state with ETag draft merge. + Draft = 1, + + /// Author submitted for review (Review Required and Approval presets only). + ReadyForReview = 2, + + /// Reviewer returned with a reason code; re-opens as Draft on the next save. + Returned = 3, + + /// Approval/Acknowledgement preset only; approver may not be the author. + Approved = 4, + + /// An immutable revision plus attestation has been written. + Finalized = 5, + + /// An amendment revision has been finalized; the prior revision is retained verbatim. + Amended = 6, + + /// Terminal. History retained; no further transitions. + Voided = 7, + + /// Terminal. A non-finalized Record was abandoned; reserved numbers are released per policy. + Cancelled = 8, + + /// Reporting-destination states (RMS-2); only for definitions that declare a destination. + Submitted = 9, + Accepted = 10, + Rejected = 11, + Corrected = 12 + } + + /// Governed lifecycle presets (RMS plan section 4.1). Three are sufficient for launch. + public enum RmsLifecyclePreset + { + /// Draft -> Finalized. Author holding Record_Create + Record_Finalize. + QuickEntry = 1, + + /// Draft -> ReadyForReview -> (Returned -> Draft)* -> Finalized. + ReviewRequired = 2, + + /// Draft -> ReadyForReview -> Approved -> Finalized, with the same return path. + ApprovalAcknowledgement = 3 + } + + /// How many Records may exist per Call for a definition (RMS plan section 5.2.1). + public enum RmsRecordCardinality + { + /// Unique on (DepartmentId, CallId, ReportingEntityId, DefinitionKey). NERIS and any locked submission definition. + SingleAuthoritative = 1, + + /// No uniqueness rule; each Record is independent. Run, Work, Coroner, Callback, and department definitions. + MultiplePerCall = 2, + + /// Unique on (DepartmentId, CallId, DefinitionKey, SubjectType, SubjectId). Unit Activity, responder exposure. + OnePerSubjectPerCall = 3 + } + + /// + /// Where a prefilled value came from (RMS plan section 4.2). Records provenance; gates nothing. + /// Every prefilled time is freely editable in Draft; after finalization a change is an amendment. + /// + public enum RmsSourceKind + { + None = 0, + Dispatch = 1, + App = 2, + Derived = 3, + Imported = 4 + } + + /// + /// Anchors that populate the materialized RmsRecordGroupScope set (RMS plan section 5.7.1). v1 ships + /// one fixed inclusive set: RecordGroup, Author, Participant, Unit, Share. Call is deferred. + /// + public enum RmsGroupScopeAnchorType + { + RecordGroup = 1, + Author = 2, + Participant = 3, + Unit = 4, + Share = 5, + Call = 6 + } + + /// Transition that created an immutable RmsRevision. + public enum RmsRevisionTransition + { + Finalized = 1, + Amended = 2, + Voided = 3 + } + + /// State of a department's Records cutover row (RMS plan section 4.1, rollback decision frame). + public enum RmsDepartmentCutoverState + { + /// Records is active; the legacy Log/UnitLog write guard is engaged. + Active = 1, + + /// Clean revert or drain-and-revert completed; Logs writes reopened. ActivatedOn retained in history. + Reverted = 2 + } + + /// Bounded origin marker for audit and Workflow filtering; never a device identifier. + public enum RmsOriginClient + { + System = 0, + Web = 1, + Responder = 2, + Unit = 3, + IncidentCommand = 4, + Dispatch = 5, + Api = 6 + } + + /// Department setting 75: cross-group Record visibility mode. v1 is on/off only. + public enum RecordsGroupVisibilityMode + { + DepartmentWide = 0, + GroupScoped = 1 + } + + /// Who may act on a permission row; stored as the Permission.Action integer. + public enum RmsNumberAssignment + { + /// Sequence advances only when an immutable revision is written; abandoned drafts consume nothing. + OnFinalize = 1, + + /// Number reserved at draft creation; an abandoned reservation is recorded as a voided number, never reused. + OnCreate = 2 + } + + /// Delivery state of a DomainEventOutbox row. + public enum DomainEventOutboxState + { + Pending = 0, + Dispatched = 1, + Failed = 2, + Skipped = 3 + } + + /// Locked Logs-parity record types, mirrored from LogTypes with Unit Activity added. + public enum RmsOperationalRecordType + { + Run = 1, + Training = 2, + Work = 4, + Meeting = 5, + Coroner = 6, + Callback = 7, + UnitActivity = 8 + } +} diff --git a/Core/Resgrid.Model/Records/RmsExternalReference.cs b/Core/Resgrid.Model/Records/RmsExternalReference.cs new file mode 100644 index 000000000..50dae04f3 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsExternalReference.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// Versioned correlation from a Record to a source subsystem entity (Call, Inventory usage, Checklist + /// completion, Run Card activation, external order/request/fill, ...) with identifier scheme, source + /// version/event, capture time, provenance and checksum (cross-plan contract RmsExternalReference). + /// Never a capability-bearing download link; is display only. + /// + [Table("RmsExternalReferences")] + public class RmsExternalReference : IEntity + { + public string RmsExternalReferenceId { get; set; } + + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + public string RecordId { get; set; } + + /// . + public int RecordKind { get; set; } + + public string SourceSubsystem { get; set; } + + public string SourceEntityType { get; set; } + + /// Opaque, source-qualified identifier; never parsed for meaning. + public string SourceEntityId { get; set; } + + public string IdentifierScheme { get; set; } + + public string SourceVersion { get; set; } + + public string SourceEventId { get; set; } + + /// Subject, Evidence, LinkedCall, InventoryUsage, ... + public string SemanticRole { get; set; } + + public DateTime CapturedOn { get; set; } + + public string CapturedByUserId { get; set; } + + public string Checksum { get; set; } + + public string SafeUrl { get; set; } + + /// Bounded, server-authored display snapshot; never a second source of truth. + public string SnapshotJson { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime ModifiedOn { get; set; } + + public long RowVersion { get; set; } + + public DateTime? DeletedOn { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsExternalReferenceId; } + set { RmsExternalReferenceId = value?.ToString(); } + } + + [NotMapped] + public string TableName => "RmsExternalReferences"; + + [NotMapped] + public string IdName => "RmsExternalReferenceId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Records/RmsLifecycle.cs b/Core/Resgrid.Model/Records/RmsLifecycle.cs new file mode 100644 index 000000000..554642b5f --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsLifecycle.cs @@ -0,0 +1,113 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model +{ + /// + /// The single Records state machine (RMS plan section 5.7, "Lifecycle states"). A preset is a subset + /// of permitted transitions, never a different machine, so a definition that changes preset does not + /// change how existing Records are interpreted. No transition exists outside this table. + /// + public static class RmsLifecycle + { + public readonly struct Transition + { + public Transition(RmsRecordState from, RmsRecordState to, RmsLifecyclePreset[] presets) + { + From = from; + To = to; + Presets = presets; + } + + public RmsRecordState From { get; } + public RmsRecordState To { get; } + public RmsLifecyclePreset[] Presets { get; } + } + + private static readonly RmsLifecyclePreset[] All = { RmsLifecyclePreset.QuickEntry, RmsLifecyclePreset.ReviewRequired, RmsLifecyclePreset.ApprovalAcknowledgement }; + private static readonly RmsLifecyclePreset[] ReviewPresets = { RmsLifecyclePreset.ReviewRequired, RmsLifecyclePreset.ApprovalAcknowledgement }; + private static readonly RmsLifecyclePreset[] ApprovalOnly = { RmsLifecyclePreset.ApprovalAcknowledgement }; + private static readonly RmsLifecyclePreset[] QuickAndReview = { RmsLifecyclePreset.QuickEntry, RmsLifecyclePreset.ReviewRequired }; + + public static readonly IReadOnlyList Table = new List + { + // Quick Entry: Draft -> Finalized directly. + new Transition(RmsRecordState.Draft, RmsRecordState.Finalized, new[] { RmsLifecyclePreset.QuickEntry }), + // Review Required / Approval: author submits. + new Transition(RmsRecordState.Draft, RmsRecordState.ReadyForReview, ReviewPresets), + // Reviewer finalizes (Review Required) or approver approves (Approval). + new Transition(RmsRecordState.ReadyForReview, RmsRecordState.Finalized, new[] { RmsLifecyclePreset.ReviewRequired }), + new Transition(RmsRecordState.ReadyForReview, RmsRecordState.Approved, ApprovalOnly), + new Transition(RmsRecordState.Approved, RmsRecordState.Finalized, ApprovalOnly), + // Returns, from review or approval, back through Returned to Draft. + new Transition(RmsRecordState.ReadyForReview, RmsRecordState.Returned, ReviewPresets), + new Transition(RmsRecordState.Approved, RmsRecordState.Returned, ApprovalOnly), + new Transition(RmsRecordState.Returned, RmsRecordState.Draft, ReviewPresets), + // Amend: Finalized/Amended -> Amended (new revision; prior retained). + new Transition(RmsRecordState.Finalized, RmsRecordState.Amended, All), + new Transition(RmsRecordState.Amended, RmsRecordState.Amended, All), + // Void: terminal from any finalized state. + new Transition(RmsRecordState.Finalized, RmsRecordState.Voided, All), + new Transition(RmsRecordState.Amended, RmsRecordState.Voided, All), + new Transition(RmsRecordState.Accepted, RmsRecordState.Voided, All), + new Transition(RmsRecordState.Rejected, RmsRecordState.Voided, All), + // Cancel: terminal from any non-finalized state. + new Transition(RmsRecordState.Draft, RmsRecordState.Cancelled, All), + new Transition(RmsRecordState.ReadyForReview, RmsRecordState.Cancelled, ReviewPresets), + new Transition(RmsRecordState.Returned, RmsRecordState.Cancelled, ReviewPresets), + new Transition(RmsRecordState.Approved, RmsRecordState.Cancelled, ApprovalOnly), + // Reporting destination states (RMS-2); only definitions that declare a destination use them. + new Transition(RmsRecordState.Finalized, RmsRecordState.Submitted, All), + new Transition(RmsRecordState.Amended, RmsRecordState.Submitted, All), + new Transition(RmsRecordState.Submitted, RmsRecordState.Accepted, All), + new Transition(RmsRecordState.Submitted, RmsRecordState.Rejected, All), + new Transition(RmsRecordState.Rejected, RmsRecordState.Corrected, All), + new Transition(RmsRecordState.Corrected, RmsRecordState.Submitted, All), + new Transition(RmsRecordState.Accepted, RmsRecordState.Amended, All), + new Transition(RmsRecordState.Rejected, RmsRecordState.Amended, All) + }; + + public static bool CanTransition(RmsLifecyclePreset preset, RmsRecordState from, RmsRecordState to) + { + return Table.Any(t => t.From == from && t.To == to && t.Presets.Contains(preset)); + } + + public static IEnumerable NextStates(RmsLifecyclePreset preset, RmsRecordState from) + { + return Table.Where(t => t.From == from && t.Presets.Contains(preset)).Select(t => t.To).Distinct(); + } + + public static bool IsTerminal(RmsRecordState state) + { + return state == RmsRecordState.Voided || state == RmsRecordState.Cancelled; + } + + /// States in which the Record has at least one immutable revision. + public static bool IsFinalizedFamily(RmsRecordState state) + { + return state == RmsRecordState.Finalized || state == RmsRecordState.Amended || state == RmsRecordState.Voided || + state == RmsRecordState.Submitted || state == RmsRecordState.Accepted || state == RmsRecordState.Rejected || state == RmsRecordState.Corrected; + } + + /// The only state that autosaves (Draft), plus Returned which re-opens as Draft on save. + public static bool IsEditable(RmsRecordState state) + { + return state == RmsRecordState.Draft || state == RmsRecordState.Returned; + } + + /// The Workflow trigger a transition emits after commit, or null when it emits none. + public static WorkflowTriggerEventType? TriggerFor(RmsRecordState from, RmsRecordState to) + { + switch (to) + { + case RmsRecordState.ReadyForReview: return WorkflowTriggerEventType.RecordSubmittedForReview; + case RmsRecordState.Returned: return WorkflowTriggerEventType.RecordReturnedForCorrection; + case RmsRecordState.Finalized: return WorkflowTriggerEventType.RecordFinalized; + case RmsRecordState.Amended: return WorkflowTriggerEventType.RecordAmended; + case RmsRecordState.Voided: return WorkflowTriggerEventType.RecordVoided; + case RmsRecordState.Cancelled: return WorkflowTriggerEventType.RecordCancelled; + default: return null; + } + } + } +} diff --git a/Core/Resgrid.Model/Records/RmsOperationalRecord.cs b/Core/Resgrid.Model/Records/RmsOperationalRecord.cs new file mode 100644 index 000000000..22e61047b --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsOperationalRecord.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// Records (RMS) operational Record header: the aggregate root for locked Logs-parity types and, later, + /// department-owned definitions (RMS plan section 5.2). Pins the definition/version, lifecycle preset + /// and state, numbering, ownership, review/approval/finalize/void/cancel facts and the ETag + /// RowVersion. Typed field values live in ; immutable + /// history in . Exposes the cross-plan RmsRecord reference shape + /// (DepartmentId, RecordId, RecordKind, RevisionId). + /// + [Table("RmsOperationalRecords")] + public class RmsOperationalRecord : IEntity + { + /// Client-compatible GUID string; assigned by the client for offline drafts or by the server. + public string RmsOperationalRecordId { get; set; } + + public int DepartmentId { get; set; } + + /// Immutable random ID allocated at insert; read by nothing until Protected Data enrollment (plan section 5.9.1). + public string ProtectionId { get; set; } + + /// Stable definition key, e.g. . + public string DefinitionKey { get; set; } + + public int DefinitionVersion { get; set; } + + /// for locked definitions; null for department-owned ones. + public int? RecordType { get; set; } + + /// the Record was created under. + public int LifecyclePreset { get; set; } + + /// . + public int State { get; set; } + + /// Authoritative record number, assigned at finalization by default (plan section 4.1). Never reused. + public string RecordNumber { get; set; } + + /// Non-authoritative draft reference issued at creation, e.g. D-7KQ2M. Retained on the finalized Record. + public string DraftReference { get; set; } + + /// Server-authored list/queue display text; never a protected-candidate value. + public string DisplaySummary { get; set; } + + public int? StationGroupId { get; set; } + + public int? CallId { get; set; } + + public string ExternalId { get; set; } + + public string AuthorUserId { get; set; } + + /// Author's group at creation; a snapshot, so a later transfer never rewrites visibility. + public int? AuthorGroupIdSnapshot { get; set; } + + /// Current draft owner; equals the author unless reassigned (Record_Reassign). + public string OwnerUserId { get; set; } + + public DateTime? StartedOn { get; set; } + + public DateTime? EndedOn { get; set; } + + public DateTime? ReviewDueOn { get; set; } + + public DateTime? SubmittedForReviewOn { get; set; } + + public DateTime? ReturnedOn { get; set; } + + public string ReturnReasonCode { get; set; } + + public string ReturnReasonText { get; set; } + + /// Audited review round-trip count. + public int ReturnCount { get; set; } + + public string ReviewerUserId { get; set; } + + public DateTime? ApprovedOn { get; set; } + + public string ApproverUserId { get; set; } + + public DateTime? FinalizedOn { get; set; } + + public string FinalizedByUserId { get; set; } + + /// The latest immutable revision; null until first finalization. + public string CurrentRevisionId { get; set; } + + public int RevisionCount { get; set; } + + /// Non-null while an amendment draft is open against that revision (at most one per Record). + public string AmendsRevisionId { get; set; } + + public DateTime? VoidedOn { get; set; } + + public string VoidedByUserId { get; set; } + + public string VoidReasonCode { get; set; } + + public string VoidReasonText { get; set; } + + public DateTime? CancelledOn { get; set; } + + public string CancelledByUserId { get; set; } + + /// Scoped idempotency key for create; unique per department when present. + public string IdempotencyKey { get; set; } + + /// . + public int OriginClient { get; set; } + + public DateTime CreatedOn { get; set; } + + public string CreatedByUserId { get; set; } + + public DateTime ModifiedOn { get; set; } + + public string ModifiedByUserId { get; set; } + + /// Optimistic-concurrency counter (ETag). Incremented by the service on every draft save. + public long RowVersion { get; set; } + + public DateTime? DeletedOn { get; set; } + + [NotMapped] + public string RecordId => RmsOperationalRecordId; + + [NotMapped] + public RmsRecordKind RecordKind => RmsRecordKind.Operational; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsOperationalRecordId; } + set { RmsOperationalRecordId = value?.ToString(); } + } + + [NotMapped] + public string TableName => "RmsOperationalRecords"; + + [NotMapped] + public string IdName => "RmsOperationalRecordId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "RecordId", "RecordKind" }; + } +} diff --git a/Core/Resgrid.Model/Records/RmsOperationalRecordDetail.cs b/Core/Resgrid.Model/Records/RmsOperationalRecordDetail.cs new file mode 100644 index 000000000..346156274 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsOperationalRecordDetail.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// First-class typed detail for the locked Logs-parity definitions (Run, Training, Work, Meeting, + /// Coroner, Callback, Unit Activity) plus the Call snapshot captured at authoring time. One row per + /// Record with null is the working draft; one immutable row per revision is + /// written at finalize/amend. Locked definitions never use the generic RmsRecordValues table (plan + /// section 5.3). Protected-candidate columns (narrative, initial report, cause, contact details, + /// location, body/deceased fields, case number, destination) are envelope-capable text; IsProtected + /// and ProtectedCatalogVersion ship inert. + /// + [Table("RmsOperationalRecordDetails")] + public class RmsOperationalRecordDetail : IEntity + { + public string RmsOperationalRecordDetailId { get; set; } + + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + public string RecordId { get; set; } + + /// Null = the working draft row; otherwise the immutable snapshot for that revision. + public string RevisionId { get; set; } + + public string Narrative { get; set; } + + public string InitialReport { get; set; } + + /// Run type / meeting type free text (Logs parity Type column). + public string Type { get; set; } + + public string Course { get; set; } + + public string CourseCode { get; set; } + + public string Instructors { get; set; } + + public string Cause { get; set; } + + public string InvestigatedByUserId { get; set; } + + public string ContactName { get; set; } + + public string ContactNumber { get; set; } + + public string OtherPersonnel { get; set; } + + public string Location { get; set; } + + public string OtherAgencies { get; set; } + + public string OtherUnits { get; set; } + + public string BodyLocation { get; set; } + + public string PronouncedDeceasedBy { get; set; } + + public string CaseNumber { get; set; } + + public string Destination { get; set; } + + public string Facilitator { get; set; } + + /// Unit Activity subject unit. + public int? UnitId { get; set; } + + /// Unit Activity timestamp (UnitLog.Timestamp parity). + public DateTime? ActivityOn { get; set; } + + public string CallNumber { get; set; } + + public string CallName { get; set; } + + public string CallType { get; set; } + + public int? CallPriority { get; set; } + + public DateTime? CallLoggedOn { get; set; } + + public string CallAddress { get; set; } + + public string CallNature { get; set; } + + /// ADP row marker: true once cataloged values carry rgdp envelopes. Inert (false) until enrollment. + public bool IsProtected { get; set; } + + /// + /// Inert until Protected Data enrollment (plan section 5.9.1): null today; a protected write nulls the typed + /// columns and stores rgdp:1:{keyVersion}:{base64(nonce|tag|ciphertext)} here, so enrolling never needs a + /// schema change on a populated table. + /// + public string ProtectedEnvelope { get; set; } + + public int ProtectedCatalogVersion { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime ModifiedOn { get; set; } + + public long RowVersion { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsOperationalRecordDetailId; } + set { RmsOperationalRecordDetailId = value?.ToString(); } + } + + [NotMapped] + public string TableName => "RmsOperationalRecordDetails"; + + [NotMapped] + public string IdName => "RmsOperationalRecordDetailId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Records/RmsRecordAttachment.cs b/Core/Resgrid.Model/Records/RmsRecordAttachment.cs new file mode 100644 index 000000000..04093c01b --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsRecordAttachment.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// Attachment on a Record (LogAttachment parity with the stronger RMS controls): file metadata, + /// SHA-256 checksum, scan state, the metadata-strip decision and the bytes. Downloads are + /// authorized per Record on every request, never merely absent from a list (plan section 5.7.1). + /// File name and description inherit the highest classification of their source (plan 5.9.2). + /// + [Table("RmsRecordAttachments")] + public class RmsRecordAttachment : IEntity + { + public string RmsRecordAttachmentId { get; set; } + + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + public string RecordId { get; set; } + + public string FileName { get; set; } + + public string ContentType { get; set; } + + public long ByteSize { get; set; } + + /// Lower-case hex SHA-256 of . + public string Checksum { get; set; } + + public byte[] Data { get; set; } + + /// Reserved for an external blob location; null while bytes are stored in-row. + public string StorageReference { get; set; } + + public string Description { get; set; } + + public string UploadedByUserId { get; set; } + + public DateTime UploadedOn { get; set; } + + /// . + public int ScanState { get; set; } + + /// True when location/device metadata was stripped on upload (plan section 4.7, media hygiene). + public bool MetadataStripped { get; set; } + + public bool IsProtected { get; set; } + + public int ProtectedCatalogVersion { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime ModifiedOn { get; set; } + + public long RowVersion { get; set; } + + public DateTime? DeletedOn { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsRecordAttachmentId; } + set { RmsRecordAttachmentId = value?.ToString(); } + } + + [NotMapped] + public string TableName => "RmsRecordAttachments"; + + [NotMapped] + public string IdName => "RmsRecordAttachmentId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Records/RmsRecordGroupScope.cs b/Core/Resgrid.Model/Records/RmsRecordGroupScope.cs new file mode 100644 index 000000000..5f1a2e4d2 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsRecordGroupScope.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// One materialized (RecordId, DepartmentGroupId, AnchorType) visibility row (RMS plan section 5.7.1), + /// recomputed in-transaction on every save/finalize/amend/participant/unit change. The join target + /// for every list, search, report, export and sync query under group-scoped visibility. + /// + [Table("RmsRecordGroupScopes")] + public class RmsRecordGroupScope : IEntity + { + public long RmsRecordGroupScopeId { get; set; } + + public int DepartmentId { get; set; } + + public string RecordId { get; set; } + + public int DepartmentGroupId { get; set; } + + /// . + public int AnchorType { get; set; } + + public DateTime CreatedOn { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsRecordGroupScopeId; } + set { RmsRecordGroupScopeId = Convert.ToInt64(value); } + } + + [NotMapped] + public string TableName => "RmsRecordGroupScopes"; + + [NotMapped] + public string IdName => "RmsRecordGroupScopeId"; + + [NotMapped] + public int IdType => 0; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// + /// Explicit, audited, optionally time-boxed grant of one Record to one further group (Record_Share). + /// Additive only; never widens a restricted section. + /// + [Table("RmsRecordShares")] + public class RmsRecordShare : IEntity + { + public string RmsRecordShareId { get; set; } + + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + public string RecordId { get; set; } + + public int DepartmentGroupId { get; set; } + + public string GrantedByUserId { get; set; } + + public DateTime GrantedOn { get; set; } + + public string Reason { get; set; } + + public DateTime? ExpiresOn { get; set; } + + public DateTime? RevokedOn { get; set; } + + public string RevokedByUserId { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime ModifiedOn { get; set; } + + public long RowVersion { get; set; } + + public bool IsEffective(DateTime utcNow) + { + return !RevokedOn.HasValue && (!ExpiresOn.HasValue || ExpiresOn.Value > utcNow); + } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsRecordShareId; } + set { RmsRecordShareId = value?.ToString(); } + } + + [NotMapped] + public string TableName => "RmsRecordShares"; + + [NotMapped] + public string IdName => "RmsRecordShareId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Records/RmsRecordParticipant.cs b/Core/Resgrid.Model/Records/RmsRecordParticipant.cs new file mode 100644 index 000000000..49a50bd0b --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsRecordParticipant.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// Person participation on a Record (LogUser parity): user, display-name and group snapshots, optional + /// unit assignment, role and participation times. Draft rows have null; + /// finalization copies them under the new revision so a later profile change never rewrites history. + /// + [Table("RmsRecordParticipants")] + public class RmsRecordParticipant : IEntity + { + public string RmsRecordParticipantId { get; set; } + + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + public string RecordId { get; set; } + + public string RevisionId { get; set; } + + public string UserId { get; set; } + + public string DisplayNameSnapshot { get; set; } + + public int? GroupIdSnapshot { get; set; } + + public string GroupNameSnapshot { get; set; } + + /// Unit the person was assigned to for this Record, when any. + public int? UnitId { get; set; } + + /// Instructor, Attendee, Personnel, Investigator, Officer, Facilitator, ... + public string Role { get; set; } + + public DateTime? ParticipationStart { get; set; } + + public DateTime? ParticipationEnd { get; set; } + + /// of the participation facts. + public int SourceKind { get; set; } + + public int Ordinal { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime ModifiedOn { get; set; } + + public long RowVersion { get; set; } + + public DateTime? DeletedOn { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsRecordParticipantId; } + set { RmsRecordParticipantId = value?.ToString(); } + } + + [NotMapped] + public string TableName => "RmsRecordParticipants"; + + [NotMapped] + public string IdName => "RmsRecordParticipantId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Records/RmsRecordPrintLayout.cs b/Core/Resgrid.Model/Records/RmsRecordPrintLayout.cs new file mode 100644 index 000000000..9237d40d4 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsRecordPrintLayout.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Resgrid.Model +{ + public enum RmsRecordPrintLayoutScope + { + /// One per department: the branding block, letterhead lines, footer, watermark, page size, date format. + DepartmentDefault = 1, + /// Per definition version (RMS-1B designer). + Definition = 2 + } + + /// + /// The DepartmentDefault print layout content (RMS plan section 4.10.1). Identity and logo come from the + /// Department Profile; this holds only how they render plus the letterhead extras. Serialized as JSON on + /// . + /// + public class RecordsPrintLayoutConfig + { + public static readonly string[] PageSizes = { "Letter", "A4" }; + + public bool ShowLogo { get; set; } = true; + public bool UseShortName { get; set; } + public bool ShowAddress { get; set; } = true; + public bool ShowPhone { get; set; } = true; + public bool ShowWebsite { get; set; } = true; + public string LetterheadLine1 { get; set; } + public string LetterheadLine2 { get; set; } + public string FooterText { get; set; } + public string WatermarkLabel { get; set; } + public string PageSize { get; set; } = "Letter"; + /// .NET date/time format applied in the department time zone; null keeps the department default. + public string DateTimeFormat { get; set; } + + public static RecordsPrintLayoutConfig Default() => new RecordsPrintLayoutConfig(); + + public static string NormalizePageSize(string value) + { + return string.Equals(value, "A4", StringComparison.OrdinalIgnoreCase) ? "A4" : "Letter"; + } + } + + /// Versioned print layout row (migration M0160). Only the DepartmentDefault scope is written in RMS-1. + [Table("RmsRecordPrintLayouts")] + public class RmsRecordPrintLayout : IEntity + { + public const string GeneratedLayoutVersion = "system-default/1"; + + [Key] + [Required] + public string RmsRecordPrintLayoutId { get; set; } + + [Required] + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + /// . + public int Scope { get; set; } + + /// Empty string for the DepartmentDefault scope so the (DepartmentId, Scope, DefinitionKey) unique index works in both dialects. + public string DefinitionKey { get; set; } = string.Empty; + + public int Version { get; set; } + + public string ConfigJson { get; set; } + + public string ModifiedByUserId { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime ModifiedOn { get; set; } + + public long RowVersion { get; set; } + + /// The value the provenance footer prints; the generated default when no row has been saved yet. + [NotMapped] + public string LayoutVersion => Version <= 0 + ? GeneratedLayoutVersion + : (Scope == (int)RmsRecordPrintLayoutScope.DepartmentDefault ? "department-default/" + Version : DefinitionKey + "/" + Version); + + [NotMapped] + public RecordsPrintLayoutConfig Config { get; set; } + + [NotMapped] + public object IdValue + { + get { return RmsRecordPrintLayoutId; } + set { RmsRecordPrintLayoutId = value?.ToString(); } + } + + [NotMapped] + public string TableName => "RmsRecordPrintLayouts"; + + [NotMapped] + public string IdName => "RmsRecordPrintLayoutId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "LayoutVersion", "Config" }; + } +} diff --git a/Core/Resgrid.Model/Records/RmsRecordSearchProjection.cs b/Core/Resgrid.Model/Records/RmsRecordSearchProjection.cs new file mode 100644 index 000000000..009f3f436 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsRecordSearchProjection.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// RecordSearchProjectionV1: the derived, rebuildable, safe search row per Record (RMS plan sections + /// 5.3/5.10). Holds scope, identity, number/type/state, safe dates, station/group, Call correlation, + /// authorization hints and limited to fields whose classification and + /// Searchable flag currently permit it. Never narrative, address detail, restricted sections, + /// ciphertext or attachment content. Keyed by the Record id. + /// + [Table("RmsRecordSearchProjections")] + public class RmsRecordSearchProjection : IEntity + { + public const int CurrentProjectionVersion = 1; + + public string RmsRecordSearchProjectionId { get; set; } + + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + /// . + public int SourceType { get; set; } + + public string SourceId { get; set; } + + public int RecordKind { get; set; } + + public string RecordNumber { get; set; } + + public string DraftReference { get; set; } + + public string DefinitionKey { get; set; } + + public int DefinitionVersion { get; set; } + + public int? RecordType { get; set; } + + public int State { get; set; } + + public DateTime? OccurredOn { get; set; } + + public DateTime RecordCreatedOn { get; set; } + + public DateTime? FinalizedOn { get; set; } + + public int? StationGroupId { get; set; } + + public int? CallId { get; set; } + + public string CallNumber { get; set; } + + public string AuthorUserId { get; set; } + + public string OwnerUserId { get; set; } + + public string ReviewerUserId { get; set; } + + /// Comma-separated participant user ids (always-visible rule, plan section 5.7.1). + public string ParticipantUserIds { get; set; } + + /// Comma-separated responding unit ids. + public string UnitIds { get; set; } + + /// Comma-separated group ids from RmsRecordGroupScopes. + public string GroupScopeIds { get; set; } + + public string DisplaySummary { get; set; } + + public string SearchText { get; set; } + + public bool IsLegacy { get; set; } + + public int ProjectionVersion { get; set; } + + public int ProtectedCatalogVersion { get; set; } + + public long PolicyEpoch { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime ModifiedOn { get; set; } + + public long RowVersion { get; set; } + + public DateTime? DeletedOn { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsRecordSearchProjectionId; } + set { RmsRecordSearchProjectionId = value?.ToString(); } + } + + [NotMapped] + public string TableName => "RmsRecordSearchProjections"; + + [NotMapped] + public string IdName => "RmsRecordSearchProjectionId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// Per-department index generation key for the RMS-owned records index (plan section 5.10). + [Table("RmsSearchIndexStates")] + public class RmsSearchIndexState : IEntity + { + public const string RecordsIndexName = "records"; + + public int RmsSearchIndexStateId { get; set; } + + public int DepartmentId { get; set; } + + public string IndexName { get; set; } + + public int SchemaVersion { get; set; } + + public int ProtectedCatalogVersion { get; set; } + + public long PolicyEpoch { get; set; } + + /// Generation key text: {schemaVersion}.{protectedCatalogVersion}.{policyEpoch}. + public string Generation { get; set; } + + public int State { get; set; } + + public int DocumentCount { get; set; } + + public DateTime? LastRebuiltOn { get; set; } + + public DateTime? LastIndexedModifiedOn { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime ModifiedOn { get; set; } + + public static string BuildGeneration(int schemaVersion, int protectedCatalogVersion, long policyEpoch) + { + return $"{schemaVersion}.{protectedCatalogVersion}.{policyEpoch}"; + } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsSearchIndexStateId; } + set { RmsSearchIndexStateId = (int)value; } + } + + [NotMapped] + public string TableName => "RmsSearchIndexStates"; + + [NotMapped] + public string IdName => "RmsSearchIndexStateId"; + + [NotMapped] + public int IdType => 0; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Records/RmsRecordUnitResponse.cs b/Core/Resgrid.Model/Records/RmsRecordUnitResponse.cs new file mode 100644 index 000000000..0440e5eaf --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsRecordUnitResponse.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// Unit response on a Record (LogUnit parity): unit snapshot plus dispatched/en-route/on-scene/ + /// released/in-quarters times. Every prefilled time is freely editable in Draft; the originally + /// prefilled values and their are retained in + /// so provenance is never lost (RMS plan section 4.2). + /// + [Table("RmsRecordUnitResponses")] + public class RmsRecordUnitResponse : IEntity + { + public string RmsRecordUnitResponseId { get; set; } + + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + public string RecordId { get; set; } + + public string RevisionId { get; set; } + + public int UnitId { get; set; } + + public string UnitNameSnapshot { get; set; } + + public string UnitTypeSnapshot { get; set; } + + public int? StationGroupIdSnapshot { get; set; } + + public DateTime? Dispatched { get; set; } + + public DateTime? Enroute { get; set; } + + public DateTime? OnScene { get; set; } + + public DateTime? Released { get; set; } + + public DateTime? InQuarters { get; set; } + + /// the time set was prefilled from; None when entered by the author. + public int TimesSourceKind { get; set; } + + /// Server-authored JSON of the originally prefilled times and their sources; never client-authored. + public string PrefillJson { get; set; } + + public int Ordinal { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime ModifiedOn { get; set; } + + public long RowVersion { get; set; } + + public DateTime? DeletedOn { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsRecordUnitResponseId; } + set { RmsRecordUnitResponseId = value?.ToString(); } + } + + [NotMapped] + public string TableName => "RmsRecordUnitResponses"; + + [NotMapped] + public string IdName => "RmsRecordUnitResponseId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Records/RmsRevision.cs b/Core/Resgrid.Model/Records/RmsRevision.cs new file mode 100644 index 000000000..f473c749e --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsRevision.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// One immutable, checksummed revision of a Record (RMS plan sections 4.8/5.2). Written by finalize, + /// amend and void; pinned to the definition version it was authored under; references the prior + /// revision. is the complete server-authored snapshot (header, typed + /// details, participants, units, attachment metadata); diffs are computed from two snapshots and + /// never stored. Self-referencing through only as a plain column, never + /// a navigation collection, because of the RepositoryBase cascade landmine (plan section 5.11.1). + /// + [Table("RmsRevisions")] + public class RmsRevision : IEntity + { + public string RmsRevisionId { get; set; } + + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + public string RecordId { get; set; } + + /// . + public int RecordKind { get; set; } + + public int RevisionNumber { get; set; } + + /// . + public int Transition { get; set; } + + public string PriorRevisionId { get; set; } + + public string DefinitionKey { get; set; } + + public int DefinitionVersion { get; set; } + + public string SnapshotJson { get; set; } + + /// Lower-case hex SHA-256 of . + public string Checksum { get; set; } + + public string ActorUserId { get; set; } + + public string ActorRoleSnapshot { get; set; } + + /// Required for amend and void; null for the first finalization. + public string ReasonCode { get; set; } + + public string ReasonText { get; set; } + + /// Version of the attestation statement the actor accepted, when the transition attests. + public string AttestationStatementVersion { get; set; } + + public DateTime? AttestedOn { get; set; } + + /// . + public int OriginClient { get; set; } + + public bool IsProtected { get; set; } + + public int ProtectedCatalogVersion { get; set; } + + public DateTime CreatedOn { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsRevisionId; } + set { RmsRevisionId = value?.ToString(); } + } + + [NotMapped] + public string TableName => "RmsRevisions"; + + [NotMapped] + public string IdName => "RmsRevisionId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Repositories/IDepartmentProfileRepositories.cs b/Core/Resgrid.Model/Repositories/IDepartmentProfileRepositories.cs new file mode 100644 index 000000000..a72f3fa43 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IDepartmentProfileRepositories.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface IDepartmentProfileRepository : IRepository + { + Task GetByDepartmentIdAsync(int departmentId); + } + + public interface IDepartmentProfileMediaRepository : IRepository + { + /// Every rendition row for the department without its bytes. + Task> GetMetadataForDepartmentAsync(int departmentId); + + /// One rendition with its bytes. + Task GetAsync(int departmentId, int kind); + + /// Anonymous serving path: the rendition matching an opaque per-department key. + Task GetByMediaKeyAsync(string mediaKey, int kind); + + Task DeleteForDepartmentAsync(int departmentId, CancellationToken cancellationToken = default); + + Task UpdateMediaKeyAsync(int departmentId, string mediaKey, CancellationToken cancellationToken = default); + } + + public interface IRmsRecordPrintLayoutsRepository : IRepository + { + Task GetAsync(int departmentId, int scope, string definitionKey); + } +} diff --git a/Core/Resgrid.Model/Repositories/IRmsLegacyStatsRepository.cs b/Core/Resgrid.Model/Repositories/IRmsLegacyStatsRepository.cs new file mode 100644 index 000000000..fdd8aa5b6 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IRmsLegacyStatsRepository.cs @@ -0,0 +1,21 @@ +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + /// Pre-activation legacy Log/UnitLog counts (RMS plan section 4.1). Read-only; no legacy row is ever mutated. + public class RmsLegacyStats + { + public int LogCount { get; set; } + /// Logs rows with no StationGroupId; they stay department-wide under group scoping (plan 5.7.1). + public int LogsWithoutGroupCount { get; set; } + public int EventTypeLogCount { get; set; } + public int UnitLogCount { get; set; } + public int MaxLogId { get; set; } + public int MaxUnitLogId { get; set; } + } + + public interface IRmsLegacyStatsRepository + { + Task GetLegacyStatsAsync(int departmentId); + } +} diff --git a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs new file mode 100644 index 000000000..df50e01c4 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + /// + /// Paged, indexed list filter for the Records work queue (RMS plan section 5.10 work queue). Free-text + /// search is served by the RMS-owned records index, not by this filter; nothing here runs LIKE over + /// narrative. + /// + public sealed class RmsRecordQuery + { + public IList States { get; set; } + public string DefinitionKey { get; set; } + public int? Year { get; set; } + public int? CallId { get; set; } + public string AuthorUserId { get; set; } + public string OwnerUserId { get; set; } + public int? StationGroupId { get; set; } + /// When non-null, restricts to Records whose group scope intersects these ids or whose author/owner/participant/reviewer is . + public IList VisibleGroupIds { get; set; } + public string ViewerUserId { get; set; } + public bool IncludeLegacy { get; set; } + public int Skip { get; set; } + public int Take { get; set; } = 50; + } + + /// RmsOperationalRecords: every read is department-scoped; there is no ID-only lookup. + public interface IRmsOperationalRecordsRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string recordId); + Task GetByIdempotencyKeyAsync(int departmentId, string idempotencyKey); + Task> GetByCallAsync(int departmentId, int callId); + /// Records of one definition whose StartedOn falls in [start, end], in the given states; the report feed. + Task> GetByDefinitionAndStartedRangeAsync(int departmentId, string definitionKey, IEnumerable states, DateTime start, DateTime end); + Task> GetByOwnerAndStatesAsync(int departmentId, string ownerUserId, IEnumerable states); + Task> GetByDepartmentAndStatesAsync(int departmentId, IEnumerable states, int? year, int skip, int take); + Task CountByDepartmentAsync(int departmentId, IEnumerable states); + Task CountCreatedSinceAsync(int departmentId, DateTime sinceUtc); + Task CountFinalizedSinceAsync(int departmentId, DateTime sinceUtc); + /// Every live Record in the department, any state. + Task CountAllAsync(int departmentId); + /// Live Records in Draft, ReadyForReview, Returned or Approved (accountability view, plan 4.7). + Task> GetOpenAsync(int departmentId); + /// Live Finalized/Amended Records whose FinalizedOn is at or after the instant. + Task> GetFinalizedSinceAsync(int departmentId, DateTime sinceUtc); + /// Live Records with no RmsRecordGroupScope row: they stay department-wide under group scoping (plan 5.7.1). + Task CountWithoutGroupScopeAsync(int departmentId); + Task> GetYearsAsync(int departmentId); + /// Highest sequence already issued for a number prefix (e.g. "TRN-2026-"), 0 when none. + Task GetMaxRecordNumberSequenceAsync(int departmentId, string numberPrefix); + /// + /// Optimistic-concurrency guard: bumps RowVersion only if it still equals . + /// Returns false on a stale ETag. Run inside the caller's unit of work so the row lock serializes writers. + /// + Task TryBumpRowVersionAsync(int departmentId, string recordId, long expectedRowVersion, CancellationToken cancellationToken = default); + } + + public interface IRmsOperationalRecordDetailsRepository : IRepository + { + Task GetDraftAsync(int departmentId, string recordId); + Task GetByRevisionAsync(int departmentId, string recordId, string revisionId); + /// Working-draft detail rows for many records at once. + Task> GetDraftsForRecordsAsync(int departmentId, IEnumerable recordIds); + } + + public interface IRmsRecordParticipantsRepository : IRepository + { + /// null returns the working-draft rows. + Task> GetForRecordAsync(int departmentId, string recordId, string revisionId); + Task> GetByUserAsync(int departmentId, string userId); + /// Working-draft participant rows for many records at once. + Task> GetForRecordsAsync(int departmentId, IEnumerable recordIds); + Task DeleteDraftForRecordAsync(int departmentId, string recordId, CancellationToken cancellationToken = default); + } + + public interface IRmsRecordUnitResponsesRepository : IRepository + { + Task> GetForRecordAsync(int departmentId, string recordId, string revisionId); + Task> GetByUnitAsync(int departmentId, int unitId); + /// Working-draft unit response rows for many records at once. + Task> GetForRecordsAsync(int departmentId, IEnumerable recordIds); + Task DeleteDraftForRecordAsync(int departmentId, string recordId, CancellationToken cancellationToken = default); + } + + public interface IRmsRecordAttachmentsRepository : IRepository + { + /// Metadata only: never loads Data. + Task> GetMetadataForRecordAsync(int departmentId, string recordId); + /// Loads Data; authorized per Record by the caller on every request. + Task GetByIdForDepartmentAsync(int departmentId, string attachmentId); + } + + public interface IRmsExternalReferencesRepository : IRepository + { + Task> GetForRecordAsync(int departmentId, string recordId); + } + + public interface IDomainEventOutboxRepository : IRepository + { + Task GetNextSequenceAsync(int departmentId, string aggregateId); + /// Leases up to pending rows whose NextAttemptOn has passed (or is null) to . + Task> ClaimPendingBatchAsync(string leaseOwner, TimeSpan leaseDuration, int batchSize, DateTime utcNow, CancellationToken cancellationToken = default); + /// Leases one specific pending row (post-commit fast path); null when it is no longer pending or is leased elsewhere. + Task ClaimByIdAsync(long domainEventOutboxId, string leaseOwner, TimeSpan leaseDuration, DateTime utcNow, CancellationToken cancellationToken = default); + Task MarkDispatchedAsync(long domainEventOutboxId, DateTime utcNow, CancellationToken cancellationToken = default); + Task MarkFailedAsync(long domainEventOutboxId, string error, DateTime? nextAttemptOn, bool terminal, CancellationToken cancellationToken = default); + Task CountByStateAsync(int state); + Task GetOldestPendingCreatedOnAsync(); + Task PurgeDispatchedOlderThanAsync(DateTime cutoffUtc, CancellationToken cancellationToken = default); + } + + public interface IRmsDepartmentCutoversRepository : IRepository + { + Task GetByDepartmentIdAsync(int departmentId); + /// Every department whose cutover is currently Active (the search index sweep scope). + Task> GetActiveAsync(); + } + + public interface IRmsDepartmentCutoverEventsRepository : IRepository + { + Task> GetForCutoverAsync(int departmentId, int cutoverId); + } + + public interface IRmsRevisionsRepository : IRepository + { + Task> GetForRecordAsync(int departmentId, string recordId); + Task GetByIdForDepartmentAsync(int departmentId, string revisionId); + } + + public interface IRmsAccessAuditsRepository : IRepository + { + Task> GetForRecordAsync(int departmentId, string recordId, int take); + } + + public interface IRmsRecordSearchProjectionsRepository : IRepository + { + Task GetByRecordIdAsync(int departmentId, string recordId); + Task> QueryAsync(int departmentId, RmsRecordQuery query); + Task CountAsync(int departmentId, RmsRecordQuery query); + Task> GetYearsAsync(int departmentId); + /// Projections (including soft-deleted ones) modified after , oldest first; the search index catch-up feed. + Task> GetModifiedSinceAsync(int departmentId, DateTime? since, int take); + /// Live projections by record id, for post-retrieval loading of search hits. + Task> GetByIdsAsync(int departmentId, IEnumerable recordIds); + } + + public interface IRmsSearchIndexStatesRepository : IRepository + { + Task GetAsync(int departmentId, string indexName); + } + + public interface IRmsRecordGroupScopesRepository : IRepository + { + Task> GetForRecordAsync(int departmentId, string recordId); + Task> GetForRecordsAsync(int departmentId, IEnumerable recordIds); + /// Deletes the Record's scope rows and inserts the supplied set; run inside the owning transaction. + Task ReplaceForRecordAsync(int departmentId, string recordId, IEnumerable scopes, CancellationToken cancellationToken = default); + /// Distinct live Records anchored to each group, keyed by DepartmentGroupId (impact preview, plan 5.7.1). + Task> CountRecordsByGroupAsync(int departmentId); + } + + public interface IRmsRecordSharesRepository : IRepository + { + Task> GetForRecordAsync(int departmentId, string recordId); + } +} diff --git a/Core/Resgrid.Model/Repositories/IWorkflowRunRepository.cs b/Core/Resgrid.Model/Repositories/IWorkflowRunRepository.cs index 5e9356146..2d556922e 100644 --- a/Core/Resgrid.Model/Repositories/IWorkflowRunRepository.cs +++ b/Core/Resgrid.Model/Repositories/IWorkflowRunRepository.cs @@ -10,5 +10,8 @@ public interface IWorkflowRunRepository : IRepository Task> GetRunsByWorkflowIdAsync(string workflowId, int page, int pageSize); Task> GetRunsByDepartmentInMinuteAsync(int departmentId); Task DeleteAllByWorkflowIdAsync(string workflowId); + + /// The initial run for a (WorkflowId, EventId) pair, or null; plan section 5.6 dedup (retries reuse it). + Task GetByWorkflowAndEventAsync(string workflowId, string eventId); } } diff --git a/Core/Resgrid.Model/Services/IDepartmentProfileMediaService.cs b/Core/Resgrid.Model/Services/IDepartmentProfileMediaService.cs new file mode 100644 index 000000000..8c05df962 --- /dev/null +++ b/Core/Resgrid.Model/Services/IDepartmentProfileMediaService.cs @@ -0,0 +1,41 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Department Profile elevation (RMS plan section 4.10.1): the profile row as the department's identity source + /// and the logo with its renditions (DepartmentProfileMedia). Not gated by Records.System; branding is useful + /// before activation and changes nothing for a department that never fills it in. + /// + public interface IDepartmentProfileMediaService + { + /// The profile row, created from the Department row on first use. + Task GetOrCreateProfileAsync(int departmentId, CancellationToken cancellationToken = default); + + Task SaveProfileAsync(DepartmentProfile profile, CancellationToken cancellationToken = default); + + /// Identity block plus media metadata (no bytes); falls back to the Department row when no profile exists. + Task GetBrandingAsync(int departmentId); + + /// Validates, re-encodes (metadata stripped) and stores the logo with its renditions; replaces any previous logo. + Task UploadLogoAsync(int departmentId, string userId, string fileName, string contentType, byte[] data, CancellationToken cancellationToken = default); + + Task RemoveLogoAsync(int departmentId, string userId, CancellationToken cancellationToken = default); + + /// Issues a new MediaKey, invalidating every previously sent masthead link. + Task RegenerateMediaKeyAsync(int departmentId, string userId, CancellationToken cancellationToken = default); + + /// A rendition with its bytes, for authenticated serving and print embedding. + Task GetMediaAsync(int departmentId, DepartmentProfileMediaKind kind); + + /// The EmailMasthead rendition for the anonymous endpoint; null when the key is unknown. + Task GetPublicMastheadAsync(string mediaKey); + + /// + /// Masthead data for the department-scoped operational emails. Cached, and never throws: a branding lookup + /// failure means the email goes out with Resgrid chrome, not that it goes unsent. + /// + Task GetEmailBrandingAsync(int departmentId); + } +} diff --git a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs index 11fc47b00..b3b3c1620 100644 --- a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs @@ -391,5 +391,21 @@ Task SetUnitCallStatusOverridesByUnitTypeAsync(int department Task GetHardwareTrackingMobileFallbackEnabledAsync(int departmentId, bool bypassCache = false); Task GetHardwareTrackingLocationRetentionDaysAsync(int departmentId, bool bypassCache = false); + + // -- Records (RMS) department settings 70-77 (RMS plan section 4.9) -------------------------------- + Task GetRecordsDefaultLifecyclePresetAsync(int departmentId, bool bypassCache = false); + Task SetRecordsDefaultLifecyclePresetAsync(int departmentId, RmsLifecyclePreset preset, CancellationToken cancellationToken = default(CancellationToken)); + Task GetRecordsReviewDueHoursAsync(int departmentId, bool bypassCache = false); + Task SetRecordsReviewDueHoursAsync(int departmentId, int hours, CancellationToken cancellationToken = default(CancellationToken)); + Task GetRecordsNumberingConfigAsync(int departmentId, bool bypassCache = false); + Task SetRecordsNumberingConfigAsync(int departmentId, RecordsNumberingConfig config, CancellationToken cancellationToken = default(CancellationToken)); + Task GetRecordsSearchConfigAsync(int departmentId, bool bypassCache = false); + Task SetRecordsSearchConfigAsync(int departmentId, RecordsSearchConfig config, CancellationToken cancellationToken = default(CancellationToken)); + Task GetRecordsRetentionPolicyAsync(int departmentId, bool bypassCache = false); + Task SetRecordsRetentionPolicyAsync(int departmentId, RecordsRetentionPolicy policy, CancellationToken cancellationToken = default(CancellationToken)); + Task GetRecordsGroupVisibilityModeAsync(int departmentId, bool bypassCache = false); + Task SetRecordsGroupVisibilityModeAsync(int departmentId, RecordsGroupVisibilityMode mode, CancellationToken cancellationToken = default(CancellationToken)); + Task GetRecordsDisclosureConfigAsync(int departmentId, bool bypassCache = false); + Task SetRecordsDisclosureConfigAsync(int departmentId, RecordsDisclosureConfig config, CancellationToken cancellationToken = default(CancellationToken)); } } diff --git a/Core/Resgrid.Model/Services/IDomainEventOutboxService.cs b/Core/Resgrid.Model/Services/IDomainEventOutboxService.cs new file mode 100644 index 000000000..27f560489 --- /dev/null +++ b/Core/Resgrid.Model/Services/IDomainEventOutboxService.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Reusable transactional outbox transport (RMS plan section 5.6, cross-plan adjustment 7). A producer + /// enqueues inside its own unit of work; after commit it asks for an in-process dispatch of the rows + /// it wrote, and worker command 40 sweeps whatever that missed. Producers own event names, schemas + /// and payload contracts; this service owns identity, sequence, leases, retries and delivery state. + /// + public interface IDomainEventOutboxService + { + /// Writes the row inside the caller's current unit of work. Returns the row (with EventId and Sequence). + Task EnqueueAsync(int departmentId, string producerSubsystem, DomainEventEnvelope envelope, CancellationToken cancellationToken = default); + + /// Post-commit fast path: dispatches the given rows if still pending. Never throws to the caller. + Task DispatchAfterCommitAsync(IEnumerable domainEventOutboxIds, CancellationToken cancellationToken = default); + + /// Durable sweep used by worker command 40: leases and dispatches pending rows. + Task DispatchPendingAsync(string leaseOwner, int batchSize, CancellationToken cancellationToken = default); + + Task GetHealthAsync(); + + Task PurgeDispatchedAsync(int olderThanDays, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsAccountabilityService.cs b/Core/Resgrid.Model/Services/IRecordsAccountabilityService.cs new file mode 100644 index 000000000..f691aee9b --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsAccountabilityService.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// The "who owes me a report" axis (RMS plan section 4.7, Accountability): open Records, overdue reviews, + /// returned-not-corrected Records and time-to-finalize pivoted by person, station/group or unit, with a bounded + /// reminder. Rows are scoped by the same visibility rule as the queue, so a group-scoped viewer sees only + /// what the queue would show. + /// + public interface IRecordsAccountabilityService + { + /// At most this many reminders leave in one action, and a Record is reminded at most once per day. + int MaxRemindersPerAction { get; } + + Task BuildAsync(int departmentId, string viewerUserId, RecordsAccountabilityPivot pivot, int windowDays); + + Task SendReminderAsync(int departmentId, string senderUserId, string recordId, CancellationToken cancellationToken = default); + + Task> SendRemindersAsync(int departmentId, string senderUserId, IEnumerable recordIds, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs b/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs new file mode 100644 index 000000000..3acdfdf82 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Per-Record visibility (RMS plan section 5.7.1). Group visibility narrows; it never grants: a user + /// without Record_View gains nothing from being in the right group, and department administrators see + /// everything, matching every other LockToGroup permission. Follows the CanUserViewPersonAsync / + /// CanUserViewUnitAsync exemplars: read the ViewGroupRecords row, branch on PermissionActions, honor + /// LockToGroup, fail closed on evaluation error. + /// + public interface IRecordsAuthorizationService + { + /// Whether cross-group scoping is in force for the department (setting 75 GroupScoped and ViewGroupRecords LockToGroup). + Task IsGroupScopedAsync(int departmentId); + + /// + /// The group ids whose Records the user may see, or null when the user sees the whole department + /// (department-wide mode, department admin, or an unlocked ViewGroupRecords row). Used as the join + /// input for list, search and report queries so visibility is a join, not a per-row callback. + /// + Task> GetVisibleGroupIdsAsync(string userId, int departmentId); + + /// Per-Record check applied on every detail, attachment and deep-link read. + Task CanUserViewRecordAsync(string userId, string recordId, int departmentId); + + /// What GroupScoped would hide, by group, before an administrator confirms it (plan 5.7.1 "Turning it on"). + Task PreviewGroupScopingAsync(int departmentId); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsCutoverService.cs b/Core/Resgrid.Model/Services/IRecordsCutoverService.cs new file mode 100644 index 000000000..e42c499b5 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsCutoverService.cs @@ -0,0 +1,41 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Records (RMS) module state and the department cutover (RMS plan section 4.1). The feature flag + /// selects Records versus Logs in the UI; the append-only RmsDepartmentCutover row is what engages + /// the legacy Log/UnitLog write guard and what the rollback runbook keys off. + /// + public interface IRecordsCutoverService + { + Task GetModuleStateAsync(int departmentId, bool bypassCache = false); + + /// Records.System evaluates on for the department. + Task IsRecordsEnabledAsync(int departmentId); + + /// True once the department has an active cutover row; every legacy Log/UnitLog mutation must then be denied. + Task AreLegacyWritesBlockedAsync(int departmentId); + + /// Service-boundary guard: throws and audits the attempt when blocked. + Task EnsureLegacyWriteAllowedAsync(int departmentId, string context, string userId = null); + + Task GetActivationPreviewAsync(int departmentId); + + /// + /// Activates Records for the department in one transaction: cutover row, Permission-row migration + /// (registry section 4.6), ViewGroupRecords row when the administrator chose LockToGroup, history + /// event and access audit. Refuses when the flag is off, preflight blocks, or already activated. + /// + Task ActivateAsync(int departmentId, string userId, string reason, bool viewGroupRecordsLockToGroup, CancellationToken cancellationToken = default); + + /// Which rollback outcome the decision frame permits right now. + Task GetRollbackOutcomeAsync(int departmentId); + + /// Clean revert only (zero Records rows since activation); anything else returns a failure naming the outcome. + Task RevertAsync(int departmentId, string userId, string reason, CancellationToken cancellationToken = default); + + Task InvalidateCacheAsync(int departmentId); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsNotificationService.cs b/Core/Resgrid.Model/Services/IRecordsNotificationService.cs new file mode 100644 index 000000000..2b235b109 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsNotificationService.cs @@ -0,0 +1,17 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Delivers the RMS-owned notification EventTypes 31-33 (RMS plan section 4.7). Return-for-correction is + /// author-targeted: it goes straight to the record's author and never consults department notification + /// settings, which is why the types stay [NotMapped]. Invoked by the notification worker + /// (NotificationBroadcastLogic) from the NotificationItem the Records service enqueues after commit. + /// + public interface IRecordsNotificationService + { + /// Notifies the author that their record was returned; false when nothing was sent (record missing, not Returned, no author). + Task NotifyReturnedForCorrectionAsync(int departmentId, string recordId, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsPrintLayoutService.cs b/Core/Resgrid.Model/Services/IRecordsPrintLayoutService.cs new file mode 100644 index 000000000..b32a14d89 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsPrintLayoutService.cs @@ -0,0 +1,14 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// The DepartmentDefault print layout (RMS plan section 4.10.1), versioned on every save. + public interface IRecordsPrintLayoutService + { + /// The saved layout with its parsed config, or an unsaved default (Version 0, generated layout version). + Task GetDepartmentDefaultAsync(int departmentId); + + Task SaveDepartmentDefaultAsync(int departmentId, string userId, RecordsPrintLayoutConfig config, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsReportingService.cs b/Core/Resgrid.Model/Services/IRecordsReportingService.cs new file mode 100644 index 000000000..c805496e6 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsReportingService.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Source-agnostic activity feed for the department reports (RMS plan section 4.10). Legacy Logs remain the + /// pre-cutover history and finalized Records are the post-cutover history; both are returned in one shape so + /// report totals reconcile across activation. Records are filtered by the viewer's group scope exactly as the + /// Records queue is, so a report never discloses a record the viewer could not open. + /// + public interface IRecordsReportingService + { + Task> GetActivityAsync(int departmentId, string viewerUserId, RmsOperationalRecordType type, DateTime start, DateTime end); + + /// Every legacy Log and finalized Record linked to one call, any type. + Task> GetCallActivityAsync(int departmentId, string viewerUserId, int callId); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsSearchIndexMaintenanceService.cs b/Core/Resgrid.Model/Services/IRecordsSearchIndexMaintenanceService.cs new file mode 100644 index 000000000..745a362ac --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsSearchIndexMaintenanceService.cs @@ -0,0 +1,18 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Worker command 44: keeps the records index in step with RmsRecordSearchProjections for every activated + /// department. A generation-key change rebuilds the department; otherwise documents modified since the last + /// sweep are re-indexed and soft-deleted projections are removed. + /// + public interface IRecordsSearchIndexMaintenanceService + { + Task SweepAsync(CancellationToken cancellationToken = default); + + /// Forces a full rebuild of one department regardless of its generation key. + Task RebuildDepartmentAsync(int departmentId, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsSearchIndexer.cs b/Core/Resgrid.Model/Services/IRecordsSearchIndexer.cs new file mode 100644 index 000000000..753f17ea1 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsSearchIndexer.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Write side of the RMS-owned Lucene index. Only the worker process (command 44, in the process that also + /// runs command 40) holds the single IndexWriter; web processes never resolve this in anger. + /// + public interface IRecordsSearchIndexer + { + Task IndexAsync(IEnumerable documents, CancellationToken cancellationToken = default); + + Task DeleteAsync(int departmentId, int sourceType, string sourceId, CancellationToken cancellationToken = default); + + Task DeleteDepartmentAsync(int departmentId, CancellationToken cancellationToken = default); + + Task CommitAsync(CancellationToken cancellationToken = default); + + Task CountDocumentsAsync(int departmentId); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsSearchService.cs b/Core/Resgrid.Model/Services/IRecordsSearchService.cs new file mode 100644 index 000000000..71f6e59c4 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsSearchService.cs @@ -0,0 +1,20 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Read side of the RMS-owned Lucene index (RMS plan section 5.10). The department filter and, under + /// group-scoped visibility, the group-scope clause are injected from authenticated state by the caller, + /// never from request input. Every hit must still be re-checked by the caller before it is shown. + /// + public interface IRecordsSearchService + { + /// True when the host is enabled and an index exists to read. + bool IsAvailable { get; } + + Task SearchAsync(int departmentId, RecordsSearchRequest request, CancellationToken cancellationToken = default); + + Task GetHealthAsync(); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsService.cs b/Core/Resgrid.Model/Services/IRecordsService.cs new file mode 100644 index 000000000..de9704d2c --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsService.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Repositories; + +namespace Resgrid.Model.Services +{ + /// + /// The Records (RMS) operational aggregate: drafts, lifecycle transitions, immutable revisions, + /// attachments, group scope and the search projection (RMS plan sections 4.1, 4.8, 5.3, 5.7). Every + /// method is department-scoped; actor identity comes from the caller's authenticated state. Legal + /// transitions (finalize, amend, void, submit) are online-only and never last-writer-wins. + /// + public interface IRecordsService + { + Task CreateDraftAsync(int departmentId, string userId, RecordDraftInput input, CancellationToken cancellationToken = default); + + /// ETag-guarded draft save; throws on a stale RowVersion. + Task SaveDraftAsync(int departmentId, string userId, string recordId, long expectedRowVersion, RecordDraftInput input, CancellationToken cancellationToken = default); + + Task SubmitForReviewAsync(int departmentId, string userId, string recordId, long expectedRowVersion, CancellationToken cancellationToken = default); + + Task ReturnForCorrectionAsync(int departmentId, string userId, string recordId, string reasonCode, string reasonText, CancellationToken cancellationToken = default); + + Task ApproveAsync(int departmentId, string userId, string recordId, CancellationToken cancellationToken = default); + + /// + /// Writes the immutable revision (finalize, or amend when an amendment draft is open), assigns the + /// record number under the default OnFinalize policy, recomputes group scope, refreshes the search + /// projection and enqueues the lifecycle event(s) in one transaction. + /// + Task FinalizeAsync(int departmentId, string userId, string recordId, long expectedRowVersion, string attestationStatementVersion, string reasonCode, string reasonText, CancellationToken cancellationToken = default); + + /// Opens an amendment draft seeded from the current revision (at most one per Record). + Task OpenAmendmentAsync(int departmentId, string userId, string recordId, CancellationToken cancellationToken = default); + + /// Discards an open amendment draft; changes nothing and consumes no number. + Task AbandonAmendmentAsync(int departmentId, string userId, string recordId, CancellationToken cancellationToken = default); + + Task VoidAsync(int departmentId, string userId, string recordId, string reasonCode, string reasonText, CancellationToken cancellationToken = default); + + Task CancelAsync(int departmentId, string userId, string recordId, CancellationToken cancellationToken = default); + + Task ReassignDraftAsync(int departmentId, string userId, string recordId, string newOwnerUserId, string reason, CancellationToken cancellationToken = default); + + Task GetAsync(int departmentId, string recordId, bool includeRevisions = false); + + /// Records of the same definition already linked to the Call (duplicate warning, RMS plan section 4.7). + Task> GetDuplicateCandidatesAsync(int departmentId, string definitionKey, int callId); + + /// Unfinalized Records a member still owns (plan 4.7 draft ownership transfer): surfaced before deactivation. + Task> GetOutstandingForOwnerAsync(int departmentId, string userId); + + Task> QueryAsync(int departmentId, RmsRecordQuery query); + + Task CountAsync(int departmentId, RmsRecordQuery query); + /// Live projections by record id, in no particular order; the search path re-checks each before showing it. + Task> GetProjectionsByIdsAsync(int departmentId, IEnumerable recordIds); + + Task> GetYearsAsync(int departmentId); + + Task AddAttachmentAsync(int departmentId, string userId, string recordId, string fileName, string contentType, byte[] data, string description, CancellationToken cancellationToken = default); + + /// Loads bytes; the caller authorizes per Record before serving. + Task GetAttachmentAsync(int departmentId, string attachmentId); + + Task RemoveAttachmentAsync(int departmentId, string userId, string recordId, string attachmentId, CancellationToken cancellationToken = default); + + Task> GetRevisionsAsync(int departmentId, string recordId); + + /// The Record exactly as it stood at a revision, rendered from that revision's snapshot. + Task GetRevisionSnapshotAsync(int departmentId, string revisionId); + + /// Field-level diff computed on demand from two snapshots; restricted fields are withheld for viewers without RecordRestricted_View. + Task> DiffRevisionsAsync(int departmentId, string fromRevisionId, string toRevisionId, bool canViewRestricted); + + Task RecordAccessAsync(int departmentId, string userId, string recordId, string revisionId, RmsAccessAuditAction action, string purpose = null, string ipAddress = null, RmsOriginClient originClient = RmsOriginClient.Web); + } +} diff --git a/Core/Resgrid.Model/Services/IRmsInventoryUsageAdapter.cs b/Core/Resgrid.Model/Services/IRmsInventoryUsageAdapter.cs new file mode 100644 index 000000000..aa04672e5 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRmsInventoryUsageAdapter.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// One inventory item consumed against a Record (or, in principle, a legacy Log). + public class RmsInventoryUsage + { + public const string SourceRecord = "Record"; + public const string SourceLegacyLog = "LegacyLog"; + + /// RmsExternalReference ID for Record-sourced usage; null for legacy usage. + public string ReferenceId { get; set; } + public string Source { get; set; } + public string RecordId { get; set; } + public int? LegacyLogId { get; set; } + public int InventoryId { get; set; } + public decimal Quantity { get; set; } + public string Note { get; set; } + public string CapturedByUserId { get; set; } + public DateTime CapturedOn { get; set; } + } + + /// + /// Source-agnostic inventory-usage adapter (RMS plan RMS-1 package: read legacy Log usage, write new usage + /// against RmsRecord/RmsRevision without a legacy row). Usage against a Record is an RmsExternalReference with + /// the InventoryUsage semantic role, so no Records table changes when inventory is modernized. The legacy read + /// exists so a caller never has to know which side a subject lives on; the current Logs schema carries no + /// inventory linkage (Inventory rows have no LogId), so it answers empty today and a Logs-side source can + /// plug in without changing callers. + /// + public interface IRmsInventoryUsageAdapter + { + Task> GetUsageForRecordAsync(int departmentId, string recordId); + + Task> GetUsageForLegacyLogAsync(int departmentId, int logId); + + Task RecordUsageAsync(int departmentId, string userId, string recordId, int inventoryId, decimal quantity, string note, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IRmsRecordValueService.cs b/Core/Resgrid.Model/Services/IRmsRecordValueService.cs new file mode 100644 index 000000000..992d05073 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRmsRecordValueService.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// The single seam for Record value reads and writes (RMS plan section 5.9.1, "Single service seam for all + /// value reads/writes"). In RMS-1 the typed Logs-parity fields live on RmsOperationalRecordDetail and this is + /// the only caller of the details repository; RMS-1B's RmsRecordValues rows join it here, and Protected Data + /// enrollment builds its protected persistence clone here, before the repository write, without touching + /// RecordsService. Everything the seam does today is inert: IsProtected stays false, the envelope stays null, + /// the catalog version reads 0, and a stray envelope without the flag is refused rather than stored. + /// + public interface IRmsRecordValueService + { + Task GetDraftAsync(int departmentId, string recordId); + + Task GetByRevisionAsync(int departmentId, string recordId, string revisionId); + + Task> GetDraftsForRecordsAsync(int departmentId, IEnumerable recordIds); + + Task InsertAsync(RmsOperationalRecordDetail details, CancellationToken cancellationToken = default); + + Task UpdateAsync(RmsOperationalRecordDetail details, CancellationToken cancellationToken = default); + + Task SaveOrUpdateAsync(RmsOperationalRecordDetail details, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/UdfEntityType.cs b/Core/Resgrid.Model/UdfEntityType.cs index 1410fca77..b66d0ae65 100644 --- a/Core/Resgrid.Model/UdfEntityType.cs +++ b/Core/Resgrid.Model/UdfEntityType.cs @@ -22,7 +22,15 @@ public enum UdfEntityType [Display(Name = "Contact")] [Description("Contact")] - Contact = 3 + Contact = 3, + + /// + /// A Records (RMS) Record. UDF definitions for Records are scoped to a definition key and version, + /// and UDF values never enter a standardized export or submission payload (RMS plan section 4.1). + /// + [Display(Name = "Record")] + [Description("Record")] + Record = 4 } } diff --git a/Core/Resgrid.Model/WorkflowHealthSummary.cs b/Core/Resgrid.Model/WorkflowHealthSummary.cs index 904117361..fba4a84e1 100644 --- a/Core/Resgrid.Model/WorkflowHealthSummary.cs +++ b/Core/Resgrid.Model/WorkflowHealthSummary.cs @@ -15,6 +15,9 @@ public sealed class WorkflowHealthSummary public int SuccessfulRuns24h { get; set; } public int FailedRuns24h { get; set; } public int RetryingRuns24h { get; set; } + public int SkippedRuns24h { get; set; } + public int SkippedRuns7d { get; set; } + public int SkippedRuns30d { get; set; } // Last 7 days public int TotalRuns7d { get; set; } diff --git a/Core/Resgrid.Model/WorkflowRun.cs b/Core/Resgrid.Model/WorkflowRun.cs index be7ac1153..8916f4227 100644 --- a/Core/Resgrid.Model/WorkflowRun.cs +++ b/Core/Resgrid.Model/WorkflowRun.cs @@ -39,6 +39,32 @@ public class WorkflowRun : IEntity public DateTime QueuedOn { get; set; } + // Domain-event envelope (RMS plan section 5.6). EventId is the immutable idempotency key: one initial run + // per (WorkflowId, EventId), retries reuse the run. Null for the legacy in-process events. + [MaxLength(36)] + public string EventId { get; set; } + + public int? EventSchemaVersion { get; set; } + + [MaxLength(36)] + public string CorrelationId { get; set; } + + [MaxLength(36)] + public string CausationId { get; set; } + + /// Per-aggregate sequence from the outbox, so out-of-order execution is diagnosable. + public long? RecordSequence { get; set; } + + /// Maps to RmsOriginClient for Records events. + public int? OriginClient { get; set; } + + [MaxLength(36)] + public string AggregateId { get; set; } + + /// Why the run was recorded as Skipped without executing (WorkflowRunSkipReasons); null for executed runs. + [MaxLength(100)] + public string SkipReason { get; set; } + public virtual ICollection Logs { get; set; } [NotMapped] diff --git a/Core/Resgrid.Model/WorkflowRunEnvelope.cs b/Core/Resgrid.Model/WorkflowRunEnvelope.cs new file mode 100644 index 000000000..9955cca91 --- /dev/null +++ b/Core/Resgrid.Model/WorkflowRunEnvelope.cs @@ -0,0 +1,72 @@ +using System; +using Resgrid.Model.Events; + +namespace Resgrid.Model +{ + /// Why a run was recorded as Skipped without executing (RMS plan section 5.6: Records events are never silently dropped). + public static class WorkflowRunSkipReasons + { + public const string RateLimit = "RateLimit"; + public const string DailyLimit = "DailyLimit"; + } + + /// + /// Carries the domain-event envelope onto a WorkflowRun (plan section 5.6): the immutable EventId that + /// deduplicates runs per (WorkflowId, EventId), plus the correlation, causation, sequence and origin data + /// that run history shows. Pure functions so the provider's behaviour is testable without a container. + /// + public static class WorkflowRunEnvelope + { + public static WorkflowRun Apply(WorkflowRun run, DomainEventDispatchedEvent envelope) + { + if (run == null) + throw new ArgumentNullException(nameof(run)); + + if (envelope == null) + return run; + + run.EventId = string.IsNullOrWhiteSpace(envelope.EventId) ? null : envelope.EventId; + run.EventSchemaVersion = envelope.SchemaVersion; + run.CorrelationId = envelope.CorrelationId; + run.CausationId = envelope.CausationId; + run.RecordSequence = envelope.Sequence; + run.OriginClient = envelope.OriginClient; + run.AggregateId = envelope.AggregateId; + return run; + } + + /// A run that was suppressed before execution: durable, visible in history, never a transition. + public static WorkflowRun MarkSkipped(WorkflowRun run, string reason, DateTime utcNow) + { + if (run == null) + throw new ArgumentNullException(nameof(run)); + + run.Status = (int)WorkflowRunStatus.Skipped; + run.SkipReason = string.IsNullOrWhiteSpace(reason) ? null : reason; + run.CompletedOn = utcNow; + run.ErrorMessage = null; + return run; + } + + /// + /// The unique index on (WorkflowId, EventId) is the dedup backstop for two dispatchers racing on the same + /// event (post-commit dispatcher and worker sweep). Without a provider-specific exception type at this + /// layer, the violation is recognised by the messages SQL Server and PostgreSQL emit. + /// + public static bool IsDuplicateKeyViolation(Exception exception) + { + for (var ex = exception; ex != null; ex = ex.InnerException) + { + var message = ex.Message ?? string.Empty; + if (message.Contains("UX_WorkflowRuns_Workflow_Event", StringComparison.OrdinalIgnoreCase) || + message.Contains("ux_workflowruns_workflow_event", StringComparison.OrdinalIgnoreCase) || + message.Contains("23505", StringComparison.Ordinal) || + message.Contains("Cannot insert duplicate key", StringComparison.OrdinalIgnoreCase) || + message.Contains("duplicate key value violates unique constraint", StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + } +} diff --git a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs index b0a76e58e..654c36149 100644 --- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs +++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs @@ -18,6 +18,9 @@ public static class WorkflowTemplateVariableCatalog new TemplateVariableDescriptor("department.use_24_hour_time", "Whether the department uses 24-hour time", "bool", true), new TemplateVariableDescriptor("department.created_on", "Department creation date", "datetime", true), new TemplateVariableDescriptor("department.phone_number", "Department phone number", "string", true), + new TemplateVariableDescriptor("department.display_name", "Department Profile display name (falls back to the department name)", "string", true), + new TemplateVariableDescriptor("department.logo_url", "Department email masthead logo URL; empty unless a logo is uploaded and 'Use department branding in emails' is on", "string", true), + new TemplateVariableDescriptor("department.website", "Department Profile website (absolute URL, empty when not set)", "string", true), new TemplateVariableDescriptor("department.address.street", "Street address", "string", true), new TemplateVariableDescriptor("department.address.city", "City", "string", true), new TemplateVariableDescriptor("department.address.state", "State/Province", "string", true), @@ -86,6 +89,70 @@ private static List GetCommon() => new List(CommonDeptVariables.Count + CommonTimestampVariables.Count + CommonUserVariables.Count) .Also(l => { l.AddRange(CommonDeptVariables); l.AddRange(CommonTimestampVariables); l.AddRange(CommonUserVariables); }); + // Records (RMS) native lifecycle triggers 100-107 (plan section 5.6). The event, record and record_change + // namespaces are the bounded snapshot the DomainEventOutbox dispatched; nothing is rehydrated from current + // record state, and no narrative or restricted-section field is exposed here. + private static readonly List RecordEventVariables = new List + { + new TemplateVariableDescriptor("event.id", "Event ID (stable across retries)", "string", false), + new TemplateVariableDescriptor("event.name", "Trigger name, e.g. RecordFinalized", "string", false), + new TemplateVariableDescriptor("event.schema_version", "Event schema version", "int", false), + new TemplateVariableDescriptor("event.occurred_on", "When the event occurred (UTC)", "datetime", false), + new TemplateVariableDescriptor("event.correlation_id", "Correlation ID (the record ID)", "string", false), + new TemplateVariableDescriptor("event.causation_id", "ID of the event that caused this one", "string", false), + new TemplateVariableDescriptor("event.sequence", "Per-record sequence number", "int", false), + new TemplateVariableDescriptor("event.is_replay", "Whether this is a replayed event", "bool", false), + new TemplateVariableDescriptor("event.origin_client", "Originating client (Web, Responder, Unit, IncidentCommand, Dispatch, Api, System)", "string", false), + }; + + private static readonly List RecordVariables = new List + { + new TemplateVariableDescriptor("record.id", "Record ID", "string", false), + new TemplateVariableDescriptor("record.record_number", "Record number (empty until finalized)", "string", false), + new TemplateVariableDescriptor("record.draft_reference", "Draft reference shown before numbering", "string", false), + new TemplateVariableDescriptor("record.definition_key", "Definition key, e.g. system.training", "string", false), + new TemplateVariableDescriptor("record.definition_version", "Definition version", "int", false), + new TemplateVariableDescriptor("record.type_key", "Record type (Run, Training, Work, Meeting, Coroner, Callback, UnitActivity)", "string", false), + new TemplateVariableDescriptor("record.state", "Lifecycle state after this event", "string", false), + new TemplateVariableDescriptor("record.lifecycle_preset", "Lifecycle preset (QuickEntry, ReviewRequired, ApprovalAcknowledgement)", "string", false), + new TemplateVariableDescriptor("record.department_id", "Department ID", "int", false), + new TemplateVariableDescriptor("record.station_group_id", "Station/group ID", "int", false), + new TemplateVariableDescriptor("record.call_id", "Linked call ID", "int", false), + new TemplateVariableDescriptor("record.external_id", "External ID", "string", false), + new TemplateVariableDescriptor("record.author_user_id", "Author user ID", "string", false), + new TemplateVariableDescriptor("record.owner_user_id", "Current owner user ID", "string", false), + new TemplateVariableDescriptor("record.started_on", "Start time", "datetime", false), + new TemplateVariableDescriptor("record.ended_on", "End time", "datetime", false), + new TemplateVariableDescriptor("record.created_on", "When the record was created", "datetime", false), + new TemplateVariableDescriptor("record.finalized_on", "When the record was finalized", "datetime", false), + new TemplateVariableDescriptor("record.revision_id", "Current revision ID", "string", false), + new TemplateVariableDescriptor("record.revision_number", "Current revision number", "int", false), + new TemplateVariableDescriptor("record.checksum", "Revision checksum (SHA-256)", "string", false), + new TemplateVariableDescriptor("record.summary", "Display summary", "string", false), + new TemplateVariableDescriptor("record.url", "Link to the record in Resgrid", "string", false), + }; + + private static readonly List RecordChangeVariables = new List + { + new TemplateVariableDescriptor("record_change.previous_state", "State before this event", "string", false), + new TemplateVariableDescriptor("record_change.current_state", "State after this event", "string", false), + new TemplateVariableDescriptor("record_change.prior_revision_id", "Prior revision ID", "string", false), + new TemplateVariableDescriptor("record_change.current_revision_id", "Current revision ID", "string", false), + new TemplateVariableDescriptor("record_change.reason_code", "Normalized reason code", "string", false), + }; + + // review.* rides only on the two review-path triggers: review bookkeeping, never record content. + private static readonly List ReviewVariables = new List + { + new TemplateVariableDescriptor("review.reviewer_user_id", "Reviewer user ID (set when the record was returned)", "string", false), + new TemplateVariableDescriptor("review.submitted_for_review_on", "When the record was submitted for review (UTC)", "datetime", false), + new TemplateVariableDescriptor("review.review_due_on", "Review due time from the department's review-due hours (UTC)", "datetime", false), + new TemplateVariableDescriptor("review.returned_on", "When the record was last returned for correction (UTC)", "datetime", false), + new TemplateVariableDescriptor("review.return_count", "How many times the record has been returned", "int", false), + new TemplateVariableDescriptor("review.reason_code", "Return reason code", "string", false), + new TemplateVariableDescriptor("review.reason_text", "Return reason text entered by the reviewer", "string", false), + }; + public static IReadOnlyList GetVariableCatalog(WorkflowTriggerEventType eventType) { var list = GetCommon(); @@ -555,6 +622,22 @@ public static IReadOnlyList GetVariableCatalog(Workf new TemplateVariableDescriptor("coverage_gap.summary", "Human-readable coverage gap summary", "string", false), }); break; + + case WorkflowTriggerEventType.RecordCreated: + case WorkflowTriggerEventType.RecordSubmittedForReview: + case WorkflowTriggerEventType.RecordReturnedForCorrection: + case WorkflowTriggerEventType.RecordFinalized: + case WorkflowTriggerEventType.RecordAmended: + case WorkflowTriggerEventType.RecordVoided: + case WorkflowTriggerEventType.RecordCancelled: + list.AddRange(RecordEventVariables); + list.AddRange(RecordVariables); + list.AddRange(RecordChangeVariables); + if (eventType == WorkflowTriggerEventType.RecordCancelled) + list.Add(new TemplateVariableDescriptor("record_change.number_disposition", "What happened to a reserved record number (none or voided)", "string", false)); + if (eventType == WorkflowTriggerEventType.RecordSubmittedForReview || eventType == WorkflowTriggerEventType.RecordReturnedForCorrection) + list.AddRange(ReviewVariables); + break; } return list.AsReadOnly(); diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs index 25b338471..085378279 100644 --- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs +++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs @@ -57,7 +57,47 @@ public enum WorkflowTriggerEventType RunCardActivated = 48, CallAlarmEscalated = 49, DispatchShortfallDetected = 50, - StationCoverageGapDetected = 51 + StationCoverageGapDetected = 51, + + // -- Records (RMS) block 100-115 -- Identifier Allocation Registry section 3.2. Values 52-99 are + // reserved by other pending plans and must not be taken here. Workflow definitions persist the + // integer, so these are append-only and never renumbered. 103 (RecordApproved) and 113-114 are + // RMS-1B, 108-111 RMS-2, 112 RMS-3; they are appended when their package lands. + // Emitted after commit through the DomainEventOutbox, never on a draft autosave. + + /// A new non-legacy Record is first persisted (once, not per autosave). + RecordCreated = 100, + + /// An author explicitly sent a Record to review. + RecordSubmittedForReview = 101, + + /// A reviewer returned a Record with a reason code. + RecordReturnedForCorrection = 102, + + /// A Record revision was finalized/attested. One-step Records emit Created then Finalized. + RecordFinalized = 104, + + /// An amendment revision was finalized, referencing the prior revision. + RecordAmended = 105, + + /// An authorized void completed with a reason code. + RecordVoided = 106, + + /// A non-finalized Record was abandoned; carries the reserved-number disposition. + RecordCancelled = 107 + } + + public static class WorkflowTriggerEventTypes + { + /// The Records (RMS) trigger block, 100-115, assigned by the Identifier Allocation Registry section 3.2. + public const int RecordsBlockFirst = 100; + public const int RecordsBlockLast = 115; + + public static bool IsRecordsTrigger(WorkflowTriggerEventType type) + { + var value = (int)type; + return value >= RecordsBlockFirst && value <= RecordsBlockLast; + } } } diff --git a/Core/Resgrid.Search/LuceneRecordsIndexHost.cs b/Core/Resgrid.Search/LuceneRecordsIndexHost.cs new file mode 100644 index 000000000..4ac04a453 --- /dev/null +++ b/Core/Resgrid.Search/LuceneRecordsIndexHost.cs @@ -0,0 +1,157 @@ +using System; +using System.IO; +using Lucene.Net.Analysis; +using Lucene.Net.Analysis.Standard; +using Lucene.Net.Index; +using Lucene.Net.Search; +using Lucene.Net.Store; +using Resgrid.Config; +using Resgrid.Framework; +using Directory = Lucene.Net.Store.Directory; + +namespace Resgrid.Search +{ + /// + /// The shared Lucene host for the records index (Unified Search plan section 3.4): one process-wide + /// instance per index. The worker process opens the single IndexWriter and reads near-real-time through it; + /// every other process opens a read-only SearcherManager over the committed segments and refreshes on demand. + /// Cross-process coordination is Lucene's own directory locking on the shared volume. + /// + public sealed class LuceneRecordsIndexHost : IDisposable + { + private readonly object _sync = new object(); + private readonly Directory _directory; + private readonly bool _ownsDirectory; + private IndexWriter _writer; + private SearcherManager _searcherManager; + private bool _searcherIsNrt; + private bool _disposed; + + public LuceneRecordsIndexHost() + : this(OpenConfiguredDirectory(), true) + { + } + + /// Test seam: host any directory (e.g. RAMDirectory) without touching the configured path. + public LuceneRecordsIndexHost(Directory directory, bool ownsDirectory = false) + { + _directory = directory ?? throw new ArgumentNullException(nameof(directory)); + _ownsDirectory = ownsDirectory; + Analyzer = new StandardAnalyzer(RecordsIndexFields.Version); + } + + public Analyzer Analyzer { get; } + + public string IndexPath => Path.Combine(SearchConfig.IndexPath ?? string.Empty, RecordsIndexFields.IndexName); + + public bool Enabled => SearchConfig.Enabled; + + public bool IndexExists + { + get + { + try { return DirectoryReader.IndexExists(_directory); } + catch (Exception ex) + { + Logging.LogException(ex, "Records search index existence check failed."); + return false; + } + } + } + + /// Opens (once) the single IndexWriter. Only the worker process should ever call this. + public IndexWriter GetWriter() + { + lock (_sync) + { + ThrowIfDisposed(); + if (_writer != null) + return _writer; + + var config = new IndexWriterConfig(RecordsIndexFields.Version, Analyzer) + { + OpenMode = OpenMode.CREATE_OR_APPEND, + RAMBufferSizeMB = Math.Max(1, SearchConfig.RamBufferSizeMb) + }; + _writer = new IndexWriter(_directory, config); + + // A reader opened before the writer existed keeps working; from here on prefer the NRT view. + if (_searcherManager != null && !_searcherIsNrt) + { + _searcherManager.Dispose(); + _searcherManager = null; + } + + return _writer; + } + } + + /// The reader for this process, or null when no index has been created yet. + public SearcherManager GetSearcherManager() + { + lock (_sync) + { + ThrowIfDisposed(); + if (_searcherManager != null) + return _searcherManager; + + if (_writer != null) + { + _searcherManager = new SearcherManager(_writer, true, null); + _searcherIsNrt = true; + return _searcherManager; + } + + if (!DirectoryReader.IndexExists(_directory)) + return null; + + _searcherManager = new SearcherManager(_directory, null); + _searcherIsNrt = false; + return _searcherManager; + } + } + + public void MaybeRefresh() + { + SearcherManager manager; + lock (_sync) + { + manager = _searcherManager; + } + + try { manager?.MaybeRefresh(); } + catch (Exception ex) { Logging.LogException(ex, "Records search reader refresh failed."); } + } + + private static Directory OpenConfiguredDirectory() + { + var path = Path.Combine(SearchConfig.IndexPath ?? string.Empty, RecordsIndexFields.IndexName); + System.IO.Directory.CreateDirectory(path); + return FSDirectory.Open(path); + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(LuceneRecordsIndexHost)); + } + + public void Dispose() + { + lock (_sync) + { + if (_disposed) + return; + _disposed = true; + + try { _searcherManager?.Dispose(); } catch (Exception ex) { Logging.LogException(ex); } + try { _writer?.Dispose(); } catch (Exception ex) { Logging.LogException(ex); } + if (_ownsDirectory) + { + try { _directory.Dispose(); } catch (Exception ex) { Logging.LogException(ex); } + } + Analyzer.Dispose(); + } + } + } +} diff --git a/Core/Resgrid.Search/LuceneRecordsIndexer.cs b/Core/Resgrid.Search/LuceneRecordsIndexer.cs new file mode 100644 index 000000000..81e4f2e28 --- /dev/null +++ b/Core/Resgrid.Search/LuceneRecordsIndexer.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Lucene.Net.Index; +using Lucene.Net.Search; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Search +{ + /// + /// Write side of the records index: upsert by document key, delete by key or by department, explicit commit. + /// Holds no state of its own; the host owns the single writer. + /// + public class LuceneRecordsIndexer : IRecordsSearchIndexer + { + private readonly LuceneRecordsIndexHost _host; + + public LuceneRecordsIndexer(LuceneRecordsIndexHost host) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + } + + public Task IndexAsync(IEnumerable documents, CancellationToken cancellationToken = default) + { + var writer = _host.GetWriter(); + var count = 0; + + foreach (var source in documents ?? Array.Empty()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (source?.Projection == null) + continue; + + var p = source.Projection; + var key = RecordsIndexFields.BuildKey(p.DepartmentId, p.SourceType, p.SourceId); + + if (p.DeletedOn.HasValue) + { + writer.DeleteDocuments(new Term(RecordsIndexFields.Key, key)); + continue; + } + + writer.UpdateDocument(new Term(RecordsIndexFields.Key, key), RecordsSearchDocumentBuilder.Build(source)); + count++; + } + + return Task.FromResult(count); + } + + public Task DeleteAsync(int departmentId, int sourceType, string sourceId, CancellationToken cancellationToken = default) + { + _host.GetWriter().DeleteDocuments(new Term(RecordsIndexFields.Key, RecordsIndexFields.BuildKey(departmentId, sourceType, sourceId))); + return Task.CompletedTask; + } + + public Task DeleteDepartmentAsync(int departmentId, CancellationToken cancellationToken = default) + { + _host.GetWriter().DeleteDocuments(new Term(RecordsIndexFields.DepartmentId, departmentId.ToString())); + return Task.CompletedTask; + } + + public Task CommitAsync(CancellationToken cancellationToken = default) + { + _host.GetWriter().Commit(); + _host.MaybeRefresh(); + return Task.CompletedTask; + } + + public Task CountDocumentsAsync(int departmentId) + { + var manager = _host.GetSearcherManager(); + if (manager == null) + return Task.FromResult(0); + + _host.MaybeRefresh(); + var searcher = manager.Acquire(); + try + { + var hits = searcher.Search(new TermQuery(new Term(RecordsIndexFields.DepartmentId, departmentId.ToString())), 1); + return Task.FromResult(hits.TotalHits); + } + finally + { + manager.Release(searcher); + } + } + } +} diff --git a/Core/Resgrid.Search/LuceneRecordsSearchService.cs b/Core/Resgrid.Search/LuceneRecordsSearchService.cs new file mode 100644 index 0000000000000000000000000000000000000000..9e626f05663d1d25ba8a51a9d3c9618990783591 GIT binary patch literal 7611 zcmcIpZF3XJ5zg23uNe4nSyyxmBq6B+3}vtZRs3>Tf~(ZkRcgFDvL;^bikV&8iqHQ& zPj}D0NS2J@I6esPY)?2A-SNld<;azIl64r{J6%nJO6;nYmiC{-HK*@soDmIG?=9wfCJIz3n5T zT{>;{pBR7UYz7^QiEU+RR^K{3Gnb`1UnM^*Gn21fOh(3C*wjGstpdCKT&G4g4CU~x z>#gX~qetq6uV*vu7BA(I6;R%0MwJs)O^tFUEnVi7C6yC3#!_bH+Ep`CR4UDN?TxZt z>HJbJi0r@cgUOz&8XKHCb7ejM(Wm9yWa`3tdunrAE#x%fe__p~aewymGB>irt9f2~ z7C;#P$b0cR)!uj&>$;kjZlKCbIC}soh0j2dXg0NTT~ymx_GNxyNp)f-#+f2DYe`0e!oh|k-sn8P;{Z{b#9hn$NIM4= z2?x;)Vq&xE!#&GM1Of3v%rH=sywuf8Hpc6+^gzoF9CfKSl7c^*>X$X9=-cX7FnT&+ zmDiCjTkQ+P!G1*bx6Ld&GZNunHxqUSCBi4030u<&-7N#RpjrYcqvt(rss1y?ued}Y zy+pSJ=6LB<$AUO)H#TQ}5SbR$t5=93S6;HacHLP`DeI`t^Zl#T%&Az!lpf1T%sjTA zllTk|k>#{3b2aq47ux3f6uD>Tr3jqt7xbK|AASf6EYALw1w8uNQ2lrjU?9pM3)eZ| zBu~43QKYd&BJFbO5Se6Ui{yxqD+cO~Mi}KeAYB&Y^4t`ZoK{wLz;U8+RBM_h)$FuM z2lgQ#uRAPeV?-C4(h*4vo35dyH$)12HBsA%qdDM ziipXQhoZ&zvI9AwRT1t2(0c-ZCu842T7oXByc|yYpgfVPX0l*UG54WfoEnq?NHT3e zLjaRvyrW*%sGA?CGRMo&&;p?)Ul;((AR3Tr+gXt4jc8FVpWU|Z~UEw&{d z8Mg0q)&sW~oiejc@DLo-2v6E1yIIC&TuRz_BTNJDXjIlNHDomK$0@iIRqO2C(QR`d z0!zu#kSz4Yjl-T^06&- zuk?gMAIUk%agp1CAlWPP&AWZ)O3HQ*StYKNDZ-6nb%7iALsoBNSI{a#!7k2>)>j=O z;P~Z)c1MDZf;~3rKxrrl71Tq>a*O$fE|pNFftZX7PH?}%Wey<%x{#I_s!rH} zg90?%$hf#>qp5zz_hECT(<<}}bT!B2nC2V`S=}f7`*9R5$l+?sO+~-I_}Y&9K35L{ z6_-bNo?;u9MP!_yy;3dRmvA_u4rN-3j_V5z5<$Z|N}#;XrZO zOb$Pe#!8UU>&TmCWmu$notXo3rqhKa1(vq0DEE_T=!F9s-Nz4?&kkvXQc!c~2%`mz zKORU*pzG4|8_c{YR9-G!3phGu<84cZ?k8HV#Y9boc!m2O?qjw{aYYe@Bf^8}is=1R zW+gD)p}lJjKYD-s@nBC1Nz@rt4(oeINF*#cQ-8Tp+cU@xmTr3gGK-)_cLd?=eo8Fy z=~~#OrCy+#z8XbiLOAdE2T z#0iYQTA+}*-qF}|h{0KaK3hpL0EVvomKquKXYoAO1u_}Woz&ChpUIPUZb{?R<(cKP z)iLSu$;Kfm=7#u3(Im4`h14_-LKbjy3=)Keb#h>e zGqfd5AU9=YtVQ3#|ClDme;oUhFp2)-PQdqtX2LCpV9qRV z*kGp6W#D2#gJr(G@K_$c@8!%DoykZ?qdqqXhJnoX5wGjYSL<1ZEn!Q% zi_a)EM*g8x>@*{!=$o96B#O%Qr=l!OuX-&X-Q_YC;CCi)x|2)Zmhh**3SSHmkRQ4a zMz3)zN%r|$LR&v+bCZ?3xRlQVq0KnN2+s2LxyNf=H}MJIq)YIHf53CnWEWSZ z#s6q{Pp@RiEooguj!-3Nlz2Qhn4@!--u#*D9_@vj<8Gdpmk07e?dR|X1Tu1~&n}hG zdla#p{PjdsTIsVZ$bfy}+Xujo%UUp4D6xX)(D^b!JAzvhJ8-&xk?dbh_o>(H>H%3M zcmn7>DDll|SiqMS-5apvf^_lF)2Z1I>N3cdx_#b5Hi55hwr;lm51IC#!~g&Q literal 0 HcmV?d00001 diff --git a/Core/Resgrid.Search/RecordsIndexFields.cs b/Core/Resgrid.Search/RecordsIndexFields.cs new file mode 100644 index 000000000..eb33d4bb6 --- /dev/null +++ b/Core/Resgrid.Search/RecordsIndexFields.cs @@ -0,0 +1,45 @@ +using Lucene.Net.Util; + +namespace Resgrid.Search +{ + /// Field names of the records index (RecordsSearchGeneration.SchemaVersion governs changes here). + public static class RecordsIndexFields + { + public const LuceneVersion Version = LuceneVersion.LUCENE_48; + public const string IndexName = "records"; + + public const string Key = "Key"; + public const string DepartmentId = "DepartmentId"; + public const string SourceType = "SourceType"; + public const string SourceId = "SourceId"; + public const string RecordNumber = "RecordNumber"; + public const string RecordNumberExact = "RecordNumberExact"; + public const string DraftReference = "DraftReference"; + public const string DefinitionKey = "DefinitionKey"; + public const string DefinitionVersion = "DefinitionVersion"; + public const string RecordType = "RecordType"; + public const string State = "State"; + public const string Year = "Year"; + public const string OccurredOn = "OccurredOn"; + public const string OccurredOnSort = "OccurredOnSort"; + public const string StationGroupId = "StationGroupId"; + public const string CallId = "CallId"; + public const string CallNumber = "CallNumber"; + public const string AuthorUserId = "AuthorUserId"; + public const string OwnerUserId = "OwnerUserId"; + public const string ReviewerUserId = "ReviewerUserId"; + public const string ParticipantUserIds = "ParticipantUserIds"; + public const string UnitIds = "UnitIds"; + public const string GroupScopeIds = "GroupScopeIds"; + public const string Summary = "Summary"; + public const string SearchText = "SearchText"; + public const string Narrative = "Narrative"; + public const string IsLegacy = "IsLegacy"; + public const string Generation = "Generation"; + + public static string BuildKey(int departmentId, int sourceType, string sourceId) + { + return departmentId + "|" + sourceType + "|" + sourceId; + } + } +} diff --git a/Core/Resgrid.Search/RecordsSearchDocumentBuilder.cs b/Core/Resgrid.Search/RecordsSearchDocumentBuilder.cs new file mode 100644 index 000000000..3a4399dfc --- /dev/null +++ b/Core/Resgrid.Search/RecordsSearchDocumentBuilder.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using Lucene.Net.Documents; +using Resgrid.Model; +using Document = Lucene.Net.Documents.Document; + +namespace Resgrid.Search +{ + /// + /// RmsRecordSearchProjection to Lucene Document (RMS plan section 5.10 index contract). The projection is + /// already the safe field set; narrative is added only when the caller was allowed to supply it. Nothing from + /// the restricted sections, attachments, submission payloads or protected-candidate fields ever lands here. + /// + public static class RecordsSearchDocumentBuilder + { + public static Document Build(RecordsSearchDocumentSource source) + { + if (source?.Projection == null) + throw new ArgumentNullException(nameof(source)); + + var p = source.Projection; + var doc = new Document + { + new StringField(RecordsIndexFields.Key, RecordsIndexFields.BuildKey(p.DepartmentId, p.SourceType, p.SourceId), Field.Store.YES), + new StringField(RecordsIndexFields.DepartmentId, p.DepartmentId.ToString(), Field.Store.YES), + new StringField(RecordsIndexFields.SourceType, p.SourceType.ToString(), Field.Store.YES), + new StringField(RecordsIndexFields.SourceId, p.SourceId ?? string.Empty, Field.Store.YES), + new StringField(RecordsIndexFields.DefinitionKey, p.DefinitionKey ?? string.Empty, Field.Store.YES), + new StringField(RecordsIndexFields.DefinitionVersion, p.DefinitionVersion.ToString(), Field.Store.YES), + new StringField(RecordsIndexFields.RecordType, p.RecordType.HasValue ? p.RecordType.Value.ToString() : string.Empty, Field.Store.YES), + new StringField(RecordsIndexFields.State, p.State.ToString(), Field.Store.YES), + new StringField(RecordsIndexFields.IsLegacy, p.IsLegacy ? "1" : "0", Field.Store.YES), + new StringField(RecordsIndexFields.Generation, source.Generation ?? string.Empty, Field.Store.YES), + new StringField(RecordsIndexFields.AuthorUserId, p.AuthorUserId ?? string.Empty, Field.Store.YES), + new StringField(RecordsIndexFields.OwnerUserId, p.OwnerUserId ?? string.Empty, Field.Store.YES), + new StringField(RecordsIndexFields.ReviewerUserId, p.ReviewerUserId ?? string.Empty, Field.Store.YES) + }; + + var occurred = p.OccurredOn ?? p.RecordCreatedOn; + doc.Add(new StringField(RecordsIndexFields.Year, occurred.Year.ToString(), Field.Store.YES)); + doc.Add(new Int64Field(RecordsIndexFields.OccurredOn, occurred.Ticks, Field.Store.YES)); + doc.Add(new NumericDocValuesField(RecordsIndexFields.OccurredOnSort, occurred.Ticks)); + + if (!string.IsNullOrWhiteSpace(p.RecordNumber)) + { + doc.Add(new TextField(RecordsIndexFields.RecordNumber, p.RecordNumber, Field.Store.YES)); + doc.Add(new StringField(RecordsIndexFields.RecordNumberExact, p.RecordNumber.Trim().ToLowerInvariant(), Field.Store.NO)); + } + if (!string.IsNullOrWhiteSpace(p.DraftReference)) + doc.Add(new TextField(RecordsIndexFields.DraftReference, p.DraftReference, Field.Store.YES)); + if (p.StationGroupId.HasValue) + doc.Add(new StringField(RecordsIndexFields.StationGroupId, p.StationGroupId.Value.ToString(), Field.Store.YES)); + if (p.CallId.HasValue) + doc.Add(new StringField(RecordsIndexFields.CallId, p.CallId.Value.ToString(), Field.Store.YES)); + if (!string.IsNullOrWhiteSpace(p.CallNumber)) + doc.Add(new TextField(RecordsIndexFields.CallNumber, p.CallNumber, Field.Store.YES)); + if (!string.IsNullOrWhiteSpace(p.DisplaySummary)) + doc.Add(new TextField(RecordsIndexFields.Summary, p.DisplaySummary, Field.Store.YES)); + if (!string.IsNullOrWhiteSpace(p.SearchText)) + doc.Add(new TextField(RecordsIndexFields.SearchText, p.SearchText, Field.Store.NO)); + if (!string.IsNullOrWhiteSpace(source.Narrative)) + doc.Add(new TextField(RecordsIndexFields.Narrative, source.Narrative, Field.Store.NO)); + + foreach (var id in SplitCsv(p.ParticipantUserIds)) + doc.Add(new StringField(RecordsIndexFields.ParticipantUserIds, id, Field.Store.NO)); + foreach (var id in SplitCsv(p.UnitIds)) + doc.Add(new StringField(RecordsIndexFields.UnitIds, id, Field.Store.NO)); + foreach (var id in SplitCsv(p.GroupScopeIds)) + doc.Add(new StringField(RecordsIndexFields.GroupScopeIds, id, Field.Store.NO)); + + return doc; + } + + public static IEnumerable SplitCsv(string csv) + { + if (string.IsNullOrWhiteSpace(csv)) + yield break; + + foreach (var part in csv.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) + { + var trimmed = part.Trim(); + if (trimmed.Length > 0) + yield return trimmed; + } + } + } +} diff --git a/Core/Resgrid.Search/Resgrid.Search.csproj b/Core/Resgrid.Search/Resgrid.Search.csproj new file mode 100644 index 000000000..210bd21a0 --- /dev/null +++ b/Core/Resgrid.Search/Resgrid.Search.csproj @@ -0,0 +1,23 @@ + + + + net9.0 + latest + disable + Shared Lucene.NET search host (Unified Search Phase 1) and the RMS-owned records index. + + + + + + + + + + + + + + + + diff --git a/Core/Resgrid.Search/SearchModule.cs b/Core/Resgrid.Search/SearchModule.cs new file mode 100644 index 000000000..dd1f41285 --- /dev/null +++ b/Core/Resgrid.Search/SearchModule.cs @@ -0,0 +1,20 @@ +using Autofac; +using Resgrid.Model.Services; + +namespace Resgrid.Search +{ + /// + /// Registers the shared Lucene host (one per process) and the records index services. Reader and writer + /// share the host; which side a process uses is decided by who calls it, so the same module serves Web, + /// API and Worker. + /// + public class SearchModule : Module + { + protected override void Load(ContainerBuilder builder) + { + builder.RegisterType().AsSelf().SingleInstance(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + } + } +} diff --git a/Core/Resgrid.Services/DepartmentLogoRenditions.cs b/Core/Resgrid.Services/DepartmentLogoRenditions.cs new file mode 100644 index 000000000..9cbdc2fc8 --- /dev/null +++ b/Core/Resgrid.Services/DepartmentLogoRenditions.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.IO; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Processing; +using Resgrid.Model; + +namespace Resgrid.Services +{ + /// Thrown when a logo upload fails validation; the message is safe to show to the administrator. + public class DepartmentLogoRejectedException : ArgumentException + { + public DepartmentLogoRejectedException(string message) : base(message) { } + } + + public sealed class LogoRendition + { + public DepartmentProfileMediaKind Kind { get; set; } + public byte[] Data { get; set; } + public string ContentType { get; set; } + public int Width { get; set; } + public int Height { get; set; } + } + + /// + /// Server-side logo handling (RMS plan section 4.10.1): PNG or JPEG only (SVG refused), at most 2 MB, at least + /// 200 px on the long edge. Every upload is re-encoded with its metadata stripped, PNG transparency preserved, + /// and the PrintHeader (1200x400 max), EmailMasthead (188 px wide) and Thumbnail (128x128) renditions generated. + /// + public static class DepartmentLogoRenditions + { + public const int MaxBytes = 2 * 1024 * 1024; + public const int MinimumLongEdge = 200; + public const int RecommendedLongEdge = 512; + public const int PrintHeaderMaxWidth = 1200; + public const int PrintHeaderMaxHeight = 400; + public const int EmailMastheadWidth = 188; + public const int ThumbnailSize = 128; + + public static List Build(byte[] data) + { + if (data == null || data.Length == 0) + throw new DepartmentLogoRejectedException("A logo file is required."); + if (data.Length > MaxBytes) + throw new DepartmentLogoRejectedException($"The logo must be {MaxBytes / (1024 * 1024)} MB or smaller."); + + Image image; + try + { + using var input = new MemoryStream(data, writable: false); + image = Image.Load(new DecoderOptions { MaxFrames = 1 }, input); + } + catch (Exception ex) + { + throw new DepartmentLogoRejectedException($"The logo could not be read as a PNG or JPEG image ({ex.GetType().Name})."); + } + + using (image) + { + var format = image.Metadata.DecodedImageFormat; + var isPng = format != null && string.Equals(format.Name, "PNG", StringComparison.OrdinalIgnoreCase); + var isJpeg = format != null && string.Equals(format.Name, "JPEG", StringComparison.OrdinalIgnoreCase); + if (!isPng && !isJpeg) + throw new DepartmentLogoRejectedException("Only PNG or JPEG logos are accepted."); + + if (Math.Max(image.Width, image.Height) < MinimumLongEdge) + throw new DepartmentLogoRejectedException($"The logo must be at least {MinimumLongEdge} px on its long edge ({RecommendedLongEdge} px or larger is recommended)."); + + Strip(image); + var contentType = isPng ? "image/png" : "image/jpeg"; + + return new List + { + Encode(image, DepartmentProfileMediaKind.PrimaryLogo, isPng, contentType), + Encode(Fit(image, PrintHeaderMaxWidth, PrintHeaderMaxHeight), DepartmentProfileMediaKind.PrintHeader, isPng, contentType, dispose: true), + Encode(Fit(image, EmailMastheadWidth, 0), DepartmentProfileMediaKind.EmailMasthead, isPng, contentType, dispose: true), + Encode(Fit(image, ThumbnailSize, ThumbnailSize), DepartmentProfileMediaKind.Thumbnail, isPng, contentType, dispose: true) + }; + } + } + + private static void Strip(Image image) + { + image.Metadata.ExifProfile = null; + image.Metadata.XmpProfile = null; + image.Metadata.IptcProfile = null; + image.Metadata.GetPngMetadata().TextData.Clear(); + } + + /// Fits inside the box without upscaling; a zero height means "width only". + private static Image Fit(Image source, int maxWidth, int maxHeight) + { + var fitsWidth = source.Width <= maxWidth; + var fitsHeight = maxHeight <= 0 || source.Height <= maxHeight; + if (fitsWidth && fitsHeight) + return source.Clone(ctx => { }); + + return source.Clone(ctx => ctx.Resize(new ResizeOptions + { + Size = new Size(maxWidth, maxHeight), + Mode = ResizeMode.Max, + Sampler = KnownResamplers.Lanczos3 + })); + } + + private static LogoRendition Encode(Image image, DepartmentProfileMediaKind kind, bool asPng, string contentType, bool dispose = false) + { + try + { + using var output = new MemoryStream(); + IImageEncoder encoder = asPng ? new PngEncoder { ColorType = PngColorType.RgbWithAlpha } : new JpegEncoder { Quality = 90 }; + image.Save(output, encoder); + return new LogoRendition { Kind = kind, Data = output.ToArray(), ContentType = contentType, Width = image.Width, Height = image.Height }; + } + finally + { + if (dispose) + image.Dispose(); + } + } + } +} diff --git a/Core/Resgrid.Services/DepartmentProfileMediaService.cs b/Core/Resgrid.Services/DepartmentProfileMediaService.cs new file mode 100644 index 000000000..e637fe479 --- /dev/null +++ b/Core/Resgrid.Services/DepartmentProfileMediaService.cs @@ -0,0 +1,308 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + /// Department Profile elevation (RMS plan section 4.10.1). The profile row is the identity source; logo bytes and + /// renditions live in DepartmentProfileMedia under one per-department MediaKey. A logo migrated from the legacy + /// DepartmentProfile.Logo column arrives as a PrimaryLogo row without renditions; they are generated on first + /// read, and an undecodable legacy blob is simply ignored rather than served. + /// + public class DepartmentProfileMediaService : IDepartmentProfileMediaService + { + private readonly IDepartmentProfileRepository _profiles; + private readonly IDepartmentProfileMediaRepository _media; + private readonly IDepartmentsService _departments; + private readonly IUnitOfWork _unitOfWork; + private readonly ICacheProvider _cacheProvider; + + private static string EmailBrandingCacheKey = "DepartmentEmailBranding_{0}"; + private static TimeSpan EmailBrandingCacheLength = TimeSpan.FromDays(1); + + public DepartmentProfileMediaService(IDepartmentProfileRepository profiles, IDepartmentProfileMediaRepository media, IDepartmentsService departments, IUnitOfWork unitOfWork, + ICacheProvider cacheProvider) + { + _profiles = profiles; + _media = media; + _departments = departments; + _unitOfWork = unitOfWork; + _cacheProvider = cacheProvider; + } + + public async Task GetOrCreateProfileAsync(int departmentId, CancellationToken cancellationToken = default) + { + var existing = await _profiles.GetByDepartmentIdAsync(departmentId); + if (existing != null) + return existing; + + var department = await _departments.GetDepartmentByIdAsync(departmentId, false); + var profile = new DepartmentProfile + { + DepartmentId = departmentId, + Name = department?.Name, + Code = department?.Code, + AddressId = department?.AddressId, + Disabled = false + }; + + return await _profiles.SaveOrUpdateAsync(profile, cancellationToken, true); + } + + public Task SaveProfileAsync(DepartmentProfile profile, CancellationToken cancellationToken = default) + { + if (profile == null) + throw new ArgumentNullException(nameof(profile)); + + // The legacy Logo column is never written again; the media table owns the bytes now. + profile.Logo = null; + return SaveProfileAndInvalidateAsync(profile, cancellationToken); + } + + private async Task SaveProfileAndInvalidateAsync(DepartmentProfile profile, CancellationToken cancellationToken) + { + var saved = await _profiles.SaveOrUpdateAsync(profile, cancellationToken, true); + await InvalidateEmailBrandingAsync(profile.DepartmentId); + return saved; + } + + public async Task GetBrandingAsync(int departmentId) + { + var department = await _departments.GetDepartmentByIdAsync(departmentId, false); + var profile = await _profiles.GetByDepartmentIdAsync(departmentId); + var media = (await _media.GetMetadataForDepartmentAsync(departmentId))?.Where(m => m != null).ToList() ?? new List(); + + media = await EnsureRenditionsAsync(departmentId, media); + + var name = !string.IsNullOrWhiteSpace(profile?.Name) ? profile.Name : department?.Name; + return new DepartmentBranding + { + DepartmentId = departmentId, + Profile = profile, + DisplayName = name, + ShortName = !string.IsNullOrWhiteSpace(profile?.ShortName) ? profile.ShortName : name, + Code = !string.IsNullOrWhiteSpace(profile?.Code) ? profile.Code : department?.Code, + AddressText = department?.Address?.FormatAddress(), + PhoneNumber = profile?.PhoneNumber, + Website = profile?.Website, + UseDepartmentBrandingInEmails = profile?.UseDepartmentBrandingInEmails ?? false, + MediaKey = media.FirstOrDefault()?.MediaKey, + Media = media + }; + } + + public async Task UploadLogoAsync(int departmentId, string userId, string fileName, string contentType, byte[] data, CancellationToken cancellationToken = default) + { + var renditions = DepartmentLogoRenditions.Build(data); + var existing = (await _media.GetMetadataForDepartmentAsync(departmentId))?.FirstOrDefault(m => m != null); + var mediaKey = existing?.MediaKey ?? NewMediaKey(); + + await InTransactionAsync(async () => + { + await _media.DeleteForDepartmentAsync(departmentId, cancellationToken); + foreach (var rendition in renditions) + await _media.InsertAsync(ToRow(departmentId, userId, mediaKey, rendition), cancellationToken, true); + }); + + // Plan section 4.10.1: email branding "defaults on when a logo exists". Only the first logo flips it, + // so an admin who turned it off keeps that choice when they replace the artwork. + if (existing == null) + { + var profile = await GetOrCreateProfileAsync(departmentId, cancellationToken); + if (!profile.UseDepartmentBrandingInEmails) + { + profile.UseDepartmentBrandingInEmails = true; + await SaveProfileAsync(profile, cancellationToken); + } + } + + await InvalidateEmailBrandingAsync(departmentId); + return await GetBrandingAsync(departmentId); + } + + public async Task RemoveLogoAsync(int departmentId, string userId, CancellationToken cancellationToken = default) + { + var removed = await _media.DeleteForDepartmentAsync(departmentId, cancellationToken); + await InvalidateEmailBrandingAsync(departmentId); + return removed > 0; + } + + public async Task RegenerateMediaKeyAsync(int departmentId, string userId, CancellationToken cancellationToken = default) + { + var key = NewMediaKey(); + await _media.UpdateMediaKeyAsync(departmentId, key, cancellationToken); + await InvalidateEmailBrandingAsync(departmentId); + return key; + } + + public async Task GetEmailBrandingAsync(int departmentId) + { + async Task build() + { + var branding = await GetBrandingAsync(departmentId); + var result = new DepartmentEmailBranding + { + DepartmentId = departmentId, + DisplayName = branding.DisplayName, + Website = DepartmentEmailBranding.NormalizeWebsite(branding.Website) + }; + + // Both conditions from the plan: a logo uploaded (its masthead rendition exists) and the opt-in. + var masthead = branding.Rendition(DepartmentProfileMediaKind.EmailMasthead); + if (branding.UseDepartmentBrandingInEmails && masthead != null && !string.IsNullOrWhiteSpace(masthead.MediaKey)) + { + result.Enabled = true; + result.LogoUrl = PublicMastheadUrl(masthead.MediaKey); + } + + return result; + } + + try + { + if (Config.SystemBehaviorConfig.CacheEnabled) + return await _cacheProvider.RetrieveAsync(string.Format(EmailBrandingCacheKey, departmentId), build, EmailBrandingCacheLength); + + return await build(); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Email branding lookup failed for department {departmentId}; sending with Resgrid chrome."); + return DepartmentEmailBranding.Disabled(departmentId); + } + } + + /// The anonymous EmailMasthead URL that goes into emails; the key is the only credential. + public static string PublicMastheadUrl(string mediaKey) + { + return $"{Config.SystemBehaviorConfig.ResgridBaseUrl}/User/Department/PublicMasthead?key={mediaKey}"; + } + + private async Task InvalidateEmailBrandingAsync(int departmentId) + { + if (!Config.SystemBehaviorConfig.CacheEnabled) + return; + + try + { + await _cacheProvider.RemoveAsync(string.Format(EmailBrandingCacheKey, departmentId)); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Could not invalidate cached email branding for department {departmentId}."); + } + } + + public Task GetMediaAsync(int departmentId, DepartmentProfileMediaKind kind) + { + return _media.GetAsync(departmentId, (int)kind); + } + + public Task GetPublicMastheadAsync(string mediaKey) + { + if (string.IsNullOrWhiteSpace(mediaKey) || mediaKey.Length < 16) + return Task.FromResult(null); + + return _media.GetByMediaKeyAsync(mediaKey.Trim(), (int)DepartmentProfileMediaKind.EmailMasthead); + } + + /// A legacy PrimaryLogo without renditions (migration M0172) gets them generated once; undecodable bytes stay unserved. + private async Task> EnsureRenditionsAsync(int departmentId, List media) + { + var primary = media.FirstOrDefault(m => m.Kind == (int)DepartmentProfileMediaKind.PrimaryLogo); + if (primary == null || media.Any(m => m.Kind == (int)DepartmentProfileMediaKind.PrintHeader)) + return media; + + try + { + var full = await _media.GetAsync(departmentId, (int)DepartmentProfileMediaKind.PrimaryLogo); + if (full?.Data == null || full.Data.Length == 0) + return media; + + var renditions = DepartmentLogoRenditions.Build(full.Data); + var mediaKey = full.MediaKey ?? NewMediaKey(); + await InTransactionAsync(async () => + { + await _media.DeleteForDepartmentAsync(departmentId, CancellationToken.None); + foreach (var rendition in renditions) + await _media.InsertAsync(ToRow(departmentId, full.UploadedByUserId, mediaKey, rendition), CancellationToken.None, true); + }); + + return (await _media.GetMetadataForDepartmentAsync(departmentId))?.Where(m => m != null).ToList() ?? new List(); + } + catch (DepartmentLogoRejectedException ex) + { + Logging.LogError($"Legacy department logo for department {departmentId} could not be converted and is not served: {ex.Message}"); + return media.Where(m => m.Kind != (int)DepartmentProfileMediaKind.PrimaryLogo).ToList(); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Rendition generation failed for department {departmentId}."); + return media; + } + } + + private static DepartmentProfileMedia ToRow(int departmentId, string userId, string mediaKey, LogoRendition rendition) + { + var now = DateTime.UtcNow; + return new DepartmentProfileMedia + { + DepartmentProfileMediaId = Guid.NewGuid().ToString(), + DepartmentId = departmentId, + ProtectionId = Guid.NewGuid().ToString(), + Kind = (int)rendition.Kind, + ContentType = rendition.ContentType, + Width = rendition.Width, + Height = rendition.Height, + ByteSize = rendition.Data.LongLength, + Checksum = Checksum(rendition.Data), + Data = rendition.Data, + UploadedByUserId = userId, + UploadedOn = now, + MediaKey = mediaKey, + CreatedOn = now, + ModifiedOn = now, + RowVersion = 1 + }; + } + + public static string NewMediaKey() + { + var bytes = new byte[24]; + using (var rng = RandomNumberGenerator.Create()) + rng.GetBytes(bytes); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + private static string Checksum(byte[] data) + { + using var sha = SHA256.Create(); + return Convert.ToHexString(sha.ComputeHash(data)).ToLowerInvariant(); + } + + private async Task InTransactionAsync(Func work) + { + _unitOfWork.CreateOrGetConnection(); + try + { + await work(); + _unitOfWork.CommitChanges(); + } + catch + { + _unitOfWork.DiscardChanges(); + throw; + } + } + } +} diff --git a/Core/Resgrid.Services/DepartmentSettingsService.cs b/Core/Resgrid.Services/DepartmentSettingsService.cs index fa96d2c4d..f4fa2503a 100644 --- a/Core/Resgrid.Services/DepartmentSettingsService.cs +++ b/Core/Resgrid.Services/DepartmentSettingsService.cs @@ -1415,5 +1415,126 @@ private static string GetDefaultTtsLanguage() return EspeakVoiceCatalog.DefaultIdentifier; } + + #region Records (RMS) department settings 70-77 - RMS plan section 4.9 + + private const string RecordsSettingCacheKey = "DSetRecords_{0}_{1}"; + public const int DefaultRecordsReviewDueHours = 72; + + private async Task GetRecordsSettingStringAsync(int departmentId, DepartmentSettingTypes type, bool bypassCache) + { + async Task getSetting() + { + var s = await GetSettingByDepartmentIdType(departmentId, type); + return s?.Setting ?? string.Empty; + } + + if (Config.SystemBehaviorConfig.CacheEnabled && !bypassCache) + return await _cacheProvider.RetrieveAsync(string.Format(RecordsSettingCacheKey, (int)type, departmentId), getSetting, LongCacheLength); + + return await getSetting(); + } + + private async Task GetRecordsSettingObjectAsync(int departmentId, DepartmentSettingTypes type, bool bypassCache) where T : class, new() + { + var value = await GetRecordsSettingStringAsync(departmentId, type, bypassCache); + if (!String.IsNullOrWhiteSpace(value)) + { + try + { + var config = ObjectSerialization.Deserialize(value); + if (config != null) + return config; + } + catch (Exception) + { + // A corrupt setting blob falls back to defaults rather than breaking Records. + } + } + + return new T(); + } + + private async Task SetRecordsSettingAsync(int departmentId, DepartmentSettingTypes type, string value, CancellationToken cancellationToken) + { + var result = await SaveOrUpdateSettingAsync(departmentId, value, type, cancellationToken); + _cacheProvider.Remove(string.Format(RecordsSettingCacheKey, (int)type, departmentId)); + return result; + } + + public async Task GetRecordsDefaultLifecyclePresetAsync(int departmentId, bool bypassCache = false) + { + var value = await GetRecordsSettingStringAsync(departmentId, DepartmentSettingTypes.RecordsDefaultLifecyclePreset, bypassCache); + return int.TryParse(value, out var preset) && Enum.IsDefined(typeof(RmsLifecyclePreset), preset) ? (RmsLifecyclePreset)preset : RmsLifecyclePreset.QuickEntry; + } + + public Task SetRecordsDefaultLifecyclePresetAsync(int departmentId, RmsLifecyclePreset preset, CancellationToken cancellationToken = default(CancellationToken)) + { + return SetRecordsSettingAsync(departmentId, DepartmentSettingTypes.RecordsDefaultLifecyclePreset, ((int)preset).ToString(), cancellationToken); + } + + public async Task GetRecordsReviewDueHoursAsync(int departmentId, bool bypassCache = false) + { + var value = await GetRecordsSettingStringAsync(departmentId, DepartmentSettingTypes.RecordsReviewDueHours, bypassCache); + return int.TryParse(value, out var hours) && hours > 0 ? Math.Min(hours, 24 * 365) : DefaultRecordsReviewDueHours; + } + + public Task SetRecordsReviewDueHoursAsync(int departmentId, int hours, CancellationToken cancellationToken = default(CancellationToken)) + { + return SetRecordsSettingAsync(departmentId, DepartmentSettingTypes.RecordsReviewDueHours, Math.Max(1, hours).ToString(), cancellationToken); + } + + public Task GetRecordsNumberingConfigAsync(int departmentId, bool bypassCache = false) + { + return GetRecordsSettingObjectAsync(departmentId, DepartmentSettingTypes.RecordsNumberingConfig, bypassCache); + } + + public Task SetRecordsNumberingConfigAsync(int departmentId, RecordsNumberingConfig config, CancellationToken cancellationToken = default(CancellationToken)) + { + return SetRecordsSettingAsync(departmentId, DepartmentSettingTypes.RecordsNumberingConfig, ObjectSerialization.Serialize(config ?? new RecordsNumberingConfig()), cancellationToken); + } + + public Task GetRecordsSearchConfigAsync(int departmentId, bool bypassCache = false) + { + return GetRecordsSettingObjectAsync(departmentId, DepartmentSettingTypes.RecordsSearchConfig, bypassCache); + } + + public Task SetRecordsSearchConfigAsync(int departmentId, RecordsSearchConfig config, CancellationToken cancellationToken = default(CancellationToken)) + { + return SetRecordsSettingAsync(departmentId, DepartmentSettingTypes.RecordsSearchConfig, ObjectSerialization.Serialize(config ?? new RecordsSearchConfig()), cancellationToken); + } + + public Task GetRecordsRetentionPolicyAsync(int departmentId, bool bypassCache = false) + { + return GetRecordsSettingObjectAsync(departmentId, DepartmentSettingTypes.RecordsRetentionPolicy, bypassCache); + } + + public Task SetRecordsRetentionPolicyAsync(int departmentId, RecordsRetentionPolicy policy, CancellationToken cancellationToken = default(CancellationToken)) + { + return SetRecordsSettingAsync(departmentId, DepartmentSettingTypes.RecordsRetentionPolicy, ObjectSerialization.Serialize(policy ?? new RecordsRetentionPolicy()), cancellationToken); + } + + public async Task GetRecordsGroupVisibilityModeAsync(int departmentId, bool bypassCache = false) + { + var value = await GetRecordsSettingStringAsync(departmentId, DepartmentSettingTypes.RecordsGroupVisibilityMode, bypassCache); + return int.TryParse(value, out var mode) && mode == (int)RecordsGroupVisibilityMode.GroupScoped ? RecordsGroupVisibilityMode.GroupScoped : RecordsGroupVisibilityMode.DepartmentWide; + } + + public Task SetRecordsGroupVisibilityModeAsync(int departmentId, RecordsGroupVisibilityMode mode, CancellationToken cancellationToken = default(CancellationToken)) + { + return SetRecordsSettingAsync(departmentId, DepartmentSettingTypes.RecordsGroupVisibilityMode, ((int)mode).ToString(), cancellationToken); + } + + public Task GetRecordsDisclosureConfigAsync(int departmentId, bool bypassCache = false) + { + return GetRecordsSettingObjectAsync(departmentId, DepartmentSettingTypes.RecordsDisclosureConfig, bypassCache); + } + + public Task SetRecordsDisclosureConfigAsync(int departmentId, RecordsDisclosureConfig config, CancellationToken cancellationToken = default(CancellationToken)) + { + return SetRecordsSettingAsync(departmentId, DepartmentSettingTypes.RecordsDisclosureConfig, ObjectSerialization.Serialize(config ?? new RecordsDisclosureConfig()), cancellationToken); + } + + #endregion Records (RMS) department settings } } diff --git a/Core/Resgrid.Services/EmailService.cs b/Core/Resgrid.Services/EmailService.cs index d1659c619..5bb5e01c6 100644 --- a/Core/Resgrid.Services/EmailService.cs +++ b/Core/Resgrid.Services/EmailService.cs @@ -31,11 +31,13 @@ public class EmailService : IEmailService private readonly ICallEmailProvider _callEmailProvider; private readonly IEmailSender _emailSender; private readonly IAmazonEmailSender _amazonEmailSender; + private readonly IDepartmentProfileMediaService _departmentProfileMediaService; private SmtpClient _smtpClient; public EmailService(IUserProfileService userProfileService, IUsersService usersService, IGeoLocationProvider geoLocationProvider, IEmailProvider emailProvider, - IDepartmentsService departmentsService, ICallEmailProvider callEmailProvider, IEmailSender emailSender, IAmazonEmailSender amazonEmailSender) + IDepartmentsService departmentsService, ICallEmailProvider callEmailProvider, IEmailSender emailSender, IAmazonEmailSender amazonEmailSender, + IDepartmentProfileMediaService departmentProfileMediaService) { _userProfileService = userProfileService; _usersService = usersService; @@ -45,6 +47,7 @@ public EmailService(IUserProfileService userProfileService, IUsersService usersS _callEmailProvider = callEmailProvider; _emailSender = emailSender; _amazonEmailSender = amazonEmailSender; + _departmentProfileMediaService = departmentProfileMediaService; _smtpClient = new SmtpClient { @@ -62,6 +65,37 @@ public EmailService(IUserProfileService userProfileService, IUsersService usersS } #endregion Private Members and Constructors + /// + /// Department masthead for a department-scoped operational email (RMS plan section 4.10.1). A lookup + /// failure means Resgrid chrome, never a lost email: a degraded dispatch email is bad, an undelivered + /// one is dangerous. + /// + private async Task GetEmailBrandingAsync(int departmentId) + { + try + { + return await _departmentProfileMediaService.GetEmailBrandingAsync(departmentId) ?? DepartmentEmailBranding.Disabled(departmentId); + } + catch (Exception ex) + { + Logging.LogException(ex); + return DepartmentEmailBranding.Disabled(departmentId); + } + } + + /// + /// The plain-text notification email has no masthead to swap, so the department identity rides on the + /// From display name instead ("Springfield Fire via Resgrid"). The address, and with it the sender + /// accountability, stays Resgrid's. + /// + public static string NotificationFromDisplayName(DepartmentEmailBranding branding) + { + if (branding != null && branding.Enabled && !String.IsNullOrWhiteSpace(branding.DisplayName)) + return $"{branding.DisplayName.Trim()} via Resgrid"; + + return "Resgrid"; + } + public async Task SendWelcomeEmail(string departmentName, string name, string emailAddress, string userName, int departmentId) { try @@ -175,7 +209,10 @@ public async Task SendMessageAsync(Message message, string senderName, int senderEmail = "do-not-reply@resgrid.com"; if (profile != null && profile.SendMessageEmail) - await _emailProvider.SendMessageMail(user.Email, subject, message.Subject, message.Body, senderEmail, senderName, message.SentOn.ToString("G") + " UTC", message.MessageId); + { + var branding = await GetEmailBrandingAsync(departmentId); + await _emailProvider.SendMessageMail(user.Email, subject, message.Subject, message.Body, senderEmail, senderName, message.SentOn.ToString("G") + " UTC", message.MessageId, branding); + } return true; } @@ -261,8 +298,11 @@ public async Task SendCallAsync(Call call, CallDispatch dispatch, UserProf } if (profile != null && profile.SendEmail && !String.IsNullOrWhiteSpace(emailAddress)) + { + var branding = await GetEmailBrandingAsync(call.DepartmentId); await _emailProvider.SendCallMail(emailAddress, subject, call.Name, priority, call.NatureOfCall, call.MapPage, - address, dispatchedOn, call.CallId, dispatch.UserId, coordinates, call.ShortenedAudioUrl); + address, dispatchedOn, call.CallId, dispatch.UserId, coordinates, call.ShortenedAudioUrl, branding); + } return true; } @@ -338,8 +378,11 @@ public async Task SendCancelCallAsync(Call call, CallDispatch dispatch, Us } if (profile != null && profile.SendEmail && !String.IsNullOrWhiteSpace(emailAddress)) + { + var branding = await GetEmailBrandingAsync(call.DepartmentId); await _emailProvider.SendCallMail(emailAddress, subject, call.Name, priority, natureOfCall, call.MapPage, - address, dispatchedOn, call.CallId, dispatch.UserId, coordinates, call.ShortenedAudioUrl); + address, dispatchedOn, call.CallId, dispatch.UserId, coordinates, call.ShortenedAudioUrl, branding); + } return true; } @@ -414,7 +457,8 @@ public async Task SendReportDeliveryAsync(EmailNotification email, int dep var body = string.Format("Your scheduled Resgrid report is attached. It will be as an email attachment. You can view the live report by clicking the link in the email and logging in."); - return await _emailProvider.SendReportDeliveryMail(email.To, email.Subject, body, DateTime.UtcNow.ToString("G") + " UTC", reportName, email.AttachmentName, email.AttachmentData, reportUrl); + var branding = await GetEmailBrandingAsync(departmentId); + return await _emailProvider.SendReportDeliveryMail(email.To, email.Subject, body, DateTime.UtcNow.ToString("G") + " UTC", reportName, email.AttachmentName, email.AttachmentData, reportUrl, branding); } @@ -656,11 +700,13 @@ public async Task SendNotificationAsync(string userId, string message, int if (email != null) { + var branding = await GetEmailBrandingAsync(departmentId); + using (var mail = new MailMessage()) { mail.To.Add(email); mail.Subject = SystemMessagesResources.Get("NotificationEmailSubject", profile?.Language); - mail.From = new MailAddress(Config.OutboundEmailServerConfig.FromMail, "Resgrid"); + mail.From = new MailAddress(Config.OutboundEmailServerConfig.FromMail, NotificationFromDisplayName(branding)); mail.Body = message; mail.IsBodyHtml = false; diff --git a/Core/Resgrid.Services/PermissionsService.cs b/Core/Resgrid.Services/PermissionsService.cs index 7ba4ec69e..f954681d1 100644 --- a/Core/Resgrid.Services/PermissionsService.cs +++ b/Core/Resgrid.Services/PermissionsService.cs @@ -138,6 +138,11 @@ where roleIds.Contains(r.PersonnelRoleId) } } } + else if (permission.Action == (int)PermissionActions.DepartmentAndGroupAdminsAndSelectRoles) + { + if (isUserDepartmentAdmin || isUserGroupAdmin || HasSelectedRole(permission.Data, roles)) + return true; + } else if (permission.Action == (int)PermissionActions.Everyone) { return true; @@ -165,6 +170,10 @@ public bool IsUserAllowed(Permission permission, int departmentId, int? sourceGr { return true; } + else if (permission.Action == (int)PermissionActions.DepartmentAndGroupAdminsAndSelectRoles && isUserDepartmentAdmin) + { + return true; + } else if (permission.Action == (int)PermissionActions.Everyone) { return true; @@ -172,7 +181,12 @@ public bool IsUserAllowed(Permission permission, int departmentId, int? sourceGr if (permission.LockToGroup) { - if (permission.Action == (int)PermissionActions.DepartmentAndGroupAdmins && isUserGroupAdmin) + if (permission.Action == (int)PermissionActions.DepartmentAndGroupAdminsAndSelectRoles) + { + if (sourceGroupId == userGroupId && (isUserGroupAdmin || HasSelectedRole(permission.Data, roles))) + return true; + } + else if (permission.Action == (int)PermissionActions.DepartmentAndGroupAdmins && isUserGroupAdmin) { if (sourceGroupId == userGroupId) { @@ -200,7 +214,12 @@ where roleIds.Contains(r.PersonnelRoleId) } else { - if (permission.Action == (int)PermissionActions.DepartmentAndGroupAdmins && isUserGroupAdmin) + if (permission.Action == (int)PermissionActions.DepartmentAndGroupAdminsAndSelectRoles) + { + if (isUserGroupAdmin || HasSelectedRole(permission.Data, roles)) + return true; + } + else if (permission.Action == (int)PermissionActions.DepartmentAndGroupAdmins && isUserGroupAdmin) { return true; } @@ -277,6 +296,16 @@ where roleIds.Contains(r.PersonnelRoleId) } } } + else if (permission.Action == (int)PermissionActions.DepartmentAndGroupAdminsAndSelectRoles) + { + if (isUserDepartmentAdmin || isUserGroupAdmin || HasSelectedRole(permission.Data, roles)) + { + if (permission.LockToGroup && !isUserDepartmentAdmin) + return allUsers.Where(x => x.DepartmentGroupId == sourceGroupId).Select(x => x.UserId).ToList(); + + return allUsers.Select(x => x.UserId).ToList(); + } + } else if (permission.Action == (int)PermissionActions.Everyone) { return allUsers.Select(x => x.UserId).ToList(); @@ -284,5 +313,20 @@ where roleIds.Contains(r.PersonnelRoleId) return new List(); } + + /// Null-safe role-CSV membership check used by the DepartmentAndGroupAdminsAndSelectRoles (4) branches. + private static bool HasSelectedRole(string roleIdCsv, List roles) + { + if (String.IsNullOrWhiteSpace(roleIdCsv) || roles == null || roles.Count == 0) + return false; + + foreach (var part in roleIdCsv.Split(',')) + { + if (int.TryParse(part.Trim(), out var roleId) && roles.Any(r => r.PersonnelRoleId == roleId)) + return true; + } + + return false; + } } } diff --git a/Core/Resgrid.Services/Records/DomainEventOutboxService.cs b/Core/Resgrid.Services/Records/DomainEventOutboxService.cs new file mode 100644 index 000000000..37350bb78 --- /dev/null +++ b/Core/Resgrid.Services/Records/DomainEventOutboxService.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Reusable transactional outbox transport (RMS plan section 5.6). Enqueue writes inside the caller's + /// unit of work; dispatch publishes on the in-process event + /// aggregator and marks the row, retrying with exponential backoff and parking it as Failed after the + /// attempt cap. The row is the durable source: a crash between publish and mark re-delivers rather + /// than drops. + /// + public class DomainEventOutboxService : IDomainEventOutboxService + { + public const int MaximumAttempts = 8; + private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(2); + + private readonly IDomainEventOutboxRepository _outboxRepository; + private readonly IEventAggregator _eventAggregator; + + public DomainEventOutboxService(IDomainEventOutboxRepository outboxRepository, IEventAggregator eventAggregator) + { + _outboxRepository = outboxRepository; + _eventAggregator = eventAggregator; + } + + public async Task EnqueueAsync(int departmentId, string producerSubsystem, DomainEventEnvelope envelope, CancellationToken cancellationToken = default) + { + if (envelope == null) throw new ArgumentNullException(nameof(envelope)); + if (string.IsNullOrWhiteSpace(envelope.EventName)) throw new ArgumentException("EventName is required.", nameof(envelope)); + if (string.IsNullOrWhiteSpace(envelope.AggregateId)) throw new ArgumentException("AggregateId is required.", nameof(envelope)); + + var now = DateTime.UtcNow; + var entry = new DomainEventOutboxEntry + { + DepartmentId = departmentId, + EventId = Guid.NewGuid().ToString(), + ProducerSubsystem = producerSubsystem, + EventName = envelope.EventName, + SchemaVersion = envelope.SchemaVersion <= 0 ? 1 : envelope.SchemaVersion, + AggregateType = envelope.AggregateType ?? string.Empty, + AggregateId = envelope.AggregateId, + AggregateVersion = envelope.AggregateVersion, + Sequence = await _outboxRepository.GetNextSequenceAsync(departmentId, envelope.AggregateId), + TriggerEventType = envelope.Trigger.HasValue ? (int?)envelope.Trigger.Value : null, + PayloadJson = JsonConvert.SerializeObject(envelope.Payload ?? new object()), + CorrelationId = envelope.CorrelationId, + CausationId = envelope.CausationId, + OriginClient = (int)envelope.OriginClient, + HopCount = 0, + State = (int)DomainEventOutboxState.Pending, + Attempts = 0, + OccurredOn = envelope.OccurredOn ?? now, + CreatedOn = now + }; + + return await _outboxRepository.InsertAsync(entry, cancellationToken, true); + } + + public async Task DispatchAfterCommitAsync(IEnumerable domainEventOutboxIds, CancellationToken cancellationToken = default) + { + var dispatched = 0; + foreach (var id in (domainEventOutboxIds ?? Enumerable.Empty()).Distinct()) + { + try + { + var entry = await _outboxRepository.ClaimByIdAsync(id, "inproc:" + Environment.MachineName, LeaseDuration, DateTime.UtcNow, cancellationToken); + if (entry == null) + continue; + + if (await DispatchOneAsync(entry, cancellationToken)) + dispatched++; + } + catch (Exception ex) + { + // The post-commit path is best effort; worker command 40 sweeps whatever it missed. + Logging.LogException(ex, $"Post-commit outbox dispatch failed for row {id}."); + } + } + + return dispatched; + } + + public async Task DispatchPendingAsync(string leaseOwner, int batchSize, CancellationToken cancellationToken = default) + { + var entries = (await _outboxRepository.ClaimPendingBatchAsync(leaseOwner, LeaseDuration, batchSize <= 0 ? 100 : batchSize, DateTime.UtcNow, cancellationToken))?.ToList() + ?? new List(); + + var dispatched = 0; + foreach (var entry in entries.OrderBy(e => e.DomainEventOutboxId)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (await DispatchOneAsync(entry, cancellationToken)) + dispatched++; + } + + return dispatched; + } + + public async Task GetHealthAsync() + { + var oldest = await _outboxRepository.GetOldestPendingCreatedOnAsync(); + return new DomainEventOutboxHealth + { + Pending = await _outboxRepository.CountByStateAsync((int)DomainEventOutboxState.Pending), + Failed = await _outboxRepository.CountByStateAsync((int)DomainEventOutboxState.Failed), + OldestPendingCreatedOn = oldest, + Backlog = oldest.HasValue ? DateTime.UtcNow - oldest.Value : (TimeSpan?)null + }; + } + + public Task PurgeDispatchedAsync(int olderThanDays, CancellationToken cancellationToken = default) + { + return _outboxRepository.PurgeDispatchedOlderThanAsync(DateTime.UtcNow.AddDays(-Math.Max(1, olderThanDays)), cancellationToken); + } + + /// Backoff for attempt n (1-based): 1m, 4m, 16m, ... capped at 6h. + public static TimeSpan BackoffFor(int attempts) + { + var minutes = Math.Pow(4, Math.Max(0, attempts - 1)); + return TimeSpan.FromMinutes(Math.Min(360, minutes)); + } + + private async Task DispatchOneAsync(DomainEventOutboxEntry entry, CancellationToken cancellationToken) + { + try + { + _eventAggregator.SendMessage(new DomainEventDispatchedEvent + { + DepartmentId = entry.DepartmentId, + EventId = entry.EventId, + ProducerSubsystem = entry.ProducerSubsystem, + EventName = entry.EventName, + SchemaVersion = entry.SchemaVersion, + AggregateType = entry.AggregateType, + AggregateId = entry.AggregateId, + Sequence = entry.Sequence, + TriggerEventType = entry.TriggerEventType, + PayloadJson = entry.PayloadJson, + CorrelationId = entry.CorrelationId, + CausationId = entry.CausationId, + OriginClient = entry.OriginClient, + IsReplay = entry.Attempts > 1, + OccurredOn = entry.OccurredOn + }); + + await _outboxRepository.MarkDispatchedAsync(entry.DomainEventOutboxId, DateTime.UtcNow, cancellationToken); + return true; + } + catch (Exception ex) + { + Logging.LogException(ex, $"Outbox dispatch failed for event {entry.EventId} (attempt {entry.Attempts})."); + var terminal = entry.Attempts >= MaximumAttempts; + await _outboxRepository.MarkFailedAsync(entry.DomainEventOutboxId, ex.Message, terminal ? (DateTime?)null : DateTime.UtcNow.Add(BackoffFor(entry.Attempts)), terminal, cancellationToken); + return false; + } + } + } +} diff --git a/Core/Resgrid.Services/Records/NullRecordAttachmentScanner.cs b/Core/Resgrid.Services/Records/NullRecordAttachmentScanner.cs new file mode 100644 index 000000000..0c5fca68d --- /dev/null +++ b/Core/Resgrid.Services/Records/NullRecordAttachmentScanner.cs @@ -0,0 +1,18 @@ +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Providers; + +namespace Resgrid.Services.Records +{ + /// Default scanner: no engine configured, so every attachment is stored as Skipped and stays eligible for a later scan. + public class NullRecordAttachmentScanner : IRecordAttachmentScanner + { + public const string NoScannerDetail = "No attachment scanner is configured."; + + public Task ScanAsync(string fileName, string contentType, byte[] data, CancellationToken cancellationToken = default) + { + return Task.FromResult(new RecordAttachmentScanResult { State = RmsAttachmentScanState.Skipped, Detail = NoScannerDetail }); + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordAttachmentHygiene.cs b/Core/Resgrid.Services/Records/RecordAttachmentHygiene.cs new file mode 100644 index 000000000..ce1d1f34e --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordAttachmentHygiene.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.IO; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Bmp; +using SixLabors.ImageSharp.Formats.Gif; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Formats.Tiff; +using SixLabors.ImageSharp.Formats.Webp; + +namespace Resgrid.Services.Records +{ + /// Thrown when an attachment fails media hygiene or the scanner; the message is safe to show to the uploader. + public class RecordAttachmentRejectedException : ArgumentException + { + public RecordAttachmentRejectedException(string message) : base(message) { } + } + + public sealed class AttachmentHygieneResult + { + public byte[] Data { get; set; } + public string FileName { get; set; } + public string ContentType { get; set; } + public bool IsImage { get; set; } + public bool MetadataStripped { get; set; } + } + + /// + /// Media hygiene for Records attachments (RMS plan section 4.7): raster images are decoded and re-encoded so + /// EXIF, XMP, IPTC and text chunks (location, device, author) never reach storage; SVG and active content + /// types are refused outright; everything else passes through byte-for-byte for the scanner to judge. + /// + public static class RecordAttachmentHygiene + { + public const int MaxBytes = 25 * 1024 * 1024; + public const long MaxPixels = 40_000_000; + public const int MaxFrames = 64; + + private static readonly HashSet BlockedExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".exe", ".dll", ".com", ".scr", ".msi", ".msp", ".bat", ".cmd", ".ps1", ".psm1", ".sh", ".vbs", ".vbe", ".js", ".jse", ".wsf", ".wsh", ".hta", ".jar", ".cpl", ".reg", ".lnk", ".svg", ".svgz" + }; + + private static readonly HashSet BlockedContentTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "image/svg+xml", "application/x-msdownload", "application/x-msdos-program", "application/x-executable", "application/x-sh", + "application/x-bat", "application/javascript", "text/javascript", "application/x-javascript", "application/java-archive", "application/hta" + }; + + private static readonly HashSet ImageExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".png", ".jpg", ".jpeg", ".jpe", ".gif", ".webp", ".bmp", ".tif", ".tiff" + }; + + public static AttachmentHygieneResult Sanitize(string fileName, string contentType, byte[] data) + { + if (data == null || data.Length == 0) + throw new RecordAttachmentRejectedException("Attachment content is required."); + if (data.Length > MaxBytes) + throw new RecordAttachmentRejectedException($"Attachment '{fileName}' exceeds the {MaxBytes / (1024 * 1024)} MB limit."); + + var safeName = Path.GetFileName(fileName ?? string.Empty); + var extension = Path.GetExtension(safeName) ?? string.Empty; + var declaredType = (contentType ?? string.Empty).Trim(); + + if (BlockedExtensions.Contains(extension) || BlockedContentTypes.Contains(declaredType)) + throw new RecordAttachmentRejectedException($"Attachment '{safeName}' is not an accepted file type."); + + var looksLikeImage = ImageExtensions.Contains(extension) || declaredType.StartsWith("image/", StringComparison.OrdinalIgnoreCase); + if (!looksLikeImage) + return new AttachmentHygieneResult { Data = data, FileName = safeName, ContentType = declaredType, IsImage = false, MetadataStripped = false }; + + try + { + var options = new DecoderOptions { MaxFrames = MaxFrames }; + using var input = new MemoryStream(data, writable: false); + using var image = Image.Load(options, input); + + if ((long)image.Width * image.Height > MaxPixels) + throw new RecordAttachmentRejectedException($"Attachment '{safeName}' is larger than the {MaxPixels / 1_000_000} megapixel limit."); + + var format = image.Metadata.DecodedImageFormat ?? PngFormat.Instance; + StripMetadata(image); + + using var output = new MemoryStream(); + image.Save(output, EncoderFor(format)); + + return new AttachmentHygieneResult + { + Data = output.ToArray(), + FileName = safeName, + ContentType = format.DefaultMimeType, + IsImage = true, + MetadataStripped = true + }; + } + catch (RecordAttachmentRejectedException) + { + throw; + } + catch (Exception ex) + { + // Undecodable "images" are refused rather than stored raw: a mismatched type is exactly the case hygiene exists for. + throw new RecordAttachmentRejectedException($"Attachment '{safeName}' could not be read as an image ({ex.GetType().Name})."); + } + } + + private static void StripMetadata(Image image) + { + image.Metadata.ExifProfile = null; + image.Metadata.XmpProfile = null; + image.Metadata.IptcProfile = null; + // ICC stays: it carries colour, never identity or location. + + var png = image.Metadata.GetPngMetadata(); + png.TextData.Clear(); + + foreach (var frame in image.Frames) + { + frame.Metadata.ExifProfile = null; + frame.Metadata.XmpProfile = null; + frame.Metadata.IptcProfile = null; + } + } + + private static IImageEncoder EncoderFor(IImageFormat format) + { + switch (format.Name.ToUpperInvariant()) + { + case "JPEG": return new JpegEncoder { Quality = 90 }; + case "GIF": return new GifEncoder(); + case "WEBP": return new WebpEncoder(); + case "BMP": return new BmpEncoder(); + case "TIFF": return new TiffEncoder(); + default: return new PngEncoder(); + } + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordSnapshotSerializer.cs b/Core/Resgrid.Services/Records/RecordSnapshotSerializer.cs new file mode 100644 index 000000000..c8cd6c7e9 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordSnapshotSerializer.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using Resgrid.Model; + +namespace Resgrid.Services.Records +{ + /// + /// Builds the complete, checksummed revision snapshot (RMS plan section 4.8) and computes on-demand + /// diffs between two snapshots. Serialization is canonical (ordered members, invariant culture, no + /// indentation) so the same content always produces the same checksum; attachment bytes are never + /// part of a snapshot, only their metadata. + /// + public static class RecordSnapshotSerializer + { + private static readonly JsonSerializerSettings Settings = new JsonSerializerSettings + { + ContractResolver = new OrderedContractResolver(), + DateFormatHandling = DateFormatHandling.IsoDateFormat, + DateTimeZoneHandling = DateTimeZoneHandling.Utc, + NullValueHandling = NullValueHandling.Include, + Formatting = Formatting.None, + Culture = System.Globalization.CultureInfo.InvariantCulture + }; + + /// Fields that require RecordRestricted_View inside a revision or a diff (plan section 5.9.2, Tier 1). + public static readonly HashSet RestrictedDetailFields = new HashSet(StringComparer.Ordinal) + { + nameof(RmsOperationalRecordDetail.CaseNumber), + nameof(RmsOperationalRecordDetail.PronouncedDeceasedBy), + nameof(RmsOperationalRecordDetail.BodyLocation), + nameof(RmsOperationalRecordDetail.OtherPersonnel), + nameof(RmsOperationalRecordDetail.Destination) + }; + + private static readonly string[] DetailFieldOrder = + { + nameof(RmsOperationalRecordDetail.Narrative), nameof(RmsOperationalRecordDetail.InitialReport), nameof(RmsOperationalRecordDetail.Type), + nameof(RmsOperationalRecordDetail.Course), nameof(RmsOperationalRecordDetail.CourseCode), nameof(RmsOperationalRecordDetail.Instructors), + nameof(RmsOperationalRecordDetail.Cause), nameof(RmsOperationalRecordDetail.InvestigatedByUserId), nameof(RmsOperationalRecordDetail.ContactName), + nameof(RmsOperationalRecordDetail.ContactNumber), nameof(RmsOperationalRecordDetail.OtherPersonnel), nameof(RmsOperationalRecordDetail.Location), + nameof(RmsOperationalRecordDetail.OtherAgencies), nameof(RmsOperationalRecordDetail.OtherUnits), nameof(RmsOperationalRecordDetail.BodyLocation), + nameof(RmsOperationalRecordDetail.PronouncedDeceasedBy), nameof(RmsOperationalRecordDetail.CaseNumber), nameof(RmsOperationalRecordDetail.Destination), + nameof(RmsOperationalRecordDetail.Facilitator), nameof(RmsOperationalRecordDetail.UnitId), nameof(RmsOperationalRecordDetail.ActivityOn), + nameof(RmsOperationalRecordDetail.CallNumber), nameof(RmsOperationalRecordDetail.CallName), nameof(RmsOperationalRecordDetail.CallType), + nameof(RmsOperationalRecordDetail.CallPriority), nameof(RmsOperationalRecordDetail.CallLoggedOn), nameof(RmsOperationalRecordDetail.CallAddress), + nameof(RmsOperationalRecordDetail.CallNature) + }; + + public static RecordSnapshot Build(RecordAggregate aggregate) + { + if (aggregate?.Record == null) + throw new ArgumentNullException(nameof(aggregate)); + + var r = aggregate.Record; + return new RecordSnapshot + { + RecordId = r.RmsOperationalRecordId, + DepartmentId = r.DepartmentId, + DefinitionKey = r.DefinitionKey, + DefinitionVersion = r.DefinitionVersion, + RecordType = r.RecordType, + RecordNumber = r.RecordNumber, + DraftReference = r.DraftReference, + StationGroupId = r.StationGroupId, + CallId = r.CallId, + ExternalId = r.ExternalId, + AuthorUserId = r.AuthorUserId, + StartedOn = r.StartedOn, + EndedOn = r.EndedOn, + Details = aggregate.Details, + Participants = (aggregate.Participants ?? new List()).OrderBy(p => p.Ordinal).ThenBy(p => p.UserId, StringComparer.Ordinal).ToList(), + Units = (aggregate.Units ?? new List()).OrderBy(u => u.Ordinal).ThenBy(u => u.UnitId).ToList(), + Attachments = (aggregate.Attachments ?? new List()).Select(StripBytes).OrderBy(a => a.UploadedOn).ThenBy(a => a.RmsRecordAttachmentId, StringComparer.Ordinal).ToList() + }; + } + + public static string Serialize(RecordSnapshot snapshot) + { + return JsonConvert.SerializeObject(snapshot, Settings); + } + + public static RecordSnapshot Deserialize(string json) + { + return string.IsNullOrWhiteSpace(json) ? null : JsonConvert.DeserializeObject(json, Settings); + } + + /// Lower-case hex SHA-256 of the UTF-8 bytes. + public static string Checksum(string content) + { + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(content ?? string.Empty)); + var sb = new StringBuilder(hash.Length * 2); + foreach (var b in hash) + sb.Append(b.ToString("x2")); + return sb.ToString(); + } + + public static string Checksum(byte[] content) + { + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(content ?? Array.Empty()); + var sb = new StringBuilder(hash.Length * 2); + foreach (var b in hash) + sb.Append(b.ToString("x2")); + return sb.ToString(); + } + + /// + /// Field-level diff grouped by section (plan section 4.8). Unchanged fields are omitted. A restricted + /// field that changed is reported as changed with both values withheld when the viewer lacks + /// RecordRestricted_View, so history never becomes a bypass. + /// + public static List Diff(RecordSnapshot from, RecordSnapshot to, bool canViewRestricted) + { + var diffs = new List(); + from = from ?? new RecordSnapshot(); + to = to ?? new RecordSnapshot(); + + Compare(diffs, "Record", nameof(RecordSnapshot.RecordNumber), from.RecordNumber, to.RecordNumber, false, canViewRestricted); + Compare(diffs, "Record", nameof(RecordSnapshot.StationGroupId), from.StationGroupId?.ToString(), to.StationGroupId?.ToString(), false, canViewRestricted); + Compare(diffs, "Record", nameof(RecordSnapshot.CallId), from.CallId?.ToString(), to.CallId?.ToString(), false, canViewRestricted); + Compare(diffs, "Record", nameof(RecordSnapshot.ExternalId), from.ExternalId, to.ExternalId, false, canViewRestricted); + Compare(diffs, "Record", nameof(RecordSnapshot.StartedOn), Iso(from.StartedOn), Iso(to.StartedOn), false, canViewRestricted); + Compare(diffs, "Record", nameof(RecordSnapshot.EndedOn), Iso(from.EndedOn), Iso(to.EndedOn), false, canViewRestricted); + + var fromDetails = from.Details ?? new RmsOperationalRecordDetail(); + var toDetails = to.Details ?? new RmsOperationalRecordDetail(); + foreach (var field in DetailFieldOrder) + { + var property = typeof(RmsOperationalRecordDetail).GetProperty(field); + var oldValue = Stringify(property.GetValue(fromDetails)); + var newValue = Stringify(property.GetValue(toDetails)); + Compare(diffs, "Details", field, oldValue, newValue, RestrictedDetailFields.Contains(field), canViewRestricted); + } + + DiffSet(diffs, "Participants", from.Participants.Select(p => p.UserId + (p.UnitId.HasValue ? "@" + p.UnitId : string.Empty) + (string.IsNullOrEmpty(p.Role) ? string.Empty : ":" + p.Role)), + to.Participants.Select(p => p.UserId + (p.UnitId.HasValue ? "@" + p.UnitId : string.Empty) + (string.IsNullOrEmpty(p.Role) ? string.Empty : ":" + p.Role))); + DiffSet(diffs, "Units", from.Units.Select(u => $"{u.UnitId}|{Iso(u.Dispatched)}|{Iso(u.Enroute)}|{Iso(u.OnScene)}|{Iso(u.Released)}|{Iso(u.InQuarters)}"), + to.Units.Select(u => $"{u.UnitId}|{Iso(u.Dispatched)}|{Iso(u.Enroute)}|{Iso(u.OnScene)}|{Iso(u.Released)}|{Iso(u.InQuarters)}")); + DiffSet(diffs, "Attachments", from.Attachments.Select(a => a.RmsRecordAttachmentId + ":" + a.Checksum), to.Attachments.Select(a => a.RmsRecordAttachmentId + ":" + a.Checksum)); + + return diffs; + } + + private static void Compare(List diffs, string section, string field, string oldValue, string newValue, bool restricted, bool canViewRestricted) + { + if (string.Equals(oldValue ?? string.Empty, newValue ?? string.Empty, StringComparison.Ordinal)) + return; + + var withheld = restricted && !canViewRestricted; + diffs.Add(new RecordFieldDiff + { + Section = section, + FieldKey = field, + OldValue = withheld ? null : oldValue, + NewValue = withheld ? null : newValue, + Withheld = withheld + }); + } + + private static void DiffSet(List diffs, string section, IEnumerable from, IEnumerable to) + { + var a = new HashSet(from ?? Enumerable.Empty(), StringComparer.Ordinal); + var b = new HashSet(to ?? Enumerable.Empty(), StringComparer.Ordinal); + + foreach (var removed in a.Except(b).OrderBy(x => x, StringComparer.Ordinal)) + diffs.Add(new RecordFieldDiff { Section = section, FieldKey = "removed", OldValue = removed, NewValue = null }); + + foreach (var added in b.Except(a).OrderBy(x => x, StringComparer.Ordinal)) + diffs.Add(new RecordFieldDiff { Section = section, FieldKey = "added", OldValue = null, NewValue = added }); + } + + private static string Iso(DateTime? value) + { + return value?.ToString("O", System.Globalization.CultureInfo.InvariantCulture); + } + + private static string Stringify(object value) + { + switch (value) + { + case null: return null; + case DateTime dt: return Iso(dt); + default: return Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture); + } + } + + private static RmsRecordAttachment StripBytes(RmsRecordAttachment attachment) + { + return new RmsRecordAttachment + { + RmsRecordAttachmentId = attachment.RmsRecordAttachmentId, + DepartmentId = attachment.DepartmentId, + ProtectionId = attachment.ProtectionId, + RecordId = attachment.RecordId, + FileName = attachment.FileName, + ContentType = attachment.ContentType, + ByteSize = attachment.ByteSize, + Checksum = attachment.Checksum, + StorageReference = attachment.StorageReference, + Description = attachment.Description, + UploadedByUserId = attachment.UploadedByUserId, + UploadedOn = attachment.UploadedOn, + ScanState = attachment.ScanState, + MetadataStripped = attachment.MetadataStripped, + IsProtected = attachment.IsProtected, + ProtectedCatalogVersion = attachment.ProtectedCatalogVersion, + CreatedOn = attachment.CreatedOn, + ModifiedOn = attachment.ModifiedOn, + RowVersion = attachment.RowVersion, + DeletedOn = attachment.DeletedOn + }; + } + + private sealed class OrderedContractResolver : DefaultContractResolver + { + protected override IList CreateProperties(Type type, MemberSerialization memberSerialization) + { + return base.CreateProperties(type, memberSerialization).OrderBy(p => p.PropertyName, StringComparer.Ordinal).ToList(); + } + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsAccountabilityService.cs b/Core/Resgrid.Services/Records/RecordsAccountabilityService.cs new file mode 100644 index 000000000..782d430a4 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsAccountabilityService.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Accountability view (RMS plan section 4.7): "who owes me a report", pivoted by person, group or unit, over + /// the open Records plus the ones finalized inside the window. Everything is computed in memory from + /// batch-loaded rows and filtered by the queue's visibility rule. The reminder is bounded twice: one per + /// Record per day (checked against the access audit) and a cap per action. + /// + public class RecordsAccountabilityService : IRecordsAccountabilityService + { + public const string ReminderTitle = "Records reminder"; + public const string ReminderPurposePrefix = "Reminder sent to "; + public const string UnassignedKey = ""; + private static readonly TimeSpan ReminderCooldown = TimeSpan.FromHours(24); + + private readonly IRmsOperationalRecordsRepository _records; + private readonly IRmsRecordParticipantsRepository _participants; + private readonly IRmsRecordUnitResponsesRepository _units; + private readonly IRmsRecordGroupScopesRepository _scopes; + private readonly IRmsRecordValueService _values; + private readonly IRmsAccessAuditsRepository _audits; + private readonly IRecordsAuthorizationService _authorization; + private readonly IRecordsService _recordsService; + private readonly ICommunicationService _communication; + private readonly IDepartmentsService _departments; + private readonly IDepartmentSettingsService _departmentSettings; + private readonly IUserProfileService _profiles; + + public RecordsAccountabilityService(IRmsOperationalRecordsRepository records, IRmsRecordParticipantsRepository participants, + IRmsRecordUnitResponsesRepository units, IRmsRecordGroupScopesRepository scopes, IRmsRecordValueService values, + IRmsAccessAuditsRepository audits, IRecordsAuthorizationService authorization, IRecordsService recordsService, + ICommunicationService communication, IDepartmentsService departments, IDepartmentSettingsService departmentSettings, + IUserProfileService profiles) + { + _records = records; + _participants = participants; + _units = units; + _scopes = scopes; + _values = values; + _audits = audits; + _authorization = authorization; + _recordsService = recordsService; + _communication = communication; + _departments = departments; + _departmentSettings = departmentSettings; + _profiles = profiles; + } + + public int MaxRemindersPerAction => 25; + + public async Task BuildAsync(int departmentId, string viewerUserId, RecordsAccountabilityPivot pivot, int windowDays) + { + var now = DateTime.UtcNow; + windowDays = Math.Clamp(windowDays, 1, 365); + var report = new RecordsAccountabilityReport { Pivot = pivot, WindowDays = windowDays, GeneratedOn = now }; + + var open = (await _records.GetOpenAsync(departmentId) ?? Enumerable.Empty()).Where(r => r != null && !r.DeletedOn.HasValue).ToList(); + var finalized = (await _records.GetFinalizedSinceAsync(departmentId, now.AddDays(-windowDays)) ?? Enumerable.Empty()).Where(r => r != null && !r.DeletedOn.HasValue).ToList(); + + var all = open.Concat(finalized).GroupBy(r => r.RmsOperationalRecordId).Select(g => g.First()).ToList(); + var ids = all.Select(r => r.RmsOperationalRecordId).ToList(); + var participants = (await _participants.GetForRecordsAsync(departmentId, ids) ?? Enumerable.Empty()).ToLookup(p => p.RecordId); + var scopes = (await _scopes.GetForRecordsAsync(departmentId, ids) ?? Enumerable.Empty()).ToLookup(s => s.RecordId); + var visibleGroups = await _authorization.GetVisibleGroupIdsAsync(viewerUserId, departmentId); + + bool Visible(RmsOperationalRecord r) => RecordsReportingService.IsVisible(r, participants[r.RmsOperationalRecordId], scopes[r.RmsOperationalRecordId], visibleGroups, viewerUserId); + open = open.Where(Visible).ToList(); + finalized = finalized.Where(Visible).ToList(); + + var keysByRecord = await PivotKeysAsync(departmentId, pivot, open.Concat(finalized).ToList()); + + var rows = new Dictionary(StringComparer.OrdinalIgnoreCase); + RecordsAccountabilityRow Row(string key) + { + if (!rows.TryGetValue(key, out var row)) + { + row = new RecordsAccountabilityRow { Key = key }; + rows[key] = row; + } + return row; + } + + foreach (var record in open) + { + var entry = ToEntry(record, now); + foreach (var key in keysByRecord[record.RmsOperationalRecordId]) + { + var row = Row(key); + row.Open++; + if (entry.OverdueReview) row.OverdueReviews++; + if (entry.ReturnedNotCorrected) row.ReturnedNotCorrected++; + if (!row.OldestOpenOn.HasValue || record.CreatedOn < row.OldestOpenOn.Value) row.OldestOpenOn = record.CreatedOn; + row.OpenRecords.Add(entry); + } + } + + var hours = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var record in finalized) + { + if (!record.FinalizedOn.HasValue) + continue; + + foreach (var key in keysByRecord[record.RmsOperationalRecordId]) + { + var row = Row(key); + row.FinalizedInWindow++; + if (!hours.TryGetValue(key, out var list)) + hours[key] = list = new List(); + list.Add(Math.Max(0, (record.FinalizedOn.Value - record.CreatedOn).TotalHours)); + } + } + + foreach (var pair in hours) + rows[pair.Key].AverageHoursToFinalize = Math.Round(pair.Value.Average(), 1); + + foreach (var row in rows.Values) + row.OpenRecords = row.OpenRecords.OrderBy(r => r.CreatedOn).ToList(); + + report.Rows = rows.Values + .OrderByDescending(r => r.OverdueReviews + r.ReturnedNotCorrected) + .ThenByDescending(r => r.Open) + .ThenBy(r => r.Key, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var openEntries = open.Select(r => ToEntry(r, now)).ToList(); + var finalizedHours = finalized.Where(r => r.FinalizedOn.HasValue).Select(r => Math.Max(0, (r.FinalizedOn.Value - r.CreatedOn).TotalHours)).ToList(); + report.Totals = new RecordsAccountabilityRow + { + Key = "*", + Open = open.Count, + OverdueReviews = openEntries.Count(e => e.OverdueReview), + ReturnedNotCorrected = openEntries.Count(e => e.ReturnedNotCorrected), + FinalizedInWindow = finalized.Count, + AverageHoursToFinalize = finalizedHours.Count == 0 ? (double?)null : Math.Round(finalizedHours.Average(), 1), + OldestOpenOn = open.Count == 0 ? (DateTime?)null : open.Min(r => r.CreatedOn) + }; + + return report; + } + + public async Task SendReminderAsync(int departmentId, string senderUserId, string recordId, CancellationToken cancellationToken = default) + { + var record = await _records.GetByIdForDepartmentAsync(departmentId, recordId); + if (record == null || record.DeletedOn.HasValue || !IsOpen(record)) + return new RecordsReminderResult { RecordId = recordId, Reason = RecordsReminderResult.ReasonNotOpen }; + + var recipient = string.IsNullOrWhiteSpace(record.OwnerUserId) ? record.AuthorUserId : record.OwnerUserId; + if (string.IsNullOrWhiteSpace(recipient)) + return new RecordsReminderResult { RecordId = recordId, Reason = RecordsReminderResult.ReasonNoRecipient }; + + if (await RecentlyRemindedAsync(departmentId, recordId, DateTime.UtcNow)) + return new RecordsReminderResult { RecordId = recordId, RecipientUserId = recipient, Reason = RecordsReminderResult.ReasonRecentlyReminded }; + + cancellationToken.ThrowIfCancellationRequested(); + var department = await _departments.GetDepartmentByIdAsync(departmentId, false); + var departmentNumber = await _departmentSettings.GetTextToCallNumberForDepartmentAsync(departmentId); + var profile = await _profiles.GetProfileByUserIdAsync(recipient, false); + + try + { + var sent = await _communication.SendNotificationAsync(recipient, departmentId, BuildReminderMessage(record, DateTime.UtcNow), departmentNumber, department, ReminderTitle, profile); + await _recordsService.RecordAccessAsync(departmentId, senderUserId, recordId, null, RmsAccessAuditAction.Admin, ReminderPurposePrefix + recipient, null); + return new RecordsReminderResult { RecordId = recordId, RecipientUserId = recipient, Sent = sent, Reason = sent ? RecordsReminderResult.ReasonSent : RecordsReminderResult.ReasonFailed }; + } + catch (Exception ex) + { + Logging.LogException(ex, $"Reminder for record {recordId} could not be sent."); + return new RecordsReminderResult { RecordId = recordId, RecipientUserId = recipient, Reason = RecordsReminderResult.ReasonFailed }; + } + } + + public async Task> SendRemindersAsync(int departmentId, string senderUserId, IEnumerable recordIds, CancellationToken cancellationToken = default) + { + var results = new List(); + var ids = (recordIds ?? Enumerable.Empty()).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var sent = 0; + + foreach (var id in ids) + { + if (sent >= MaxRemindersPerAction) + { + results.Add(new RecordsReminderResult { RecordId = id, Reason = RecordsReminderResult.ReasonLimit }); + continue; + } + + var result = await SendReminderAsync(departmentId, senderUserId, id, cancellationToken); + if (result.Sent) + sent++; + results.Add(result); + } + + return results; + } + + /// Header-only wording plus a link: no narrative or restricted content ever rides in a reminder. + public static string BuildReminderMessage(RmsOperationalRecord record, DateTime utcNow) + { + var reference = string.IsNullOrWhiteSpace(record.RecordNumber) ? record.DraftReference : record.RecordNumber; + var type = record.RecordType.HasValue ? ((RmsOperationalRecordType)record.RecordType.Value).ToString() : "Record"; + var state = (RmsRecordState)record.State; + var age = Math.Max(0, (int)(utcNow - record.CreatedOn).TotalDays); + + var builder = new StringBuilder(); + builder.Append(type).Append(" record ").Append(reference).Append(" is still ").Append(state); + builder.Append(" after ").Append(age).Append(age == 1 ? " day" : " days").Append('.'); + if (state == RmsRecordState.Returned) + builder.Append(" It was returned for correction and is waiting on you."); + else if (state == RmsRecordState.ReadyForReview && record.ReviewDueOn.HasValue && record.ReviewDueOn.Value < utcNow) + builder.Append(" Its review is overdue."); + + var baseUrl = (Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/'); + builder.Append(' ').Append(baseUrl).Append("/User/Records/Details/").Append(record.RmsOperationalRecordId); + return builder.ToString(); + } + + public static bool IsOpen(RmsOperationalRecord record) + { + var state = (RmsRecordState)record.State; + return state == RmsRecordState.Draft || state == RmsRecordState.ReadyForReview || state == RmsRecordState.Returned || state == RmsRecordState.Approved; + } + + private async Task RecentlyRemindedAsync(int departmentId, string recordId, DateTime utcNow) + { + var audits = await _audits.GetForRecordAsync(departmentId, recordId, 50) ?? Enumerable.Empty(); + return audits.Any(a => a.Action == (int)RmsAccessAuditAction.Admin + && (a.Purpose ?? string.Empty).StartsWith(ReminderPurposePrefix, StringComparison.Ordinal) + && a.OccurredOn > utcNow - ReminderCooldown); + } + + private static RecordsAccountabilityRecord ToEntry(RmsOperationalRecord record, DateTime now) + { + var state = (RmsRecordState)record.State; + return new RecordsAccountabilityRecord + { + RecordId = record.RmsOperationalRecordId, + Reference = string.IsNullOrWhiteSpace(record.RecordNumber) ? record.DraftReference : record.RecordNumber, + Summary = record.DisplaySummary, + State = state, + OwnerUserId = string.IsNullOrWhiteSpace(record.OwnerUserId) ? record.AuthorUserId : record.OwnerUserId, + CreatedOn = record.CreatedOn, + ReviewDueOn = record.ReviewDueOn, + ReturnedOn = record.ReturnedOn, + OverdueReview = state == RmsRecordState.ReadyForReview && record.ReviewDueOn.HasValue && record.ReviewDueOn.Value < now, + ReturnedNotCorrected = state == RmsRecordState.Returned + }; + } + + /// The pivot keys a Record counts under: one owner, one station/group, but possibly several units. + private async Task> PivotKeysAsync(int departmentId, RecordsAccountabilityPivot pivot, List records) + { + var pairs = new List>(); + switch (pivot) + { + case RecordsAccountabilityPivot.Group: + foreach (var record in records) + pairs.Add(new KeyValuePair(record.RmsOperationalRecordId, record.StationGroupId?.ToString() ?? UnassignedKey)); + break; + + case RecordsAccountabilityPivot.Unit: + var ids = records.Select(r => r.RmsOperationalRecordId).ToList(); + var responses = (await _units.GetForRecordsAsync(departmentId, ids) ?? Enumerable.Empty()) + .Select(u => (u.RecordId, u.UnitId)).Distinct().ToLookup(x => x.RecordId, x => x.UnitId); + var details = (await _values.GetDraftsForRecordsAsync(departmentId, ids) ?? Enumerable.Empty()) + .Where(d => d.UnitId.HasValue).ToDictionary(d => d.RecordId, d => d.UnitId.Value); + foreach (var record in records) + { + var unitIds = new HashSet(responses[record.RmsOperationalRecordId]); + if (details.TryGetValue(record.RmsOperationalRecordId, out var unitId)) + unitIds.Add(unitId); + if (unitIds.Count == 0) + pairs.Add(new KeyValuePair(record.RmsOperationalRecordId, UnassignedKey)); + foreach (var id in unitIds) + pairs.Add(new KeyValuePair(record.RmsOperationalRecordId, id.ToString())); + } + break; + + default: + foreach (var record in records) + pairs.Add(new KeyValuePair(record.RmsOperationalRecordId, (string.IsNullOrWhiteSpace(record.OwnerUserId) ? record.AuthorUserId : record.OwnerUserId) ?? UnassignedKey)); + break; + } + + return pairs.ToLookup(p => p.Key, p => p.Value); + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsAuthorizationService.cs b/Core/Resgrid.Services/Records/RecordsAuthorizationService.cs new file mode 100644 index 000000000..3f77342e5 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsAuthorizationService.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Per-Record visibility (RMS plan section 5.7.1), following the CanUserViewPersonAsync / + /// CanUserViewUnitAsync exemplars: read the ViewGroupRecords row, branch on PermissionActions, honor + /// LockToGroup, fail closed on evaluation error, cache the group set briefly. Always visible regardless + /// of group: the author, the current owner/reviewer/approver, a named participant, a responding unit's + /// crew (unit group), department administrators, and a Record shared to the viewer's group. + /// + public class RecordsAuthorizationService : IRecordsAuthorizationService + { + private const string VisibleGroupsCacheKey = "RmsVisibleGroups_{0}_{1}"; + private static readonly TimeSpan VisibleGroupsCacheLength = TimeSpan.FromMinutes(2); + + private readonly IPermissionsService _permissionsService; + private readonly IDepartmentsService _departmentsService; + private readonly IDepartmentGroupsService _departmentGroupsService; + private readonly IPersonnelRolesService _personnelRolesService; + private readonly IDepartmentSettingsService _departmentSettingsService; + private readonly IRmsOperationalRecordsRepository _recordsRepository; + private readonly IRmsRecordGroupScopesRepository _groupScopesRepository; + private readonly IRmsRecordParticipantsRepository _participantsRepository; + private readonly ICacheProvider _cacheProvider; + private readonly IRmsLegacyStatsRepository _legacyStats; + + public RecordsAuthorizationService(IPermissionsService permissionsService, IDepartmentsService departmentsService, + IDepartmentGroupsService departmentGroupsService, IPersonnelRolesService personnelRolesService, IDepartmentSettingsService departmentSettingsService, + IRmsOperationalRecordsRepository recordsRepository, IRmsRecordGroupScopesRepository groupScopesRepository, + IRmsRecordParticipantsRepository participantsRepository, ICacheProvider cacheProvider, IRmsLegacyStatsRepository legacyStats) + { + _legacyStats = legacyStats; + _permissionsService = permissionsService; + _departmentsService = departmentsService; + _departmentGroupsService = departmentGroupsService; + _personnelRolesService = personnelRolesService; + _departmentSettingsService = departmentSettingsService; + _recordsRepository = recordsRepository; + _groupScopesRepository = groupScopesRepository; + _participantsRepository = participantsRepository; + _cacheProvider = cacheProvider; + } + + public async Task IsGroupScopedAsync(int departmentId) + { + if (await _departmentSettingsService.GetRecordsGroupVisibilityModeAsync(departmentId) != RecordsGroupVisibilityMode.GroupScoped) + return false; + + var permission = await _permissionsService.GetPermissionByDepartmentTypeAsync(departmentId, PermissionTypes.ViewGroupRecords); + return permission != null && permission.LockToGroup; + } + + public async Task> GetVisibleGroupIdsAsync(string userId, int departmentId) + { + async Task> resolve() + { + try + { + if (!await IsGroupScopedAsync(departmentId)) + return null; + + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); + if (department == null) + return new List(); + + if (department.IsUserAnAdmin(userId)) + return null; + + var permission = await _permissionsService.GetPermissionByDepartmentTypeAsync(departmentId, PermissionTypes.ViewGroupRecords); + var group = await _departmentGroupsService.GetGroupForUserAsync(userId, departmentId); + var isGroupAdmin = group != null && group.IsUserGroupAdmin(userId); + var roles = await _personnelRolesService.GetRolesForUserAsync(userId, departmentId) ?? new List(); + + // The row's Action decides who the cross-group rule widens for; everyone else is confined to their own group. + var seesEverything = permission == null || Resgrid.Model.RecordPermissionEvaluation.IsSatisfied(permission.Action, permission.Data, department.IsUserAnAdmin(userId), isGroupAdmin, roles) && !permission.LockToGroup; + if (seesEverything) + return null; + + return group == null ? new List() : new List { group.DepartmentGroupId }; + } + catch (Exception ex) + { + Logging.LogException(ex, $"Record visibility evaluation failed for user {userId} in department {departmentId}; failing closed."); + return new List(); + } + } + + if (Config.SystemBehaviorConfig.CacheEnabled) + { + var cached = await _cacheProvider.RetrieveAsync(string.Format(VisibleGroupsCacheKey, departmentId, userId), + async () => new VisibleGroupsCacheEntry { DepartmentId = departmentId, Unrestricted = (await resolve()) == null, GroupIds = await resolve() ?? new List() }, + VisibleGroupsCacheLength); + + if (cached != null && cached.DepartmentId == departmentId) + return cached.Unrestricted ? null : cached.GroupIds; + } + + return await resolve(); + } + + public async Task CanUserViewRecordAsync(string userId, string recordId, int departmentId) + { + try + { + var record = await _recordsRepository.GetByIdForDepartmentAsync(departmentId, recordId); + if (record == null || record.DeletedOn.HasValue) + return false; + + // Always-visible cases resolve before any group evaluation. + if (string.Equals(record.AuthorUserId, userId, StringComparison.Ordinal) || + string.Equals(record.OwnerUserId, userId, StringComparison.Ordinal) || + string.Equals(record.ReviewerUserId, userId, StringComparison.Ordinal) || + string.Equals(record.ApproverUserId, userId, StringComparison.Ordinal)) + return true; + + var visible = await GetVisibleGroupIdsAsync(userId, departmentId); + if (visible == null) + return true; + + var participants = await _participantsRepository.GetForRecordAsync(departmentId, recordId, null); + if (participants != null && participants.Any(p => string.Equals(p.UserId, userId, StringComparison.Ordinal))) + return true; + + var scope = await _groupScopesRepository.GetForRecordAsync(departmentId, recordId); + return scope != null && scope.Any(s => visible.Contains(s.DepartmentGroupId)); + } + catch (Exception ex) + { + Logging.LogException(ex, $"CanUserViewRecordAsync failed for record {recordId}; failing closed."); + return false; + } + } + + private class VisibleGroupsCacheEntry + { + public int DepartmentId { get; set; } + public bool Unrestricted { get; set; } + public List GroupIds { get; set; } + } + + public async Task PreviewGroupScopingAsync(int departmentId) + { + var preview = new RecordsGroupScopePreview + { + TotalRecords = await _recordsRepository.CountAllAsync(departmentId), + RecordsWithoutGroupAnchor = await _recordsRepository.CountWithoutGroupScopeAsync(departmentId) + }; + + var legacy = await _legacyStats.GetLegacyStatsAsync(departmentId); + preview.LegacyLogsWithoutGroup = legacy?.LogsWithoutGroupCount ?? 0; + preview.LegacyUnitLogs = legacy?.UnitLogCount ?? 0; + + var perGroup = await _groupScopesRepository.CountRecordsByGroupAsync(departmentId) ?? new Dictionary(); + var groupedUsers = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var group in await _departmentGroupsService.GetAllGroupsForDepartmentAsync(departmentId) ?? new List()) + { + var members = (await _departmentGroupsService.GetAllMembersForGroupAsync(group.DepartmentGroupId) ?? new List()) + .Where(m => !string.IsNullOrWhiteSpace(m.UserId)).Select(m => m.UserId).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + foreach (var member in members) + groupedUsers.Add(member); + + preview.Groups.Add(new RecordsGroupScopePreviewGroup + { + GroupId = group.DepartmentGroupId, + GroupName = group.Name, + MemberCount = members.Count, + RecordCount = perGroup.TryGetValue(group.DepartmentGroupId, out var count) ? count : 0 + }); + } + + var users = (await _departmentsService.GetAllUsersForDepartmentAsync(departmentId) ?? new List()) + .Where(u => !string.IsNullOrWhiteSpace(u.UserId)).Select(u => u.UserId).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + preview.UsersInDepartment = users.Count; + preview.UsersWithoutGroup = users.Count(u => !groupedUsers.Contains(u)); + preview.DepartmentAdmins = (await _departmentsService.GetAllAdminsForDepartmentAsync(departmentId))?.Count ?? 0; + + return preview; + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsCutoverService.cs b/Core/Resgrid.Services/Records/RecordsCutoverService.cs new file mode 100644 index 000000000..68a548538 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsCutoverService.cs @@ -0,0 +1,432 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Records module state and department cutover (RMS plan section 4.1). The flag selects Records vs + /// Logs in the UI; only the append-only RmsDepartmentCutover row engages the legacy write guard. + /// Activation migrates Permission rows (registry section 4.6) and is refused unless the flag is on, + /// the Protected Data preflight passes (NotApplicable when the subsystem is absent), and the + /// department is not already active. Rollback follows the three-outcome decision frame. + /// + public class RecordsCutoverService : IRecordsCutoverService + { + private const string ModuleStateCacheKey = "RmsModuleState_{0}"; + private static readonly TimeSpan ModuleStateCacheLength = TimeSpan.FromMinutes(5); + + private readonly IRmsDepartmentCutoversRepository _cutoversRepository; + private readonly IRmsDepartmentCutoverEventsRepository _cutoverEventsRepository; + private readonly IRmsOperationalRecordsRepository _recordsRepository; + private readonly IRmsAccessAuditsRepository _accessAuditsRepository; + private readonly IRmsLegacyStatsRepository _legacyStatsRepository; + private readonly IFeatureToggleService _featureToggleService; + private readonly IPermissionsService _permissionsService; + private readonly IDepartmentDataProtectionService _dataProtectionService; + private readonly IUnitOfWork _unitOfWork; + private readonly ICacheProvider _cacheProvider; + + public RecordsCutoverService(IRmsDepartmentCutoversRepository cutoversRepository, IRmsDepartmentCutoverEventsRepository cutoverEventsRepository, + IRmsOperationalRecordsRepository recordsRepository, IRmsAccessAuditsRepository accessAuditsRepository, IRmsLegacyStatsRepository legacyStatsRepository, + IFeatureToggleService featureToggleService, IPermissionsService permissionsService, IDepartmentDataProtectionService dataProtectionService, + IUnitOfWork unitOfWork, ICacheProvider cacheProvider) + { + _cutoversRepository = cutoversRepository; + _cutoverEventsRepository = cutoverEventsRepository; + _recordsRepository = recordsRepository; + _accessAuditsRepository = accessAuditsRepository; + _legacyStatsRepository = legacyStatsRepository; + _featureToggleService = featureToggleService; + _permissionsService = permissionsService; + _dataProtectionService = dataProtectionService; + _unitOfWork = unitOfWork; + _cacheProvider = cacheProvider; + } + + public async Task GetModuleStateAsync(int departmentId, bool bypassCache = false) + { + async Task load() + { + var state = new RecordsModuleState { DepartmentId = departmentId }; + state.FlagEnabled = await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.RecordsSystem, departmentId); + + var cutover = await _cutoversRepository.GetByDepartmentIdAsync(departmentId); + if (cutover != null) + { + state.Activated = true; + state.ActivatedOn = cutover.ActivatedOn; + state.CutoverId = cutover.RmsDepartmentCutoverId; + state.CutoverState = (RmsDepartmentCutoverState)cutover.State; + state.LegacyWritesBlocked = cutover.IsActive; + } + + return state; + } + + if (!bypassCache && Config.SystemBehaviorConfig.CacheEnabled) + { + var cached = await _cacheProvider.RetrieveAsync(string.Format(ModuleStateCacheKey, departmentId), load, ModuleStateCacheLength); + + // A blank cache payload deserializes to a non-null entity with DepartmentId 0; never trust it. + if (cached != null && cached.DepartmentId == departmentId) + return cached; + } + + return await load(); + } + + public async Task IsRecordsEnabledAsync(int departmentId) + { + return (await GetModuleStateAsync(departmentId)).FlagEnabled; + } + + public async Task AreLegacyWritesBlockedAsync(int departmentId) + { + return (await GetModuleStateAsync(departmentId)).LegacyWritesBlocked; + } + + public async Task EnsureLegacyWriteAllowedAsync(int departmentId, string context, string userId = null) + { + if (!await AreLegacyWritesBlockedAsync(departmentId)) + return; + + try + { + await _accessAuditsRepository.InsertAsync(new RmsAccessAudit + { + DepartmentId = departmentId, + Action = (int)RmsAccessAuditAction.LegacyWriteDenied, + ActorUserId = userId, + Purpose = context, + Successful = false, + OccurredOn = DateTime.UtcNow, + OriginClient = (int)RmsOriginClient.System + }, CancellationToken.None, true); + } + catch (Exception ex) + { + // The denial itself must never depend on the audit write succeeding. + Logging.LogException(ex, $"Failed to audit a denied legacy write for department {departmentId} ({context})."); + } + + throw new RecordsLegacyWriteBlockedException(departmentId, context); + } + + public async Task GetActivationPreviewAsync(int departmentId) + { + var state = await GetModuleStateAsync(departmentId, true); + var stats = await _legacyStatsRepository.GetLegacyStatsAsync(departmentId); + var permissions = await _permissionsService.GetAllPermissionsForDepartmentAsync(departmentId) ?? new List(); + + var preview = new RecordsActivationPreview + { + DepartmentId = departmentId, + FlagEnabled = state.FlagEnabled, + AlreadyActivated = state.Activated && state.CutoverState == RmsDepartmentCutoverState.Active, + LegacyLogCount = stats.LogCount, + LegacyUnitLogCount = stats.UnitLogCount, + LegacyEventTypeLogCount = stats.EventTypeLogCount, + SourceChecksum = ComputeSourceChecksum(stats), + PermissionMapping = BuildPermissionMapping(permissions), + SuggestedViewGroupRecordsLockToGroup = permissions.FirstOrDefault(p => p.PermissionType == (int)PermissionTypes.ViewGroupUsers)?.LockToGroup ?? false, + ProtectedDataPreflight = await ResolveProtectedDataPreflightAsync(departmentId) + }; + + if (!preview.FlagEnabled) + preview.Blockers.Add("The Records.System feature flag is not enabled for this department."); + + if (preview.ProtectedDataPreflight != "NotApplicable" && preview.ProtectedDataPreflight != "Enabled") + preview.Blockers.Add($"Protected Data is in state {preview.ProtectedDataPreflight}; activation is blocked until it settles."); + + return preview; + } + + public async Task ActivateAsync(int departmentId, string userId, string reason, bool viewGroupRecordsLockToGroup, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(userId)) + return RecordsActivationResult.Failed("An acting user is required."); + + var preview = await GetActivationPreviewAsync(departmentId); + if (preview.AlreadyActivated) + return RecordsActivationResult.Failed("Records is already active for this department."); + if (!preview.CanActivate) + return RecordsActivationResult.Failed(string.Join(" ", preview.Blockers)); + + var now = DateTime.UtcNow; + var existing = await _cutoversRepository.GetByDepartmentIdAsync(departmentId); + + _unitOfWork.CreateOrGetConnection(); + try + { + RmsDepartmentCutover cutover; + if (existing == null) + { + cutover = new RmsDepartmentCutover + { + DepartmentId = departmentId, + ProtectionId = Guid.NewGuid().ToString(), + ActivatedOn = now, + ActivatedByUserId = userId, + Reason = reason, + SourceLegacyLogCount = preview.LegacyLogCount, + SourceLegacyUnitLogCount = preview.LegacyUnitLogCount, + SourceChecksum = preview.SourceChecksum, + State = (int)RmsDepartmentCutoverState.Active, + PermissionMappingJson = JsonConvert.SerializeObject(preview.PermissionMapping, new Newtonsoft.Json.Converters.StringEnumConverter()), + CreatedOn = now, + ModifiedOn = now, + RowVersion = 1 + }; + cutover = await _cutoversRepository.InsertAsync(cutover, cancellationToken, true); + } + else + { + // Re-activation after a clean revert: the history event records the prior activation. + existing.ActivatedOn = now; + existing.ActivatedByUserId = userId; + existing.Reason = reason; + existing.SourceLegacyLogCount = preview.LegacyLogCount; + existing.SourceLegacyUnitLogCount = preview.LegacyUnitLogCount; + existing.SourceChecksum = preview.SourceChecksum; + existing.State = (int)RmsDepartmentCutoverState.Active; + existing.RevertedOn = null; + existing.RevertedByUserId = null; + existing.PermissionMappingJson = JsonConvert.SerializeObject(preview.PermissionMapping, new Newtonsoft.Json.Converters.StringEnumConverter()); + existing.ModifiedOn = now; + existing.RowVersion += 1; + cutover = await _cutoversRepository.UpdateAsync(existing, cancellationToken, true); + } + + var migrated = await MigratePermissionRowsAsync(departmentId, userId, preview.PermissionMapping, viewGroupRecordsLockToGroup, cancellationToken); + + await _cutoverEventsRepository.InsertAsync(new RmsDepartmentCutoverEvent + { + DepartmentId = departmentId, + RmsDepartmentCutoverId = cutover.RmsDepartmentCutoverId, + EventType = RmsDepartmentCutoverEventTypes.Activated, + ActorUserId = userId, + OccurredOn = now, + DetailJson = JsonConvert.SerializeObject(new { reason, preview.LegacyLogCount, preview.LegacyUnitLogCount, preview.LegacyEventTypeLogCount, preview.SourceChecksum, preview.ProtectedDataPreflight, viewGroupRecordsLockToGroup }), + CreatedOn = now + }, cancellationToken, true); + + await _cutoverEventsRepository.InsertAsync(new RmsDepartmentCutoverEvent + { + DepartmentId = departmentId, + RmsDepartmentCutoverId = cutover.RmsDepartmentCutoverId, + EventType = RmsDepartmentCutoverEventTypes.PermissionRowsMigrated, + ActorUserId = userId, + OccurredOn = now, + DetailJson = JsonConvert.SerializeObject(migrated), + CreatedOn = now + }, cancellationToken, true); + + await _accessAuditsRepository.InsertAsync(new RmsAccessAudit + { + DepartmentId = departmentId, + Action = (int)RmsAccessAuditAction.Activation, + ActorUserId = userId, + Purpose = "Records activation", + Successful = true, + OccurredOn = now, + OriginClient = (int)RmsOriginClient.Web, + DetailJson = JsonConvert.SerializeObject(new { cutover.RmsDepartmentCutoverId, reason }) + }, cancellationToken, true); + + _unitOfWork.CommitChanges(); + await InvalidateCacheAsync(departmentId); + + return new RecordsActivationResult { Success = true, Cutover = cutover }; + } + catch (Exception ex) + { + _unitOfWork.DiscardChanges(); + Logging.LogException(ex, $"Records activation failed for department {departmentId}."); + return RecordsActivationResult.Failed("Activation failed; no change was made."); + } + } + + public async Task GetRollbackOutcomeAsync(int departmentId) + { + var cutover = await _cutoversRepository.GetByDepartmentIdAsync(departmentId); + if (cutover == null || !cutover.IsActive) + return RecordsRollbackOutcome.CleanRevert; + + if (await _recordsRepository.CountFinalizedSinceAsync(departmentId, cutover.ActivatedOn) > 0) + return RecordsRollbackOutcome.NoRollback; + + return await _recordsRepository.CountCreatedSinceAsync(departmentId, cutover.ActivatedOn) > 0 + ? RecordsRollbackOutcome.DrainAndRevert + : RecordsRollbackOutcome.CleanRevert; + } + + public async Task RevertAsync(int departmentId, string userId, string reason, CancellationToken cancellationToken = default) + { + var cutover = await _cutoversRepository.GetByDepartmentIdAsync(departmentId); + if (cutover == null || !cutover.IsActive) + return RecordsActivationResult.Failed("Records is not active for this department."); + + var outcome = await GetRollbackOutcomeAsync(departmentId); + if (outcome == RecordsRollbackOutcome.NoRollback) + return RecordsActivationResult.Failed("A finalized Record exists; Records cannot be reverted. Use forward fixes (disable definitions, amend data) or restore from backup."); + if (outcome == RecordsRollbackOutcome.DrainAndRevert) + return RecordsActivationResult.Failed("Draft Records exist since activation; export and void them through the operator runbook before reverting."); + + var now = DateTime.UtcNow; + _unitOfWork.CreateOrGetConnection(); + try + { + cutover.State = (int)RmsDepartmentCutoverState.Reverted; + cutover.RevertedOn = now; + cutover.RevertedByUserId = userId; + cutover.ModifiedOn = now; + cutover.RowVersion += 1; + await _cutoversRepository.UpdateAsync(cutover, cancellationToken, true); + + await _cutoverEventsRepository.InsertAsync(new RmsDepartmentCutoverEvent + { + DepartmentId = departmentId, + RmsDepartmentCutoverId = cutover.RmsDepartmentCutoverId, + EventType = RmsDepartmentCutoverEventTypes.Reverted, + ActorUserId = userId, + OccurredOn = now, + DetailJson = JsonConvert.SerializeObject(new { reason, outcome = outcome.ToString() }), + CreatedOn = now + }, cancellationToken, true); + + await _accessAuditsRepository.InsertAsync(new RmsAccessAudit + { + DepartmentId = departmentId, + Action = (int)RmsAccessAuditAction.Activation, + ActorUserId = userId, + Purpose = "Records clean revert", + Successful = true, + OccurredOn = now, + OriginClient = (int)RmsOriginClient.Web, + DetailJson = JsonConvert.SerializeObject(new { cutover.RmsDepartmentCutoverId, reason }) + }, cancellationToken, true); + + _unitOfWork.CommitChanges(); + await InvalidateCacheAsync(departmentId); + + return new RecordsActivationResult { Success = true, Cutover = cutover }; + } + catch (Exception ex) + { + _unitOfWork.DiscardChanges(); + Logging.LogException(ex, $"Records revert failed for department {departmentId}."); + return RecordsActivationResult.Failed("Revert failed; no change was made."); + } + } + + public Task InvalidateCacheAsync(int departmentId) + { + _cacheProvider.Remove(string.Format(ModuleStateCacheKey, departmentId)); + return Task.CompletedTask; + } + + /// Registry section 4.6: copy CreateLog to CreateRecord + FinalizeRecords and DeleteLog to DeleteRecord verbatim; everything else takes its no-row default. + public static List BuildPermissionMapping(IEnumerable existing) + { + var rows = new List(); + var byType = (existing ?? Enumerable.Empty()).GroupBy(p => p.PermissionType).ToDictionary(g => g.Key, g => g.First()); + + foreach (var mapping in RecordPermissionCatalog.ActivationRowMapping) + { + byType.TryGetValue((int)mapping.Key, out var source); + foreach (var target in mapping.Value) + { + var descriptor = RecordPermissionCatalog.Get(target); + rows.Add(new RecordsPermissionMappingRow + { + Source = mapping.Key, + Target = target, + SourceRowExists = source != null, + SourceAction = source?.Action, + SourceData = source?.Data, + SourceLockToGroup = source?.LockToGroup ?? false, + EffectiveAction = source != null ? (PermissionActions)source.Action : descriptor.NoRowDefault, + Note = source != null ? "Copied verbatim from the existing row." : $"No row exists; the no-row default ({descriptor.NoRowDefault}) applies, matching today's Logs behavior." + }); + } + } + + foreach (var descriptor in RecordPermissionCatalog.All) + { + if (rows.Any(r => r.Target == descriptor.Type)) + continue; + + rows.Add(new RecordsPermissionMappingRow + { + Source = descriptor.Type, + Target = descriptor.Type, + SourceRowExists = false, + EffectiveAction = descriptor.NoRowDefault, + Note = descriptor.Type == PermissionTypes.ViewGroupRecords + ? "Department-wide unless the administrator locks it to group at activation." + : $"No row at activation; no-row default ({descriptor.NoRowDefault})." + }); + } + + return rows; + } + + private async Task> MigratePermissionRowsAsync(int departmentId, string userId, IEnumerable mapping, bool viewGroupRecordsLockToGroup, CancellationToken cancellationToken) + { + var written = new List(); + var current = (await _permissionsService.GetAllPermissionsForDepartmentAsync(departmentId) ?? new List()).ToDictionary(p => p.PermissionType, p => p); + + foreach (var row in mapping.Where(r => r.SourceRowExists && r.Source != r.Target)) + { + // Never overwrite a Records row an administrator already configured. + if (current.ContainsKey((int)row.Target)) + continue; + + await _permissionsService.SetPermissionForDepartmentAsync(departmentId, userId, row.Target, (PermissionActions)row.SourceAction.GetValueOrDefault((int)PermissionActions.Everyone), row.SourceData, row.SourceLockToGroup, cancellationToken); + written.Add(new { target = row.Target.ToString(), action = row.SourceAction, row.SourceData, lockToGroup = row.SourceLockToGroup }); + } + + if (viewGroupRecordsLockToGroup && !current.ContainsKey((int)PermissionTypes.ViewGroupRecords)) + { + await _permissionsService.SetPermissionForDepartmentAsync(departmentId, userId, PermissionTypes.ViewGroupRecords, PermissionActions.Everyone, null, true, cancellationToken); + written.Add(new { target = PermissionTypes.ViewGroupRecords.ToString(), action = (int)PermissionActions.Everyone, lockToGroup = true }); + } + + return written; + } + + private async Task ResolveProtectedDataPreflightAsync(int departmentId) + { + try + { + var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(departmentId); + if (policy == null) + return "NotApplicable"; + + var state = (DepartmentDataProtectionState)policy.State; + return state == DepartmentDataProtectionState.Disabled ? "NotApplicable" : state.ToString(); + } + catch (Exception ex) + { + // The subsystem being unreachable is treated as absent, per decision 18; the state is logged. + Logging.LogException(ex, $"Protected Data preflight could not be resolved for department {departmentId}; treating as NotApplicable."); + return "NotApplicable"; + } + } + + private static string ComputeSourceChecksum(RmsLegacyStats stats) + { + return RecordSnapshotSerializer.Checksum($"{stats.LogCount}:{stats.EventTypeLogCount}:{stats.MaxLogId}:{stats.UnitLogCount}:{stats.MaxUnitLogId}"); + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsNotificationService.cs b/Core/Resgrid.Services/Records/RecordsNotificationService.cs new file mode 100644 index 000000000..a007ebdb1 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsNotificationService.cs @@ -0,0 +1,99 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Author-targeted Records notifications (EventTypes 31). Message text is assembled from the record header + /// only: type, reference, reviewer, reason code and the reviewer's note to the author, plus a link. No + /// narrative or restricted-section content ever leaves through this path. + /// + public class RecordsNotificationService : IRecordsNotificationService + { + public const string ReturnedForCorrectionTitle = "Record returned for correction"; + private const int MaxReasonTextLength = 200; + + private readonly IRmsOperationalRecordsRepository _records; + private readonly ICommunicationService _communication; + private readonly IDepartmentsService _departments; + private readonly IDepartmentSettingsService _departmentSettings; + private readonly IUserProfileService _profiles; + + public RecordsNotificationService(IRmsOperationalRecordsRepository records, ICommunicationService communication, + IDepartmentsService departments, IDepartmentSettingsService departmentSettings, IUserProfileService profiles) + { + _records = records; + _communication = communication; + _departments = departments; + _departmentSettings = departmentSettings; + _profiles = profiles; + } + + public async Task NotifyReturnedForCorrectionAsync(int departmentId, string recordId, CancellationToken cancellationToken = default) + { + if (departmentId <= 0 || string.IsNullOrWhiteSpace(recordId)) + return false; + + var record = await _records.GetByIdForDepartmentAsync(departmentId, recordId); + if (record == null || record.State != (int)RmsRecordState.Returned || string.IsNullOrWhiteSpace(record.AuthorUserId)) + return false; + + cancellationToken.ThrowIfCancellationRequested(); + + var department = await _departments.GetDepartmentByIdAsync(departmentId, false); + var departmentNumber = await _departmentSettings.GetTextToCallNumberForDepartmentAsync(departmentId); + var author = await _profiles.GetProfileByUserIdAsync(record.AuthorUserId, false); + var reviewer = string.IsNullOrWhiteSpace(record.ReviewerUserId) ? null : await _profiles.GetProfileByUserIdAsync(record.ReviewerUserId, false); + var reviewerName = reviewer == null ? null : $"{reviewer.FirstName} {reviewer.LastName}".Trim(); + + var message = BuildReturnedForCorrectionMessage(record, reviewerName); + + try + { + return await _communication.SendNotificationAsync(record.AuthorUserId, departmentId, message, departmentNumber, department, ReturnedForCorrectionTitle, author); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Return-for-correction notification for record {recordId} could not be sent."); + return false; + } + } + + public static string BuildReturnedForCorrectionMessage(RmsOperationalRecord record, string reviewerName) + { + if (record == null) + throw new ArgumentNullException(nameof(record)); + + var reference = string.IsNullOrWhiteSpace(record.RecordNumber) ? record.DraftReference : record.RecordNumber; + var type = record.RecordType.HasValue ? ((RmsOperationalRecordType)record.RecordType.Value).ToString() : "Record"; + + var builder = new StringBuilder(); + builder.Append(type).Append(" record ").Append(reference).Append(" was returned for correction"); + if (!string.IsNullOrWhiteSpace(reviewerName)) + builder.Append(" by ").Append(reviewerName); + builder.Append('.'); + + if (!string.IsNullOrWhiteSpace(record.ReturnReasonCode)) + builder.Append(" Reason: ").Append(record.ReturnReasonCode.Trim()); + + if (!string.IsNullOrWhiteSpace(record.ReturnReasonText)) + { + var note = record.ReturnReasonText.Trim(); + if (note.Length > MaxReasonTextLength) + note = note.Substring(0, MaxReasonTextLength) + "…"; + builder.Append(" - ").Append(note); + } + + var baseUrl = (Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/'); + builder.Append(' ').Append(baseUrl).Append("/User/Records/Details/").Append(record.RmsOperationalRecordId); + + return builder.ToString(); + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsPrintLayoutService.cs b/Core/Resgrid.Services/Records/RecordsPrintLayoutService.cs new file mode 100644 index 000000000..48dd6f0e8 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsPrintLayoutService.cs @@ -0,0 +1,113 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// DepartmentDefault print layout (RMS plan section 4.10.1). Every save bumps the version so the provenance + /// footer on a print names the exact letterhead it rendered with; record content never depends on it. + /// + public class RecordsPrintLayoutService : IRecordsPrintLayoutService + { + private readonly IRmsRecordPrintLayoutsRepository _layouts; + + public RecordsPrintLayoutService(IRmsRecordPrintLayoutsRepository layouts) + { + _layouts = layouts; + } + + public async Task GetDepartmentDefaultAsync(int departmentId) + { + var row = await _layouts.GetAsync(departmentId, (int)RmsRecordPrintLayoutScope.DepartmentDefault, string.Empty); + if (row == null) + { + return new RmsRecordPrintLayout + { + DepartmentId = departmentId, + Scope = (int)RmsRecordPrintLayoutScope.DepartmentDefault, + DefinitionKey = string.Empty, + Version = 0, + Config = RecordsPrintLayoutConfig.Default() + }; + } + + row.Config = Parse(row.ConfigJson); + return row; + } + + public async Task SaveDepartmentDefaultAsync(int departmentId, string userId, RecordsPrintLayoutConfig config, CancellationToken cancellationToken = default) + { + config = Normalize(config ?? RecordsPrintLayoutConfig.Default()); + var now = DateTime.UtcNow; + var row = await _layouts.GetAsync(departmentId, (int)RmsRecordPrintLayoutScope.DepartmentDefault, string.Empty); + + if (row == null) + { + row = new RmsRecordPrintLayout + { + RmsRecordPrintLayoutId = Guid.NewGuid().ToString(), + DepartmentId = departmentId, + ProtectionId = Guid.NewGuid().ToString(), + Scope = (int)RmsRecordPrintLayoutScope.DepartmentDefault, + DefinitionKey = string.Empty, + Version = 1, + CreatedOn = now, + RowVersion = 1 + }; + } + else + { + row.Version += 1; + row.RowVersion += 1; + } + + row.ConfigJson = JsonConvert.SerializeObject(config); + row.ModifiedByUserId = userId; + row.ModifiedOn = now; + row = await _layouts.SaveOrUpdateAsync(row, cancellationToken, true); + row.Config = config; + return row; + } + + public static RecordsPrintLayoutConfig Parse(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return RecordsPrintLayoutConfig.Default(); + + try + { + return Normalize(JsonConvert.DeserializeObject(json) ?? RecordsPrintLayoutConfig.Default()); + } + catch (Exception ex) + { + Logging.LogException(ex, "Print layout config could not be parsed; using the generated default."); + return RecordsPrintLayoutConfig.Default(); + } + } + + public static RecordsPrintLayoutConfig Normalize(RecordsPrintLayoutConfig config) + { + config.PageSize = RecordsPrintLayoutConfig.NormalizePageSize(config.PageSize); + config.LetterheadLine1 = Trim(config.LetterheadLine1, 200); + config.LetterheadLine2 = Trim(config.LetterheadLine2, 200); + config.FooterText = Trim(config.FooterText, 500); + config.WatermarkLabel = Trim(config.WatermarkLabel, 40); + config.DateTimeFormat = Trim(config.DateTimeFormat, 40); + return config; + } + + private static string Trim(string value, int max) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + var trimmed = value.Trim(); + return trimmed.Length > max ? trimmed.Substring(0, max) : trimmed; + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsReportingService.cs b/Core/Resgrid.Services/Records/RecordsReportingService.cs new file mode 100644 index 000000000..240da5495 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsReportingService.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Merges legacy Logs with finalized Records into rows. Legacy rows never + /// migrate, so a Log and a Record never describe the same event and the union is exact. Records are included + /// only while the Records flag is on for the department and only in the finalized family (Finalized, Amended), + /// matching the fact that a legacy Log was final the moment it was saved. + /// + public class RecordsReportingService : IRecordsReportingService + { + private static readonly int[] FinalizedFamily = { (int)RmsRecordState.Finalized, (int)RmsRecordState.Amended }; + + private readonly IWorkLogsService _legacyLogs; + private readonly IRecordsCutoverService _cutover; + private readonly IRmsOperationalRecordsRepository _records; + private readonly IRmsRecordValueService _details; + private readonly IRmsRecordParticipantsRepository _participants; + private readonly IRmsRecordUnitResponsesRepository _units; + private readonly IRmsRecordGroupScopesRepository _scopes; + private readonly IRecordsAuthorizationService _authorization; + + public RecordsReportingService(IWorkLogsService legacyLogs, IRecordsCutoverService cutover, IRmsOperationalRecordsRepository records, + IRmsRecordValueService details, IRmsRecordParticipantsRepository participants, IRmsRecordUnitResponsesRepository units, + IRmsRecordGroupScopesRepository scopes, IRecordsAuthorizationService authorization) + { + _legacyLogs = legacyLogs; + _cutover = cutover; + _records = records; + _details = details; + _participants = participants; + _units = units; + _scopes = scopes; + _authorization = authorization; + } + + public async Task> GetActivityAsync(int departmentId, string viewerUserId, RmsOperationalRecordType type, DateTime start, DateTime end) + { + var entries = new List(); + + // Legacy rows stay readable after activation (plan section 4.1); they are the pre-cutover history. + if (Enum.IsDefined(typeof(LogTypes), (int)type)) + { + var logs = await _legacyLogs.GetAllLogsByDepartmentDateRangeAsync(departmentId, (LogTypes)(int)type, start, end) ?? new List(); + entries.AddRange(logs.Where(l => l != null).Select(FromLog)); + } + + var state = await _cutover.GetModuleStateAsync(departmentId); + if (state == null || !state.FlagEnabled) + return Order(entries); + + var definitionKey = RmsDefinitionKeys.ForType(type); + if (string.IsNullOrWhiteSpace(definitionKey)) + return Order(entries); + + var records = (await _records.GetByDefinitionAndStartedRangeAsync(departmentId, definitionKey, FinalizedFamily, start, end))?.Where(r => r != null && !r.DeletedOn.HasValue).ToList() + ?? new List(); + if (records.Count == 0) + return Order(entries); + + entries.AddRange(await MapVisibleAsync(departmentId, viewerUserId, records)); + return Order(entries); + } + + private async Task> MapVisibleAsync(int departmentId, string viewerUserId, List records) + { + var entries = new List(); + var ids = records.Select(r => r.RmsOperationalRecordId).ToList(); + var visible = await _authorization.GetVisibleGroupIdsAsync(viewerUserId, departmentId); + + var details = (await _details.GetDraftsForRecordsAsync(departmentId, ids) ?? Enumerable.Empty()) + .GroupBy(d => d.RecordId, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + var participants = (await _participants.GetForRecordsAsync(departmentId, ids) ?? Enumerable.Empty()).ToLookup(p => p.RecordId, StringComparer.Ordinal); + var units = (await _units.GetForRecordsAsync(departmentId, ids) ?? Enumerable.Empty()).ToLookup(u => u.RecordId, StringComparer.Ordinal); + var scopes = visible == null + ? null + : (await _scopes.GetForRecordsAsync(departmentId, ids) ?? Enumerable.Empty()).ToLookup(s => s.RecordId, StringComparer.Ordinal); + + foreach (var record in records) + { + var recordParticipants = participants[record.RmsOperationalRecordId].ToList(); + if (!IsVisible(record, recordParticipants, scopes?[record.RmsOperationalRecordId], visible, viewerUserId)) + continue; + + details.TryGetValue(record.RmsOperationalRecordId, out var detail); + entries.Add(FromRecord(record, detail, recordParticipants, units[record.RmsOperationalRecordId])); + } + + return entries; + } + + public async Task> GetCallActivityAsync(int departmentId, string viewerUserId, int callId) + { + var entries = new List(); + var logs = await _legacyLogs.GetLogsForCallAsync(callId) ?? new List(); + entries.AddRange(logs.Where(l => l != null && l.DepartmentId == departmentId).Select(FromLog)); + + var state = await _cutover.GetModuleStateAsync(departmentId); + if (state == null || !state.FlagEnabled) + return Order(entries); + + var records = (await _records.GetByCallAsync(departmentId, callId))? + .Where(r => r != null && !r.DeletedOn.HasValue && FinalizedFamily.Contains(r.State)).ToList() ?? new List(); + if (records.Count == 0) + return Order(entries); + + entries.AddRange(await MapVisibleAsync(departmentId, viewerUserId, records)); + return Order(entries); + } + + /// The same rule the Records queue applies (plan section 5.7.1), evaluated in memory over batch-loaded rows. + public static bool IsVisible(RmsOperationalRecord record, IEnumerable participants, IEnumerable scope, List visibleGroupIds, string viewerUserId) + { + if (record == null) + return false; + + if (!string.IsNullOrEmpty(viewerUserId) && ( + string.Equals(record.AuthorUserId, viewerUserId, StringComparison.Ordinal) || + string.Equals(record.OwnerUserId, viewerUserId, StringComparison.Ordinal) || + string.Equals(record.ReviewerUserId, viewerUserId, StringComparison.Ordinal) || + string.Equals(record.ApproverUserId, viewerUserId, StringComparison.Ordinal))) + return true; + + if (visibleGroupIds == null) + return true; + + if (!string.IsNullOrEmpty(viewerUserId) && participants != null && participants.Any(p => string.Equals(p.UserId, viewerUserId, StringComparison.Ordinal))) + return true; + + return scope != null && scope.Any(s => visibleGroupIds.Contains(s.DepartmentGroupId)); + } + + public static ReportActivityEntry FromLog(Log log) + { + return new ReportActivityEntry + { + Source = ReportActivitySources.LegacyLog, + SourceId = log.LogId.ToString(), + Type = (RmsOperationalRecordType)log.LogType.GetValueOrDefault(), + StartedOn = log.StartedOn, + EndedOn = log.EndedOn, + LoggedOn = log.LoggedOn, + LoggedByUserId = log.LoggedByUserId, + Course = log.Course, + CallId = log.CallId, + CallNumber = log.Call?.Number, + CallName = log.Call?.Name, + StationGroupId = log.StationGroupId, + Participants = (log.Users ?? new List()).Where(u => u != null).Select(u => new ReportActivityParticipant { UserId = u.UserId, UnitId = u.UnitId }).ToList(), + Units = (log.Units ?? new List()).Where(u => u != null).Select(u => new ReportActivityUnit + { + UnitId = u.UnitId, Dispatched = u.Dispatched, Enroute = u.Enroute, OnScene = u.OnScene, Released = u.Released, InQuarters = u.InQuarters + }).ToList() + }; + } + + public static ReportActivityEntry FromRecord(RmsOperationalRecord record, RmsOperationalRecordDetail detail, IEnumerable participants, IEnumerable units) + { + return new ReportActivityEntry + { + Source = ReportActivitySources.Record, + SourceId = record.RmsOperationalRecordId, + Type = (RmsOperationalRecordType)record.RecordType.GetValueOrDefault(), + StartedOn = record.StartedOn, + EndedOn = record.EndedOn, + LoggedOn = record.FinalizedOn ?? record.CreatedOn, + LoggedByUserId = record.AuthorUserId, + Course = detail?.Course, + CallId = record.CallId, + CallNumber = detail?.CallNumber, + CallName = detail?.CallName, + StationGroupId = record.StationGroupId, + Participants = (participants ?? Enumerable.Empty()).Where(p => p != null && !string.IsNullOrWhiteSpace(p.UserId)) + .Select(p => new ReportActivityParticipant { UserId = p.UserId }).ToList(), + Units = (units ?? Enumerable.Empty()).Where(u => u != null).Select(u => new ReportActivityUnit + { + UnitId = u.UnitId, Dispatched = u.Dispatched, Enroute = u.Enroute, OnScene = u.OnScene, Released = u.Released, InQuarters = u.InQuarters + }).ToList() + }; + } + + private static List Order(List entries) + { + return entries.OrderBy(e => e.StartedOn ?? e.LoggedOn).ThenBy(e => e.Source, StringComparer.Ordinal).ToList(); + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsSearchIndexMaintenanceService.cs b/Core/Resgrid.Services/Records/RecordsSearchIndexMaintenanceService.cs new file mode 100644 index 000000000..3c7f79cf0 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsSearchIndexMaintenanceService.cs @@ -0,0 +1,280 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Worker command 44 (RMS plan section 5.10): per activated department, compare the stored generation key with + /// (schemaVersion, protectedCatalogVersion, policyEpoch); rebuild on mismatch, otherwise catch up documents + /// modified since the last sweep. Narrative is indexed only for unprotected departments that opted in, and is + /// dropped by the rebuild an enrollment triggers (the degrade path). The projection table stays the source of + /// truth; the index is fully rebuildable from it. + /// + public class RecordsSearchIndexMaintenanceService : IRecordsSearchIndexMaintenanceService + { + private readonly IRmsDepartmentCutoversRepository _cutovers; + private readonly IRmsRecordSearchProjectionsRepository _projections; + private readonly IRmsSearchIndexStatesRepository _states; + private readonly IRmsOperationalRecordDetailsRepository _details; + private readonly IRecordsSearchIndexer _indexer; + private readonly IDepartmentDataProtectionService _dataProtection; + private readonly IDepartmentSettingsService _departmentSettings; + + public RecordsSearchIndexMaintenanceService(IRmsDepartmentCutoversRepository cutovers, IRmsRecordSearchProjectionsRepository projections, + IRmsSearchIndexStatesRepository states, IRmsOperationalRecordDetailsRepository details, IRecordsSearchIndexer indexer, + IDepartmentDataProtectionService dataProtection, IDepartmentSettingsService departmentSettings) + { + _cutovers = cutovers; + _projections = projections; + _states = states; + _details = details; + _indexer = indexer; + _dataProtection = dataProtection; + _departmentSettings = departmentSettings; + } + + public async Task SweepAsync(CancellationToken cancellationToken = default) + { + var result = new RecordsSearchIndexSweepResult(); + if (!SearchConfig.Enabled) + { + result.Skipped = true; + result.Message = "Search host disabled."; + return result; + } + + var active = (await _cutovers.GetActiveAsync())?.ToList() ?? new List(); + var rebuilds = 0; + + foreach (var cutover in active) + { + cancellationToken.ThrowIfCancellationRequested(); + result.DepartmentsChecked++; + + try + { + var generation = await ComputeGenerationAsync(cutover.DepartmentId); + var state = await _states.GetAsync(cutover.DepartmentId, RmsSearchIndexState.RecordsIndexName); + var needsRebuild = state == null || state.State != (int)RmsSearchIndexBuildState.Ready || !string.Equals(state.Generation, generation, StringComparison.Ordinal); + + if (needsRebuild) + { + if (rebuilds >= Math.Max(1, SearchConfig.MaxRebuildsPerSweep)) + continue; + + rebuilds++; + await RebuildAsync(cutover.DepartmentId, generation, state, result, cancellationToken); + } + else + { + await CatchUpAsync(cutover.DepartmentId, generation, state, result, cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + result.Errors++; + Logging.LogException(ex, $"Records search index maintenance failed for department {cutover.DepartmentId}."); + } + } + + if (result.DocumentsIndexed > 0 || result.DocumentsDeleted > 0 || result.DepartmentsRebuilt > 0) + await _indexer.CommitAsync(cancellationToken); + + result.Message = $"Checked {result.DepartmentsChecked} department(s); rebuilt {result.DepartmentsRebuilt}; indexed {result.DocumentsIndexed}; deleted {result.DocumentsDeleted}; errors {result.Errors}."; + return result; + } + + public async Task RebuildDepartmentAsync(int departmentId, CancellationToken cancellationToken = default) + { + var result = new RecordsSearchIndexSweepResult { DepartmentsChecked = 1 }; + if (!SearchConfig.Enabled) + { + result.Skipped = true; + result.Message = "Search host disabled."; + return result; + } + + var generation = await ComputeGenerationAsync(departmentId); + var state = await _states.GetAsync(departmentId, RmsSearchIndexState.RecordsIndexName); + await RebuildAsync(departmentId, generation, state, result, cancellationToken); + await _indexer.CommitAsync(cancellationToken); + result.Message = $"Rebuilt department {departmentId}: {result.DocumentsIndexed} document(s)."; + return result; + } + + private async Task RebuildAsync(int departmentId, string generation, RmsSearchIndexState state, RecordsSearchIndexSweepResult result, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + state = state ?? new RmsSearchIndexState { DepartmentId = departmentId, IndexName = RmsSearchIndexState.RecordsIndexName, CreatedOn = now }; + state.State = (int)RmsSearchIndexBuildState.Rebuilding; + state.Generation = generation; + ApplyGeneration(state, generation); + state.ModifiedOn = now; + state = await _states.SaveOrUpdateAsync(state, cancellationToken, true); + + try + { + await _indexer.DeleteDepartmentAsync(departmentId, cancellationToken); + + var includeNarrative = await NarrativeAllowedAsync(departmentId); + DateTime? lastModified = null; + var indexed = 0; + var skip = 0; + var batch = Math.Max(50, SearchConfig.IndexBatchSize); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var page = (await _projections.QueryAsync(departmentId, new RmsRecordQuery { IncludeLegacy = true, Skip = skip, Take = batch }))?.ToList() ?? new List(); + if (page.Count == 0) + break; + + var sources = await BuildSourcesAsync(departmentId, page, generation, includeNarrative); + indexed += await _indexer.IndexAsync(sources, cancellationToken); + lastModified = Max(lastModified, page.Max(p => p.ModifiedOn)); + + skip += page.Count; + if (page.Count < batch) + break; + } + + state.State = (int)RmsSearchIndexBuildState.Ready; + state.DocumentCount = indexed; + state.LastRebuiltOn = DateTime.UtcNow; + state.LastIndexedModifiedOn = lastModified; + state.ModifiedOn = DateTime.UtcNow; + await _states.SaveOrUpdateAsync(state, cancellationToken, true); + + result.DepartmentsRebuilt++; + result.DocumentsIndexed += indexed; + } + catch + { + state.State = (int)RmsSearchIndexBuildState.Failed; + state.ModifiedOn = DateTime.UtcNow; + await _states.SaveOrUpdateAsync(state, CancellationToken.None, true); + throw; + } + } + + private async Task CatchUpAsync(int departmentId, string generation, RmsSearchIndexState state, RecordsSearchIndexSweepResult result, CancellationToken cancellationToken) + { + var includeNarrative = await NarrativeAllowedAsync(departmentId); + var since = state.LastIndexedModifiedOn; + var batch = Math.Max(50, SearchConfig.IndexBatchSize); + var touched = false; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var page = (await _projections.GetModifiedSinceAsync(departmentId, since, batch))?.ToList() ?? new List(); + if (page.Count == 0) + break; + + var deleted = page.Where(p => p.DeletedOn.HasValue).ToList(); + var live = page.Where(p => !p.DeletedOn.HasValue).ToList(); + + foreach (var gone in deleted) + await _indexer.DeleteAsync(departmentId, gone.SourceType, gone.SourceId, cancellationToken); + + var indexed = await _indexer.IndexAsync(await BuildSourcesAsync(departmentId, live, generation, includeNarrative), cancellationToken); + result.DocumentsIndexed += indexed; + result.DocumentsDeleted += deleted.Count; + touched = true; + + var pageMax = page.Max(p => p.ModifiedOn); + if (since.HasValue && pageMax <= since.Value) + break; + since = pageMax; + + if (page.Count < batch) + break; + } + + if (touched) + { + state.LastIndexedModifiedOn = since; + state.DocumentCount = await _indexer.CountDocumentsAsync(departmentId); + state.ModifiedOn = DateTime.UtcNow; + await _states.SaveOrUpdateAsync(state, cancellationToken, true); + } + } + + private async Task> BuildSourcesAsync(int departmentId, List projections, string generation, bool includeNarrative) + { + var sources = new List(projections.Count); + foreach (var projection in projections) + { + string narrative = null; + if (includeNarrative && !projection.IsLegacy && !projection.DeletedOn.HasValue) + { + try + { + narrative = (await _details.GetDraftAsync(departmentId, projection.SourceId))?.Narrative; + } + catch (Exception ex) + { + Logging.LogException(ex, $"Narrative lookup failed for record {projection.SourceId}; indexing metadata only."); + } + } + + sources.Add(new RecordsSearchDocumentSource { Projection = projection, Narrative = narrative, Generation = generation }); + } + + return sources; + } + + /// Narrative search is available to unprotected departments that opted in and withdrawn on enrollment (plan section 5.10). + private async Task NarrativeAllowedAsync(int departmentId) + { + try + { + if (await _dataProtection.IsProtectionEnforcedAsync(departmentId)) + return false; + + var config = await _departmentSettings.GetRecordsSearchConfigAsync(departmentId, true); + return config != null && config.IndexNarrative; + } + catch (Exception ex) + { + // Unknown protection state never widens exposure: index metadata only. + Logging.LogException(ex, $"Narrative indexing eligibility could not be determined for department {departmentId}; indexing metadata only."); + return false; + } + } + + private async Task ComputeGenerationAsync(int departmentId) + { + var catalogVersion = 0; + long policyEpoch = 0; + try { catalogVersion = await _dataProtection.GetPinnedCatalogVersionAsync(departmentId); } catch (Exception ex) { Logging.LogException(ex); } + try { policyEpoch = (await _dataProtection.GetPolicyByDepartmentIdAsync(departmentId))?.PolicyEpoch ?? 0; } catch (Exception ex) { Logging.LogException(ex); } + return RecordsSearchGeneration.Compute(catalogVersion, policyEpoch); + } + + private static void ApplyGeneration(RmsSearchIndexState state, string generation) + { + var parts = (generation ?? string.Empty).Split('.'); + state.SchemaVersion = parts.Length > 0 && int.TryParse(parts[0], out var schema) ? schema : RecordsSearchGeneration.SchemaVersion; + state.ProtectedCatalogVersion = parts.Length > 1 && int.TryParse(parts[1], out var catalog) ? catalog : 0; + state.PolicyEpoch = parts.Length > 2 && long.TryParse(parts[2], out var epoch) ? epoch : 0; + } + + private static DateTime? Max(DateTime? a, DateTime b) + { + return !a.HasValue || b > a.Value ? b : a; + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsService.cs b/Core/Resgrid.Services/Records/RecordsService.cs new file mode 100644 index 000000000..55c825f39 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsService.cs @@ -0,0 +1,1319 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Queue; +using Resgrid.Model.Events; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// The Records (RMS) operational aggregate for the locked Logs-parity definitions (RMS plan sections + /// 4.1, 4.8, 5.3, 5.7). Every mutation runs in one unit of work: header, typed detail, participants, + /// units, group scope, search projection and the outbox row commit together, and the outbox rows are + /// dispatched in-process after commit. Draft rows always mirror the latest revision when no amendment + /// is open; finalize copies them under the new revision rather than moving them. + /// + public class RecordsService : IRecordsService + { + private const string AttestationStatementVersion = "1"; + private const int NumberAllocationRetries = 3; + + private readonly IRmsOperationalRecordsRepository _records; + private readonly IRmsRecordValueService _details; + private readonly IRmsRecordParticipantsRepository _participants; + private readonly IRmsRecordUnitResponsesRepository _units; + private readonly IRmsRecordAttachmentsRepository _attachments; + private readonly IRmsRevisionsRepository _revisions; + private readonly IRmsRecordGroupScopesRepository _scopes; + private readonly IRmsRecordSharesRepository _shares; + private readonly IRmsRecordSearchProjectionsRepository _projections; + private readonly IRmsAccessAuditsRepository _audits; + private readonly IDomainEventOutboxService _outbox; + private readonly IRecordsCutoverService _cutover; + private readonly IDepartmentSettingsService _settings; + private readonly IDepartmentGroupsService _groups; + private readonly IUserProfileService _profiles; + private readonly IUnitsService _unitsService; + private readonly ICallsService _calls; + private readonly IDepartmentDataProtectionService _dataProtection; + private readonly IUnitOfWork _unitOfWork; + private readonly IOutboundQueueProvider _outboundQueue; + private readonly IRecordAttachmentScanner _attachmentScanner; + + public RecordsService(IRmsOperationalRecordsRepository records, IRmsRecordValueService details, + IRmsRecordParticipantsRepository participants, IRmsRecordUnitResponsesRepository units, IRmsRecordAttachmentsRepository attachments, + IRmsRevisionsRepository revisions, IRmsRecordGroupScopesRepository scopes, IRmsRecordSharesRepository shares, + IRmsRecordSearchProjectionsRepository projections, IRmsAccessAuditsRepository audits, IDomainEventOutboxService outbox, + IRecordsCutoverService cutover, IDepartmentSettingsService settings, IDepartmentGroupsService groups, IUserProfileService profiles, + IUnitsService unitsService, ICallsService calls, IDepartmentDataProtectionService dataProtection, IUnitOfWork unitOfWork, + IOutboundQueueProvider outboundQueue, IRecordAttachmentScanner attachmentScanner) + { + _records = records; + _details = details; + _participants = participants; + _units = units; + _attachments = attachments; + _revisions = revisions; + _scopes = scopes; + _shares = shares; + _projections = projections; + _audits = audits; + _outbox = outbox; + _cutover = cutover; + _settings = settings; + _groups = groups; + _profiles = profiles; + _unitsService = unitsService; + _calls = calls; + _dataProtection = dataProtection; + _unitOfWork = unitOfWork; + _outboundQueue = outboundQueue; + _attachmentScanner = attachmentScanner; + } + + #region Create / save + + public async Task CreateDraftAsync(int departmentId, string userId, RecordDraftInput input, CancellationToken cancellationToken = default) + { + if (input == null) throw new ArgumentNullException(nameof(input)); + if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentException("An acting user is required.", nameof(userId)); + if (!RmsDefinitionKeys.LockedTypes.TryGetValue(input.DefinitionKey ?? string.Empty, out var recordType)) + throw new ArgumentException($"'{input.DefinitionKey}' is not a published definition.", nameof(input)); + + await EnsureRecordsUsableAsync(departmentId); + + if (!string.IsNullOrWhiteSpace(input.IdempotencyKey)) + { + var existing = await _records.GetByIdempotencyKeyAsync(departmentId, input.IdempotencyKey); + if (existing != null) + return await HydrateAsync(existing, false); + } + + var now = DateTime.UtcNow; + var recordId = ResolveClientRecordId(input.ClientRecordId); + var authorGroup = await _groups.GetGroupForUserAsync(userId, departmentId); + + var record = new RmsOperationalRecord + { + RmsOperationalRecordId = recordId, + DepartmentId = departmentId, + ProtectionId = Guid.NewGuid().ToString(), + DefinitionKey = input.DefinitionKey, + DefinitionVersion = RmsDefinitionKeys.LockedDefinitionVersion, + RecordType = (int)recordType, + LifecyclePreset = (int)RmsDefinitionKeys.LockedDefaultPreset, + State = (int)RmsRecordState.Draft, + DraftReference = NewDraftReference(), + StationGroupId = input.StationGroupId ?? authorGroup?.DepartmentGroupId, + CallId = input.CallId, + ExternalId = input.ExternalId, + AuthorUserId = userId, + AuthorGroupIdSnapshot = authorGroup?.DepartmentGroupId, + OwnerUserId = userId, + StartedOn = input.StartedOn, + EndedOn = input.EndedOn, + IdempotencyKey = string.IsNullOrWhiteSpace(input.IdempotencyKey) ? null : input.IdempotencyKey, + OriginClient = (int)input.OriginClient, + CreatedOn = now, + CreatedByUserId = userId, + ModifiedOn = now, + ModifiedByUserId = userId, + RowVersion = 1 + }; + + var details = new RmsOperationalRecordDetail + { + RmsOperationalRecordDetailId = Guid.NewGuid().ToString(), + DepartmentId = departmentId, + ProtectionId = Guid.NewGuid().ToString(), + RecordId = recordId, + CreatedOn = now, + ModifiedOn = now, + RowVersion = 1 + }; + ApplyDetails(details, input.Details); + await ApplyCallSnapshotAsync(departmentId, details, input.CallId); + ValidateDefinitionRequirements(recordType, details); + + var participants = await BuildParticipantsAsync(departmentId, recordId, input.Participants, now); + var units = await BuildUnitsAsync(departmentId, recordId, input.Units, now); + record.DisplaySummary = BuildDisplaySummary(recordType, record, details, units); + + var outboxIds = new List(); + await InTransactionAsync(async () => + { + await _records.InsertAsync(record, cancellationToken, true); + await _details.InsertAsync(details, cancellationToken); + foreach (var participant in participants) + await _participants.InsertAsync(participant, cancellationToken, true); + foreach (var unit in units) + await _units.InsertAsync(unit, cancellationToken, true); + + var aggregate = new RecordAggregate { Record = record, Details = details, Participants = participants, Units = units }; + await RecomputeGroupScopeAsync(aggregate, cancellationToken); + await UpsertProjectionAsync(aggregate, cancellationToken); + + outboxIds.Add((await EnqueueLifecycleEventAsync(record, null, WorkflowTriggerEventType.RecordCreated, RmsRecordState.Draft, RmsRecordState.Draft, null, cancellationToken)).DomainEventOutboxId); + await AuditAsync(departmentId, userId, recordId, null, RmsAccessAuditAction.Change, "Create draft", input.OriginClient, cancellationToken, + string.IsNullOrWhiteSpace(input.DuplicateContinueReason) ? null : new { duplicateContinueReason = input.DuplicateContinueReason }); + }); + + await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); + return await GetAsync(departmentId, recordId, false); + } + + public async Task SaveDraftAsync(int departmentId, string userId, string recordId, long expectedRowVersion, RecordDraftInput input, CancellationToken cancellationToken = default) + { + if (input == null) throw new ArgumentNullException(nameof(input)); + var record = await LoadRecordAsync(departmentId, recordId); + var state = (RmsRecordState)record.State; + + if (!RmsLifecycle.IsEditable(state) && record.AmendsRevisionId == null) + throw new RecordTransitionException(recordId, state, state, "the Record is not editable in this state"); + + var recordType = (RmsOperationalRecordType)record.RecordType.GetValueOrDefault(); + var now = DateTime.UtcNow; + + await InTransactionAsync(async () => + { + await GuardVersionAsync(record, expectedRowVersion, cancellationToken); + + var details = await _details.GetDraftAsync(departmentId, recordId) ?? new RmsOperationalRecordDetail + { + RmsOperationalRecordDetailId = Guid.NewGuid().ToString(), + DepartmentId = departmentId, + ProtectionId = Guid.NewGuid().ToString(), + RecordId = recordId, + CreatedOn = now, + RowVersion = 0 + }; + ApplyDetails(details, input.Details); + if (input.CallId != record.CallId) + await ApplyCallSnapshotAsync(departmentId, details, input.CallId); + ValidateDefinitionRequirements(recordType, details); + details.ModifiedOn = now; + details.RowVersion += 1; + await _details.SaveOrUpdateAsync(details, cancellationToken); + + await _participants.DeleteDraftForRecordAsync(departmentId, recordId, cancellationToken); + await _units.DeleteDraftForRecordAsync(departmentId, recordId, cancellationToken); + var participants = await BuildParticipantsAsync(departmentId, recordId, input.Participants, now); + var units = await BuildUnitsAsync(departmentId, recordId, input.Units, now); + foreach (var participant in participants) + await _participants.InsertAsync(participant, cancellationToken, true); + foreach (var unit in units) + await _units.InsertAsync(unit, cancellationToken, true); + + record.CallId = input.CallId; + record.StationGroupId = input.StationGroupId ?? record.StationGroupId; + record.ExternalId = input.ExternalId; + record.StartedOn = input.StartedOn; + record.EndedOn = input.EndedOn; + record.DisplaySummary = BuildDisplaySummary(recordType, record, details, units); + record.ModifiedOn = now; + record.ModifiedByUserId = userId; + if (state == RmsRecordState.Returned) + record.State = (int)RmsRecordState.Draft; + await _records.UpdateAsync(record, cancellationToken, true); + + var aggregate = new RecordAggregate { Record = record, Details = details, Participants = participants, Units = units }; + await RecomputeGroupScopeAsync(aggregate, cancellationToken); + await UpsertProjectionAsync(aggregate, cancellationToken); + // Draft autosaves emit no Workflow event (RMS plan section 5.6). + }); + + return await GetAsync(departmentId, recordId, false); + } + + #endregion + + #region Lifecycle transitions + + public async Task SubmitForReviewAsync(int departmentId, string userId, string recordId, long expectedRowVersion, CancellationToken cancellationToken = default) + { + var record = await LoadRecordAsync(departmentId, recordId); + var from = (RmsRecordState)record.State; + RequireTransition(record, from, RmsRecordState.ReadyForReview); + + var now = DateTime.UtcNow; + var outboxIds = new List(); + await InTransactionAsync(async () => + { + await GuardVersionAsync(record, expectedRowVersion, cancellationToken); + record.State = (int)RmsRecordState.ReadyForReview; + record.SubmittedForReviewOn = now; + record.ReviewDueOn = now.AddHours(await _settings.GetRecordsReviewDueHoursAsync(departmentId)); + record.ModifiedOn = now; + record.ModifiedByUserId = userId; + await _records.UpdateAsync(record, cancellationToken, true); + await RefreshProjectionAsync(record, cancellationToken); + outboxIds.Add((await EnqueueLifecycleEventAsync(record, null, WorkflowTriggerEventType.RecordSubmittedForReview, from, RmsRecordState.ReadyForReview, null, cancellationToken)).DomainEventOutboxId); + await AuditAsync(departmentId, userId, recordId, null, RmsAccessAuditAction.Change, "Submit for review", (RmsOriginClient)record.OriginClient, cancellationToken); + }); + + await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); + return await GetAsync(departmentId, recordId, false); + } + + public async Task ReturnForCorrectionAsync(int departmentId, string userId, string recordId, string reasonCode, string reasonText, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(reasonCode)) throw new ArgumentException("A reason code is required to return a Record.", nameof(reasonCode)); + var record = await LoadRecordAsync(departmentId, recordId); + var from = (RmsRecordState)record.State; + RequireTransition(record, from, RmsRecordState.Returned); + + var now = DateTime.UtcNow; + var outboxIds = new List(); + await InTransactionAsync(async () => + { + await GuardVersionAsync(record, record.RowVersion, cancellationToken); + record.State = (int)RmsRecordState.Returned; + record.ReturnedOn = now; + record.ReturnReasonCode = reasonCode; + record.ReturnReasonText = reasonText; + record.ReturnCount += 1; + record.ReviewerUserId = userId; + record.ModifiedOn = now; + record.ModifiedByUserId = userId; + await _records.UpdateAsync(record, cancellationToken, true); + await RefreshProjectionAsync(record, cancellationToken); + outboxIds.Add((await EnqueueLifecycleEventAsync(record, null, WorkflowTriggerEventType.RecordReturnedForCorrection, from, RmsRecordState.Returned, reasonCode, cancellationToken)).DomainEventOutboxId); + await AuditAsync(departmentId, userId, recordId, null, RmsAccessAuditAction.Change, "Return for correction", RmsOriginClient.Web, cancellationToken, new { reasonCode }); + }); + + await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); + + // Notification EventTypes 31 (RecordReturnedForCorrection, RMS plan section 4.7): author-targeted, delivered by + // the notification worker through IRecordsNotificationService. Enqueued after commit and never blocking. + if (!await _outboundQueue.EnqueueNotification(new NotificationItem + { + DepartmentId = departmentId, + Type = (int)EventTypes.RecordReturnedForCorrection, + Value = recordId, + UserId = record.AuthorUserId + })) + Logging.LogError($"Unable to enqueue the return-for-correction notification for record {recordId}."); + + return await GetAsync(departmentId, recordId, false); + } + + public async Task ApproveAsync(int departmentId, string userId, string recordId, CancellationToken cancellationToken = default) + { + var record = await LoadRecordAsync(departmentId, recordId); + var from = (RmsRecordState)record.State; + RequireTransition(record, from, RmsRecordState.Approved); + if (string.Equals(record.AuthorUserId, userId, StringComparison.Ordinal)) + throw new RecordTransitionException(recordId, from, RmsRecordState.Approved, "the approver may not be the author"); + + var now = DateTime.UtcNow; + await InTransactionAsync(async () => + { + await GuardVersionAsync(record, record.RowVersion, cancellationToken); + record.State = (int)RmsRecordState.Approved; + record.ApprovedOn = now; + record.ApproverUserId = userId; + record.ModifiedOn = now; + record.ModifiedByUserId = userId; + await _records.UpdateAsync(record, cancellationToken, true); + await RefreshProjectionAsync(record, cancellationToken); + // RecordApproved (103) is an RMS-1B trigger; no event until it is appended. + await AuditAsync(departmentId, userId, recordId, null, RmsAccessAuditAction.Sign, "Approve", RmsOriginClient.Web, cancellationToken); + }); + + return await GetAsync(departmentId, recordId, false); + } + + public async Task FinalizeAsync(int departmentId, string userId, string recordId, long expectedRowVersion, string attestationStatementVersion, string reasonCode, string reasonText, CancellationToken cancellationToken = default) + { + var record = await LoadRecordAsync(departmentId, recordId); + var from = (RmsRecordState)record.State; + var isAmendment = record.AmendsRevisionId != null; + var to = isAmendment ? RmsRecordState.Amended : RmsRecordState.Finalized; + RequireTransition(record, from, to); + + if (isAmendment && string.IsNullOrWhiteSpace(reasonCode)) + throw new ArgumentException("A reason code is required to finalize an amendment.", nameof(reasonCode)); + + var now = DateTime.UtcNow; + var recordType = (RmsOperationalRecordType)record.RecordType.GetValueOrDefault(); + var outboxIds = new List(); + RmsRevision revision = null; + + await InTransactionAsync(async () => + { + await GuardVersionAsync(record, expectedRowVersion, cancellationToken); + + var draft = await HydrateDraftAsync(record); + ValidateDefinitionRequirements(recordType, draft.Details); + ValidateForFinalization(recordType, draft); + + if (string.IsNullOrWhiteSpace(record.RecordNumber)) + record.RecordNumber = await AllocateRecordNumberAsync(record, cancellationToken); + + revision = await WriteRevisionAsync(record, draft, isAmendment ? RmsRevisionTransition.Amended : RmsRevisionTransition.Finalized, + userId, reasonCode, reasonText, attestationStatementVersion ?? AttestationStatementVersion, now, cancellationToken); + + var priorState = from; + record.State = (int)to; + if (!isAmendment) + { + record.FinalizedOn = now; + record.FinalizedByUserId = userId; + } + record.CurrentRevisionId = revision.RmsRevisionId; + record.RevisionCount = revision.RevisionNumber; + record.AmendsRevisionId = null; + record.ModifiedOn = now; + record.ModifiedByUserId = userId; + await _records.UpdateAsync(record, cancellationToken, true); + + draft.Record = record; + await RecomputeGroupScopeAsync(draft, cancellationToken); + await UpsertProjectionAsync(draft, cancellationToken); + + var lifecycle = await EnqueueLifecycleEventAsync(record, revision, isAmendment ? WorkflowTriggerEventType.RecordAmended : WorkflowTriggerEventType.RecordFinalized, priorState, to, reasonCode, cancellationToken); + outboxIds.Add(lifecycle.DomainEventOutboxId); + + // Legacy LogAdded compatibility (plan section 5.6): eligible Logs-parity records also project the exact + // log.* contract once, at finalize, through the same outbox. Unit Activity never emitted LogAdded and + // does not start now; amendments never re-fire it. + if (!isAmendment && LogAddedCompatibility.IsEligible(record)) + outboxIds.Add((await EnqueueLogAddedCompatibilityAsync(record, revision, draft.Details, lifecycle.EventId, now, cancellationToken)).DomainEventOutboxId); + await AuditAsync(departmentId, userId, recordId, revision.RmsRevisionId, RmsAccessAuditAction.Sign, isAmendment ? "Finalize amendment" : "Finalize", (RmsOriginClient)record.OriginClient, cancellationToken, new { revision.RevisionNumber, revision.Checksum, reasonCode }); + }); + + await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); + return await GetAsync(departmentId, recordId, true); + } + + public async Task OpenAmendmentAsync(int departmentId, string userId, string recordId, CancellationToken cancellationToken = default) + { + var record = await LoadRecordAsync(departmentId, recordId); + var state = (RmsRecordState)record.State; + if (!RmsLifecycle.CanTransition((RmsLifecyclePreset)record.LifecyclePreset, state, RmsRecordState.Amended)) + throw new RecordTransitionException(recordId, state, RmsRecordState.Amended); + if (record.AmendsRevisionId != null) + throw new RecordTransitionException(recordId, state, RmsRecordState.Amended, "an amendment draft is already open"); + + await InTransactionAsync(async () => + { + await GuardVersionAsync(record, record.RowVersion, cancellationToken); + record.AmendsRevisionId = record.CurrentRevisionId; + record.OwnerUserId = userId; + record.ModifiedOn = DateTime.UtcNow; + record.ModifiedByUserId = userId; + await _records.UpdateAsync(record, cancellationToken, true); + await AuditAsync(departmentId, userId, recordId, record.CurrentRevisionId, RmsAccessAuditAction.Change, "Open amendment", RmsOriginClient.Web, cancellationToken); + }); + + return await GetAsync(departmentId, recordId, true); + } + + public async Task AbandonAmendmentAsync(int departmentId, string userId, string recordId, CancellationToken cancellationToken = default) + { + var record = await LoadRecordAsync(departmentId, recordId); + if (record.AmendsRevisionId == null) + return await GetAsync(departmentId, recordId, true); + + var revision = await _revisions.GetByIdForDepartmentAsync(departmentId, record.CurrentRevisionId); + var snapshot = RecordSnapshotSerializer.Deserialize(revision?.SnapshotJson); + if (snapshot == null) + throw new InvalidOperationException($"Revision {record.CurrentRevisionId} has no snapshot to restore."); + + var now = DateTime.UtcNow; + await InTransactionAsync(async () => + { + await GuardVersionAsync(record, record.RowVersion, cancellationToken); + await RestoreDraftFromSnapshotAsync(record, snapshot, now, cancellationToken); + record.AmendsRevisionId = null; + record.ModifiedOn = now; + record.ModifiedByUserId = userId; + await _records.UpdateAsync(record, cancellationToken, true); + var aggregate = await HydrateDraftAsync(record); + await RecomputeGroupScopeAsync(aggregate, cancellationToken); + await UpsertProjectionAsync(aggregate, cancellationToken); + await AuditAsync(departmentId, userId, recordId, record.CurrentRevisionId, RmsAccessAuditAction.Change, "Abandon amendment", RmsOriginClient.Web, cancellationToken); + }); + + return await GetAsync(departmentId, recordId, true); + } + + public async Task VoidAsync(int departmentId, string userId, string recordId, string reasonCode, string reasonText, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(reasonCode)) throw new ArgumentException("A reason code is required to void a Record.", nameof(reasonCode)); + var record = await LoadRecordAsync(departmentId, recordId); + var from = (RmsRecordState)record.State; + RequireTransition(record, from, RmsRecordState.Voided); + if (record.AmendsRevisionId != null) + throw new RecordTransitionException(recordId, from, RmsRecordState.Voided, "abandon the open amendment first"); + + var now = DateTime.UtcNow; + var outboxIds = new List(); + await InTransactionAsync(async () => + { + await GuardVersionAsync(record, record.RowVersion, cancellationToken); + var draft = await HydrateDraftAsync(record); + var revision = await WriteRevisionAsync(record, draft, RmsRevisionTransition.Voided, userId, reasonCode, reasonText, AttestationStatementVersion, now, cancellationToken); + + record.State = (int)RmsRecordState.Voided; + record.VoidedOn = now; + record.VoidedByUserId = userId; + record.VoidReasonCode = reasonCode; + record.VoidReasonText = reasonText; + record.CurrentRevisionId = revision.RmsRevisionId; + record.RevisionCount = revision.RevisionNumber; + record.ModifiedOn = now; + record.ModifiedByUserId = userId; + await _records.UpdateAsync(record, cancellationToken, true); + + draft.Record = record; + await UpsertProjectionAsync(draft, cancellationToken); + outboxIds.Add((await EnqueueLifecycleEventAsync(record, revision, WorkflowTriggerEventType.RecordVoided, from, RmsRecordState.Voided, reasonCode, cancellationToken)).DomainEventOutboxId); + await AuditAsync(departmentId, userId, recordId, revision.RmsRevisionId, RmsAccessAuditAction.Change, "Void", RmsOriginClient.Web, cancellationToken, new { reasonCode }); + }); + + await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); + return await GetAsync(departmentId, recordId, true); + } + + public async Task CancelAsync(int departmentId, string userId, string recordId, CancellationToken cancellationToken = default) + { + var record = await LoadRecordAsync(departmentId, recordId); + var from = (RmsRecordState)record.State; + RequireTransition(record, from, RmsRecordState.Cancelled); + + var now = DateTime.UtcNow; + var outboxIds = new List(); + await InTransactionAsync(async () => + { + await GuardVersionAsync(record, record.RowVersion, cancellationToken); + record.State = (int)RmsRecordState.Cancelled; + record.CancelledOn = now; + record.CancelledByUserId = userId; + record.ModifiedOn = now; + record.ModifiedByUserId = userId; + await _records.UpdateAsync(record, cancellationToken, true); + await RefreshProjectionAsync(record, cancellationToken); + // Under OnFinalize numbering a cancelled draft holds no number, so the disposition is "none". + outboxIds.Add((await EnqueueLifecycleEventAsync(record, null, WorkflowTriggerEventType.RecordCancelled, from, RmsRecordState.Cancelled, null, cancellationToken, + new { number_disposition = record.RecordNumber == null ? "none" : "voided", record.RecordNumber })).DomainEventOutboxId); + await AuditAsync(departmentId, userId, recordId, null, RmsAccessAuditAction.Change, "Cancel", RmsOriginClient.Web, cancellationToken); + }); + + await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); + return await GetAsync(departmentId, recordId, false); + } + + public async Task ReassignDraftAsync(int departmentId, string userId, string recordId, string newOwnerUserId, string reason, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(newOwnerUserId)) throw new ArgumentException("A new owner is required.", nameof(newOwnerUserId)); + var record = await LoadRecordAsync(departmentId, recordId); + var state = (RmsRecordState)record.State; + if (RmsLifecycle.IsFinalizedFamily(state) && record.AmendsRevisionId == null || RmsLifecycle.IsTerminal(state)) + throw new RecordTransitionException(recordId, state, state, "only an unfinalized Record or an open amendment can be reassigned"); + + await InTransactionAsync(async () => + { + await GuardVersionAsync(record, record.RowVersion, cancellationToken); + var previousOwner = record.OwnerUserId; + record.OwnerUserId = newOwnerUserId; + record.ModifiedOn = DateTime.UtcNow; + record.ModifiedByUserId = userId; + await _records.UpdateAsync(record, cancellationToken, true); + await RefreshProjectionAsync(record, cancellationToken); + await AuditAsync(departmentId, userId, recordId, null, RmsAccessAuditAction.Admin, "Reassign draft", RmsOriginClient.Web, cancellationToken, new { previousOwner, newOwnerUserId, reason }); + }); + + return await GetAsync(departmentId, recordId, false); + } + + #endregion + + #region Reads + + public async Task GetAsync(int departmentId, string recordId, bool includeRevisions = false) + { + var record = await _records.GetByIdForDepartmentAsync(departmentId, recordId); + if (record == null || record.DeletedOn.HasValue) + return null; + + return await HydrateAsync(record, includeRevisions); + } + + public async Task> GetOutstandingForOwnerAsync(int departmentId, string userId) + { + if (string.IsNullOrWhiteSpace(userId)) + return new List(); + + var open = new[] { (int)RmsRecordState.Draft, (int)RmsRecordState.ReadyForReview, (int)RmsRecordState.Returned, (int)RmsRecordState.Approved }; + var records = await _records.GetByOwnerAndStatesAsync(departmentId, userId, open) ?? Enumerable.Empty(); + return records.Where(r => r != null && !r.DeletedOn.HasValue).OrderBy(r => r.CreatedOn).ToList(); + } + + public async Task> GetDuplicateCandidatesAsync(int departmentId, string definitionKey, int callId) + { + var records = await _records.GetByCallAsync(departmentId, callId); + return (records ?? Enumerable.Empty()) + .Where(r => string.Equals(r.DefinitionKey, definitionKey, StringComparison.Ordinal) && !RmsLifecycle.IsTerminal((RmsRecordState)r.State)) + .ToList(); + } + + public async Task> QueryAsync(int departmentId, RmsRecordQuery query) + { + return (await _projections.QueryAsync(departmentId, query ?? new RmsRecordQuery()))?.ToList() ?? new List(); + } + + public Task CountAsync(int departmentId, RmsRecordQuery query) + { + return _projections.CountAsync(departmentId, query ?? new RmsRecordQuery()); + } + + public async Task> GetYearsAsync(int departmentId) + { + return (await _projections.GetYearsAsync(departmentId))?.ToList() ?? new List(); + } + + public async Task> GetRevisionsAsync(int departmentId, string recordId) + { + return (await _revisions.GetForRecordAsync(departmentId, recordId))?.ToList() ?? new List(); + } + + public async Task GetRevisionSnapshotAsync(int departmentId, string revisionId) + { + var revision = await _revisions.GetByIdForDepartmentAsync(departmentId, revisionId); + return revision == null ? null : RecordSnapshotSerializer.Deserialize(revision.SnapshotJson); + } + + public async Task> DiffRevisionsAsync(int departmentId, string fromRevisionId, string toRevisionId, bool canViewRestricted) + { + var from = await GetRevisionSnapshotAsync(departmentId, fromRevisionId); + var to = await GetRevisionSnapshotAsync(departmentId, toRevisionId); + if (from == null || to == null) + throw new KeyNotFoundException("One of the revisions does not exist in this department."); + + return RecordSnapshotSerializer.Diff(from, to, canViewRestricted); + } + + public Task RecordAccessAsync(int departmentId, string userId, string recordId, string revisionId, RmsAccessAuditAction action, string purpose = null, string ipAddress = null, RmsOriginClient originClient = RmsOriginClient.Web) + { + return _audits.InsertAsync(new RmsAccessAudit + { + DepartmentId = departmentId, + RecordId = recordId, + RevisionId = revisionId, + Action = (int)action, + ActorUserId = userId, + Purpose = purpose, + OriginClient = (int)originClient, + IpAddress = ipAddress, + Successful = true, + OccurredOn = DateTime.UtcNow + }, CancellationToken.None, true); + } + + #endregion + + public async Task> GetProjectionsByIdsAsync(int departmentId, IEnumerable recordIds) + { + return (await _projections.GetByIdsAsync(departmentId, recordIds))?.ToList() ?? new List(); + } + + #region Attachments + + public async Task AddAttachmentAsync(int departmentId, string userId, string recordId, string fileName, string contentType, byte[] data, string description, CancellationToken cancellationToken = default) + { + if (data == null || data.Length == 0) throw new ArgumentException("Attachment content is required.", nameof(data)); + var record = await LoadRecordAsync(departmentId, recordId); + if (RmsLifecycle.IsTerminal((RmsRecordState)record.State)) + throw new RecordTransitionException(recordId, (RmsRecordState)record.State, (RmsRecordState)record.State, "attachments cannot be added to a voided or cancelled Record"); + + // Media hygiene (plan section 4.7): images are re-encoded so EXIF/XMP/IPTC never reach storage, active + // content is refused, and the bytes pass the configured scanner before they are stored. + var hygiene = RecordAttachmentHygiene.Sanitize(fileName, contentType, data); + var scan = await _attachmentScanner.ScanAsync(hygiene.FileName, hygiene.ContentType, hygiene.Data, cancellationToken) ?? new RecordAttachmentScanResult(); + if (scan.State == RmsAttachmentScanState.Rejected) + throw new RecordAttachmentRejectedException($"Attachment '{hygiene.FileName}' was rejected by the scanner: {scan.Detail}"); + + var now = DateTime.UtcNow; + var attachment = new RmsRecordAttachment + { + RmsRecordAttachmentId = Guid.NewGuid().ToString(), + DepartmentId = departmentId, + ProtectionId = Guid.NewGuid().ToString(), + RecordId = recordId, + FileName = hygiene.FileName, + ContentType = hygiene.ContentType, + ByteSize = hygiene.Data.LongLength, + Checksum = RecordSnapshotSerializer.Checksum(hygiene.Data), + Data = hygiene.Data, + Description = description, + UploadedByUserId = userId, + UploadedOn = now, + ScanState = (int)scan.State, + MetadataStripped = hygiene.MetadataStripped, + CreatedOn = now, + ModifiedOn = now, + RowVersion = 1 + }; + + await InTransactionAsync(async () => + { + await _attachments.InsertAsync(attachment, cancellationToken, true); + record.ModifiedOn = now; + record.ModifiedByUserId = userId; + await _records.UpdateAsync(record, cancellationToken, true); + await AuditAsync(departmentId, userId, recordId, null, RmsAccessAuditAction.Change, "Add attachment", RmsOriginClient.Web, cancellationToken, new { attachment.RmsRecordAttachmentId, attachment.ByteSize, attachment.Checksum }); + }); + + attachment.Data = null; + return attachment; + } + + public Task GetAttachmentAsync(int departmentId, string attachmentId) + { + return _attachments.GetByIdForDepartmentAsync(departmentId, attachmentId); + } + + public async Task RemoveAttachmentAsync(int departmentId, string userId, string recordId, string attachmentId, CancellationToken cancellationToken = default) + { + var record = await LoadRecordAsync(departmentId, recordId); + if (RmsLifecycle.IsFinalizedFamily((RmsRecordState)record.State) && record.AmendsRevisionId == null) + throw new RecordTransitionException(recordId, (RmsRecordState)record.State, (RmsRecordState)record.State, "attachments on a finalized Record are removed through an amendment"); + + var attachment = await _attachments.GetByIdForDepartmentAsync(departmentId, attachmentId); + if (attachment == null || !string.Equals(attachment.RecordId, recordId, StringComparison.Ordinal)) + return false; + + await InTransactionAsync(async () => + { + attachment.DeletedOn = DateTime.UtcNow; + attachment.ModifiedOn = attachment.DeletedOn.Value; + attachment.RowVersion += 1; + await _attachments.UpdateAsync(attachment, cancellationToken, true); + await AuditAsync(departmentId, userId, recordId, null, RmsAccessAuditAction.Change, "Remove attachment", RmsOriginClient.Web, cancellationToken, new { attachmentId }); + }); + + return true; + } + + #endregion + + #region Internals + + private async Task EnsureRecordsUsableAsync(int departmentId) + { + var state = await _cutover.GetModuleStateAsync(departmentId); + if (!state.RecordsUsable) + throw new InvalidOperationException("Records is not active for this department."); + } + + private async Task LoadRecordAsync(int departmentId, string recordId) + { + var record = await _records.GetByIdForDepartmentAsync(departmentId, recordId); + if (record == null || record.DeletedOn.HasValue) + throw new KeyNotFoundException($"Record {recordId} does not exist in this department."); + return record; + } + + private static void RequireTransition(RmsOperationalRecord record, RmsRecordState from, RmsRecordState to) + { + if (!RmsLifecycle.CanTransition((RmsLifecyclePreset)record.LifecyclePreset, from, to)) + throw new RecordTransitionException(record.RmsOperationalRecordId, from, to); + } + + /// Serializes writers on the row and rejects stale ETags; bumps the in-memory version to match. + private async Task GuardVersionAsync(RmsOperationalRecord record, long expectedRowVersion, CancellationToken cancellationToken) + { + if (!await _records.TryBumpRowVersionAsync(record.DepartmentId, record.RmsOperationalRecordId, expectedRowVersion, cancellationToken)) + { + var current = await _records.GetByIdForDepartmentAsync(record.DepartmentId, record.RmsOperationalRecordId); + throw new RecordConcurrencyException(record.RmsOperationalRecordId, expectedRowVersion, current?.RowVersion ?? -1); + } + + record.RowVersion = expectedRowVersion + 1; + } + + private async Task InTransactionAsync(Func work) + { + _unitOfWork.CreateOrGetConnection(); + try + { + await work(); + _unitOfWork.CommitChanges(); + } + catch + { + _unitOfWork.DiscardChanges(); + throw; + } + } + + private async Task HydrateAsync(RmsOperationalRecord record, bool includeRevisions) + { + var aggregate = await HydrateDraftAsync(record); + aggregate.GroupScope = (await _scopes.GetForRecordAsync(record.DepartmentId, record.RmsOperationalRecordId))?.ToList() ?? new List(); + if (includeRevisions) + aggregate.Revisions = (await _revisions.GetForRecordAsync(record.DepartmentId, record.RmsOperationalRecordId))?.ToList() ?? new List(); + return aggregate; + } + + private async Task HydrateDraftAsync(RmsOperationalRecord record) + { + return new RecordAggregate + { + Record = record, + Details = await _details.GetDraftAsync(record.DepartmentId, record.RmsOperationalRecordId), + Participants = (await _participants.GetForRecordAsync(record.DepartmentId, record.RmsOperationalRecordId, null))?.ToList() ?? new List(), + Units = (await _units.GetForRecordAsync(record.DepartmentId, record.RmsOperationalRecordId, null))?.ToList() ?? new List(), + Attachments = (await _attachments.GetMetadataForRecordAsync(record.DepartmentId, record.RmsOperationalRecordId))?.ToList() ?? new List() + }; + } + + private async Task WriteRevisionAsync(RmsOperationalRecord record, RecordAggregate draft, RmsRevisionTransition transition, string userId, + string reasonCode, string reasonText, string attestationVersion, DateTime now, CancellationToken cancellationToken) + { + var snapshot = RecordSnapshotSerializer.Build(draft); + snapshot.RecordNumber = record.RecordNumber; + var json = RecordSnapshotSerializer.Serialize(snapshot); + + var revision = new RmsRevision + { + RmsRevisionId = Guid.NewGuid().ToString(), + DepartmentId = record.DepartmentId, + ProtectionId = Guid.NewGuid().ToString(), + RecordId = record.RmsOperationalRecordId, + RecordKind = (int)RmsRecordKind.Operational, + RevisionNumber = record.RevisionCount + 1, + Transition = (int)transition, + PriorRevisionId = record.CurrentRevisionId, + DefinitionKey = record.DefinitionKey, + DefinitionVersion = record.DefinitionVersion, + SnapshotJson = json, + Checksum = RecordSnapshotSerializer.Checksum(json), + ActorUserId = userId, + ReasonCode = reasonCode, + ReasonText = reasonText, + AttestationStatementVersion = transition == RmsRevisionTransition.Voided ? null : attestationVersion, + AttestedOn = transition == RmsRevisionTransition.Voided ? (DateTime?)null : now, + OriginClient = record.OriginClient, + CreatedOn = now + }; + await _revisions.InsertAsync(revision, cancellationToken, true); + + // Revision-bound copies of the typed rows keep finalized data queryable without touching the draft rows. + if (draft.Details != null) + { + var copy = CloneDetails(draft.Details); + copy.RmsOperationalRecordDetailId = Guid.NewGuid().ToString(); + copy.RevisionId = revision.RmsRevisionId; + copy.CreatedOn = now; + copy.ModifiedOn = now; + copy.RowVersion = 1; + await _details.InsertAsync(copy, cancellationToken); + } + + foreach (var participant in draft.Participants) + { + var copy = CloneParticipant(participant); + copy.RmsRecordParticipantId = Guid.NewGuid().ToString(); + copy.RevisionId = revision.RmsRevisionId; + await _participants.InsertAsync(copy, cancellationToken, true); + } + + foreach (var unit in draft.Units) + { + var copy = CloneUnit(unit); + copy.RmsRecordUnitResponseId = Guid.NewGuid().ToString(); + copy.RevisionId = revision.RmsRevisionId; + await _units.InsertAsync(copy, cancellationToken, true); + } + + return revision; + } + + private async Task RestoreDraftFromSnapshotAsync(RmsOperationalRecord record, RecordSnapshot snapshot, DateTime now, CancellationToken cancellationToken) + { + var details = await _details.GetDraftAsync(record.DepartmentId, record.RmsOperationalRecordId); + if (details != null && snapshot.Details != null) + { + var restored = CloneDetails(snapshot.Details); + restored.RmsOperationalRecordDetailId = details.RmsOperationalRecordDetailId; + restored.ProtectionId = details.ProtectionId; + restored.RevisionId = null; + restored.CreatedOn = details.CreatedOn; + restored.ModifiedOn = now; + restored.RowVersion = details.RowVersion + 1; + await _details.UpdateAsync(restored, cancellationToken); + } + + await _participants.DeleteDraftForRecordAsync(record.DepartmentId, record.RmsOperationalRecordId, cancellationToken); + await _units.DeleteDraftForRecordAsync(record.DepartmentId, record.RmsOperationalRecordId, cancellationToken); + foreach (var participant in snapshot.Participants ?? new List()) + { + var copy = CloneParticipant(participant); + copy.RmsRecordParticipantId = Guid.NewGuid().ToString(); + copy.RevisionId = null; + copy.ModifiedOn = now; + await _participants.InsertAsync(copy, cancellationToken, true); + } + foreach (var unit in snapshot.Units ?? new List()) + { + var copy = CloneUnit(unit); + copy.RmsRecordUnitResponseId = Guid.NewGuid().ToString(); + copy.RevisionId = null; + copy.ModifiedOn = now; + await _units.InsertAsync(copy, cancellationToken, true); + } + + record.StationGroupId = snapshot.StationGroupId; + record.CallId = snapshot.CallId; + record.ExternalId = snapshot.ExternalId; + record.StartedOn = snapshot.StartedOn; + record.EndedOn = snapshot.EndedOn; + } + + private async Task AllocateRecordNumberAsync(RmsOperationalRecord record, CancellationToken cancellationToken) + { + var config = await _settings.GetRecordsNumberingConfigAsync(record.DepartmentId); + var prefixBase = RmsDefinitionKeys.DefaultNumberPrefix(record.DefinitionKey); + var year = (record.StartedOn ?? DateTime.UtcNow).Year; + var prefix = prefixBase + "-"; + if (config.PerGroupSequence && record.StationGroupId.HasValue) + prefix += "G" + record.StationGroupId.Value + "-"; + if (config.IncludeYear) + prefix += year + "-"; + + var width = Math.Max(3, Math.Min(8, config.SequenceWidth <= 0 ? 4 : config.SequenceWidth)); + var sequence = await _records.GetMaxRecordNumberSequenceAsync(record.DepartmentId, prefix) + 1; + return prefix + sequence.ToString("D" + width); + } + + private async Task RecomputeGroupScopeAsync(RecordAggregate aggregate, CancellationToken cancellationToken) + { + var record = aggregate.Record; + var scopes = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + void add(int? groupId, RmsGroupScopeAnchorType anchor) + { + if (!groupId.HasValue || !seen.Add(groupId.Value + ":" + (int)anchor)) + return; + scopes.Add(new RmsRecordGroupScope { DepartmentId = record.DepartmentId, RecordId = record.RmsOperationalRecordId, DepartmentGroupId = groupId.Value, AnchorType = (int)anchor, CreatedOn = DateTime.UtcNow }); + } + + add(record.StationGroupId, RmsGroupScopeAnchorType.RecordGroup); + add(record.AuthorGroupIdSnapshot, RmsGroupScopeAnchorType.Author); + foreach (var participant in aggregate.Participants) + add(participant.GroupIdSnapshot, RmsGroupScopeAnchorType.Participant); + foreach (var unit in aggregate.Units) + add(unit.StationGroupIdSnapshot, RmsGroupScopeAnchorType.Unit); + + var shares = await _shares.GetForRecordAsync(record.DepartmentId, record.RmsOperationalRecordId); + var now = DateTime.UtcNow; + foreach (var share in (shares ?? Enumerable.Empty()).Where(s => s.IsEffective(now))) + add(share.DepartmentGroupId, RmsGroupScopeAnchorType.Share); + + await _scopes.ReplaceForRecordAsync(record.DepartmentId, record.RmsOperationalRecordId, scopes, cancellationToken); + aggregate.GroupScope = scopes; + } + + private async Task RefreshProjectionAsync(RmsOperationalRecord record, CancellationToken cancellationToken) + { + var aggregate = await HydrateDraftAsync(record); + aggregate.GroupScope = (await _scopes.GetForRecordAsync(record.DepartmentId, record.RmsOperationalRecordId))?.ToList() ?? new List(); + await UpsertProjectionAsync(aggregate, cancellationToken); + } + + private async Task UpsertProjectionAsync(RecordAggregate aggregate, CancellationToken cancellationToken) + { + var record = aggregate.Record; + var details = aggregate.Details ?? new RmsOperationalRecordDetail(); + var existing = await _projections.GetByRecordIdAsync(record.DepartmentId, record.RmsOperationalRecordId); + var projection = existing ?? new RmsRecordSearchProjection + { + RmsRecordSearchProjectionId = record.RmsOperationalRecordId, + DepartmentId = record.DepartmentId, + ProtectionId = Guid.NewGuid().ToString(), + CreatedOn = DateTime.UtcNow, + RowVersion = 0 + }; + + projection.SourceType = (int)RmsSearchSourceType.Record; + projection.SourceId = record.RmsOperationalRecordId; + projection.RecordKind = (int)RmsRecordKind.Operational; + projection.RecordNumber = record.RecordNumber; + projection.DraftReference = record.DraftReference; + projection.DefinitionKey = record.DefinitionKey; + projection.DefinitionVersion = record.DefinitionVersion; + projection.RecordType = record.RecordType; + projection.State = record.State; + projection.OccurredOn = record.StartedOn ?? details.ActivityOn; + projection.RecordCreatedOn = record.CreatedOn; + projection.FinalizedOn = record.FinalizedOn; + projection.StationGroupId = record.StationGroupId; + projection.CallId = record.CallId; + projection.CallNumber = details.CallNumber; + projection.AuthorUserId = record.AuthorUserId; + projection.OwnerUserId = record.OwnerUserId; + projection.ReviewerUserId = record.ReviewerUserId; + projection.ParticipantUserIds = string.Join(",", aggregate.Participants.Select(p => p.UserId).Where(u => !string.IsNullOrEmpty(u)).Distinct()); + projection.UnitIds = string.Join(",", aggregate.Units.Select(u => u.UnitId).Distinct()); + projection.GroupScopeIds = string.Join(",", (aggregate.GroupScope ?? new List()).Select(s => s.DepartmentGroupId).Distinct()); + projection.DisplaySummary = record.DisplaySummary; + // Safe fields only: never narrative, address detail, contact or restricted sections (plan section 5.10). + projection.SearchText = string.Join(" ", new[] { record.RecordNumber, record.DraftReference, record.DisplaySummary, details.Course, details.CourseCode, details.CallNumber, details.CallName, details.Type, record.ExternalId }.Where(s => !string.IsNullOrWhiteSpace(s))); + projection.IsLegacy = false; + projection.ProjectionVersion = RmsRecordSearchProjection.CurrentProjectionVersion; + projection.ProtectedCatalogVersion = await SafeCatalogVersionAsync(record.DepartmentId); + projection.PolicyEpoch = await SafePolicyEpochAsync(record.DepartmentId); + projection.ModifiedOn = DateTime.UtcNow; + projection.RowVersion += 1; + projection.DeletedOn = record.DeletedOn; + + if (existing == null) + await _projections.InsertAsync(projection, cancellationToken, true); + else + await _projections.UpdateAsync(projection, cancellationToken, true); + } + + private async Task SafeCatalogVersionAsync(int departmentId) + { + try { return await _dataProtection.GetPinnedCatalogVersionAsync(departmentId); } + catch (Exception ex) { Logging.LogException(ex); return 0; } + } + + private async Task SafePolicyEpochAsync(int departmentId) + { + try { return (await _dataProtection.GetPolicyByDepartmentIdAsync(departmentId))?.PolicyEpoch ?? 0; } + catch (Exception ex) { Logging.LogException(ex); return 0; } + } + + private async Task EnqueueLifecycleEventAsync(RmsOperationalRecord record, RmsRevision revision, WorkflowTriggerEventType trigger, RmsRecordState from, RmsRecordState to, string reasonCode, CancellationToken cancellationToken, object extra = null) + { + var payload = new Dictionary + { + ["record"] = new + { + id = record.RmsOperationalRecordId, + record_number = record.RecordNumber, + draft_reference = record.DraftReference, + definition_key = record.DefinitionKey, + definition_version = record.DefinitionVersion, + type_key = record.RecordType.HasValue ? ((RmsOperationalRecordType)record.RecordType.Value).ToString() : null, + state = to.ToString(), + lifecycle_preset = ((RmsLifecyclePreset)record.LifecyclePreset).ToString(), + department_id = record.DepartmentId, + station_group_id = record.StationGroupId, + call_id = record.CallId, + external_id = record.ExternalId, + author_user_id = record.AuthorUserId, + owner_user_id = record.OwnerUserId, + started_on = record.StartedOn, + ended_on = record.EndedOn, + created_on = record.CreatedOn, + finalized_on = record.FinalizedOn, + revision_id = revision?.RmsRevisionId ?? record.CurrentRevisionId, + revision_number = revision?.RevisionNumber ?? record.RevisionCount, + checksum = revision?.Checksum, + summary = record.DisplaySummary + }, + ["record_change"] = new + { + previous_state = from.ToString(), + current_state = to.ToString(), + prior_revision_id = revision?.PriorRevisionId, + current_revision_id = revision?.RmsRevisionId ?? record.CurrentRevisionId, + reason_code = reasonCode + } + }; + if (extra != null) + payload["extra"] = extra; + + // review.* (plan section 5.6): who reviewed, when it was due, how often it came back. Only the two + // review-path triggers carry it; the fields are review bookkeeping, never record content. + if (trigger == WorkflowTriggerEventType.RecordSubmittedForReview || trigger == WorkflowTriggerEventType.RecordReturnedForCorrection) + { + payload["review"] = new + { + reviewer_user_id = record.ReviewerUserId, + submitted_for_review_on = record.SubmittedForReviewOn, + review_due_on = record.ReviewDueOn, + returned_on = record.ReturnedOn, + return_count = record.ReturnCount, + reason_code = record.ReturnReasonCode, + reason_text = record.ReturnReasonText + }; + } + + var entry = await _outbox.EnqueueAsync(record.DepartmentId, DomainEventProducers.Records, new DomainEventEnvelope + { + EventName = trigger.ToString(), + SchemaVersion = 1, + AggregateType = DomainEventProducers.RecordsAggregate, + AggregateId = record.RmsOperationalRecordId, + AggregateVersion = revision?.RevisionNumber ?? record.RevisionCount, + Trigger = trigger, + Payload = payload, + CorrelationId = record.RmsOperationalRecordId, + OriginClient = (RmsOriginClient)record.OriginClient + }, cancellationToken); + + return entry; + } + + /// + /// Legacy LogAdded compatibility (plan section 5.6): the exact pre-existing log.* contract, projected once at + /// finalize for eligible Logs-parity records through the same outbox, caused by the RecordFinalized event. + /// + private Task EnqueueLogAddedCompatibilityAsync(RmsOperationalRecord record, RmsRevision revision, RmsOperationalRecordDetail details, string causationEventId, DateTime now, CancellationToken cancellationToken) + { + return _outbox.EnqueueAsync(record.DepartmentId, DomainEventProducers.Records, new DomainEventEnvelope + { + EventName = WorkflowTriggerEventType.LogAdded.ToString(), + SchemaVersion = 1, + AggregateType = DomainEventProducers.RecordsAggregate, + AggregateId = record.RmsOperationalRecordId, + AggregateVersion = revision?.RevisionNumber, + Trigger = WorkflowTriggerEventType.LogAdded, + Payload = LogAddedCompatibility.Build(record, details, now), + CorrelationId = record.RmsOperationalRecordId, + CausationId = causationEventId, + OriginClient = (RmsOriginClient)record.OriginClient + }, cancellationToken); + } + + private Task AuditAsync(int departmentId, string userId, string recordId, string revisionId, RmsAccessAuditAction action, string purpose, RmsOriginClient origin, CancellationToken cancellationToken, object detail = null) + { + return _audits.InsertAsync(new RmsAccessAudit + { + DepartmentId = departmentId, + RecordId = recordId, + RevisionId = revisionId, + Action = (int)action, + ActorUserId = userId, + Purpose = purpose, + OriginClient = (int)origin, + Successful = true, + OccurredOn = DateTime.UtcNow, + DetailJson = detail == null ? null : JsonConvert.SerializeObject(detail) + }, cancellationToken, true); + } + + private async Task> BuildParticipantsAsync(int departmentId, string recordId, IEnumerable inputs, DateTime now) + { + var result = new List(); + var ordinal = 0; + foreach (var input in (inputs ?? Enumerable.Empty()).Where(i => !string.IsNullOrWhiteSpace(i.UserId))) + { + var profile = await _profiles.GetProfileByUserIdAsync(input.UserId); + var group = await _groups.GetGroupForUserAsync(input.UserId, departmentId); + result.Add(new RmsRecordParticipant + { + RmsRecordParticipantId = Guid.NewGuid().ToString(), + DepartmentId = departmentId, + ProtectionId = Guid.NewGuid().ToString(), + RecordId = recordId, + UserId = input.UserId, + DisplayNameSnapshot = profile == null ? null : $"{profile.FirstName} {profile.LastName}".Trim(), + GroupIdSnapshot = group?.DepartmentGroupId, + GroupNameSnapshot = group?.Name, + UnitId = input.UnitId, + Role = input.Role, + SourceKind = (int)RmsSourceKind.None, + Ordinal = ordinal++, + CreatedOn = now, + ModifiedOn = now, + RowVersion = 1 + }); + } + return result; + } + + private async Task> BuildUnitsAsync(int departmentId, string recordId, IEnumerable inputs, DateTime now) + { + var result = new List(); + var ordinal = 0; + foreach (var input in (inputs ?? Enumerable.Empty()).Where(i => i.UnitId > 0)) + { + var unit = await _unitsService.GetUnitByIdAsync(input.UnitId); + if (unit == null || unit.DepartmentId != departmentId) + throw new ArgumentException($"Unit {input.UnitId} does not belong to this department."); + + result.Add(new RmsRecordUnitResponse + { + RmsRecordUnitResponseId = Guid.NewGuid().ToString(), + DepartmentId = departmentId, + ProtectionId = Guid.NewGuid().ToString(), + RecordId = recordId, + UnitId = unit.UnitId, + UnitNameSnapshot = unit.Name, + UnitTypeSnapshot = unit.Type, + StationGroupIdSnapshot = unit.StationGroupId, + Dispatched = input.Dispatched, + Enroute = input.Enroute, + OnScene = input.OnScene, + Released = input.Released, + InQuarters = input.InQuarters, + TimesSourceKind = (int)RmsSourceKind.None, + Ordinal = ordinal++, + CreatedOn = now, + ModifiedOn = now, + RowVersion = 1 + }); + } + return result; + } + + private async Task ApplyCallSnapshotAsync(int departmentId, RmsOperationalRecordDetail details, int? callId) + { + if (!callId.HasValue) + { + details.CallNumber = null; + details.CallName = null; + details.CallType = null; + details.CallPriority = null; + details.CallLoggedOn = null; + details.CallAddress = null; + details.CallNature = null; + return; + } + + var call = await _calls.GetCallByIdAsync(callId.Value); + if (call == null || call.DepartmentId != departmentId) + throw new ArgumentException($"Call {callId} does not belong to this department."); + + details.CallNumber = call.Number; + details.CallName = call.Name; + details.CallType = call.Type; + details.CallPriority = call.Priority; + details.CallLoggedOn = call.LoggedOn; + details.CallAddress = call.Address; + details.CallNature = call.NatureOfCall; + } + + private static void ApplyDetails(RmsOperationalRecordDetail target, RmsOperationalRecordDetail source) + { + if (source == null) + return; + + target.Narrative = source.Narrative; + target.InitialReport = source.InitialReport; + target.Type = source.Type; + target.Course = source.Course; + target.CourseCode = source.CourseCode; + target.Instructors = source.Instructors; + target.Cause = source.Cause; + target.InvestigatedByUserId = source.InvestigatedByUserId; + target.ContactName = source.ContactName; + target.ContactNumber = source.ContactNumber; + target.OtherPersonnel = source.OtherPersonnel; + target.Location = source.Location; + target.OtherAgencies = source.OtherAgencies; + target.OtherUnits = source.OtherUnits; + target.BodyLocation = source.BodyLocation; + target.PronouncedDeceasedBy = source.PronouncedDeceasedBy; + target.CaseNumber = source.CaseNumber; + target.Destination = source.Destination; + target.Facilitator = source.Facilitator; + target.UnitId = source.UnitId; + target.ActivityOn = source.ActivityOn; + } + + private static void ValidateDefinitionRequirements(RmsOperationalRecordType type, RmsOperationalRecordDetail details) + { + if (type == RmsOperationalRecordType.UnitActivity && (details?.UnitId).GetValueOrDefault() <= 0) + throw new ArgumentException("A Unit Activity record requires a unit."); + } + + /// Logs parity: a narrative is required on every legacy type; Unit Activity requires its timestamp. + private static void ValidateForFinalization(RmsOperationalRecordType type, RecordAggregate draft) + { + if (draft.Details == null || string.IsNullOrWhiteSpace(draft.Details.Narrative)) + throw new ArgumentException("A narrative is required before a Record can be finalized."); + if (type == RmsOperationalRecordType.UnitActivity && !draft.Details.ActivityOn.HasValue) + throw new ArgumentException("A Unit Activity record requires an activity time before it can be finalized."); + } + + private static string BuildDisplaySummary(RmsOperationalRecordType type, RmsOperationalRecord record, RmsOperationalRecordDetail details, List units) + { + string summary; + switch (type) + { + case RmsOperationalRecordType.Training: + summary = string.Join(" ", new[] { details.Course, string.IsNullOrWhiteSpace(details.CourseCode) ? null : "(" + details.CourseCode + ")" }.Where(s => !string.IsNullOrWhiteSpace(s))); + break; + case RmsOperationalRecordType.Run: + case RmsOperationalRecordType.Callback: + summary = string.Join(" ", new[] { details.CallNumber, details.CallName }.Where(s => !string.IsNullOrWhiteSpace(s))); + break; + case RmsOperationalRecordType.Meeting: + summary = string.Join(" ", new[] { details.Type, details.Facilitator }.Where(s => !string.IsNullOrWhiteSpace(s))); + break; + case RmsOperationalRecordType.UnitActivity: + summary = units.FirstOrDefault()?.UnitNameSnapshot; + break; + case RmsOperationalRecordType.Coroner: + // Restricted section; the summary never carries case, location or person detail. + summary = null; + break; + default: + summary = details.Type; + break; + } + + if (string.IsNullOrWhiteSpace(summary)) + summary = type.ToString(); + + return summary.Length > 400 ? summary.Substring(0, 400) : summary; + } + + private static string ResolveClientRecordId(string clientRecordId) + { + if (string.IsNullOrWhiteSpace(clientRecordId)) + return Guid.NewGuid().ToString(); + if (!Guid.TryParse(clientRecordId, out var parsed)) + throw new ArgumentException("A client-supplied record id must be a GUID.", nameof(clientRecordId)); + return parsed.ToString(); + } + + private static string NewDraftReference() + { + const string alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + var bytes = new byte[5]; + using (var rng = RandomNumberGenerator.Create()) + rng.GetBytes(bytes); + var chars = new char[5]; + for (var i = 0; i < 5; i++) + chars[i] = alphabet[bytes[i] % alphabet.Length]; + return "D-" + new string(chars); + } + + private static RmsOperationalRecordDetail CloneDetails(RmsOperationalRecordDetail source) + { + return JsonConvert.DeserializeObject(JsonConvert.SerializeObject(source)); + } + + private static RmsRecordParticipant CloneParticipant(RmsRecordParticipant source) + { + return JsonConvert.DeserializeObject(JsonConvert.SerializeObject(source)); + } + + private static RmsRecordUnitResponse CloneUnit(RmsRecordUnitResponse source) + { + return JsonConvert.DeserializeObject(JsonConvert.SerializeObject(source)); + } + + #endregion + } +} diff --git a/Core/Resgrid.Services/Records/RmsInventoryUsageAdapter.cs b/Core/Resgrid.Services/Records/RmsInventoryUsageAdapter.cs new file mode 100644 index 000000000..57cf571ad --- /dev/null +++ b/Core/Resgrid.Services/Records/RmsInventoryUsageAdapter.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Inventory usage over RmsExternalReference rows (semantic role InventoryUsage). Writes never touch a legacy + /// Log row; reads answer for either source so callers stay source-agnostic (RMS plan RMS-1 package). + /// + public class RmsInventoryUsageAdapter : IRmsInventoryUsageAdapter + { + public const string SemanticRole = "InventoryUsage"; + public const string SourceSubsystem = "Inventory"; + public const string IdentifierScheme = "resgrid:inventory"; + + private readonly IRmsExternalReferencesRepository _references; + private readonly IRmsOperationalRecordsRepository _records; + + public RmsInventoryUsageAdapter(IRmsExternalReferencesRepository references, IRmsOperationalRecordsRepository records) + { + _references = references; + _records = records; + } + + public async Task> GetUsageForRecordAsync(int departmentId, string recordId) + { + var references = await _references.GetForRecordAsync(departmentId, recordId) ?? Enumerable.Empty(); + return references + .Where(r => r != null && !r.DeletedOn.HasValue && string.Equals(r.SemanticRole, SemanticRole, StringComparison.Ordinal)) + .Select(FromReference) + .Where(u => u != null) + .OrderBy(u => u.CapturedOn) + .ToList(); + } + + /// + /// The legacy Logs schema has no inventory linkage (Inventory rows carry a unit and a group, never a LogId), + /// so legacy usage is empty by construction. The method exists so callers never branch on the source. + /// + public Task> GetUsageForLegacyLogAsync(int departmentId, int logId) + { + return Task.FromResult(new List()); + } + + public async Task RecordUsageAsync(int departmentId, string userId, string recordId, int inventoryId, decimal quantity, string note, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(recordId)) throw new ArgumentException("A record is required.", nameof(recordId)); + if (inventoryId <= 0) throw new ArgumentException("An inventory item is required.", nameof(inventoryId)); + if (quantity <= 0) throw new ArgumentException("Quantity must be positive.", nameof(quantity)); + + var record = await _records.GetByIdForDepartmentAsync(departmentId, recordId); + if (record == null || record.DeletedOn.HasValue) + throw new InvalidOperationException($"Record {recordId} does not exist in department {departmentId}."); + + if (RmsLifecycle.IsTerminal((RmsRecordState)record.State)) + throw new InvalidOperationException("Inventory usage cannot be recorded against a voided or cancelled Record."); + + var now = DateTime.UtcNow; + var snapshot = JsonConvert.SerializeObject(new UsageSnapshot { InventoryId = inventoryId, Quantity = quantity, Note = note }); + var reference = new RmsExternalReference + { + RmsExternalReferenceId = Guid.NewGuid().ToString(), + DepartmentId = departmentId, + ProtectionId = Guid.NewGuid().ToString(), + RecordId = recordId, + RecordKind = (int)RmsRecordKind.Operational, + SourceSubsystem = SourceSubsystem, + SourceEntityType = "Inventory", + SourceEntityId = inventoryId.ToString(), + IdentifierScheme = IdentifierScheme, + SemanticRole = SemanticRole, + CapturedOn = now, + CapturedByUserId = userId, + Checksum = Checksum(snapshot), + SnapshotJson = snapshot, + CreatedOn = now, + ModifiedOn = now, + RowVersion = 1 + }; + + await _references.InsertAsync(reference, cancellationToken, true); + return FromReference(reference); + } + + private static RmsInventoryUsage FromReference(RmsExternalReference reference) + { + UsageSnapshot snapshot; + try + { + snapshot = string.IsNullOrWhiteSpace(reference.SnapshotJson) ? new UsageSnapshot() : JsonConvert.DeserializeObject(reference.SnapshotJson) ?? new UsageSnapshot(); + } + catch (JsonException) + { + return null; + } + + if (!int.TryParse(reference.SourceEntityId, out var inventoryId)) + inventoryId = snapshot.InventoryId; + + return new RmsInventoryUsage + { + ReferenceId = reference.RmsExternalReferenceId, + Source = RmsInventoryUsage.SourceRecord, + RecordId = reference.RecordId, + InventoryId = inventoryId, + Quantity = snapshot.Quantity, + Note = snapshot.Note, + CapturedByUserId = reference.CapturedByUserId, + CapturedOn = reference.CapturedOn + }; + } + + private static string Checksum(string text) + { + using var sha = SHA256.Create(); + return Convert.ToHexString(sha.ComputeHash(Encoding.UTF8.GetBytes(text))).ToLowerInvariant(); + } + + private sealed class UsageSnapshot + { + public int InventoryId { get; set; } + public decimal Quantity { get; set; } + public string Note { get; set; } + } + } +} diff --git a/Core/Resgrid.Services/Records/RmsRecordValueService.cs b/Core/Resgrid.Services/Records/RmsRecordValueService.cs new file mode 100644 index 000000000..afa38d3d1 --- /dev/null +++ b/Core/Resgrid.Services/Records/RmsRecordValueService.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// RMS-1 value seam (plan section 5.9.1): the only caller of the details repository. Every write passes + /// , which is where Protected Data enrollment will clone the row into its + /// protected persistence shape; today it only enforces the inert contract so nothing can pre-empt it. + /// + public class RmsRecordValueService : IRmsRecordValueService + { + private readonly IRmsOperationalRecordDetailsRepository _details; + + public RmsRecordValueService(IRmsOperationalRecordDetailsRepository details) + { + _details = details; + } + + public Task GetDraftAsync(int departmentId, string recordId) + { + return _details.GetDraftAsync(departmentId, recordId); + } + + public Task GetByRevisionAsync(int departmentId, string recordId, string revisionId) + { + return _details.GetByRevisionAsync(departmentId, recordId, revisionId); + } + + public Task> GetDraftsForRecordsAsync(int departmentId, IEnumerable recordIds) + { + return _details.GetDraftsForRecordsAsync(departmentId, recordIds); + } + + public Task InsertAsync(RmsOperationalRecordDetail details, CancellationToken cancellationToken = default) + { + PrepareForStorage(details); + return _details.InsertAsync(details, cancellationToken, true); + } + + public Task UpdateAsync(RmsOperationalRecordDetail details, CancellationToken cancellationToken = default) + { + PrepareForStorage(details); + return _details.UpdateAsync(details, cancellationToken, true); + } + + public Task SaveOrUpdateAsync(RmsOperationalRecordDetail details, CancellationToken cancellationToken = default) + { + PrepareForStorage(details); + return _details.SaveOrUpdateAsync(details, cancellationToken, true); + } + + /// + /// The inert protection contract. An unprotected row carries no envelope and catalog version 0; a row + /// marked protected must carry its envelope (the typed columns are what enrollment nulls out). Anything + /// else is a caller writing around the enrollment path and is refused. + /// + public static void PrepareForStorage(RmsOperationalRecordDetail details) + { + if (details == null) + throw new ArgumentNullException(nameof(details)); + + if (!details.IsProtected) + { + if (!string.IsNullOrEmpty(details.ProtectedEnvelope)) + throw new InvalidOperationException("A protected envelope was supplied for a record detail row that is not marked protected; envelopes are written only through Protected Data enrollment."); + + details.ProtectedCatalogVersion = 0; + return; + } + + if (string.IsNullOrEmpty(details.ProtectedEnvelope)) + throw new InvalidOperationException("A record detail row marked protected must carry its envelope."); + } + } +} diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index f669971d4..ab016addb 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -232,6 +232,23 @@ protected override void Load(ContainerBuilder builder) // Weather Alert Services builder.RegisterType().As().InstancePerLifetimeScope(); + + // Records (RMS) Services - RMS plan section 5.11.1 + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + // Value seam (plan 5.9.1): the only caller of the details repository; enrollment hooks in here. + 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(); + // Default attachment scanner: no engine, rows stay Skipped. A real scanner provider replaces this registration. + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); } } } diff --git a/Core/Resgrid.Services/UnitsService.cs b/Core/Resgrid.Services/UnitsService.cs index e114bde10..8f765400e 100644 --- a/Core/Resgrid.Services/UnitsService.cs +++ b/Core/Resgrid.Services/UnitsService.cs @@ -35,6 +35,9 @@ public class UnitsService : IUnitsService // Lazy: defers the protected-write graph (broker client) until a state save actually needs it. private readonly Lazy _protectedWriteService; + // Lazy: the Records cutover guard (RMS plan section 4.1) is consulted only on a legacy UnitLog write. + private readonly Lazy _recordsCutoverService; + public UnitsService(IUnitsRepository unitsRepository, IUnitStatesRepository unitStatesRepository, IUnitLogsRepository unitLogsRepository, IUnitTypesRepository unitTypesRepository, ISubscriptionsService subscriptionsService, IUnitRolesRepository unitRolesRepository, IUnitStateRoleRepository unitStateRoleRepository, IUserStateService userStateService, @@ -42,8 +45,9 @@ public UnitsService(IUnitsRepository unitsRepository, IUnitStatesRepository unit IUnitLocationsDocRepository unitLocationsDocRepository, Lazy unitLocationsMongoRepository, IUnitActiveRolesRepository unitActiveRolesRepository, IDepartmentGroupsService departmentGroupsService, ILimitsService limitsService, IPersonnelRolesService personnelRolesService, - Lazy protectedWriteService) + Lazy protectedWriteService, Lazy recordsCutoverService) { + _recordsCutoverService = recordsCutoverService; _unitsRepository = unitsRepository; _unitStatesRepository = unitStatesRepository; _unitLogsRepository = unitLogsRepository; @@ -109,6 +113,11 @@ private void SendUnitVisibilityRefresh(int departmentId) public async Task SaveUnitLogAsync(UnitLog unitLog, CancellationToken cancellationToken = default(CancellationToken)) { + // UnitLogs carry no DepartmentId; scope the Records cutover guard through the owning Unit (RMS plan section 4.1). + var owningUnit = await _unitsRepository.GetByIdAsync(unitLog.UnitId); + if (owningUnit != null) + await _recordsCutoverService.Value.EnsureLegacyWriteAllowedAsync(owningUnit.DepartmentId, "UnitsService.SaveUnitLogAsync"); + var saved = await _unitLogsRepository.SaveOrUpdateAsync(unitLog, cancellationToken); // ADP write safety net (plan 4.2/19.2, catalog v9). Runs AFTER the save because the AAD diff --git a/Core/Resgrid.Services/WorkLogsService.cs b/Core/Resgrid.Services/WorkLogsService.cs index a01e9a0c2..16808ffff 100644 --- a/Core/Resgrid.Services/WorkLogsService.cs +++ b/Core/Resgrid.Services/WorkLogsService.cs @@ -23,11 +23,15 @@ public class WorkLogsService : IWorkLogsService // Lazy: defers the protected-write graph (broker client) until a log save actually needs it. private readonly Lazy _protectedWriteService; + // Lazy: the Records cutover guard (RMS plan section 4.1) is consulted only on a legacy write. + private readonly Lazy _recordsCutoverService; + public WorkLogsService(ILogsRepository logsRepository, ICallLogsRepository callLogsRepository, ILogUsersRepository logUsersRepository, ILogAttachmentRepository logAttachmentRepository, ILogUnitsRepository logUnitsRepository, IDepartmentsService departmentsService, IDepartmentGroupsService departmentGroupsService, ICallsService callsService, - Lazy protectedWriteService) + Lazy protectedWriteService, Lazy recordsCutoverService) { + _recordsCutoverService = recordsCutoverService; _logsRepository = logsRepository; _callLogsRepository = callLogsRepository; _logUsersRepository = logUsersRepository; @@ -105,6 +109,9 @@ public async Task GetCallLogByIdAsync(int callLogId) public async Task SaveLogAsync(Log log, CancellationToken cancellationToken = default(CancellationToken)) { + // After Records activation no legacy Log is created or edited, whoever the caller is (RMS plan section 4.1). + await _recordsCutoverService.Value.EnsureLegacyWriteAllowedAsync(log.DepartmentId, "WorkLogsService.SaveLogAsync", log.LoggedByUserId); + log.LoggedOn = DateTime.UtcNow; var savedLog = await _logsRepository.SaveOrUpdateAsync(log, cancellationToken, true); @@ -212,6 +219,8 @@ public async Task> GetAllLogsByDepartmentDateRangeAsync(int department if (log != null) { + await _recordsCutoverService.Value.EnsureLegacyWriteAllowedAsync(log.DepartmentId, "WorkLogsService.DeleteLogAsync"); + return await _logsRepository.DeleteAsync(log, cancellationToken); } @@ -226,6 +235,10 @@ public async Task> GetCallLogsForCallAsync(int callId) public async Task SaveLogAttachmentAsync(LogAttachment attachment, CancellationToken cancellationToken = default(CancellationToken)) { + var owningLog = await _logsRepository.GetByIdAsync(attachment.LogId); + if (owningLog != null) + await _recordsCutoverService.Value.EnsureLegacyWriteAllowedAsync(owningLog.DepartmentId, "WorkLogsService.SaveLogAttachmentAsync", attachment.UserId); + return await _logAttachmentRepository.SaveOrUpdateAsync(attachment, cancellationToken); } diff --git a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs index f7d11e4d2..28b5a0af7 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -31,6 +31,9 @@ private static void AddSampleDepartment(ScriptObject obj) d["use_24_hour_time"] = false; d["created_on"] = new DateTime(2020, 1, 1); d["phone_number"] = "555-867-5309"; + d["display_name"] = "Sample Fire Department"; + d["logo_url"] = "https://app.resgrid.com/User/Department/PublicMasthead?key=sample"; + d["website"] = "https://www.samplefire.example"; var addr = new ScriptObject(); addr["street"] = "100 Main Street"; @@ -502,6 +505,136 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve coverageGap["summary"] = "Station 1: 0/1 Engine"; obj["coverage_gap"] = coverageGap; break; + + case WorkflowTriggerEventType.RecordCreated: + case WorkflowTriggerEventType.RecordSubmittedForReview: + case WorkflowTriggerEventType.RecordReturnedForCorrection: + case WorkflowTriggerEventType.RecordFinalized: + case WorkflowTriggerEventType.RecordAmended: + case WorkflowTriggerEventType.RecordVoided: + case WorkflowTriggerEventType.RecordCancelled: + AddRecordsSamples(obj, eventType); + break; + } + } + + /// + /// Records (RMS) triggers 100-107: a bounded snapshot matching RecordEventVariables, RecordVariables and + /// RecordChangeVariables in WorkflowTemplateVariableCatalog. The state pair follows the trigger. + /// + private static void AddRecordsSamples(ScriptObject obj, WorkflowTriggerEventType eventType) + { + var previousState = "Draft"; + var currentState = "Draft"; + var reasonCode = ""; + var recordNumber = ""; + var revisionNumber = 0; + string revisionId = null; + string priorRevisionId = null; + DateTime? finalizedOn = null; + + switch (eventType) + { + case WorkflowTriggerEventType.RecordSubmittedForReview: + currentState = "ReadyForReview"; + break; + case WorkflowTriggerEventType.RecordReturnedForCorrection: + previousState = "ReadyForReview"; + currentState = "Returned"; + reasonCode = "incomplete"; + break; + case WorkflowTriggerEventType.RecordFinalized: + currentState = "Finalized"; + recordNumber = "TRN-2026-0042"; + revisionNumber = 1; + revisionId = "0f3c1d2e-5b6a-4c7d-8e9f-0a1b2c3d4e5f"; + finalizedOn = DateTime.Now; + break; + case WorkflowTriggerEventType.RecordAmended: + previousState = "Finalized"; + currentState = "Amended"; + recordNumber = "TRN-2026-0042"; + revisionNumber = 2; + priorRevisionId = "0f3c1d2e-5b6a-4c7d-8e9f-0a1b2c3d4e5f"; + revisionId = "1a2b3c4d-6e7f-4a8b-9c0d-1e2f3a4b5c6d"; + reasonCode = "correction"; + finalizedOn = DateTime.Now.AddDays(-1); + break; + case WorkflowTriggerEventType.RecordVoided: + previousState = "Finalized"; + currentState = "Voided"; + recordNumber = "TRN-2026-0042"; + revisionNumber = 2; + priorRevisionId = "0f3c1d2e-5b6a-4c7d-8e9f-0a1b2c3d4e5f"; + revisionId = "2b3c4d5e-7f8a-4b9c-0d1e-2f3a4b5c6d7e"; + reasonCode = "duplicate"; + finalizedOn = DateTime.Now.AddDays(-1); + break; + case WorkflowTriggerEventType.RecordCancelled: + currentState = "Cancelled"; + break; + } + + var e = new ScriptObject(); + e["id"] = "5f1c0f0e-9a2b-4c3d-8e4f-6a7b8c9d0e1f"; + e["name"] = eventType.ToString(); + e["schema_version"] = 1; + e["occurred_on"] = DateTime.Now; + e["correlation_id"] = "9d8c7b6a-5f4e-4d3c-b2a1-0f9e8d7c6b5a"; + e["causation_id"] = ""; + e["sequence"] = revisionNumber + 1; + e["is_replay"] = false; + e["origin_client"] = "Web"; + obj["event"] = e; + + var r = new ScriptObject(); + r["id"] = "9d8c7b6a-5f4e-4d3c-b2a1-0f9e8d7c6b5a"; + r["record_number"] = recordNumber; + r["draft_reference"] = "D-7Q2MX"; + r["definition_key"] = "system.training"; + r["definition_version"] = 1; + r["type_key"] = "Training"; + r["state"] = currentState; + r["lifecycle_preset"] = "QuickEntry"; + r["department_id"] = 1; + r["station_group_id"] = 12; + r["call_id"] = 1001; + r["external_id"] = ""; + r["author_user_id"] = "00000000-0000-0000-0000-000000000001"; + r["owner_user_id"] = "00000000-0000-0000-0000-000000000001"; + r["started_on"] = DateTime.Now.AddHours(-3); + r["ended_on"] = DateTime.Now.AddHours(-1); + r["created_on"] = DateTime.Now.AddHours(-1); + r["finalized_on"] = finalizedOn; + r["revision_id"] = revisionId ?? ""; + r["revision_number"] = revisionNumber; + r["checksum"] = revisionId == null ? "" : "3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b"; + r["summary"] = "Pump Operations — Hose evolutions"; + r["url"] = "https://resgrid.local/User/Records/Details/9d8c7b6a-5f4e-4d3c-b2a1-0f9e8d7c6b5a"; + obj["record"] = r; + + var c = new ScriptObject(); + c["previous_state"] = previousState; + c["current_state"] = currentState; + c["prior_revision_id"] = priorRevisionId ?? ""; + c["current_revision_id"] = revisionId ?? ""; + c["reason_code"] = reasonCode; + if (eventType == WorkflowTriggerEventType.RecordCancelled) + c["number_disposition"] = "none"; + obj["record_change"] = c; + + if (eventType == WorkflowTriggerEventType.RecordSubmittedForReview || eventType == WorkflowTriggerEventType.RecordReturnedForCorrection) + { + var returned = eventType == WorkflowTriggerEventType.RecordReturnedForCorrection; + var review = new ScriptObject(); + review["reviewer_user_id"] = returned ? "00000000-0000-0000-0000-000000000002" : ""; + review["submitted_for_review_on"] = DateTime.Now.AddHours(-2); + review["review_due_on"] = DateTime.Now.AddHours(46); + review["returned_on"] = returned ? DateTime.Now : (DateTime?)null; + review["return_count"] = returned ? 1 : 0; + review["reason_code"] = returned ? "incomplete" : ""; + review["reason_text"] = returned ? "Unit times are missing for Engine 1." : ""; + obj["review"] = review; } } } diff --git a/Core/Resgrid.Services/WorkflowService.cs b/Core/Resgrid.Services/WorkflowService.cs index b82fbe21c..764245384 100644 --- a/Core/Resgrid.Services/WorkflowService.cs +++ b/Core/Resgrid.Services/WorkflowService.cs @@ -652,13 +652,16 @@ public async Task GetWorkflowHealthAsync(string workflowI TotalRuns24h = runs24h.Count, SuccessfulRuns24h = runs24h.Count(r => r.Status == (int)WorkflowRunStatus.Completed), FailedRuns24h = runs24h.Count(r => r.Status == (int)WorkflowRunStatus.Failed), + SkippedRuns24h = runs24h.Count(r => r.Status == (int)WorkflowRunStatus.Skipped), RetryingRuns24h = runs24h.Count(r => r.Status == (int)WorkflowRunStatus.Retrying), TotalRuns7d = runs7d.Count, SuccessfulRuns7d = runs7d.Count(r => r.Status == (int)WorkflowRunStatus.Completed), FailedRuns7d = runs7d.Count(r => r.Status == (int)WorkflowRunStatus.Failed), + SkippedRuns7d = runs7d.Count(r => r.Status == (int)WorkflowRunStatus.Skipped), TotalRuns30d = runs30d.Count, SuccessfulRuns30d = runs30d.Count(r => r.Status == (int)WorkflowRunStatus.Completed), FailedRuns30d = runs30d.Count(r => r.Status == (int)WorkflowRunStatus.Failed), + SkippedRuns30d = runs30d.Count(r => r.Status == (int)WorkflowRunStatus.Skipped), AverageDurationMs30d = avgDurationMs, LastRunOn = lastRun?.StartedOn, LastRunStatus = lastRun != null ? (WorkflowRunStatus?)lastRun.Status : null, diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index 3a6ed4f04..3dd79a5c9 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -27,6 +27,7 @@ public class WorkflowTemplateContextBuilder : IWorkflowTemplateContextBuilder private readonly IPersonnelRolesService _personnelRolesService; private readonly IUnitsService _unitsService; private readonly IDepartmentMemberSensitiveDataService _memberSensitiveDataService; + private readonly IDepartmentProfileMediaService _departmentProfileMediaService; public WorkflowTemplateContextBuilder( IDepartmentsService departmentsService, @@ -35,7 +36,8 @@ public WorkflowTemplateContextBuilder( IDepartmentGroupsService departmentGroupsService, IPersonnelRolesService personnelRolesService, IUnitsService unitsService, - IDepartmentMemberSensitiveDataService memberSensitiveDataService) + IDepartmentMemberSensitiveDataService memberSensitiveDataService, + IDepartmentProfileMediaService departmentProfileMediaService) { _departmentsService = departmentsService; _departmentSettingsService = departmentSettingsService; @@ -44,6 +46,7 @@ public WorkflowTemplateContextBuilder( _personnelRolesService = personnelRolesService; _unitsService = unitsService; _memberSensitiveDataService = memberSensitiveDataService; + _departmentProfileMediaService = departmentProfileMediaService; } public async Task BuildContextAsync( @@ -56,8 +59,9 @@ public async Task BuildContextAsync( var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); var phoneNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(departmentId); + var branding = await _departmentProfileMediaService.GetEmailBrandingAsync(departmentId); - AddCommonDepartmentVariables(scriptObject, department, phoneNumber); + AddCommonDepartmentVariables(scriptObject, department, phoneNumber, branding); AddCommonTimestampVariables(scriptObject, department?.TimeZone); string triggeringUserId = null; @@ -396,6 +400,21 @@ public async Task BuildContextAsync( } break; } + case WorkflowTriggerEventType.RecordCreated: + case WorkflowTriggerEventType.RecordSubmittedForReview: + case WorkflowTriggerEventType.RecordReturnedForCorrection: + case WorkflowTriggerEventType.RecordFinalized: + case WorkflowTriggerEventType.RecordAmended: + case WorkflowTriggerEventType.RecordVoided: + case WorkflowTriggerEventType.RecordCancelled: + { + // Records (RMS): the payload is the outbox snapshot carried by RecordsWorkflowEvent; it is never + // rehydrated from current record state, so a retry sees exactly what the original run saw. + var evt = TryDeserialize(eventPayloadJson); + if (evt != null) + triggeringUserId = MapRecordsEventVariables(scriptObject, evt); + break; + } } await AddCommonUserVariablesAsync(scriptObject, departmentId, triggeringUserId); @@ -410,7 +429,7 @@ public IReadOnlyList GetVariableDescriptors(Workflow // ── Common Variable Mappers ─────────────────────────────────────────────────── - private static void AddCommonDepartmentVariables(ScriptObject obj, Department dept, string phoneNumber) + private static void AddCommonDepartmentVariables(ScriptObject obj, Department dept, string phoneNumber, DepartmentEmailBranding branding) { var d = new ScriptObject(); d["id"] = dept?.DepartmentId ?? 0; @@ -422,6 +441,13 @@ private static void AddCommonDepartmentVariables(ScriptObject obj, Department de d["created_on"] = dept?.CreatedOn; d["phone_number"] = phoneNumber ?? string.Empty; + // Department Profile identity (RMS plan section 4.10.1) for workflow email bodies. The logo URL is + // gated by the same opt-in as the system emails, so a workflow author cannot leak a masthead the + // department never enabled; the name and website are plain identity and always available. + d["display_name"] = !string.IsNullOrWhiteSpace(branding?.DisplayName) ? branding.DisplayName : (dept?.Name ?? string.Empty); + d["logo_url"] = branding != null && branding.Enabled ? (branding.LogoUrl ?? string.Empty) : string.Empty; + d["website"] = branding?.Website ?? string.Empty; + var addr = new ScriptObject(); if (dept?.Address != null) { @@ -1131,6 +1157,81 @@ private static void MapGroupVariables(ScriptObject obj, DepartmentGroup group, s obj[key] = g; } + // ── Records (RMS) ──────────────────────────────────────────────────────────────── + + /// Maps event.*, record.* and record_change.* from the dispatched snapshot; returns the author as the triggering user. + private static string MapRecordsEventVariables(ScriptObject obj, RecordsWorkflowEvent evt) + { + var e = new ScriptObject(); + e["id"] = evt.EventId ?? string.Empty; + e["name"] = evt.EventName ?? string.Empty; + e["schema_version"] = evt.SchemaVersion; + e["occurred_on"] = evt.OccurredOn; + e["correlation_id"] = evt.CorrelationId ?? string.Empty; + e["causation_id"] = evt.CausationId ?? string.Empty; + e["sequence"] = evt.Sequence; + e["is_replay"] = evt.IsReplay; + e["origin_client"] = evt.OriginClient ?? RmsOriginClient.System.ToString(); + obj["event"] = e; + + var payload = evt.Payload ?? new JObject(); + var recordToken = payload["record"] as JObject; + var record = ToScriptObject(recordToken); + var recordId = recordToken?["id"]?.Type == JTokenType.String ? (string)recordToken["id"] : null; + record["url"] = string.IsNullOrWhiteSpace(recordId) + ? string.Empty + : $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/Records/Details/{recordId}"; + obj["record"] = record; + + var change = ToScriptObject(payload["record_change"] as JObject); + if (payload["extra"] is JObject extra) + { + // Transition-specific facts (e.g. the cancelled record's number_disposition) surface on record_change. + foreach (var property in extra.Properties()) + change[property.Name] = ToScriptValue(property.Value); + } + obj["record_change"] = change; + + if (payload["review"] is JObject review) + obj["review"] = ToScriptObject(review); + + return recordToken?["author_user_id"]?.Type == JTokenType.String ? (string)recordToken["author_user_id"] : null; + } + + private static ScriptObject ToScriptObject(JObject source) + { + var result = new ScriptObject(); + if (source == null) + return result; + + foreach (var property in source.Properties()) + result[property.Name] = ToScriptValue(property.Value); + + return result; + } + + private static object ToScriptValue(JToken token) + { + if (token == null) + return null; + + switch (token.Type) + { + case JTokenType.Null: + case JTokenType.Undefined: + return null; + case JTokenType.Object: + return ToScriptObject((JObject)token); + case JTokenType.Array: + var array = new ScriptArray(); + foreach (var item in (JArray)token) + array.Add(ToScriptValue(item)); + return array; + default: + return ((JValue)token).Value; + } + } + private static T TryDeserialize(string json) where T : class { try { return string.IsNullOrWhiteSpace(json) ? null : JsonConvert.DeserializeObject(json); } diff --git a/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs b/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs index 707fd101c..9a3748f85 100644 --- a/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs +++ b/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Threading; +using System.Threading.Tasks; using Newtonsoft.Json; using Resgrid.Config; using Resgrid.Model; @@ -125,12 +126,75 @@ private void RegisterListeners() _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.CallAlarmEscalated, e)); _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.DispatchShortfallDetected, e)); _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.StationCoverageGapDetected, e)); + + // Records (RMS) lifecycle triggers 100-107 arrive through the DomainEventOutbox dispatcher (post-commit in + // the producing process, or worker command 40's sweep), never straight from the Records transaction + // (plan section 5.6). The legacy LogAdded compatibility projection rides the same road with + // TriggerEventType = LogAdded and a LogAddedEvent-shaped payload, so existing LogAdded workflows keep + // their exact log.* contract without a legacy Logs row ever being written. + _eventAggregator.AddListener(e => HandleDomainEvent(e)); } - private static async void HandleEvent(int departmentId, WorkflowTriggerEventType eventType, object eventObj) + private static void HandleDomainEvent(DomainEventDispatchedEvent dispatched) + { + if (dispatched == null || !dispatched.TriggerEventType.HasValue || + !Enum.IsDefined(typeof(WorkflowTriggerEventType), dispatched.TriggerEventType.Value)) + return; + + // Replays (activation, legacy indexing, operator re-drives) must never flood workflows. + if (dispatched.IsReplay) + return; + + var trigger = (WorkflowTriggerEventType)dispatched.TriggerEventType.Value; + var evt = RecordsWorkflowEvent.From(dispatched); + + if (trigger == WorkflowTriggerEventType.LogAdded) + { + LogAddedEvent compatibility; + try + { + compatibility = evt.Payload?.ToObject(); + } + catch (Exception ex) + { + Framework.Logging.LogException(ex, $"LogAdded compatibility payload for event {dispatched.EventId} could not be read."); + return; + } + + if (compatibility?.Log == null) + return; + + compatibility.DepartmentId = dispatched.DepartmentId; + HandleEvent(dispatched.DepartmentId, trigger, compatibility, dispatched); + return; + } + + HandleEvent(dispatched.DepartmentId, trigger, evt, dispatched); + } + + /// + /// Creates one Pending run per matching workflow and enqueues it. For Records events (an envelope is + /// present) the plan section 5.6 contract applies on top: the run carries the event envelope, one initial + /// run exists per (WorkflowId, EventId), and a rate- or daily-limited event is persisted as a Skipped run + /// with its reason rather than dropped. Legacy in-process events keep today's behaviour. + /// + private static async void HandleEvent(int departmentId, WorkflowTriggerEventType eventType, object eventObj, DomainEventDispatchedEvent envelope = null) { try { + System.Collections.Generic.List workflows = null; + string payloadJson = null; + + if (envelope != null) + { + // The skip rows below need the workflow list, so for Records events it is loaded before the limits. + workflows = (await _workflowRepository.GetAllActiveByDepartmentAndEventTypeAsync(departmentId, (int)eventType))?.ToList(); + if (workflows == null || workflows.Count == 0) + return; + + payloadJson = await _protectedProjectionService.BuildSafeWorkflowPayloadAsync(departmentId, eventObj); + } + // ── Plan-aware rate limiting ───────────────────────────────────────── var plan = await _subscriptionsService.GetCurrentPlanForDepartmentAsync(departmentId); var isFreePlan = plan?.IsFree ?? false; @@ -139,36 +203,49 @@ private static async void HandleEvent(int departmentId, WorkflowTriggerEventType { // Free plan: aggressive per-minute limit with NO event-type exemptions if (!IsWithinRateLimit(departmentId, WorkflowConfig.FreePlanRateLimitPerDepartmentPerMinute)) + { + await RecordSkippedAsync(workflows, departmentId, eventType, envelope, payloadJson, WorkflowRunSkipReasons.RateLimit); return; + } // Free plan: daily run cap if (!IsWithinDailyLimit(departmentId, WorkflowConfig.FreePlanDailyRunLimit)) + { + await RecordSkippedAsync(workflows, departmentId, eventType, envelope, payloadJson, WorkflowRunSkipReasons.DailyLimit); return; + } } else { // Paid plan: standard limit; call/update/close events are exempt if (!_rateLimitExemptEventTypes.Contains(eventType) && !IsWithinRateLimit(departmentId, WorkflowConfig.RateLimitPerDepartmentPerMinute)) + { + await RecordSkippedAsync(workflows, departmentId, eventType, envelope, payloadJson, WorkflowRunSkipReasons.RateLimit); return; + } } // ── End rate limiting ──────────────────────────────────────────────── - var workflows = await _workflowRepository.GetAllActiveByDepartmentAndEventTypeAsync( - departmentId, (int)eventType); + if (workflows == null) + workflows = (await _workflowRepository.GetAllActiveByDepartmentAndEventTypeAsync(departmentId, (int)eventType))?.ToList(); - if (workflows == null) return; + if (workflows == null || workflows.Count == 0) return; // ADP safe projection (plan section 8): for protected departments the payload is // redacted HERE, before it reaches WorkflowRun.InputPayload, the queue, retries, // dead letters, history, or designer previews. - var payloadJson = await _protectedProjectionService.BuildSafeWorkflowPayloadAsync(departmentId, eventObj); + if (payloadJson == null) + payloadJson = await _protectedProjectionService.BuildSafeWorkflowPayloadAsync(departmentId, eventObj); var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); var deptCode = department?.Code ?? string.Empty; foreach (var workflow in workflows) { - var run = new WorkflowRun + if (await IsDuplicateAsync(workflow.WorkflowId, envelope)) + continue; + + var run = WorkflowRunEnvelope.Apply(new WorkflowRun { WorkflowRunId = Guid.NewGuid().ToString(), WorkflowId = workflow.WorkflowId, @@ -179,8 +256,18 @@ private static async void HandleEvent(int departmentId, WorkflowTriggerEventType StartedOn = DateTime.UtcNow, QueuedOn = DateTime.UtcNow, AttemptNumber = 1 - }; - run = await _runRepository.InsertAsync(run, CancellationToken.None); + }, envelope); + + try + { + run = await _runRepository.InsertAsync(run, CancellationToken.None); + } + catch (Exception ex) when (envelope != null && WorkflowRunEnvelope.IsDuplicateKeyViolation(ex)) + { + // Two dispatchers (post-commit and the worker sweep) raced on the same event; the index is the backstop. + Framework.Logging.LogInfo($"Workflow {workflow.WorkflowId} already has a run for event {envelope.EventId}; duplicate dispatch ignored."); + continue; + } var queueItem = new WorkflowQueueItem { @@ -207,6 +294,61 @@ private static async void HandleEvent(int departmentId, WorkflowTriggerEventType } } + /// One initial run per (WorkflowId, EventId): a retry or a second dispatcher reuses the existing run. + private static async Task IsDuplicateAsync(string workflowId, DomainEventDispatchedEvent envelope) + { + if (envelope == null || string.IsNullOrWhiteSpace(envelope.EventId)) + return false; + + var existing = await _runRepository.GetByWorkflowAndEventAsync(workflowId, envelope.EventId); + if (existing == null) + return false; + + Framework.Logging.LogInfo($"Workflow {workflowId} already has run {existing.WorkflowRunId} for event {envelope.EventId}; duplicate dispatch ignored."); + return true; + } + + /// + /// A Records event that a plan or rate limit suppresses is persisted as a Skipped run per workflow, with the + /// reason, so it shows in run history and health instead of vanishing (plan section 5.6). Legacy events + /// (no envelope) are still dropped silently, as before. + /// + private static async Task RecordSkippedAsync(System.Collections.Generic.List workflows, int departmentId, WorkflowTriggerEventType eventType, + DomainEventDispatchedEvent envelope, string payloadJson, string reason) + { + if (envelope == null || workflows == null) + return; + + var now = DateTime.UtcNow; + foreach (var workflow in workflows) + { + if (await IsDuplicateAsync(workflow.WorkflowId, envelope)) + continue; + + var run = WorkflowRunEnvelope.MarkSkipped(WorkflowRunEnvelope.Apply(new WorkflowRun + { + WorkflowRunId = Guid.NewGuid().ToString(), + WorkflowId = workflow.WorkflowId, + DepartmentId = departmentId, + TriggerEventType = (int)eventType, + InputPayload = payloadJson, + StartedOn = now, + QueuedOn = now, + AttemptNumber = 1 + }, envelope), reason, now); + + try + { + await _runRepository.InsertAsync(run, CancellationToken.None); + Framework.Logging.LogError($"Records event {envelope.EventId} ({eventType}) was skipped for workflow {workflow.WorkflowId} in department {departmentId}: {reason}."); + } + catch (Exception ex) when (WorkflowRunEnvelope.IsDuplicateKeyViolation(ex)) + { + // Already recorded by the other dispatcher. + } + } + } + private static bool IsWithinRateLimit(int departmentId, int limit) { var now = DateTime.UtcNow; diff --git a/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs index 17ebb4478..30909dea4 100644 --- a/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs +++ b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs @@ -1656,5 +1656,114 @@ public static void AddWeatherAlertClaims(ClaimsIdentity identity, bool isAdmin) identity.AddClaim(new Claim(ResgridClaimTypes.Resources.WeatherAlert, ResgridClaimTypes.Actions.Delete)); } } + + #region Records (RMS) - PermissionTypes 50-67, Identifier Allocation Registry section 4 + + /// + /// Derives every Records claim from the department's Permission rows. A missing row evaluates as + /// the descriptor's documented no-row default (RecordPermissionCatalog), which equals today's Logs + /// behavior for the parity types, so activation changes nobody's effective access by accident. + /// Record_View is issued to every department member exactly as Log:View is today; ViewGroupRecords + /// (61) issues no claim because cross-group visibility is evaluated per Record at the service layer. + /// + public static void AddRecordClaims(ClaimsIdentity identity, bool isAdmin, List permissions, bool isGroupAdmin, List roles) + { + AddClaimOnce(identity, ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.View); + + foreach (var descriptor in RecordPermissionCatalog.All) + { + var grants = RecordClaimGrants(descriptor.Type); + if (grants.Length == 0) + continue; + + var permission = permissions?.FirstOrDefault(x => x.PermissionType == (int)descriptor.Type); + var action = permission != null ? permission.Action : (int)descriptor.NoRowDefault; + var roleIdCsv = permission != null ? permission.Data : null; + + if (!IsPermissionActionSatisfied(action, roleIdCsv, isAdmin, isGroupAdmin, roles)) + continue; + + foreach (var grant in grants) + AddClaimOnce(identity, grant.Resource, grant.Action); + } + } + + /// + /// The PermissionActions ladder as one function, including value 4 + /// (DepartmentAndGroupAdminsAndSelectRoles). Department administrators pass every value, an + /// unrecognised future value denies (fail closed), and a malformed role CSV is treated as empty. + /// + public static bool IsPermissionActionSatisfied(int action, string roleIdCsv, bool isAdmin, bool isGroupAdmin, List roles) + { + return RecordPermissionEvaluation.IsSatisfied(action, roleIdCsv, isAdmin, isGroupAdmin, roles); + } + + public readonly struct RecordClaimGrant + { + public RecordClaimGrant(string resource, string action) + { + Resource = resource; + Action = action; + } + + public string Resource { get; } + public string Action { get; } + } + + private static readonly RecordClaimGrant[] NoGrants = new RecordClaimGrant[0]; + + /// Claim(s) a satisfied PermissionTypes row issues. Registry section 4.1, "Grants" column. + public static RecordClaimGrant[] RecordClaimGrants(PermissionTypes type) + { + switch (type) + { + case PermissionTypes.CreateRecord: + return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.Create) }; + case PermissionTypes.DeleteRecord: + return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.Void) }; + case PermissionTypes.ReviewRecords: + return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.Review) }; + case PermissionTypes.ApproveRecords: + return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.Approve) }; + case PermissionTypes.FinalizeRecords: + return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.Finalize) }; + case PermissionTypes.AmendRecords: + return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.Amend) }; + case PermissionTypes.SubmitRecords: + return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.Submit) }; + case PermissionTypes.ExportRecords: + return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.Export) }; + case PermissionTypes.ShareRecordsExternally: + return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.Share) }; + case PermissionTypes.ViewRestrictedRecords: + return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.RecordRestricted, ResgridClaimTypes.Actions.View) }; + case PermissionTypes.ViewLegacyRecords: + return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.RecordLegacy, ResgridClaimTypes.Actions.View) }; + case PermissionTypes.ViewGroupRecords: + return NoGrants; + case PermissionTypes.ManageRecordDefinitions: + return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.RecordDefinition, ResgridClaimTypes.Actions.Update) }; + case PermissionTypes.PublishRecordDefinitions: + return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.RecordDefinition, ResgridClaimTypes.Actions.Publish) }; + case PermissionTypes.ManageRecordReports: + return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.RecordReport, ResgridClaimTypes.Actions.Update) }; + case PermissionTypes.ManageRecordDisclosures: + return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.RecordDisclosure, ResgridClaimTypes.Actions.Update) }; + case PermissionTypes.ManageRecordLegalHold: + return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.RecordLegalHold, ResgridClaimTypes.Actions.Update) }; + case PermissionTypes.ReassignRecordDrafts: + return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.Reassign) }; + default: + return NoGrants; + } + } + + private static void AddClaimOnce(ClaimsIdentity identity, string type, string value) + { + if (!identity.HasClaim(type, value)) + identity.AddClaim(new Claim(type, value)); + } + + #endregion Records (RMS) } } diff --git a/Providers/Resgrid.Providers.Claims/ClaimsPrincipalFactory.cs b/Providers/Resgrid.Providers.Claims/ClaimsPrincipalFactory.cs index d2909a8b9..e28e954e6 100644 --- a/Providers/Resgrid.Providers.Claims/ClaimsPrincipalFactory.cs +++ b/Providers/Resgrid.Providers.Claims/ClaimsPrincipalFactory.cs @@ -179,6 +179,7 @@ public override async Task CreateAsync(TUser user) ClaimsLogic.AddActionClaims(id); ClaimsLogic.AddLogClaims(id, departmentAdmin, permissions, isGroupAdmin, roles); ClaimsLogic.AddDeleteLogClaims(id, departmentAdmin, permissions, isGroupAdmin, roles); + ClaimsLogic.AddRecordClaims(id, departmentAdmin, permissions, isGroupAdmin, roles); ClaimsLogic.AddStaffingClaims(id); ClaimsLogic.AddPersonnelClaims(id, departmentAdmin, permissions, isGroupAdmin,roles); ClaimsLogic.AddUnitClaims(id, departmentAdmin); diff --git a/Providers/Resgrid.Providers.Claims/JwtTokenProvider.cs b/Providers/Resgrid.Providers.Claims/JwtTokenProvider.cs index 7c3fde492..79a700656 100644 --- a/Providers/Resgrid.Providers.Claims/JwtTokenProvider.cs +++ b/Providers/Resgrid.Providers.Claims/JwtTokenProvider.cs @@ -99,6 +99,7 @@ public async Task BuildTokenAsync(string userId, int departmentId) ClaimsLogic.AddActionClaims(id); ClaimsLogic.AddLogClaims(id, departmentAdmin, permissions, isGroupAdmin, roles); ClaimsLogic.AddDeleteLogClaims(id, departmentAdmin, permissions, isGroupAdmin, roles); + ClaimsLogic.AddRecordClaims(id, departmentAdmin, permissions, isGroupAdmin, roles); ClaimsLogic.AddStaffingClaims(id); ClaimsLogic.AddPersonnelClaims(id, departmentAdmin, permissions, isGroupAdmin, roles); ClaimsLogic.AddUnitClaims(id, departmentAdmin); diff --git a/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs b/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs index 392d41a44..b7e3affaa 100644 --- a/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs +++ b/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs @@ -9,6 +9,19 @@ public static class Actions public const string Create = "Create"; public const string Update = "Update"; public const string Delete = "Delete"; + + // Records (RMS) lifecycle actions (Identifier Allocation Registry section 4.2). Each is paired + // with a ResgridResources policy in BOTH Startup.cs files and both ClaimsAuthorizationHelper copies. + public const string Review = "Review"; + public const string Approve = "Approve"; + public const string Finalize = "Finalize"; + public const string Submit = "Submit"; + public const string Amend = "Amend"; + public const string Void = "Void"; + public const string Export = "Export"; + public const string Publish = "Publish"; + public const string Share = "Share"; + public const string Reassign = "Reassign"; } public static class Memberships @@ -73,6 +86,15 @@ public static class Resources public const string Route = "Route"; public const string CommunicationTest = "CommunicationTest"; public const string WeatherAlert = "WeatherAlert"; + + // Records (RMS) resources (Identifier Allocation Registry section 4.2). + public const string Record = "Record"; + public const string RecordLegacy = "RecordLegacy"; + public const string RecordRestricted = "RecordRestricted"; + public const string RecordDefinition = "RecordDefinition"; + public const string RecordReport = "RecordReport"; + public const string RecordDisclosure = "RecordDisclosure"; + public const string RecordLegalHold = "RecordLegalHold"; } public static string CreateDepartmentClaimTypeString(int departmentId) diff --git a/Providers/Resgrid.Providers.Claims/ResgridIdentity.cs b/Providers/Resgrid.Providers.Claims/ResgridIdentity.cs index b7a588d62..7ba612dc5 100644 --- a/Providers/Resgrid.Providers.Claims/ResgridIdentity.cs +++ b/Providers/Resgrid.Providers.Claims/ResgridIdentity.cs @@ -1092,5 +1092,11 @@ public void AddWeatherAlertClaims(bool isAdmin) { ClaimsLogic.AddWeatherAlertClaims(this, isAdmin); } + + /// Records (RMS) claims; delegates to so the two never drift. + public void AddRecordClaims(bool isAdmin, List permissions, bool isGroupAdmin, List roles) + { + ClaimsLogic.AddRecordClaims(this, isAdmin, permissions, isGroupAdmin, roles); + } } } diff --git a/Providers/Resgrid.Providers.Claims/ResgridResources.cs b/Providers/Resgrid.Providers.Claims/ResgridResources.cs index b9baaec3a..8d500ff9d 100644 --- a/Providers/Resgrid.Providers.Claims/ResgridResources.cs +++ b/Providers/Resgrid.Providers.Claims/ResgridResources.cs @@ -178,5 +178,26 @@ public static class ResgridResources public const string WeatherAlert_Update = "WeatherAlert_Update"; public const string WeatherAlert_Create = "WeatherAlert_Create"; public const string WeatherAlert_Delete = "WeatherAlert_Delete"; + + // Records (RMS): 18 policies from PermissionTypes 50-67 (registry section 4.2). Registered in + // both Startup.cs files; ClaimsAuthorizationHelperParityTests asserts the helper copies match. + public const string Record_View = "Record_View"; + public const string Record_Create = "Record_Create"; + public const string Record_Review = "Record_Review"; + public const string Record_Approve = "Record_Approve"; + public const string Record_Finalize = "Record_Finalize"; + public const string Record_Submit = "Record_Submit"; + public const string Record_Amend = "Record_Amend"; + public const string Record_Void = "Record_Void"; + public const string Record_Export = "Record_Export"; + public const string Record_Share = "Record_Share"; + public const string Record_Reassign = "Record_Reassign"; + public const string RecordLegacy_View = "RecordLegacy_View"; + public const string RecordRestricted_View = "RecordRestricted_View"; + public const string RecordDefinition_Update = "RecordDefinition_Update"; + public const string RecordDefinition_Publish = "RecordDefinition_Publish"; + public const string RecordReport_Update = "RecordReport_Update"; + public const string RecordDisclosure_Update = "RecordDisclosure_Update"; + public const string RecordLegalHold_Update = "RecordLegalHold_Update"; } } diff --git a/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs b/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs index 36e080c94..a73466d48 100644 --- a/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs +++ b/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs @@ -81,7 +81,7 @@ public async Task SendDeleteDepartmentEmail(string requesterName, string d public async Task SendCallMail(string email, string subject, string title, string priority, string natureOfCall, string mapPage, string address, - string dispatchedOn, int callId, string userId, string coordinates, string shortenedAudioUrl) + string dispatchedOn, int callId, string userId, string coordinates, string shortenedAudioUrl, DepartmentEmailBranding branding) { string callQuery = String.Empty; @@ -117,6 +117,8 @@ public async Task SendCallMail(string email, string subject, string title, } }; + AddDepartmentBranding(templateModel, branding); + try { @@ -281,7 +283,7 @@ public async Task SendInviteMail(string code, string departmentName, strin } public async Task SendMessageMail(string email, string subject, string messageSubject, string messageBody, string senderEmail, string senderName, string sentOn, - int messageId) + int messageId, DepartmentEmailBranding branding) { var templateModel = new Dictionary { @@ -292,6 +294,8 @@ public async Task SendMessageMail(string email, string subject, string mes { "timestamp", sentOn }, }; + AddDepartmentBranding(templateModel, branding); + try { var template = Mustachio.Parser.Parse(GetTempate("Message.html")); @@ -596,7 +600,7 @@ public async Task SendNewDepartmentLinkMail(string name, string department } public async Task SendReportDeliveryMail(string email, string subject, string messageBody, string sentOn, - string reportName, string attachmentFilename, byte[] attachmentData, string reportUrl) + string reportName, string attachmentFilename, byte[] attachmentData, string reportUrl, DepartmentEmailBranding branding) { if (attachmentData == null) return false; @@ -625,6 +629,8 @@ public async Task SendReportDeliveryMail(string email, string subject, str { "timestamp", sentOn } }; + AddDepartmentBranding(templateModel, branding); + try { var template = Mustachio.Parser.Parse(GetTempate("ReportDelivery.html")); @@ -703,6 +709,28 @@ public async Task SendCommunicationTestMail(string email, CommunicationTes return false; } + /// + /// Department masthead variables (RMS plan section 4.10.1). The two sections are mutually exclusive, so a + /// department without the opt-in (or without a logo) renders today's Resgrid chrome byte-for-byte and the + /// department block only appears when both conditions hold. Mustache resolves names inside a section + /// against the section value, so the department variables ride inside the department_branding object. + /// The footer's Resgrid, LLC identity is never part of the swap. + /// + private static void AddDepartmentBranding(Dictionary templateModel, DepartmentEmailBranding branding) + { + var enabled = branding != null && branding.Enabled && !String.IsNullOrWhiteSpace(branding.LogoUrl); + + templateModel["resgrid_branding"] = !enabled; + templateModel["department_branding"] = enabled + ? new Dictionary + { + { "department_logo_url", branding.LogoUrl }, + { "department_display_name", branding.DisplayName ?? String.Empty }, + { "department_website", branding.Website ?? SystemBehaviorConfig.ResgridBaseUrl } + } + : (object)false; + } + private string GetTempate(string templateName) { var assembly = typeof(PostmarkTemplateProvider).Assembly; diff --git a/Providers/Resgrid.Providers.Email/Template/Call.html b/Providers/Resgrid.Providers.Email/Template/Call.html index df290405b..707cdd922 100644 --- a/Providers/Resgrid.Providers.Email/Template/Call.html +++ b/Providers/Resgrid.Providers.Email/Template/Call.html @@ -390,13 +390,18 @@ - + {{#department_branding}} + + + {{/department_branding}}{{#resgrid_branding}} - + {{/resgrid_branding}}